Files
claude-engineering-plugin/plugins/compound-engineering/skills/ce-onboarding/scripts/inventory.mjs
Trevin Chow 5c0ec9137a
Some checks failed
CI / pr-title (push) Has been cancelled
CI / test (push) Has been cancelled
Release PR / release-pr (push) Has been cancelled
Release PR / publish-cli (push) Has been cancelled
refactor(cli)!: rename all skills and agents to consistent ce- prefix (#503)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 15:44:22 -07:00

854 lines
31 KiB
JavaScript

#!/usr/bin/env node
// Produces a structured JSON inventory of a repository for the onboarding skill.
// Gathers file tree, manifest data, framework detection, entry points, scripts,
// existing documentation, and test infrastructure — all deterministic work that
// shouldn't burn model tokens.
//
// Usage: node inventory.mjs [--root <path>]
//
// Output: JSON to stdout
import { readdir, readFile, access } from "node:fs/promises";
import { join, basename, resolve } from "node:path";
const args = process.argv.slice(2);
function flag(name, fallback) {
const i = args.indexOf(`--${name}`);
return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
}
const root = flag("root", process.cwd());
// ── Exclusions ────────────────────────────────────────────────────────────────
const EXCLUDED_DIRS = new Set([
"node_modules", ".git", "vendor", "target", "dist", "build",
"__pycache__", ".next", ".cache", ".turbo", ".nuxt", ".output",
".svelte-kit", ".parcel-cache", "coverage", ".pytest_cache",
".mypy_cache", ".tox", "venv", ".venv", "env", ".env",
"bower_components", ".gradle", ".idea", ".vscode",
"Pods", "DerivedData", "xcuserdata",
]);
// ── Helpers ───────────────────────────────────────────────────────────────────
async function exists(p) {
try { await access(p); return true; } catch { return false; }
}
async function readJson(p) {
try {
return JSON.parse(await readFile(p, "utf-8"));
} catch { return null; }
}
async function readText(p) {
try { return await readFile(p, "utf-8"); } catch { return null; }
}
async function listDir(dir, { includeDotfiles = false } = {}) {
try {
const entries = await readdir(dir, { withFileTypes: true });
if (includeDotfiles) return entries;
return entries.filter(e => !e.name.startsWith(".") || e.name === ".github");
} catch { return []; }
}
async function listDirNames(dir) {
const entries = await listDir(dir);
return entries
.filter(e => e.isDirectory() && !EXCLUDED_DIRS.has(e.name))
.map(e => e.name + "/");
}
async function listFileNames(dir, opts) {
const entries = await listDir(dir, opts);
return entries.filter(e => e.isFile()).map(e => e.name);
}
async function globShallow(dir, extensions) {
const files = await listFileNames(dir);
if (!extensions) return files;
return files.filter(f => extensions.some(ext => f.endsWith(ext)));
}
// ── Project Name ──────────────────────────────────────────────────────────────
async function detectName() {
const pkg = await readJson(join(root, "package.json"));
if (pkg?.name) return pkg.name;
const cargo = await readText(join(root, "Cargo.toml"));
if (cargo) {
const m = cargo.match(/\[package\][\s\S]*?name\s*=\s*"([^"]+)"/);
if (m) return m[1];
}
const gomod = await readText(join(root, "go.mod"));
if (gomod) {
const m = gomod.match(/^module\s+(.+)/m);
if (m) {
const parts = m[1].split("/");
// Skip Go major-version suffix (v2, v3, etc.)
let last = parts.pop();
if (/^v\d+$/.test(last) && parts.length > 0) last = parts.pop();
return last;
}
}
const pyproject = await readText(join(root, "pyproject.toml"));
if (pyproject) {
const m = pyproject.match(/name\s*=\s*"([^"]+)"/);
if (m) return m[1];
}
const gemspec = (await globShallow(root, [".gemspec"]))[0];
if (gemspec) {
const content = await readText(join(root, gemspec));
if (content) {
const m = content.match(/\.name\s*=\s*["']([^"']+)["']/);
if (m) return m[1];
}
}
return basename(resolve(root));
}
// ── Language & Framework Detection ────────────────────────────────────────────
const MANIFEST_MAP = [
{ file: "package.json", ecosystem: "Node.js" },
{ file: "tsconfig.json", ecosystem: "TypeScript" },
{ file: "go.mod", ecosystem: "Go" },
{ file: "Cargo.toml", ecosystem: "Rust" },
{ file: "Gemfile", ecosystem: "Ruby" },
{ file: "requirements.txt", ecosystem: "Python" },
{ file: "pyproject.toml", ecosystem: "Python" },
{ file: "Pipfile", ecosystem: "Python" },
{ file: "setup.py", ecosystem: "Python" },
{ file: "mix.exs", ecosystem: "Elixir" },
{ file: "composer.json", ecosystem: "PHP" },
{ file: "pubspec.yaml", ecosystem: "Dart/Flutter" },
{ file: "Package.swift", ecosystem: "Swift" },
{ file: "pom.xml", ecosystem: "Java" },
{ file: "build.gradle", ecosystem: "JVM" },
{ file: "build.gradle.kts", ecosystem: "Kotlin/JVM" },
{ file: "CMakeLists.txt", ecosystem: "C/C++" },
{ file: "Makefile", ecosystem: null }, // too generic to infer language
{ file: "deno.json", ecosystem: "Deno" },
{ file: "deno.jsonc", ecosystem: "Deno" },
];
// Layer 3: Config-file-based framework detection/confirmation.
// These config files are strong signals even when dependencies are ambiguous.
// Pattern follows Vercel's fs-detectors and Netlify's framework-info.
const CONFIG_FILE_FRAMEWORKS = [
{ file: "next.config.js", framework: "Next.js" },
{ file: "next.config.mjs", framework: "Next.js" },
{ file: "next.config.ts", framework: "Next.js" },
{ file: "nuxt.config.ts", framework: "Nuxt" },
{ file: "nuxt.config.js", framework: "Nuxt" },
{ file: "vite.config.ts", framework: "Vite" },
{ file: "vite.config.js", framework: "Vite" },
{ file: "vite.config.mts", framework: "Vite" },
{ file: "astro.config.mjs", framework: "Astro" },
{ file: "astro.config.ts", framework: "Astro" },
{ file: "svelte.config.js", framework: "SvelteKit" },
{ file: "svelte.config.ts", framework: "SvelteKit" },
{ file: "gatsby-config.js", framework: "Gatsby" },
{ file: "gatsby-config.ts", framework: "Gatsby" },
{ file: "angular.json", framework: "Angular" },
{ file: "remix.config.js", framework: "Remix" },
{ file: "remix.config.ts", framework: "Remix" },
{ file: "ember-cli-build.js", framework: "Ember" },
{ file: "quasar.config.js", framework: "Quasar" },
{ file: "ionic.config.json", framework: "Ionic" },
{ file: "electron-builder.json", framework: "Electron" },
{ file: "electron-builder.yml", framework: "Electron" },
{ file: "tauri.conf.json", framework: "Tauri" },
{ file: "expo-env.d.ts", framework: "Expo" },
{ file: "app.json", framework: null }, // too ambiguous alone
{ file: "webpack.config.js", framework: "Webpack" },
{ file: "webpack.config.ts", framework: "Webpack" },
{ file: "rollup.config.js", framework: "Rollup" },
{ file: "turbo.json", framework: "Turborepo" },
// Python
{ file: "manage.py", framework: "Django" },
// Ruby
{ file: "config/routes.rb", framework: "Rails" },
{ file: "config.ru", framework: "Rack" },
// PHP
{ file: "artisan", framework: "Laravel" },
{ file: "symfony.lock", framework: "Symfony" },
// Elixir
{ file: "config/config.exs", framework: "Phoenix" },
];
// Known frameworks detectable from package.json dependencies.
// Sourced from Vercel's frameworks.ts and Netlify's framework-info definitions.
const NODE_FRAMEWORKS = {
// Meta-frameworks / SSR
"next": "Next.js", "nuxt": "Nuxt", "@sveltejs/kit": "SvelteKit",
"@remix-run/node": "Remix", "remix": "Remix", "gatsby": "Gatsby",
"astro": "Astro", "@builder.io/qwik": "Qwik",
"@tanstack/react-start": "TanStack Start",
"@analogjs/platform": "Analog",
// UI libraries
"react": "React", "vue": "Vue", "svelte": "Svelte",
"@angular/core": "Angular", "solid-js": "Solid",
"preact": "Preact", "lit": "Lit",
// Server frameworks
"express": "Express", "fastify": "Fastify", "hono": "Hono",
"koa": "Koa", "@nestjs/core": "NestJS", "h3": "H3",
"nitro": "Nitro", "@elysiajs/core": "Elysia", "elysia": "Elysia",
// Build tools
"vite": "Vite", "esbuild": "esbuild",
"webpack": "Webpack", "turbo": "Turborepo",
// Desktop / Mobile
"electron": "Electron", "tauri": "Tauri",
"expo": "Expo", "react-native": "React Native",
// Documentation / Static
"vitepress": "VitePress", "vuepress": "VuePress",
"@docusaurus/core": "Docusaurus", "@storybook/core": "Storybook",
"11ty": "Eleventy", "@11ty/eleventy": "Eleventy",
// E-commerce
"@shopify/hydrogen": "Hydrogen",
};
// Exclusion rules: if these packages are present, suppress the indicated framework.
// Prevents false positives from monorepo wrappers. (Pattern from Netlify)
const NODE_FRAMEWORK_EXCLUSIONS = {
"Next.js": ["@nrwl/next"], // Nx wrapper -- different build config
};
const NODE_TEST_FRAMEWORKS = {
"jest": "Jest", "vitest": "Vitest", "mocha": "Mocha",
"@playwright/test": "Playwright", "cypress": "Cypress",
"ava": "AVA", "tap": "tap", "bun:test": "Bun test",
};
async function detectLanguagesAndFrameworks() {
const languages = new Set();
const frameworks = [];
let packageManager = null;
let testFramework = null;
const rootFiles = await listFileNames(root);
for (const { file, ecosystem } of MANIFEST_MAP) {
if (rootFiles.includes(file) && ecosystem) {
languages.add(ecosystem);
}
}
// package.json deep inspection
const pkg = await readJson(join(root, "package.json"));
if (pkg) {
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
for (const [dep, fw] of Object.entries(NODE_FRAMEWORKS)) {
if (allDeps[dep]) {
// Check exclusion rules before adding
const exclusions = NODE_FRAMEWORK_EXCLUSIONS[fw];
if (exclusions && exclusions.some(ex => allDeps[ex])) continue;
const ver = allDeps[dep].replace(/[\^~>=<]/g, "").split(" ")[0];
frameworks.push(ver ? `${fw} ${ver}` : fw);
}
}
for (const [dep, name] of Object.entries(NODE_TEST_FRAMEWORKS)) {
if (allDeps[dep]) { testFramework = name; break; }
}
}
// Package manager detection -- runs independently of package.json
// so workspace roots with only a lockfile are still detected.
if (rootFiles.includes("bun.lockb") || rootFiles.includes("bun.lock")) packageManager = "bun";
else if (rootFiles.includes("pnpm-lock.yaml")) packageManager = "pnpm";
else if (rootFiles.includes("yarn.lock")) packageManager = "yarn";
else if (rootFiles.includes("package-lock.json")) packageManager = "npm";
// Ruby framework detection
if (languages.has("Ruby")) {
const gemfile = await readText(join(root, "Gemfile"));
if (gemfile) {
if (/gem\s+['"]rails['"]/.test(gemfile)) frameworks.push("Rails");
if (/gem\s+['"]sinatra['"]/.test(gemfile)) frameworks.push("Sinatra");
if (/gem\s+['"]hanami['"]/.test(gemfile)) frameworks.push("Hanami");
if (/gem\s+['"]grape['"]/.test(gemfile)) frameworks.push("Grape");
if (/gem\s+['"]roda['"]/.test(gemfile)) frameworks.push("Roda");
// Ruby test frameworks
if (/gem\s+['"]rspec['"]/.test(gemfile)) testFramework = testFramework || "RSpec";
else if (/gem\s+['"]minitest['"]/.test(gemfile)) testFramework = testFramework || "Minitest";
}
}
// Python framework detection (covers deps in requirements.txt, pyproject.toml, Pipfile)
if (languages.has("Python")) {
const reqs = await readText(join(root, "requirements.txt"));
const pyproject = await readText(join(root, "pyproject.toml"));
const pipfile = await readText(join(root, "Pipfile"));
const combined = (reqs || "") + (pyproject || "") + (pipfile || "");
if (/\bdjango\b/i.test(combined)) frameworks.push("Django");
if (/\bfastapi\b/i.test(combined)) frameworks.push("FastAPI");
if (/\bflask\b/i.test(combined)) frameworks.push("Flask");
if (/\bstarlette\b/i.test(combined)) frameworks.push("Starlette");
if (/\bstreamlit\b/i.test(combined)) frameworks.push("Streamlit");
if (/\bgradio\b/i.test(combined)) frameworks.push("Gradio");
if (/\bcelery\b/i.test(combined)) frameworks.push("Celery");
if (/\bsanic\b/i.test(combined)) frameworks.push("Sanic");
if (/\btornado\b/i.test(combined)) frameworks.push("Tornado");
if (/\bpytest\b/i.test(combined)) testFramework = testFramework || "pytest";
if (rootFiles.includes("pytest.ini") || rootFiles.includes("conftest.py"))
testFramework = testFramework || "pytest";
if (/\bunittest\b/i.test(combined)) testFramework = testFramework || "unittest";
}
// Go framework detection
if (languages.has("Go")) {
const gomod = await readText(join(root, "go.mod"));
if (gomod) {
if (/github\.com\/gin-gonic\/gin/.test(gomod)) frameworks.push("Gin");
if (/github\.com\/labstack\/echo/.test(gomod)) frameworks.push("Echo");
if (/github\.com\/gofiber\/fiber/.test(gomod)) frameworks.push("Fiber");
if (/github\.com\/gorilla\/mux/.test(gomod)) frameworks.push("Gorilla Mux");
if (/github\.com\/go-chi\/chi/.test(gomod)) frameworks.push("Chi");
if (/google\.golang\.org\/grpc/.test(gomod)) frameworks.push("gRPC");
if (/github\.com\/bufbuild\/connect-go/.test(gomod)) frameworks.push("Connect");
}
testFramework = testFramework || "go test";
}
// Rust framework detection
if (languages.has("Rust")) {
const cargo = await readText(join(root, "Cargo.toml"));
if (cargo) {
if (/\bactix-web\b/.test(cargo)) frameworks.push("Actix Web");
if (/\baxum\b/.test(cargo)) frameworks.push("Axum");
if (/\brocket\b/.test(cargo)) frameworks.push("Rocket");
if (/\bwarp\b/.test(cargo)) frameworks.push("Warp");
if (/\btokio\b/.test(cargo)) frameworks.push("Tokio");
if (/\btauri\b/.test(cargo)) frameworks.push("Tauri");
}
}
// PHP framework detection
if (languages.has("PHP")) {
const composer = await readJson(join(root, "composer.json"));
if (composer) {
const allDeps = { ...composer.require, ...composer["require-dev"] };
if (allDeps["laravel/framework"]) frameworks.push("Laravel");
if (allDeps["symfony/framework-bundle"]) frameworks.push("Symfony");
if (allDeps["slim/slim"]) frameworks.push("Slim");
if (allDeps["phpunit/phpunit"]) testFramework = testFramework || "PHPUnit";
if (allDeps["pestphp/pest"]) testFramework = testFramework || "Pest";
}
}
// Elixir framework detection
if (languages.has("Elixir")) {
const mixfile = await readText(join(root, "mix.exs"));
if (mixfile) {
if (/:phoenix\b/.test(mixfile)) frameworks.push("Phoenix");
if (/:plug\b/.test(mixfile)) frameworks.push("Plug");
}
}
// Rust test framework
if (languages.has("Rust")) {
testFramework = testFramework || "cargo test";
}
// Fallback: infer test framework from the test script command
if (!testFramework && pkg?.scripts?.test) {
const testCmd = pkg.scripts.test;
if (/\bbun\s+test\b/.test(testCmd)) testFramework = "bun test";
else if (/\bjest\b/.test(testCmd)) testFramework = "Jest";
else if (/\bvitest\b/.test(testCmd)) testFramework = "Vitest";
else if (/\bmocha\b/.test(testCmd)) testFramework = "Mocha";
else if (/\bpytest\b/.test(testCmd)) testFramework = "pytest";
else if (/\brspec\b/.test(testCmd)) testFramework = "RSpec";
}
// Layer 3: Config-file-based framework confirmation/detection.
// Catches frameworks missed by dependency scanning and confirms ambiguous cases.
const frameworkNames = new Set(frameworks.map(f => f.split(" ")[0]));
const uncheckedConfigs = CONFIG_FILE_FRAMEWORKS.filter(
({ framework }) => framework && !frameworkNames.has(framework)
);
const configResults = await Promise.all(
uncheckedConfigs.map(async ({ file, framework }) => ({
framework,
found: await exists(join(root, file)),
}))
);
for (const { framework, found } of configResults) {
if (found && !frameworkNames.has(framework)) {
frameworks.push(framework);
frameworkNames.add(framework);
}
}
return {
languages: [...languages],
frameworks,
packageManager,
testFramework,
};
}
// ── Directory Structure ───────────────────────────────────────────────────────
async function getStructure() {
const topLevel = [];
const srcLayout = {};
const entries = await listDir(root);
for (const entry of entries) {
if (EXCLUDED_DIRS.has(entry.name)) continue;
if (entry.isDirectory()) {
topLevel.push(entry.name + "/");
} else {
topLevel.push(entry.name);
}
}
// One level deeper into common source directories
const srcDirs = ["src", "lib", "app", "pkg", "internal", "cmd", "server", "api"];
for (const dir of srcDirs) {
const dirPath = join(root, dir);
if (await exists(dirPath)) {
const children = await listDirNames(dirPath);
const files = await listFileNames(dirPath);
if (children.length > 0 || files.length > 0) {
srcLayout[dir] = {
dirs: children,
files: files.slice(0, 10), // cap file listing
};
}
}
}
return { topLevel, srcLayout };
}
// ── Entry Points ──────────────────────────────────────────────────────────────
// Helper: check a batch of candidate paths, return those that exist.
async function filterExisting(candidates) {
const results = await Promise.all(
candidates.map(async (p) => (await exists(join(root, p))) ? p : null)
);
return results.filter(Boolean);
}
async function findEntryPoints(languages) {
const langSet = new Set(languages);
// Universal entry points — check root and src/ in one batch
const universalCandidates = [
"index.ts", "index.js", "index.mjs", "index.tsx", "index.jsx",
"main.ts", "main.js", "main.mjs", "main.tsx", "main.jsx",
"app.ts", "app.js", "app.mjs", "app.tsx", "app.jsx",
"server.ts", "server.js", "server.mjs",
];
const allCandidates = [
...universalCandidates,
...universalCandidates.map(f => `src/${f}`),
];
// Language-specific candidates — add to the same batch
if (langSet.has("Node.js") || langSet.has("TypeScript") || langSet.has("Deno")) {
allCandidates.push(
"app/page.tsx", "app/page.jsx", "app/layout.tsx", "app/layout.jsx",
"src/app/page.tsx", "src/app/page.jsx", "src/app/layout.tsx", "src/app/layout.jsx",
"pages/index.tsx", "pages/index.jsx", "pages/index.js",
"src/pages/index.tsx", "src/pages/index.jsx",
);
}
if (langSet.has("Python")) {
allCandidates.push(
"main.py", "app.py", "manage.py", "run.py", "wsgi.py", "asgi.py",
"src/main.py", "src/app.py",
);
}
if (langSet.has("Ruby")) {
allCandidates.push(
"config.ru", "config/routes.rb", "config/application.rb",
"bin/rails", "Rakefile",
);
}
if (langSet.has("Go")) {
allCandidates.push("main.go");
}
if (langSet.has("Rust")) {
allCandidates.push("src/main.rs", "src/lib.rs");
}
// Single parallel batch for all fixed-path candidates
const entryPoints = await filterExisting(allCandidates);
// Node/TS: also check package.json main/module fields
if (langSet.has("Node.js") || langSet.has("TypeScript") || langSet.has("Deno")) {
const pkg = await readJson(join(root, "package.json"));
for (const field of [pkg?.main, pkg?.module]) {
if (field && !entryPoints.includes(field) && await exists(join(root, field))) {
entryPoints.push(field);
}
}
}
// Python: __main__.py in src subdirectories (requires listing)
if (langSet.has("Python")) {
const srcEntries = await listDir(join(root, "src"));
const pyMains = await filterExisting(
srcEntries.filter(e => e.isDirectory()).map(e => `src/${e.name}/__main__.py`)
);
entryPoints.push(...pyMains);
}
// Go: cmd/*/main.go (requires listing)
if (langSet.has("Go")) {
const cmdDir = join(root, "cmd");
if (await exists(cmdDir)) {
const cmds = await listDir(cmdDir);
const goMains = await filterExisting(
cmds.filter(c => c.isDirectory()).map(c => `cmd/${c.name}/main.go`)
);
entryPoints.push(...goMains);
}
}
return [...new Set(entryPoints)];
}
// ── Scripts / Commands ────────────────────────────────────────────────────────
async function detectScripts() {
const scripts = {};
// package.json scripts
const pkg = await readJson(join(root, "package.json"));
if (pkg?.scripts) {
const important = ["dev", "start", "build", "test", "lint", "serve",
"preview", "typecheck", "check", "format", "migrate"];
for (const key of important) {
if (pkg.scripts[key]) scripts[key] = pkg.scripts[key];
}
// Also include any scripts not in our list but keep it bounded
for (const [key, val] of Object.entries(pkg.scripts)) {
if (!scripts[key] && Object.keys(scripts).length < 15) {
scripts[key] = val;
}
}
}
// Makefile targets -- always include alongside npm scripts for polyglot repos
const makefile = await readText(join(root, "Makefile"));
if (makefile) {
const targets = makefile.match(/^([a-zA-Z_][\w-]*)\s*:/gm);
if (targets) {
for (const t of targets.slice(0, 15)) {
const name = t.replace(":", "").trim();
if (!scripts[`make ${name}`]) scripts[`make ${name}`] = "(Makefile target)";
}
}
}
// Procfile
const procfile = await readText(join(root, "Procfile"));
if (procfile) {
for (const line of procfile.split("\n")) {
const m = line.match(/^(\w+):\s*(.+)/);
if (m) scripts[`Procfile:${m[1]}`] = m[2].trim();
}
}
return scripts;
}
// ── Documentation Discovery ──────────────────────────────────────────────────
// Extract the first markdown heading from a file (cheap I/O, avoids model reads).
async function extractTitle(filePath) {
try {
const content = await readFile(filePath, "utf-8");
// Match first ATX heading (# Title)
const m = content.match(/^#{1,3}\s+(.+)/m);
return m ? m[1].trim() : null;
} catch { return null; }
}
async function findDocs() {
const seen = new Set();
const paths = [];
function add(path) {
if (!seen.has(path)) { seen.add(path); paths.push(path); }
}
// Root markdown files
const rootFiles = await globShallow(root, [".md"]);
for (const f of rootFiles) add(f);
// Common doc directories — only top-level entries; subdirs are discovered
// via the nested scan below, so no need to list nested paths like
// "docs/solutions" here (which caused duplicates).
const docDirs = ["docs", "doc", "documentation", "wiki", ".github"];
for (const dir of docDirs) {
const dirPath = join(root, dir);
if (await exists(dirPath)) {
const files = await globShallow(dirPath, [".md"]);
for (const f of files.slice(0, 10)) add(`${dir}/${f}`);
// One level deeper
const subdirs = await listDirNames(dirPath);
for (const sub of subdirs.slice(0, 5)) {
const subName = sub.replace("/", "");
const subFiles = await globShallow(join(dirPath, subName), [".md"]);
for (const f of subFiles.slice(0, 5)) add(`${dir}/${subName}/${f}`);
}
}
}
// Extract titles in parallel so the model can triage without reading each file
const docs = await Promise.all(
paths.map(async (p) => {
const title = await extractTitle(join(root, p));
return title ? { path: p, title } : { path: p };
})
);
return docs;
}
// ── Test Infrastructure ───────────────────────────────────────────────────────
async function findTestInfra() {
const dirs = [];
const config = [];
// Test directories
const testDirs = ["tests", "test", "spec", "__tests__", "e2e",
"integration", "src/tests", "src/test", "src/__tests__"];
for (const dir of testDirs) {
if (await exists(join(root, dir))) dirs.push(dir + "/");
}
// Test config files
const testConfigs = [
"jest.config.js", "jest.config.ts", "jest.config.mjs",
"vitest.config.js", "vitest.config.ts", "vitest.config.mts",
".rspec", "pytest.ini", "conftest.py", "setup.cfg",
"phpunit.xml", "karma.conf.js", "cypress.config.js", "cypress.config.ts",
"playwright.config.js", "playwright.config.ts",
];
const rootFiles = await listFileNames(root, { includeDotfiles: true });
for (const f of testConfigs) {
if (rootFiles.includes(f)) config.push(f);
}
return { dirs, config };
}
// ── Monorepo Detection ────────────────────────────────────────────────────────
async function detectMonorepo() {
const rootFiles = await listFileNames(root);
const signals = [];
const pkg = await readJson(join(root, "package.json"));
if (pkg?.workspaces) {
signals.push("npm/yarn workspaces");
}
if (rootFiles.includes("pnpm-workspace.yaml")) signals.push("pnpm workspaces");
if (rootFiles.includes("nx.json")) signals.push("Nx");
if (rootFiles.includes("lerna.json")) signals.push("Lerna");
if (rootFiles.includes("turbo.json")) signals.push("Turborepo");
const cargo = await readText(join(root, "Cargo.toml"));
if (cargo && /\[workspace\]/.test(cargo)) signals.push("Cargo workspace");
if (signals.length === 0) {
// Check for conventional monorepo directories
const monoIndicators = ["apps", "packages", "services", "modules", "libs"];
let found = 0;
for (const dir of monoIndicators) {
if (await exists(join(root, dir))) found++;
}
if (found >= 2) signals.push("convention-based (multiple top-level package dirs)");
}
if (signals.length === 0) return null;
// List workspaces
const workspaces = [];
const wsDirs = ["apps", "packages", "services", "modules", "libs", "plugins"];
for (const dir of wsDirs) {
const dirPath = join(root, dir);
if (await exists(dirPath)) {
const children = await listDirNames(dirPath);
for (const c of children.slice(0, 20)) {
workspaces.push(`${dir}/${c}`);
}
}
}
return { signals, workspaces };
}
// ── Infrastructure & External Dependencies ────────────────────────────────────
async function findInfrastructure() {
const rootFiles = await listFileNames(root, { includeDotfiles: true });
const envFiles = [];
const configFiles = [];
const services = [];
// Environment files (signal for external dependencies)
const envCandidates = [
".env.example", ".env.sample", ".env.template", ".env.local.example",
".env.development", ".env.production",
];
for (const f of envCandidates) {
if (rootFiles.includes(f)) envFiles.push(f);
}
// Docker / container config (reveals databases, caches, queues)
const dockerFiles = [
"docker-compose.yml", "docker-compose.yaml",
"docker-compose.dev.yml", "docker-compose.dev.yaml",
"docker-compose.override.yml", "Dockerfile",
];
for (const f of dockerFiles) {
if (rootFiles.includes(f)) configFiles.push(f);
}
// Deployment / infrastructure config
const infraFiles = [
"fly.toml", "vercel.json", "netlify.toml", "render.yaml",
"railway.json", "app.yaml", "serverless.yml", "sam-template.yaml",
"Procfile", "nixpacks.toml",
];
for (const f of infraFiles) {
if (rootFiles.includes(f)) configFiles.push(f);
}
// Detect common services from docker-compose
for (const dcFile of ["docker-compose.yml", "docker-compose.yaml"]) {
const dc = await readText(join(root, dcFile));
if (dc) {
if (/postgres/i.test(dc)) services.push("PostgreSQL");
if (/mysql|mariadb/i.test(dc)) services.push("MySQL");
if (/mongo/i.test(dc)) services.push("MongoDB");
if (/redis/i.test(dc)) services.push("Redis");
if (/rabbitmq/i.test(dc)) services.push("RabbitMQ");
if (/kafka/i.test(dc)) services.push("Kafka");
if (/elasticsearch/i.test(dc)) services.push("Elasticsearch");
if (/minio|localstack/i.test(dc)) services.push("S3-compatible storage");
if (/mailhog|mailpit/i.test(dc)) services.push("Email (dev)");
break;
}
}
// Detect services from env example files
for (const envFile of envFiles) {
const content = await readText(join(root, envFile));
if (content) {
if (/DATABASE_URL|DB_HOST|POSTGRES/i.test(content) && !services.includes("PostgreSQL") && !services.includes("MySQL"))
services.push("Database (see env config)");
if (/REDIS/i.test(content) && !services.includes("Redis"))
services.push("Redis");
if (/STRIPE/i.test(content)) services.push("Stripe");
if (/OPENAI|ANTHROPIC|CLAUDE/i.test(content)) services.push("AI/LLM API");
if (/AWS_|S3_/i.test(content) && !services.includes("S3-compatible storage"))
services.push("AWS/S3");
if (/SENDGRID|MAILGUN|POSTMARK|RESEND/i.test(content))
services.push("Email service");
if (/TWILIO/i.test(content)) services.push("Twilio");
if (/SENTRY/i.test(content)) services.push("Sentry");
if (/AUTH0|CLERK|SUPABASE_/i.test(content)) services.push("Auth service");
break; // Only read the first env example
}
}
return {
envFiles,
configFiles,
services: [...new Set(services)],
};
}
// ── Main ──────────────────────────────────────────────────────────────────────
async function main() {
const [
name,
langInfo,
structure,
docs,
testInfra,
scripts,
monorepo,
infrastructure,
] = await Promise.all([
detectName(),
detectLanguagesAndFrameworks(),
getStructure(),
findDocs(),
findTestInfra(),
detectScripts(),
detectMonorepo(),
findInfrastructure(),
]);
const entryPoints = await findEntryPoints(langInfo.languages);
const inventory = {
name,
languages: langInfo.languages,
frameworks: langInfo.frameworks,
packageManager: langInfo.packageManager,
testFramework: langInfo.testFramework,
monorepo,
structure,
entryPoints,
scripts,
docs,
testInfra,
infrastructure,
};
process.stdout.write(JSON.stringify(inventory) + "\n");
}
main().catch(err => {
// Always exit 0 with valid JSON, even on error
process.stdout.write(JSON.stringify({
error: err.message,
name: basename(root),
languages: [],
frameworks: [],
packageManager: null,
testFramework: null,
monorepo: null,
structure: { topLevel: [], srcLayout: {} },
entryPoints: [],
scripts: {},
docs: [],
testInfra: { dirs: [], config: [] },
infrastructure: { envFiles: [], configFiles: [], services: [] },
}) + "\n");
});