doctrina-cli 0.4.0 → 0.8.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 CHANGED
@@ -3,7 +3,7 @@
3
3
  > CLI for the Doctrina framework. The package is published as
4
4
  > `doctrina-cli`; the executable it installs is `doctrina`.
5
5
 
6
- **Status:** v0.4.0 — current release. Zero runtime dependencies
6
+ **Status:** v0.6.0 — current release. Zero runtime dependencies
7
7
  (Node.js standard library only).
8
8
 
9
9
  ## Install
@@ -22,11 +22,16 @@ Requires Node.js 20.12 or later.
22
22
  doctrina init scaffold AGENTS.md + .doctrina/ in cwd
23
23
  doctrina intake [<file>] store the full description + bootstrap playbook (clarification gate)
24
24
  doctrina work "<prompt>" brief prompt -> scaffolded change + work playbook (clarification gate)
25
+ doctrina work --resume <id> reprint an open change's playbook (no new change)
26
+ doctrina work --from-diff backfill a spec from working-tree changes (code-first)
27
+ doctrina work "<prompt>" --chore spec-less chore (infra/docs/build): no spec-delta steps
25
28
  doctrina spec new <capability> create a capability spec
26
29
  doctrina spec list list specs with version, status, size
30
+ doctrina spec set <cap> [--bump ...] edit spec headers/criterion + resync index (no lockstep)
27
31
  doctrina change new <id> "<title>" open a change proposal
28
- doctrina change apply <id> apply spec deltas (ADDED/REMOVED auto)
32
+ doctrina change apply <id> apply spec deltas (ADDED/REMOVED auto; MODIFIED ops block applies)
29
33
  doctrina change archive <id> archive an applied change
34
+ doctrina change abandon <id> delete an open change + its index entry (ledgered)
30
35
  doctrina change diff <id> preview spec deltas (line diff for MODIFIED)
31
36
  doctrina contract new <name> own the integration surface (ports, env, interfaces)
32
37
  doctrina contract check verify port collisions, env drift, referenced specs
@@ -42,10 +47,11 @@ doctrina analyze <change-id> inspect a change folder before applyi
42
47
  doctrina clarify <path> smell-test a Markdown file for ambiguity (--all: tree)
43
48
  doctrina context [<capability>] print the context pack in read order (--concat)
44
49
  doctrina search <term> [...] search artifacts, grouped by category (ranked)
45
- doctrina validate schema + artifact-existence checks
46
- doctrina coverage acceptance criteria with linked evidence (--strict gates)
50
+ doctrina validate schema + artifact-existence + index-drift checks (--fix rebuilds)
51
+ doctrina coverage acceptance criteria with linked evidence (--strict gates; skips = conditional)
47
52
  doctrina trace intent provenance: product intent -> specs (--strict gates)
48
53
  doctrina verify run project typecheck/test/build checks (the real gate)
54
+ doctrina verify --clean reproducibility lint: clean-checkout footguns (dist/codegen)
49
55
  doctrina templates list enumerate shipped templates
50
56
  doctrina templates check compare project against recommended template shape
51
57
  doctrina templates update additive fixer for check findings (preview; --write applies)
@@ -92,10 +98,17 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
92
98
  - **ADDED:** writes the delta body to the target spec; refuses if the
93
99
  target already exists.
94
100
  - **REMOVED:** deletes the target spec.
95
- - **MODIFIED:** prints a manual-merge pointer and does not write. The
96
- user merges the delta into the target spec by hand. This is
97
- deliberate; auto-merging is the kind of implicit decision Doctrina
98
- avoids.
101
+ - **MODIFIED:** when the delta carries a fenced ` ```ops ` block
102
+ (`set-header` / `bump-version` / `set-criterion` / `replace-criterion`
103
+ / `append-criterion`), applies it mechanically to the target spec — all
104
+ ops or none — and regenerates the index (ADR 0007). Without an ops
105
+ block it prints a manual-merge pointer and does not write: auto-merging
106
+ arbitrary prose is the kind of implicit decision Doctrina avoids.
107
+
108
+ On any spec write, the index is rebuilt from the tree so it never drifts
109
+ from the applied spec. `doctrina change abandon <id>` is the inverse of
110
+ `change new`: it deletes an open change and its index entry and ledgers
111
+ the abandonment.
99
112
 
100
113
  ## `validate` checks
101
114
 
@@ -113,7 +126,9 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
113
126
  12. Skills carry the required frontmatter triple (`name`, `description`, `when`).
114
127
  13. Skills are ≤ 200 lines (warns at > 150).
115
128
  14. Each skill's `name:` matches its filename slug.
116
- 15. Each spec's `Version:` header matches the version in `index.json` (drift warn).
129
+ 15. Indexed artifact metadata (spec/decision/change/contract) matches its
130
+ file — a mismatch is an **error** so a green `validate` can't hide the
131
+ drift `index rebuild --check` would catch (G5; `validate --fix` rebuilds).
117
132
  16. Each skill's frontmatter description matches `index.json` (drift warn; `skill sync` fixes).
118
133
  17. EARS grammar shape per section in specs declaring `## Requirements (EARS)` (warn).
