diff --git a/src/resources/extensions/gsd/detection.ts b/src/resources/extensions/gsd/detection.ts index 98cfe8689..ad363f5b6 100644 --- a/src/resources/extensions/gsd/detection.ts +++ b/src/resources/extensions/gsd/detection.ts @@ -226,6 +226,8 @@ const TEST_MARKERS = [ const RECURSIVE_SCAN_IGNORED_DIRS = new Set([ ".git", "node_modules", + ".venv", + "venv", "dist", "build", "coverage", @@ -234,8 +236,27 @@ const RECURSIVE_SCAN_IGNORED_DIRS = new Set([ "target", "vendor", ".turbo", + "Pods", + "bin", + "obj", + ".gradle", + "DerivedData", + "out", ]) as ReadonlySet; +/** Project file markers safe to detect recursively via suffix matching. */ +const ROOT_ONLY_PROJECT_FILES = new Set([ + ".github/workflows", + "package.json", + "Gemfile", + "Makefile", + "CMakeLists.txt", + "build.gradle", + "build.gradle.kts", + "deno.json", + "deno.jsonc", +]); + const MAX_RECURSIVE_SCAN_FILES = 2000; const MAX_RECURSIVE_SCAN_DEPTH = 6; @@ -366,6 +387,16 @@ export function detectProjectSignals(basePath: string): ProjectSignals { // without walking the entire repo or diving into heavyweight folders. const scannedFiles = scanProjectFiles(basePath); + for (const file of PROJECT_FILES) { + if (detectedFiles.includes(file) || ROOT_ONLY_PROJECT_FILES.has(file)) continue; + if (scannedFiles.some((scannedFile) => matchesProjectFileMarker(scannedFile, file))) { + pushUnique(detectedFiles, file); + if (!primaryLanguage && LANGUAGE_MAP[file]) { + primaryLanguage = LANGUAGE_MAP[file]; + } + } + } + if (scannedFiles.some((file) => SQLITE_EXTENSIONS.some((ext) => file.endsWith(ext)))) { pushUnique(detectedFiles, "*.sqlite"); } @@ -402,7 +433,7 @@ export function detectProjectSignals(basePath: string): ProjectSignals { if (dependencyFiles.length > 0) { try { const depContent: string[] = []; - for (const relativePath of dependencyFiles.slice(0, 10)) { + for (const relativePath of dependencyFiles) { depContent.push(readBounded(join(basePath, relativePath), 64 * 1024)); } const combined = depContent.join("\n").toLowerCase(); @@ -730,6 +761,14 @@ function pushUnique(arr: string[], value: string): void { if (!arr.includes(value)) arr.push(value); } +function matchesProjectFileMarker(scannedFile: string, marker: string): boolean { + return ( + scannedFile === marker || + scannedFile.endsWith(`/${marker}`) || + scannedFile.endsWith(`\\${marker}`) + ); +} + 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/tests/detection.test.ts b/src/resources/extensions/gsd/tests/detection.test.ts index 9b5cd382c..3b989ed8a 100644 --- a/src/resources/extensions/gsd/tests/detection.test.ts +++ b/src/resources/extensions/gsd/tests/detection.test.ts @@ -528,6 +528,18 @@ test("detectProjectSignals: Next.js project via next.config.ts", () => { } }); +test("detectProjectSignals: nested Next.js config via packages/web/next.config.ts", () => { + const dir = makeTempDir("signals-nextjs-nested"); + try { + mkdirSync(join(dir, "packages", "web"), { recursive: true }); + writeFileSync(join(dir, "packages", "web", "next.config.ts"), "export default {}", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("next.config.ts"), "should detect nested Next.js config"); + } finally { + cleanup(dir); + } +}); + test("detectProjectSignals: Flutter project via pubspec.yaml", () => { const dir = makeTempDir("signals-flutter"); try { @@ -552,6 +564,19 @@ test("detectProjectSignals: Django project via manage.py", () => { } }); +test("detectProjectSignals: nested Django manage.py", () => { + const dir = makeTempDir("signals-django-nested"); + try { + mkdirSync(join(dir, "services", "api"), { recursive: true }); + writeFileSync(join(dir, "services", "api", "manage.py"), "#!/usr/bin/env python", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("manage.py"), "should detect nested manage.py"); + assert.equal(signals.primaryLanguage, "python"); + } finally { + cleanup(dir); + } +}); + test("detectProjectSignals: Docker project via Dockerfile", () => { const dir = makeTempDir("signals-docker"); try { @@ -635,6 +660,21 @@ test("detectProjectSignals: Android project via app/build.gradle", () => { const signals = detectProjectSignals(dir); assert.ok(signals.detectedFiles.includes("app/build.gradle")); assert.equal(signals.primaryLanguage, "java/kotlin"); + assert.ok(!signals.detectedFiles.includes("build.gradle"), "should not collapse Android app/build.gradle into generic build.gradle"); + } finally { + cleanup(dir); + } +}); + +test("detectProjectSignals: nested app/build.gradle normalizes to Android marker", () => { + const dir = makeTempDir("signals-android-nested"); + try { + mkdirSync(join(dir, "apps", "mobile", "app"), { recursive: true }); + writeFileSync(join(dir, "apps", "mobile", "app", "build.gradle"), "apply plugin: 'com.android.application'", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("app/build.gradle"), "should detect nested Android app/build.gradle"); + assert.ok(!signals.detectedFiles.includes("build.gradle"), "should not emit generic build.gradle marker for nested Android modules"); + assert.equal(signals.primaryLanguage, "java/kotlin"); } finally { cleanup(dir); } @@ -772,6 +812,20 @@ test("detectProjectSignals: FastAPI detected via nested service requirements.txt 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"); + assert.ok(signals.detectedFiles.includes("requirements.txt"), "should normalize nested requirements.txt marker"); + assert.equal(signals.primaryLanguage, "python"); + } finally { + cleanup(dir); + } +}); + +test("detectProjectSignals: nested Prisma schema normalizes to prisma/schema.prisma", () => { + const dir = makeTempDir("signals-prisma-nested"); + try { + mkdirSync(join(dir, "services", "api", "prisma"), { recursive: true }); + writeFileSync(join(dir, "services", "api", "prisma", "schema.prisma"), "datasource db { provider = \"sqlite\" }", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("prisma/schema.prisma"), "should detect nested Prisma schema"); } finally { cleanup(dir); }