doctrina-cli 0.15.0 → 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.
Files changed (85) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +24 -1
  4. package/src/commands/analyze.js +8 -199
  5. package/src/commands/change.js +143 -217
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +58 -21
  8. package/src/commands/close.js +291 -42
  9. package/src/commands/constitution.js +14 -91
  10. package/src/commands/context.js +278 -107
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +54 -7
  14. package/src/commands/doctor.js +292 -129
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +11 -2
  17. package/src/commands/index-rebuild.js +9 -48
  18. package/src/commands/init.js +70 -13
  19. package/src/commands/intake.js +30 -92
  20. package/src/commands/metrics.js +53 -84
  21. package/src/commands/next.js +105 -147
  22. package/src/commands/prime.js +24 -91
  23. package/src/commands/report.js +50 -108
  24. package/src/commands/review.js +68 -36
  25. package/src/commands/search.js +18 -4
  26. package/src/commands/show.js +24 -9
  27. package/src/commands/skill.js +16 -25
  28. package/src/commands/spec.js +26 -5
  29. package/src/commands/status.js +51 -172
  30. package/src/commands/templates.js +31 -138
  31. package/src/commands/trace.js +36 -71
  32. package/src/commands/triage.js +7 -75
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +121 -160
  35. package/src/commands/why.js +2 -2
  36. package/src/commands/work.js +234 -263
  37. package/src/index.js +106 -7
  38. package/src/lib/actions.js +484 -0
  39. package/src/lib/adapters.js +0 -11
  40. package/src/lib/adr-guard.js +19 -0
  41. package/src/lib/agent-changelog.js +133 -0
  42. package/src/lib/analysis.js +264 -0
  43. package/src/lib/change-ops.js +131 -0
  44. package/src/lib/commands.js +109 -6
  45. package/src/lib/config.js +206 -0
  46. package/src/lib/constitution-model.js +102 -0
  47. package/src/lib/coverage-model.js +392 -0
  48. package/src/lib/criteria.js +23 -0
  49. package/src/lib/doc-model.js +326 -0
  50. package/src/lib/docs-impact.js +196 -27
  51. package/src/lib/flag-catalog.js +0 -16
  52. package/src/lib/fs-ops.js +58 -1
  53. package/src/lib/gates.js +210 -24
  54. package/src/lib/git.js +132 -0
  55. package/src/lib/index-json.js +2 -19
  56. package/src/lib/intake-model.js +86 -0
  57. package/src/lib/json-out.js +100 -2
  58. package/src/lib/ledger.js +192 -0
  59. package/src/lib/lexicon.js +191 -0
  60. package/src/lib/metrics-model.js +159 -0
  61. package/src/lib/names.js +42 -0
  62. package/src/lib/playbook.js +97 -0
  63. package/src/lib/project.js +57 -0
  64. package/src/lib/reproducibility.js +137 -0
  65. package/src/lib/runtime.js +238 -11
  66. package/src/lib/scan.js +204 -13
  67. package/src/lib/signoff.js +147 -0
  68. package/src/lib/snapshot.js +209 -0
  69. package/src/lib/spec-ops.js +50 -5
  70. package/src/lib/templates-model.js +262 -0
  71. package/src/lib/trace-model.js +95 -0
  72. package/src/lib/triage-model.js +121 -0
  73. package/src/lib/usage.js +6 -4
  74. package/src/lib/validation-model.js +1299 -0
  75. package/src/lib/version.js +22 -0
  76. package/src/lib/views.js +410 -0
  77. package/src/lib/work-model.js +77 -0
  78. package/templates/AGENTS.md.template +20 -26
  79. package/templates/README.md +35 -0
  80. package/templates/change/proposal.md.template +1 -0
  81. package/templates/contract.md.template +7 -0
  82. package/templates/hooks/pre-commit.sample +6 -2
  83. package/templates/playbooks/bootstrap.md.template +43 -0
  84. package/templates/playbooks/chore.md.template +19 -0
  85. package/templates/playbooks/work.md.template +76 -0
@@ -1,5 +1,4 @@
1
1
  // @ts-check
2
- import { getSection } from "../lib/doc-model.js";
3
2
  import path from "node:path";
4
3
  import process from "node:process";
5
4
  import { readdirSync } from "node:fs";
@@ -12,10 +11,12 @@ import { flagBool } from "../lib/args.js";
12
11
  import { c } from "../lib/colors.js";
13
12
  import { suggest } from "../lib/suggest.js";
14
13
  import { notADoctrinaProject } from "../lib/exit-codes.js";
