doctrina-cli 0.18.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +84 -0
  4. package/src/commands/analyze.js +14 -3
  5. package/src/commands/change.js +164 -17
  6. package/src/commands/clarify.js +1 -204
  7. package/src/commands/close.js +18 -1
  8. package/src/commands/context.js +81 -9
  9. package/src/commands/contract.js +1 -0
  10. package/src/commands/delivery.js +356 -0
  11. package/src/commands/doctor.js +21 -1
  12. package/src/commands/handoff.js +11 -2
  13. package/src/commands/index-rebuild.js +23 -0
  14. package/src/commands/init.js +28 -1
  15. package/src/commands/intake.js +60 -3
  16. package/src/commands/metrics.js +43 -3
  17. package/src/commands/next.js +41 -6
  18. package/src/commands/prime.js +125 -7
  19. package/src/commands/review.js +39 -3
  20. package/src/commands/skill.js +2 -1
  21. package/src/commands/status.js +28 -0
  22. package/src/commands/upgrade.js +33 -0
  23. package/src/commands/validate.js +18 -0
  24. package/src/commands/verify.js +32 -4
  25. package/src/commands/work.js +126 -7
  26. package/src/commands/workspace.js +419 -0
  27. package/src/index.js +100 -66
  28. package/src/lib/actions.js +44 -4
  29. package/src/lib/adapters.js +143 -1
  30. package/src/lib/args.js +33 -1
  31. package/src/lib/clarify-scan.js +221 -0
  32. package/src/lib/code-inventory.js +770 -0
  33. package/src/lib/commands.js +52 -5
  34. package/src/lib/context-slice.js +149 -0
  35. package/src/lib/delivery.js +286 -0
  36. package/src/lib/docs-impact.js +36 -1
  37. package/src/lib/fs-ops.js +23 -0
  38. package/src/lib/intake-model.js +1 -1
  39. package/src/lib/json-out.js +9 -1
  40. package/src/lib/metrics-model.js +23 -1
  41. package/src/lib/playbook.js +1 -1
  42. package/src/lib/shell.js +45 -0
  43. package/src/lib/skill-export.js +95 -0
  44. package/src/lib/snapshot.js +43 -2
  45. package/src/lib/task-card.js +195 -0
  46. package/src/lib/views.js +52 -3
  47. package/src/lib/work-model.js +66 -2
  48. package/src/lib/workspace-agents.js +306 -0
  49. package/src/lib/workspace-guard.js +65 -0
  50. package/src/lib/workspace-locate.js +141 -0
  51. package/src/lib/workspace-tools.js +160 -0
  52. package/src/lib/workspace-view.js +391 -0
  53. package/src/lib/workspace.js +323 -0
  54. package/templates/AGENTS.md.template +17 -14
  55. package/templates/README.md +11 -1
  56. package/templates/adapters/claude/.claude/settings.json.template +18 -0
  57. package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
  58. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
  59. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  60. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  61. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  62. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  63. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  64. package/templates/delivery.md.template +39 -0
  65. package/templates/playbooks/absorb.md.template +63 -0
  66. package/templates/playbooks/bootstrap.md.template +18 -6
  67. package/templates/playbooks/delivery.md.template +44 -0
  68. package/templates/playbooks/work.md.template +1 -1
