fix(skills): address QA round 15

QA15-1: Extend exact FastAPI dependency detection to support common
PEP 508 specifiers like ~= and !=.

QA15-2: Scope pyproject.toml FastAPI detection to actual dependency
sections ([project] dependencies array, [project.optional-dependencies],
[tool.poetry.dependencies], and [tool.poetry.group.*.dependencies]) so
metadata-only mentions do not emit dep:fastapi.

QA15-3: Remove spring.boot alias-name matching from direct Spring Boot
detection. Direct matches now require org.springframework.boot or
spring-boot(-starter) strings; alias-based detection only succeeds via
alias usage + version-catalog id correlation.

QA15-4: When a nested Gradle Spring Boot service emits dep:spring-boot
and no language was otherwise detected, set primaryLanguage to
java/kotlin so onboarding no longer reports 'unknown project'.

Add regression tests for:
- FastAPI ~= operator
- pyproject metadata-only fastapi mention
- spring-like alias names without Spring Boot id
- nested Spring Boot language hint
This commit is contained in:
Derek Pearson 2026-03-22 08:25:07 -04:00
parent 803913b13d
commit da6f246891
2 changed files with 106 additions and 3 deletions

View file

@ -440,6 +440,9 @@ export function detectProjectSignals(basePath: string): ProjectSignals {
const springBootVersionCatalogs = scannedFiles.filter((file) => file.endsWith("libs.versions.toml"));
if (containsSpringBootMarker(basePath, springBootBuildFiles, springBootVersionCatalogs)) {
pushUnique(detectedFiles, "dep:spring-boot");
if (!primaryLanguage) {
primaryLanguage = springBootBuildFiles.some((file) => file.endsWith("build.gradle.kts")) ? "kotlin" : "java/kotlin";
}
}
// Git repo detection
@ -770,8 +773,8 @@ function containsDependencyMarker(basePath: string, relativePaths: string[], mar
for (const relativePath of relativePaths) {
try {
const raw = readBounded(join(basePath, relativePath), 64 * 1024);
const content = stripDependencyComments(relativePath, raw).toLowerCase();
if (marker === "fastapi" && /\bfastapi(?=$|[\s<=>\[\],;"'])/.test(content)) {
const content = extractDependencyContent(relativePath, raw).toLowerCase();
if (marker === "fastapi" && /\bfastapi(?=$|[\s<=>!~\[\],;"'])/.test(content)) {
return true;
}
} catch {
@ -793,7 +796,7 @@ function containsSpringBootMarker(
try {
const raw = readBounded(join(basePath, relativePath), 64 * 1024);
const content = stripDependencyComments(relativePath, raw).toLowerCase();
if (/(org\.springframework\.boot|spring[-.]boot(?:[-.]starter)?)/.test(content)) {
if (/(org\.springframework\.boot|spring-boot(?:-starter)?)/.test(content)) {
return true;
}
@ -854,6 +857,62 @@ function stripDependencyComments(relativePath: string, content: string): string
return content;
}
function extractDependencyContent(relativePath: string, content: string): string {
const stripped = stripDependencyComments(relativePath, content);
if (relativePath.endsWith("pyproject.toml")) {
return extractPyprojectDependencySections(stripped);
}
return stripped;
}
function extractPyprojectDependencySections(content: string): string {
const lines = content.split("\n");
const collected: string[] = [];
let section = "";
let collectingProjectDeps = false;
let bracketDepth = 0;
for (const line of lines) {
const trimmed = line.trim();
if (collectingProjectDeps) {
collected.push(line);
bracketDepth += countChar(line, "[") - countChar(line, "]");
if (bracketDepth <= 0) {
collectingProjectDeps = false;
}
continue;
}
const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
section = sectionMatch[1].trim();
continue;
}
if (section === "project" && /^dependencies\s*=\s*\[/.test(trimmed)) {
collected.push(line);
bracketDepth = countChar(line, "[") - countChar(line, "]");
collectingProjectDeps = bracketDepth > 0;
continue;
}
if (
section === "project.optional-dependencies" ||
section === "tool.poetry.dependencies" ||
/^tool\.poetry\.group\.[^.]+\.dependencies$/.test(section)
) {
collected.push(line);
}
}
return collected.join("\n");
}
function countChar(text: string, char: string): number {
return [...text].filter((c) => c === char).length;
}
function normalizePluginAlias(alias: string): string {
return alias.toLowerCase().replace(/[-_]/g, ".");
}

View file

@ -782,6 +782,32 @@ test("detectProjectSignals: FastAPI detected via pyproject.toml dependency", ()
}
});
test("detectProjectSignals: FastAPI detected with PEP 508 ~= operator", () => {
const dir = makeTempDir("signals-fastapi-compatible-release");
try {
writeFileSync(join(dir, "requirements.txt"), "fastapi~=0.115\n", "utf-8");
const signals = detectProjectSignals(dir);
assert.ok(signals.detectedFiles.includes("dep:fastapi"), "~= should count as a FastAPI dependency");
} finally {
cleanup(dir);
}
});
test("detectProjectSignals: pyproject metadata mention does not trigger dep:fastapi", () => {
const dir = makeTempDir("signals-fastapi-pyproject-metadata");
try {
writeFileSync(
join(dir, "pyproject.toml"),
'[project]\nname = "example"\nkeywords = ["fastapi"]\ndependencies = ["flask>=3.0"]\n',
"utf-8",
);
const signals = detectProjectSignals(dir);
assert.ok(!signals.detectedFiles.includes("dep:fastapi"), "metadata-only mentions should not trigger FastAPI detection");
} finally {
cleanup(dir);
}
});
test("detectProjectSignals: FastAPI comments do not trigger dep:fastapi", () => {
const dir = makeTempDir("signals-fastapi-comment");
try {
@ -875,6 +901,7 @@ test("detectProjectSignals: nested Spring Boot Gradle service emits dep:spring-b
);
const signals = detectProjectSignals(dir);
assert.ok(signals.detectedFiles.includes("dep:spring-boot"), "should detect nested Spring Boot Gradle service");
assert.equal(signals.primaryLanguage, "java/kotlin");
} finally {
cleanup(dir);
}
@ -956,3 +983,20 @@ test("detectProjectSignals: unused Spring Boot alias in libs.versions.toml does
cleanup(dir);
}
});
test("detectProjectSignals: spring-like alias name without Spring Boot id does not emit dep:spring-boot", () => {
const dir = makeTempDir("signals-spring-version-catalog-false-alias");
try {
mkdirSync(join(dir, "gradle"), { recursive: true });
writeFileSync(join(dir, "build.gradle.kts"), "plugins { alias(libs.plugins.spring.boot.conventions) }", "utf-8");
writeFileSync(
join(dir, "gradle", "libs.versions.toml"),
"[plugins]\nspring-boot-conventions = { id = 'com.example.conventions', version = '1.0.0' }\n",
"utf-8",
);
const signals = detectProjectSignals(dir);
assert.ok(!signals.detectedFiles.includes("dep:spring-boot"), "spring-looking alias names should not imply Spring Boot without matching id");
} finally {
cleanup(dir);
}
});