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:
parent
d7d5d0e3ad
commit
bc63161593
4 changed files with 199 additions and 35 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue