diff --git a/packages/autoskills/lib.ts b/packages/autoskills/lib.ts index 3279778a..06e8075b 100644 --- a/packages/autoskills/lib.ts +++ b/packages/autoskills/lib.ts @@ -163,6 +163,57 @@ export function hasWebFrontendFiles(projectDir: string, maxDepth: number = 3): b return scan(projectDir, 0); } +// ── Auto-Discover Subdirectories ─────────────────────────────── + +const MANIFEST_FILES = [ + "package.json", + "deno.json", + "deno.jsonc", + "pom.xml", + "build.gradle.kts", + "build.gradle", + "pubspec.yaml", + "Cargo.toml", + "go.mod", + "composer.json", + "Gemfile", + "pyproject.toml", + "requirements.txt", + "setup.py", + "Pipfile", +]; + +function discoverSubDirs(projectDir: string, maxDepth: number = 3): string[] { + const dirs: string[] = []; + + function walk(dir: string, depth: number): void { + let entries: import("node:fs").Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith(".") || SCAN_SKIP_DIRS.has(entry.name)) continue; + + const subDir = join(dir, entry.name); + + if (MANIFEST_FILES.some((f) => existsSync(join(subDir, f)))) { + dirs.push(subDir); + } + + if (depth < maxDepth) { + walk(subDir, depth + 1); + } + } + } + + walk(projectDir, 0); + return dirs; +} + // ── Workspace Resolution ────────────────────────────────────── function parsePnpmWorkspaceYaml(content: string): string[] { @@ -457,8 +508,12 @@ export function detectTechnologies(projectDir: string): DetectResult { const seenIds = new Map(root.detected.map((t) => [t.id, t])); let isFrontend = root.isFrontendByPackages || root.isFrontendByFiles; - const workspaceDirs = resolveWorkspaces(projectDir, { pkg, denoJson }); - for (const wsDir of workspaceDirs) { + let scanDirs = resolveWorkspaces(projectDir, { pkg, denoJson }); + if (scanDirs.length === 0) { + scanDirs = discoverSubDirs(projectDir); + } + + for (const wsDir of scanDirs) { const ws = detectTechnologiesInDir(wsDir, { skipFrontendFiles: isFrontend }); for (const tech of ws.detected) { diff --git a/packages/autoskills/tests/detect.test.ts b/packages/autoskills/tests/detect.test.ts index d9cb3251..b0498a56 100644 --- a/packages/autoskills/tests/detect.test.ts +++ b/packages/autoskills/tests/detect.test.ts @@ -1397,6 +1397,128 @@ describe("detectTechnologies (monorepo)", () => { }); }); +// ── detectTechnologies (auto-discover, no workspace config) ─── + +describe("detectTechnologies (auto-discover subdirectories)", () => { + const tmp = useTmpDir(); + + it("discovers subdirectories with package.json without workspace config", () => { + writePackageJson(tmp.path, { name: "root" }); + addWorkspace(tmp.path, "frontend", { dependencies: { react: "^19", vite: "^6" } }); + const { detected } = detectTechnologies(tmp.path); + ok(detected.some((t) => t.id === "react"), "react should be detected from subdirectory"); + ok(detected.some((t) => t.id === "vite"), "vite should be detected from subdirectory"); + }); + + it("discovers subdirectories with pom.xml (Maven) without workspace config", () => { + writePackageJson(tmp.path, { name: "root" }); + writeFile( + tmp.path, + "backend/pom.xml", + "com.example", + ); + const { detected } = detectTechnologies(tmp.path); + ok(detected.some((t) => t.id === "java"), "java should be detected from subdirectory pom.xml"); + }); + + it("detects technologies from nested subdirectories (root/sub/backend + root/sub/frontend)", () => { + writePackageJson(tmp.path, { name: "root", devDependencies: { playwright: "^1.40" } }); + addWorkspace(tmp.path, "sub/frontend", { + dependencies: { react: "^19", "react-dom": "^19", vite: "^6" }, + }); + writeFile( + tmp.path, + "sub/backend/pom.xml", + `org.springframework.bootspring-boot-starter-web`, + ); + const { detected } = detectTechnologies(tmp.path); + const ids = detected.map((t) => t.id); + ok(ids.includes("react"), "react from sub/frontend"); + ok(ids.includes("vite"), "vite from sub/frontend"); + ok(ids.includes("java"), "java from sub/backend"); + ok(ids.includes("springboot"), "springboot from sub/backend"); + ok(ids.includes("playwright"), "playwright from root"); + }); + + it("does not auto-discover when workspace config exists", () => { + writePackageJson(tmp.path, { name: "root", workspaces: ["packages/*"] }); + addWorkspace(tmp.path, "packages/app", { dependencies: { express: "^4" } }); + addWorkspace(tmp.path, "standalone", { dependencies: { react: "^19" } }); + const { detected } = detectTechnologies(tmp.path); + const ids = detected.map((t) => t.id); + ok(ids.includes("express"), "workspace member should be detected"); + ok(!ids.includes("react"), "non-workspace dir should not be discovered via fallback"); + }); + + it("skips SCAN_SKIP_DIRS during auto-discover", () => { + writePackageJson(tmp.path, { name: "root" }); + addWorkspace(tmp.path, "node_modules/fake-pkg", { dependencies: { react: "^19" } }); + const { detected } = detectTechnologies(tmp.path); + ok(!detected.some((t) => t.id === "react"), "node_modules should be skipped"); + }); + + it("discovers Go module from subdirectory", () => { + writePackageJson(tmp.path, { name: "root" }); + writeFile(tmp.path, "api/go.mod", "module example.com/api\n\ngo 1.24.0\n"); + const { detected } = detectTechnologies(tmp.path); + ok(detected.some((t) => t.id === "go"), "go should be detected from subdirectory"); + }); + + it("discovers Python from subdirectory requirements.txt", () => { + writePackageJson(tmp.path, { name: "root" }); + writeFile(tmp.path, "ml-service/requirements.txt", "fastapi==0.100.0\npydantic==2.0.0"); + const { detected } = detectTechnologies(tmp.path); + ok(detected.some((t) => t.id === "fastapi"), "fastapi from subdirectory"); + ok(detected.some((t) => t.id === "pydantic"), "pydantic from subdirectory"); + }); + + it("deduplicates technologies across auto-discovered directories", () => { + writePackageJson(tmp.path, { name: "root" }); + addWorkspace(tmp.path, "web", { dependencies: { react: "^19" } }); + addWorkspace(tmp.path, "mobile", { dependencies: { react: "^19" } }); + const { detected } = detectTechnologies(tmp.path); + const reactCount = detected.filter((t) => t.id === "react").length; + strictEqual(reactCount, 1, "react should appear only once"); + }); + + it("detects frontend from auto-discovered subdirectory", () => { + writePackageJson(tmp.path, { name: "root" }); + addWorkspace(tmp.path, "web", { dependencies: { react: "^19" } }); + const { isFrontend } = detectTechnologies(tmp.path); + strictEqual(isFrontend, true, "should detect frontend from subdirectory"); + }); + + it("detects combos across auto-discovered subdirectories", () => { + writePackageJson(tmp.path, { name: "root" }); + addWorkspace(tmp.path, "web", { dependencies: { next: "^15" } }); + addWorkspace(tmp.path, "db", { dependencies: { "@supabase/supabase-js": "^2" } }); + const { combos } = detectTechnologies(tmp.path); + ok( + combos.some((c) => c.id === "nextjs-supabase"), + "cross-directory combo should be detected", + ); + }); + + it("detects root + backend (Maven) + frontend (Vite React) without workspace config", () => { + writePackageJson(tmp.path, { name: "root", devDependencies: { "@playwright/test": "^1.40" } }); + addWorkspace(tmp.path, "frontend", { + dependencies: { react: "^19", "react-dom": "^19", vite: "^6" }, + }); + writeFile( + tmp.path, + "backend/pom.xml", + "com.example", + ); + const { detected, isFrontend } = detectTechnologies(tmp.path); + const ids = detected.map((t) => t.id); + ok(ids.includes("react"), "react from frontend/"); + ok(ids.includes("vite"), "vite from frontend/"); + ok(ids.includes("java"), "java from backend/pom.xml"); + ok(ids.includes("playwright"), "playwright from root package.json"); + strictEqual(isFrontend, true, "frontend detected from frontend/ subdirectory"); + }); +}); + // ── detectCombos ────────────────────────────────────────────── describe("detectCombos", () => {