doctrina-cli 0.20.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 (57) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +10 -0
  4. package/src/commands/change.js +92 -14
  5. package/src/commands/clarify.js +1 -204
  6. package/src/commands/context.js +33 -2
  7. package/src/commands/contract.js +1 -0
  8. package/src/commands/delivery.js +135 -17
  9. package/src/commands/doctor.js +21 -1
  10. package/src/commands/handoff.js +11 -2
  11. package/src/commands/init.js +13 -1
  12. package/src/commands/intake.js +16 -5
  13. package/src/commands/metrics.js +15 -0
  14. package/src/commands/next.js +41 -6
  15. package/src/commands/prime.js +53 -4
  16. package/src/commands/review.js +39 -3
  17. package/src/commands/skill.js +2 -1
  18. package/src/commands/status.js +28 -0
  19. package/src/commands/upgrade.js +18 -0
  20. package/src/commands/validate.js +18 -0
  21. package/src/commands/verify.js +32 -4
  22. package/src/commands/work.js +43 -8
  23. package/src/commands/workspace.js +73 -63
  24. package/src/index.js +98 -62
  25. package/src/lib/actions.js +43 -3
  26. package/src/lib/adapters.js +63 -1
  27. package/src/lib/args.js +33 -1
  28. package/src/lib/clarify-scan.js +221 -0
  29. package/src/lib/code-inventory.js +576 -103
  30. package/src/lib/commands.js +8 -0
  31. package/src/lib/context-slice.js +34 -3
  32. package/src/lib/delivery.js +88 -10
  33. package/src/lib/docs-impact.js +36 -1
  34. package/src/lib/fs-ops.js +23 -0
  35. package/src/lib/intake-model.js +1 -1
  36. package/src/lib/json-out.js +9 -1
  37. package/src/lib/shell.js +45 -0
  38. package/src/lib/snapshot.js +5 -1
  39. package/src/lib/task-card.js +100 -7
  40. package/src/lib/views.js +3 -3
  41. package/src/lib/work-model.js +8 -2
  42. package/src/lib/workspace-agents.js +65 -18
  43. package/src/lib/workspace-guard.js +65 -0
  44. package/src/lib/workspace-locate.js +141 -0
  45. package/src/lib/workspace-tools.js +63 -16
  46. package/src/lib/workspace-view.js +391 -0
  47. package/src/lib/workspace.js +82 -130
  48. package/templates/AGENTS.md.template +1 -1
  49. package/templates/README.md +2 -1
  50. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  51. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  52. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  53. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  54. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  55. package/templates/playbooks/absorb.md.template +39 -16
  56. package/templates/playbooks/bootstrap.md.template +18 -6
  57. package/templates/playbooks/delivery.md.template +12 -6
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
- import { readdirSync } from "node:fs";
2
+ import { readdirSync, statSync } from "node:fs";
3
+ import process from "node:process";
3
4
  import path from "node:path";
4
5
  import { isDir, isFile, read } from "./fs-ops.js";
5
6
  import { gitLines } from "./git.js";
@@ -13,48 +14,133 @@ import { gitLines } from "./git.js";
13
14
  // half — reading the code it points at and writing what the system does
14
15
  // today — through the absorb playbook (ADR 0005: the CLI does no
15
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.
16
29
 
17
30
  const SKIP_DIRS = new Set([
18
31
  ".git", "node_modules", ".doctrina", "dist", "build", "out", "target", "vendor", "coverage",
19
- ".venv", "venv", "__pycache__", ".next", ".nuxt", ".gradle", ".idea", ".vscode", "bin", "obj",
20
- ".terraform", ".pytest_cache", ".mypy_cache", "Pods", ".dart_tool", ".expo",
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",
21
36
  ]);
22
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;
23
40
 
24
41
  // Dependency name → what it tells about the project.
