From bc631615938aab030b98a4cc379393355e8a38a8 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sun, 22 Mar 2026 08:01:31 -0400 Subject: [PATCH] fix(skills): address QA round 10 QA10-1: Replace root-only synthetic marker detection with a bounded recursive project scan (max 2000 files, depth 6, ignoring heavy dirs) so nested .csproj/.fsproj/.sln, SQL, SQLite, Vue, and FastAPI service layouts are detected correctly. QA10-2: Split .NET synthetic markers by actual file type: - *.csproj => primaryLanguage csharp - *.fsproj => primaryLanguage fsharp - *.sln => primaryLanguage dotnet Update .NET Backend Patterns to match generic .NET markers (*.csproj, *.fsproj, *.sln) while keeping .NET & C# limited to C#. QA10-3: Add invariant tests validating: - every SKILL_CATALOG matchFiles entry is backed by detection - every GREENFIELD_STACKS pack label resolves to SKILL_CATALOG Also add regression tests for nested SQL, nested .csproj, F#, nested .vue, and nested FastAPI service requirements detection. --- src/resources/extensions/gsd/detection.ts | 126 +++++++++++++----- src/resources/extensions/gsd/skill-catalog.ts | 2 +- .../extensions/gsd/tests/detection.test.ts | 65 ++++++++- .../gsd/tests/skill-catalog.test.ts | 41 +++++- 4 files changed, 199 insertions(+), 35 deletions(-) diff --git a/src/resources/extensions/gsd/detection.ts b/src/resources/extensions/gsd/detection.ts index 1141fefac..98cfe8689 100644 --- a/src/resources/extensions/gsd/detection.ts +++ b/src/resources/extensions/gsd/detection.ts @@ -222,6 +222,23 @@ const TEST_MARKERS = [ "phpunit.xml", ] as const; +/** Directories skipped during bounded recursive project scans. */ +const RECURSIVE_SCAN_IGNORED_DIRS = new Set([ + ".git", + "node_modules", + "dist", + "build", + "coverage", + ".next", + ".nuxt", + "target", + "vendor", + ".turbo", +]) as ReadonlySet; + +const MAX_RECURSIVE_SCAN_FILES = 2000; +const MAX_RECURSIVE_SCAN_DEPTH = 6; + // ─── Core Detection ───────────────────────────────────────────────────────────── /** @@ -343,46 +360,54 @@ export function detectProjectSignals(basePath: string): ProjectSignals { } } - // SQLite / SQL / .NET file detection — scan root entries for file extensions. - // Adds synthetic markers (e.g. "*.sqlite", "*.sql", "*.csproj") to detectedFiles - // so skill catalog matchFiles can reference them. - try { - const rootEntries = readdirSync(basePath); - if (rootEntries.some((e) => SQLITE_EXTENSIONS.some((ext) => e.endsWith(ext)))) { - detectedFiles.push("*.sqlite"); - } - if (rootEntries.some((e) => SQL_EXTENSIONS.some((ext) => e.endsWith(ext)))) { - detectedFiles.push("*.sql"); - } - if (rootEntries.some((e) => DOTNET_EXTENSIONS.some((ext) => e.endsWith(ext)))) { - detectedFiles.push("*.csproj"); - if (!primaryLanguage) primaryLanguage = "csharp"; - } - // Vue.js: scan src/ dir for .vue files (Vite-based Vue projects have no vue.config.*) - try { - const srcEntries = readdirSync(join(basePath, "src")); - if (srcEntries.some((e) => VUE_EXTENSIONS.some((ext) => e.endsWith(ext)))) { - detectedFiles.push("*.vue"); - } - } catch { - // no src/ directory — skip Vue scan - } - } catch { - // unreadable root — skip extension scan + // Bounded recursive scan for nested markers and dependency files. + // This covers common brownfield layouts like src/App/App.csproj, + // db/migrations/*.sql, src/components/*.vue, and services/api/pyproject.toml + // without walking the entire repo or diving into heavyweight folders. + const scannedFiles = scanProjectFiles(basePath); + + if (scannedFiles.some((file) => SQLITE_EXTENSIONS.some((ext) => file.endsWith(ext)))) { + pushUnique(detectedFiles, "*.sqlite"); + } + if (scannedFiles.some((file) => SQL_EXTENSIONS.some((ext) => file.endsWith(ext)))) { + pushUnique(detectedFiles, "*.sql"); + } + + const hasCsproj = scannedFiles.some((file) => file.endsWith(".csproj")); + const hasFsproj = scannedFiles.some((file) => file.endsWith(".fsproj")); + const hasSln = scannedFiles.some((file) => file.endsWith(".sln")); + + if (hasCsproj) { + pushUnique(detectedFiles, "*.csproj"); + if (!primaryLanguage) primaryLanguage = "csharp"; + } + if (hasFsproj) { + pushUnique(detectedFiles, "*.fsproj"); + if (!primaryLanguage) primaryLanguage = "fsharp"; + } + if (hasSln) { + pushUnique(detectedFiles, "*.sln"); + if (!primaryLanguage) primaryLanguage = "dotnet"; + } + + if (scannedFiles.some((file) => VUE_EXTENSIONS.some((ext) => file.endsWith(ext)))) { + pushUnique(detectedFiles, "*.vue"); } // Python framework detection — scan dependency files for framework-specific packages. // Adds synthetic markers (e.g. "dep:fastapi") so skill catalog matchFiles can reference them. - if (detectedFiles.includes("requirements.txt") || detectedFiles.includes("pyproject.toml")) { + const dependencyFiles = scannedFiles.filter((file) => + file.endsWith("requirements.txt") || file.endsWith("pyproject.toml"), + ); + if (dependencyFiles.length > 0) { try { const depContent: string[] = []; - const reqPath = join(basePath, "requirements.txt"); - if (existsSync(reqPath)) depContent.push(readBounded(reqPath, 64 * 1024)); - const pyprojectPath = join(basePath, "pyproject.toml"); - if (existsSync(pyprojectPath)) depContent.push(readBounded(pyprojectPath, 64 * 1024)); + for (const relativePath of dependencyFiles.slice(0, 10)) { + depContent.push(readBounded(join(basePath, relativePath), 64 * 1024)); + } const combined = depContent.join("\n").toLowerCase(); if (/\bfastapi\b/.test(combined)) { - detectedFiles.push("dep:fastapi"); + pushUnique(detectedFiles, "dep:fastapi"); } } catch { // unreadable dependency files — skip framework scan @@ -700,3 +725,40 @@ function readMakefileTargets(basePath: string): string[] { return []; } } + +function pushUnique(arr: string[], value: string): void { + if (!arr.includes(value)) arr.push(value); +} + +function scanProjectFiles(basePath: string): string[] { + const files: string[] = []; + const queue: Array<{ path: string; depth: number }> = [{ path: basePath, depth: 0 }]; + + while (queue.length > 0 && files.length < MAX_RECURSIVE_SCAN_FILES) { + const current = queue.shift()!; + let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>; + try { + entries = readdirSync(current.path, { withFileTypes: true, encoding: "utf8" }); + } catch { + continue; + } + + for (const entry of entries) { + const entryPath = join(current.path, entry.name); + const relativePath = entryPath.slice(basePath.length + 1); + + if (entry.isDirectory()) { + if (current.depth < MAX_RECURSIVE_SCAN_DEPTH && !RECURSIVE_SCAN_IGNORED_DIRS.has(entry.name)) { + queue.push({ path: entryPath, depth: current.depth + 1 }); + } + continue; + } + + if (!entry.isFile()) continue; + files.push(relativePath); + if (files.length >= MAX_RECURSIVE_SCAN_FILES) break; + } + } + + return files; +} diff --git a/src/resources/extensions/gsd/skill-catalog.ts b/src/resources/extensions/gsd/skill-catalog.ts index 3771e217d..c6d39cc8f 100644 --- a/src/resources/extensions/gsd/skill-catalog.ts +++ b/src/resources/extensions/gsd/skill-catalog.ts @@ -322,7 +322,7 @@ export const SKILL_CATALOG: SkillPack[] = [ description: ".NET backend architecture, middleware, and production patterns", repo: "wshobson/agents", skills: ["dotnet-backend-patterns"], - matchFiles: ["*.csproj"], + matchFiles: ["*.csproj", "*.fsproj", "*.sln"], }, // ── Flutter / Dart ──────────────────────────────────────────────────────── { diff --git a/src/resources/extensions/gsd/tests/detection.test.ts b/src/resources/extensions/gsd/tests/detection.test.ts index c8b89ac9b..9b5cd382c 100644 --- a/src/resources/extensions/gsd/tests/detection.test.ts +++ b/src/resources/extensions/gsd/tests/detection.test.ts @@ -419,6 +419,18 @@ test("detectProjectSignals: SQL file detection", () => { } }); +test("detectProjectSignals: nested SQL file detection", () => { + const dir = makeTempDir("signals-sql-nested"); + try { + mkdirSync(join(dir, "db", "migrations"), { recursive: true }); + writeFileSync(join(dir, "db", "migrations", "001_init.sql"), "", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("*.sql"), "should detect nested SQL files"); + } finally { + cleanup(dir); + } +}); + test("detectProjectSignals: .db file triggers SQLite detection", () => { const dir = makeTempDir("signals-db"); try { @@ -454,12 +466,38 @@ test("detectProjectSignals: .NET project via .csproj extension", () => { } }); +test("detectProjectSignals: nested .csproj detection", () => { + const dir = makeTempDir("signals-dotnet-nested"); + try { + mkdirSync(join(dir, "src", "App"), { recursive: true }); + writeFileSync(join(dir, "src", "App", "App.csproj"), "", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("*.csproj"), "should detect nested .csproj files"); + assert.equal(signals.primaryLanguage, "csharp"); + } finally { + cleanup(dir); + } +}); + test("detectProjectSignals: .NET project via .sln extension", () => { const dir = makeTempDir("signals-sln"); try { writeFileSync(join(dir, "MyApp.sln"), "", "utf-8"); const signals = detectProjectSignals(dir); - assert.ok(signals.detectedFiles.includes("*.csproj"), "should add synthetic *.csproj marker for .sln files"); + assert.ok(signals.detectedFiles.includes("*.sln"), "should add synthetic *.sln marker for .sln files"); + assert.equal(signals.primaryLanguage, "dotnet"); + } finally { + cleanup(dir); + } +}); + +test("detectProjectSignals: F# project via .fsproj extension", () => { + const dir = makeTempDir("signals-fsharp"); + try { + writeFileSync(join(dir, "MyApp.fsproj"), "", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("*.fsproj"), "should add synthetic *.fsproj marker"); + assert.equal(signals.primaryLanguage, "fsharp"); } finally { cleanup(dir); } @@ -551,6 +589,19 @@ test("detectProjectSignals: Vue.js via .vue files in src/", () => { } }); +test("detectProjectSignals: Vue.js via nested .vue file in src/components/", () => { + const dir = makeTempDir("signals-vue-nested"); + try { + writeFileSync(join(dir, "package.json"), '{"name":"vue-app"}', "utf-8"); + mkdirSync(join(dir, "src", "components"), { recursive: true }); + writeFileSync(join(dir, "src", "components", "Card.vue"), "", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("*.vue"), "should detect nested .vue files"); + } finally { + cleanup(dir); + } +}); + test("detectProjectSignals: Vue CLI via vue.config.js", () => { const dir = makeTempDir("signals-vue-cli"); try { @@ -713,3 +764,15 @@ test("detectProjectSignals: FastAPI detected case-insensitively (PyPI canonical cleanup(dir); } }); + +test("detectProjectSignals: FastAPI detected via nested service requirements.txt", () => { + const dir = makeTempDir("signals-fastapi-nested"); + try { + mkdirSync(join(dir, "services", "api"), { recursive: true }); + writeFileSync(join(dir, "services", "api", "requirements.txt"), "fastapi==0.115.0\n", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("dep:fastapi"), "should detect FastAPI in nested service requirements.txt"); + } 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 8c6d194ef..a93926850 100644 --- a/src/resources/extensions/gsd/tests/skill-catalog.test.ts +++ b/src/resources/extensions/gsd/tests/skill-catalog.test.ts @@ -7,7 +7,8 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { matchPacksForProject } from "../skill-catalog.ts"; +import { PROJECT_FILES } from "../detection.ts"; +import { GREENFIELD_STACKS, SKILL_CATALOG, matchPacksForProject } from "../skill-catalog.ts"; import type { ProjectSignals } from "../detection.ts"; function makeSignals(overrides: Partial = {}): ProjectSignals { @@ -143,3 +144,41 @@ test("matchPacksForProject: Godot does not include Unity", () => { assert.ok(labels.includes("Godot"), "should include Godot"); assert.ok(!labels.includes("Unity"), "should NOT include Unity"); }); + +test("matchPacksForProject: .NET backend patterns match F# and solution markers", () => { + const fsprojLabels = packLabels(makeSignals({ detectedFiles: ["*.fsproj"], primaryLanguage: "fsharp" })); + assert.ok(fsprojLabels.includes(".NET Backend Patterns"), "should include generic .NET backend patterns for F# projects"); + assert.ok(!fsprojLabels.includes(".NET & C#"), "should not include C#-specific pack for F# projects"); + + const slnLabels = packLabels(makeSignals({ detectedFiles: ["*.sln"], primaryLanguage: "dotnet" })); + assert.ok(slnLabels.includes(".NET Backend Patterns"), "should include generic .NET backend patterns for solution files"); +}); + +test("SKILL_CATALOG: every matchFiles entry is backed by detection", () => { + const knownMarkers = new Set([ + ...PROJECT_FILES, + "*.sqlite", + "*.sql", + "*.csproj", + "*.fsproj", + "*.sln", + "*.vue", + "dep:fastapi", + ]); + + for (const pack of SKILL_CATALOG) { + for (const marker of pack.matchFiles ?? []) { + assert.ok(knownMarkers.has(marker), `Unknown detection marker: ${marker} (pack: ${pack.label})`); + } + } +}); + +test("GREENFIELD_STACKS: every pack label resolves to SKILL_CATALOG", () => { + const labels = new Set(SKILL_CATALOG.map((pack) => pack.label)); + + for (const stack of GREENFIELD_STACKS) { + for (const packLabel of stack.packs) { + assert.ok(labels.has(packLabel), `Unknown pack label: ${packLabel} (stack: ${stack.id})`); + } + } +});