doctrina-cli 0.15.1 → 0.16.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 +1 -1
- package/src/commands/adapter.js +24 -1
- package/src/commands/analyze.js +8 -212
- package/src/commands/change.js +143 -217
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +58 -21
- package/src/commands/close.js +291 -42
- package/src/commands/constitution.js +14 -91
- package/src/commands/context.js +278 -107
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +54 -7
- package/src/commands/doctor.js +292 -129
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +11 -2
- package/src/commands/index-rebuild.js +9 -48
- package/src/commands/init.js +70 -13
- package/src/commands/intake.js +30 -92
- package/src/commands/metrics.js +53 -84
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +24 -91
- package/src/commands/report.js +50 -108
- package/src/commands/review.js +68 -36
- package/src/commands/search.js +18 -4
- package/src/commands/show.js +24 -9
- package/src/commands/skill.js +16 -25
- package/src/commands/spec.js +26 -5
- package/src/commands/status.js +51 -172
- package/src/commands/templates.js +31 -138
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/validate.js +14 -871
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +234 -263
- package/src/index.js +106 -7
- package/src/lib/actions.js +484 -0
- package/src/lib/adapters.js +0 -11
- package/src/lib/adr-guard.js +19 -0
- package/src/lib/agent-changelog.js +133 -0
- package/src/lib/analysis.js +264 -0
- package/src/lib/change-ops.js +131 -0
- package/src/lib/commands.js +56 -4
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +392 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +326 -0
- package/src/lib/docs-impact.js +196 -27
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +181 -23
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +2 -19
- package/src/lib/intake-model.js +86 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +191 -0
- package/src/lib/metrics-model.js +159 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +57 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +204 -13
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +209 -0
- package/src/lib/spec-ops.js +50 -5
- package/src/lib/templates-model.js +262 -0
- package/src/lib/trace-model.js +95 -0
- package/src/lib/triage-model.js +121 -0
- package/src/lib/usage.js +6 -4
- package/src/lib/validation-model.js +1299 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +410 -0
- package/src/lib/work-model.js +77 -0
- package/templates/AGENTS.md.template +20 -26
- package/templates/README.md +35 -0
- package/templates/change/proposal.md.template +1 -0
- package/templates/hooks/pre-commit.sample +6 -2
- package/templates/playbooks/bootstrap.md.template +43 -0
- package/templates/playbooks/chore.md.template +19 -0
- package/templates/playbooks/work.md.template +76 -0
package/src/commands/decision.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import { getHeader, setHeader } from "../lib/doc-model.js";
|
|
2
|
+
import { unwrittenSections, getHeader, setHeader } from "../lib/doc-model.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import { exists, read, relPath, write } from "../lib/fs-ops.js";
|
|
@@ -9,7 +9,7 @@ import * as idx from "../lib/index-json.js";
|
|
|
9
9
|
import { today, slugify, padNumber } from "../lib/dates.js";
|
|
10
10
|
import { c } from "../lib/colors.js";
|
|
11
11
|
import { suggest } from "../lib/suggest.js";
|
|
12
|
-
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
|
+
import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
|
|
13
13
|
import { flagBool } from "../lib/args.js";
|
|
14
14
|
import { parseAdrScope, decisionEntry } from "../lib/scan.js";
|
|
15
15
|
|
|
@@ -100,7 +100,7 @@ function decisionSupersede(args) {
|
|
|
100
100
|
const oldFile = files.find((f) => path.basename(f).startsWith(`${padded}-`));
|
|
101
101
|
if (!oldFile) {
|
|
102
102
|
console.error(c.red("error:") + ` no ADR found with number ${padded} in ${relPath(projectRoot, adrDir)}`);
|
|
103
|
-
return
|
|
103
|
+
return EXIT.USAGE;
|
|
104
104
|
}
|
|
105
105
|
const oldText = read(oldFile);
|
|
106
106
|
const oldStatusValue = getHeader(oldText, "Status");
|
|
@@ -113,6 +113,14 @@ function decisionSupersede(args) {
|
|
|
113
113
|
console.error(c.red("error:") + ` ADR ${padded} is already superseded`);
|
|
114
114
|
return 1;
|
|
115
115
|
}
|
|
116
|
+
// Only a decision that HELD can be superseded (change 0109). A proposed
|
|
117
|
+
// ADR was never a rule; superseding it builds a chain of decisions that
|
|
118
|
+
// never applied. A proposal that fell is edited to `rejected`, or deleted.
|
|
119
|
+
if (oldStatus.trim().toLowerCase() !== "accepted") {
|
|
120
|
+
console.error(c.red("error:") + ` ADR ${padded} is "${oldStatus.trim()}", not "accepted" — only an accepted decision is superseded`);
|
|
121
|
+
console.error(c.gray("hint: ") + "a proposal that fell is not superseded: set its Status: to rejected, or delete the file");
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
116
124
|
|
|
117
125
|
// Title for new ADR is read from argv after the target number, or prompted.
|
|
118
126
|
const title = args.slice(1).join(" ").trim();
|
|
@@ -120,6 +128,13 @@ function decisionSupersede(args) {
|
|
|
120
128
|
console.error(c.red("error:") + " supply the new ADR title as the second argument");
|
|
121
129
|
return 2;
|
|
122
130
|
}
|
|
131
|
+
// A title that is only digits is the argument order swapped, not a
|
|
132
|
+
// decision (change 0109): `supersede 0001 0002` made an ADR titled "0002".
|
|
133
|
+
if (/^\d+$/.test(title)) {
|
|
134
|
+
console.error(c.red("error:") + ` "${title}" is a number, not a title`);
|
|
135
|
+
console.error(c.gray("hint: ") + "the grammar is `doctrina decision supersede <number> \"<title>\"` — the target first, then the new ADR's title");
|
|
136
|
+
return 2;
|
|
137
|
+
}
|
|
123
138
|
|
|
124
139
|
// Create the new ADR using the same path the `new` flow uses.
|
|
125
140
|
const next = nextDecisionNumber(projectRoot);
|
|
@@ -175,7 +190,7 @@ function decisionAccept(args) {
|
|
|
175
190
|
const file = walk(adrDir).find((f) => path.basename(f).startsWith(`${padded}-`));
|
|
176
191
|
if (!file) {
|
|
177
192
|
console.error(c.red("error:") + ` no ADR found with number ${padded} in ${relPath(projectRoot, adrDir)}`);
|
|
178
|
-
return
|
|
193
|
+
return EXIT.USAGE;
|
|
179
194
|
}
|
|
180
195
|
const text = read(file);
|
|
181
196
|
const statusValue = getHeader(text, "Status");
|
|
@@ -189,13 +204,38 @@ function decisionAccept(args) {
|
|
|
189
204
|
return 1;
|
|
190
205
|
}
|
|
191
206
|
|
|
207
|
+
// An accepted ADR is IMMUTABLE, becomes a standing rule in `prime --rules`,
|
|
208
|
+
// and loads into every context pack it is scoped to (ADR 0022). Accepting
|
|
209
|
+
// one whose body is still the template was accepting a decision nobody had
|
|
210
|
+
// written down (change 0065). `analyze` has had exactly this guard for a
|
|
211
|
+
// change proposal since the beginning; the more consequential document was
|
|
212
|
+
// the one without it.
|
|
213
|
+
const unwritten = unwrittenSections(text, ["Context", "Decision", "Consequences"], {
|
|
214
|
+
template: "decision.md.template",
|
|
215
|
+
});
|
|
216
|
+
if (unwritten.length > 0) {
|
|
217
|
+
console.error(c.red("error:") +
|
|
218
|
+
` ADR ${padded} still carries the template in ${unwritten.map((x) => `## ${x}`).join(", ")}`);
|
|
219
|
+
console.error(c.gray("hint: ") +
|
|
220
|
+
`write the decision before accepting it — an accepted ADR is immutable, becomes a ` +
|
|
221
|
+
`standing rule, and loads into every pack it governs (${relPath(projectRoot, file)})`);
|
|
222
|
+
return 1;
|
|
223
|
+
}
|
|
224
|
+
|
|
192
225
|
// Mutate ONLY the Status: header; the body stays immutable.
|
|
193
226
|
write(file, setHeader(text, "Status", "accepted") ?? text, { force: true });
|
|
194
227
|
console.log(c.green("accepted") + ` ${relPath(projectRoot, file)}`);
|
|
195
228
|
|
|
196
229
|
const date = today();
|
|
197
230
|
const index = idx.load(projectRoot);
|
|
198
|
-
|
|
231
|
+
// Re-derive the whole entry, not just the status. Between `decision new` and
|
|
232
|
+
// `decision accept` the author writes the body — which is now REQUIRED, so
|
|
233
|
+
// it is the normal flow, not the exception — and the entry's summary and
|
|
234
|
+
// scope are derived from that body. Updating only the status left the index
|
|
235
|
+
// holding the scaffold's summary, and `validate` reported drift the author
|
|
236
|
+
// had no reason to expect (change 0065).
|
|
237
|
+
const accepted = decisionEntry(read(file), path.basename(file), null, date);
|
|
238
|
+
idx.updateDecision(index, padded, () => accepted);
|
|
199
239
|
idx.touch(index, date);
|
|
200
240
|
idx.save(projectRoot, index);
|
|
201
241
|
console.log(c.green("indexed") + ` decision ${padded} -> accepted`);
|
|
@@ -223,7 +263,7 @@ function decisionLand(args) {
|
|
|
223
263
|
const file = walk(adrDir).find((f) => path.basename(f).startsWith(`${padded}-`));
|
|
224
264
|
if (!file) {
|
|
225
265
|
console.error(c.red("error:") + ` no ADR found with number ${padded} in ${relPath(projectRoot, adrDir)}`);
|
|
226
|
-
return
|
|
266
|
+
return EXIT.USAGE;
|
|
227
267
|
}
|
|
228
268
|
const text = read(file);
|
|
229
269
|
const statusValue = getHeader(text, "Status");
|
|
@@ -372,7 +412,14 @@ function decisionScope(args, cmdFlags) {
|
|
|
372
412
|
}
|
|
373
413
|
|
|
374
414
|
if (rows.length === 0) {
|
|
375
|
-
|
|
415
|
+
// A number that names no ADR is a wrong invocation, not an empty tree:
|
|
416
|
+
// reporting it as success was the one place in this family that
|
|
417
|
+
// APPROVED an unresolvable reference (change 0093).
|
|
418
|
+
if (wanted) {
|
|
419
|
+
console.error(c.red("error:") + ` no ADR ${wanted} under .doctrina/decisions/`);
|
|
420
|
+
return EXIT.USAGE;
|
|
421
|
+
}
|
|
422
|
+
console.log(c.gray("no ADRs found in .doctrina/decisions/"));
|
|
376
423
|
return 0;
|
|
377
424
|
}
|
|
378
425
|
|
package/src/commands/doctor.js
CHANGED
|
@@ -1,24 +1,40 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { collectStatus } from "./status.js";
|
|
8
|
-
import { collectFindings } from "./templates.js";
|
|
4
|
+
import { exists, isFile, read, relPath } from "../lib/fs-ops.js";
|
|
5
|
+
import { collectStatus } from "../lib/snapshot.js";
|
|
6
|
+
import { collectFindings, surfaceBudget } from "../lib/templates-model.js";
|
|
9
7
|
import { c } from "../lib/colors.js";
|
|
10
8
|
import { flagBool } from "../lib/args.js";
|
|
11
9
|
import { collectRuntimeFindings, checkLocalEnv } from "../lib/runtime.js";
|
|
10
|
+
import { sequence, stepRerun } from "../lib/gates.js";
|
|
12
11
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
|
+
import { agentsMdBudget, collectValidation } from "../lib/validation-model.js";
|
|
13
|
+
import { collectIndexDrift } from "../lib/scan.js";
|
|
14
|
+
import { collectReproducibility } from "../lib/reproducibility.js";
|
|
15
|
+
import { configRows, loadConfig } from "../lib/config.js";
|
|
16
|
+
import { USAGE_ENV, summarise } from "../lib/usage.js";
|
|
17
|
+
import { OPERATIONS } from "../lib/commands.js";
|
|
13
18
|
|
|
19
|
+
import { coverageLabel } from "../lib/views.js";
|
|
20
|
+
|
|
21
|
+
// How many never-invoked operations the usage row names before pointing at
|
|
22
|
+
// `metrics --commands` for the rest. The spec states this number, so it is
|
|
23
|
+
// named here rather than repeated as a literal in three places.
|
|
24
|
+
const UNUSED_OPS_LISTED = 8;
|
|
14
25
|
// Aggregate diagnostic: the one command to run when "something looks wrong"
|
|
15
26
|
// and you do not know which gate to ask. It sequences the existing checks —
|
|
16
27
|
// validate, index drift, clean-checkout lint, template shape — and reports
|
|
17
28
|
// each with its exact remediation command. A DRIVER over existing commands
|
|
18
29
|
// (like `close`): it adds no checks of its own, so it can never disagree
|
|
19
30
|
// with the authoritative gates it fronts.
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
//
|
|
32
|
+
// Every row is a COLLECTION read in this process (change 0045). Three of them
|
|
33
|
+
// used to be a `spawnSync` of this same binary with `--json`, whose output
|
|
34
|
+
// this command then parsed — two integration styles inside one command, three
|
|
35
|
+
// extra Node processes per run, and a failure mode — the report that never
|
|
36
|
+
// arrived — that existed only because of the choice. A driver calls its
|
|
37
|
+
// collectors.
|
|
22
38
|
|
|
23
39
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
24
40
|
// adding a command never requires editing the entrypoint — the gap that
|
|
@@ -43,73 +59,125 @@ export async function run(_positional, _flags) {
|
|
|
43
59
|
if (state === "fail") failures += 1;
|
|
44
60
|
};
|
|
45
61
|
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
vParsed = JSON.parse(v.stdout);
|
|
51
|
-
} catch {
|
|
52
|
-
vParsed = null;
|
|
53
|
-
}
|
|
54
|
-
if (!vParsed) {
|
|
55
|
-
row("fail", "validate", "did not produce a report", "doctrina validate");
|
|
56
|
-
} else if (!vParsed.ok) {
|
|
57
|
-
row("fail", "validate", `${vParsed.errors.length} error${vParsed.errors.length === 1 ? "" : "s"}, ${vParsed.warnings.length} warning${vParsed.warnings.length === 1 ? "" : "s"}`, "doctrina validate (then `doctrina validate --fix` for index drift)");
|
|
58
|
-
for (const e of vParsed.errors.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e}`);
|
|
59
|
-
} else if (vParsed.warnings.length > 0) {
|
|
60
|
-
warningsTotal += vParsed.warnings.length;
|
|
61
|
-
row("warn", "validate", `0 errors, ${vParsed.warnings.length} warning${vParsed.warnings.length === 1 ? "" : "s"}`, "doctrina validate (warnings listed there)");
|
|
62
|
-
} else {
|
|
63
|
-
row("ok", "validate", "all structural checks pass");
|
|
64
|
-
}
|
|
62
|
+
// status is read once and shared: three rows below are views of the same
|
|
63
|
+
// collection, and re-deriving it per row would make them disagree.
|
|
64
|
+
const s = collectStatus(projectRoot);
|
|
65
65
|
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
// How each declared diagnostic row renders, keyed by the step id in
|
|
67
|
+
// SEQUENCES.doctor. As in `close`, the DECLARATION owns which rows exist
|
|
68
|
+
// and in what order (lib/gates.js, audit finding F1); this table owns only
|
|
69
|
+
// how one row is computed. A step with no reporter here falls back to
|
|
70
|
+
// running its declared argv and reporting the exit code, so a gate added to
|
|
71
|
+
// the sequence shows up as a row without a second edit.
|
|
72
|
+
const reporters = {
|
|
70
73
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
row("ok", "trace", `${s.trace.realized}/${s.trace.anchors} anchors realized`);
|
|
87
|
-
} else {
|
|
88
|
-
row("warn", "trace", `${s.trace.realized}/${s.trace.anchors} realized, ${s.trace.dropped} dropped, ${s.trace.untraceable} untraceable`, "doctrina trace");
|
|
89
|
-
warningsTotal += 1;
|
|
90
|
-
}
|
|
74
|
+
// 1. Structural gate (validate), machine-read so the counts are exact.
|
|
75
|
+
validate: () => {
|
|
76
|
+
// Read-only: `doctor` never passes fix — a diagnostic that repaired the
|
|
77
|
+
// tree while reporting on it could not be run to find out what is wrong.
|
|
78
|
+
const { errors, warnings } = collectValidation(projectRoot);
|
|
79
|
+
if (errors.length > 0) {
|
|
80
|
+
row("fail", "validate", `${errors.length} error${errors.length === 1 ? "" : "s"}, ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`, "doctrina validate (then `doctrina validate --fix` for index drift)");
|
|
81
|
+
for (const e of errors.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e}`);
|
|
82
|
+
} else if (warnings.length > 0) {
|
|
83
|
+
warningsTotal += warnings.length;
|
|
84
|
+
row("warn", "validate", `0 errors, ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`, "doctrina validate (warnings listed there)");
|
|
85
|
+
} else {
|
|
86
|
+
row("ok", "validate", "all structural checks pass");
|
|
87
|
+
}
|
|
88
|
+
},
|
|
91
89
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
90
|
+
// 2. Index ↔ tree drift.
|
|
91
|
+
index: () => {
|
|
92
|
+
const drift = collectIndexDrift(projectRoot);
|
|
93
|
+
if (drift.ok) row("ok", "index", "index.json matches the tree");
|
|
94
|
+
else row("fail", "index", "index.json has drifted from the tree", "doctrina validate --fix (or `doctrina index rebuild`)");
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
// The two budgets that are COUPLED, reported before either is breached
|
|
98
|
+
// (change 0072). The generated surface block lives inside AGENTS.md, so
|
|
99
|
+
// one command added to the catalog spends a line of both — and the
|
|
100
|
+
// AGENTS.md ceiling is declared OUTPUT, which means `analyze` refuses the
|
|
101
|
+
// raise-the-ceiling fix by design. The only remedy is to send less, and
|
|
102
|
+
// knowing that a week before the warning fires is the difference between
|
|
103
|
+
// choosing what to cut and cutting whatever is nearest.
|
|
104
|
+
budgets: () => {
|
|
105
|
+
// Both numbers come from their OWNER — `agentsMdBudget` and
|
|
106
|
+
// `surfaceBudget` — never from a second count here, so this row can
|
|
107
|
+
// never disagree with `validate` or `templates check` about a size
|
|
108
|
+
// they all report.
|
|
109
|
+
const agents = agentsMdBudget(projectRoot);
|
|
110
|
+
const surface = surfaceBudget(projectRoot);
|
|
111
|
+
// The coupling: the block is written INTO AGENTS.md, so the next
|
|
112
|
+
// command added to the catalog spends a line of each. What is left is
|
|
113
|
+
// therefore the smaller of the two slacks, not either one alone.
|
|
114
|
+
const headroom = Math.min(agents.slack, surface.slack);
|
|
115
|
+
const detail = `AGENTS.md ${agents.used}/${agents.soft} lines, ` +
|
|
116
|
+
`surface block ${surface.used}/${surface.budget}`;
|
|
117
|
+
const cost = `${headroom} line${headroom === 1 ? "" : "s"} of headroom — ` +
|
|
118
|
+
"one command added to the catalog spends a line of each";
|
|
119
|
+
if (agents.used > agents.soft || surface.used > surface.budget) {
|
|
120
|
+
row("warn", "budgets", `${detail} — over a declared ceiling`,
|
|
121
|
+
"cut prose from AGENTS.md or commands from the surface — both are OUTPUT budgets, so raising them is refused");
|
|
122
|
+
warningsTotal += 1;
|
|
123
|
+
} else if (headroom <= 1) {
|
|
124
|
+
row("warn", "budgets", `${detail} — ${cost}`,
|
|
125
|
+
"cut prose from AGENTS.md now, while there is still a choice about what goes");
|
|
126
|
+
warningsTotal += 1;
|
|
127
|
+
} else {
|
|
128
|
+
row("ok", "budgets", `${detail} · ${cost}`);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
// 3. Evidence and provenance ratios (cheap summaries; strict gates confirm).
|
|
133
|
+
coverage: () => {
|
|
134
|
+
if (s.coverage.totalCriteria === 0) {
|
|
135
|
+
row("warn", "coverage", "no acceptance criteria declared yet", "write criteria with evidence — see `doctrina coverage`");
|
|
136
|
+
warningsTotal += 1;
|
|
137
|
+
} else if (s.coverage.pct === 100 && s.coverage.totalDangling === 0 && s.coverage.totalConditional === 0) {
|
|
138
|
+
row("ok", "coverage", coverageLabel(s.coverage));
|
|
139
|
+
} else {
|
|
140
|
+
row("warn", "coverage", `${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria}${s.coverage.totalDangling ? `, ${s.coverage.totalDangling} dangling` : ""}${s.coverage.totalConditional ? `, ${s.coverage.totalConditional} conditional` : ""})`, "doctrina coverage (cite the missing evidence)");
|
|
141
|
+
warningsTotal += 1;
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
trace: () => {
|
|
146
|
+
if (s.trace.anchors === 0) {
|
|
147
|
+
row("warn", "trace", "no intent anchors declared in product.md", "tag bullets `- [SC1] ...`, add `**Realizes:**` to specs");
|
|
148
|
+
warningsTotal += 1;
|
|
149
|
+
} else if (s.trace.realized === s.trace.anchors && s.trace.untraceable === 0 && s.trace.dangling === 0) {
|
|
150
|
+
row("ok", "trace", `${s.trace.realized}/${s.trace.anchors} anchors realized`);
|
|
151
|
+
} else {
|
|
152
|
+
row("warn", "trace", `${s.trace.realized}/${s.trace.anchors} realized, ${s.trace.dropped} dropped, ${s.trace.untraceable} untraceable`, "doctrina trace");
|
|
153
|
+
warningsTotal += 1;
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
// 4. Clean-checkout reproducibility lint (ADR 0008).
|
|
158
|
+
"clean-checkout": () => {
|
|
159
|
+
const clean = collectReproducibility(projectRoot);
|
|
160
|
+
if (clean.findings.length === 0) row("ok", "clean-checkout", "no reproducibility footguns detected");
|
|
161
|
+
else row("fail", "clean-checkout", "a fresh clone would not build/run as-is", "doctrina verify --clean (fix the listed package.json footguns)");
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
// 5. Template shape vs the running CLI's recommendation. The row reports
|
|
165
|
+
// the ACTUAL findings and the remedy each one names, rather than
|
|
166
|
+
// assuming every failure is a missing section — an adapter pointer
|
|
167
|
+
// break was reported as "recommended sections/fields are missing" and
|
|
168
|
+
// sent the user to `templates update`, which does not touch adapters
|
|
169
|
+
// and so could never clear it (C2).
|
|
170
|
+
templates: () => {
|
|
171
|
+
let tmplFindings = [];
|
|
172
|
+
try {
|
|
173
|
+
tmplFindings = collectFindings(projectRoot).findings;
|
|
174
|
+
} catch {
|
|
175
|
+
tmplFindings = [];
|
|
176
|
+
}
|
|
177
|
+
if (tmplFindings.length === 0) {
|
|
178
|
+
row("ok", "templates", "project follows the current template shape");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
113
181
|
// Group by remedy so the row names what actually fixes what.
|
|
114
182
|
const byRemedy = new Map();
|
|
115
183
|
for (const f of tmplFindings) {
|
|
@@ -122,57 +190,158 @@ export async function run(_positional, _flags) {
|
|
|
122
190
|
const remedy = [...byRemedy.keys()].filter((k) => k !== "(manual repair)");
|
|
123
191
|
row("warn", "templates", summary, remedy.length === 1 ? remedy[0] : "doctrina templates check (each finding names its own fix)");
|
|
124
192
|
warningsTotal += 1;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
193
|
+
},
|
|
127
194
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
195
|
+
// 6. The RUNTIME surface (change 0029). Every row above reads Markdown;
|
|
196
|
+
// this one reads what the Markdown CLAIMS about the running system and
|
|
197
|
+
// checks it — the wiring, the empty-vs-unset default, the enum, the
|
|
198
|
+
// selector that would match nothing. Still a driver: the checks belong
|
|
199
|
+
// to lib/runtime.js and `contract check` renders the same verdict.
|
|
200
|
+
runtime: () => {
|
|
201
|
+
const runtime = collectRuntimeFindings(projectRoot);
|
|
202
|
+
const errs = runtime.findings.filter((f) => f.level === "error");
|
|
203
|
+
const warns = runtime.findings.length - errs.length;
|
|
204
|
+
if (runtime.contracts === 0) {
|
|
205
|
+
row("ok", "runtime", "no contracts — nothing declares a runtime surface");
|
|
206
|
+
} else if (errs.length > 0) {
|
|
207
|
+
// An error is an error whether or not any Wiring/Selectors row was
|
|
208
|
+
// declared: the structural half (CT01-CT03, change 0103) fails a
|
|
209
|
+
// contract that declares no rows at all, and "unchecked" would
|
|
210
|
+
// hide it.
|
|
211
|
+
row("fail", "runtime", `${errs.length} finding${errs.length === 1 ? "" : "s"} do${errs.length === 1 ? "es" : ""} not hold`, "doctrina contract check (each finding names its own fix)");
|
|
212
|
+
for (const e of errs.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e.code} ${e.message}`);
|
|
213
|
+
} else if (runtime.declared === 0) {
|
|
214
|
+
// Silence is not proof: an undeclared surface is UNCHECKED, and a row
|
|
215
|
+
// that read "ok" here would be the exact false confidence this
|
|
216
|
+
// command exists to prevent.
|
|
217
|
+
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`");
|
|
218
|
+
warningsTotal += 1;
|
|
219
|
+
} else if (warns > 0) {
|
|
220
|
+
row("warn", "runtime", `${runtime.declared} declared row${runtime.declared === 1 ? " holds" : "s hold"}; ${warns} advisory finding${warns === 1 ? "" : "s"}`, "doctrina triage");
|
|
221
|
+
warningsTotal += 1;
|
|
222
|
+
} else {
|
|
223
|
+
row("ok", "runtime", `${runtime.declared} declared row${runtime.declared === 1 ? " holds" : "s hold"}`);
|
|
224
|
+
}
|
|
225
|
+
},
|
|
155
226
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
227
|
+
// 7. The local .env, declared with `flag: "env"` so the sequence carries
|
|
228
|
+
// it and the loop skips it unless asked. Off by default because it
|
|
229
|
+
// reads a file that is not committed and is none of CI's business;
|
|
230
|
+
// when asked for, it reports NAMES and enum membership only, never the
|
|
231
|
+
// value.
|
|
232
|
+
"local-env": () => {
|
|
233
|
+
const envFindings = checkLocalEnv(projectRoot);
|
|
234
|
+
if (envFindings.length === 0) {
|
|
235
|
+
row("ok", "local .env", "matches the declared names and enums (or no .env present)");
|
|
236
|
+
} else {
|
|
237
|
+
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)");
|
|
238
|
+
for (const e of envFindings.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e.message}`);
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
// 8. Verify configuration (the real build gate must exist to be run).
|
|
243
|
+
"verify-config": () => {
|
|
244
|
+
if (s.verify.invalid) row("fail", "verify config", ".doctrina/verify.json is invalid JSON", "fix the JSON, then `doctrina verify`");
|
|
245
|
+
else if (!s.verify.configured) {
|
|
246
|
+
row("warn", "verify config", "no verify.json — the build gate is undeclared", "doctrina verify --init");
|
|
247
|
+
warningsTotal += 1;
|
|
248
|
+
} else {
|
|
249
|
+
// Executed proof and signed proof are different claims (change 0039):
|
|
250
|
+
// a row that counts them together is the false confidence this
|
|
251
|
+
// command exists to prevent.
|
|
252
|
+
const so = s.verify.signoffs;
|
|
253
|
+
const stale = so ? so.expired + so.unverifiable + so.pending : 0;
|
|
254
|
+
const detail = `${s.verify.checks} check${s.verify.checks === 1 ? "" : "s"} declared` +
|
|
255
|
+
(so && so.manual > 0 ? `, ${so.manual} signed rather than executed` : "") +
|
|
256
|
+
" (run `doctrina verify` to execute)";
|
|
257
|
+
if (stale > 0) {
|
|
258
|
+
const parts = [];
|
|
259
|
+
if (so.expired) parts.push(`${so.expired} expired`);
|
|
260
|
+
if (so.unverifiable) parts.push(`${so.unverifiable} unverifiable`);
|
|
261
|
+
if (so.pending) parts.push(`${so.pending} unsigned`);
|
|
262
|
+
row("warn", "verify config", `${detail} — ${parts.join(", ")}`,
|
|
263
|
+
"doctrina verify (each names the re-sign command)");
|
|
264
|
+
warningsTotal += 1;
|
|
265
|
+
} else {
|
|
266
|
+
row("ok", "verify config", detail);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
},
|
|
168
270
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
271
|
+
// 9. What this project has CONFIGURED, and what is simply the default.
|
|
272
|
+
// Never a failure: it reports the effective value of each option and
|
|
273
|
+
// the file it came from, so "why is clarify using English here?" has
|
|
274
|
+
// an answer that does not involve reading the CLI's source.
|
|
275
|
+
config: () => {
|
|
276
|
+
const rows = configRows(projectRoot);
|
|
277
|
+
const set = rows.filter((r) => r.configured);
|
|
278
|
+
// The FILE first, then the values (change 0115): over an invalid
|
|
279
|
+
// config.json this row said "ok, all at their defaults" — true of the
|
|
280
|
+
// values, false of the file, on the same screen where the validate
|
|
281
|
+
// row failed it. A key the CLI does not know is named, not ignored.
|
|
282
|
+
const cfg = loadConfig(projectRoot);
|
|
283
|
+
const summary = set.length === 0
|
|
284
|
+
? `all ${rows.length} options at their defaults`
|
|
285
|
+
: `${set.length} of ${rows.length} options configured`;
|
|
286
|
+
if (cfg.errors.length > 0) {
|
|
287
|
+
row("fail", "config", cfg.errors[0], "fix .doctrina/config.json, then `doctrina validate`");
|
|
288
|
+
} else if (cfg.unknown.length > 0) {
|
|
289
|
+
row("warn", "config", `${summary}; unknown key${cfg.unknown.length === 1 ? "" : "s"} ${cfg.unknown.map((k) => `"${k}"`).join(", ")} ignored`, "the keys are language, context_budget, rules");
|
|
290
|
+
warningsTotal += 1;
|
|
291
|
+
} else {
|
|
292
|
+
row("ok", "config", summary);
|
|
293
|
+
}
|
|
294
|
+
for (const r of rows) {
|
|
295
|
+
console.log(` ${" ".repeat(16)} ${c.gray("·")} ${r.option.padEnd(15)} ${r.value.padEnd(22)} ${c.gray(r.source)}`);
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
// 10. Which of the catalog's operations have never been reached for —
|
|
300
|
+
// but ONLY when the operator turned the log on. `lib/usage.js` was
|
|
301
|
+
// built to instrument the surface before shrinking it, and the only
|
|
302
|
+
// reader was a command someone had to think to type, so the decision
|
|
303
|
+
// it exists to inform kept being taken on opinion (audit finding
|
|
304
|
+
// F16). It reports and never writes: no log file is created here.
|
|
305
|
+
usage: () => {
|
|
306
|
+
const target = process.env[USAGE_ENV];
|
|
307
|
+
if (!target || !isFile(target)) return;
|
|
308
|
+
const catalog = OPERATIONS.map(([op]) => op);
|
|
309
|
+
const { samples, unused } = summarise(read(target), catalog);
|
|
310
|
+
if (samples === 0) {
|
|
311
|
+
row("ok", "usage", `${relPath(projectRoot, target)} is empty — nothing recorded yet`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const detail = `${samples} sample${samples === 1 ? "" : "s"}; ` +
|
|
315
|
+
`${unused.length} of ${catalog.length} operations never invoked`;
|
|
316
|
+
row("ok", "usage", detail);
|
|
317
|
+
// Zero samples for an operation is a CANDIDATE, never a verdict: a
|
|
318
|
+
// command reached for once a quarter and one nobody wants look
|
|
319
|
+
// identical over a week. ADR 0026: retiring one needs demonstrated
|
|
320
|
+
// redundancy, and this list is where you go looking for it.
|
|
321
|
+
for (const op of unused.slice(0, UNUSED_OPS_LISTED)) {
|
|
322
|
+
console.log(` ${" ".repeat(16)} ${c.gray("·")} never invoked: ${op}`);
|
|
323
|
+
}
|
|
324
|
+
if (unused.length > UNUSED_OPS_LISTED) {
|
|
325
|
+
console.log(` ${" ".repeat(16)} ${c.gray(`· … ${unused.length - UNUSED_OPS_LISTED} more — doctrina metrics --commands`)}`);
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// The sequence, in the declared order. A step gated on a flag is skipped
|
|
331
|
+
// silently when that flag is absent. A step declared without a reporter is
|
|
332
|
+
// reported as UNCHECKED and names the command that answers it: this command
|
|
333
|
+
// reads collections, and a row it cannot compute here is a gap in this file,
|
|
334
|
+
// not something to shell out for. Silence would be the false confidence the
|
|
335
|
+
// whole diagnostic exists to prevent.
|
|
336
|
+
for (const step of sequence("doctor")) {
|
|
337
|
+
if (step.flag && !flagBool(_flags, step.flag, false)) continue;
|
|
338
|
+
const report = reporters[step.id];
|
|
339
|
+
if (report) {
|
|
340
|
+
report();
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
row("warn", step.label, "declared in the doctor sequence but has no reporter here", stepRerun(step));
|
|
173
344
|
warningsTotal += 1;
|
|
174
|
-
} else {
|
|
175
|
-
row("ok", "verify config", `${s.verify.checks} check${s.verify.checks === 1 ? "" : "s"} declared (run \`doctrina verify\` to execute)`);
|
|
176
345
|
}
|
|
177
346
|
|
|
178
347
|
console.log("");
|
|
@@ -188,14 +357,6 @@ export async function run(_positional, _flags) {
|
|
|
188
357
|
return 0;
|
|
189
358
|
}
|
|
190
359
|
|
|
191
|
-
function runSelf(args, cwd) {
|
|
192
|
-
return spawnSync(process.execPath, [cliEntry, ...args], {
|
|
193
|
-
cwd,
|
|
194
|
-
encoding: "utf8",
|
|
195
|
-
env: { ...process.env, NO_COLOR: "1" },
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
|
|
199
360
|
export const help = `
|
|
200
361
|
Usage: doctrina doctor
|
|
201
362
|
|
|
@@ -210,7 +371,9 @@ exact remediation command.
|
|
|
210
371
|
enums. Reports membership only; a rejected value is never
|
|
211
372
|
printed, so this is safe to run and to paste.
|
|
212
373
|
|
|
213
|
-
A driver over the
|
|
214
|
-
checks of its own, so it can never disagree with the gates it fronts
|
|
215
|
-
|
|
374
|
+
A driver over the same collections the gates themselves render; it adds
|
|
375
|
+
no checks of its own, so it can never disagree with the gates it fronts,
|
|
376
|
+
and it runs entirely in this process — no subprocess, no re-parsing of
|
|
377
|
+
its own output. Read-only: it never repairs what it reports. Exits 1
|
|
378
|
+
when any area fails, 0 otherwise (warnings allowed).
|
|
216
379
|
`;
|