25
42
  const JS_SIGNALS = {
26
43
  react: "React", next: "Next.js", vue: "Vue", nuxt: "Nuxt", "@angular/core": "Angular", svelte: "Svelte",
27
- "react-native": "React Native", expo: "Expo", express: "Express", fastify: "Fastify", "@nestjs/core": "NestJS",
28
- koa: "Koa", graphql: "GraphQL", prisma: "Prisma", typeorm: "TypeORM", sequelize: "Sequelize", mongoose: "Mongoose",
29
- jest: "Jest", vitest: "Vitest", mocha: "Mocha", "@playwright/test": "Playwright", playwright: "Playwright",
30
- cypress: "Cypress", "@cucumber/cucumber": "Cucumber", webdriverio: "WebdriverIO", "@testing-library/react": "Testing Library",
31
- storybook: "Storybook", "@storybook/react": "Storybook", typescript: "TypeScript", "k6": "k6",
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",
32
56
  };
33
57
  const PY_SIGNALS = {
34
- django: "Django", flask: "Flask", fastapi: "FastAPI", pytest: "pytest", behave: "Behave",
35
- "robotframework": "Robot Framework", selenium: "Selenium", playwright: "Playwright", requests: "requests",
36
- sqlalchemy: "SQLAlchemy", pandas: "pandas", pyspark: "PySpark", locust: "Locust", celery: "Celery",
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",
37
64
  };
38
65
  const JVM_SIGNALS = {
39
- "spring-boot": "Spring Boot", junit: "JUnit", "rest-assured": "REST Assured", cucumber: "Cucumber",
40
- testng: "TestNG", selenium: "Selenium", kotlin: "Kotlin", mockito: "Mockito",
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",
41
99
  };
42
100
 
43
- const TEST_FILE = /(\.(test|spec)\.[cm]?[jt]sx?$)|(^test_.*\.py$)|(_test\.(py|go)$)|(\.feature$)|(Tests?\.(java|kt|cs)$)|(\.robot$)|(_spec\.rb$)/;
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;
44
103
  const CI_FILES = [
45
104
  [".github/workflows", "GitHub Actions"], [".gitlab-ci.yml", "GitLab CI"], ["Jenkinsfile", "Jenkins"],
46
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"],
47
107
  ];
48
- const INFRA_FILES = [
49
- ["Dockerfile", "Docker"], ["docker-compose.yml", "Docker Compose"], ["docker-compose.yaml", "Docker Compose"],
50
- ["compose.yml", "Docker Compose"], ["helm", "Helm"], ["k8s", "Kubernetes manifests"], ["kubernetes", "Kubernetes manifests"],
51
- ["serverless.yml", "Serverless"], ["Procfile", "Procfile"],
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",
52
115
  ];
53
- const ENV_EXAMPLES = [".env.example", ".env.sample", ".env.template", ".env.dist"];
54
- const AREA_ROOTS = ["src", "app", "lib", "packages", "apps", "services", "modules", "features", "pages", "components", "tests", "test", "e2e", "cypress"];
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
+ }
55
140
 
