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.
- package/README.md +7 -7
- package/package.json +1 -1
- package/src/commands/adapter.js +10 -0
- package/src/commands/change.js +202 -27
- package/src/commands/clarify.js +3 -206
- package/src/commands/close.js +1 -1
- package/src/commands/context.js +33 -2
- package/src/commands/contract.js +1 -0
- package/src/commands/coverage.js +69 -6
- package/src/commands/delivery.js +135 -17
- package/src/commands/doctor.js +83 -5
- package/src/commands/handoff.js +11 -2
- package/src/commands/hooks.js +56 -11
- package/src/commands/index-rebuild.js +47 -1
- package/src/commands/init.js +64 -8
- package/src/commands/intake.js +16 -5
- package/src/commands/metrics.js +16 -1
- package/src/commands/next.js +41 -6
- package/src/commands/onboard.js +113 -0
- package/src/commands/prime.js +53 -4
- package/src/commands/review.js +40 -4
- package/src/commands/search.js +2 -2
- package/src/commands/skill.js +2 -1
- package/src/commands/status.js +28 -0
- package/src/commands/templates.js +2 -1
- package/src/commands/triage.js +16 -1
- package/src/commands/ui.js +512 -0
- package/src/commands/upgrade.js +62 -4
- package/src/commands/validate.js +18 -0
- package/src/commands/verify.js +35 -7
- package/src/commands/why.js +1 -1
- package/src/commands/work.js +90 -42
- package/src/commands/workspace.js +89 -66
- package/src/index.js +107 -62
- package/src/lib/actions.js +62 -3
- package/src/lib/adapters.js +83 -1
- package/src/lib/agent-bridge.js +400 -0
- package/src/lib/applied-deltas.js +82 -0
- package/src/lib/args.js +34 -2
- package/src/lib/change-numbers.js +115 -0
- package/src/lib/change-ops.js +2 -2
- package/src/lib/clarify-scan.js +221 -0
- package/src/lib/code-inventory.js +594 -104
- package/src/lib/commands.js +58 -4
- package/src/lib/config.js +27 -8
- package/src/lib/conflict-markers.js +61 -0
- package/src/lib/console-model.js +668 -0
- package/src/lib/console-prefs.js +67 -0
- package/src/lib/console-room.js +201 -0
- package/src/lib/console-state.js +150 -0
- package/src/lib/console-view.js +557 -0
- package/src/lib/context-slice.js +34 -3
- package/src/lib/coverage-model.js +29 -6
- package/src/lib/delivery.js +90 -12
- package/src/lib/docs-impact.js +37 -2
- package/src/lib/fs-ops.js +103 -12
- package/src/lib/gates.js +7 -1
- package/src/lib/git.js +18 -0
- package/src/lib/index-json.js +10 -1
- package/src/lib/index-merge.js +90 -0
- package/src/lib/install.js +80 -0
- package/src/lib/intake-model.js +5 -2
- package/src/lib/json-out.js +9 -1
- package/src/lib/ledger.js +2 -3
- package/src/lib/lock.js +58 -0
- package/src/lib/merge-setup.js +55 -0
- package/src/lib/metrics-model.js +2 -2
- package/src/lib/onboard.js +197 -0
- package/src/lib/preset.js +177 -0
- package/src/lib/prompt.js +1 -1
- package/src/lib/properties.js +149 -0
- package/src/lib/runtime.js +19 -7
- package/src/lib/scan.js +13 -8
- package/src/lib/shell.js +45 -0
- package/src/lib/snapshot.js +5 -1
- package/src/lib/spec-ops.js +2 -2
- package/src/lib/task-card.js +101 -8
- package/src/lib/templates-model.js +3 -2
- package/src/lib/triage-model.js +75 -0
- package/src/lib/validation-model.js +23 -5
- package/src/lib/views.js +5 -4
- package/src/lib/work-model.js +8 -2
- package/src/lib/workspace-agents.js +67 -20
- package/src/lib/workspace-guard.js +65 -0
- package/src/lib/workspace-locate.js +141 -0
- package/src/lib/workspace-tools.js +104 -17
- package/src/lib/workspace-view.js +391 -0
- package/src/lib/workspace.js +83 -131
- package/templates/AGENTS.md.template +9 -5
- package/templates/README.md +4 -2
- package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
- package/templates/adapters/kiro/README.md +22 -0
- package/templates/playbooks/absorb.md.template +39 -16
- package/templates/playbooks/bootstrap.md.template +18 -6
- 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
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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",
|
|
35
|
-
"
|
|
36
|
-
|
|
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",
|
|
40
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
54
|
-
const
|
|
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 (
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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 (
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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:
|
|
220
|
-
//
|
|
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
|
|
224
|
-
|
|
225
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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("
|
|
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(`-
|
|
267
|
-
L.push(`-
|
|
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)
|
|
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}
|
|
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 (
|
|
276
|
-
for (const [dir, n] of inv.areas) L.push(`- \`${dir}/\` — ${n}
|
|
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
|
|
284
|
-
|
|
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
|
|
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
|
-
|