14
+ import { wantsJson, emitJson } from "../lib/json-out.js";
15
15
  import {
16
- parseTable, parseRuntimeDeclaration, checkWiring, checkEmptySemantics,
16
+ parseRuntimeDeclaration, checkStructure, checkWiring, checkEmptySemantics,
17
17
  checkEnums, checkSelectors,
18
18
  } from "../lib/runtime.js";
19
+ import { artifactNameError } from "../lib/names.js";
19
20
 
20
21
  // Contracts are the first-class home for the integration/runtime surface
21
22
  // no single capability owns: the port map, the env/dependency contract,
@@ -30,6 +31,10 @@ const SUBCOMMANDS = ["new", "list", "check"];
30
31
  // Flags this command accepts. Declared HERE, with the command, so
31
32
  // adding a command never requires editing the entrypoint — the gap that
32
33
  // let six flags ship undeclared and silently swallow a positional (C3).
34
+ // `check` builds a real payload; `new` and `list` have nothing but their
35
+ // prose, so they keep the captured envelope (change 0068).
36
+ export const jsonNative = (args) => args[0] === "check";
37
+
33
38
  export const flags = { boolean: ["json", "force"], string: [] };
34
39
 
35
40
  export async function run(positional, flags) {
@@ -53,8 +58,9 @@ export async function run(positional, flags) {
53
58
 
54
59
  function contractNew(args, flags) {
55
60
  const name = args[0];
56
- if (!name || !/^[a-z][a-z0-9-]*$/.test(name)) {
57
- console.error(c.red("error:") + " contract name must be lowercase letters, digits, or hyphens (e.g. \"system\", \"api\").");
61
+ const nameError = artifactNameError(name, "contract name");
62
+ if (nameError) {
63
+ console.error(c.red("error:") + ` ${nameError} (e.g. "system", "api")`);
58
64
  return 2;
59
65
  }
60
66
  const force = flagBool(flags, "force", false);
@@ -114,7 +120,11 @@ function contractList() {
114
120
  return 0;
115
121
  }
116
122
 
117
- function contractCheck(args, _flags) {
123
+ function contractCheck(args, cmdFlags) {
124
+ const json = wantsJson(cmdFlags);
125
+ // `--json` means the payload IS the answer: prose on stdout ahead of it
126
+ // would corrupt the very output it describes.
127
+ const say = (...parts) => { if (!json) console.log(...parts); };
118
128
  const projectRoot = process.cwd();
119
129
  ensureDoctrinaProject(projectRoot);
120
130
  const dir = path.join(projectRoot, ".doctrina", "contracts");
@@ -128,68 +138,44 @@ function contractCheck(args, _flags) {
128
138
  names = [];
129
139
  }
130
140
  if (names.length === 0) {
131
- console.log(c.gray("no contracts to check in .doctrina/contracts/"));
141
+ say(c.gray("no contracts to check in .doctrina/contracts/"));
132
142
  return 0;
133
143
  }
134
144
 
135
- // The env contract is checked against .env.example when one exists.
136
- const envExamplePath = path.join(projectRoot, ".env.example");
137
- const envExample = isFile(envExamplePath) ? read(envExamplePath) : null;
138
-
139
145
  let errors = 0;
140
146
  let warnings = 0;
147
+ // The runtime half is counted, not just printed. A contract that declares
148
+ // no Wiring and no Selectors was not checked — and the summary line is the
149
+ // one thing the CI log and the close print, so it is the line that has to
150
+ // say so (change 0029 decided the per-contract line; this is the same
151
+ // decision applied to the total).
152
+ let declaredRows = 0;
153
+ let uncheckedContracts = 0;
154
+ /** @type {Array<{contract: string, code: string, level: string, message: string, remedy: string}>} */
155
+ const findings = [];
156
+ /** @type {string[]} */
157
+ const unchecked = [];
141
158
  for (const name of names) {
142
159
  const file = path.join(dir, `${name}.md`);
143
160
  if (!isFile(file)) {
144
- console.log(c.red("error: ") + `contract "${name}" not found at ${relPath(projectRoot, file)}`);
161
+ say(c.red("error: ") + `contract "${name}" not found at ${relPath(projectRoot, file)}`);
145
162
  errors += 1;
146
163
  continue;
147
164
  }
148
165
  const text = read(file);
149
- console.log(c.bold(name) + c.gray(` (${relPath(projectRoot, file)})`));
166
+ say(c.bold(name) + c.gray(` (${relPath(projectRoot, file)})`));
150
167
 
151
- // 1. Port collisions — two services must not claim the same port.
152
- const ports = parseTable(getSection(text, "Ports"));
153
- if (ports) {
154
- const portCol = colIndex(ports.headers, "port");
155
- const svcCol = colIndex(ports.headers, "service");
156
- const seen = new Map();
157
- for (const row of ports.rows) {
158
- const port = (row[portCol] ?? "").trim();
159
- if (!/^\d+$/.test(port)) continue;
160
- const svc = (row[svcCol] ?? "?").trim();
161
- if (seen.has(port)) {
162
- console.log(c.red(" ✗ ") + `port ${port} is claimed by both "${seen.get(port)}" and "${svc}"`);
163
- errors += 1;
164
- } else {
165
- seen.set(port, svc);
166
- }
167
- }
168
- }
169
-
170
- // 2. Environment drift — every declared variable must exist in
171
- // .env.example (when present), so code, example, and infra agree.
172
- const env = parseTable(getSection(text, "Environment"));
173
- if (env && envExample !== null) {
174
- const varCol = colIndex(env.headers, "variable");
175
- for (const row of env.rows) {
176
- const name2 = (row[varCol] ?? "").trim();
177
- if (!/^[A-Z][A-Z0-9_]*$/.test(name2)) continue;
178
- const declared = new RegExp(`^\\s*(export\\s+)?${name2}\\s*=`, "m").test(envExample);
179
- if (!declared) {
180
- console.log(c.yellow(" ! ") + `env var ${name2} is in the contract but absent from .env.example`);
181
- warnings += 1;
182
- }
183
- }
184
- }
185
-
186
- // 3. Referenced capability specs must exist.
187
- for (const refCap of referencedCapabilities(getSection(text, "References"))) {
188
- const specPath = path.join(projectRoot, ".doctrina", "specs", refCap, "spec.md");
189
- if (!isFile(specPath)) {
190
- console.log(c.red(" ✗ ") + `references spec "${refCap}" but ${relPath(projectRoot, specPath)} does not exist`);
191
- errors += 1;
192
- }
168
+ // 1-3. The STRUCTURAL half — port collisions, environment drift against
169
+ // .env.example, references to specs that do not exist. One
170
+ // collection in lib/runtime.js (change 0103), shared with the
171
+ // close's runtime step and `doctor`, so a contract this command
172
+ // fails cannot pass the close.
173
+ for (const f of checkStructure(projectRoot, text)) {
174
+ const mark = f.level === "error" ? c.red(" ✗ ") : c.yellow(" ! ");
175
+ say(mark + f.message + c.gray(` [${f.code}]`));
176
+ findings.push({ contract: name, code: f.code, level: f.level, message: f.message, remedy: f.remedy });
177
+ if (f.level === "error") errors += 1;
178
+ else warnings += 1;
193
179
  }
194
180
 
195
181
  // 4. The RUNTIME half: a declaration is only worth what binds it to the
@@ -208,40 +194,74 @@ function contractCheck(args, _flags) {
208
194
  ...checkSelectors(projectRoot, decl),
209
195
  ]) {
210
196
  const mark = f.level === "error" ? c.red(" ✗ ") : c.yellow(" ! ");
211
- console.log(mark + f.message + c.gray(` [${f.code}]`));
212
- console.log(` ${c.gray(`fix: ${f.remedy}`)}`);
197
+ say(mark + f.message + c.gray(` [${f.code}]`));
198
+ say(` ${c.gray(`fix: ${f.remedy}`)}`);
199
+ findings.push({ contract: name, code: f.code, level: f.level, message: f.message, remedy: f.remedy });
213
200
  if (f.level === "error") errors += 1;
214
201
  else warnings += 1;
215
202
  }
216
203
 
217
- if (decl.wiring.length + decl.selectors.length === 0) {
204
+ const rows = decl.wiring.length + decl.selectors.length;
205
+ declaredRows += rows;
206
+ if (rows === 0) {
218
207
  // Silence here is not proof of correctness — it is proof that nothing
219
208
  // was declared. Saying so is what stops a green check from being read
220
209
  // as "the wiring is verified".
221
- console.log(c.gray(" · no Wiring or Selectors declared — the runtime surface is unchecked"));
210
+ uncheckedContracts += 1;
211
+ unchecked.push(name);
212
+ say(c.gray(" · no Wiring or Selectors declared — the runtime surface is unchecked"));
222
213
  }
223
214
  }
224
215
 
225
- console.log("");
226
- if (errors === 0 && warnings === 0) {
227
- console.log(c.green("ok") + ` ${names.length} contract${names.length === 1 ? "" : "s"} consistent`);
228
- return 0;
216
+ say("");
217
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
218
+
219
+ // The MACHINE answer, in a field of its own (change 0068). Change 0056 took
220
+ // the word "consistent" out of the human summary for an undeclared surface;
221
+ // the envelope kept saying `ok: true`, because the only machine signal was
222
+ // the exit code — and the exit code is 0 by the deliberate decision of
223
+ // change 0029 (an undeclared surface is REPORTED, not failed). Right for the
224
+ // status, wrong as the only signal: a consumer reading only the envelope was
225
+ // exactly where the human reader stood before 0056. So it branches on
226
+ // `unchecked`, the same way change 0061 gave deprecation a field of its own.
227
+ const emit = (verdict, code) => {
228
+ if (json) {
229
+ emitJson("contract check", {
230
+ contracts: names,
231
+ checked: names.length - unchecked.length,
232
+ unchecked,
233
+ declared_rows: declaredRows,
234
+ findings,
235
+ verdict,
236
+ }, { ok: errors === 0, exitCode: code });
237
+ }
238
+ return code;
239
+ };
240
+
241
+ if (errors > 0) {
242
+ say(c.red("fail") + ` ${plural(errors, "error")}, ${plural(warnings, "warning")}`);
243
+ return emit("failed", 1);
229
244
  }
230
- const summary = `${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}`;
231
- console.log((errors === 0 ? c.green("ok") : c.red("fail")) + " " + summary);
232
- return errors === 0 ? 0 : 1;
233
- }
234
245
 
235
- function colIndex(headers, name) {
236
- const i = headers.findIndex((h) => h.toLowerCase() === name.toLowerCase());
237
- return i < 0 ? 0 : i;
238
- }
246
+ // "consistent" is a claim about something that was verified. Where nothing
247
+ // was declared there is nothing to be consistent WITH, so the word does not
248
+ // appear — the same state `doctor` calls "unchecked" and `triage` calls
249
+ // "UNCHECKED, not verified" is not allowed to read as approval here just
250
+ // because this is the copy that runs in CI. It stays exit 0: change 0029
251
+ // decided an undeclared surface is reported, not failed.
252
+ if (uncheckedContracts > 0) {
253
+ const scope = uncheckedContracts === names.length
254
+ ? `${plural(names.length, "contract")}`
255
+ : `${uncheckedContracts} of ${plural(names.length, "contract")}`;
256
+ const verb = uncheckedContracts === 1 && names.length === 1 ? "declares" : "declare";
257
+ say(c.yellow("warn") + ` ${scope} ${verb} no Wiring/Selectors rows — the runtime surface is unchecked` +
258
+ (warnings > 0 ? c.gray(`; ${plural(warnings, "warning")} above`) : ""));
259
+ return emit("unchecked", 0);
260
+ }
239
261
 
240
- // Capability names referenced as `specs/<cap>` (placeholder <...> ignored).
241
- function referencedCapabilities(text) {
242
- const out = new Set();
243
- for (const m of text.matchAll(/specs\/([a-z][a-z0-9-]*)/g)) out.add(m[1]);
244
- return [...out];
262
+ const held = `${plural(names.length, "contract")} consistent, ${plural(declaredRows, "declared row")} ${declaredRows === 1 ? "holds" : "hold"}`;
263
+ say(c.green("ok") + ` ${held}` + (warnings > 0 ? c.gray(`; ${plural(warnings, "warning")} above`) : ""));
264
+ return emit("consistent", 0);
245
265
  }
246
266
 
247
267
  function ensureDoctrinaProject(projectRoot) {
@@ -1,14 +1,21 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { readdirSync } from "node:fs";
5
4
  import { spawnSync } from "node:child_process";
6
- import { exists, isDir, isFile, read, relPath } from "../lib/fs-ops.js";
7
- import { specHeader } from "../lib/scan.js";
5
+ import { exists, isFile, read, relPath } from "../lib/fs-ops.js";
8
6
  import { flagBool, flagString } from "../lib/args.js";
9
7
  import { c } from "../lib/colors.js";
10
8
  import { emitJson } from "../lib/json-out.js";
11
- import { notADoctrinaProject } from "../lib/exit-codes.js";
9
+ import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
10
+ import { suggest } from "../lib/suggest.js";
11
+ import { collect, summarize } from "../lib/coverage-model.js";
12
+ import { knownCapabilities } from "../lib/scan.js";
13
+
14
+ // The numbers this command reports come from lib/coverage-model.js, which
15
+ // `status`, `review`, `validate`, `close` and `spec set` read too — so no
16
+ // two surfaces can disagree about what "covered" means, and none of them
17
+ // has to import out of this file to find out (audit finding F7).
18
+ export { collect, summarize, deriveImplementation, derivedImplementations, implementationMismatch } from "../lib/coverage-model.js";
12
19
 
13
20
  // Traceability report: how many acceptance criteria cite an artifact or
14
21
  // test that actually exists on disk. Doctrina otherwise has no link
@@ -48,6 +55,28 @@ export async function run(_positional, flags) {
48
55
  const onlyRaw = flagString(flags, "only");
49
56
  const only = onlyRaw ? new Set(onlyRaw.split(",").map((s) => s.trim()).filter(Boolean)) : null;
50
57
 
58
+ // A filter that matches no capability is a USAGE error, not a clean gate.
59
+ // It used to fall through to "no acceptance criteria found under
60
+ // .doctrina/specs/" and exit 0 — on THIS repository, which declares over
61
+ // two hundred of them — `--strict` included. So a CI job running
62
+ // `coverage --only billing --strict` stayed green forever once the
63
+ // capability was renamed or split: the failure looked exactly like
64
+ // success. RT05 already refuses a contract selector that matches zero
65
+ // targets, for the same reason (change 0090).
66
+ if (only) {
67
+ const known = knownCapabilities(projectRoot);
68
+ const missing = [...only].filter((cap) => !known.includes(cap));
69
+ if (missing.length > 0) {
70
+ for (const cap of missing) {
71
+ console.error(c.red("error:") + ` --only names no capability with a spec: "${cap}"`);
72
+ const guess = suggest(cap, known);
73
+ if (guess) console.error(c.gray("hint: ") + `did you mean "${guess}"?`);
74
+ }
75
+ if (known.length > 0) console.error(c.gray("known: ") + known.sort().join(", "));
76
+ return EXIT.USAGE;
77
+ }
78
+ }
79
+
51
80
  const reports = collect(projectRoot, { only });
52
81
 
53
82
  // --run: execute the cited evidence instead of only checking it exists —
@@ -60,10 +89,14 @@ export async function run(_positional, flags) {
60
89
 
61
90
  if (reports.length === 0) {
62
91
  if (json) {
63
- emitJson("coverage", { specs: [], summary: { criteria: 0, covered: 0, dangling: 0, conditional: 0, pct: 100 } });
92
+ emitJson("coverage", { specs: [], summary: { criteria: 0, covered: 0, dangling: 0, conditional: 0, pct: null } });
64
93
  return 0;
65
94
  }
66
- console.log(c.gray("no acceptance criteria found under .doctrina/specs/"));
95
+ // The two cases used to share one sentence, and the shared one was the
96
+ // false one whenever a filter was in play.
97
+ console.log(c.gray(only
98
+ ? `no acceptance criteria declared by ${[...only].sort().join(", ")}`
99
+ : "no acceptance criteria found under .doctrina/specs/"));
67
100
  return 0;
68
101
  }
69
102
 
@@ -81,7 +114,8 @@ export async function run(_positional, flags) {
81
114
  totalUnguarded += rep.rows.filter((r) => r.kind === "unguarded").length;
82
115
  totalDeferred += rep.rows.filter((r) => r.kind === "deferred").length;
83
116
  }
84
- const jsonPct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
117
+ // null, not 100: a ratio over nothing is not a perfect score (change 0057).
118
+ const jsonPct = totalCriteria === 0 ? null : Math.round((totalCovered / totalCriteria) * 100);
85
119
  // Deferred criteria are visible but never gate: declared debt ≠ hidden debt.
86
120
  const jsonClean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0
87
121
  && totalConditional === 0 && totalUnguarded === 0;
@@ -112,7 +146,18 @@ export async function run(_positional, flags) {
112
146
  const note = notes.length > 0 ? ` (${notes.join(", ")})` : "";
113
147
  console.log(` ${c.cyan(rep.cap.padEnd(20))} ${covered}/${rep.rows.length} criteria${note}`);
114
148
  for (const r of rep.rows) {
115
- if (r.kind === "covered") continue;
149
+ if (r.kind === "covered") {
150
+ // Linked is not certified (change 0105), and one citation resolving
151
+ // does not make the other one true (change 0106): both are said,
152
+ // neither lowers the count — the evidence IS linked.
153
+ if (r.unverified) {
154
+ console.log(` ${c.gray("○")} #${r.n} proof resolves but the criterion is still marked [unverified] — flip the mark when the test proves it (spec set <cap> --criterion ${r.n}:verified)`);
155
+ }
156
+ if (r.missing?.length > 0) {
157
+ console.log(` ${c.yellow("!")} #${r.n} also cites evidence that does not resolve: ${r.missing.map((m) => `\`${m}\``).join(", ")}`);
158
+ }
159
+ continue;
160
+ }
116
161
  if (r.kind === "deferred") {
117
162
  console.log(` ${c.gray("○")} #${r.n} deferred — spec declares "Implementation: planned — <why>" (visible, not gated)`);
118
163
  } else if (r.kind === "bare") {
@@ -127,9 +172,12 @@ export async function run(_positional, flags) {
127
172
  }
128
173
  }
129
174
 
130
- const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
175
+ const pct = totalCriteria === 0 ? null : Math.round((totalCovered / totalCriteria) * 100);
131
176
  console.log("");
132
- const summary = `${totalCovered} of ${totalCriteria} acceptance criteria across ${reports.length} spec${reports.length === 1 ? "" : "s"} have linked evidence (${pct}%)`;
177
+ const specCount = `${reports.length} spec${reports.length === 1 ? "" : "s"}`;
178
+ const summary = pct === null
179
+ ? `no acceptance criteria declared across ${specCount} — nothing to cover yet`
180
+ : `${totalCovered} of ${totalCriteria} acceptance criteria across ${specCount} have linked evidence (${pct}%)`;
133
181
  const clean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0
134
182
  && totalConditional === 0 && totalUnguarded === 0;
135
183
  const extras = [];
@@ -201,269 +249,6 @@ function runEvidence(projectRoot, reports, strict) {
201
249
  return 1;
202
250
  }
203
251
 
204
- // Per-spec criterion rows — the full classification behind both the report
205
- // and the --json output. Each row: { n, kind, missing?, skipped? }.
206
- //
207
- // A spec that declares a deliberate deferral — `Implementation: planned —
208
- // <why>`, the exact escape hatch `validate` already honours — has its
209
- // non-covered criteria remapped to kind "deferred": visible in every report,
210
- // never a --strict failure. Declared debt and hidden debt stop being punished
211
- // identically (0.11.0 field review item 4: one deferred capability poisoned
212
- // the close of every unrelated change).
213
- export function collect(projectRoot, { only = null } = {}) {
214
- const specsDir = path.join(projectRoot, ".doctrina", "specs");
215
- const reports = [];
216
- if (isDir(specsDir)) {
217
- for (const cap of readdirSync(specsDir).sort()) {
218
- if (only && !only.has(cap)) continue;
219
- const specPath = path.join(specsDir, cap, "spec.md");
220
- if (!isFile(specPath)) continue;
221
- const text = read(specPath);
222
- const criteria = extractAcceptanceCriteria(text);
223
- if (criteria.length === 0) continue;
224
- const deferred = isDeclaredDeferral(text);
225
- const rows = criteria.map((crit, i) => {
226
- const row = classify(crit, i + 1, projectRoot, path.dirname(specPath));
227
- if (deferred && row.kind !== "covered") {
228
- return { ...row, kind: "deferred", was: row.kind };
229
- }
230
- return row;
231
- });
232
- reports.push({ cap, specPath, rows, deferred });
233
- }
234
- }
235
- return reports;
236
- }
237
-
238
- // The declared-deferral escape hatch, matching validate's two-axis check:
239
- // Implementation is "planned" WITH an explanatory note after the state word.
240
- // A bare "planned" is an inventory claim, not a deferral, and gets no pass.
241
- function isDeclaredDeferral(specText) {
242
- const implRaw = specHeader(specText, "Implementation");
243
- if (!implRaw) return false;
244
- const tokens = implRaw.trim().split(/\s+/);
245
- const word = (tokens[0] ?? "").replace(/[—-]+$/, "").toLowerCase();
246
- return word === "planned" && tokens.length > 1;
247
- }
248
-
249
- // Pure summary of coverage across the spec tree, for other commands
250
- // (`status`, `review`) that need the numbers without the report output.
251
- // Deferred criteria (declared deferral, see collect) are counted separately
252
- // and excluded from the problem counts, matching the gate semantics.
253
- export function summarize(projectRoot) {
254
- let totalCriteria = 0, totalCovered = 0, totalDangling = 0, totalConditional = 0, totalDeferred = 0;
255
- const perCap = [];
256
- for (const rep of collect(projectRoot)) {
257
- const covered = rep.rows.filter((r) => r.kind === "covered").length;
258
- const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
259
- const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
260
- const unguarded = rep.rows.filter((r) => r.kind === "unguarded").length;
261
- const deferred = rep.rows.filter((r) => r.kind === "deferred").length;
262
- totalCriteria += rep.rows.length;
263
- totalCovered += covered;
264
- totalDangling += dangling;
265
- totalConditional += conditional;
266
- totalDeferred += deferred;
267
- perCap.push({ cap: rep.cap, total: rep.rows.length, covered, dangling, conditional, unguarded, deferred });
268
- }
269
- const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
270
- return { perCap, totalCriteria, totalCovered, totalDangling, totalConditional, totalDeferred, pct };
271
- }
272
-
273
- // Pull the numbered items out of the "## Acceptance criteria" section.
274
- // Each item may span multiple lines (continuation prose); accumulate until
275
- // the next number or the next "## " heading. Returns an array of strings.
276
- function extractAcceptanceCriteria(text) {
277
- const lines = text.split(/\r?\n/);
278
- const out = [];
279
- let inSection = false;
280
- let buf = null;
281
- const flush = () => {
282
- if (buf !== null) out.push(buf.trim());
283
- buf = null;
284
- };
285
- for (const line of lines) {
286
- if (/^##\s+/.test(line)) {
287
- // Entering or leaving a section.
288
- if (inSection) {
289
- flush();
290
- inSection = false;
291
- }
292
- if (/^##\s+Acceptance criteria\b/i.test(line)) inSection = true;
293
- continue;
294
- }
295
- if (!inSection) continue;
296
- if (/^\s*\d+\.\s+/.test(line)) {
297
- flush();
298
- buf = line.replace(/^\s*\d+\.\s+/, "");
299
- } else if (buf !== null) {
300
- // Continuation line of the current criterion.
301
- if (line.trim() === "") buf += " ";
302
- else buf += " " + line.trim();
303
- }
304
- }
305
- flush();
306
- // Drop empty placeholder items (a lone "1." with no text).
307
- return out.filter((s) => s.length > 0);
308
- }
309
-
310
- // ORCHESTRATION criteria (change 0029). Coverage measures CITATION: a
311
- // criterion is covered when it cites a file that exists (and, since G3, a
312
- // test whose suite is not skipped). That is the right test for "this
313
- // function behaves", and the wrong one for "the pipeline ran at all".
314
- //
315
- // A criterion like "absence of the report is explicit and the step does not
316
- // fail" is satisfied, on paper, by a job that executed zero cases and
317
- // printed a well-written empty state — the citation resolves, the suite is
318
- // not skipped, and the only visible signal is a tidy message saying nothing
319
- // happened. Coverage calls that proven.
320
- //
321
- // Marking a criterion `[orchestration]` says: the claim is that a RUN
322
- // happened, so the proof must be a fail-closed one. It cites a verify check
323
- // by name (`verify:<check>`), and that check must declare an `expect` guard
324
- // — the thing that turns "exit 0" into "exit 0 having actually done
325
- // something". A named check without a guard is UNGUARDED: the criterion is
326
- // not proven, and says so, instead of quietly counting as covered.
327
- const ORCHESTRATION_MARKER = /^orchestration\b/;
328
-
329
- function citedVerifyChecks(criterion) {
330
- const out = new Set();
331
- for (const m of criterion.matchAll(/`verify:([A-Za-z0-9_.-]+)`/g)) out.add(m[1]);
332
- return [...out];
333
- }
334
-
335
- function guardedVerifyChecks(projectRoot) {
336
- const configPath = path.join(projectRoot, ".doctrina", "verify.json");
337
- if (!isFile(configPath)) return null;
338
- let config;
339
- try {
340
- config = JSON.parse(read(configPath));
341
- } catch {
342
- return null;
343
- }
344
- const map = new Map();
345
- for (const ch of Array.isArray(config?.checks) ? config.checks : []) {
346
- if (!ch?.name) continue;
347
- const exp = ch.expect;
348
- const guarded = Boolean(exp && typeof exp === "object"
349
- && (typeof exp.fail_if_output_matches === "string" || typeof exp.require_output_matches === "string"));
350
- map.set(ch.name, guarded);
351
- }
352
- return map;
353
- }
354
-
355
- function classifyOrchestration(criterion, n, projectRoot) {
356
- const named = citedVerifyChecks(criterion);
357
- if (named.length === 0) {
358
- return { kind: "unguarded", n, reason: "cites no verify check — an orchestration claim is proven by a fail-closed check, cited as `verify:<check>`" };
359
- }
360
- const checks = guardedVerifyChecks(projectRoot);
361
- if (checks === null) {
362
- return { kind: "dangling", n, missing: named.map((x) => `verify:${x}`) };
363
- }
364
- const unknown = named.filter((x) => !checks.has(x));
365
- if (unknown.length > 0) {
366
- return { kind: "dangling", n, missing: unknown.map((x) => `verify:${x}`) };
367
- }
368
- const unguarded = named.filter((x) => !checks.get(x));
369
- if (unguarded.length > 0) {
370
- return {
371
- kind: "unguarded",
372
- n,
373
- reason: `verify check${unguarded.length === 1 ? "" : "s"} ${unguarded.join(", ")} declare${unguarded.length === 1 ? "s" : ""} no "expect" guard — a check that exits 0 having run nothing would still pass it`,
374
- };
375
- }
376
- return { kind: "covered", n, evidence: [] };
377
- }
378
-
379
- // Decide whether a single criterion is covered, conditional, dangling, or bare.
380
- function classify(criterion, n, projectRoot, specDir) {
381
- // An orchestration criterion is judged on its GUARD, not on whether a
382
- // cited file exists — the whole point is that existence proves nothing here.
383
- const marker = criterion.match(/^\[([^\]]+)\]/)?.[1]?.toLowerCase() ?? "";
384
- if (ORCHESTRATION_MARKER.test(marker)) {
385
- return classifyOrchestration(criterion, n, projectRoot);
386
- }
387
-
388
- const cited = extractBacktickPaths(criterion);
389
- if (cited.length === 0) return { kind: "bare", n };
390
- const missing = [];
391
- const resolved = [];
392
- for (const token of cited) {
393
- const candidates = [
394
- path.resolve(projectRoot, token),
395
- path.resolve(specDir, token),
396
- ];
397
- const hit = candidates.find(exists);
398
- if (!hit) {
399
- missing.push(token);
400
- continue;
401
- }
402
- const isTest = looksLikeTestFile(token);
403
- const skipped = isTest && isFile(hit) ? testSuiteIsSkipped(read(hit)) : false;
404
- resolved.push({ token, isTest, skipped });
405
- }
406
- if (resolved.length === 0) return { kind: "dangling", n, missing };
407
- // Runnable evidence: the resolving test-shaped citations, recorded so
408
- // `coverage --run` can execute them (existence → passing proof).
409
- const evidence = resolved.filter((r) => r.isTest && !r.skipped).map((r) => r.token);
410
- // Real proof = a non-test artifact, or a test file whose suite runs. If the
411
- // only thing that resolves is a skipped test, the criterion is conditional.
412
- const hasRealProof = resolved.some((r) => !r.isTest || !r.skipped);
413
- if (hasRealProof) return { kind: "covered", n, evidence };
414
- return { kind: "conditional", n, skipped: resolved.map((r) => r.token), evidence: [] };
415
- }
416
-
417
- // A cited path is a test file when it sits under a tests directory or carries
418
- // a test/spec/e2e filename marker — the only files skip-detection applies to.
419
- function looksLikeTestFile(token) {
420
- if (/(?:^|\/)(?:tests?|__tests__|specs?|e2e)\//i.test(token)) return true;
421
- if (/(?:\.|_|-)(?:test|spec|e2e)\.[a-z0-9]+$/i.test(token)) return true;
422
- if (/(?:^|\/)test_[^/]+\.py$/i.test(token)) return true;
423
- return false;
424
- }
425
-
426
- // Heuristic, dependency-free "is this whole test file gated off?" check: the
427
- // first test construct in the file is a skip/todo (e.g. the Prisma e2e suite
428
- // wrapped in `describe.skip`). A file whose first suite runs is treated as
429
- // real proof even if it has an incidental `it.skip` later — that keeps the
430
- // false-positive rate low without parsing the file. Python skip decorators
431
- // and a module-level `pytest.skip` count too.
432
- function testSuiteIsSkipped(text) {
433
- const m = text.match(
434
- /\b(x(?:describe|context|it|test)|(?:describe|context|suite|it|test|specify)\s*\.\s*(?:skip|todo)|(?:describe|context|suite|it|test|specify))\s*\(/,
435
- );
436
- if (m) {
437
- const head = m[1];
438
- if (/^x/.test(head) || /\.\s*(?:skip|todo)/.test(head)) return true;
439
- }
440
- if (/@(?:pytest\.mark\.skip|unittest\.skip)\b/.test(text)) return true;
441
- if (/^\s*pytest\.skip\s*\(/m.test(text)) return true;
442
- return false;
443
- }
444
-
445
- // Backtick spans that look like repository file paths. Mirrors the
446
- // validate stale-link heuristic: a token is a path if it has a slash or a
447
- // file extension and is not a URL/placeholder.
448
- function extractBacktickPaths(text) {
449
- const out = new Set();
450
- for (const m of text.matchAll(/`([^`]+)`/g)) {
451
- const token = m[1].trim();
452
- if (looksLikePath(token)) out.add(token);
453
- }
454
- return [...out];
455
- }
456
-
457
- function looksLikePath(s) {
458
- if (!s) return false;
459
- if (/^(https?:|mailto:|ftp:|#|@)/i.test(s)) return false;
460
- if (/[\s<>*?{}]/.test(s)) return false;
461
- const hasSlash = s.includes("/");
462
- const hasExt = /\.[a-z0-9]{1,8}$/i.test(s);
463
- if (!hasSlash && !hasExt) return false;
464
- if (s.startsWith("-")) return false;
465
- return true;
466
- }
467
252
 
468
253
  export const help = `
469
254
  Usage: doctrina coverage [--strict] [--only <cap,cap>] [--run] [--json]