From d8443b89e546089b34f2cc5f0cc2070fa781b831 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sun, 22 Mar 2026 08:41:42 -0400 Subject: [PATCH] fix(skills): address QA round 18 QA18-1: Replace token-level FastAPI matching with package-name-aware parsing for requirements.txt and pyproject dependency sections so extras like my-sdk[fastapi] and unrelated fastapi tokens do not emit dep:fastapi. QA18-2: Scope direct Spring Boot detection to actual plugin/dependency declarations instead of arbitrary spring-boot text, and fix Kotlin DSL plugin syntax matching (id("org.springframework.boot")). Add regression tests for: - dependency extras mentioning fastapi - build metadata mentioning spring-boot - Kotlin DSL Spring Boot plugin detection --- src/resources/extensions/gsd/detection.ts | 70 ++++++++++++++++--- .../extensions/gsd/tests/detection.test.ts | 22 ++++++ 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/src/resources/extensions/gsd/detection.ts b/src/resources/extensions/gsd/detection.ts index 00fd1ceb5..1b59e0fc4 100644 --- a/src/resources/extensions/gsd/detection.ts +++ b/src/resources/extensions/gsd/detection.ts @@ -430,7 +430,7 @@ export function detectProjectSignals(basePath: string): ProjectSignals { const dependencyFiles = scannedFiles.filter((file) => file.endsWith("requirements.txt") || file.endsWith("pyproject.toml"), ); - if (containsDependencyMarker(basePath, dependencyFiles, "fastapi")) { + if (containsFastapiDependency(basePath, dependencyFiles)) { pushUnique(detectedFiles, "dep:fastapi"); } @@ -769,13 +769,20 @@ function matchesProjectFileMarker(scannedFile: string, marker: string): boolean ); } -function containsDependencyMarker(basePath: string, relativePaths: string[], marker: "fastapi"): boolean { +function containsFastapiDependency(basePath: string, relativePaths: string[]): boolean { for (const relativePath of relativePaths) { try { const raw = readBounded(join(basePath, relativePath), 64 * 1024); - const content = extractDependencyContent(relativePath, raw).toLowerCase(); - if (marker === "fastapi" && /\bfastapi(?=$|[\s<=>!~@\[\],;"'])/.test(content)) { - return true; + const content = extractDependencyContent(relativePath, raw); + if (relativePath.endsWith("requirements.txt")) { + for (const line of content.split("\n")) { + if (extractRequirementName(line) === "fastapi") return true; + } + continue; + } + + if (relativePath.endsWith("pyproject.toml")) { + if (containsFastapiInPyproject(content)) return true; } } catch { // unreadable file — continue scanning other candidate files @@ -796,19 +803,20 @@ function containsSpringBootMarker( for (const relativePath of buildFiles) { try { const raw = readBounded(join(basePath, relativePath), 64 * 1024); - const content = stripDependencyComments(relativePath, raw).toLowerCase(); - if (/(org\.springframework\.boot|spring-boot(?:-starter)?)/.test(content)) { + const content = stripDependencyComments(relativePath, raw); + if (containsDirectSpringBootReference(relativePath, content)) { return true; } + const normalized = content.toLowerCase(); const aliasRe = /alias\(\s*libs\.plugins\.([a-z0-9_.-]+)\s*\)/gi; let match: RegExpExecArray | null; - while ((match = aliasRe.exec(content)) !== null) { + while ((match = aliasRe.exec(normalized)) !== null) { usedPluginAliases.add(normalizePluginAlias(match[1])); } const libraryAliasRe = /\blibs\.((?!plugins\b)[a-z0-9_.-]+)/gi; - while ((match = libraryAliasRe.exec(content)) !== null) { + while ((match = libraryAliasRe.exec(normalized)) !== null) { usedLibraryAliases.add(normalizePluginAlias(match[1])); } } catch { @@ -896,6 +904,46 @@ function extractDependencyContent(relativePath: string, content: string): string return stripped; } +function extractRequirementName(spec: string): string | null { + const trimmed = spec.trim().replace(/^["']|["']$/g, ""); + if (!trimmed) return null; + + const match = trimmed.match(/^([A-Za-z0-9_.-]+)(?:\[[^\]]+\])?(?=\s*(?:@|[<>=!~;]|$))/); + if (!match) return null; + return normalizePackageName(match[1]); +} + +function containsFastapiInPyproject(content: string): boolean { + for (const line of content.split("\n")) { + const keyMatch = line.match(/^\s*([A-Za-z0-9_.-]+)\s*=/); + if (keyMatch && normalizePackageName(keyMatch[1]) === "fastapi") { + return true; + } + } + + const quotedSpecRe = /["']([^"']+)["']/g; + let match: RegExpExecArray | null; + while ((match = quotedSpecRe.exec(content)) !== null) { + if (extractRequirementName(match[1]) === "fastapi") { + return true; + } + } + + return false; +} + +function containsDirectSpringBootReference(relativePath: string, content: string): boolean { + if (relativePath.endsWith("pom.xml")) { + return /\s*org\.springframework\.boot\s*<\/groupId>|\s*spring-boot[^<]*<\/artifactId>/i.test(content); + } + + if (relativePath.endsWith("build.gradle") || relativePath.endsWith("build.gradle.kts")) { + return /(id\s*\(?\s*["']org\.springframework\.boot["']|(?:implementation|api|compileOnly|runtimeOnly|testImplementation|annotationProcessor|kapt)\s*\(?\s*["'][^"']*org\.springframework\.boot:[^"']*spring-boot[^"']*["'])/i.test(content); + } + + return false; +} + function extractPyprojectDependencySections(content: string): string { const lines = content.split("\n"); const collected: string[] = []; @@ -964,6 +1012,10 @@ function countChar(text: string, char: string): number { return [...text].filter((c) => c === char).length; } +function normalizePackageName(name: string): string { + return name.toLowerCase().replace(/[_.]/g, "-"); +} + function normalizePluginAlias(alias: string): string { return alias.toLowerCase().replace(/[-_]/g, "."); } diff --git a/src/resources/extensions/gsd/tests/detection.test.ts b/src/resources/extensions/gsd/tests/detection.test.ts index 7435bf514..ad12603f9 100644 --- a/src/resources/extensions/gsd/tests/detection.test.ts +++ b/src/resources/extensions/gsd/tests/detection.test.ts @@ -882,6 +882,17 @@ test("detectProjectSignals: fastapi-* packages do not trigger dep:fastapi withou } }); +test("detectProjectSignals: dependency extras mentioning fastapi do not trigger dep:fastapi", () => { + const dir = makeTempDir("signals-fastapi-extra-only"); + try { + writeFileSync(join(dir, "requirements.txt"), "my-sdk[fastapi]>=1.0\n", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(!signals.detectedFiles.includes("dep:fastapi"), "dependency extras should not imply FastAPI framework usage"); + } finally { + cleanup(dir); + } +}); + test("detectProjectSignals: Django project does NOT get dep:fastapi marker", () => { const dir = makeTempDir("signals-django-no-fastapi"); try { @@ -991,6 +1002,17 @@ test("detectProjectSignals: Android inline comments do not emit dep:spring-boot" } }); +test("detectProjectSignals: build metadata mentioning spring-boot does not emit dep:spring-boot", () => { + const dir = makeTempDir("signals-spring-metadata-only"); + try { + writeFileSync(join(dir, "build.gradle"), 'def notes = "spring-boot migration planned later"', "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(!signals.detectedFiles.includes("dep:spring-boot"), "arbitrary metadata text should not trigger Spring Boot detection"); + } finally { + cleanup(dir); + } +}); + test("detectProjectSignals: Spring Boot version-catalog alias emits dep:spring-boot", () => { const dir = makeTempDir("signals-spring-version-catalog"); try {