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 +32 -13
- package/package.json +1 -1
- package/src/commands/change.js +128 -38
- package/src/commands/close.js +104 -0
- package/src/commands/coverage.js +100 -20
- package/src/commands/hooks.js +9 -3
- package/src/commands/next.js +56 -3
- package/src/commands/review.js +177 -0
- package/src/commands/skill.js +112 -2
- package/src/commands/spec.js +92 -6
- package/src/commands/status.js +178 -0
- package/src/commands/trace.js +31 -0
- package/src/commands/validate.js +97 -11
- package/src/commands/verify.js +234 -13
- package/src/commands/watch.js +97 -0
- package/src/commands/why.js +182 -0
- package/src/commands/work.js +241 -20
- package/src/index.js +13 -1
- package/src/lib/spec-ops.js +236 -0
- package/templates/AGENTS.md.template +49 -22
- package/templates/change/spec-delta.md.template +13 -2
- package/templates/hooks/pre-commit.sample +21 -4
- package/templates/spec.md.template +7 -3
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.
|
|
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:**
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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.
|
|
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
|
|
130
|
-
|
|
131
|
-
intent
|
|
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
package/src/commands/change.js
CHANGED
|
@@ -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
|
-
|
|
76
|
-
c.
|
|
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
|
-
|
|
137
|
-
|
|
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
|
|
165
|
-
// flipped to applied. The
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
+
`;
|
package/src/commands/coverage.js
CHANGED
|
@@ -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
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
|
62
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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 (
|
|
139
|
-
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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
|
`;
|