56
141
  function walk(root) {
57
142
  const files = [];
143
+ const skipped = new Set();
58
144
  let truncated = false;
59
145
  const stack = [""];
60
146
  while (stack.length && !truncated) {
@@ -68,7 +154,8 @@ function walk(root) {
68
154
  for (const e of entries) {
69
155
  const r = rel ? `${rel}/${e.name}` : e.name;
70
156
  if (e.isDirectory()) {
71
- if (!SKIP_DIRS.has(e.name)) stack.push(r);
157
+ if (SKIP_DIRS.has(e.name)) { if (e.name !== ".git") skipped.add(e.name); }
158
+ else stack.push(r);
72
159
  } else if (e.isFile()) {
73
160
  files.push(r);
74
161
  if (files.length >= MAX_FILES) {
@@ -78,7 +165,7 @@ function walk(root) {
78
165
  }
79
166
  }
80
167
  }
81
- return { files: files.sort(), truncated };
168
+ return { files: files.sort(), truncated, skipped: [...skipped].sort() };
82
169
  }
83
170
 
84
171
  function readJson(p) {
@@ -89,6 +176,14 @@ function readJson(p) {
89
176
  }
90
177
  }
91
178
 
179
+ function readText(root, rel) {
180
+ try {
181
+ return read(path.join(root, rel));
182
+ } catch {
183
+ return "";
184
+ }
185
+ }
186
+
92
187
  function signalsFrom(names, table) {
93
188
  const out = new Set();
94
189
  for (const n of names) {
@@ -110,7 +205,7 @@ export function envNames(text) {
110
205
  }
111
206
 
112
207
  function readmeSummary(root) {
113
- for (const name of ["README.md", "readme.md", "README.MD", "README"]) {
208
+ for (const name of ["README.md", "readme.md", "README.MD", "Readme.md", "README.rst", "README"]) {
114
209
  const p = path.join(root, name);
115
210
  if (!isFile(p)) continue;
116
211
  const lines = read(p).split(/\r?\n/);
@@ -137,124 +232,440 @@ function readmeSummary(root) {
137
232
  function gitFacts(root) {
138
233
  const dates = gitLines(root, ["log", "--format=%cs"]);
139
234
  if (!dates || dates.length === 0) return null;
140
- return { commits: dates.length, first: dates[dates.length - 1], last: dates[0] };
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;
141
286
  }
142
287
 
143
288
  /**
144
- * The inventory of the codebase at `root`.
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.
145
291
  */
146
- export function inventory(root) {
147
- const { files, truncated } = walk(root);
148
- const has = (rel) => files.includes(rel) || isDir(path.join(root, rel));
149
- const stack = new Set();
150
- const signals = new Set();
151
- const checks = [];
152
-
153
- const pkg = isFile(path.join(root, "package.json")) ? readJson(path.join(root, "package.json")) : null;
154
- if (pkg) {
155
- stack.add(isFile(path.join(root, "tsconfig.json")) ? "Node.js / TypeScript" : "Node.js");
156
- const deps = [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})];
157
- for (const s of signalsFrom(deps, JS_SIGNALS)) signals.add(s);
158
- const runner = isFile(path.join(root, "pnpm-lock.yaml")) ? "pnpm" : isFile(path.join(root, "yarn.lock")) ? "yarn" : "npm";
159
- for (const name of ["typecheck", "lint", "test", "build"]) {
160
- if (pkg.scripts?.[name]) checks.push({ name, command: `${runner} run ${name}`, from: "package.json scripts" });
161
- }
162
- for (const [name, cmd] of Object.entries(pkg.scripts ?? {})) {
163
- if (/^(test:|e2e)/.test(name)) checks.push({ name, command: `${runner} run ${name}`, from: `package.json scripts (${String(cmd).slice(0, 60)})` });
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
+ }
164
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
+ });
165
343
  }
166
- const pyFiles = ["pyproject.toml", "requirements.txt", "requirements-dev.txt", "setup.py", "Pipfile"].filter((f) => isFile(path.join(root, f)));
167
- if (pyFiles.length) {
168
- stack.add("Python");
169
- const text = pyFiles.map((f) => read(path.join(root, f))).join("\n").toLowerCase();
170
- const names = text.match(/[a-z][a-z0-9_.-]+/g) ?? [];
171
- for (const s of signalsFrom([...new Set(names)].filter((n) => PY_SIGNALS[n]), PY_SIGNALS)) signals.add(s);
172
- if (signals.has("pytest")) checks.push({ name: "test", command: "pytest", from: pyFiles.join(", ") });
173
- if (signals.has("Behave")) checks.push({ name: "bdd", command: "behave", from: pyFiles.join(", ") });
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 });
174
361
  }
175
- const jvm = ["pom.xml", "build.gradle", "build.gradle.kts"].filter((f) => isFile(path.join(root, f)));
176
- if (jvm.length) {
177
- stack.add(jvm[0] === "pom.xml" ? "Java / Maven" : "JVM / Gradle");
178
- const text = jvm.map((f) => read(path.join(root, f))).join("\n").toLowerCase();
179
- for (const [dep, label] of Object.entries(JVM_SIGNALS)) if (text.includes(dep)) signals.add(label);
180
- checks.push(jvm[0] === "pom.xml"
181
- ? { name: "test", command: "mvn -q test", from: "pom.xml" }
182
- : { name: "test", command: isFile(path.join(root, "gradlew")) ? "./gradlew test" : "gradle test", from: jvm[0] });
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 });
183
365
  }
184
- if (isFile(path.join(root, "go.mod"))) {
185
- stack.add("Go");
186
- checks.push({ name: "test", command: "go test ./...", from: "go.mod" });
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
+ }
187
445
  }
188
- if (isFile(path.join(root, "Cargo.toml"))) {
189
- stack.add("Rust");
190
- checks.push({ name: "test", command: "cargo test", from: "Cargo.toml" });
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
+ }
191
466
  }
