doctrina-cli 0.3.0 → 0.6.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 +48 -11
- package/package.json +1 -1
- package/src/commands/change.js +131 -29
- package/src/commands/coverage.js +73 -20
- package/src/commands/decision.js +83 -1
- package/src/commands/index-rebuild.js +9 -0
- package/src/commands/init.js +7 -1
- package/src/commands/intake.js +16 -1
- package/src/commands/search.js +42 -7
- package/src/commands/spec.js +92 -6
- package/src/commands/trace.js +157 -0
- package/src/commands/validate.js +140 -34
- package/src/commands/verify.js +152 -2
- package/src/commands/work.js +243 -19
- package/src/index.js +6 -15
- package/src/lib/clarity.js +49 -0
- package/src/lib/index-json.js +8 -2
- package/src/lib/scan.js +8 -0
- package/src/lib/spec-ops.js +236 -0
- package/src/lib/version.js +21 -0
- package/templates/change/spec-delta.md.template +13 -2
- package/templates/decision.md.template +8 -0
- package/templates/doctrina/index.json.template +1 -1
- package/templates/doctrina/product.md.template +6 -1
- package/templates/spec.md.template +4 -0
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.4.0 — current release. Zero runtime dependencies
|
|
7
7
|
(Node.js standard library only).
|
|
8
8
|
|
|
9
9
|
## Install
|
|
@@ -20,14 +20,24 @@ Requires Node.js 20.12 or later.
|
|
|
20
20
|
|
|
21
21
|
```
|
|
22
22
|
doctrina init scaffold AGENTS.md + .doctrina/ in cwd
|
|
23
|
+
doctrina intake [<file>] store the full description + bootstrap playbook (clarification gate)
|
|
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
|
|
23
28
|
doctrina spec new <capability> create a capability spec
|
|
24
29
|
doctrina spec list list specs with version, status, size
|
|
30
|
+
doctrina spec set <cap> [--bump ...] edit spec headers/criterion + resync index (no lockstep)
|
|
25
31
|
doctrina change new <id> "<title>" open a change proposal
|
|
26
|
-
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)
|
|
27
33
|
doctrina change archive <id> archive an applied change
|
|
34
|
+
doctrina change abandon <id> delete an open change + its index entry (ledgered)
|
|
28
35
|
doctrina change diff <id> preview spec deltas (line diff for MODIFIED)
|
|
36
|
+
doctrina contract new <name> own the integration surface (ports, env, interfaces)
|
|
37
|
+
doctrina contract check verify port collisions, env drift, referenced specs
|
|
29
38
|
doctrina decision new "<title>" create the next sequential ADR
|
|
30
39
|
doctrina decision accept <num> flip a proposed ADR to accepted
|
|
40
|
+
doctrina decision land <num> [path...] stamp an accepted ADR as implemented (non-mutating Landed:)
|
|
31
41
|
doctrina decision supersede <num> "<t>" supersede an existing ADR
|
|
32
42
|
doctrina decision list list ADRs with status, date, title
|
|
33
43
|
doctrina skill new <name> scaffold an on-demand procedural-memory skill
|
|
@@ -36,8 +46,12 @@ doctrina skill sync mirror skill frontmatter descriptions
|
|
|
36
46
|
doctrina analyze <change-id> inspect a change folder before applying
|
|
37
47
|
doctrina clarify <path> smell-test a Markdown file for ambiguity (--all: tree)
|
|
38
48
|
doctrina context [<capability>] print the context pack in read order (--concat)
|
|
39
|
-
doctrina search <term> [...] search artifacts, grouped by category
|
|
40
|
-
doctrina validate schema + artifact-existence checks
|
|
49
|
+
doctrina search <term> [...] search artifacts, grouped by category (ranked)
|
|
50
|
+
doctrina validate schema + artifact-existence + index-drift checks (--fix rebuilds)
|
|
51
|
+
doctrina coverage acceptance criteria with linked evidence (--strict gates; skips = conditional)
|
|
52
|
+
doctrina trace intent provenance: product intent -> specs (--strict gates)
|
|
53
|
+
doctrina verify run project typecheck/test/build checks (the real gate)
|
|
54
|
+
doctrina verify --clean reproducibility lint: clean-checkout footguns (dist/codegen)
|
|
41
55
|
doctrina templates list enumerate shipped templates
|
|
42
56
|
doctrina templates check compare project against recommended template shape
|
|
43
57
|
doctrina templates update additive fixer for check findings (preview; --write applies)
|
|
@@ -84,10 +98,17 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
|
|
|
84
98
|
- **ADDED:** writes the delta body to the target spec; refuses if the
|
|
85
99
|
target already exists.
|
|
86
100
|
- **REMOVED:** deletes the target spec.
|
|
87
|
-
- **MODIFIED:**
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
91
112
|
|
|
92
113
|
## `validate` checks
|
|
93
114
|
|
|
@@ -105,12 +126,28 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
|
|
|
105
126
|
12. Skills carry the required frontmatter triple (`name`, `description`, `when`).
|
|
106
127
|
13. Skills are ≤ 200 lines (warns at > 150).
|
|
107
128
|
14. Each skill's `name:` matches its filename slug.
|
|
108
|
-
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).
|
|
109
132
|
16. Each skill's frontmatter description matches `index.json` (drift warn; `skill sync` fixes).
|
|
110
133
|
17. EARS grammar shape per section in specs declaring `## Requirements (EARS)` (warn).
|
|
111
134
|
18. Nested `AGENTS.md` files obey the root size caps (warn > 150, error > 200).
|
|
112
|
-
|
|
113
|
-
|
|
135
|
+
19. No two ADR files share the same `NNNN` number (merge-collision error).
|
|
136
|
+
20. `index.json` records a `framework_version` matching the running CLI
|
|
137
|
+
(stamp-divergence warn; `index rebuild` migrates it).
|
|
138
|
+
21. Accepted ADRs anchor to reality via `Evidence:` and/or `Landed:`; cited
|
|
139
|
+
paths must exist on disk (drift warn).
|
|
140
|
+
|
|
141
|
+
Exit code: 0 if no errors (warnings allowed), 1 otherwise. `--fix`
|
|
142
|
+
regenerates `index.json` from the tree before validating.
|
|
143
|
+
|
|
144
|
+
Two related read-only reports gate separately: `doctrina coverage`
|
|
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).
|
|
114
151
|
|
|
115
152
|
## Tests
|
|
116
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;
|
|
@@ -144,6 +190,7 @@ function changeApply(args, _flags) {
|
|
|
144
190
|
// Flip proposal status from "proposed" to "applied" when fully successful.
|
|
145
191
|
// Zero-delta changes are trivially successful (no work to fail), so they
|
|
146
192
|
// also flip — otherwise metadata-only changes are stuck at "proposed".
|
|
193
|
+
let flippedToApplied = false;
|
|
147
194
|
if (errors === 0 && manual === 0) {
|
|
148
195
|
const proposalPath = path.join(changeDir, "proposal.md");
|
|
149
196
|
if (exists(proposalPath)) {
|
|
@@ -155,32 +202,23 @@ function changeApply(args, _flags) {
|
|
|
155
202
|
if (updated !== txt) {
|
|
156
203
|
write(proposalPath, updated, { force: true });
|
|
157
204
|
console.log(c.green("status") + " proposal.md → applied");
|
|
205
|
+
flippedToApplied = true;
|
|
158
206
|
}
|
|
159
207
|
}
|
|
160
208
|
}
|
|
161
209
|
|
|
162
|
-
// Update index
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
status: "active",
|
|
175
|
-
version: "0.1.0",
|
|
176
|
-
last_updated: date,
|
|
177
|
-
});
|
|
178
|
-
} else if (op === "REMOVED") {
|
|
179
|
-
idx.removeSpec(index, capability);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
idx.touch(index, date);
|
|
183
|
-
idx.save(projectRoot, index);
|
|
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.
|
|
217
|
+
if (writes > 0 || flippedToApplied) {
|
|
218
|
+
const current = idx.load(projectRoot);
|
|
219
|
+
const derived = deriveIndex(projectRoot, current);
|
|
220
|
+
derived.last_updated = date;
|
|
221
|
+
idx.save(projectRoot, derived);
|
|
184
222
|
}
|
|
185
223
|
|
|
186
224
|
console.log("");
|
|
@@ -293,6 +331,52 @@ function changeArchive(args, flags) {
|
|
|
293
331
|
return 0;
|
|
294
332
|
}
|
|
295
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
|
+
|
|
296
380
|
function changeDiff(args, _flags) {
|
|
297
381
|
const id = args[0];
|
|
298
382
|
if (!id) {
|
|
@@ -436,13 +520,31 @@ Usage: doctrina change <subcommand> [args]
|
|
|
436
520
|
|
|
437
521
|
Subcommands:
|
|
438
522
|
new <id> "<title>" Open a new change proposal at .doctrina/changes/<id>/
|
|
439
|
-
|
|
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.
|
|
440
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 "..."]).
|
|
441
533
|
diff <id> Preview every spec delta: line diff for MODIFIED,
|
|
442
534
|
summary for ADDED/REMOVED. Read-only.
|
|
443
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
|
+
|
|
444
544
|
Options:
|
|
445
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
|
|
446
548
|
`;
|
|
447
549
|
|
|
448
550
|
// Re-export parsers so scan.js (index rebuild) can reuse them, and the
|
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,14 +82,18 @@ 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
|
|
|
@@ -121,22 +134,60 @@ function extractAcceptanceCriteria(text) {
|
|
|
121
134
|
return out.filter((s) => s.length > 0);
|
|
122
135
|
}
|
|
123
136
|
|
|
124
|
-
// Decide whether a single criterion is covered, dangling, or bare.
|
|
137
|
+
// Decide whether a single criterion is covered, conditional, dangling, or bare.
|
|
125
138
|
function classify(criterion, n, projectRoot, specDir) {
|
|
126
139
|
const cited = extractBacktickPaths(criterion);
|
|
127
140
|
if (cited.length === 0) return { kind: "bare", n };
|
|
128
141
|
const missing = [];
|
|
129
|
-
|
|
142
|
+
const resolved = [];
|
|
130
143
|
for (const token of cited) {
|
|
131
144
|
const candidates = [
|
|
132
145
|
path.resolve(projectRoot, token),
|
|
133
146
|
path.resolve(specDir, token),
|
|
134
147
|
];
|
|
135
|
-
|
|
136
|
-
|
|
148
|
+
const hit = candidates.find(exists);
|
|
149
|
+
if (!hit) {
|
|
150
|
+
missing.push(token);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const isTest = looksLikeTestFile(token);
|
|
154
|
+
const skipped = isTest && isFile(hit) ? testSuiteIsSkipped(read(hit)) : false;
|
|
155
|
+
resolved.push({ token, isTest, skipped });
|
|
156
|
+
}
|
|
157
|
+
if (resolved.length === 0) return { kind: "dangling", n, missing };
|
|
158
|
+
// Real proof = a non-test artifact, or a test file whose suite runs. If the
|
|
159
|
+
// only thing that resolves is a skipped test, the criterion is conditional.
|
|
160
|
+
const hasRealProof = resolved.some((r) => !r.isTest || !r.skipped);
|
|
161
|
+
if (hasRealProof) return { kind: "covered", n };
|
|
162
|
+
return { kind: "conditional", n, skipped: resolved.map((r) => r.token) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// A cited path is a test file when it sits under a tests directory or carries
|
|
166
|
+
// a test/spec/e2e filename marker — the only files skip-detection applies to.
|
|
167
|
+
function looksLikeTestFile(token) {
|
|
168
|
+
if (/(?:^|\/)(?:tests?|__tests__|specs?|e2e)\//i.test(token)) return true;
|
|
169
|
+
if (/(?:\.|_|-)(?:test|spec|e2e)\.[a-z0-9]+$/i.test(token)) return true;
|
|
170
|
+
if (/(?:^|\/)test_[^/]+\.py$/i.test(token)) return true;
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Heuristic, dependency-free "is this whole test file gated off?" check: the
|
|
175
|
+
// first test construct in the file is a skip/todo (e.g. the Prisma e2e suite
|
|
176
|
+
// wrapped in `describe.skip`). A file whose first suite runs is treated as
|
|
177
|
+
// real proof even if it has an incidental `it.skip` later — that keeps the
|
|
178
|
+
// false-positive rate low without parsing the file. Python skip decorators
|
|
179
|
+
// and a module-level `pytest.skip` count too.
|
|
180
|
+
function testSuiteIsSkipped(text) {
|
|
181
|
+
const m = text.match(
|
|
182
|
+
/\b(x(?:describe|context|it|test)|(?:describe|context|suite|it|test|specify)\s*\.\s*(?:skip|todo)|(?:describe|context|suite|it|test|specify))\s*\(/,
|
|
183
|
+
);
|
|
184
|
+
if (m) {
|
|
185
|
+
const head = m[1];
|
|
186
|
+
if (/^x/.test(head) || /\.\s*(?:skip|todo)/.test(head)) return true;
|
|
137
187
|
}
|
|
138
|
-
if (
|
|
139
|
-
|
|
188
|
+
if (/@(?:pytest\.mark\.skip|unittest\.skip)\b/.test(text)) return true;
|
|
189
|
+
if (/^\s*pytest\.skip\s*\(/m.test(text)) return true;
|
|
190
|
+
return false;
|
|
140
191
|
}
|
|
141
192
|
|
|
142
193
|
// Backtick spans that look like repository file paths. Mirrors the
|
|
@@ -169,11 +220,13 @@ Report how many acceptance criteria across .doctrina/specs/ cite an
|
|
|
169
220
|
artifact or test that exists on disk. Each numbered criterion may cite
|
|
170
221
|
its evidence as a backtick path span, e.g. \`test/quota.test.ts\`.
|
|
171
222
|
|
|
172
|
-
A criterion is covered when
|
|
173
|
-
|
|
174
|
-
|
|
223
|
+
A criterion is covered when a cited path resolves to real proof,
|
|
224
|
+
conditional when its only resolving proof is a test file whose suite is
|
|
225
|
+
skipped (\`describe.skip\` / \`xit\` / \`@pytest.mark.skip\` — proves
|
|
226
|
+
nothing), dangling when a cited path is missing on disk, and bare when
|
|
227
|
+
nothing is cited. Read-only.
|
|
175
228
|
|
|
176
229
|
Flags:
|
|
177
|
-
--strict Exit 1 when any criterion is bare
|
|
178
|
-
Without it the command always exits 0 (a report).
|
|
230
|
+
--strict Exit 1 when any criterion is bare, dangling, or conditional
|
|
231
|
+
(CI gate). Without it the command always exits 0 (a report).
|
|
179
232
|
`;
|
package/src/commands/decision.js
CHANGED
|
@@ -8,7 +8,7 @@ import { today, slugify, padNumber } from "../lib/dates.js";
|
|
|
8
8
|
import { c } from "../lib/colors.js";
|
|
9
9
|
import { suggest } from "../lib/suggest.js";
|
|
10
10
|
|
|
11
|
-
const SUBCOMMANDS = ["new", "supersede", "accept", "list"];
|
|
11
|
+
const SUBCOMMANDS = ["new", "supersede", "accept", "land", "list"];
|
|
12
12
|
|
|
13
13
|
export async function run(positional, _flags) {
|
|
14
14
|
const sub = positional[0];
|
|
@@ -19,6 +19,8 @@ export async function run(positional, _flags) {
|
|
|
19
19
|
return decisionSupersede(positional.slice(1));
|
|
20
20
|
case "accept":
|
|
21
21
|
return decisionAccept(positional.slice(1));
|
|
22
|
+
case "land":
|
|
23
|
+
return decisionLand(positional.slice(1));
|
|
22
24
|
case "list":
|
|
23
25
|
return decisionList();
|
|
24
26
|
default:
|
|
@@ -198,6 +200,81 @@ function decisionAccept(args) {
|
|
|
198
200
|
return 0;
|
|
199
201
|
}
|
|
200
202
|
|
|
203
|
+
// Record that an accepted ADR is now implemented and verified, without
|
|
204
|
+
// editing the immutable body. Mutates ONLY the **Landed:** header line (the
|
|
205
|
+
// same "headers are updatable, the decision prose is not" pattern that accept
|
|
206
|
+
// and supersede already use), stamping today's date plus any cited proof
|
|
207
|
+
// paths. This closes the gap where a design-time ADR ("Evidence: n/a — no
|
|
208
|
+
// implementation yet") had no first-class way to record that reality caught up
|
|
209
|
+
// short of a heavyweight supersede. (3.4)
|
|
210
|
+
function decisionLand(args) {
|
|
211
|
+
const target = args[0];
|
|
212
|
+
if (!target) {
|
|
213
|
+
console.error(c.red("error:") + " decision land requires the target ADR number (e.g. 0003)");
|
|
214
|
+
return 2;
|
|
215
|
+
}
|
|
216
|
+
const projectRoot = process.cwd();
|
|
217
|
+
ensureDoctrinaProject(projectRoot);
|
|
218
|
+
|
|
219
|
+
const padded = padNumber(parseInt(target, 10));
|
|
220
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
221
|
+
const file = walk(adrDir).find((f) => path.basename(f).startsWith(`${padded}-`));
|
|
222
|
+
if (!file) {
|
|
223
|
+
console.error(c.red("error:") + ` no ADR found with number ${padded} in ${relPath(projectRoot, adrDir)}`);
|
|
224
|
+
return 1;
|
|
225
|
+
}
|
|
226
|
+
const text = read(file);
|
|
227
|
+
const statusMatch = text.match(/^-\s+\*\*Status:\*\*\s+(.+)$/m);
|
|
228
|
+
if (!statusMatch) {
|
|
229
|
+
console.error(c.red("error:") + ` ADR at ${relPath(projectRoot, file)} has no Status: header`);
|
|
230
|
+
return 1;
|
|
231
|
+
}
|
|
232
|
+
const status = statusMatch[1].trim().toLowerCase();
|
|
233
|
+
if (status !== "accepted") {
|
|
234
|
+
console.error(
|
|
235
|
+
c.red("error:") +
|
|
236
|
+
` ADR ${padded} is "${statusMatch[1].trim()}", not "accepted" — accept it before recording that it landed`,
|
|
237
|
+
);
|
|
238
|
+
return 1;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const date = today();
|
|
242
|
+
const proofPaths = args.slice(1).filter(Boolean);
|
|
243
|
+
const cited = proofPaths.map((p) => `\`${p}\``).join(", ");
|
|
244
|
+
const landedValue = cited ? `${date} — ${cited}` : date;
|
|
245
|
+
|
|
246
|
+
// Rewrite the existing Landed header, or insert one (after Evidence, else
|
|
247
|
+
// after Status) for ADRs that predate the field. The body stays untouched.
|
|
248
|
+
let updated;
|
|
249
|
+
if (/^-\s+\*\*Landed:\*\*/m.test(text)) {
|
|
250
|
+
updated = text.replace(/^(-\s+\*\*Landed:\*\*)\s+.*$/m, `$1 ${landedValue}`);
|
|
251
|
+
} else if (/^-\s+\*\*Evidence:\*\*.*$/m.test(text)) {
|
|
252
|
+
updated = text.replace(/^(-\s+\*\*Evidence:\*\*.*)$/m, `$1\n- **Landed:** ${landedValue}`);
|
|
253
|
+
} else {
|
|
254
|
+
updated = text.replace(/^(-\s+\*\*Status:\*\*.*)$/m, `$1\n- **Landed:** ${landedValue}`);
|
|
255
|
+
}
|
|
256
|
+
write(file, updated, { force: true });
|
|
257
|
+
console.log(c.green("landed") + ` ${relPath(projectRoot, file)} on ${date}`);
|
|
258
|
+
|
|
259
|
+
const index = idx.load(projectRoot);
|
|
260
|
+
// Store the same value the header now carries, so the index matches what
|
|
261
|
+
// `index rebuild` would derive from the file (no perpetual drift).
|
|
262
|
+
idx.updateDecision(index, padded, () => ({ landed: landedValue }));
|
|
263
|
+
idx.touch(index, date);
|
|
264
|
+
idx.save(projectRoot, index);
|
|
265
|
+
console.log(c.green("indexed") + ` decision ${padded} landed`);
|
|
266
|
+
|
|
267
|
+
// Parity with `validate`: cited proof that does not resolve is a dangling
|
|
268
|
+
// citation, surfaced now rather than at the next validate.
|
|
269
|
+
for (const p of proofPaths) {
|
|
270
|
+
const candidates = [path.resolve(path.dirname(file), p), path.resolve(projectRoot, p)];
|
|
271
|
+
if (!candidates.some(exists)) {
|
|
272
|
+
console.log(c.yellow("warn:") + ` cited proof \`${p}\` not found on disk`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
|
|
201
278
|
function decisionList() {
|
|
202
279
|
const projectRoot = process.cwd();
|
|
203
280
|
ensureDoctrinaProject(projectRoot);
|
|
@@ -259,6 +336,11 @@ Subcommands:
|
|
|
259
336
|
new "<title>" Create the next sequentially numbered ADR
|
|
260
337
|
accept <number> Flip a proposed ADR to accepted (rewrites
|
|
261
338
|
only the Status: header) and update the index
|
|
339
|
+
land <number> [path ...] Stamp an accepted ADR as implemented:
|
|
340
|
+
rewrites only the Landed: header with today's
|
|
341
|
+
date plus any cited proof paths. The decision
|
|
342
|
+
body stays immutable. Satisfies the accepted-
|
|
343
|
+
ADR evidence check without a supersede.
|
|
262
344
|
supersede <number> "<new title>" Create a new ADR that supersedes the target
|
|
263
345
|
and rewrite the target's Status: header
|
|
264
346
|
list One line per ADR: number, status, date,
|
|
@@ -3,6 +3,7 @@ import process from "node:process";
|
|
|
3
3
|
import { exists } from "../lib/fs-ops.js";
|
|
4
4
|
import * as idx from "../lib/index-json.js";
|
|
5
5
|
import { deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
|
|
6
|
+
import { cliVersion } from "../lib/version.js";
|
|
6
7
|
import { today } from "../lib/dates.js";
|
|
7
8
|
import { flagBool } from "../lib/args.js";
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
@@ -42,6 +43,11 @@ function rebuild(flags) {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
const derived = deriveIndex(projectRoot, current);
|
|
46
|
+
// Migrate the framework stamp to the running CLI (3.6). deriveIndex carries
|
|
47
|
+
// the old value over (so `next` does not nag on a version-only difference);
|
|
48
|
+
// overriding it here lets a stale stamp count as drift, so `index rebuild`
|
|
49
|
+
// both reports and fixes it instead of short-circuiting on "nothing to do".
|
|
50
|
+
derived.framework_version = cliVersion();
|
|
45
51
|
|
|
46
52
|
if (indexesMatch(derived, current)) {
|
|
47
53
|
console.log(c.green("ok") + " index.json matches the tree (nothing to do)");
|
|
@@ -72,6 +78,9 @@ function rebuild(flags) {
|
|
|
72
78
|
function describeDrift(current, derived) {
|
|
73
79
|
const lines = [];
|
|
74
80
|
if (!current) return ["index.json missing or unreadable"];
|
|
81
|
+
if ((current.framework_version ?? null) !== (derived.framework_version ?? null)) {
|
|
82
|
+
lines.push(`framework_version: ${current.framework_version ?? "unset"} -> ${derived.framework_version}`);
|
|
83
|
+
}
|
|
75
84
|
const categories = ["specs", "decisions", "changes", "changes_archive", "skills"];
|
|
76
85
|
for (const cat of categories) {
|
|
77
86
|
const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|