doctrina-cli 0.17.0 → 0.20.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 +8 -4
- package/package.json +1 -1
- package/src/commands/adapter.js +74 -0
- package/src/commands/analyze.js +14 -3
- package/src/commands/change.js +131 -7
- package/src/commands/close.js +92 -28
- package/src/commands/context.js +49 -8
- package/src/commands/delivery.js +238 -0
- package/src/commands/index-rebuild.js +23 -0
- package/src/commands/init.js +15 -0
- package/src/commands/intake.js +47 -1
- package/src/commands/metrics.js +28 -3
- package/src/commands/next.js +1 -0
- package/src/commands/prime.js +76 -7
- package/src/commands/review.js +47 -8
- package/src/commands/skill.js +3 -127
- package/src/commands/upgrade.js +15 -0
- package/src/commands/verify.js +59 -6
- package/src/commands/work.js +139 -5
- package/src/commands/workspace.js +409 -0
- package/src/index.js +5 -7
- package/src/lib/actions.js +9 -5
- package/src/lib/adapters.js +80 -0
- package/src/lib/change-ops.js +92 -0
- package/src/lib/code-inventory.js +297 -0
- package/src/lib/commands.js +52 -5
- package/src/lib/context-slice.js +118 -0
- package/src/lib/delivery.js +208 -0
- package/src/lib/git.js +59 -2
- package/src/lib/metrics-model.js +23 -1
- package/src/lib/playbook.js +1 -1
- package/src/lib/runtime.js +6 -0
- package/src/lib/scan.js +13 -0
- package/src/lib/signoff.js +24 -0
- package/src/lib/skill-candidates.js +162 -0
- package/src/lib/skill-export.js +95 -0
- package/src/lib/snapshot.js +76 -2
- package/src/lib/spec-ops.js +117 -1
- package/src/lib/task-card.js +102 -0
- package/src/lib/validation-model.js +1 -1
- package/src/lib/verify-cache.js +76 -0
- package/src/lib/views.js +56 -2
- package/src/lib/work-model.js +90 -12
- package/src/lib/workspace-agents.js +259 -0
- package/src/lib/workspace-tools.js +113 -0
- package/src/lib/workspace.js +371 -0
- package/templates/AGENTS.md.template +17 -14
- package/templates/README.md +11 -0
- package/templates/adapters/claude/.claude/settings.json.template +18 -0
- package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
- package/templates/change/spec-delta.md.template +2 -0
- package/templates/delivery.md.template +39 -0
- package/templates/playbooks/absorb.md.template +40 -0
- package/templates/playbooks/consolidate.md.template +28 -0
- package/templates/playbooks/delivery.md.template +38 -0
- package/templates/playbooks/work.md.template +4 -1
package/src/index.js
CHANGED
|
@@ -48,6 +48,8 @@ import * as upgrade from "./commands/upgrade.js";
|
|
|
48
48
|
import * as adapter from "./commands/adapter.js";
|
|
49
49
|
import * as triage from "./commands/triage.js";
|
|
50
50
|
import * as ci from "./commands/ci.js";
|
|
51
|
+
import * as workspace from "./commands/workspace.js";
|
|
52
|
+
import * as delivery from "./commands/delivery.js";
|
|
51
53
|
|
|
52
54
|
const COMMANDS = {
|
|
53
55
|
init, spec, change, decision, validate, hooks, analyze, clarify,
|
|
@@ -56,18 +58,14 @@ const COMMANDS = {
|
|
|
56
58
|
status, close, review, watch, why,
|
|
57
59
|
prime, handoff, show, doctor, report, completion,
|
|
58
60
|
intent, upgrade, adapter, triage, ci,
|
|
61
|
+
workspace, delivery,
|
|
59
62
|
};
|
|
60
63
|
|
|
61
|
-
const TOP_HELP = `
|
|
62
|
-
Usage: doctrina <command> [options] (\`doctrina <command> --help\` for its flags)
|
|
64
|
+
const TOP_HELP = `Usage: doctrina <command> [options] (\`doctrina <command> --help\` for its flags)
|
|
63
65
|
|
|
64
66
|
${surfaceHelp()}
|
|
65
67
|
|
|
66
|
-
Global flags:
|
|
67
|
-
--help, -h Show this message (or per-command help if after a command)
|
|
68
|
-
--version, -v Print the version
|
|
69
|
-
--debug On an unexpected error, also print the stack trace
|
|
70
|
-
|
|
68
|
+
Global flags: --help/-h (this, or a command's own) · --version/-v · --debug (stack traces)
|
|
71
69
|
Exit codes (a contract — an agent reads these to decide what to do next):
|
|
72
70
|
${exitCodeHelp()}
|
|
73
71
|
`;
|
package/src/lib/actions.js
CHANGED
|
@@ -151,7 +151,7 @@ export function computeActions(projectRoot, gates = {}) {
|
|
|
151
151
|
severity: "blocking",
|
|
152
152
|
why: "no capability is specced yet, so nothing states what this project must do",
|
|
153
153
|
text: 'doctrina intake --text "<what this project is>" — no capability is specced yet; ' +
|
|
154
|
-
"for an existing codebase, `doctrina work --from-diff` backfills from
|
|
154
|
+
"for an existing codebase, `doctrina intake --from-code` takes its inventory as the intake (`doctrina work --from-diff` backfills one change from code already written)",
|
|
155
155
|
}));
|
|
156
156
|
}
|
|
157
157
|
|
|
@@ -199,9 +199,13 @@ export function computeActions(projectRoot, gates = {}) {
|
|
|
199
199
|
}
|
|
200
200
|
const status = listHeader(read(proposalPath), "Status") ?? "proposed";
|
|
201
201
|
if (status === "applied") {
|
|
202
|
+
// The close, not the archive (change 0201). 0.17 retired the
|
|
203
|
+
// hand-driven tail — `close` skips the apply it already did and
|
|
204
|
+
// still runs verify, coverage and the docs gate — but this line kept
|
|
205
|
+
// teaching `change archive`, the one path past every one of them.
|
|
202
206
|
actions.push(action({
|
|
203
207
|
id: "change-archive-pending",
|
|
204
|
-
command: "
|
|
208
|
+
command: "close",
|
|
205
209
|
args: [id],
|
|
206
210
|
gate: "archive",
|
|
207
211
|
runnable: true,
|
|
@@ -239,11 +243,11 @@ export function computeActions(projectRoot, gates = {}) {
|
|
|
239
243
|
} else {
|
|
240
244
|
actions.push(action({
|
|
241
245
|
id: "change-deltas-missing",
|
|
242
|
-
command: "
|
|
246
|
+
command: "close",
|
|
243
247
|
args: [id],
|
|
244
|
-
why: `add spec deltas under .doctrina/changes/${id}/specs/, or
|
|
248
|
+
why: `add spec deltas under .doctrina/changes/${id}/specs/, or close it as metadata-only`,
|
|
245
249
|
text: `add spec deltas under .doctrina/changes/${id}/specs/, ` +
|
|
246
|
-
`or
|
|
250
|
+
`or close it as metadata-only: doctrina close ${id}`,
|
|
247
251
|
}));
|
|
248
252
|
}
|
|
249
253
|
}
|
package/src/lib/adapters.js
CHANGED
|
@@ -127,3 +127,83 @@ export function renderAdapterFile(file, tokens) {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
export { exists };
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Configuration files an adapter SHARES with the user (change 0211).
|
|
133
|
+
//
|
|
134
|
+
// A hook lives in the agent's own settings — `.claude/settings.json`,
|
|
135
|
+
// `.cursor/hooks.json` — which the project may already own: permissions,
|
|
136
|
+
// other hooks, a model choice. Writing the file whole would be the data loss
|
|
137
|
+
// `adapter add` exists to avoid, and skipping it because it exists would
|
|
138
|
+
// leave the hooks out exactly where an agent is configured. So a `.json`
|
|
139
|
+
// adapter file is MERGED: objects recurse, arrays gain the entries they do
|
|
140
|
+
// not already hold, and a scalar the user set stays theirs. Removal takes
|
|
141
|
+
// back only what the template put in.
|
|
142
|
+
|
|
143
|
+
/** Whether an adapter file is merged into a shared configuration file. */
|
|
144
|
+
export function isMergedConfig(file) {
|
|
145
|
+
return file.relativePath.endsWith(".json");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
149
|
+
|
|
150
|
+
/** `ours` merged into `theirs`; neither argument is modified. */
|
|
151
|
+
export function mergeJson(theirs, ours) {
|
|
152
|
+
if (Array.isArray(theirs) && Array.isArray(ours)) {
|
|
153
|
+
return [...theirs, ...ours.filter((o) => !theirs.some((t) => same(t, o)))];
|
|
154
|
+
}
|
|
155
|
+
if (isPlainObject(theirs) && isPlainObject(ours)) {
|
|
156
|
+
const out = { ...theirs };
|
|
157
|
+
for (const [k, v] of Object.entries(ours)) {
|
|
158
|
+
out[k] = k in theirs ? mergeJson(theirs[k], v) : v;
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
return theirs;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* `theirs` with everything `ours` contributed taken out, and the containers
|
|
167
|
+
* that leaves empty pruned. A scalar is removed only when it equals ours AND
|
|
168
|
+
* nothing of the user's remains beside it. Returns null when nothing is left.
|
|
169
|
+
*/
|
|
170
|
+
export function unmergeJson(theirs, ours) {
|
|
171
|
+
if (Array.isArray(theirs) && Array.isArray(ours)) {
|
|
172
|
+
const left = theirs.filter((t) => !ours.some((o) => same(t, o)));
|
|
173
|
+
return left.length > 0 ? left : null;
|
|
174
|
+
}
|
|
175
|
+
if (isPlainObject(theirs) && isPlainObject(ours)) {
|
|
176
|
+
const out = {};
|
|
177
|
+
for (const [k, v] of Object.entries(theirs)) {
|
|
178
|
+
if (!(k in ours) || isScalar(ours[k])) { out[k] = v; continue; }
|
|
179
|
+
const kept = unmergeJson(v, ours[k]);
|
|
180
|
+
if (kept !== null) out[k] = kept;
|
|
181
|
+
}
|
|
182
|
+
// Our scalars (a `"version": 1`) go only when they are all that is left.
|
|
183
|
+
const userKeys = Object.keys(out).filter((k) => !(k in ours && isScalar(ours[k]) && same(out[k], ours[k])));
|
|
184
|
+
return userKeys.length > 0 ? out : null;
|
|
185
|
+
}
|
|
186
|
+
return same(theirs, ours) ? null : theirs;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isPlainObject(v) {
|
|
190
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isScalar(v) {
|
|
194
|
+
return v === null || typeof v !== "object";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Whether `theirs` holds at least one array entry `ours` contributes. */
|
|
198
|
+
export function holdsAnyOf(theirs, ours) {
|
|
199
|
+
if (Array.isArray(theirs) && Array.isArray(ours)) return ours.some((o) => theirs.some((t) => same(t, o)));
|
|
200
|
+
if (isPlainObject(theirs) && isPlainObject(ours)) {
|
|
201
|
+
return Object.keys(ours).some((k) => k in theirs && holdsAnyOf(theirs[k], ours[k]));
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Whether every entry `ours` contributes is present in `theirs`. */
|
|
207
|
+
export function containsJson(theirs, ours) {
|
|
208
|
+
return same(mergeJson(theirs, ours), theirs);
|
|
209
|
+
}
|
package/src/lib/change-ops.js
CHANGED
|
@@ -15,6 +15,10 @@ import { flagBool, flagString } from "./args.js";
|
|
|
15
15
|
import { c } from "./colors.js";
|
|
16
16
|
import { ensureDoctrinaProject, isChangeId, resolveWithinProject } from "./project.js";
|
|
17
17
|
import { changeEntry } from "./scan.js";
|
|
18
|
+
import { parseOp, insertOps, extractOps, applyOps } from "./spec-ops.js";
|
|
19
|
+
import { parseOperation } from "./doc-model.js";
|
|
20
|
+
import { substitute } from "./templates.js";
|
|
21
|
+
import { EXIT } from "./exit-codes.js";
|
|
18
22
|
export function changeNew(args, flags) {
|
|
19
23
|
const id = args[0];
|
|
20
24
|
const title = args.slice(1).join(" ").trim();
|
|
@@ -131,3 +135,91 @@ export function reindexChange(projectRoot, id, date = today()) {
|
|
|
131
135
|
idx.touch(index, date);
|
|
132
136
|
idx.save(projectRoot, index);
|
|
133
137
|
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Add op lines to a change's MODIFIED delta for one capability (change 0205).
|
|
141
|
+
*
|
|
142
|
+
* A field review of 0.17 asked for "a command to add a requirement and a
|
|
143
|
+
* criterion without writing the ops blocks by hand". The grammar is
|
|
144
|
+
* `apply`'s (ADR 0021): every line is parsed by the same parser, and the
|
|
145
|
+
* whole block — what was there plus what is added — is dry-run against the
|
|
146
|
+
* current spec before anything is written, so an op that would not apply is
|
|
147
|
+
* refused now rather than at the close. Creates the delta from the template
|
|
148
|
+
* when the change has none for the capability yet; a second `bump-version`
|
|
149
|
+
* replaces the first instead of bumping twice.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} projectRoot
|
|
152
|
+
* @param {string} id change id (already refused/resolved by the caller)
|
|
153
|
+
* @param {string} capability
|
|
154
|
+
* @param {string[]} lines op lines, one operation each
|
|
155
|
+
* @returns {{ code: number, error?: string, hint?: string, details?: string[],
|
|
156
|
+
* deltaRel?: string, created?: boolean, block?: string[] }}
|
|
157
|
+
*/
|
|
158
|
+
export function addDeltaOps(projectRoot, id, capability, lines) {
|
|
159
|
+
if (lines.length === 0) {
|
|
160
|
+
return { code: EXIT.USAGE, error: "nothing to add",
|
|
161
|
+
hint: "pass --requirement \"<section>: <text>\", --criterion \"<text>\", --bump <level> or an op line" };
|
|
162
|
+
}
|
|
163
|
+
const bad = lines.map((l) => ({ l, op: parseOp(l) })).filter((x) => x.op.error);
|
|
164
|
+
if (bad.length > 0) {
|
|
165
|
+
return { code: EXIT.USAGE, error: "not an operation `apply` can read", details: bad.map((x) => x.op.error) };
|
|
166
|
+
}
|
|
167
|
+
const specPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
|
|
168
|
+
if (!isFile(specPath)) {
|
|
169
|
+
return { code: EXIT.USAGE, error: `capability "${capability}" has no spec at ${relPath(projectRoot, specPath)}`,
|
|
170
|
+
hint: `a NEW capability is an ADDED delta with the full body — \`doctrina spec new ${capability}\`, then write it` };
|
|
171
|
+
}
|
|
172
|
+
const deltaPath = path.join(projectRoot, ".doctrina", "changes", id, "specs", capability, "delta.md");
|
|
173
|
+
const created = !isFile(deltaPath);
|
|
174
|
+
let text;
|
|
175
|
+
if (created) {
|
|
176
|
+
const tpl = read(path.join(locateTemplatesDir(), "change", "spec-delta.md.template"));
|
|
177
|
+
text = substitute(tpl, { CAPABILITY: capability }).replace(/^\*\*Operation:\*\*.*$/m, "**Operation:** MODIFIED");
|
|
178
|
+
} else {
|
|
179
|
+
text = read(deltaPath);
|
|
180
|
+
const op = parseOperation(text);
|
|
181
|
+
if (op && op !== "MODIFIED") {
|
|
182
|
+
return { code: EXIT.PRECONDITION, error: `${relPath(projectRoot, deltaPath)} is an ${op} delta — ops apply only to a MODIFIED one`,
|
|
183
|
+
hint: op === "ADDED" ? "write the requirement into the ADDED body itself" : "a REMOVED delta deletes the spec; there is nothing to add to" };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const next = insertOps(text, lines);
|
|
187
|
+
const dry = applyOps(read(specPath), extractOps(next));
|
|
188
|
+
if (dry.errors.length > 0) {
|
|
189
|
+
return { code: EXIT.GATE, error: `the ops block would not apply to ${relPath(projectRoot, specPath)} — nothing written`, details: dry.errors };
|
|
190
|
+
}
|
|
191
|
+
mkdirp(path.dirname(deltaPath));
|
|
192
|
+
write(deltaPath, next, { force: true });
|
|
193
|
+
addAffectedSpec(projectRoot, id, capability);
|
|
194
|
+
return { code: EXIT.OK, deltaRel: relPath(projectRoot, deltaPath), created, block: extractOps(next).map((o) => o.verb) };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Name `capability` in the proposal's `Affects specs:` header when the
|
|
199
|
+
* header does not already (change 0207). A change whose delta targets a
|
|
200
|
+
* capability affects that spec, and a header left empty while the delta
|
|
201
|
+
* folder said otherwise was the one fact `prime`, `status` and the review
|
|
202
|
+
* of the proposal disagreed on. A chore's "(none — chore)" is left alone:
|
|
203
|
+
* that is a declaration, and the structure gate is where a chore carrying a
|
|
204
|
+
* delta gets questioned.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} projectRoot
|
|
207
|
+
* @param {string} id
|
|
208
|
+
* @param {string} capability
|
|
209
|
+
* @returns {boolean} whether the header changed
|
|
210
|
+
*/
|
|
211
|
+
export function addAffectedSpec(projectRoot, id, capability) {
|
|
212
|
+
const proposalPath = path.join(projectRoot, ".doctrina", "changes", id, "proposal.md");
|
|
213
|
+
if (!isFile(proposalPath)) return false;
|
|
214
|
+
const text = read(proposalPath);
|
|
215
|
+
const m = text.match(/^(-[ \t]+\*\*Affects specs:\*\*)([^\r\n]*)/m);
|
|
216
|
+
if (!m) return false;
|
|
217
|
+
const current = m[2].trim();
|
|
218
|
+
if (/^\(none/i.test(current)) return false;
|
|
219
|
+
const named = current.split(",").map((s) => s.trim().replace(/`/g, "")).filter(Boolean);
|
|
220
|
+
if (named.includes(capability)) return false;
|
|
221
|
+
const value = [...named, capability].join(", ");
|
|
222
|
+
write(proposalPath, text.replace(m[0], `${m[1]} ${value}`), { force: true });
|
|
223
|
+
reindexChange(projectRoot, id);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { readdirSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { isDir, isFile, read } from "./fs-ops.js";
|
|
5
|
+
import { gitLines } from "./git.js";
|
|
6
|
+
|
|
7
|
+
// The INVENTORY of an existing codebase (change 0221): what `intake
|
|
8
|
+
// --from-code` stores as the intake of a repository that already has code
|
|
9
|
+
// and no specs. Deterministic, local and cheap — it reads manifests and
|
|
10
|
+
// counts files; it never interprets prose and never reads a secret.
|
|
11
|
+
//
|
|
12
|
+
// It is the FACTS half of absorbing a project. The agent does the other
|
|
13
|
+
// half — reading the code it points at and writing what the system does
|
|
14
|
+
// today — through the absorb playbook (ADR 0005: the CLI does no
|
|
15
|
+
// interpretation).
|
|
16
|
+
|
|
17
|
+
const SKIP_DIRS = new Set([
|
|
18
|
+
".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",
|
|
21
|
+
]);
|
|
22
|
+
const MAX_FILES = 20000;
|
|
23
|
+
|
|
24
|
+
// Dependency name → what it tells about the project.
|
|
25
|
+
const JS_SIGNALS = {
|
|
26
|
+
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",
|
|
32
|
+
};
|
|
33
|
+
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",
|
|
37
|
+
};
|
|
38
|
+
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",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const TEST_FILE = /(\.(test|spec)\.[cm]?[jt]sx?$)|(^test_.*\.py$)|(_test\.(py|go)$)|(\.feature$)|(Tests?\.(java|kt|cs)$)|(\.robot$)|(_spec\.rb$)/;
|
|
44
|
+
const CI_FILES = [
|
|
45
|
+
[".github/workflows", "GitHub Actions"], [".gitlab-ci.yml", "GitLab CI"], ["Jenkinsfile", "Jenkins"],
|
|
46
|
+
["azure-pipelines.yml", "Azure Pipelines"], ["bitbucket-pipelines.yml", "Bitbucket Pipelines"], [".circleci", "CircleCI"],
|
|
47
|
+
];
|
|
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"],
|
|
52
|
+
];
|
|
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"];
|
|
55
|
+
|
|
56
|
+
function walk(root) {
|
|
57
|
+
const files = [];
|
|
58
|
+
let truncated = false;
|
|
59
|
+
const stack = [""];
|
|
60
|
+
while (stack.length && !truncated) {
|
|
61
|
+
const rel = stack.pop();
|
|
62
|
+
let entries;
|
|
63
|
+
try {
|
|
64
|
+
entries = readdirSync(path.join(root, rel), { withFileTypes: true });
|
|
65
|
+
} catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
for (const e of entries) {
|
|
69
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
70
|
+
if (e.isDirectory()) {
|
|
71
|
+
if (!SKIP_DIRS.has(e.name)) stack.push(r);
|
|
72
|
+
} else if (e.isFile()) {
|
|
73
|
+
files.push(r);
|
|
74
|
+
if (files.length >= MAX_FILES) {
|
|
75
|
+
truncated = true;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { files: files.sort(), truncated };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readJson(p) {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(read(p));
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function signalsFrom(names, table) {
|
|
93
|
+
const out = new Set();
|
|
94
|
+
for (const n of names) {
|
|
95
|
+
const key = String(n).toLowerCase();
|
|
96
|
+
for (const [dep, label] of Object.entries(table)) {
|
|
97
|
+
// Exact names only (a scope prefix aside): `express` must not read as
|
|
98
|
+
// `expo`, nor `nextjs-toolbox` as Next.js.
|
|
99
|
+
if (key === dep || key.endsWith("/" + dep)) out.add(label);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return [...out];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every `KEY=` name in an env example file — names only, never a value. */
|
|
106
|
+
export function envNames(text) {
|
|
107
|
+
return String(text).split(/\r?\n/)
|
|
108
|
+
.map((l) => l.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/)?.[1])
|
|
109
|
+
.filter(Boolean);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readmeSummary(root) {
|
|
113
|
+
for (const name of ["README.md", "readme.md", "README.MD", "README"]) {
|
|
114
|
+
const p = path.join(root, name);
|
|
115
|
+
if (!isFile(p)) continue;
|
|
116
|
+
const lines = read(p).split(/\r?\n/);
|
|
117
|
+
const title = lines.find((l) => /^#\s+/.test(l))?.replace(/^#\s+/, "").trim() ?? "";
|
|
118
|
+
const para = [];
|
|
119
|
+
let started = false;
|
|
120
|
+
for (const l of lines) {
|
|
121
|
+
if (/^#/.test(l) || /^\s*(<|!\[|\[!\[)/.test(l)) {
|
|
122
|
+
if (started) break;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (l.trim() === "") {
|
|
126
|
+
if (started) break;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
started = true;
|
|
130
|
+
para.push(l.trim());
|
|
131
|
+
}
|
|
132
|
+
return { file: name, title, summary: para.join(" ").slice(0, 400) };
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function gitFacts(root) {
|
|
138
|
+
const dates = gitLines(root, ["log", "--format=%cs"]);
|
|
139
|
+
if (!dates || dates.length === 0) return null;
|
|
140
|
+
return { commits: dates.length, first: dates[dates.length - 1], last: dates[0] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The inventory of the codebase at `root`.
|
|
145
|
+
*/
|
|
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)})` });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
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(", ") });
|
|
174
|
+
}
|
|
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] });
|
|
183
|
+
}
|
|
184
|
+
if (isFile(path.join(root, "go.mod"))) {
|
|
185
|
+
stack.add("Go");
|
|
186
|
+
checks.push({ name: "test", command: "go test ./...", from: "go.mod" });
|
|
187
|
+
}
|
|
188
|
+
if (isFile(path.join(root, "Cargo.toml"))) {
|
|
189
|
+
stack.add("Rust");
|
|
190
|
+
checks.push({ name: "test", command: "cargo test", from: "Cargo.toml" });
|
|
191
|
+
}
|
|
192
|
+
if (files.some((f) => /\.(csproj|sln)$/.test(f))) {
|
|
193
|
+
stack.add(".NET");
|
|
194
|
+
checks.push({ name: "test", command: "dotnet test", from: "*.csproj" });
|
|
195
|
+
}
|
|
196
|
+
if (isFile(path.join(root, "pubspec.yaml"))) {
|
|
197
|
+
stack.add("Dart / Flutter");
|
|
198
|
+
checks.push({ name: "test", command: "flutter test", from: "pubspec.yaml" });
|
|
199
|
+
}
|
|
200
|
+
if (isFile(path.join(root, "Gemfile"))) stack.add("Ruby");
|
|
201
|
+
if (isFile(path.join(root, "composer.json"))) stack.add("PHP");
|
|
202
|
+
if (isDir(path.join(root, "android"))) signals.add("Android project");
|
|
203
|
+
if (isDir(path.join(root, "ios"))) signals.add("iOS project");
|
|
204
|
+
if (files.some((f) => f.endsWith(".tf"))) signals.add("Terraform");
|
|
205
|
+
|
|
206
|
+
const testFiles = files.filter((f) => TEST_FILE.test(path.basename(f)) || /(^|\/)(cypress|e2e)\/.+\.[cm]?[jt]sx?$/.test(f));
|
|
207
|
+
const testDirs = new Map();
|
|
208
|
+
for (const f of testFiles) {
|
|
209
|
+
const dir = f.includes("/") ? f.split("/").slice(0, Math.min(2, f.split("/").length - 1)).join("/") : ".";
|
|
210
|
+
testDirs.set(dir, (testDirs.get(dir) ?? 0) + 1);
|
|
211
|
+
}
|
|
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
|
+
|
|
219
|
+
// Candidate AREAS: the folders one level under the conventional source
|
|
220
|
+
// roots, with how many files each holds — where capabilities usually are.
|
|
221
|
+
const areas = new Map();
|
|
222
|
+
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]}`;
|
|
226
|
+
areas.set(key, (areas.get(key) ?? 0) + 1);
|
|
227
|
+
}
|
|
228
|
+
const topDirs = new Map();
|
|
229
|
+
for (const f of files) {
|
|
230
|
+
const top = f.includes("/") ? f.split("/")[0] : null;
|
|
231
|
+
if (top) topDirs.set(top, (topDirs.get(top) ?? 0) + 1);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
root,
|
|
236
|
+
files: files.length,
|
|
237
|
+
truncated,
|
|
238
|
+
readme: readmeSummary(root),
|
|
239
|
+
stack: [...stack],
|
|
240
|
+
signals: [...signals].sort(),
|
|
241
|
+
checks,
|
|
242
|
+
tests: { files: testFiles.length, dirs: [...testDirs.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10) },
|
|
243
|
+
ci,
|
|
244
|
+
infra,
|
|
245
|
+
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),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The inventory as the Markdown body of an intake. */
|
|
253
|
+
export function inventoryMarkdown(inv, projectName) {
|
|
254
|
+
const L = [];
|
|
255
|
+
L.push(`Code inventory of ${projectName} — the FACTS an agent absorbs the project from.`);
|
|
256
|
+
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.");
|
|
258
|
+
L.push("");
|
|
259
|
+
if (inv.readme) {
|
|
260
|
+
L.push(`## README (${inv.readme.file})`, "");
|
|
261
|
+
if (inv.readme.title) L.push(`- Title: ${inv.readme.title}`);
|
|
262
|
+
if (inv.readme.summary) L.push(`- Opening: ${inv.readme.summary}`);
|
|
263
|
+
L.push("");
|
|
264
|
+
}
|
|
265
|
+
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"}`);
|
|
268
|
+
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}`);
|
|
270
|
+
L.push("");
|
|
271
|
+
L.push("## Where the code is", "");
|
|
272
|
+
for (const [dir, n] of inv.topDirs) L.push(`- \`${dir}/\` — ${n} files`);
|
|
273
|
+
if (inv.topDirs.length === 0) L.push("- (flat repository)");
|
|
274
|
+
L.push("");
|
|
275
|
+
L.push("## Candidate areas (one capability each, usually)", "");
|
|
276
|
+
for (const [dir, n] of inv.areas) L.push(`- \`${dir}/\` — ${n} files`);
|
|
277
|
+
if (inv.areas.length === 0) L.push("- none under the conventional roots — derive areas from the top-level folders");
|
|
278
|
+
L.push("");
|
|
279
|
+
L.push("## Tests", "");
|
|
280
|
+
L.push(`- ${inv.tests.files} test file${inv.tests.files === 1 ? "" : "s"}`);
|
|
281
|
+
for (const [dir, n] of inv.tests.dirs) L.push(`- \`${dir}\` — ${n}`);
|
|
282
|
+
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}`);
|
|
285
|
+
if (inv.checks.length === 0) L.push("- none detected — ask the human how this project is built and tested");
|
|
286
|
+
L.push("");
|
|
287
|
+
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}\`)`);
|
|
290
|
+
if (inv.ci.length + inv.infra.length === 0) L.push("- no CI or runtime manifest found");
|
|
291
|
+
L.push("");
|
|
292
|
+
L.push("## Configuration (names only — values are never read)", "");
|
|
293
|
+
for (const e of inv.env) L.push(`- \`${e.file}\`: ${e.names.length ? e.names.map((n) => `\`${n}\``).join(", ") : "(no KEY= lines)"}`);
|
|
294
|
+
if (inv.env.length === 0) L.push("- no env example file");
|
|
295
|
+
return L.join("\n") + "\n";
|
|
296
|
+
}
|
|
297
|
+
|