doctrina-cli 0.13.0 → 0.15.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 +1 -1
- package/package.json +3 -2
- package/src/commands/adapter.js +205 -0
- package/src/commands/analyze.js +120 -17
- package/src/commands/change.js +100 -42
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +57 -2
- package/src/commands/completion.js +6 -0
- package/src/commands/constitution.js +8 -1
- package/src/commands/context.js +391 -58
- package/src/commands/contract.js +76 -43
- package/src/commands/coverage.js +126 -12
- package/src/commands/decision.js +172 -40
- package/src/commands/doctor.js +92 -11
- package/src/commands/handoff.js +8 -1
- package/src/commands/hooks.js +6 -0
- package/src/commands/index-rebuild.js +8 -1
- package/src/commands/init.js +139 -6
- package/src/commands/intake.js +11 -2
- package/src/commands/intent.js +8 -1
- package/src/commands/metrics.js +80 -7
- package/src/commands/next.js +38 -3
- package/src/commands/prime.js +8 -1
- package/src/commands/report.js +8 -1
- package/src/commands/review.js +8 -1
- package/src/commands/search.js +8 -1
- package/src/commands/show.js +8 -1
- package/src/commands/skill.js +201 -8
- package/src/commands/spec.js +13 -6
- package/src/commands/status.js +14 -2
- package/src/commands/templates.js +193 -59
- package/src/commands/trace.js +15 -3
- package/src/commands/triage.js +248 -0
- package/src/commands/upgrade.js +8 -1
- package/src/commands/validate.js +148 -5
- package/src/commands/verify.js +126 -5
- package/src/commands/watch.js +8 -1
- package/src/commands/why.js +10 -24
- package/src/commands/work.js +44 -3
- package/src/index.js +61 -6
- package/src/lib/adapters.js +140 -0
- package/src/lib/adr-guard.js +1 -0
- package/src/lib/args.js +10 -0
- package/src/lib/clarity.js +1 -0
- package/src/lib/colors.js +1 -0
- package/src/lib/commands.js +252 -27
- package/src/lib/criteria.js +1 -0
- package/src/lib/dates.js +1 -0
- package/src/lib/diff.js +1 -0
- package/src/lib/doc-model.js +287 -0
- package/src/lib/docs-impact.js +167 -0
- package/src/lib/ears.js +1 -0
- package/src/lib/exit-codes.js +103 -0
- package/src/lib/flag-catalog.js +106 -0
- package/src/lib/fs-ops.js +1 -0
- package/src/lib/gates.js +123 -0
- package/src/lib/git.js +97 -0
- package/src/lib/index-json.js +14 -6
- package/src/lib/json-out.js +83 -0
- package/src/lib/pipeline.js +161 -0
- package/src/lib/prompt.js +18 -1
- package/src/lib/runtime.js +683 -0
- package/src/lib/scan.js +81 -33
- package/src/lib/spec-ops.js +94 -9
- package/src/lib/suggest.js +1 -0
- package/src/lib/templates.js +89 -0
- package/src/lib/usage.js +89 -0
- package/src/lib/version.js +1 -0
- package/templates/AGENTS.md.template +59 -53
- package/templates/contract.md.template +74 -3
- package/templates/spec.md.template +22 -0
package/src/commands/decision.js
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { getHeader, setHeader } from "../lib/doc-model.js";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
import process from "node:process";
|
|
3
5
|
import { exists, read, relPath, write } from "../lib/fs-ops.js";
|
|
4
6
|
import { walk } from "../lib/fs-ops.js";
|
|
5
|
-
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
7
|
+
import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
6
8
|
import * as idx from "../lib/index-json.js";
|
|
7
9
|
import { today, slugify, padNumber } from "../lib/dates.js";
|
|
8
10
|
import { c } from "../lib/colors.js";
|
|
9
11
|
import { suggest } from "../lib/suggest.js";
|
|
12
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
13
|
+
import { flagBool } from "../lib/args.js";
|
|
14
|
+
import { parseAdrScope, decisionEntry } from "../lib/scan.js";
|
|
10
15
|
|
|
11
|
-
const SUBCOMMANDS = ["new", "supersede", "accept", "land", "list"];
|
|
16
|
+
const SUBCOMMANDS = ["new", "supersede", "accept", "land", "list", "scope"];
|
|
12
17
|
|
|
13
|
-
|
|
18
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
19
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
20
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
21
|
+
export const flags = { boolean: ["json", "write"], string: [] };
|
|
22
|
+
|
|
23
|
+
export async function run(positional, cmdFlags) {
|
|
14
24
|
const sub = positional[0];
|
|
15
25
|
switch (sub) {
|
|
16
26
|
case "new":
|
|
@@ -21,6 +31,8 @@ export async function run(positional, _flags) {
|
|
|
21
31
|
return decisionAccept(positional.slice(1));
|
|
22
32
|
case "land":
|
|
23
33
|
return decisionLand(positional.slice(1));
|
|
34
|
+
case "scope":
|
|
35
|
+
return decisionScope(positional.slice(1), cmdFlags);
|
|
24
36
|
case "list":
|
|
25
37
|
return decisionList();
|
|
26
38
|
default:
|
|
@@ -52,8 +64,7 @@ function decisionNew(args) {
|
|
|
52
64
|
return 1;
|
|
53
65
|
}
|
|
54
66
|
|
|
55
|
-
const
|
|
56
|
-
const tpl = read(path.join(templatesDir, "decision.md.template"));
|
|
67
|
+
const tpl = readTemplate(projectRoot, "decision.md.template").body;
|
|
57
68
|
const body = substitute(tpl, {
|
|
58
69
|
DECISION_NUMBER: next,
|
|
59
70
|
DECISION_TITLE: title,
|
|
@@ -63,14 +74,11 @@ function decisionNew(args) {
|
|
|
63
74
|
write(targetPath, body, { force: false });
|
|
64
75
|
console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}`);
|
|
65
76
|
|
|
77
|
+
// Register through the SAME deriver the index rebuild uses. Assembling a
|
|
78
|
+
// look-alike entry here is how a field added to the deriver turns every
|
|
79
|
+
// freshly created ADR into index drift the moment it is written.
|
|
66
80
|
const index = idx.load(projectRoot);
|
|
67
|
-
idx.addDecision(index,
|
|
68
|
-
id: next,
|
|
69
|
-
path: `.doctrina/decisions/${filename}`,
|
|
70
|
-
title,
|
|
71
|
-
status: "proposed",
|
|
72
|
-
date,
|
|
73
|
-
});
|
|
81
|
+
idx.addDecision(index, decisionEntry(body, filename, null, date));
|
|
74
82
|
idx.touch(index, date);
|
|
75
83
|
idx.save(projectRoot, index);
|
|
76
84
|
console.log(c.green("indexed") + ` decision ${next}`);
|
|
@@ -95,12 +103,12 @@ function decisionSupersede(args) {
|
|
|
95
103
|
return 1;
|
|
96
104
|
}
|
|
97
105
|
const oldText = read(oldFile);
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
106
|
+
const oldStatusValue = getHeader(oldText, "Status");
|
|
107
|
+
if (oldStatusValue === null) {
|
|
100
108
|
console.error(c.red("error:") + ` ADR at ${relPath(projectRoot, oldFile)} has no Status: header`);
|
|
101
109
|
return 1;
|
|
102
110
|
}
|
|
103
|
-
const oldStatus =
|
|
111
|
+
const oldStatus = oldStatusValue;
|
|
104
112
|
if (oldStatus.toLowerCase().startsWith("superseded")) {
|
|
105
113
|
console.error(c.red("error:") + ` ADR ${padded} is already superseded`);
|
|
106
114
|
return 1;
|
|
@@ -132,23 +140,17 @@ function decisionSupersede(args) {
|
|
|
132
140
|
write(newPath, body, { force: false });
|
|
133
141
|
console.log(c.green("created") + ` ${relPath(projectRoot, newPath)}`);
|
|
134
142
|
|
|
135
|
-
// Mutate ONLY the Status: and Superseded by: headers of the old ADR
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
143
|
+
// Mutate ONLY the Status: and Superseded by: headers of the old ADR.
|
|
144
|
+
// Both go through the document model, so an ADR written with a slightly
|
|
145
|
+
// off header form is still updated rather than silently skipped (M3).
|
|
146
|
+
const withStatus = setHeader(oldText, "Status", `superseded by ${next}`) ?? oldText;
|
|
147
|
+
const updated = setHeader(withStatus, "Superseded by", String(next)) ?? withStatus;
|
|
139
148
|
write(oldFile, updated, { force: true });
|
|
140
149
|
console.log(c.green("updated") + ` ${relPath(projectRoot, oldFile)} status -> superseded by ${next}`);
|
|
141
150
|
|
|
142
151
|
// Update index
|
|
143
152
|
const index = idx.load(projectRoot);
|
|
144
|
-
idx.addDecision(index,
|
|
145
|
-
id: next,
|
|
146
|
-
path: `.doctrina/decisions/${filename}`,
|
|
147
|
-
title,
|
|
148
|
-
status: "proposed",
|
|
149
|
-
date,
|
|
150
|
-
supersedes: padded,
|
|
151
|
-
});
|
|
153
|
+
idx.addDecision(index, decisionEntry(body, filename, null, date));
|
|
152
154
|
idx.updateDecision(index, padded, () => ({
|
|
153
155
|
status: `superseded by ${next}`,
|
|
154
156
|
superseded_by: next,
|
|
@@ -176,19 +178,19 @@ function decisionAccept(args) {
|
|
|
176
178
|
return 1;
|
|
177
179
|
}
|
|
178
180
|
const text = read(file);
|
|
179
|
-
const
|
|
180
|
-
if (
|
|
181
|
+
const statusValue = getHeader(text, "Status");
|
|
182
|
+
if (statusValue === null) {
|
|
181
183
|
console.error(c.red("error:") + ` ADR at ${relPath(projectRoot, file)} has no Status: header`);
|
|
182
184
|
return 1;
|
|
183
185
|
}
|
|
184
|
-
const status =
|
|
186
|
+
const status = statusValue.toLowerCase();
|
|
185
187
|
if (status !== "proposed") {
|
|
186
|
-
console.error(c.red("error:") + ` ADR ${padded} is "${
|
|
188
|
+
console.error(c.red("error:") + ` ADR ${padded} is "${statusValue}", not "proposed" — nothing to accept`);
|
|
187
189
|
return 1;
|
|
188
190
|
}
|
|
189
191
|
|
|
190
192
|
// Mutate ONLY the Status: header; the body stays immutable.
|
|
191
|
-
write(file, text
|
|
193
|
+
write(file, setHeader(text, "Status", "accepted") ?? text, { force: true });
|
|
192
194
|
console.log(c.green("accepted") + ` ${relPath(projectRoot, file)}`);
|
|
193
195
|
|
|
194
196
|
const date = today();
|
|
@@ -224,16 +226,16 @@ function decisionLand(args) {
|
|
|
224
226
|
return 1;
|
|
225
227
|
}
|
|
226
228
|
const text = read(file);
|
|
227
|
-
const
|
|
228
|
-
if (
|
|
229
|
+
const statusValue = getHeader(text, "Status");
|
|
230
|
+
if (statusValue === null) {
|
|
229
231
|
console.error(c.red("error:") + ` ADR at ${relPath(projectRoot, file)} has no Status: header`);
|
|
230
232
|
return 1;
|
|
231
233
|
}
|
|
232
|
-
const status =
|
|
234
|
+
const status = statusValue.toLowerCase();
|
|
233
235
|
if (status !== "accepted") {
|
|
234
236
|
console.error(
|
|
235
237
|
c.red("error:") +
|
|
236
|
-
` ADR ${padded} is "${
|
|
238
|
+
` ADR ${padded} is "${statusValue}", not "accepted" — accept it before recording that it landed`,
|
|
237
239
|
);
|
|
238
240
|
return 1;
|
|
239
241
|
}
|
|
@@ -251,7 +253,7 @@ function decisionLand(args) {
|
|
|
251
253
|
} else if (/^-\s+\*\*Evidence:\*\*.*$/m.test(text)) {
|
|
252
254
|
updated = text.replace(/^(-\s+\*\*Evidence:\*\*.*)$/m, `$1\n- **Landed:** ${landedValue}`);
|
|
253
255
|
} else {
|
|
254
|
-
updated = text.replace(/^(
|
|
256
|
+
updated = text.replace(/^(\s*-\s+\*\*Status:\*\*.*)$/m, `$1\n- **Landed:** ${landedValue}`);
|
|
255
257
|
}
|
|
256
258
|
write(file, updated, { force: true });
|
|
257
259
|
console.log(c.green("landed") + ` ${relPath(projectRoot, file)} on ${date}`);
|
|
@@ -286,11 +288,11 @@ function decisionList() {
|
|
|
286
288
|
if (!m) continue;
|
|
287
289
|
const text = read(f);
|
|
288
290
|
const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
|
|
289
|
-
const
|
|
291
|
+
const listedStatus = getHeader(text, "Status");
|
|
290
292
|
const dateMatch = text.match(/^-\s+\*\*Date:\*\*\s+(\S+)/m);
|
|
291
293
|
rows.push({
|
|
292
294
|
id: m[1],
|
|
293
|
-
status:
|
|
295
|
+
status: listedStatus ?? "?",
|
|
294
296
|
date: dateMatch ? dateMatch[1] : "—",
|
|
295
297
|
title: titleMatch ? titleMatch[1].trim() : path.basename(f),
|
|
296
298
|
});
|
|
@@ -309,6 +311,127 @@ function decisionList() {
|
|
|
309
311
|
return 0;
|
|
310
312
|
}
|
|
311
313
|
|
|
314
|
+
// Propose — or, with --write, apply — the capability scope of an ADR.
|
|
315
|
+
//
|
|
316
|
+
// An unscoped ADR is GLOBAL: it loads into every context pack, forever,
|
|
317
|
+
// because ADRs are immutable and never retire. That is what makes the
|
|
318
|
+
// default read pack grow with the project's age instead of with the task
|
|
319
|
+
// at hand. Scoping is the fix, but only if it gets adopted, and nobody
|
|
320
|
+
// hand-annotates forty immutable documents.
|
|
321
|
+
//
|
|
322
|
+
// So the scope is SUGGESTED from evidence the tree already holds: the
|
|
323
|
+
// archived change that introduced an ADR records which specs it touched
|
|
324
|
+
// (`changes_archive[].specs_affected`), and the ADR's own text names the
|
|
325
|
+
// capabilities it governs. Both are hints. The agent or human confirms —
|
|
326
|
+
// the tool proposes, it never decides (ADR 0005).
|
|
327
|
+
function decisionScope(args, cmdFlags) {
|
|
328
|
+
const projectRoot = process.cwd();
|
|
329
|
+
ensureDoctrinaProject(projectRoot);
|
|
330
|
+
const doWrite = flagBool(cmdFlags, "write", false);
|
|
331
|
+
|
|
332
|
+
const index = idx.load(projectRoot);
|
|
333
|
+
const capabilities = new Set((index.artifacts.specs ?? []).map((s) => s.id));
|
|
334
|
+
const archive = index.artifacts.changes_archive ?? [];
|
|
335
|
+
const wanted = args[0] ? padNumber(parseInt(args[0], 10)) : null;
|
|
336
|
+
|
|
337
|
+
const rows = [];
|
|
338
|
+
for (const dec of index.artifacts.decisions ?? []) {
|
|
339
|
+
if (wanted && dec.id !== wanted) continue;
|
|
340
|
+
const file = path.join(projectRoot, dec.path);
|
|
341
|
+
if (!exists(file)) continue;
|
|
342
|
+
const text = read(file);
|
|
343
|
+
const declared = parseAdrScope(text);
|
|
344
|
+
|
|
345
|
+
// Signal 1 — the archived change that cites this ADR, and the specs
|
|
346
|
+
// that change touched. Strongest evidence: it is what actually moved.
|
|
347
|
+
const fromLedger = new Set();
|
|
348
|
+
for (const ch of archive) {
|
|
349
|
+
if (!archivedChangeCites(projectRoot, ch, dec.id)) continue;
|
|
350
|
+
for (const s of ch.specs_affected ?? []) {
|
|
351
|
+
if (capabilities.has(s.capability)) fromLedger.add(s.capability);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// Signal 2 — capability ids the ADR's own text names, and only as a
|
|
355
|
+
// FALLBACK. Capability ids are ordinary words (`cli`, `core`, `docs`),
|
|
356
|
+
// so on a mature tree this signal matches nearly every ADR and a scope
|
|
357
|
+
// of "everything" is the same as no scope at all. The ledger records
|
|
358
|
+
// what actually moved; text only guesses. When the ledger has an
|
|
359
|
+
// answer, it is the answer.
|
|
360
|
+
const fromText = new Set();
|
|
361
|
+
if (fromLedger.size === 0) {
|
|
362
|
+
for (const cap of capabilities) {
|
|
363
|
+
const escaped = cap.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
364
|
+
if (new RegExp(`(^|[^a-z0-9-])${escaped}([^a-z0-9-]|$)`, "im").test(text)) {
|
|
365
|
+
fromText.add(cap);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const viaLedger = fromLedger.size > 0;
|
|
370
|
+
const suggested = [...(viaLedger ? fromLedger : fromText)].sort();
|
|
371
|
+
rows.push({ dec, file, text, declared, suggested, viaLedger });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (rows.length === 0) {
|
|
375
|
+
console.log(c.gray(wanted ? `no ADR ${wanted}` : "no ADRs found in .doctrina/decisions/"));
|
|
376
|
+
return 0;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
console.log(c.bold("ADR scope") + c.gray(" — an unscoped ADR is global: it loads into every pack"));
|
|
380
|
+
console.log("");
|
|
381
|
+
let wrote = 0;
|
|
382
|
+
for (const r of rows) {
|
|
383
|
+
const have = r.declared.length > 0
|
|
384
|
+
? c.green(r.declared.join(", "))
|
|
385
|
+
: c.gray("(global)");
|
|
386
|
+
console.log(` ${c.cyan(r.dec.id)} ${String(r.dec.title ?? "").slice(0, 48).padEnd(48)} ${have}`);
|
|
387
|
+
if (r.declared.length > 0 || r.suggested.length === 0) continue;
|
|
388
|
+
const via = r.viaLedger ? "ledger" : "text — confirm before writing";
|
|
389
|
+
console.log(` ${c.yellow("suggest:")} ${r.suggested.join(", ")} ${c.gray(`(${via})`)}`);
|
|
390
|
+
if (!doWrite) continue;
|
|
391
|
+
const updated = insertScopeHeader(r.text, r.suggested);
|
|
392
|
+
if (updated) {
|
|
393
|
+
write(r.file, updated, { force: true });
|
|
394
|
+
wrote += 1;
|
|
395
|
+
console.log(` ${c.green("written")}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
console.log("");
|
|
400
|
+
if (wrote > 0) {
|
|
401
|
+
console.log(c.green("ok") + ` scoped ${wrote} ADR${wrote === 1 ? "" : "s"}`);
|
|
402
|
+
console.log(c.gray("next: ") + c.cyan("doctrina index rebuild") + c.gray(" then review — a scope too narrow"));
|
|
403
|
+
console.log(c.gray(" hides a decision from the pack that needs it."));
|
|
404
|
+
return 0;
|
|
405
|
+
}
|
|
406
|
+
const open = rows.filter((r) => r.declared.length === 0);
|
|
407
|
+
const actionable = open.filter((r) => r.suggested.length > 0).length;
|
|
408
|
+
console.log(c.gray(`${open.length} of ${rows.length} unscoped (global)`)
|
|
409
|
+
+ (actionable > 0 ? c.gray(`, ${actionable} with a suggestion`) : ""));
|
|
410
|
+
if (!doWrite && actionable > 0) {
|
|
411
|
+
console.log(c.gray("apply: ") + c.cyan("doctrina decision scope --write"));
|
|
412
|
+
}
|
|
413
|
+
return 0;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Does an archived change cite this ADR anywhere in its folder?
|
|
417
|
+
function archivedChangeCites(projectRoot, archived, adrId) {
|
|
418
|
+
if (!archived.path) return false;
|
|
419
|
+
const dir = path.join(projectRoot, archived.path);
|
|
420
|
+
for (const f of walk(dir)) {
|
|
421
|
+
if (f.endsWith(".md") && read(f).includes(`ADR ${adrId}`)) return true;
|
|
422
|
+
}
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Place `- **Scope:** a, b` in the metadata block, right after Status —
|
|
427
|
+
// where a reader looks for what a decision applies to.
|
|
428
|
+
function insertScopeHeader(text, scope) {
|
|
429
|
+
if (getHeader(text, "Scope") !== null) return null;
|
|
430
|
+
const line = `- **Scope:** ${scope.join(", ")}`;
|
|
431
|
+
const updated = text.replace(/^(-[ \t]+\*\*Status:\*\*.*)$/m, `$1\n${line}`);
|
|
432
|
+
return updated === text ? null : updated;
|
|
433
|
+
}
|
|
434
|
+
|
|
312
435
|
function nextDecisionNumber(projectRoot) {
|
|
313
436
|
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
314
437
|
const files = walk(adrDir);
|
|
@@ -325,7 +448,7 @@ function nextDecisionNumber(projectRoot) {
|
|
|
325
448
|
|
|
326
449
|
function ensureDoctrinaProject(projectRoot) {
|
|
327
450
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
328
|
-
throw
|
|
451
|
+
throw notADoctrinaProject();
|
|
329
452
|
}
|
|
330
453
|
}
|
|
331
454
|
|
|
@@ -345,4 +468,13 @@ Subcommands:
|
|
|
345
468
|
and rewrite the target's Status: header
|
|
346
469
|
list One line per ADR: number, status, date,
|
|
347
470
|
title. Read-only.
|
|
471
|
+
scope [<number>] [--write] Show which capabilities each ADR governs
|
|
472
|
+
and propose one for the unscoped, from the
|
|
473
|
+
archived change that cites it. An unscoped
|
|
474
|
+
ADR is global: it loads into every context
|
|
475
|
+
pack. --write applies the suggestions.
|
|
476
|
+
|
|
477
|
+
Options:
|
|
478
|
+
--write scope: apply the suggested scopes
|
|
479
|
+
--json Machine-readable output
|
|
348
480
|
`;
|
package/src/commands/doctor.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { spawnSync } from "node:child_process";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { exists } from "../lib/fs-ops.js";
|
|
6
7
|
import { collectStatus } from "./status.js";
|
|
8
|
+
import { collectFindings } from "./templates.js";
|
|
7
9
|
import { c } from "../lib/colors.js";
|
|
10
|
+
import { flagBool } from "../lib/args.js";
|
|
11
|
+
import { collectRuntimeFindings, checkLocalEnv } from "../lib/runtime.js";
|
|
12
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
8
13
|
|
|
9
14
|
// Aggregate diagnostic: the one command to run when "something looks wrong"
|
|
10
15
|
// and you do not know which gate to ask. It sequences the existing checks —
|
|
@@ -15,10 +20,15 @@ import { c } from "../lib/colors.js";
|
|
|
15
20
|
|
|
16
21
|
const cliEntry = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "index.js");
|
|
17
22
|
|
|
23
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
24
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
25
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
26
|
+
export const flags = { boolean: ["json", "env"], string: [] };
|
|
27
|
+
|
|
18
28
|
export async function run(_positional, _flags) {
|
|
19
29
|
const projectRoot = process.cwd();
|
|
20
30
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
21
|
-
throw
|
|
31
|
+
throw notADoctrinaProject();
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
console.log(c.bold("Doctrina doctor") + c.gray(" — aggregate diagnostic (read-only):"));
|
|
@@ -84,15 +94,79 @@ export async function run(_positional, _flags) {
|
|
|
84
94
|
if (clean.status === 0) row("ok", "clean-checkout", "no reproducibility footguns detected");
|
|
85
95
|
else row("fail", "clean-checkout", "a fresh clone would not build/run as-is", "doctrina verify --clean (fix the listed package.json footguns)");
|
|
86
96
|
|
|
87
|
-
// 5. Template shape vs the running CLI's recommendation.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
97
|
+
// 5. Template shape vs the running CLI's recommendation. The row reports
|
|
98
|
+
// the ACTUAL findings and the remedy each one names, rather than
|
|
99
|
+
// assuming every failure is a missing section — an adapter pointer
|
|
100
|
+
// break was reported as "recommended sections/fields are missing" and
|
|
101
|
+
// sent the user to `templates update`, which does not touch adapters
|
|
102
|
+
// and so could never clear it (C2).
|
|
103
|
+
{
|
|
104
|
+
let tmplFindings = [];
|
|
105
|
+
try {
|
|
106
|
+
tmplFindings = collectFindings(projectRoot).findings;
|
|
107
|
+
} catch {
|
|
108
|
+
tmplFindings = [];
|
|
109
|
+
}
|
|
110
|
+
if (tmplFindings.length === 0) {
|
|
111
|
+
row("ok", "templates", "project follows the current template shape");
|
|
112
|
+
} else {
|
|
113
|
+
// Group by remedy so the row names what actually fixes what.
|
|
114
|
+
const byRemedy = new Map();
|
|
115
|
+
for (const f of tmplFindings) {
|
|
116
|
+
const key = f.remedy ?? "(manual repair)";
|
|
117
|
+
byRemedy.set(key, (byRemedy.get(key) ?? 0) + 1);
|
|
118
|
+
}
|
|
119
|
+
const summary = tmplFindings.length === 1
|
|
120
|
+
? tmplFindings[0].message
|
|
121
|
+
: `${tmplFindings.length} template findings`;
|
|
122
|
+
const remedy = [...byRemedy.keys()].filter((k) => k !== "(manual repair)");
|
|
123
|
+
row("warn", "templates", summary, remedy.length === 1 ? remedy[0] : "doctrina templates check (each finding names its own fix)");
|
|
124
|
+
warningsTotal += 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 6. The RUNTIME surface (change 0029). Every row above reads Markdown;
|
|
129
|
+
// this one reads what the Markdown CLAIMS about the running system and
|
|
130
|
+
// checks it — the wiring, the empty-vs-unset default, the enum, the
|
|
131
|
+
// selector that would match nothing. Still a driver: the checks belong
|
|
132
|
+
// to lib/runtime.js and `contract check` renders the same verdict.
|
|
133
|
+
{
|
|
134
|
+
const runtime = collectRuntimeFindings(projectRoot);
|
|
135
|
+
const errs = runtime.findings.filter((f) => f.level === "error");
|
|
136
|
+
const warns = runtime.findings.length - errs.length;
|
|
137
|
+
if (runtime.contracts === 0) {
|
|
138
|
+
row("ok", "runtime", "no contracts — nothing declares a runtime surface");
|
|
139
|
+
} else if (runtime.declared === 0) {
|
|
140
|
+
// Silence is not proof: an undeclared surface is UNCHECKED, and a row
|
|
141
|
+
// that read "ok" here would be the exact false confidence this
|
|
142
|
+
// command exists to prevent.
|
|
143
|
+
row("warn", "runtime", `${runtime.contracts} contract${runtime.contracts === 1 ? "" : "s"}, 0 Wiring/Selectors rows — the runtime surface is unchecked`, "declare Wiring/Selectors rows, then `doctrina contract check`");
|
|
144
|
+
warningsTotal += 1;
|
|
145
|
+
} else if (errs.length > 0) {
|
|
146
|
+
row("fail", "runtime", `${errs.length} declaration${errs.length === 1 ? "" : "s"} do not hold`, "doctrina triage (each finding names its own fix)");
|
|
147
|
+
for (const e of errs.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e.code} ${e.message}`);
|
|
148
|
+
} else if (warns > 0) {
|
|
149
|
+
row("warn", "runtime", `${runtime.declared} declared rows hold; ${warns} advisory finding${warns === 1 ? "" : "s"}`, "doctrina triage");
|
|
150
|
+
warningsTotal += 1;
|
|
151
|
+
} else {
|
|
152
|
+
row("ok", "runtime", `${runtime.declared} declared row${runtime.declared === 1 ? "" : "s"} hold`);
|
|
153
|
+
}
|
|
93
154
|
}
|
|
94
155
|
|
|
95
|
-
//
|
|
156
|
+
// 7. The local .env, opt-in. Off by default because it reads a file that
|
|
157
|
+
// is not committed and is none of CI's business; when asked for, it
|
|
158
|
+
// reports NAMES and enum membership only and never the value.
|
|
159
|
+
if (flagBool(_flags, "env", false)) {
|
|
160
|
+
const envFindings = checkLocalEnv(projectRoot);
|
|
161
|
+
if (envFindings.length === 0) {
|
|
162
|
+
row("ok", "local .env", "matches the declared names and enums (or no .env present)");
|
|
163
|
+
} else {
|
|
164
|
+
row("fail", "local .env", `${envFindings.length} finding${envFindings.length === 1 ? "" : "s"} — config would be rejected on boot`, "fix the named variables (values are never printed)");
|
|
165
|
+
for (const e of envFindings.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e.message}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 8. Verify configuration (the real build gate must exist to be run).
|
|
96
170
|
if (s.verify.invalid) row("fail", "verify config", ".doctrina/verify.json is invalid JSON", "fix the JSON, then `doctrina verify`");
|
|
97
171
|
else if (!s.verify.configured) {
|
|
98
172
|
row("warn", "verify config", "no verify.json — the build gate is undeclared", "doctrina verify --init");
|
|
@@ -127,9 +201,16 @@ Usage: doctrina doctor
|
|
|
127
201
|
|
|
128
202
|
Aggregate diagnostic: run the structural gate (validate), the index
|
|
129
203
|
drift check, the coverage/trace ratios, the clean-checkout lint
|
|
130
|
-
(verify --clean), the template-shape check,
|
|
131
|
-
|
|
204
|
+
(verify --clean), the template-shape check, the RUNTIME surface (the
|
|
205
|
+
declared wiring, enums and selectors, checked against the code and
|
|
206
|
+
workflows), and the verify-config presence — each reported with its
|
|
207
|
+
exact remediation command.
|
|
208
|
+
|
|
209
|
+
--env also check the local .env against the declared names and
|
|
210
|
+
enums. Reports membership only; a rejected value is never
|
|
211
|
+
printed, so this is safe to run and to paste.
|
|
132
212
|
|
|
133
|
-
A driver over the existing commands; it adds no
|
|
213
|
+
A driver over the existing commands and lib/runtime.js; it adds no
|
|
214
|
+
checks of its own, so it can never disagree with the gates it fronts.
|
|
134
215
|
Read-only. Exits 1 when any area fails, 0 otherwise (warnings allowed).
|
|
135
216
|
`;
|
package/src/commands/handoff.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { exists } from "../lib/fs-ops.js";
|
|
@@ -6,6 +7,7 @@ import { computeActions } from "./next.js";
|
|
|
6
7
|
import { openChanges } from "./prime.js";
|
|
7
8
|
import { today } from "../lib/dates.js";
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
10
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
9
11
|
|
|
10
12
|
// Session handoff note: everything the NEXT session (a fresh agent, a
|
|
11
13
|
// teammate, tomorrow's you) needs to resume without re-reading the tree —
|
|
@@ -16,10 +18,15 @@ import { c } from "../lib/colors.js";
|
|
|
16
18
|
// a stored file: a saved note goes stale the moment work continues, the
|
|
17
19
|
// tree never does (no new home for facts).
|
|
18
20
|
|
|
21
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
22
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
23
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
24
|
+
export const flags = { boolean: ["json"], string: [] };
|
|
25
|
+
|
|
19
26
|
export async function run(_positional, _flags) {
|
|
20
27
|
const projectRoot = process.cwd();
|
|
21
28
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
22
|
-
throw
|
|
29
|
+
throw notADoctrinaProject();
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
const s = collectStatus(projectRoot);
|
package/src/commands/hooks.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { chmodSync } from "node:fs";
|
|
@@ -9,6 +10,11 @@ import { suggest } from "../lib/suggest.js";
|
|
|
9
10
|
|
|
10
11
|
const SUBCOMMANDS = ["install"];
|
|
11
12
|
|
|
13
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
14
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
15
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
16
|
+
export const flags = { boolean: ["json", "force"], string: [] };
|
|
17
|
+
|
|
12
18
|
export async function run(positional, flags) {
|
|
13
19
|
const sub = positional[0];
|
|
14
20
|
if (!SUBCOMMANDS.includes(sub)) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { exists } from "../lib/fs-ops.js";
|
|
@@ -8,9 +9,15 @@ import { today } from "../lib/dates.js";
|
|
|
8
9
|
import { flagBool } from "../lib/args.js";
|
|
9
10
|
import { c } from "../lib/colors.js";
|
|
10
11
|
import { suggest } from "../lib/suggest.js";
|
|
12
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
11
13
|
|
|
12
14
|
const SUBCOMMANDS = ["rebuild"];
|
|
13
15
|
|
|
16
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
17
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
18
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
19
|
+
export const flags = { boolean: ["json", "check"], string: [] };
|
|
20
|
+
|
|
14
21
|
export async function run(positional, flags) {
|
|
15
22
|
const sub = positional[0];
|
|
16
23
|
if (sub !== "rebuild") {
|
|
@@ -28,7 +35,7 @@ function rebuild(flags) {
|
|
|
28
35
|
const check = flagBool(flags, "check", false);
|
|
29
36
|
const projectRoot = process.cwd();
|
|
30
37
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
31
|
-
throw
|
|
38
|
+
throw notADoctrinaProject();
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
let current = null;
|