119
134
  18. Nested `AGENTS.md` files obey the root size caps (warn > 150, error > 200).
@@ -123,12 +138,16 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
123
138
  21. Accepted ADRs anchor to reality via `Evidence:` and/or `Landed:`; cited
124
139
  paths must exist on disk (drift warn).
125
140
 
126
- Exit code: 0 if no errors (warnings allowed), 1 otherwise.
141
+ Exit code: 0 if no errors (warnings allowed), 1 otherwise. `--fix`
142
+ regenerates `index.json` from the tree before validating.
127
143
 
128
144
  Two related read-only reports gate separately: `doctrina coverage`
129
- (each acceptance criterion cites a real test) and `doctrina trace`
130
- (each product intent has a capability, each capability traces to an
131
- intent). Both exit 1 under `--strict` for CI.
145
+ (each acceptance criterion cites a real test; a criterion proven only by
146
+ a skipped suite is `conditional` and fails `--strict`) and `doctrina
147
+ trace` (each product intent has a capability, each capability traces to
148
+ an intent). Both exit 1 under `--strict` for CI. `doctrina verify
149
+ --clean` statically lints for clean-checkout footguns (a build-dir entry
150
+ point with no `prepare`; a Prisma dep with no generate on install).
132
151
 
133
152
  ## Tests