192
- if (files.some((f) => /\.(csproj|sln)$/.test(f))) {
193
- stack.add(".NET");
194
- checks.push({ name: "test", command: "dotnet test", from: "*.csproj" });
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);
195
485
  }
196
- if (isFile(path.join(root, "pubspec.yaml"))) {
197
- stack.add("Dart / Flutter");
198
- checks.push({ name: "test", command: "flutter test", from: "pubspec.yaml" });
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);
199
492
  }
200
- if (isFile(path.join(root, "Gemfile"))) stack.add("Ruby");
201
- if (isFile(path.join(root, "composer.json"))) stack.add("PHP");
493
+ if (stack.has("Node.js / TypeScript")) stack.delete("Node.js");
202
494
  if (isDir(path.join(root, "android"))) signals.add("Android project");
203
495
  if (isDir(path.join(root, "ios"))) signals.add("iOS project");
204
496
  if (files.some((f) => f.endsWith(".tf"))) signals.add("Terraform");
205
497
 
206
- const testFiles = files.filter((f) => TEST_FILE.test(path.basename(f)) || /(^|\/)(cypress|e2e)\/.+\.[cm]?[jt]sx?$/.test(f));
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));
207
506
  const testDirs = new Map();
208
507
  for (const f of testFiles) {
209
- const dir = f.includes("/") ? f.split("/").slice(0, Math.min(2, f.split("/").length - 1)).join("/") : ".";
508
+ const parts = f.split("/");
509
+ const dir = parts.length > 1 ? parts.slice(0, Math.min(2, parts.length - 1)).join("/") : ".";
210
510
  testDirs.set(dir, (testDirs.get(dir) ?? 0) + 1);
211
511
  }
212
- const ci = CI_FILES.filter(([p]) => has(p)).map(([p, label]) => ({ path: p, label }));
213
- const infra = INFRA_FILES.filter(([p]) => has(p)).map(([p, label]) => ({ path: p, label }));
214
- const env = [];
215
- for (const f of ENV_EXAMPLES) {
216
- if (isFile(path.join(root, f))) env.push({ file: f, names: envNames(read(path.join(root, f))) });
217
- }
218
512
 
219
- // Candidate AREAS: the folders one level under the conventional source
220
- // roots, with how many files each holds — where capabilities usually are.
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.
221
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
+ }
222
525
  for (const f of files) {
223
- const parts = f.split("/");
224
- if (parts.length < 3 || !AREA_ROOTS.includes(parts[0])) continue;
225
- const key = `${parts[0]}/${parts[1]}`;
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;
226
535
  areas.set(key, (areas.get(key) ?? 0) + 1);
227
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
+
228
604
  const topDirs = new Map();
229
605
  for (const f of files) {
230
606
  const top = f.includes("/") ? f.split("/")[0] : null;
231
607
  if (top) topDirs.set(top, (topDirs.get(top) ?? 0) + 1);
232
608
  }
233
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);
234
627
  return {
235
628
  root,
236
629
  files: files.length,
237
630
  truncated,
631
+ skipped,
238
632
  readme: readmeSummary(root),
239
- stack: [...stack],
633
+ languages: [...languages.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 12),
634
+ stack: [...stack].sort(),
240
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,
241
641
  checks,
242
- tests: { files: testFiles.length, dirs: [...testDirs.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10) },
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,
243
647
  ci,
244
648
  infra,
245
649
  env,
246
- areas: [...areas.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25),
247
- topDirs: [...topDirs.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15),
248
- git: gitFacts(root),
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,
249
656
  };
