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.
This commit is contained in:
Derek Pearson 2026-03-22 08:01:31 -04:00
parent d7d5d0e3ad
commit bc63161593
4 changed files with 199 additions and 35 deletions

View file

@ -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<string>;
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;
}

View file

@ -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 ────────────────────────────────────────────────────────
{

View file

@ -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"), "<Project></Project>", "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"), "<Project></Project>", "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"), "<template></template>", "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);
}
});

View file

@ -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> = {}): 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<string>([
...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})`);
}
}
});