134
153
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.4.0",
3
+ "version": "0.8.0",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -4,12 +4,14 @@ import { exists, isDir, lineCount, mkdirp, move, read, relPath, remove, walk, wr
4
4
  import { diffLines, formatUnified } from "../lib/diff.js";
5
5
  import { locateTemplatesDir, loadTemplateTree, materialiseEntry } from "../lib/templates.js";
6
6
  import * as idx from "../lib/index-json.js";
7
+ import { deriveIndex } from "../lib/scan.js";
8
+ import { extractOps, applyOps } from "../lib/spec-ops.js";
7
9
  import { today } from "../lib/dates.js";
8
- import { flagBool } from "../lib/args.js";
10
+ import { flagBool, flagString } from "../lib/args.js";
9
11
  import { c } from "../lib/colors.js";
10
12
  import { suggest } from "../lib/suggest.js";
11
13
 
12
- const SUBCOMMANDS = ["new", "apply", "archive", "diff"];
14
+ const SUBCOMMANDS = ["new", "apply", "archive", "diff", "abandon"];
13
15
 
14
16
  export async function run(positional, flags) {
15
17
  const sub = positional[0];
@@ -22,6 +24,8 @@ export async function run(positional, flags) {
22
24
  return changeArchive(positional.slice(1), flags);
23
25
  case "diff":
24
26
  return changeDiff(positional.slice(1), flags);
27
+ case "abandon":
28
+ return changeAbandon(positional.slice(1), flags);
25
29
  default:
26
30
  console.error(c.red("error:") + ` unknown change subcommand "${sub ?? ""}"`);
27
31
  const guess = suggest(sub, SUBCOMMANDS);
@@ -45,6 +49,11 @@ function changeNew(args, flags) {
45
49
  }
46
50
 
47
51
  const force = flagBool(flags, "force", false);
52
+ // A chore is a spec-less change (infra / docs / build / migration) — review
53
+ // G9. It runs the full proposal → apply → archive → ledger lifecycle (so the
54
+ // history shows it) without forcing a fake spec delta. The empty specs/ dir
55
+ // is kept so `change apply`'s zero-delta path flips it to applied unchanged.
56
+ const chore = flagBool(flags, "chore", false) || flagBool(flags, "no-spec", false);
48
57
  const projectRoot = process.cwd();
49
58
  ensureDoctrinaProject(projectRoot);
50
59
 
@@ -66,14 +75,29 @@ function changeNew(args, flags) {
66
75
  }
67
76
  mkdirp(path.join(changeDir, "specs"));
68
77
 
78
+ // Stamp the proposal so a chore is honest in the artifact, not just the CLI.
79
+ if (chore) {
80
+ const proposalPath = path.join(changeDir, "proposal.md");
81
+ if (exists(proposalPath)) {
82
+ const txt = read(proposalPath);
83
+ const updated = txt.replace(/^(-\s+\*\*Affects specs:\*\*).*$/m, "$1 (none — chore)");
84
+ if (updated !== txt) write(proposalPath, updated, { force: true });
85
+ }
86
+ }
87
+
69
88
  const index = idx.load(projectRoot);
70
89
  idx.addChange(index, { id, title, path: `.doctrina/changes/${id}`, status: "proposed", opened: date });
71
90
  idx.touch(index, date);
72
91
  idx.save(projectRoot, index);
73
92
 
74
93
  console.log("");
75
- console.log(c.bold("Change opened.") + " Add spec deltas under " +
76
- c.cyan(`.doctrina/changes/${id}/specs/<capability>/delta.md`));
94
+ if (chore) {
95
+ console.log(c.bold("Chore opened.") + " No spec deltas expected — implement, check the tasks, then " +
96
+ c.cyan(`doctrina change apply ${id}`) + " and " + c.cyan(`doctrina change archive ${id}`) + ".");
97
+ } else {
98
+ console.log(c.bold("Change opened.") + " Add spec deltas under " +
99
+ c.cyan(`.doctrina/changes/${id}/specs/<capability>/delta.md`));
100
+ }
77
101
  return 0;
78
102
  }
79
103
 
@@ -133,8 +157,30 @@ function changeApply(args, _flags) {
133
157
  writes += 1;
134
158
  console.log(c.green("applied[REMOVED]") + ` ${relPath(projectRoot, targetPath)}`);
135
159
  } else if (op === "MODIFIED") {
136
- console.log(c.yellow("manual[MODIFIED]") + ` merge ${relPath(projectRoot, deltaPath)} into ${relPath(projectRoot, targetPath)} by hand`);
137
- manual += 1;
160
+ // A MODIFIED delta is applied mechanically when it carries a structured
161
+ // `ops` block (set-header / bump-version / set-criterion / ...; F3);
162
+ // otherwise it falls back to the historical manual-merge pointer, so
163
+ // prose deltas keep working unchanged.
164
+ const ops = extractOps(text);
165
+ if (ops.length === 0) {
166
+ console.log(c.yellow("manual[MODIFIED]") + ` merge ${relPath(projectRoot, deltaPath)} into ${relPath(projectRoot, targetPath)} by hand (no ops block)`);
167
+ manual += 1;
168
+ } else if (!exists(targetPath)) {
169
+ console.error(c.red("error:") + ` MODIFIED delta targets missing spec ${relPath(projectRoot, targetPath)}`);
170
+ errors += 1;
171
+ } else {
172
+ const result = applyOps(read(targetPath), ops);
173
+ if (result.errors.length > 0) {
174
+ console.error(c.red("error:") + ` ${result.errors.length} operation error${result.errors.length === 1 ? "" : "s"} in ${rel} — spec left untouched:`);
175
+ for (const e of result.errors) console.error(` - ${e}`);
176
+ errors += 1;
177
+ } else {
178
+ write(targetPath, result.text, { force: true });
179
+ writes += 1;
180
+ console.log(c.green("applied[MODIFIED]") + ` ${relPath(projectRoot, targetPath)} (${result.applied.length} op${result.applied.length === 1 ? "" : "s"})`);
181
+ for (const s of result.applied) console.log(c.gray(` · ${s}`));
182
+ }
183
+ }
138
184
  } else {
139
185
  console.error(c.red("error:") + ` unknown Operation "${op}" in ${rel}`);
140
186
  errors += 1;
@@ -161,38 +207,18 @@ function changeApply(args, _flags) {
161
207
  }
162
208
  }
163
209
 
164
- // Update the index when specs changed (ADDED/REMOVED) and/or the proposal
165
- // flipped to applied. The change entry's status must mirror the proposal —
166
- // the same value `index rebuild` derives from the file — or the index
167
- // drifts from the tree in the apply→archive window (caught by
168
- // `index rebuild --check`, e.g. in the pre-commit hook).
210
+ // Update the index whenever specs changed (ADDED / REMOVED / MODIFIED-ops)
211
+ // or the proposal flipped to applied. The files are the source of truth, so
212
+ // rather than patch entry by entry — the old path that let a spec's
213
+ // version/implementation text drift from the index, and forced a manual
214
+ // lockstep bump (F3 / G5 / G7 / G8) — the index is rebuilt from the
215
+ // now-updated tree in one shot. This keeps `index rebuild --check` green
216
+ // immediately after apply.
169
217
  if (writes > 0 || flippedToApplied) {
170
- const index = idx.load(projectRoot);
171
- if (writes > 0) {
172
- for (const deltaPath of deltaFiles) {
173
- const text = read(deltaPath);
174
- const op = parseOperation(text);
175
- const capability = parseCapabilityFromDelta(text, deltaPath);
176
- if (!capability) continue;
177
- if (op === "ADDED") {
178
- idx.addSpec(index, {
179
- id: capability,
180
- path: `.doctrina/specs/${capability}/spec.md`,
181
- status: "active",
182
- version: "0.1.0",
183
- last_updated: date,
184
- });
185
- } else if (op === "REMOVED") {
186
- idx.removeSpec(index, capability);
187
- }
188
- }
189
- }
190
- if (flippedToApplied) {
191
- const entry = index.artifacts.changes.find((ch) => ch.id === id);
192
- if (entry) entry.status = "applied";
193
- }
194
- idx.touch(index, date);
195
- idx.save(projectRoot, index);
218
+ const current = idx.load(projectRoot);
219
+ const derived = deriveIndex(projectRoot, current);
220
+ derived.last_updated = date;
221
+ idx.save(projectRoot, derived);
196
222
  }
197
223
 
198
224
  console.log("");
@@ -305,6 +331,52 @@ function changeArchive(args, flags) {
305
331
  return 0;
306
332
  }
307
333
 
334
+ // Abandon an open change: delete the folder AND the index entry in one step,
335
+ // and record the abandonment in the ledger so the history shows the dead end
336
+ // instead of a silent gap. This is the missing inverse of `change new` — the
337
+ // review had to delete a stray folder and hand-edit index.json (G6).
338
+ function changeAbandon(args, flags) {
339
+ const id = args[0];
340
+ if (!id) {
341
+ console.error(c.red("error:") + " change abandon requires <id>");
342
+ return 2;
343
+ }
344
+ const projectRoot = process.cwd();
345
+ ensureDoctrinaProject(projectRoot);
346
+
347
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
348
+ if (!isDir(changeDir)) {
349
+ console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
350
+ return 1;
351
+ }
352
+
353
+ const reason = flagString(flags, "reason") ?? "";
354
+ const date = today();
355
+
356
+ remove(changeDir);
357
+ console.log(c.green("removed") + ` ${relPath(projectRoot, changeDir)}`);
358
+
359
+ // Ledger line so the abandonment is part of the visible history.
360
+ const ledgerPath = path.join(projectRoot, ".doctrina", "changes", "archive", "LEDGER.md");
361
+ if (!exists(ledgerPath)) {
362
+ write(ledgerPath,
363
+ "# Change ledger\n\n" +
364
+ "One line per archived change, newest last. Appended by\n" +
365
+ "`doctrina change archive`; edit freely, the CLI only appends.\n\n");
366
+ }
367
+ const reasonSuffix = reason ? ` — ${reason}` : "";
368
+ write(ledgerPath, read(ledgerPath) + `- ${date} — ${id} — abandoned${reasonSuffix}\n`, { force: true });
369
+ console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerPath)}`);
370
+
371
+ // Drop the change entry; rebuild from the tree so nothing drifts.
372
+ const current = idx.load(projectRoot);
373
+ const derived = deriveIndex(projectRoot, current);
374
+ derived.last_updated = date;
375
+ idx.save(projectRoot, derived);
376
+ console.log(c.green("indexed") + ` change "${id}" removed from index.json`);
377
+ return 0;
378
+ }
379
+
308
380
  function changeDiff(args, _flags) {
309
381
  const id = args[0];
310
382
  if (!id) {
@@ -448,13 +520,31 @@ Usage: doctrina change <subcommand> [args]
448
520
 
449
521
  Subcommands:
450
522
  new <id> "<title>" Open a new change proposal at .doctrina/changes/<id>/
451
- apply <id> Apply spec deltas (ADDED writes, REMOVED deletes, MODIFIED is manual)
523
+ (--chore / --no-spec: a spec-less change for infra /
524
+ docs / build that still gets a proposal + ledger)
525
+ apply <id> Apply spec deltas: ADDED writes, REMOVED deletes,
526
+ MODIFIED with an \`\`\`ops block is applied mechanically
527
+ (set-header / bump-version / set-criterion / ...),
528
+ MODIFIED without one prints a manual-merge pointer.
529
+ On any spec write the index is rebuilt from the tree.
452
530
  archive <id> Move the change to .doctrina/changes/archive/YYYY-MM-DD-<id>/
531
+ abandon <id> Delete an open change folder and its index entry, and
532
+ record the abandonment in the ledger ([--reason "..."]).
453
533
  diff <id> Preview every spec delta: line diff for MODIFIED,
454
534
  summary for ADDED/REMOVED. Read-only.
455
535
 
536
+ A MODIFIED delta may carry a fenced operations block that \`apply\` executes:
537
+ \`\`\`ops
538
+ set-header Implementation: verified — durable adapter (\`src/db.ts\`)
539
+ bump-version minor
540
+ set-criterion 1: verified
541
+ append-criterion [unverified] new signal — verified by \`test/x.test.ts\`
542
+ \`\`\`
543
+
456
544
  Options:
457
545
  --force Overwrite existing files (where applicable)
546
+ --chore, --no-spec With new, open a spec-less chore change
547
+ --reason "<text>" With abandon, the reason recorded in the ledger
458
548
  `;
459
549
 
460
550
  // Re-export parsers so scan.js (index rebuild) can reuse them, and the
@@ -0,0 +1,104 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { exists, isFile } from "../lib/fs-ops.js";
4
+ import { flagBool } from "../lib/args.js";
5
+ import { c } from "../lib/colors.js";
6
+ import * as analyze from "./analyze.js";
7
+ import * as change from "./change.js";
8
+ import * as verify from "./verify.js";
9
+ import * as coverage from "./coverage.js";
10
+ import * as trace from "./trace.js";
11
+ import * as validate from "./validate.js";
12
+
13
+ // One-command close (review 2026-06-27 passive-user feature #2). The work
14
+ // playbook lists the closing sequence — analyze → apply → verify → coverage →
15
+ // trace → archive → validate — and the agent runs it step by step, which is
16
+ // exactly where a gate gets skipped. `close` runs the whole sequence in one
17
+ // pass, in-process, stopping at the first failure with the exact command to
18
+ // rerun, so the agent makes one call and the human approves once. It is a
19
+ // driver over the existing commands; it adds no new checks of its own.
20
+
21
+ export async function run(positional, flags) {
22
+ const id = positional[0];
23
+ if (!id) {
24
+ console.error(c.red("error:") + " close requires a change <id> (e.g. doctrina close 0001-add-login)");
25
+ return 2;
26
+ }
27
+ const projectRoot = process.cwd();
28
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
29
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
30
+ }
31
+
32
+ const force = flagBool(flags, "force", false);
33
+ const strict = new Map([["strict", true]]);
34
+ const archiveFlags = force ? new Map([["force", true]]) : new Map();
35
+
36
+ // Each step: a label, the command that runs it, and the literal command to
37
+ // rerun on failure. verify is conditional (skipped, with a loud note, when no
38
+ // verify.json is declared — a project may not have wired the real gate yet).
39
+ const verifyConfigured = isFile(path.join(projectRoot, ".doctrina", "verify.json"));
40
+ const steps = [
41
+ { label: "analyze", rerun: `doctrina analyze ${id}`, run: () => analyze.run([id], new Map()) },
42
+ { label: "apply", rerun: `doctrina change apply ${id}`, run: () => change.run(["apply", id], new Map()) },
43
+ verifyConfigured
44
+ ? { label: "verify", rerun: "doctrina verify", run: () => verify.run([], new Map()) }
45
+ : { label: "verify", skip: "no .doctrina/verify.json — declare the real gate with `doctrina verify --init`" },
46
+ { label: "coverage", rerun: "doctrina coverage --strict", run: () => coverage.run([], strict) },
47
+ // trace is advisory (provenance is a warning, not a hard gate): report it,
48
+ // never let it block the close.
49
+ { label: "trace", rerun: "doctrina trace", run: async () => { await trace.run([], new Map()); return 0; } },
50
+ { label: "archive", rerun: `doctrina change archive ${id}${force ? " --force" : ""}`, run: () => change.run(["archive", id], archiveFlags) },
51
+ { label: "validate", rerun: "doctrina validate", run: () => validate.run([], new Map()) },
52
+ ];
53
+
54
+ console.log(c.bold(`Closing change ${id}`) + c.gray(" — analyze → apply → verify → coverage → trace → archive → validate"));
55
+
56
+ for (let i = 0; i < steps.length; i++) {
57
+ const step = steps[i];
58
+ console.log("");
59
+ console.log(c.gray(`──── ${i + 1}/${steps.length} ${step.label}`));
60
+ if (step.skip) {
61
+ console.log(c.yellow("skip ") + step.skip);
62
+ continue;
63
+ }
64
+ let code;
65
+ try {
66
+ code = await step.run();
67
+ } catch (err) {
68
+ code = 1;
69
+ console.error(c.red("error:") + ` ${err.message}`);
70
+ }
71
+ if (code !== 0) {
72
+ console.log("");
73
+ console.log(c.red(`✗ close stopped at "${step.label}"`) + c.gray(` (step ${i + 1}/${steps.length})`));
74
+ console.log(c.gray("Fix it, then rerun the step or the whole close:"));
75
+ console.log(` ${c.cyan(step.rerun)}`);
76
+ console.log(` ${c.cyan(`doctrina close ${id}`)}`);
77
+ return 1;
78
+ }
79
+ }
80
+
81
+ console.log("");
82
+ console.log(c.green(`✓ change ${id} closed`) + c.gray(" — verified, archived, and validated."));
83
+ console.log(c.gray("Next: ") + c.cyan("doctrina next"));
84
+ return 0;
85
+ }
86
+
87
+ export const help = `
88
+ Usage: doctrina close <id> [--force]
89
+
90
+ Run the whole closing sequence for a change in one pass, stopping at the
91
+ first failure with the exact command to rerun:
92
+
93
+ analyze → change apply → verify → coverage --strict → trace → change
94
+ archive → validate
95
+
96
+ verify is skipped (with a note) when no .doctrina/verify.json is declared;
97
+ trace is advisory (a report, never a blocker). This is a driver over the
98
+ existing commands — it adds no checks of its own — so the agent makes one
99
+ call instead of seven and the human approves once.
100
+
101
+ Options:
102
+ --force Pass through to \`change archive\` (archive even if verification
103
+ is incomplete; records the gap).
104
+ `;
@@ -15,9 +15,10 @@ import { c } from "../lib/colors.js";
15
15
  // a backtick path span, e.g.
