doctrina-cli 0.4.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 +31 -12
- package/package.json +1 -1
- package/src/commands/change.js +128 -38
- package/src/commands/coverage.js +73 -20
- package/src/commands/spec.js +92 -6
- package/src/commands/validate.js +76 -11
- package/src/commands/verify.js +152 -2
- package/src/commands/work.js +232 -20
- package/src/lib/spec-ops.js +236 -0
- package/templates/change/spec-delta.md.template +13 -2
package/README.md
CHANGED
|
@@ -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
|
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/spec.js
CHANGED
|
@@ -3,14 +3,15 @@ import process from "node:process";
|
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
4
|
import { exists, isDir, isFile, lineCount, read, relPath, write } from "../lib/fs-ops.js";
|
|
5
5
|
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
6
|
-
import { specHeader } from "../lib/scan.js";
|
|
6
|
+
import { specHeader, deriveIndex } from "../lib/scan.js";
|
|
7
|
+
import { setHeader, bumpVersion, setCriterionMark } from "../lib/spec-ops.js";
|
|
7
8
|
import * as idx from "../lib/index-json.js";
|
|
8
9
|
import { today } from "../lib/dates.js";
|
|
9
|
-
import { flagBool } from "../lib/args.js";
|
|
10
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
10
11
|
import { c } from "../lib/colors.js";
|
|
11
12
|
import { suggest } from "../lib/suggest.js";
|
|
12
13
|
|
|
13
|
-
const SUBCOMMANDS = ["new", "list"];
|
|
14
|
+
const SUBCOMMANDS = ["new", "list", "set"];
|
|
14
15
|
|
|
15
16
|
export async function run(positional, flags) {
|
|
16
17
|
const sub = positional[0];
|
|
@@ -23,6 +24,7 @@ export async function run(positional, flags) {
|
|
|
23
24
|
return 2;
|
|
24
25
|
}
|
|
25
26
|
if (sub === "list") return specList();
|
|
27
|
+
if (sub === "set") return specSet(positional.slice(1), flags);
|
|
26
28
|
|
|
27
29
|
const capability = positional[1];
|
|
28
30
|
if (!capability || !/^[a-z][a-z0-9-]*$/.test(capability)) {
|
|
@@ -109,6 +111,82 @@ function specList() {
|
|
|
109
111
|
return 0;
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
// Edit a spec's bookkeeping headers (and a criterion mark) and resync the
|
|
115
|
+
// index in one step — the inverse drift footgun the review hit (G8): a manual
|
|
116
|
+
// "bump the version in the spec, then bump it again in index.json" lockstep.
|
|
117
|
+
// Reuses the same structured ops as `change apply` (ADR 0007), so the header
|
|
118
|
+
// and the index can never diverge after a `spec set`.
|
|
119
|
+
function specSet(args, flags) {
|
|
120
|
+
const capability = args[0];
|
|
121
|
+
if (!capability || !/^[a-z][a-z0-9-]*$/.test(capability)) {
|
|
122
|
+
console.error(c.red("error:") + " spec set requires a <capability> (lowercase letters, digits, hyphens)");
|
|
123
|
+
return 2;
|
|
124
|
+
}
|
|
125
|
+
const projectRoot = process.cwd();
|
|
126
|
+
ensureDoctrinaProject(projectRoot);
|
|
127
|
+
|
|
128
|
+
const specPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
|
|
129
|
+
if (!isFile(specPath)) {
|
|
130
|
+
console.error(c.red("error:") + ` no spec at ${relPath(projectRoot, specPath)} (create it with \`doctrina spec new ${capability}\`)`);
|
|
131
|
+
return 1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const impl = flagString(flags, "implementation");
|
|
135
|
+
const status = flagString(flags, "status");
|
|
136
|
+
const bump = flagString(flags, "bump");
|
|
137
|
+
const criterion = flagString(flags, "criterion");
|
|
138
|
+
if (impl === undefined && status === undefined && bump === undefined && criterion === undefined) {
|
|
139
|
+
console.error(c.red("error:") + " spec set needs at least one of --implementation, --status, --bump, --criterion");
|
|
140
|
+
console.error(c.gray("hint: ") + `example: doctrina spec set ${capability} --implementation "verified — \`src/x.js\`" --bump minor`);
|
|
141
|
+
return 2;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let text = read(specPath);
|
|
145
|
+
const applied = [];
|
|
146
|
+
const errors = [];
|
|
147
|
+
const apply = (res) => {
|
|
148
|
+
if (res.error) errors.push(res.error);
|
|
149
|
+
else { text = res.text; applied.push(res.summary); }
|
|
150
|
+
};
|
|
151
|
+
// Order: headers, then version, then criterion — independent edits, all or
|
|
152
|
+
// none (a failed op leaves the spec untouched, like `change apply`).
|
|
153
|
+
if (status !== undefined) apply(setHeader(text, "Status", status));
|
|
154
|
+
if (impl !== undefined) apply(setHeader(text, "Implementation", impl));
|
|
155
|
+
if (bump !== undefined) {
|
|
156
|
+
if (!["major", "minor", "patch"].includes(bump)) errors.push(`--bump needs major|minor|patch (got "${bump}")`);
|
|
157
|
+
else apply(bumpVersion(text, bump));
|
|
158
|
+
}
|
|
159
|
+
if (criterion !== undefined) {
|
|
160
|
+
const m = String(criterion).match(/^(\d+)\s*:\s*(.+)$/);
|
|
161
|
+
if (!m) errors.push(`--criterion needs "<n>:<mark>" (got "${criterion}")`);
|
|
162
|
+
else apply(setCriterionMark(text, Number(m[1]), m[2].trim()));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (errors.length > 0) {
|
|
166
|
+
console.error(c.red("error:") + ` ${errors.length} operation error${errors.length === 1 ? "" : "s"} — spec left untouched:`);
|
|
167
|
+
for (const e of errors) console.error(` - ${e}`);
|
|
168
|
+
return 1;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Stamp Last updated when the header exists (best-effort, never an error).
|
|
172
|
+
const date = today();
|
|
173
|
+
const lu = setHeader(text, "Last updated", date);
|
|
174
|
+
if (!lu.error) text = lu.text;
|
|
175
|
+
|
|
176
|
+
write(specPath, text, { force: true });
|
|
177
|
+
console.log(c.green("updated") + ` ${relPath(projectRoot, specPath)}`);
|
|
178
|
+
for (const s of applied) console.log(c.gray(` · ${s}`));
|
|
179
|
+
|
|
180
|
+
// Single source of truth: regenerate the index from the now-updated tree so
|
|
181
|
+
// the spec headers and the index can never drift (no manual lockstep — G8).
|
|
182
|
+
const index = idx.load(projectRoot);
|
|
183
|
+
const derived = deriveIndex(projectRoot, index);
|
|
184
|
+
derived.last_updated = date;
|
|
185
|
+
idx.save(projectRoot, derived);
|
|
186
|
+
console.log(c.green("indexed") + ` spec "${capability}" synced`);
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
112
190
|
function ensureDoctrinaProject(projectRoot) {
|
|
113
191
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
114
192
|
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
@@ -125,9 +203,17 @@ Subcommands:
|
|
|
125
203
|
list One line per spec: id, version,
|
|
126
204
|
status, line count, last updated.
|
|
127
205
|
Read-only.
|
|
206
|
+
set <capability> [opts] Edit a spec's headers / a criterion
|
|
207
|
+
mark and resync the index in one step
|
|
208
|
+
(no manual lockstep bump — G8).
|
|
128
209
|
|
|
129
210
|
Options:
|
|
130
|
-
--bug
|
|
131
|
-
|
|
132
|
-
--force
|
|
211
|
+
--bug Scaffold the bug-shape template (current / expected /
|
|
212
|
+
unchanged behaviour) instead of the EARS capability spec.
|
|
213
|
+
--force Overwrite an existing spec (with \`new\`).
|
|
214
|
+
--implementation "..." With \`set\`: set the Implementation header.
|
|
215
|
+
--status "..." With \`set\`: set the Status header.
|
|
216
|
+
--bump major|minor|patch With \`set\`: bump the spec Version.
|
|
217
|
+
--criterion "<n>:<mark>" With \`set\`: set criterion n's [mark]
|
|
218
|
+
(e.g. --criterion "2:verified").
|
|
133
219
|
`;
|