From c297fe2e34b91ddb15943efa2ed0096068382003 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sun, 22 Mar 2026 08:13:09 -0400 Subject: [PATCH] fix(skills): address QA round 12 QA12-1: Replace generic Gradle/POM matching for Java & Spring Boot with a real framework marker () detected by scanning pom.xml and Gradle files for Spring Boot plugins/dependencies. This restores nested Gradle service detection without reintroducing Android false positives. QA12-2: Prevent standard Android projects (root build.gradle + app/build.gradle) from matching the Spring Boot pack. Spring Boot now requires the synthetic dependency marker, not generic build files. QA12-3: Harden FastAPI detection: - strip comments before matching - scan each dependency file independently - continue on per-file read errors instead of failing the whole scan Also add regression tests for comment-only FastAPI mentions, nested Spring Boot Gradle services, Android non-Spring Gradle projects, and Spring Boot pack matching via dep:spring-boot. --- src/resources/extensions/gsd/detection.ts | 59 +++++++++++++++---- src/resources/extensions/gsd/skill-catalog.ts | 2 +- .../extensions/gsd/tests/detection.test.ts | 40 +++++++++++++ .../gsd/tests/skill-catalog.test.ts | 9 +++ 4 files changed, 96 insertions(+), 14 deletions(-) diff --git a/src/resources/extensions/gsd/detection.ts b/src/resources/extensions/gsd/detection.ts index ad363f5b6..5905ac1b6 100644 --- a/src/resources/extensions/gsd/detection.ts +++ b/src/resources/extensions/gsd/detection.ts @@ -430,19 +430,15 @@ export function detectProjectSignals(basePath: string): ProjectSignals { const dependencyFiles = scannedFiles.filter((file) => file.endsWith("requirements.txt") || file.endsWith("pyproject.toml"), ); - if (dependencyFiles.length > 0) { - try { - const depContent: string[] = []; - for (const relativePath of dependencyFiles) { - depContent.push(readBounded(join(basePath, relativePath), 64 * 1024)); - } - const combined = depContent.join("\n").toLowerCase(); - if (/\bfastapi\b/.test(combined)) { - pushUnique(detectedFiles, "dep:fastapi"); - } - } catch { - // unreadable dependency files — skip framework scan - } + if (containsDependencyMarker(basePath, dependencyFiles, "fastapi")) { + pushUnique(detectedFiles, "dep:fastapi"); + } + + const springBootFiles = scannedFiles.filter((file) => + file.endsWith("pom.xml") || file.endsWith("build.gradle") || file.endsWith("build.gradle.kts"), + ); + if (containsDependencyMarker(basePath, springBootFiles, "spring-boot")) { + pushUnique(detectedFiles, "dep:spring-boot"); } // Git repo detection @@ -769,6 +765,43 @@ function matchesProjectFileMarker(scannedFile: string, marker: string): boolean ); } +function containsDependencyMarker(basePath: string, relativePaths: string[], marker: "fastapi" | "spring-boot"): boolean { + for (const relativePath of relativePaths) { + try { + const raw = readBounded(join(basePath, relativePath), 64 * 1024); + const content = stripDependencyComments(relativePath, raw).toLowerCase(); + if (marker === "fastapi" && /\bfastapi(?:[-_][a-z0-9]+)?\b/.test(content)) { + return true; + } + if (marker === "spring-boot" && /(org\.springframework\.boot|spring-boot(?:-starter)?)/.test(content)) { + return true; + } + } catch { + // unreadable file — continue scanning other candidate files + } + } + + return false; +} + +function stripDependencyComments(relativePath: string, content: string): string { + if (relativePath.endsWith("requirements.txt")) { + return content.replace(/^\s*#.*$/gm, ""); + } + if (relativePath.endsWith("pyproject.toml")) { + return content.replace(/^\s*#.*$/gm, ""); + } + if (relativePath.endsWith("pom.xml")) { + return content.replace(//g, ""); + } + if (relativePath.endsWith("build.gradle") || relativePath.endsWith("build.gradle.kts")) { + return content + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); + } + return content; +} + function scanProjectFiles(basePath: string): string[] { const files: string[] = []; const queue: Array<{ path: string; depth: number }> = [{ path: basePath, depth: 0 }]; diff --git a/src/resources/extensions/gsd/skill-catalog.ts b/src/resources/extensions/gsd/skill-catalog.ts index c6d39cc8f..8f1c5d760 100644 --- a/src/resources/extensions/gsd/skill-catalog.ts +++ b/src/resources/extensions/gsd/skill-catalog.ts @@ -306,7 +306,7 @@ export const SKILL_CATALOG: SkillPack[] = [ description: "Spring Boot best practices, DI, RESTful APIs, JPA, testing, and security", repo: "github/awesome-copilot", skills: ["java-springboot"], - matchFiles: ["pom.xml", "build.gradle", "build.gradle.kts"], + matchFiles: ["dep:spring-boot"], }, // ── .NET / C# ──────────────────────────────────────────────────────────── { diff --git a/src/resources/extensions/gsd/tests/detection.test.ts b/src/resources/extensions/gsd/tests/detection.test.ts index 3b989ed8a..70488aa94 100644 --- a/src/resources/extensions/gsd/tests/detection.test.ts +++ b/src/resources/extensions/gsd/tests/detection.test.ts @@ -782,6 +782,17 @@ test("detectProjectSignals: FastAPI detected via pyproject.toml dependency", () } }); +test("detectProjectSignals: FastAPI comments do not trigger dep:fastapi", () => { + const dir = makeTempDir("signals-fastapi-comment"); + try { + writeFileSync(join(dir, "requirements.txt"), "# maybe evaluate fastapi later\nflask==3.0\n", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(!signals.detectedFiles.includes("dep:fastapi"), "comments should not trigger FastAPI detection"); + } finally { + cleanup(dir); + } +}); + test("detectProjectSignals: Django project does NOT get dep:fastapi marker", () => { const dir = makeTempDir("signals-django-no-fastapi"); try { @@ -830,3 +841,32 @@ test("detectProjectSignals: nested Prisma schema normalizes to prisma/schema.pri cleanup(dir); } }); + +test("detectProjectSignals: nested Spring Boot Gradle service emits dep:spring-boot", () => { + const dir = makeTempDir("signals-spring-gradle-nested"); + try { + mkdirSync(join(dir, "services", "api"), { recursive: true }); + writeFileSync( + join(dir, "services", "api", "build.gradle"), + "plugins { id 'org.springframework.boot' version '3.2.0' }", + "utf-8", + ); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("dep:spring-boot"), "should detect nested Spring Boot Gradle service"); + } finally { + cleanup(dir); + } +}); + +test("detectProjectSignals: Android Gradle project does not emit dep:spring-boot", () => { + const dir = makeTempDir("signals-android-no-spring"); + try { + writeFileSync(join(dir, "build.gradle"), "plugins { id 'com.android.application' }", "utf-8"); + mkdirSync(join(dir, "app"), { recursive: true }); + writeFileSync(join(dir, "app", "build.gradle"), "plugins { id 'com.android.application' }", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(!signals.detectedFiles.includes("dep:spring-boot"), "Android Gradle files should not trigger Spring Boot detection"); + } finally { + cleanup(dir); + } +}); diff --git a/src/resources/extensions/gsd/tests/skill-catalog.test.ts b/src/resources/extensions/gsd/tests/skill-catalog.test.ts index a93926850..4f7e3375e 100644 --- a/src/resources/extensions/gsd/tests/skill-catalog.test.ts +++ b/src/resources/extensions/gsd/tests/skill-catalog.test.ts @@ -133,6 +133,14 @@ test("matchPacksForProject: Spring Boot does not match via language alone", () = assert.ok(!labels.includes("Java & Spring Boot"), "Spring Boot should NOT match via language alone"); }); +test("matchPacksForProject: Spring Boot matches only dep:spring-boot", () => { + const positive = packLabels(makeSignals({ detectedFiles: ["dep:spring-boot"] })); + assert.ok(positive.includes("Java & Spring Boot"), "should include Spring Boot pack when dependency marker exists"); + + const androidLike = packLabels(makeSignals({ detectedFiles: ["build.gradle", "app/build.gradle"], primaryLanguage: "java/kotlin" })); + assert.ok(!androidLike.includes("Java & Spring Boot"), "generic Gradle + Android markers should not imply Spring Boot"); +}); + test("matchPacksForProject: Unity does not include Godot", () => { const labels = packLabels(makeSignals({ detectedFiles: ["ProjectSettings/ProjectVersion.txt"] })); assert.ok(labels.includes("Unity"), "should include Unity"); @@ -164,6 +172,7 @@ test("SKILL_CATALOG: every matchFiles entry is backed by detection", () => { "*.sln", "*.vue", "dep:fastapi", + "dep:spring-boot", ]); for (const pack of SKILL_CATALOG) {