16
16
  // 1. Returns HTTP 429 above the quota — verified by `test/quota.test.ts`.
17
17
  // A criterion is "covered" when at least one cited path resolves to a
18
- // file. A cited path that does not resolve is "dangling" (broken
19
- // traceability) and reported separately. Criteria with no cited path are
20
- // "bare" — the spec promises something nothing is shown to satisfy.
18
+ // real proof, "conditional" when its only resolving proof is a test file
19
+ // whose suite is skipped (a `describe.skip`/`xit`/`@pytest.mark.skip` that
20
+ // proves nothing — review G3: existence ≠ a passing test), "dangling" when
21
+ // a cited path does not resolve, and "bare" when nothing is cited.
21
22
 
22
23
  export async function run(_positional, flags) {
23
24
  const projectRoot = process.cwd();
@@ -47,6 +48,7 @@ export async function run(_positional, flags) {
47
48
  let totalCriteria = 0;
48
49
  let totalCovered = 0;
49
50
  let totalDangling = 0;
51
+ let totalConditional = 0;
50
52
 
51
53
  console.log(c.bold("Coverage") + c.gray(" — acceptance criteria with linked evidence:"));
52
54
  console.log("");
@@ -54,16 +56,23 @@ export async function run(_positional, flags) {
54
56
  for (const rep of reports) {
55
57
  const covered = rep.rows.filter((r) => r.kind === "covered").length;
56
58
  const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
59
+ const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
57
60
  totalCriteria += rep.rows.length;
58
61
  totalCovered += covered;
59
62
  totalDangling += dangling;
63
+ totalConditional += conditional;
60
64
 
61
- const danglingNote = dangling > 0 ? c.yellow(` (${dangling} dangling)`) : "";
62
- console.log(` ${c.cyan(rep.cap.padEnd(20))} ${covered}/${rep.rows.length} criteria${danglingNote}`);
65
+ const notes = [];
66
+ if (conditional > 0) notes.push(c.yellow(`${conditional} conditional`));
67
+ if (dangling > 0) notes.push(c.yellow(`${dangling} dangling`));
68
+ const note = notes.length > 0 ? ` (${notes.join(", ")})` : "";
69
+ console.log(` ${c.cyan(rep.cap.padEnd(20))} ${covered}/${rep.rows.length} criteria${note}`);
63
70
  for (const r of rep.rows) {
64
71
  if (r.kind === "covered") continue;
65
72
  if (r.kind === "bare") {
66
73
  console.log(` ${c.red("✗")} #${r.n} no evidence linked — cite the file/test that proves it in backticks`);
74
+ } else if (r.kind === "conditional") {
75
+ console.log(` ${c.yellow("!")} #${r.n} evidence is a skipped test (proves nothing): ${r.skipped.map((m) => `\`${m}\``).join(", ")}`);
67
76
  } else {
68
77
  console.log(` ${c.yellow("!")} #${r.n} evidence not found on disk: ${r.missing.map((m) => `\`${m}\``).join(", ")}`);
69
78
  }
@@ -73,17 +82,48 @@ export async function run(_positional, flags) {
73
82
  const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
74
83
  console.log("");
75
84
  const summary = `${totalCovered} of ${totalCriteria} acceptance criteria across ${reports.length} spec${reports.length === 1 ? "" : "s"} have linked evidence (${pct}%)`;
76
- const clean = totalCovered === totalCriteria && totalDangling === 0;
85
+ const clean = totalCovered === totalCriteria && totalDangling === 0 && totalConditional === 0;
77
86
  if (clean) {
78
87
  console.log(c.green("ok") + " " + summary);
79
88
  return 0;
80
89
  }
81
- const danglingSummary = totalDangling > 0 ? `, ${totalDangling} dangling` : "";
82
- console.log((strict ? c.red("fail") : c.yellow("gap")) + " " + summary + danglingSummary);
83
- // A report by default (exit 0); a gate under --strict (exit 1 for CI).
90
+ const extras = [];
91
+ if (totalConditional > 0) extras.push(`${totalConditional} conditional`);
92
+ if (totalDangling > 0) extras.push(`${totalDangling} dangling`);
93
+ const extraSummary = extras.length > 0 ? `, ${extras.join(", ")}` : "";
94
+ console.log((strict ? c.red("fail") : c.yellow("gap")) + " " + summary + extraSummary);
95
+ // A report by default (exit 0); a gate under --strict (exit 1 for CI). A
96
+ // conditional criterion fails the gate too: a skipped test is not proof.
84
97
  return strict ? 1 : 0;
85
98
  }
86
99
 
100
+ // Pure summary of coverage across the spec tree, for other commands
101
+ // (`status`, `review`) that need the numbers without the report output.
102
+ export function summarize(projectRoot) {
103
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
104
+ let totalCriteria = 0, totalCovered = 0, totalDangling = 0, totalConditional = 0;
105
+ const perCap = [];
106
+ if (isDir(specsDir)) {
107
+ for (const cap of readdirSync(specsDir).sort()) {
108
+ const specPath = path.join(specsDir, cap, "spec.md");
109
+ if (!isFile(specPath)) continue;
110
+ const criteria = extractAcceptanceCriteria(read(specPath));
111
+ if (criteria.length === 0) continue;
112
+ const rows = criteria.map((crit, i) => classify(crit, i + 1, projectRoot, path.dirname(specPath)));
113
+ const covered = rows.filter((r) => r.kind === "covered").length;
114
+ const dangling = rows.filter((r) => r.kind === "dangling").length;
115
+ const conditional = rows.filter((r) => r.kind === "conditional").length;
116
+ totalCriteria += rows.length;
117
+ totalCovered += covered;
118
+ totalDangling += dangling;
119
+ totalConditional += conditional;
120
+ perCap.push({ cap, total: rows.length, covered, dangling, conditional });
121
+ }
122
+ }
123
+ const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
124
+ return { perCap, totalCriteria, totalCovered, totalDangling, totalConditional, pct };
125
+ }
126
+
87
127
  // Pull the numbered items out of the "## Acceptance criteria" section.
88
128
  // Each item may span multiple lines (continuation prose); accumulate until
89
129
  // the next number or the next "## " heading. Returns an array of strings.
@@ -121,22 +161,60 @@ function extractAcceptanceCriteria(text) {
121
161
  return out.filter((s) => s.length > 0);
122
162
  }
123
163
 
124
- // Decide whether a single criterion is covered, dangling, or bare.
164
+ // Decide whether a single criterion is covered, conditional, dangling, or bare.
125
165
  function classify(criterion, n, projectRoot, specDir) {
126
166
  const cited = extractBacktickPaths(criterion);
127
167
  if (cited.length === 0) return { kind: "bare", n };
128
168
  const missing = [];
129
- let resolved = 0;
169
+ const resolved = [];
130
170
  for (const token of cited) {
131
171
  const candidates = [
132
172
  path.resolve(projectRoot, token),
133
173
  path.resolve(specDir, token),
134
174
  ];
135
- if (candidates.some(exists)) resolved += 1;
136
- else missing.push(token);
175
+ const hit = candidates.find(exists);
176
+ if (!hit) {
177
+ missing.push(token);
178
+ continue;
179
+ }
180
+ const isTest = looksLikeTestFile(token);
181
+ const skipped = isTest && isFile(hit) ? testSuiteIsSkipped(read(hit)) : false;
182
+ resolved.push({ token, isTest, skipped });
183
+ }
184
+ if (resolved.length === 0) return { kind: "dangling", n, missing };
185
+ // Real proof = a non-test artifact, or a test file whose suite runs. If the
186
+ // only thing that resolves is a skipped test, the criterion is conditional.
187
+ const hasRealProof = resolved.some((r) => !r.isTest || !r.skipped);
188
+ if (hasRealProof) return { kind: "covered", n };
189
+ return { kind: "conditional", n, skipped: resolved.map((r) => r.token) };
190
+ }
191
+
192
+ // A cited path is a test file when it sits under a tests directory or carries
193
+ // a test/spec/e2e filename marker — the only files skip-detection applies to.
194
+ function looksLikeTestFile(token) {
195
+ if (/(?:^|\/)(?:tests?|__tests__|specs?|e2e)\//i.test(token)) return true;
196
+ if (/(?:\.|_|-)(?:test|spec|e2e)\.[a-z0-9]+$/i.test(token)) return true;
197
+ if (/(?:^|\/)test_[^/]+\.py$/i.test(token)) return true;
198
+ return false;
199
+ }
200
+
201
+ // Heuristic, dependency-free "is this whole test file gated off?" check: the
202
+ // first test construct in the file is a skip/todo (e.g. the Prisma e2e suite
203
+ // wrapped in `describe.skip`). A file whose first suite runs is treated as
204
+ // real proof even if it has an incidental `it.skip` later — that keeps the
205
+ // false-positive rate low without parsing the file. Python skip decorators
206
+ // and a module-level `pytest.skip` count too.
207
+ function testSuiteIsSkipped(text) {
208
+ const m = text.match(
209
+ /\b(x(?:describe|context|it|test)|(?:describe|context|suite|it|test|specify)\s*\.\s*(?:skip|todo)|(?:describe|context|suite|it|test|specify))\s*\(/,
210
+ );
211
+ if (m) {
212
+ const head = m[1];
213
+ if (/^x/.test(head) || /\.\s*(?:skip|todo)/.test(head)) return true;
137
214
  }
138
- if (resolved > 0) return { kind: "covered", n };
139
- return { kind: "dangling", n, missing };
215
+ if (/@(?:pytest\.mark\.skip|unittest\.skip)\b/.test(text)) return true;
216
+ if (/^\s*pytest\.skip\s*\(/m.test(text)) return true;
217
+ return false;
140
218
  }
141
219
 
142
220
  // Backtick spans that look like repository file paths. Mirrors the
@@ -169,11 +247,13 @@ Report how many acceptance criteria across .doctrina/specs/ cite an
169
247
  artifact or test that exists on disk. Each numbered criterion may cite
170
248
  its evidence as a backtick path span, e.g. \`test/quota.test.ts\`.
171
249
 
172
- A criterion is covered when at least one cited path resolves to a file,
173
- dangling when a cited path is missing on disk, and bare when nothing is
174
- cited. Read-only.
250
+ A criterion is covered when a cited path resolves to real proof,
251
+ conditional when its only resolving proof is a test file whose suite is
252
+ skipped (\`describe.skip\` / \`xit\` / \`@pytest.mark.skip\` — proves
253
+ nothing), dangling when a cited path is missing on disk, and bare when
254
+ nothing is cited. Read-only.
175
255
 
176
256
  Flags:
177
- --strict Exit 1 when any criterion is bare or dangling (CI gate).
178
- Without it the command always exits 0 (a report).
257
+ --strict Exit 1 when any criterion is bare, dangling, or conditional
258
+ (CI gate). Without it the command always exits 0 (a report).
179
259
  `;