doctrina-cli 0.11.0 → 0.13.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/package.json +1 -1
- package/src/commands/analyze.js +18 -2
- package/src/commands/change.js +322 -12
- package/src/commands/clarify.js +97 -11
- package/src/commands/close.js +99 -13
- package/src/commands/context.js +10 -1
- package/src/commands/coverage.js +157 -41
- package/src/commands/init.js +15 -2
- package/src/commands/intent.js +173 -0
- package/src/commands/review.js +25 -4
- package/src/commands/skill.js +46 -7
- package/src/commands/spec.js +13 -3
- package/src/commands/templates.js +113 -10
- package/src/commands/upgrade.js +133 -0
- package/src/commands/validate.js +152 -3
- package/src/commands/why.js +11 -1
- package/src/commands/work.js +105 -21
- package/src/index.js +11 -2
- package/src/lib/adr-guard.js +46 -0
- package/src/lib/commands.js +105 -4
- package/src/lib/scan.js +16 -0
- package/src/lib/spec-ops.js +143 -3
- package/templates/.gitkeep +0 -0
- package/templates/AGENTS.md.template +25 -31
- package/templates/change/spec-delta.md.template +13 -7
- package/templates/spec.md.template +9 -1
package/src/commands/close.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import { exists, isFile } from "../lib/fs-ops.js";
|
|
3
|
+
import { exists, isFile, read, walk } from "../lib/fs-ops.js";
|
|
4
4
|
import { flagBool } from "../lib/args.js";
|
|
5
5
|
import { c } from "../lib/colors.js";
|
|
6
|
+
import { parseCapabilityFromDelta } from "./change.js";
|
|
7
|
+
import { printAdrCheckpoint } from "../lib/adr-guard.js";
|
|
6
8
|
import * as analyze from "./analyze.js";
|
|
7
9
|
import * as change from "./change.js";
|
|
8
10
|
import * as verify from "./verify.js";
|
|
9
11
|
import * as coverage from "./coverage.js";
|
|
10
12
|
import * as trace from "./trace.js";
|
|
11
13
|
import * as validate from "./validate.js";
|
|
14
|
+
import * as skill from "./skill.js";
|
|
12
15
|
|
|
13
16
|
// One-command close (review 2026-06-27 passive-user feature #2). The work
|
|
14
17
|
// playbook lists the closing sequence — analyze → apply → verify → coverage →
|
|
@@ -19,8 +22,7 @@ import * as validate from "./validate.js";
|
|
|
19
22
|
// driver over the existing commands; it adds no new checks of its own.
|
|
20
23
|
|
|
21
24
|
export async function run(positional, flags) {
|
|
22
|
-
|
|
23
|
-
if (!id) {
|
|
25
|
+
if (positional.length === 0) {
|
|
24
26
|
console.error(c.red("error:") + " close requires a change <id> (e.g. doctrina close 0001-add-login)");
|
|
25
27
|
return 2;
|
|
26
28
|
}
|
|
@@ -29,21 +31,72 @@ export async function run(positional, flags) {
|
|
|
29
31
|
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
// Batch close (operator review 2026-07-19 §4.5): each id closes
|
|
35
|
+
// independently, worst exit code wins, so a 9-change implementation lot is
|
|
36
|
+
// one command, not nine.
|
|
37
|
+
let worst = 0;
|
|
38
|
+
for (const id of positional) {
|
|
39
|
+
if (positional.length > 1) {
|
|
40
|
+
console.log("");
|
|
41
|
+
console.log(c.bold(`════ close ${id}`));
|
|
42
|
+
}
|
|
43
|
+
worst = Math.max(worst, await closeOne(projectRoot, id, flags));
|
|
44
|
+
}
|
|
45
|
+
if (positional.length > 1) {
|
|
46
|
+
console.log("");
|
|
47
|
+
console.log(worst === 0
|
|
48
|
+
? c.green("ok") + ` all ${positional.length} changes closed`
|
|
49
|
+
: c.red("fail") + " at least one close stopped — see above");
|
|
50
|
+
}
|
|
51
|
+
return worst;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function closeOne(projectRoot, id, flags) {
|
|
32
55
|
const force = flagBool(flags, "force", false);
|
|
33
|
-
const strict = new Map([["strict", true]]);
|
|
34
56
|
const archiveFlags = force ? new Map([["force", true]]) : new Map();
|
|
35
57
|
|
|
58
|
+
// Scope the coverage gate to the capabilities THIS change touches (its
|
|
59
|
+
// deltas), so one deliberately deferred spec elsewhere in the tree cannot
|
|
60
|
+
// block closing a change that never went near it (0.11.0 field review
|
|
61
|
+
// item 4). A change with no deltas (chore / metadata-only) falls back to
|
|
62
|
+
// the whole-tree gate — there is no narrower honest scope for it.
|
|
63
|
+
const touched = touchedCapabilities(projectRoot, id);
|
|
64
|
+
const coverageFlags = new Map([["strict", true]]);
|
|
65
|
+
let coverageRerun = "doctrina coverage --strict";
|
|
66
|
+
if (touched.length > 0) {
|
|
67
|
+
coverageFlags.set("only", touched.join(","));
|
|
68
|
+
coverageRerun = `doctrina coverage --strict --only ${touched.join(",")}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
36
71
|
// Each step: a label, the command that runs it, and the literal command to
|
|
37
72
|
// rerun on failure. verify is conditional (skipped, with a loud note, when no
|
|
38
73
|
// verify.json is declared — a project may not have wired the real gate yet).
|
|
39
74
|
const verifyConfigured = isFile(path.join(projectRoot, ".doctrina", "verify.json"));
|
|
40
75
|
const steps = [
|
|
41
76
|
{ label: "analyze", rerun: `doctrina analyze ${id}`, run: () => analyze.run([id], new Map()) },
|
|
77
|
+
// ADR checkpoint (operator review §4.6): the playbook's "record an ADR"
|
|
78
|
+
// step was skippable in silence. Advisory — it names the accepted ADRs
|
|
79
|
+
// whose text cites the touched capabilities and the amend commands, but
|
|
80
|
+
// an ADR merely mentioning a capability is normal, so it never blocks.
|
|
81
|
+
{
|
|
82
|
+
label: "ADR checkpoint (advisory)",
|
|
83
|
+
rerun: "doctrina decision list",
|
|
84
|
+
run: async () => {
|
|
85
|
+
if (printAdrCheckpoint(projectRoot, touched, { c }) === 0) {
|
|
86
|
+
console.log(c.green("ok") + " no accepted ADR cites the touched capabilities");
|
|
87
|
+
}
|
|
88
|
+
return 0;
|
|
89
|
+
},
|
|
90
|
+
},
|
|
42
91
|
{ label: "apply", rerun: `doctrina change apply ${id}`, run: () => change.run(["apply", id], new Map()) },
|
|
43
92
|
verifyConfigured
|
|
44
93
|
? { label: "verify", rerun: "doctrina verify", run: () => verify.run([], new Map()) }
|
|
45
94
|
: { label: "verify", skip: "no .doctrina/verify.json — declare the real gate with `doctrina verify --init`" },
|
|
46
|
-
{
|
|
95
|
+
{
|
|
96
|
+
label: touched.length > 0 ? `coverage (scoped: ${touched.join(", ")})` : "coverage",
|
|
97
|
+
rerun: coverageRerun,
|
|
98
|
+
run: () => coverage.run([], coverageFlags),
|
|
99
|
+
},
|
|
47
100
|
// trace is advisory (provenance is a warning, not a hard gate): report it,
|
|
48
101
|
// never let it block the close.
|
|
49
102
|
{ label: "trace", rerun: "doctrina trace", run: async () => { await trace.run([], new Map()); return 0; } },
|
|
@@ -51,7 +104,7 @@ export async function run(positional, flags) {
|
|
|
51
104
|
{ label: "validate", rerun: "doctrina validate", run: () => validate.run([], new Map()) },
|
|
52
105
|
];
|
|
53
106
|
|
|
54
|
-
console.log(c.bold(`Closing change ${id}`) + c.gray(" — analyze → apply → verify → coverage → trace → archive → validate"));
|
|
107
|
+
console.log(c.bold(`Closing change ${id}`) + c.gray(" — analyze → ADR checkpoint → apply → verify → coverage → trace → archive → validate"));
|
|
55
108
|
|
|
56
109
|
for (let i = 0; i < steps.length; i++) {
|
|
57
110
|
const step = steps[i];
|
|
@@ -78,25 +131,58 @@ export async function run(positional, flags) {
|
|
|
78
131
|
}
|
|
79
132
|
}
|
|
80
133
|
|
|
134
|
+
// Capture the lesson while it is fresh (operator review §4.7): surface the
|
|
135
|
+
// fix-shaped lessons not yet written as skills, right where the operator
|
|
136
|
+
// just proved they finish what they start. Advisory — a listing, never a
|
|
137
|
+
// gate, and a suggest failure never un-closes the change.
|
|
138
|
+
console.log("");
|
|
139
|
+
console.log(c.gray("──── skills (advisory)"));
|
|
140
|
+
try {
|
|
141
|
+
await skill.run(["suggest"], new Map());
|
|
142
|
+
} catch { /* advisory only */ }
|
|
143
|
+
|
|
81
144
|
console.log("");
|
|
82
145
|
console.log(c.green(`✓ change ${id} closed`) + c.gray(" — verified, archived, and validated."));
|
|
83
146
|
console.log(c.gray("Next: ") + c.cyan("doctrina next"));
|
|
84
147
|
return 0;
|
|
85
148
|
}
|
|
86
149
|
|
|
150
|
+
// The capabilities this change's deltas target — the honest scope for its
|
|
151
|
+
// coverage gate. Read from the change folder's specs/**/delta.md files.
|
|
152
|
+
function touchedCapabilities(projectRoot, id) {
|
|
153
|
+
const specsDir = path.join(projectRoot, ".doctrina", "changes", id, "specs");
|
|
154
|
+
const caps = new Set();
|
|
155
|
+
for (const deltaPath of walk(specsDir)) {
|
|
156
|
+
if (!deltaPath.endsWith("delta.md")) continue;
|
|
157
|
+
const cap = parseCapabilityFromDelta(read(deltaPath), deltaPath);
|
|
158
|
+
if (cap) caps.add(cap);
|
|
159
|
+
}
|
|
160
|
+
return [...caps].sort();
|
|
161
|
+
}
|
|
162
|
+
|
|
87
163
|
export const help = `
|
|
88
|
-
Usage: doctrina close <id
|
|
164
|
+
Usage: doctrina close <id...> [--force]
|
|
89
165
|
|
|
90
166
|
Run the whole closing sequence for a change in one pass, stopping at the
|
|
91
167
|
first failure with the exact command to rerun:
|
|
92
168
|
|
|
93
|
-
analyze →
|
|
94
|
-
|
|
169
|
+
analyze → ADR checkpoint (advisory) → change apply → verify → coverage
|
|
170
|
+
--strict (scoped to the change's touched capabilities) → trace → change
|
|
171
|
+
archive → validate → skill suggest (advisory)
|
|
172
|
+
|
|
173
|
+
The coverage gate is scoped to the capabilities the change's deltas touch,
|
|
174
|
+
so a deliberately deferred spec elsewhere cannot block an unrelated close;
|
|
175
|
+
a change with no deltas gates on the whole tree. verify is skipped (with a
|
|
176
|
+
note) when no .doctrina/verify.json is declared; trace, the ADR checkpoint
|
|
177
|
+
(accepted ADRs citing the touched capabilities — amend via \`decision
|
|
178
|
+
supersede\`, not silence), and the closing skill-suggest listing are
|
|
179
|
+
advisory (reports, never blockers). This is a driver over the existing
|
|
180
|
+
commands so the agent makes one call instead of nine and the human
|
|
181
|
+
approves once.
|
|
95
182
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
call instead of seven and the human approves once.
|
|
183
|
+
Multiple ids close in sequence, each independently; the exit code is the
|
|
184
|
+
worst per-id result. Preview what close would refuse: \`doctrina change
|
|
185
|
+
check <id>\`.
|
|
100
186
|
|
|
101
187
|
Options:
|
|
102
188
|
--force Pass through to \`change archive\` (archive even if verification
|
package/src/commands/context.js
CHANGED
|
@@ -3,7 +3,7 @@ import process from "node:process";
|
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
|
|
6
|
-
import { listHeader } from "../lib/scan.js";
|
|
6
|
+
import { listHeader, parseDependsOn } from "../lib/scan.js";
|
|
7
7
|
import { parseFrontmatter } from "./skill.js";
|
|
8
8
|
import { flagBool, flagString } from "../lib/args.js";
|
|
9
9
|
import { c } from "../lib/colors.js";
|
|
@@ -80,6 +80,15 @@ export async function run(positional, flags) {
|
|
|
80
80
|
const specRel = `.doctrina/specs/${capability}/spec.md`;
|
|
81
81
|
if (isFile(path.join(projectRoot, specRel))) {
|
|
82
82
|
pushFile(specRel, `spec: ${capability}`);
|
|
83
|
+
// Pull the declared dependencies into the pack (the machine-readable
|
|
84
|
+
// **Depends on:** header): a task on `risk-map` that builds on
|
|
85
|
+
// `reporting` needs both truths in one read. One level deep,
|
|
86
|
+
// deterministic, no cycles (each spec is pushed at most once).
|
|
87
|
+
for (const dep of parseDependsOn(read(path.join(projectRoot, specRel)))) {
|
|
88
|
+
if (dep === capability) continue;
|
|
89
|
+
const depRel = `.doctrina/specs/${dep}/spec.md`;
|
|
90
|
+
if (isFile(path.join(projectRoot, depRel))) pushFile(depRel, `dependency of ${capability}`);
|
|
91
|
+
}
|
|
83
92
|
} else {
|
|
84
93
|
specMissing = true;
|
|
85
94
|
}
|
package/src/commands/coverage.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
4
5
|
import { exists, isDir, isFile, read, relPath } from "../lib/fs-ops.js";
|
|
5
|
-
import {
|
|
6
|
+
import { specHeader } from "../lib/scan.js";
|
|
7
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
6
8
|
import { c } from "../lib/colors.js";
|
|
7
9
|
|
|
8
10
|
// Traceability report: how many acceptance criteria cite an artifact or
|
|
@@ -28,7 +30,21 @@ export async function run(_positional, flags) {
|
|
|
28
30
|
const strict = flagBool(flags, "strict", false);
|
|
29
31
|
const json = flagBool(flags, "json", false);
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
// --only <cap,cap>: scope the report/gate to specific capabilities — the
|
|
34
|
+
// hook `doctrina close` uses so one deliberately deferred spec elsewhere in
|
|
35
|
+
// the tree cannot block closing a change that never touched it.
|
|
36
|
+
const onlyRaw = flagString(flags, "only");
|
|
37
|
+
const only = onlyRaw ? new Set(onlyRaw.split(",").map((s) => s.trim()).filter(Boolean)) : null;
|
|
38
|
+
|
|
39
|
+
const reports = collect(projectRoot, { only });
|
|
40
|
+
|
|
41
|
+
// --run: execute the cited evidence instead of only checking it exists —
|
|
42
|
+
// promotes "the file is on disk" to "the proof passes". The runner is
|
|
43
|
+
// project-declared in .doctrina/verify.json ("evidence_runner": a command
|
|
44
|
+
// template with a {file} placeholder); the CLI never guesses a test runner.
|
|
45
|
+
if (flagBool(flags, "run", false)) {
|
|
46
|
+
return runEvidence(projectRoot, reports, strict);
|
|
47
|
+
}
|
|
32
48
|
|
|
33
49
|
if (reports.length === 0) {
|
|
34
50
|
if (json) {
|
|
@@ -43,19 +59,22 @@ export async function run(_positional, flags) {
|
|
|
43
59
|
let totalCovered = 0;
|
|
44
60
|
let totalDangling = 0;
|
|
45
61
|
let totalConditional = 0;
|
|
62
|
+
let totalDeferred = 0;
|
|
46
63
|
for (const rep of reports) {
|
|
47
64
|
totalCriteria += rep.rows.length;
|
|
48
65
|
totalCovered += rep.rows.filter((r) => r.kind === "covered").length;
|
|
49
66
|
totalDangling += rep.rows.filter((r) => r.kind === "dangling").length;
|
|
50
67
|
totalConditional += rep.rows.filter((r) => r.kind === "conditional").length;
|
|
68
|
+
totalDeferred += rep.rows.filter((r) => r.kind === "deferred").length;
|
|
51
69
|
}
|
|
52
70
|
const jsonPct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
|
|
53
|
-
|
|
71
|
+
// Deferred criteria are visible but never gate: declared debt ≠ hidden debt.
|
|
72
|
+
const jsonClean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0 && totalConditional === 0;
|
|
54
73
|
|
|
55
74
|
if (json) {
|
|
56
75
|
console.log(JSON.stringify({
|
|
57
|
-
specs: reports.map((rep) => ({ capability: rep.cap, criteria: rep.rows })),
|
|
58
|
-
summary: { criteria: totalCriteria, covered: totalCovered, dangling: totalDangling, conditional: totalConditional, pct: jsonPct },
|
|
76
|
+
specs: reports.map((rep) => ({ capability: rep.cap, deferred: rep.deferred, criteria: rep.rows })),
|
|
77
|
+
summary: { criteria: totalCriteria, covered: totalCovered, dangling: totalDangling, conditional: totalConditional, deferred: totalDeferred, pct: jsonPct },
|
|
59
78
|
}, null, 2));
|
|
60
79
|
return jsonClean ? 0 : strict ? 1 : 0;
|
|
61
80
|
}
|
|
@@ -67,15 +86,19 @@ export async function run(_positional, flags) {
|
|
|
67
86
|
const covered = rep.rows.filter((r) => r.kind === "covered").length;
|
|
68
87
|
const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
|
|
69
88
|
const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
|
|
89
|
+
const deferredN = rep.rows.filter((r) => r.kind === "deferred").length;
|
|
70
90
|
|
|
71
91
|
const notes = [];
|
|
72
92
|
if (conditional > 0) notes.push(c.yellow(`${conditional} conditional`));
|
|
73
93
|
if (dangling > 0) notes.push(c.yellow(`${dangling} dangling`));
|
|
94
|
+
if (deferredN > 0) notes.push(c.gray(`${deferredN} deferred`));
|
|
74
95
|
const note = notes.length > 0 ? ` (${notes.join(", ")})` : "";
|
|
75
96
|
console.log(` ${c.cyan(rep.cap.padEnd(20))} ${covered}/${rep.rows.length} criteria${note}`);
|
|
76
97
|
for (const r of rep.rows) {
|
|
77
98
|
if (r.kind === "covered") continue;
|
|
78
|
-
if (r.kind === "
|
|
99
|
+
if (r.kind === "deferred") {
|
|
100
|
+
console.log(` ${c.gray("○")} #${r.n} deferred — spec declares "Implementation: planned — <why>" (visible, not gated)`);
|
|
101
|
+
} else if (r.kind === "bare") {
|
|
79
102
|
console.log(` ${c.red("✗")} #${r.n} no evidence linked — cite the file/test that proves it in backticks`);
|
|
80
103
|
} else if (r.kind === "conditional") {
|
|
81
104
|
console.log(` ${c.yellow("!")} #${r.n} evidence is a skipped test (proves nothing): ${r.skipped.map((m) => `\`${m}\``).join(", ")}`);
|
|
@@ -88,64 +111,141 @@ export async function run(_positional, flags) {
|
|
|
88
111
|
const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
|
|
89
112
|
console.log("");
|
|
90
113
|
const summary = `${totalCovered} of ${totalCriteria} acceptance criteria across ${reports.length} spec${reports.length === 1 ? "" : "s"} have linked evidence (${pct}%)`;
|
|
91
|
-
const clean = totalCovered === totalCriteria && totalDangling === 0 && totalConditional === 0;
|
|
92
|
-
if (clean) {
|
|
93
|
-
console.log(c.green("ok") + " " + summary);
|
|
94
|
-
return 0;
|
|
95
|
-
}
|
|
114
|
+
const clean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0 && totalConditional === 0;
|
|
96
115
|
const extras = [];
|
|
97
116
|
if (totalConditional > 0) extras.push(`${totalConditional} conditional`);
|
|
98
117
|
if (totalDangling > 0) extras.push(`${totalDangling} dangling`);
|
|
118
|
+
if (totalDeferred > 0) extras.push(`${totalDeferred} deferred (not gated)`);
|
|
99
119
|
const extraSummary = extras.length > 0 ? `, ${extras.join(", ")}` : "";
|
|
120
|
+
if (clean) {
|
|
121
|
+
console.log(c.green("ok") + " " + summary + extraSummary);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
100
124
|
console.log((strict ? c.red("fail") : c.yellow("gap")) + " " + summary + extraSummary);
|
|
101
125
|
// A report by default (exit 0); a gate under --strict (exit 1 for CI). A
|
|
102
126
|
// conditional criterion fails the gate too: a skipped test is not proof.
|
|
127
|
+
// Deferred criteria never fail: the deferral is declared in the spec.
|
|
103
128
|
return strict ? 1 : 0;
|
|
104
129
|
}
|
|
105
130
|
|
|
131
|
+
// --run: execute every unique cited-and-resolving evidence file through the
|
|
132
|
+
// project-declared runner. Declared in .doctrina/verify.json as
|
|
133
|
+
// "evidence_runner": "python -m pytest {file}"
|
|
134
|
+
// ({file} is replaced per file). Exit 1 when any run fails (or under --strict
|
|
135
|
+
// when nothing is runnable). Deferred specs are skipped like everywhere else.
|
|
136
|
+
function runEvidence(projectRoot, reports, strict) {
|
|
137
|
+
const cfgPath = path.join(projectRoot, ".doctrina", "verify.json");
|
|
138
|
+
let runner = null;
|
|
139
|
+
if (isFile(cfgPath)) {
|
|
140
|
+
try { runner = JSON.parse(read(cfgPath))?.evidence_runner ?? null; } catch { runner = null; }
|
|
141
|
+
}
|
|
142
|
+
if (!runner || typeof runner !== "string" || !runner.includes("{file}")) {
|
|
143
|
+
console.error(c.red("error:") + " coverage --run needs an \"evidence_runner\" in .doctrina/verify.json");
|
|
144
|
+
console.error(c.gray("hint: ") + `add e.g. "evidence_runner": "python -m pytest {file}" (the {file} placeholder is required — the CLI never guesses a test runner)`);
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Unique evidence files: cited by a non-deferred criterion, resolving on
|
|
149
|
+
// disk, and test-shaped (running a cited source artifact proves nothing).
|
|
150
|
+
const files = new Set();
|
|
151
|
+
for (const rep of reports) {
|
|
152
|
+
for (const r of rep.rows) {
|
|
153
|
+
if (rep.deferred || r.kind === "deferred") continue;
|
|
154
|
+
for (const token of r.evidence ?? []) files.add(token);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (files.size === 0) {
|
|
159
|
+
console.log(c.gray("no runnable evidence found (no non-deferred criterion cites a resolving test file)"));
|
|
160
|
+
return strict ? 1 : 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log(c.bold("coverage --run") + c.gray(` — executing ${files.size} evidence file${files.size === 1 ? "" : "s"} via: ${runner}`));
|
|
164
|
+
let failed = 0;
|
|
165
|
+
for (const file of [...files].sort()) {
|
|
166
|
+
const cmd = runner.replaceAll("{file}", file);
|
|
167
|
+
console.log("");
|
|
168
|
+
console.log(c.gray(`──── ${cmd}`));
|
|
169
|
+
const res = spawnSync(cmd, { cwd: projectRoot, shell: true, stdio: "inherit" });
|
|
170
|
+
const ok = !res.error && res.status === 0;
|
|
171
|
+
console.log(ok ? c.green(`✓ ${file}`) : c.red(`✗ ${file}${res.error ? ` — ${res.error.message}` : ` (exit ${res.status})`}`));
|
|
172
|
+
if (!ok) failed += 1;
|
|
173
|
+
}
|
|
174
|
+
console.log("");
|
|
175
|
+
if (failed === 0) {
|
|
176
|
+
console.log(c.green("ok") + ` ${files.size}/${files.size} evidence runs passed — cited proof actually proves`);
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
console.log(c.red("fail") + ` ${files.size - failed}/${files.size} evidence runs passed — a cited proof does not pass`);
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
|
|
106
183
|
// Per-spec criterion rows — the full classification behind both the report
|
|
107
184
|
// and the --json output. Each row: { n, kind, missing?, skipped? }.
|
|
108
|
-
|
|
185
|
+
//
|
|
186
|
+
// A spec that declares a deliberate deferral — `Implementation: planned —
|
|
187
|
+
// <why>`, the exact escape hatch `validate` already honours — has its
|
|
188
|
+
// non-covered criteria remapped to kind "deferred": visible in every report,
|
|
189
|
+
// never a --strict failure. Declared debt and hidden debt stop being punished
|
|
190
|
+
// identically (0.11.0 field review item 4: one deferred capability poisoned
|
|
191
|
+
// the close of every unrelated change).
|
|
192
|
+
export function collect(projectRoot, { only = null } = {}) {
|
|
109
193
|
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
110
194
|
const reports = [];
|
|
111
195
|
if (isDir(specsDir)) {
|
|
112
196
|
for (const cap of readdirSync(specsDir).sort()) {
|
|
197
|
+
if (only && !only.has(cap)) continue;
|
|
113
198
|
const specPath = path.join(specsDir, cap, "spec.md");
|
|
114
199
|
if (!isFile(specPath)) continue;
|
|
115
|
-
const
|
|
200
|
+
const text = read(specPath);
|
|
201
|
+
const criteria = extractAcceptanceCriteria(text);
|
|
116
202
|
if (criteria.length === 0) continue;
|
|
117
|
-
const
|
|
118
|
-
|
|
203
|
+
const deferred = isDeclaredDeferral(text);
|
|
204
|
+
const rows = criteria.map((crit, i) => {
|
|
205
|
+
const row = classify(crit, i + 1, projectRoot, path.dirname(specPath));
|
|
206
|
+
if (deferred && row.kind !== "covered") {
|
|
207
|
+
return { ...row, kind: "deferred", was: row.kind };
|
|
208
|
+
}
|
|
209
|
+
return row;
|
|
210
|
+
});
|
|
211
|
+
reports.push({ cap, specPath, rows, deferred });
|
|
119
212
|
}
|
|
120
213
|
}
|
|
121
214
|
return reports;
|
|
122
215
|
}
|
|
123
216
|
|
|
217
|
+
// The declared-deferral escape hatch, matching validate's two-axis check:
|
|
218
|
+
// Implementation is "planned" WITH an explanatory note after the state word.
|
|
219
|
+
// A bare "planned" is an inventory claim, not a deferral, and gets no pass.
|
|
220
|
+
function isDeclaredDeferral(specText) {
|
|
221
|
+
const implRaw = specHeader(specText, "Implementation");
|
|
222
|
+
if (!implRaw) return false;
|
|
223
|
+
const tokens = implRaw.trim().split(/\s+/);
|
|
224
|
+
const word = (tokens[0] ?? "").replace(/[—-]+$/, "").toLowerCase();
|
|
225
|
+
return word === "planned" && tokens.length > 1;
|
|
226
|
+
}
|
|
227
|
+
|
|
124
228
|
// Pure summary of coverage across the spec tree, for other commands
|
|
125
229
|
// (`status`, `review`) that need the numbers without the report output.
|
|
230
|
+
// Deferred criteria (declared deferral, see collect) are counted separately
|
|
231
|
+
// and excluded from the problem counts, matching the gate semantics.
|
|
126
232
|
export function summarize(projectRoot) {
|
|
127
|
-
|
|
128
|
-
let totalCriteria = 0, totalCovered = 0, totalDangling = 0, totalConditional = 0;
|
|
233
|
+
let totalCriteria = 0, totalCovered = 0, totalDangling = 0, totalConditional = 0, totalDeferred = 0;
|
|
129
234
|
const perCap = [];
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
totalCovered += covered;
|
|
142
|
-
totalDangling += dangling;
|
|
143
|
-
totalConditional += conditional;
|
|
144
|
-
perCap.push({ cap, total: rows.length, covered, dangling, conditional });
|
|
145
|
-
}
|
|
235
|
+
for (const rep of collect(projectRoot)) {
|
|
236
|
+
const covered = rep.rows.filter((r) => r.kind === "covered").length;
|
|
237
|
+
const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
|
|
238
|
+
const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
|
|
239
|
+
const deferred = rep.rows.filter((r) => r.kind === "deferred").length;
|
|
240
|
+
totalCriteria += rep.rows.length;
|
|
241
|
+
totalCovered += covered;
|
|
242
|
+
totalDangling += dangling;
|
|
243
|
+
totalConditional += conditional;
|
|
244
|
+
totalDeferred += deferred;
|
|
245
|
+
perCap.push({ cap: rep.cap, total: rep.rows.length, covered, dangling, conditional, deferred });
|
|
146
246
|
}
|
|
147
247
|
const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
|
|
148
|
-
return { perCap, totalCriteria, totalCovered, totalDangling, totalConditional, pct };
|
|
248
|
+
return { perCap, totalCriteria, totalCovered, totalDangling, totalConditional, totalDeferred, pct };
|
|
149
249
|
}
|
|
150
250
|
|
|
151
251
|
// Pull the numbered items out of the "## Acceptance criteria" section.
|
|
@@ -206,11 +306,14 @@ function classify(criterion, n, projectRoot, specDir) {
|
|
|
206
306
|
resolved.push({ token, isTest, skipped });
|
|
207
307
|
}
|
|
208
308
|
if (resolved.length === 0) return { kind: "dangling", n, missing };
|
|
309
|
+
// Runnable evidence: the resolving test-shaped citations, recorded so
|
|
310
|
+
// `coverage --run` can execute them (existence → passing proof).
|
|
311
|
+
const evidence = resolved.filter((r) => r.isTest && !r.skipped).map((r) => r.token);
|
|
209
312
|
// Real proof = a non-test artifact, or a test file whose suite runs. If the
|
|
210
313
|
// only thing that resolves is a skipped test, the criterion is conditional.
|
|
211
314
|
const hasRealProof = resolved.some((r) => !r.isTest || !r.skipped);
|
|
212
|
-
if (hasRealProof) return { kind: "covered", n };
|
|
213
|
-
return { kind: "conditional", n, skipped: resolved.map((r) => r.token) };
|
|
315
|
+
if (hasRealProof) return { kind: "covered", n, evidence };
|
|
316
|
+
return { kind: "conditional", n, skipped: resolved.map((r) => r.token), evidence: [] };
|
|
214
317
|
}
|
|
215
318
|
|
|
216
319
|
// A cited path is a test file when it sits under a tests directory or carries
|
|
@@ -265,7 +368,7 @@ function looksLikePath(s) {
|
|
|
265
368
|
}
|
|
266
369
|
|
|
267
370
|
export const help = `
|
|
268
|
-
Usage: doctrina coverage [--strict]
|
|
371
|
+
Usage: doctrina coverage [--strict] [--only <cap,cap>] [--run] [--json]
|
|
269
372
|
|
|
270
373
|
Report how many acceptance criteria across .doctrina/specs/ cite an
|
|
271
374
|
artifact or test that exists on disk. Each numbered criterion may cite
|
|
@@ -275,10 +378,23 @@ A criterion is covered when a cited path resolves to real proof,
|
|
|
275
378
|
conditional when its only resolving proof is a test file whose suite is
|
|
276
379
|
skipped (\`describe.skip\` / \`xit\` / \`@pytest.mark.skip\` — proves
|
|
277
380
|
nothing), dangling when a cited path is missing on disk, and bare when
|
|
278
|
-
nothing is cited.
|
|
381
|
+
nothing is cited. A spec that declares a deliberate deferral —
|
|
382
|
+
\`Implementation: planned — <why>\`, the same escape hatch validate
|
|
383
|
+
honours — has its unproven criteria reported as DEFERRED: visible, but
|
|
384
|
+
never a --strict failure (declared debt is not hidden debt).
|
|
279
385
|
|
|
280
386
|
Flags:
|
|
281
|
-
--strict
|
|
282
|
-
|
|
283
|
-
|
|
387
|
+
--strict Exit 1 when any criterion is bare, dangling, or
|
|
388
|
+
conditional (CI gate). Deferred never fails. Without
|
|
389
|
+
it the command always exits 0 (a report).
|
|
390
|
+
--only <cap,cap> Scope the report/gate to specific capabilities
|
|
391
|
+
(\`doctrina close\` uses this so an unrelated deferred
|
|
392
|
+
spec cannot block a change's close).
|
|
393
|
+
--run Execute the cited evidence via the project-declared
|
|
394
|
+
"evidence_runner" in .doctrina/verify.json (a command
|
|
395
|
+
template with a {file} placeholder, e.g.
|
|
396
|
+
"python -m pytest {file}"). Exits 1 when any run
|
|
397
|
+
fails — promotes "the file exists" to "the proof
|
|
398
|
+
passes". The CLI never guesses a test runner.
|
|
399
|
+
--json Emit per-spec criterion rows + summary as JSON.
|
|
284
400
|
`;
|
package/src/commands/init.js
CHANGED
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
4
4
|
import { locateTemplatesDir, loadTemplateTree, materialiseEntry, substitute } from "../lib/templates.js";
|
|
5
|
+
import { surfaceBlock, findSurfaceBlock } from "../lib/commands.js";
|
|
5
6
|
import { today } from "../lib/dates.js";
|
|
6
7
|
import { cliVersion } from "../lib/version.js";
|
|
7
8
|
import { flagBool, flagString } from "../lib/args.js";
|
|
@@ -108,10 +109,13 @@ export async function run(_positional, flags) {
|
|
|
108
109
|
FRAMEWORK_VERSION: cliVersion(),
|
|
109
110
|
};
|
|
110
111
|
|
|
111
|
-
// Root AGENTS.md — with optional --from base content prepended.
|
|
112
|
+
// Root AGENTS.md — with optional --from base content prepended. The
|
|
113
|
+
// doctrina:surface block is regenerated from the installed catalog at init
|
|
114
|
+
// time, so a freshly scaffolded hub can never document a stale surface even
|
|
115
|
+
// if the static template lags a release (ADR 0015).
|
|
112
116
|
const agentsTemplatePath = path.join(templatesDir, "AGENTS.md.template");
|
|
113
117
|
const agentsTemplateBody = read(agentsTemplatePath);
|
|
114
|
-
const projectAgents = substitute(agentsTemplateBody, tokens);
|
|
118
|
+
const projectAgents = refreshSurfaceBlock(substitute(agentsTemplateBody, tokens));
|
|
115
119
|
const finalAgents = fromAgentsContent
|
|
116
120
|
? fromAgentsContent.replace(/\n+$/, "") + "\n\n---\n\n## Project-specific\n\n" + projectAgents
|
|
117
121
|
: projectAgents;
|
|
@@ -178,6 +182,15 @@ export async function run(_positional, flags) {
|
|
|
178
182
|
return 0;
|
|
179
183
|
}
|
|
180
184
|
|
|
185
|
+
// Replace the template's doctrina:surface span with the one generated from
|
|
186
|
+
// the running CLI's catalog; pass the text through untouched when the
|
|
187
|
+
// template carries no markers (older custom templates).
|
|
188
|
+
function refreshSurfaceBlock(text) {
|
|
189
|
+
const block = findSurfaceBlock(text);
|
|
190
|
+
if (!block) return text;
|
|
191
|
+
return text.slice(0, block.start) + surfaceBlock() + text.slice(block.end);
|
|
192
|
+
}
|
|
193
|
+
|
|
181
194
|
// First non-empty line of the intake, stripped of Markdown heading marks
|
|
182
195
|
// and clipped, as the one-line description token for the templates.
|
|
183
196
|
function firstLineSummary(text) {
|