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