@@ -0,0 +1,770 @@
1
+ // @ts-check
2
+ import { readdirSync, statSync } from "node:fs";
3
+ import process from "node:process";
4
+ import path from "node:path";
5
+ import { isDir, isFile, read } from "./fs-ops.js";
6
+ import { gitLines } from "./git.js";
7
+
8
+ // The INVENTORY of an existing codebase (change 0221): what `intake
9
+ // --from-code` stores as the intake of a repository that already has code
10
+ // and no specs. Deterministic, local and cheap — it reads manifests and
11
+ // counts files; it never interprets prose and never reads a secret.
12
+ //
13
+ // It is the FACTS half of absorbing a project. The agent does the other
14
+ // half — reading the code it points at and writing what the system does
15
+ // today — through the absorb playbook (ADR 0005: the CLI does no
16
+ // interpretation).
17
+ //
18
+ // Change 0229: every fact is read from the whole repository, not its root.
19
+ // Measured on public projects, the root-only reading distorted what an agent
20
+ // would absorb: an eleven-service polyglot demo (Go, C#, Node, Python, Java)
21
+ // read as ".NET"; a FastAPI + React template read as "no framework" with one
22
+ // candidate area, an e-mail template package; a Spring application's areas
23
+ // were `src/main/` and `src/test/`; and a `--watch` script was offered to
24
+ // `verify --init`, a gate that would then never return. So the inventory
25
+ // now reads every UNIT (a directory with its own manifest), points at entry
26
+ // points, interfaces, data, delivery and the documentation that already
27
+ // exists, and ends with what it could not see — a fact the agent must not
28
+ // fill in by guessing.
29
+
30
+ const SKIP_DIRS = new Set([
31
+ ".git", "node_modules", ".doctrina", "dist", "build", "out", "target", "vendor", "coverage",
32
+ ".venv", "venv", "env", "__pycache__", ".next", ".nuxt", ".gradle", ".idea", ".vscode", "bin", "obj",
33
+ ".terraform", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", "Pods", ".dart_tool", ".expo",
34
+ ".turbo", ".cache", ".svelte-kit", ".angular", ".parcel-cache", ".output", "storybook-static",
35
+ ".serverless", ".aws-sam", "DerivedData", ".pnpm-store", ".yarn",
36
+ ]);
37
+ const MAX_FILES = 20000;
38
+ // How deep a manifest may sit and still name a unit (examples/x/y/package.json is 3).
39
+ const MAX_UNIT_DEPTH = 5;
40
+
41
+ // Dependency name → what it tells about the project.
42
+ const JS_SIGNALS = {
43
+ react: "React", next: "Next.js", vue: "Vue", nuxt: "Nuxt", "@angular/core": "Angular", svelte: "Svelte",
44
+ "@sveltejs/kit": "SvelteKit", "solid-js": "Solid", "@remix-run/react": "Remix", astro: "Astro", vite: "Vite",
45
+ "react-native": "React Native", expo: "Expo", electron: "Electron", express: "Express", fastify: "Fastify",
46
+ "@nestjs/core": "NestJS", koa: "Koa", hono: "Hono", "@trpc/server": "tRPC", graphql: "GraphQL",
47
+ "@apollo/server": "Apollo Server", prisma: "Prisma", "@prisma/client": "Prisma", "drizzle-orm": "Drizzle",
48
+ typeorm: "TypeORM", sequelize: "Sequelize", mongoose: "Mongoose", "@tanstack/react-query": "TanStack Query",
49
+ "@tanstack/react-router": "TanStack Router", redux: "Redux", "@reduxjs/toolkit": "Redux", zustand: "Zustand",
50
+ tailwindcss: "Tailwind CSS", jest: "Jest", vitest: "Vitest", mocha: "Mocha", "@playwright/test": "Playwright",
51
+ playwright: "Playwright", cypress: "Cypress", "@cucumber/cucumber": "Cucumber", webdriverio: "WebdriverIO",
52
+ "@testing-library/react": "Testing Library", storybook: "Storybook", "@storybook/react": "Storybook",
53
+ typescript: "TypeScript", k6: "k6", "socket.io": "Socket.IO", "@grpc/grpc-js": "gRPC", kafkajs: "Kafka",
54
+ amqplib: "RabbitMQ", redis: "Redis", ioredis: "Redis", stripe: "Stripe", "next-auth": "NextAuth",
55
+ "better-auth": "Better Auth",
56
+ };
57
+ const PY_SIGNALS = {
58
+ django: "Django", djangorestframework: "Django REST framework", flask: "Flask", fastapi: "FastAPI",
59
+ sqlmodel: "SQLModel", pydantic: "Pydantic", pytest: "pytest", behave: "Behave", robotframework: "Robot Framework",
60
+ selenium: "Selenium", playwright: "Playwright", requests: "requests", httpx: "HTTPX", sqlalchemy: "SQLAlchemy",
61
+ alembic: "Alembic", pandas: "pandas", pyspark: "PySpark", locust: "Locust", celery: "Celery", grpcio: "gRPC",
62
+ streamlit: "Streamlit", langchain: "LangChain", openai: "OpenAI SDK", anthropic: "Anthropic SDK", uvicorn: "Uvicorn",
63
+ gunicorn: "Gunicorn", "opentelemetry-api": "OpenTelemetry",
64
+ };
65
+ const JVM_SIGNALS = {
66
+ "spring-boot": "Spring Boot", "spring-cloud": "Spring Cloud", quarkus: "Quarkus", micronaut: "Micronaut",
67
+ junit: "JUnit", "rest-assured": "REST Assured", cucumber: "Cucumber", testng: "TestNG", selenium: "Selenium",
68
+ kotlin: "Kotlin", mockito: "Mockito", hibernate: "Hibernate", flyway: "Flyway", liquibase: "Liquibase",
69
+ "grpc-": "gRPC", kafka: "Kafka",
70
+ };
71
+ const DOTNET_SIGNALS = {
72
+ "Microsoft.AspNetCore": "ASP.NET Core", "Microsoft.EntityFrameworkCore": "Entity Framework Core",
73
+ xunit: "xUnit", NUnit: "NUnit", MSTest: "MSTest", "Grpc.": "gRPC", "Aspire.": ".NET Aspire",
74
+ MediatR: "MediatR", Blazor: "Blazor", "Microsoft.Maui": ".NET MAUI", RabbitMQ: "RabbitMQ",
75
+ };
76
+
77
+ // Manifest file → the kind of unit it declares.
78
+ /** @type {Array<[RegExp, string]>} */
79
+ const MANIFESTS = [
80
+ [/^package\.json$/, "node"], [/^pyproject\.toml$/, "python"], [/^requirements(-[\w]+)?\.txt$/, "python"],
81
+ [/^setup\.py$/, "python"], [/^Pipfile$/, "python"], [/^pom\.xml$/, "jvm"], [/^build\.gradle(\.kts)?$/, "jvm"],
82
+ [/^go\.mod$/, "go"], [/^Cargo\.toml$/, "rust"], [/\.csproj$/, "dotnet"], [/\.fsproj$/, "dotnet"],
83
+ [/^pubspec\.yaml$/, "dart"], [/^Gemfile$/, "ruby"], [/^composer\.json$/, "php"], [/^mix\.exs$/, "elixir"],
84
+ [/^Package\.swift$/, "swift"],
85
+ ];
86
+ const KIND_LABEL = {
87
+ node: "Node.js", python: "Python", jvm: "JVM", go: "Go", rust: "Rust", dotnet: ".NET", dart: "Dart / Flutter",
88
+ ruby: "Ruby", php: "PHP", elixir: "Elixir", swift: "Swift",
89
+ };
90
+
91
+ const LANG_BY_EXT = {
92
+ ts: "TypeScript", tsx: "TypeScript", mts: "TypeScript", cts: "TypeScript", js: "JavaScript", jsx: "JavaScript",
93
+ mjs: "JavaScript", cjs: "JavaScript", py: "Python", go: "Go", java: "Java", kt: "Kotlin", kts: "Kotlin",
94
+ cs: "C#", fs: "F#", rb: "Ruby", php: "PHP", rs: "Rust", swift: "Swift", dart: "Dart", scala: "Scala",
95
+ ex: "Elixir", exs: "Elixir", c: "C", h: "C", cpp: "C++", cc: "C++", hpp: "C++", m: "Objective-C",
96
+ vue: "Vue", svelte: "Svelte", sql: "SQL", tf: "Terraform", sh: "Shell", ps1: "PowerShell",
97
+ feature: "Gherkin", proto: "Protocol Buffers", graphql: "GraphQL", gql: "GraphQL", razor: "Razor",
98
+ cshtml: "Razor", html: "HTML", css: "CSS", scss: "SCSS",
99
+ };
100
+
101
+ const TEST_FILE = /(\.(test|spec)\.[cm]?[jt]sx?$)|(^test_.*\.py$)|(_test\.(py|go)$)|(\.feature$)|(Tests?\.(java|kt|cs)$)|(\.robot$)|(_spec\.rb$)|(_test\.exs$)/;
102
+ const TEST_DIR = /(^|\/)(tests?|__tests__|spec|e2e|cypress|src\/test|integration-tests|UnitTests|FunctionalTests)(\/|$)/i;
103
+ const CI_FILES = [
104
+ [".github/workflows", "GitHub Actions"], [".gitlab-ci.yml", "GitLab CI"], ["Jenkinsfile", "Jenkins"],
105
+ ["azure-pipelines.yml", "Azure Pipelines"], ["bitbucket-pipelines.yml", "Bitbucket Pipelines"], [".circleci", "CircleCI"],
106
+ [".drone.yml", "Drone"], [".travis.yml", "Travis CI"], ["cloudbuild.yaml", "Cloud Build"], [".buildkite", "Buildkite"],
107
+ ];
108
+ const ENV_EXAMPLE = /^\.env(\.[\w-]+)?\.(example|sample|template|dist)$|^\.env\.(example|sample|template|dist)$|^example\.env$/;
109
+ const ENV_REAL = /^\.env(\.[\w-]+)?$/;
110
+ // The conventional roots under which capabilities usually live. A test root
111
+ // is never an area: tests are evidence, listed under Tests.
112
+ const AREA_ROOTS = [
113
+ "src", "app", "lib", "packages", "apps", "services", "modules", "features", "pages", "components",
114
+ "frontend", "backend", "server", "client", "api", "web", "mobile", "cmd", "internal", "pkg", "domain",
115
+ ];
116
+ const TEST_ROOTS = new Set(["test", "tests", "e2e", "cypress", "__tests__", "spec"]);
117
+ const NOT_A_UNIT_SEGMENT = /^(examples?|samples?|fixtures?|templates?|docs?|demo|playground|benchmarks?)$/i;
118
+ // Examples, fixtures and docs are read where they sit in the LAYOUT — the
119
+ // first two folders — never deeper: Spring's own sample lives in the Java
120
+ // package `org.springframework.samples.petclinic`, and that is the code.
121
+ const isAside = (/** @type {string} */ p) => p.split("/").slice(0, 2).some((s) => NOT_A_UNIT_SEGMENT.test(s));
122
+ // A script is offered to `verify --init` only when it returns on its own.
123
+ const LONG_RUNNING = /(--watch(All)?\b|\bwatch\b|\s-w(\s|$)|--ui\b|--update\b|\b-u\b|\bserve\b|\bdev\b|\bstart\b|--open\b|\bnodemon\b|\bstorybook\b(?!\s+build))/;
124
+ // The flags that make a watching runner return (second audit, change 0233):
125
+ // `jest --watchAll` was seeded — `\bwatch\b` cannot see a word that goes on —
126
+ // while `vitest --watch=false` was refused for the word it switches off.
127
+ const RETURNS = /--(no-watch|watch(All)?=false|single-run)\b|\bCI=(true|1)\b/g;
128
+ // Runners that watch unless told not to: Angular's `ng test` and Create
129
+ // React App's `react-scripts test` and `karma start` never return on their own.
130
+ const WATCHES_BY_DEFAULT = /\bng\s+test\b|\breact-scripts\s+test\b|\bkarma\s+start\b/;
131
+
132
+ /** True when a package script keeps running until someone stops it. */
133
+ export function scriptLongRunning(body) {
134
+ const text = String(body);
135
+ const off = new RegExp(RETURNS.source).test(text);
136
+ if (WATCHES_BY_DEFAULT.test(text) && !off) return true;
137
+ // Told not to watch: the runner itself (`karma start`) is no longer a server.
138
+ return LONG_RUNNING.test(text.replace(RETURNS, " ").replace(new RegExp(WATCHES_BY_DEFAULT.source, "g"), " "));
139
+ }
140
+
141
+ function walk(root) {
142
+ const files = [];
143
+ const skipped = new Set();
144
+ let truncated = false;
145
+ const stack = [""];
146
+ while (stack.length && !truncated) {
147
+ const rel = stack.pop();
148
+ let entries;
149
+ try {
150
+ entries = readdirSync(path.join(root, rel), { withFileTypes: true });
151
+ } catch {
152
+ continue;
153
+ }
154
+ for (const e of entries) {
155
+ const r = rel ? `${rel}/${e.name}` : e.name;
156
+ if (e.isDirectory()) {
157
+ if (SKIP_DIRS.has(e.name)) { if (e.name !== ".git") skipped.add(e.name); }
158
+ else stack.push(r);
159
+ } else if (e.isFile()) {
160
+ files.push(r);
161
+ if (files.length >= MAX_FILES) {
162
+ truncated = true;
163
+ break;
164
+ }
165
+ }
166
+ }
167
+ }
168
+ return { files: files.sort(), truncated, skipped: [...skipped].sort() };
169
+ }
170
+
171
+ function readJson(p) {
172
+ try {
173
+ return JSON.parse(read(p));
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ function readText(root, rel) {
180
+ try {
181
+ return read(path.join(root, rel));
182
+ } catch {
183
+ return "";
184
+ }
185
+ }
186
+
187
+ function signalsFrom(names, table) {
188
+ const out = new Set();
189
+ for (const n of names) {
190
+ const key = String(n).toLowerCase();
191
+ for (const [dep, label] of Object.entries(table)) {
192
+ // Exact names only (a scope prefix aside): `express` must not read as
193
+ // `expo`, nor `nextjs-toolbox` as Next.js.
194
+ if (key === dep || key.endsWith("/" + dep)) out.add(label);
195
+ }
196
+ }
197
+ return [...out];
198
+ }
199
+
200
+ /** Every `KEY=` name in an env example file — names only, never a value. */
201
+ export function envNames(text) {
202
+ return String(text).split(/\r?\n/)
203
+ .map((l) => l.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/)?.[1])
204
+ .filter(Boolean);
205
+ }
206
+
207
+ function readmeSummary(root) {
208
+ for (const name of ["README.md", "readme.md", "README.MD", "Readme.md", "README.rst", "README"]) {
209
+ const p = path.join(root, name);
210
+ if (!isFile(p)) continue;
211
+ const lines = read(p).split(/\r?\n/);
212
+ const title = lines.find((l) => /^#\s+/.test(l))?.replace(/^#\s+/, "").trim() ?? "";
213
+ const para = [];
214
+ let started = false;
215
+ for (const l of lines) {
216
+ if (/^#/.test(l) || /^\s*(<|!\[|\[!\[)/.test(l)) {
217
+ if (started) break;
218
+ continue;
219
+ }
220
+ if (l.trim() === "") {
221
+ if (started) break;
222
+ continue;
223
+ }
224
+ started = true;
225
+ para.push(l.trim());
226
+ }
227
+ return { file: name, title, summary: para.join(" ").slice(0, 400) };
228
+ }
229
+ return null;
230
+ }
231
+
232
+ function gitFacts(root) {
233
+ const dates = gitLines(root, ["log", "--format=%cs"]);
234
+ if (!dates || dates.length === 0) return null;
235
+ const shallow = (gitLines(root, ["rev-parse", "--is-shallow-repository"]) ?? [])[0] === "true";
236
+ return { commits: dates.length, first: dates[dates.length - 1], last: dates[0], shallow };
237
+ }
238
+
239
+ const dirOf = (/** @type {string} */ f) => (f.includes("/") ? f.slice(0, f.lastIndexOf("/")) : "");
240
+ const baseOf = (/** @type {string} */ f) => (f.includes("/") ? f.slice(f.lastIndexOf("/") + 1) : f);
241
+ const depth = (/** @type {string} */ d) => (d === "" ? 0 : d.split("/").length);
242
+ const under = (/** @type {string} */ f, /** @type {string} */ dir) => dir === "" || f === dir || f.startsWith(dir + "/");
243
+ const shown = (/** @type {string} */ d) => (d === "" ? "." : `${d}/`);
244
+
245
+ /** The package manager a Node unit runs its scripts with. */
246
+ function nodeRunner(root, unitDir, pkg, rootPkg) {
247
+ const declared = String(pkg?.packageManager ?? rootPkg?.packageManager ?? "").split("@")[0];
248
+ if (["pnpm", "yarn", "npm", "bun"].includes(declared)) return declared;
249
+ for (const dir of [unitDir, ""]) {
250
+ const at = (/** @type {string} */ f) => isFile(path.join(root, dir, f));
251
+ if (at("bun.lock") || at("bun.lockb")) return "bun";
252
+ if (at("pnpm-lock.yaml")) return "pnpm";
253
+ if (at("yarn.lock")) return "yarn";
254
+ if (at("package-lock.json")) return "npm";
255
+ }
256
+ return "npm";
257
+ }
258
+
259
+ /** Names of the dependencies a Python manifest declares (a lexical reading). */
260
+ function pythonDeps(text) {
261
+ const names = new Set();
262
+ for (const m of String(text).toLowerCase().matchAll(/(?:^|["'\s,[])([a-z][a-z0-9_.-]*)\s*(?:\[[^\]]*\])?\s*(?:[<>=~!;]|["',\]\n]|$)/gm)) {
263
+ names.add(m[1].replace(/_/g, "-"));
264
+ }
265
+ return [...names];
266
+ }
267
+
268
+ function unitName(kind, root, dir, manifests) {
269
+ const at = (/** @type {string} */ f) => readText(root, dir ? `${dir}/${f}` : f);
270
+ if (kind === "node") return readJson(path.join(root, dir, "package.json"))?.name ?? null;
271
+ if (kind === "python") return at("pyproject.toml").match(/^\s*name\s*=\s*["']([^"']+)["']/m)?.[1] ?? null;
272
+ if (kind === "go") return at("go.mod").match(/^module\s+(\S+)/m)?.[1] ?? null;
273
+ if (kind === "rust") return at("Cargo.toml").match(/^\s*name\s*=\s*"([^"]+)"/m)?.[1] ?? null;
274
+ if (kind === "jvm") {
275
+ // The project's own artifactId — the first one belongs to <parent> when
276
+ // there is one, and naming a Spring app "spring-boot-starter-parent" is
277
+ // the distortion this inventory exists to avoid.
278
+ const pom = at("pom.xml").replace(/<parent>[\s\S]*?<\/parent>/, "").replace(/<dependencies>[\s\S]*$/, "");
279
+ return pom.match(/<artifactId>([^<]+)<\/artifactId>/)?.[1]
280
+ ?? at("settings.gradle").match(/rootProject\.name\s*=\s*['"]([^'"]+)['"]/)?.[1]
281
+ ?? at("settings.gradle.kts").match(/rootProject\.name\s*=\s*"([^"]+)"/)?.[1] ?? null;
282
+ }
283
+ if (kind === "dotnet") return manifests.find((m) => /\.(cs|fs)proj$/.test(m))?.replace(/\.(cs|fs)proj$/, "") ?? null;
284
+ if (kind === "dart") return at("pubspec.yaml").match(/^name:\s*(\S+)/m)?.[1] ?? null;
285
+ return null;
286
+ }
287
+
288
+ /**
289
+ * Every directory that declares its own build — a unit — with what its
290
+ * manifest says. The root is a unit too when it holds a manifest.
291
+ */
292
+ function readUnits(root, files) {
293
+ /** @type {Map<string, Set<string>>} */
294
+ const byDir = new Map();
295
+ /** @type {Map<string, string[]>} */
296
+ const manifestsOf = new Map();
297
+ for (const f of files) {
298
+ const dir = dirOf(f);
299
+ if (depth(dir) > MAX_UNIT_DEPTH) continue;
300
+ const kind = MANIFESTS.find(([re]) => re.test(baseOf(f)))?.[1];
301
+ if (!kind) continue;
302
+ if (!byDir.has(dir)) byDir.set(dir, new Set());
303
+ byDir.get(dir).add(kind);
304
+ manifestsOf.set(dir, [...(manifestsOf.get(dir) ?? []), baseOf(f)]);
305
+ }
306
+ const rootPkg = isFile(path.join(root, "package.json")) ? readJson(path.join(root, "package.json")) : null;
307
+ const units = [];
308
+ for (const dir of [...byDir.keys()].sort((a, b) => depth(a) - depth(b) || a.localeCompare(b))) {
309
+ const kinds = [...byDir.get(dir)].sort();
310
+ const manifests = manifestsOf.get(dir).sort();
311
+ const signals = new Set();
312
+ let runner = null;
313
+ let scripts = {};
314
+ for (const kind of kinds) {
315
+ if (kind === "node") {
316
+ const pkg = readJson(path.join(root, dir, "package.json")) ?? {};
317
+ const deps = [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {}), ...Object.keys(pkg.peerDependencies ?? {})];
318
+ for (const s of signalsFrom(deps, JS_SIGNALS)) signals.add(s);
319
+ if (isFile(path.join(root, dir, "tsconfig.json"))) signals.add("TypeScript");
320
+ runner = nodeRunner(root, dir, pkg, rootPkg);
321
+ scripts = pkg.scripts ?? {};
322
+ } else if (kind === "python") {
323
+ const text = manifests.filter((m) => MANIFESTS.some(([re, k]) => k === "python" && re.test(m)))
324
+ .map((m) => readText(root, dir ? `${dir}/${m}` : m)).join("\n");
325
+ for (const s of signalsFrom(pythonDeps(text).filter((n) => PY_SIGNALS[n]), PY_SIGNALS)) signals.add(s);
326
+ } else if (kind === "jvm") {
327
+ const text = manifests.filter((m) => /^(pom\.xml|build\.gradle(\.kts)?)$/.test(m))
328
+ .map((m) => readText(root, dir ? `${dir}/${m}` : m)).join("\n").toLowerCase();
329
+ for (const [dep, label] of Object.entries(JVM_SIGNALS)) if (text.includes(dep)) signals.add(label);
330
+ } else if (kind === "dotnet") {
331
+ const text = manifests.filter((m) => /\.(cs|fs)proj$/.test(m)).map((m) => readText(root, dir ? `${dir}/${m}` : m)).join("\n");
332
+ for (const [dep, label] of Object.entries(DOTNET_SIGNALS)) if (text.includes(`Include="${dep}`)) signals.add(label);
333
+ if (/Sdk="Microsoft\.NET\.Sdk\.Web"/.test(text)) signals.add("ASP.NET Core");
334
+ }
335
+ }
336
+ units.push({
337
+ dir, kinds, manifests, name: unitName(kinds[0], root, dir, manifests),
338
+ signals: [...signals].sort(), runner, scripts,
339
+ // A project deep inside a test folder is a fixture (Django keeps one
340
+ // five levels under tests/); a test project at its top is a unit.
341
+ example: isAside(dir) || (TEST_ROOTS.has(dir.split("/")[0]) && depth(dir) > 2),
342
+ });
343
+ }
344
+ return units;
345
+ }
346
+
347
+ /** The unit a file belongs to: the deepest unit directory above it. */
348
+ function unitOf(units, f) {
349
+ let best = null;
350
+ for (const u of units) if (under(f, u.dir) && (best === null || depth(u.dir) > depth(best.dir))) best = u;
351
+ return best;
352
+ }
353
+
354
+ function workspaceTools(root, files) {
355
+ const out = [];
356
+ const rootPkg = isFile(path.join(root, "package.json")) ? readJson(path.join(root, "package.json")) : null;
357
+ if (rootPkg?.workspaces) out.push({ tool: "npm/yarn workspaces", file: "package.json" });
358
+ for (const [file, tool] of [["pnpm-workspace.yaml", "pnpm workspaces"], ["turbo.json", "Turborepo"], ["nx.json", "Nx"],
359
+ ["lerna.json", "Lerna"], ["rush.json", "Rush"], ["go.work", "Go workspace"]]) {
360
+ if (isFile(path.join(root, file))) out.push({ tool, file });
361
+ }
362
+ // A settings file names the root project; only an `include` makes it multi-project.
363
+ for (const file of ["settings.gradle", "settings.gradle.kts"]) {
364
+ if (/^\s*include\b/m.test(readText(root, file))) out.push({ tool: "Gradle multi-project", file });
365
+ }
366
+ if (/^\s*\[workspace\]/m.test(readText(root, "Cargo.toml"))) out.push({ tool: "Cargo workspace", file: "Cargo.toml" });
367
+ if (/<modules>/.test(readText(root, "pom.xml"))) out.push({ tool: "Maven multi-module", file: "pom.xml" });
368
+ for (const f of files.filter((x) => !x.includes("/") && /\.slnx?$/.test(x))) out.push({ tool: ".NET solution", file: f });
369
+ return out;
370
+ }
371
+
372
+ /** Commands a unit's build declares, each marked safe to seed or not. */
373
+ function unitCommands(root, unit, units, workspaces, files) {
374
+ const at = (/** @type {string} */ f) => isFile(path.join(root, unit.dir, f));
375
+ const cwd = unit.dir || undefined;
376
+ const tag = unit.dir ? ` (${unit.dir})` : "";
377
+ /** @type {Array<{name: string, command: string, from: string, cwd?: string, seed: boolean, why?: string}>} */
378
+ const out = [];
379
+ const nested = (/** @type {string} */ kind) => units.some((u) => u !== unit && u.kinds.includes(kind) && under(unit.dir, u.dir) && u.dir !== unit.dir);
380
+ for (const kind of unit.kinds) {
381
+ if (kind === "node") {
382
+ const ws = unit.dir === "" ? false : units.some((u) => u.dir === "" && u.kinds.includes("node") && Object.keys(u.scripts).length > 0);
383
+ for (const [name, body] of Object.entries(unit.scripts ?? {})) {
384
+ const canonical = ["typecheck", "lint", "test", "build"].includes(name);
385
+ if (!canonical && !/^(test|e2e|lint|typecheck|check)(:|$)/.test(name)) continue;
386
+ const longRunning = scriptLongRunning(body) || /(:watch|:ui|:update|:dev)$/.test(name);
387
+ // `npm init` writes this; it fails by design and tests nothing.
388
+ const placeholder = /no test specified/i.test(String(body));
389
+ out.push({
390
+ name: `${name}${tag}`, command: `${unit.runner} run ${name}`, cwd,
391
+ from: `${unit.dir ? unit.dir + "/" : ""}package.json scripts (${String(body).slice(0, 60)})`,
392
+ seed: canonical && !longRunning && !ws && !placeholder,
393
+ why: placeholder ? "npm's placeholder — no test declared" : longRunning ? "long-running or interactive"
394
+ : !canonical ? "an extra script" : ws ? "the root workspace runs it" : undefined,
395
+ });
396
+ }
397
+ } else if (kind === "python") {
398
+ const own = files.filter((f) => unitOf(units, f) === unit);
399
+ const hasTests = own.some((f) => /(^|\/)(test_[^/]*|[^/]*_test)\.py$/.test(f));
400
+ if (!hasTests) continue; // pytest declared with nothing to run is not a gate
401
+ // Seed the runner the project DECLARES. Test files alone say nothing
402
+ // about how they run: Django's run through tests/runtests.py.
403
+ const pytestDeclared = unit.signals.includes("pytest") || at("pytest.ini") || at("conftest.py")
404
+ || /\[tool\.pytest/.test(readText(root, unit.dir ? `${unit.dir}/pyproject.toml` : "pyproject.toml"))
405
+ || /\[tool:pytest\]/.test(readText(root, unit.dir ? `${unit.dir}/setup.cfg` : "setup.cfg"));
406
+ const lock = (/** @type {string} */ f) => at(f) || isFile(path.join(root, f));
407
+ const runner = lock("uv.lock") ? "uv run " : lock("poetry.lock") ? "poetry run " : lock("Pipfile.lock") ? "pipenv run " : "";
408
+ const runtests = own.find((f) => baseOf(f) === "runtests.py");
409
+ if (pytestDeclared) {
410
+ out.push({ name: `test${tag}`, command: `${runner}pytest`, cwd, from: `${unit.dir ? unit.dir + "/" : ""}${unit.manifests.join(", ")}`, seed: true });
411
+ } else {
412
+ out.push({ name: `test${tag}`, command: runtests ? `python ${unit.dir ? runtests.slice(unit.dir.length + 1) : runtests}` : "(no test runner declared)",
413
+ cwd, from: `${unit.dir ? unit.dir + "/" : ""}${unit.manifests.join(", ")}`, seed: false,
414
+ why: runtests ? "a test script, not a declared runner — confirm how it is run" : "test files, but no test runner declared — ask the human" });
415
+ }
416
+ } else if (kind === "jvm") {
417
+ if (nested("jvm") && units.some((u) => u.kinds.includes("jvm") && u.dir !== unit.dir && under(unit.dir, u.dir))) continue;
418
+ const parentBuilds = units.some((u) => u.kinds.includes("jvm") && u.dir !== unit.dir && under(unit.dir, u.dir));
419
+ if (parentBuilds) continue;
420
+ const maven = unit.manifests.includes("pom.xml");
421
+ // A wrapper committed without its execute bit runs through sh — the
422
+ // Online Boutique's `gradlew` is one, and `./gradlew` fails to start.
423
+ const wrap = (/** @type {string} */ f) => {
424
+ try {
425
+ return (statSync(path.join(root, unit.dir, f)).mode & 0o111) || process.platform === "win32" ? `./${f}` : `sh ./${f}`;
426
+ } catch {
427
+ return `./${f}`;
428
+ }
429
+ };
430
+ const wrapper = maven ? (at("mvnw") ? wrap("mvnw") : "mvn") : (at("gradlew") ? wrap("gradlew") : "gradle");
431
+ out.push({ name: `test${tag}`, command: maven ? `${wrapper} -q test` : `${wrapper} test`, cwd, from: `${unit.dir ? unit.dir + "/" : ""}${maven ? "pom.xml" : unit.manifests.find((m) => m.startsWith("build.gradle"))}`, seed: true });
432
+ } else if (kind === "go") {
433
+ out.push({ name: `test${tag}`, command: "go test ./...", cwd, from: `${unit.dir ? unit.dir + "/" : ""}go.mod`, seed: true });
434
+ } else if (kind === "rust") {
435
+ if (units.some((u) => u.kinds.includes("rust") && u.dir !== unit.dir && under(unit.dir, u.dir))) continue;
436
+ out.push({ name: `test${tag}`, command: "cargo test", cwd, from: `${unit.dir ? unit.dir + "/" : ""}Cargo.toml`, seed: true });
437
+ } else if (kind === "dotnet") {
438
+ const solution = workspaces.find((w) => w.tool === ".NET solution");
439
+ if (solution) continue; // one solution-wide command, added once below
440
+ if (!unit.manifests.some((m) => /tests?\.(cs|fs)proj$/i.test(m))) continue;
441
+ out.push({ name: `test${tag}`, command: "dotnet test", cwd, from: `${unit.dir ? unit.dir + "/" : ""}${unit.manifests.join(", ")}`, seed: true });
442
+ } else if (kind === "dart") {
443
+ out.push({ name: `test${tag}`, command: "flutter test", cwd, from: `${unit.dir ? unit.dir + "/" : ""}pubspec.yaml`, seed: true });
444
+ }
445
+ }
446
+ return out;
447
+ }
448
+
449
+ /** Feature folders of a JVM source tree: the segment after the common package. */
450
+ function jvmAreas(unitFiles, unitDir) {
451
+ const out = new Map();
452
+ for (const lang of ["java", "kotlin"]) {
453
+ const prefix = `${unitDir ? unitDir + "/" : ""}src/main/${lang}/`;
454
+ const pkgs = unitFiles.filter((f) => f.startsWith(prefix)).map((f) => dirOf(f.slice(prefix.length)).split("/").filter(Boolean));
455
+ if (pkgs.length === 0) continue;
456
+ let common = pkgs[0];
457
+ for (const p of pkgs) {
458
+ let i = 0;
459
+ while (i < common.length && i < p.length && common[i] === p[i]) i++;
460
+ common = common.slice(0, i);
461
+ }
462
+ for (const p of pkgs) {
463
+ const key = prefix + [...common, ...(p.length > common.length ? [p[common.length]] : [])].join("/");
464
+ out.set(key, (out.get(key) ?? 0) + 1);
465
+ }
466
+ }
467
+ return out;
468
+ }
469
+
470
+ /**
471
+ * The inventory of the codebase at `root`.
472
+ */
473
+ export function inventory(root) {
474
+ const { files, truncated, skipped } = walk(root);
475
+ const has = (rel) => files.includes(rel) || isDir(path.join(root, rel));
476
+ const units = readUnits(root, files);
477
+ const workspaces = workspaceTools(root, files);
478
+
479
+ // Languages, counted by file extension — what is actually written here.
480
+ const languages = new Map();
481
+ for (const f of files) {
482
+ const ext = baseOf(f).includes(".") ? baseOf(f).split(".").pop().toLowerCase() : "";
483
+ const lang = LANG_BY_EXT[ext];
484
+ if (lang) languages.set(lang, (languages.get(lang) ?? 0) + 1);
485
+ }
486
+
487
+ const stack = new Set();
488
+ const signals = new Set();
489
+ for (const u of units.filter((x) => !x.example)) {
490
+ for (const k of u.kinds) stack.add(k === "node" && u.signals.includes("TypeScript") ? "Node.js / TypeScript" : KIND_LABEL[k]);
491
+ for (const s of u.signals) signals.add(s);
492
+ }
493
+ if (stack.has("Node.js / TypeScript")) stack.delete("Node.js");
494
+ if (isDir(path.join(root, "android"))) signals.add("Android project");
495
+ if (isDir(path.join(root, "ios"))) signals.add("iOS project");
496
+ if (files.some((f) => f.endsWith(".tf"))) signals.add("Terraform");
497
+
498
+ // Commands: every unit's, seeded only when they return on their own.
499
+ const commands = [];
500
+ for (const u of units.filter((x) => !x.example)) commands.push(...unitCommands(root, u, units, workspaces, files));
501
+ const solution = workspaces.find((w) => w.tool === ".NET solution");
502
+ if (solution) commands.push({ name: "test", command: `dotnet test ${solution.file}`, from: solution.file, cwd: undefined, seed: true, why: undefined });
503
+ const checks = commands.filter((c) => c.seed).map(({ name, command, from, cwd }) => ({ name, command, from, ...(cwd ? { cwd } : {}) }));
504
+
505
+ const testFiles = files.filter((f) => TEST_FILE.test(baseOf(f)) || /(^|\/)(cypress|e2e)\/.+\.[cm]?[jt]sx?$/.test(f));
506
+ const testDirs = new Map();
507
+ for (const f of testFiles) {
508
+ const parts = f.split("/");
509
+ const dir = parts.length > 1 ? parts.slice(0, Math.min(2, parts.length - 1)).join("/") : ".";
510
+ testDirs.set(dir, (testDirs.get(dir) ?? 0) + 1);
511
+ }
512
+
513
+ // Candidate AREAS: folders where behaviour usually lives, read inside each
514
+ // unit — the unit itself when it is one of several, the folders one level
515
+ // under its conventional roots, a JVM tree's feature packages. A hint from
516
+ // the layout, never a capability by itself.
517
+ const areas = new Map();
518
+ const realUnits = units.filter((u) => !u.example);
519
+ for (const u of realUnits) {
520
+ if (u.dir !== "" && realUnits.length > 1 && !TEST_DIR.test(u.dir)) {
521
+ const n = files.filter((f) => under(f, u.dir)).length;
522
+ areas.set(`${u.dir}`, n);
523
+ }
524
+ }
525
+ for (const f of files) {
526
+ const u = unitOf(realUnits, f);
527
+ const base = u ? u.dir : "";
528
+ const rel = base ? f.slice(base.length + 1) : f;
529
+ const parts = rel.split("/");
530
+ if (parts.length < 3 || !AREA_ROOTS.includes(parts[0]) || TEST_ROOTS.has(parts[1]) || isAside(f)) continue;
531
+ if (/^(src|app)\/(main|test)$/.test(`${parts[0]}/${parts[1]}`)) continue; // JVM layout, read below
532
+ const key = `${base ? base + "/" : ""}${parts[0]}/${parts[1]}`;
533
+ // Already a unit, or the folder above units (a container, not an area).
534
+ if (realUnits.some((x) => x.dir === key || under(x.dir, key))) continue;
535
+ areas.set(key, (areas.get(key) ?? 0) + 1);
536
+ }
537
+ for (const u of realUnits.filter((x) => x.kinds.includes("jvm"))) {
538
+ for (const [k, n] of jvmAreas(files.filter((f) => under(f, u.dir)), u.dir)) areas.set(k, n);
539
+ }
540
+
541
+ // Entry points: where a reader starts.
542
+ const ENTRY = /^(main\.go|Program\.cs|Startup\.cs|manage\.py|wsgi\.py|asgi\.py|main\.dart|config\.ru|App\.(tsx|jsx|vue|svelte)|MainActivity\.(kt|java)|AppDelegate\.swift)$/;
543
+ const UNIT_ENTRY = /^((src\/|app\/)?(main|index|server|app)\.([cm]?[jt]sx?|py)|src\/app\/layout\.(tsx|jsx)|pages\/_app\.(tsx|jsx)|app\/layout\.(tsx|jsx))$/;
544
+ const entryPoints = [];
545
+ for (const f of files) {
546
+ if (isAside(f) || TEST_DIR.test(f)) continue;
547
+ const u = unitOf(realUnits, f);
548
+ const rel = u && u.dir ? f.slice(u.dir.length + 1) : f;
549
+ if (ENTRY.test(baseOf(f)) || UNIT_ENTRY.test(rel) || /^[A-Z]\w*Application\.(java|kt)$/.test(baseOf(f))) entryPoints.push(f);
550
+ }
551
+
552
+ // Interfaces a capability or a contract is drawn from.
553
+ const isSpecFile = (/** @type {string} */ f) => /(^|\/)[^/]*(openapi|swagger)[^/]*\.(ya?ml|json)$/i.test(f);
554
+ const interfaces = {
555
+ http: files.filter(isSpecFile),
556
+ grpc: files.filter((f) => f.endsWith(".proto")),
557
+ graphql: files.filter((f) => /\.(graphql|gql)$/.test(f)),
558
+ events: files.filter((f) => /(^|\/)asyncapi[^/]*\.(ya?ml|json)$/i.test(f)),
559
+ routeDirs: [...new Set(files.filter((f) => /\.(py|[cm]?[jt]sx?|go|java|kt|cs|rb|php)$/.test(f) && !TEST_DIR.test(f) && !isAside(f))
560
+ .map(dirOf).filter((d) => /(^|\/)(routes|routers|controllers|handlers|endpoints|resolvers|api)$/i.test(d)))].sort(),
561
+ };
562
+
563
+ // Data: schemas and migrations — the shape of what the system stores.
564
+ const data = {
565
+ migrations: [...new Set(files.map(dirOf).filter((d) => /(^|\/)(migrations?|alembic\/versions|db\/migrate|db\/migration)$/i.test(d)))].sort()
566
+ .map((d) => [d, files.filter((f) => dirOf(f) === d).length]),
567
+ schemas: files.filter((f) => /(^|\/)(schema\.prisma|drizzle\.config\.[cm]?[jt]s|schema\.sql|.*\.dbml)$/.test(f)),
568
+ sqlFiles: files.filter((f) => f.endsWith(".sql")).length,
569
+ };
570
+
571
+ const ci = CI_FILES.filter(([p]) => has(p)).map(([p, label]) => ({ path: p, label, files: isDir(path.join(root, p)) ? files.filter((f) => under(f, p)).length : 1 }));
572
+ const dockerfiles = files.filter((f) => /^(Dockerfile|Containerfile)(\.[\w-]+)?$|\.Dockerfile$/.test(baseOf(f)));
573
+ const infra = [];
574
+ if (dockerfiles.length) infra.push({ label: "Docker", paths: dockerfiles });
575
+ const compose = files.filter((f) => depth(dirOf(f)) <= 2 && /^(docker-)?compose(\.[\w-]+)?\.ya?ml$/.test(baseOf(f)));
576
+ if (compose.length) infra.push({ label: "Docker Compose", paths: compose });
577
+ const helm = files.filter((f) => baseOf(f) === "Chart.yaml").map(dirOf);
578
+ if (helm.length) infra.push({ label: "Helm charts", paths: helm });
579
+ const kustomize = files.filter((f) => /^kustomization\.ya?ml$/.test(baseOf(f))).map(dirOf);
580
+ if (kustomize.length) infra.push({ label: "Kustomize", paths: kustomize.map((d) => d || ".") });
581
+ const k8s = [...new Set(files.map((f) => f.split("/").slice(0, 2).join("/")).filter((d) => /(^|\/)(k8s|kubernetes|kubernetes-manifests|manifests)$/.test(d)))];
582
+ if (k8s.length) infra.push({ label: "Kubernetes manifests", paths: k8s });
583
+ const tfDirs = [...new Set(files.filter((f) => f.endsWith(".tf")).map(dirOf))];
584
+ if (tfDirs.length) infra.push({ label: "Terraform", paths: tfDirs.map((d) => d || ".") });
585
+ for (const [file, label] of [["skaffold.yaml", "Skaffold"], ["serverless.yml", "Serverless"], ["Procfile", "Procfile"],
586
+ ["fly.toml", "Fly.io"], ["vercel.json", "Vercel"], ["netlify.toml", "Netlify"], ["render.yaml", "Render"], ["app.yaml", "App Engine"]]) {
587
+ const found = files.filter((f) => baseOf(f) === file && depth(dirOf(f)) <= 2);
588
+ if (found.length) infra.push({ label, paths: found });
589
+ }
590
+
591
+ const env = files.filter((f) => ENV_EXAMPLE.test(baseOf(f)) && depth(dirOf(f)) <= 3)
592
+ .map((f) => ({ file: f, names: envNames(readText(root, f)) }));
593
+ const envReal = files.filter((f) => ENV_REAL.test(baseOf(f)) && !ENV_EXAMPLE.test(baseOf(f)) && depth(dirOf(f)) <= 3);
594
+
595
+ // What already explains the project: read it before writing a word.
596
+ const docs = {
597
+ readmes: files.filter((f) => /^readme(\.\w+)?$/i.test(baseOf(f)) && f.includes("/") && !isAside(dirOf(f))).slice(0, 30),
598
+ docDirs: [...new Set(files.map((f) => f.split("/")[0]).filter((d) => /^(docs?|documentation|dev-docs|wiki|adr)$/i.test(d)))],
599
+ adrs: [...new Set(files.filter((f) => /(^|\/)(adrs?|decisions|architecture\/decisions)\/[^/]+\.(md|adoc|rst)$/i.test(f)).map(dirOf))],
600
+ files: files.filter((f) => /^(CHANGELOG|CONTRIBUTING|CODEOWNERS|ARCHITECTURE|SECURITY)(\.md)?$/i.test(baseOf(f)) && depth(dirOf(f)) <= 1),
601
+ agents: files.filter((f) => /^(AGENTS\.md|CLAUDE\.md|GEMINI\.md|\.cursorrules|copilot-instructions\.md)$/.test(baseOf(f)) || /^\.cursor\/rules\//.test(f) || /^\.(claude|agents)\/(skills|agents|commands)\//.test(f)),
602
+ };
603
+
604
+ const topDirs = new Map();
605
+ for (const f of files) {
606
+ const top = f.includes("/") ? f.split("/")[0] : null;
607
+ if (top) topDirs.set(top, (topDirs.get(top) ?? 0) + 1);
608
+ }
609
+
610
+ // Code no unit builds — none above it, or one that builds another
611
+ // language (a Python script under a Node root): a fact to ask about.
612
+ const KIND_OF_EXT = { py: "python", js: "node", mjs: "node", cjs: "node", ts: "node", tsx: "node", jsx: "node",
613
+ go: "go", java: "jvm", kt: "jvm", cs: "dotnet", rb: "ruby", php: "php", rs: "rust", swift: "swift", dart: "dart", ex: "elixir", exs: "elixir" };
614
+ const orphanCode = new Map();
615
+ for (const f of files) {
616
+ const kind = KIND_OF_EXT[baseOf(f).split(".").pop() ?? ""];
617
+ if (!kind || isAside(f) || !f.includes("/") || TEST_DIR.test(f)) continue;
618
+ const owner = unitOf(units, f);
619
+ if (owner && owner.kinds.includes(kind)) continue;
620
+ // A JS/TS file beside any JS-building unit above it is that unit's.
621
+ if (kind === "node" && units.some((u) => u.kinds.includes("node") && under(f, u.dir))) continue;
622
+ const where = dirOf(f).split("/").slice(0, 3).join("/");
623
+ orphanCode.set(where, (orphanCode.get(where) ?? 0) + 1);
624
+ }
625
+
626
+ const git = gitFacts(root);
627
+ return {
628
+ root,
629
+ files: files.length,
630
+ truncated,
631
+ skipped,
632
+ readme: readmeSummary(root),
633
+ languages: [...languages.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 12),
634
+ stack: [...stack].sort(),
635
+ signals: [...signals].sort(),
636
+ units: units.map(({ dir, kinds, name, signals: s, runner, example, manifests }) => ({
637
+ dir, kinds, name, signals: s, runner, example, manifests,
638
+ tests: testFiles.filter((f) => unitOf(units, f)?.dir === dir).length,
639
+ })),
640
+ workspaces,
641
+ checks,
642
+ otherCommands: commands.filter((c) => !c.seed),
643
+ tests: { files: testFiles.length, dirs: [...testDirs.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 12) },
644
+ entryPoints: entryPoints.slice(0, 40),
645
+ interfaces,
646
+ data,
647
+ ci,
648
+ infra,
649
+ env,
650
+ envReal,
651
+ docs,
652
+ areas: [...areas.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 40),
653
+ topDirs: [...topDirs.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 15),
654
+ orphanCode: [...orphanCode.entries()].sort((a, b) => b[1] - a[1]),
655
+ git,
656
+ };
657
+ }
658
+
659
+ const list = (/** @type {string[]} */ items, max = 12) =>
660
+ items.slice(0, max).map((x) => `\`${x}\``).join(", ") + (items.length > max ? `, … and ${items.length - max} more` : "");
661
+
662
+ /** The inventory as the Markdown body of an intake. */
663
+ export function inventoryMarkdown(inv, projectName) {
664
+ const L = [];
665
+ L.push(`Code inventory of ${projectName} — the FACTS an agent absorbs the project from.`);
666
+ L.push("Written by `doctrina intake --from-code`: manifests read, files counted, nothing interpreted.");
667
+ L.push("Every line names the file it was read from. Business intent is NOT in here:");
668
+ L.push("what the code cannot say goes to the human as a question, never into a spec.");
669
+ L.push("");
670
+ if (inv.readme) {
671
+ L.push(`## README (${inv.readme.file})`, "");
672
+ if (inv.readme.title) L.push(`- Title: ${inv.readme.title}`);
673
+ if (inv.readme.summary) L.push(`- Opening: ${inv.readme.summary}`);
674
+ L.push("");
675
+ }
676
+ L.push("## Stack", "");
677
+ L.push(`- Builds declared: ${inv.stack.length ? inv.stack.join(", ") : "none detected from manifests"}`);
678
+ L.push(`- Languages written (files by extension): ${inv.languages.length ? inv.languages.map(([l, n]) => `${l} ${n}`).join(", ") : "none recognised"}`);
679
+ L.push(`- Frameworks and tools (from the manifests' dependencies): ${inv.signals.length ? inv.signals.join(", ") : "none detected"}`);
680
+ L.push(`- Files scanned: ${inv.files}${inv.truncated ? ` (stopped at ${MAX_FILES})` : ""}`);
681
+ if (inv.git) {
682
+ L.push(inv.git.shallow
683
+ ? `- History: shallow clone — ${inv.git.commits} commit${inv.git.commits === 1 ? "" : "s"} fetched (${inv.git.first} → ${inv.git.last}); the project's real age and churn are not visible`
684
+ : `- History: ${inv.git.commits} commit${inv.git.commits === 1 ? "" : "s"}, ${inv.git.first} → ${inv.git.last}`);
685
+ }
686
+ L.push("");
687
+ const real = inv.units.filter((u) => !u.example);
688
+ if (real.length > 1 || inv.workspaces.length) {
689
+ L.push(`## Units (${real.length} directories that declare their own build)`, "");
690
+ if (inv.workspaces.length) L.push(`- Workspace tooling: ${inv.workspaces.map((w) => `${w.tool} (\`${w.file}\`)`).join(", ")}`);
691
+ for (const u of real.slice(0, 40)) {
692
+ const bits = [u.kinds.map((k) => KIND_LABEL[k]).join(" + "), u.name ? `name \`${u.name}\`` : null,
693
+ u.runner ? `runs with ${u.runner}` : null, u.signals.length ? u.signals.join(", ") : null,
694
+ `${u.tests} test file${u.tests === 1 ? "" : "s"}`].filter(Boolean);
695
+ L.push(`- \`${shown(u.dir)}\` — ${bits.join(" · ")} (${u.manifests.join(", ")})`);
696
+ }
697
+ if (real.length > 40) L.push(`- … and ${real.length - 40} more units`);
698
+ const examples = inv.units.filter((u) => u.example);
699
+ if (examples.length) L.push(`- Not counted as units (examples, fixtures, templates, docs): ${list(examples.map((u) => shown(u.dir)), 8)}`);
700
+ L.push("");
701
+ }
702
+ L.push("## Where the code is", "");
703
+ for (const [dir, n] of inv.topDirs) L.push(`- \`${dir}/\` — ${n} file${n === 1 ? "" : "s"}`);
704
+ if (inv.topDirs.length === 0) L.push("- (flat repository)");
705
+ L.push("");
706
+ L.push("## Candidate areas (folder layout — a hint to read, not a capability yet)", "");
707
+ for (const [dir, n] of inv.areas) L.push(`- \`${dir}/\` — ${n} file${n === 1 ? "" : "s"}`);
708
+ if (inv.areas.length === 0) L.push("- none under the conventional roots — derive areas from the top-level folders");
709
+ L.push("");
710
+ L.push("## Entry points (start reading here)", "");
711
+ for (const f of inv.entryPoints) L.push(`- \`${f}\``);
712
+ if (inv.entryPoints.length === 0) L.push("- none recognised by name — find them from the manifests (`main`, `bin`, scripts)");
713
+ L.push("");
714
+ const i = inv.interfaces;
715
+ L.push("## Interfaces (where the contracts between parts are written)", "");
716
+ if (i.http.length) L.push(`- HTTP / OpenAPI: ${list(i.http)}`);
717
+ if (i.grpc.length) L.push(`- gRPC / protobuf: ${list(i.grpc)}`);
718
+ if (i.graphql.length) L.push(`- GraphQL: ${list(i.graphql)}`);
719
+ if (i.events.length) L.push(`- Events / AsyncAPI: ${list(i.events)}`);
720
+ if (i.routeDirs.length) L.push(`- Route / controller folders: ${list(i.routeDirs, 15)}`);
721
+ if (!i.http.length && !i.grpc.length && !i.graphql.length && !i.events.length && !i.routeDirs.length) L.push("- none found by name");
722
+ L.push("");
723
+ L.push("## Data", "");
724
+ for (const [d, n] of inv.data.migrations) L.push(`- Migrations: \`${d}/\` — ${n} file${n === 1 ? "" : "s"}`);
725
+ if (inv.data.schemas.length) L.push(`- Schemas: ${list(inv.data.schemas)}`);
726
+ if (inv.data.sqlFiles) L.push(`- SQL files: ${inv.data.sqlFiles}`);
727
+ if (!inv.data.migrations.length && !inv.data.schemas.length && !inv.data.sqlFiles) L.push("- no migration, schema or SQL file found");
728
+ L.push("");
729
+ L.push("## Tests", "");
730
+ L.push(`- ${inv.tests.files} test file${inv.tests.files === 1 ? "" : "s"}`);
731
+ for (const [dir, n] of inv.tests.dirs) L.push(`- \`${dir}\` — ${n}`);
732
+ L.push("");
733
+ L.push("## Build and test commands", "");
734
+ L.push("Seeded into `doctrina verify --init` (each returns on its own):");
735
+ for (const ch of inv.checks) L.push(`- \`${ch.command}\`${ch.cwd ? ` in \`${ch.cwd}/\`` : ""} — ${ch.name}, from ${ch.from}`);
736
+ if (inv.checks.length === 0) L.push("- none detected — ask the human how this project is built and tested");
737
+ if (inv.otherCommands.length) {
738
+ L.push("", "Also declared, NOT seeded:");
739
+ for (const ch of inv.otherCommands.slice(0, 20)) L.push(`- \`${ch.command}\`${ch.cwd ? ` in \`${ch.cwd}/\`` : ""} — ${ch.why}, from ${ch.from}`);
740
+ if (inv.otherCommands.length > 20) L.push(`- … and ${inv.otherCommands.length - 20} more`);
741
+ }
742
+ L.push("");
743
+ L.push("## Delivery", "");
744
+ for (const c of inv.ci) L.push(`- CI: ${c.label} (\`${c.path}\`${c.files > 1 ? `, ${c.files} files` : ""})`);
745
+ for (const r of inv.infra) L.push(`- Runtime: ${r.label} — ${list(r.paths, 10)}`);
746
+ if (inv.ci.length + inv.infra.length === 0) L.push("- no CI or runtime manifest found");
747
+ L.push("");
748
+ L.push("## Configuration (names only — values are never read)", "");
749
+ for (const e of inv.env) L.push(`- \`${e.file}\`: ${e.names.length ? e.names.map((n) => `\`${n}\``).join(", ") : "(no KEY= lines)"}`);
750
+ if (inv.env.length === 0) L.push("- no env example file");
751
+ if (inv.envReal.length) L.push(`- Present and NOT read (may hold secrets): ${list(inv.envReal)}`);
752
+ L.push("");
753
+ const d = inv.docs;
754
+ L.push("## Documentation that already exists (read it before writing a spec)", "");
755
+ if (d.docDirs.length) L.push(`- Documentation folders: ${list(d.docDirs.map((x) => `${x}/`))}`);
756
+ if (d.adrs.length) L.push(`- Architecture decisions: ${list(d.adrs.map((x) => `${x}/`))} — candidates for \`doctrina decision new\`, cited, not re-decided`);
757
+ if (d.files.length) L.push(`- ${list(d.files)}`);
758
+ if (d.readmes.length) L.push(`- READMEs below the root: ${list(d.readmes, 15)}`);
759
+ if (d.agents.length) L.push(`- Agent instructions already written: ${list(d.agents, 10)}`);
760
+ if (!d.docDirs.length && !d.adrs.length && !d.files.length && !d.readmes.length && !d.agents.length) L.push(inv.readme ? "- none beyond the root README" : "- none found — the repository has no README either");
761
+ L.push("");
762
+ L.push("## What this inventory could not see", "");
763
+ L.push("- Why any rule exists, who the users are, what is out of scope — ask the human.");
764
+ L.push("- Runtime behaviour, data in databases, private packages, code generated at build time.");
765
+ if (inv.truncated) L.push(`- Everything after the first ${MAX_FILES} files: the scan stopped there.`);
766
+ if (inv.skipped.length) L.push(`- Folders skipped as generated or vendored: ${list(inv.skipped.map((x) => `${x}/`), 10)}`);
767
+ if (inv.git?.shallow) L.push("- The project's history: this clone is shallow.");
768
+ if (inv.orphanCode.length) L.push(`- How this code is built — no manifest above it: ${inv.orphanCode.map(([dir, n]) => `\`${dir}/\` (${n} files)`).join(", ")}`);
769
+ return L.join("\n") + "\n";
770
+ }