250
657
  }
251
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
+
252
662
  /** The inventory as the Markdown body of an intake. */
253
663
  export function inventoryMarkdown(inv, projectName) {
254
664
  const L = [];
255
665
  L.push(`Code inventory of ${projectName} — the FACTS an agent absorbs the project from.`);
256
666
  L.push("Written by `doctrina intake --from-code`: manifests read, files counted, nothing interpreted.");
257
- L.push("Business intent is NOT in here: what the code cannot say goes to the human as a question.");
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.");
258
669
  L.push("");
259
670
  if (inv.readme) {
260
671
  L.push(`## README (${inv.readme.file})`, "");
@@ -263,35 +674,97 @@ export function inventoryMarkdown(inv, projectName) {
263
674
  L.push("");
264
675
  }
265
676
  L.push("## Stack", "");
266
- L.push(`- Languages / build: ${inv.stack.length ? inv.stack.join(", ") : "none detected from manifests"}`);
267
- L.push(`- Frameworks and tools: ${inv.signals.length ? inv.signals.join(", ") : "none detected"}`);
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"}`);
268
680
  L.push(`- Files scanned: ${inv.files}${inv.truncated ? ` (stopped at ${MAX_FILES})` : ""}`);
269
- if (inv.git) L.push(`- History: ${inv.git.commits} commits, ${inv.git.first} → ${inv.git.last}`);
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
+ }
270
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
+ }
271
702
  L.push("## Where the code is", "");
272
- for (const [dir, n] of inv.topDirs) L.push(`- \`${dir}/\` — ${n} files`);
703
+ for (const [dir, n] of inv.topDirs) L.push(`- \`${dir}/\` — ${n} file${n === 1 ? "" : "s"}`);
273
704
  if (inv.topDirs.length === 0) L.push("- (flat repository)");
274
705
  L.push("");
275
- L.push("## Candidate areas (one capability each, usually)", "");
276
- for (const [dir, n] of inv.areas) L.push(`- \`${dir}/\` — ${n} files`);
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"}`);
277
708
  if (inv.areas.length === 0) L.push("- none under the conventional roots — derive areas from the top-level folders");
278
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("");
279
729
  L.push("## Tests", "");
280
730
  L.push(`- ${inv.tests.files} test file${inv.tests.files === 1 ? "" : "s"}`);
281
731
  for (const [dir, n] of inv.tests.dirs) L.push(`- \`${dir}\` — ${n}`);
282
732
  L.push("");
283
- L.push("## Build and test commands (candidates for `doctrina verify --init`)", "");
284
- for (const ch of inv.checks) L.push(`- \`${ch.command}\` — ${ch.name}, from ${ch.from}`);
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}`);
285
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
+ }
286
742
  L.push("");
287
743
  L.push("## Delivery", "");
288
- for (const c of inv.ci) L.push(`- CI: ${c.label} (\`${c.path}\`)`);
289
- for (const i of inv.infra) L.push(`- Runtime: ${i.label} (\`${i.path}\`)`);
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)}`);
290
746
  if (inv.ci.length + inv.infra.length === 0) L.push("- no CI or runtime manifest found");
291
747
  L.push("");
292
748
  L.push("## Configuration (names only — values are never read)", "");
293
749
  for (const e of inv.env) L.push(`- \`${e.file}\`: ${e.names.length ? e.names.map((n) => `\`${n}\``).join(", ") : "(no KEY= lines)"}`);
294
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(", ")}`);
295
769
  return L.join("\n") + "\n";
296
770
  }
297
-