doctrina-cli 0.10.0 → 0.12.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 +1 -1
- package/package.json +1 -1
- package/src/commands/analyze.js +8 -2
- package/src/commands/change.js +78 -7
- package/src/commands/clarify.js +76 -4
- package/src/commands/close.js +42 -9
- package/src/commands/completion.js +116 -0
- package/src/commands/constitution.js +6 -4
- package/src/commands/context.js +118 -25
- package/src/commands/coverage.js +185 -44
- package/src/commands/doctor.js +135 -0
- package/src/commands/handoff.js +94 -0
- package/src/commands/init.js +12 -1
- package/src/commands/intent.js +173 -0
- package/src/commands/next.js +32 -16
- package/src/commands/prime.js +118 -0
- package/src/commands/report.js +142 -0
- package/src/commands/review.js +25 -4
- package/src/commands/show.js +173 -0
- package/src/commands/skill.js +46 -7
- package/src/commands/spec.js +13 -3
- package/src/commands/status.js +70 -40
- package/src/commands/trace.js +15 -2
- package/src/commands/upgrade.js +129 -0
- package/src/commands/validate.js +150 -3
- package/src/commands/why.js +83 -10
- package/src/commands/work.js +45 -16
- package/src/index.js +20 -42
- package/src/lib/commands.js +76 -4
- package/src/lib/scan.js +16 -0
- package/templates/AGENTS.md.template +8 -4
- package/templates/README.md +7 -1
- package/templates/adapters/aider/CONVENTIONS.md.template +4 -3
- package/templates/spec.md.template +9 -1
package/src/commands/context.js
CHANGED
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
|
|
6
|
+
import { listHeader, parseDependsOn } from "../lib/scan.js";
|
|
6
7
|
import { parseFrontmatter } from "./skill.js";
|
|
7
|
-
import { flagBool } from "../lib/args.js";
|
|
8
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
9
10
|
|
|
10
11
|
// Materialise the AGENTS.md read order as a command: print the exact
|
|
11
12
|
// context pack for a task, in the order an agent should read it. This is
|
|
12
13
|
// the context-engineering thesis turned into tooling — selection over
|
|
13
14
|
// dumping, ranked by the documented read order.
|
|
15
|
+
//
|
|
16
|
+
// The pack now carries a token estimate per file (chars/4 — the standard
|
|
17
|
+
// rough heuristic; the thesis the product is built on is that token spend
|
|
18
|
+
// predicts performance, so the pack must be measurable in tokens, not only
|
|
19
|
+
// lines). `--budget <n>` turns the estimate into a gate, and `--diff <ref>`
|
|
20
|
+
// scopes the pack to what changed since a git ref — the resume-session read.
|
|
21
|
+
|
|
22
|
+
const TOKEN_DIVISOR = 4;
|
|
14
23
|
|
|
15
24
|
export async function run(positional, flags) {
|
|
16
25
|
const capability = positional[0] ?? null;
|
|
17
26
|
const concat = flagBool(flags, "concat", false);
|
|
27
|
+
const budgetRaw = flagString(flags, "budget");
|
|
28
|
+
const diffRef = flagString(flags, "diff");
|
|
18
29
|
const projectRoot = process.cwd();
|
|
19
30
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
20
31
|
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
@@ -25,22 +36,43 @@ export async function run(positional, flags) {
|
|
|
25
36
|
return 2;
|
|
26
37
|
}
|
|
27
38
|
|
|
39
|
+
let budget = null;
|
|
40
|
+
if (budgetRaw !== undefined) {
|
|
41
|
+
budget = Number.parseInt(budgetRaw, 10);
|
|
42
|
+
if (!Number.isFinite(budget) || budget <= 0) {
|
|
43
|
+
console.error(c.red("error:") + ` --budget expects a positive token count, got "${budgetRaw}"`);
|
|
44
|
+
return 2;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --diff <ref>: restrict the stable artifacts to those changed since the
|
|
49
|
+
// ref (a resuming session already carries the rest); open changes are
|
|
50
|
+
// always in — they ARE the work being resumed.
|
|
51
|
+
let changed = null;
|
|
52
|
+
if (diffRef !== undefined) {
|
|
53
|
+
changed = changedPaths(projectRoot, diffRef);
|
|
54
|
+
if (changed === null) return 1; // changedPaths printed the error
|
|
55
|
+
}
|
|
56
|
+
// git emits POSIX paths; relPath emits native separators on Windows.
|
|
57
|
+
const wanted = (rel) => changed === null || changed.has(rel.replaceAll("\\", "/"));
|
|
58
|
+
|
|
28
59
|
const pack = [];
|
|
29
60
|
const pushFile = (rel, note) => {
|
|
30
61
|
const full = path.join(projectRoot, rel);
|
|
31
|
-
if (isFile(full))
|
|
62
|
+
if (!isFile(full)) return;
|
|
63
|
+
const text = read(full);
|
|
64
|
+
pack.push({ rel, lines: text.split(/\r?\n/).length, tokens: Math.round(text.length / TOKEN_DIVISOR), note });
|
|
32
65
|
};
|
|
33
66
|
|
|
34
67
|
// 1-2. Root rules and product truth — always first. Displayed paths are
|
|
35
68
|
// POSIX strings (path.join would emit backslashes on Windows).
|
|
36
|
-
pushFile("AGENTS.md", "root rules");
|
|
37
|
-
pushFile(".doctrina/product.md", "product truth");
|
|
69
|
+
if (wanted("AGENTS.md")) pushFile("AGENTS.md", "root rules");
|
|
70
|
+
if (wanted(".doctrina/product.md")) pushFile(".doctrina/product.md", "product truth");
|
|
38
71
|
|
|
39
72
|
// 3. The capability spec(s) — the current truth. With a named capability,
|
|
40
73
|
// just that one. Without one, EVERY active spec: the default orientation
|
|
41
74
|
// read previously jumped from product.md straight to the ADRs, leaving
|
|
42
|
-
// the single source of truth out of the pack entirely
|
|
43
|
-
// question read got history but not current truth). Specs come before
|
|
75
|
+
// the single source of truth out of the pack entirely. Specs come before
|
|
44
76
|
// open changes and ADRs, matching the documented read order.
|
|
45
77
|
let specMissing = false;
|
|
46
78
|
const specsRoot = path.join(projectRoot, ".doctrina", "specs");
|
|
@@ -48,17 +80,26 @@ export async function run(positional, flags) {
|
|
|
48
80
|
const specRel = `.doctrina/specs/${capability}/spec.md`;
|
|
49
81
|
if (isFile(path.join(projectRoot, specRel))) {
|
|
50
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
|
+
}
|
|
51
92
|
} else {
|
|
52
93
|
specMissing = true;
|
|
53
94
|
}
|
|
54
95
|
} else if (isDir(specsRoot)) {
|
|
55
96
|
for (const cap of readdirSync(specsRoot).sort()) {
|
|
56
97
|
const specRel = `.doctrina/specs/${cap}/spec.md`;
|
|
57
|
-
if (isFile(path.join(projectRoot, specRel))) pushFile(specRel, `spec: ${cap}`);
|
|
98
|
+
if (wanted(specRel) && isFile(path.join(projectRoot, specRel))) pushFile(specRel, `spec: ${cap}`);
|
|
58
99
|
}
|
|
59
100
|
}
|
|
60
101
|
|
|
61
|
-
// 4. Open changes (their proposal, tasks, and deltas).
|
|
102
|
+
// 4. Open changes (their proposal, tasks, and deltas) — never diff-filtered.
|
|
62
103
|
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
63
104
|
if (isDir(changesDir)) {
|
|
64
105
|
for (const id of readdirSync(changesDir).sort()) {
|
|
@@ -75,11 +116,15 @@ export async function run(positional, flags) {
|
|
|
75
116
|
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
76
117
|
for (const f of walk(adrDir)) {
|
|
77
118
|
if (!f.endsWith(".md")) continue;
|
|
119
|
+
const rel = relPath(projectRoot, f);
|
|
120
|
+
if (!wanted(rel)) continue;
|
|
78
121
|
const status = (listHeader(read(f), "Status") ?? "").toLowerCase();
|
|
79
|
-
if (status === "accepted") pushFile(
|
|
122
|
+
if (status === "accepted") pushFile(rel, "accepted ADR");
|
|
80
123
|
}
|
|
81
124
|
|
|
82
|
-
// 6. Skills are on-demand: list name + description, never the
|
|
125
|
+
// 6. Skills are on-demand: list name + description + trigger, never the
|
|
126
|
+
// body. The `when:` line is what lets an agent fire the right skill
|
|
127
|
+
// without loading any of them.
|
|
83
128
|
const onDemand = [];
|
|
84
129
|
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
85
130
|
for (const f of walk(skillsDir)) {
|
|
@@ -89,9 +134,13 @@ export async function run(positional, flags) {
|
|
|
89
134
|
rel: relPath(projectRoot, f),
|
|
90
135
|
name: parseFrontmatter(text, "name") ?? path.basename(f, ".md"),
|
|
91
136
|
description: parseFrontmatter(text, "description") ?? "<missing description>",
|
|
137
|
+
when: parseFrontmatter(text, "when") ?? null,
|
|
92
138
|
});
|
|
93
139
|
}
|
|
94
140
|
|
|
141
|
+
const totalTokens = pack.reduce((sum, item) => sum + item.tokens, 0);
|
|
142
|
+
const totalLines = pack.reduce((sum, item) => sum + item.lines, 0);
|
|
143
|
+
|
|
95
144
|
if (concat) {
|
|
96
145
|
for (const item of pack) {
|
|
97
146
|
console.log(`===== ${item.rel} (${item.note}) =====`);
|
|
@@ -99,20 +148,26 @@ export async function run(positional, flags) {
|
|
|
99
148
|
console.log(read(path.join(projectRoot, item.rel)).trimEnd());
|
|
100
149
|
console.log("");
|
|
101
150
|
}
|
|
151
|
+
// Keep the pack pipeable: the budget verdict goes to stderr.
|
|
152
|
+
if (budget !== null) {
|
|
153
|
+
reportBudget(totalTokens, budget, (msg) => console.error(msg));
|
|
154
|
+
}
|
|
102
155
|
} else {
|
|
103
|
-
|
|
156
|
+
const scope = diffRef !== undefined ? c.gray(` (diff vs ${diffRef})`) : "";
|
|
157
|
+
console.log(c.bold("Context pack") + (capability ? c.gray(` (capability: ${capability})`) : "") + scope + c.gray(" — read in this order:"));
|
|
104
158
|
console.log("");
|
|
105
159
|
pack.forEach((item, i) => {
|
|
106
|
-
console.log(`${i + 1}. ${c.cyan(item.rel.padEnd(56))} ${String(item.lines).padStart(4)} lines ${c.gray(item.note)}`);
|
|
160
|
+
console.log(`${i + 1}. ${c.cyan(item.rel.padEnd(56))} ${String(item.lines).padStart(4)} lines ${c.gray(`~${String(item.tokens).padStart(5)} tok`)} ${c.gray(item.note)}`);
|
|
107
161
|
});
|
|
108
|
-
const total = pack.reduce((sum, item) => sum + item.lines, 0);
|
|
109
162
|
console.log("");
|
|
110
|
-
console.log(c.gray(`${pack.length} files, ${
|
|
163
|
+
console.log(c.gray(`${pack.length} files, ${totalLines} lines, ~${totalTokens} tokens total (chars/4) · archive excluded by design`));
|
|
164
|
+
if (budget !== null) reportBudget(totalTokens, budget, (msg) => console.log(msg));
|
|
111
165
|
if (onDemand.length > 0) {
|
|
112
166
|
console.log("");
|
|
113
|
-
console.log(c.bold("On-demand skills") + c.gray(" (load the body only when the
|
|
167
|
+
console.log(c.bold("On-demand skills") + c.gray(" (load the body only when the trigger fires):"));
|
|
114
168
|
for (const s of onDemand) {
|
|
115
|
-
console.log(` ${c.cyan(s.name.padEnd(
|
|
169
|
+
console.log(` ${c.cyan(s.name.padEnd(26))} ${s.description}`);
|
|
170
|
+
if (s.when) console.log(` ${" ".repeat(26)} ${c.gray(`when: ${s.when}`)}`);
|
|
116
171
|
}
|
|
117
172
|
}
|
|
118
173
|
}
|
|
@@ -121,21 +176,59 @@ export async function run(positional, flags) {
|
|
|
121
176
|
console.error("");
|
|
122
177
|
console.error(c.yellow("warn:") + ` no spec for capability "${capability}" — create one with \`doctrina spec new ${capability}\``);
|
|
123
178
|
}
|
|
179
|
+
if (budget !== null && totalTokens > budget) return 1;
|
|
124
180
|
return 0;
|
|
125
181
|
}
|
|
126
182
|
|
|
183
|
+
function reportBudget(totalTokens, budget, log) {
|
|
184
|
+
if (totalTokens > budget) {
|
|
185
|
+
log(c.red("over budget") + ` ~${totalTokens} tokens > ${budget} — narrow the pack: name a capability, use --diff <ref>, split oversized specs`);
|
|
186
|
+
} else {
|
|
187
|
+
log(c.green("within budget") + c.gray(` ~${totalTokens} of ${budget} tokens (${Math.round((totalTokens / budget) * 100)}%)`));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Repo-relative POSIX paths changed since <ref> (worktree vs ref, plus
|
|
192
|
+
// untracked files). Returns null (after printing the error) when git or the
|
|
193
|
+
// ref is unavailable — the caller exits rather than silently showing a full
|
|
194
|
+
// pack the user asked to narrow.
|
|
195
|
+
function changedPaths(projectRoot, ref) {
|
|
196
|
+
const run = (args) => spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
|
|
197
|
+
const diff = run(["diff", "--name-only", ref, "--"]);
|
|
198
|
+
if (diff.status !== 0) {
|
|
199
|
+
console.error(c.red("error:") + ` git diff against "${ref}" failed${diff.stderr ? `: ${diff.stderr.trim().split("\n")[0]}` : " (not a git repository?)"}`);
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
const untracked = run(["ls-files", "--others", "--exclude-standard"]);
|
|
203
|
+
const out = new Set();
|
|
204
|
+
for (const chunk of [diff.stdout, untracked.status === 0 ? untracked.stdout : ""]) {
|
|
205
|
+
for (const line of chunk.split(/\r?\n/)) {
|
|
206
|
+
const p = line.trim();
|
|
207
|
+
if (p) out.add(p);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
127
213
|
export const help = `
|
|
128
|
-
Usage: doctrina context [<capability>] [--concat]
|
|
214
|
+
Usage: doctrina context [<capability>] [--concat] [--budget <tokens>] [--diff <ref>]
|
|
129
215
|
|
|
130
216
|
Print the exact context pack for a task, in the documented read
|
|
131
217
|
order: AGENTS.md, product.md, the capability spec (when given —
|
|
132
|
-
otherwise every active spec,
|
|
133
|
-
|
|
134
|
-
|
|
218
|
+
otherwise every active spec), open changes, accepted ADRs. Skills are
|
|
219
|
+
listed name + description + when-trigger only — they are on-demand by
|
|
220
|
+
design. The change archive is excluded. Every file carries a token
|
|
221
|
+
estimate (chars/4) and the pack reports its total.
|
|
135
222
|
|
|
136
223
|
Flags:
|
|
137
|
-
--concat
|
|
138
|
-
|
|
224
|
+
--concat Print the file contents (with separators) instead of
|
|
225
|
+
the list — ready to pipe into an agent. The budget
|
|
226
|
+
verdict (if any) goes to stderr, keeping stdout pure.
|
|
227
|
+
--budget <n> Token budget for the pack: prints over/under and
|
|
228
|
+
exits 1 when the estimate exceeds it (a context gate).
|
|
229
|
+
--diff <ref> Scope the stable artifacts (AGENTS.md, product.md,
|
|
230
|
+
specs, ADRs) to those changed since the git ref; open
|
|
231
|
+
changes are always included. The resume-session read.
|
|
139
232
|
|
|
140
|
-
Read-only;
|
|
233
|
+
Read-only; exits 0 (or 1 when over --budget).
|
|
141
234
|
`;
|
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
|
|
@@ -26,21 +28,29 @@ export async function run(_positional, flags) {
|
|
|
26
28
|
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
27
29
|
}
|
|
28
30
|
const strict = flagBool(flags, "strict", false);
|
|
31
|
+
const json = flagBool(flags, "json", false);
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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);
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
if (reports.length === 0) {
|
|
50
|
+
if (json) {
|
|
51
|
+
console.log(JSON.stringify({ specs: [], summary: { criteria: 0, covered: 0, dangling: 0, conditional: 0, pct: 100 } }, null, 2));
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
44
54
|
console.log(c.gray("no acceptance criteria found under .doctrina/specs/"));
|
|
45
55
|
return 0;
|
|
46
56
|
}
|
|
@@ -49,6 +59,25 @@ export async function run(_positional, flags) {
|
|
|
49
59
|
let totalCovered = 0;
|
|
50
60
|
let totalDangling = 0;
|
|
51
61
|
let totalConditional = 0;
|
|
62
|
+
let totalDeferred = 0;
|
|
63
|
+
for (const rep of reports) {
|
|
64
|
+
totalCriteria += rep.rows.length;
|
|
65
|
+
totalCovered += rep.rows.filter((r) => r.kind === "covered").length;
|
|
66
|
+
totalDangling += rep.rows.filter((r) => r.kind === "dangling").length;
|
|
67
|
+
totalConditional += rep.rows.filter((r) => r.kind === "conditional").length;
|
|
68
|
+
totalDeferred += rep.rows.filter((r) => r.kind === "deferred").length;
|
|
69
|
+
}
|
|
70
|
+
const jsonPct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
|
|
71
|
+
// Deferred criteria are visible but never gate: declared debt ≠ hidden debt.
|
|
72
|
+
const jsonClean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0 && totalConditional === 0;
|
|
73
|
+
|
|
74
|
+
if (json) {
|
|
75
|
+
console.log(JSON.stringify({
|
|
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 },
|
|
78
|
+
}, null, 2));
|
|
79
|
+
return jsonClean ? 0 : strict ? 1 : 0;
|
|
80
|
+
}
|
|
52
81
|
|
|
53
82
|
console.log(c.bold("Coverage") + c.gray(" — acceptance criteria with linked evidence:"));
|
|
54
83
|
console.log("");
|
|
@@ -57,19 +86,19 @@ export async function run(_positional, flags) {
|
|
|
57
86
|
const covered = rep.rows.filter((r) => r.kind === "covered").length;
|
|
58
87
|
const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
|
|
59
88
|
const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
|
|
60
|
-
|
|
61
|
-
totalCovered += covered;
|
|
62
|
-
totalDangling += dangling;
|
|
63
|
-
totalConditional += conditional;
|
|
89
|
+
const deferredN = rep.rows.filter((r) => r.kind === "deferred").length;
|
|
64
90
|
|
|
65
91
|
const notes = [];
|
|
66
92
|
if (conditional > 0) notes.push(c.yellow(`${conditional} conditional`));
|
|
67
93
|
if (dangling > 0) notes.push(c.yellow(`${dangling} dangling`));
|
|
94
|
+
if (deferredN > 0) notes.push(c.gray(`${deferredN} deferred`));
|
|
68
95
|
const note = notes.length > 0 ? ` (${notes.join(", ")})` : "";
|
|
69
96
|
console.log(` ${c.cyan(rep.cap.padEnd(20))} ${covered}/${rep.rows.length} criteria${note}`);
|
|
70
97
|
for (const r of rep.rows) {
|
|
71
98
|
if (r.kind === "covered") continue;
|
|
72
|
-
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") {
|
|
73
102
|
console.log(` ${c.red("✗")} #${r.n} no evidence linked — cite the file/test that proves it in backticks`);
|
|
74
103
|
} else if (r.kind === "conditional") {
|
|
75
104
|
console.log(` ${c.yellow("!")} #${r.n} evidence is a skipped test (proves nothing): ${r.skipped.map((m) => `\`${m}\``).join(", ")}`);
|
|
@@ -82,46 +111,141 @@ export async function run(_positional, flags) {
|
|
|
82
111
|
const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
|
|
83
112
|
console.log("");
|
|
84
113
|
const summary = `${totalCovered} of ${totalCriteria} acceptance criteria across ${reports.length} spec${reports.length === 1 ? "" : "s"} have linked evidence (${pct}%)`;
|
|
85
|
-
const clean = totalCovered === totalCriteria && totalDangling === 0 && totalConditional === 0;
|
|
86
|
-
if (clean) {
|
|
87
|
-
console.log(c.green("ok") + " " + summary);
|
|
88
|
-
return 0;
|
|
89
|
-
}
|
|
114
|
+
const clean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0 && totalConditional === 0;
|
|
90
115
|
const extras = [];
|
|
91
116
|
if (totalConditional > 0) extras.push(`${totalConditional} conditional`);
|
|
92
117
|
if (totalDangling > 0) extras.push(`${totalDangling} dangling`);
|
|
118
|
+
if (totalDeferred > 0) extras.push(`${totalDeferred} deferred (not gated)`);
|
|
93
119
|
const extraSummary = extras.length > 0 ? `, ${extras.join(", ")}` : "";
|
|
120
|
+
if (clean) {
|
|
121
|
+
console.log(c.green("ok") + " " + summary + extraSummary);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
94
124
|
console.log((strict ? c.red("fail") : c.yellow("gap")) + " " + summary + extraSummary);
|
|
95
125
|
// A report by default (exit 0); a gate under --strict (exit 1 for CI). A
|
|
96
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.
|
|
97
128
|
return strict ? 1 : 0;
|
|
98
129
|
}
|
|
99
130
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
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
|
+
|
|
183
|
+
// Per-spec criterion rows — the full classification behind both the report
|
|
184
|
+
// and the --json output. Each row: { n, kind, missing?, skipped? }.
|
|
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 } = {}) {
|
|
103
193
|
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
104
|
-
|
|
105
|
-
const perCap = [];
|
|
194
|
+
const reports = [];
|
|
106
195
|
if (isDir(specsDir)) {
|
|
107
196
|
for (const cap of readdirSync(specsDir).sort()) {
|
|
197
|
+
if (only && !only.has(cap)) continue;
|
|
108
198
|
const specPath = path.join(specsDir, cap, "spec.md");
|
|
109
199
|
if (!isFile(specPath)) continue;
|
|
110
|
-
const
|
|
200
|
+
const text = read(specPath);
|
|
201
|
+
const criteria = extractAcceptanceCriteria(text);
|
|
111
202
|
if (criteria.length === 0) continue;
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 });
|
|
121
212
|
}
|
|
122
213
|
}
|
|
214
|
+
return reports;
|
|
215
|
+
}
|
|
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
|
+
|
|
228
|
+
// Pure summary of coverage across the spec tree, for other commands
|
|
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.
|
|
232
|
+
export function summarize(projectRoot) {
|
|
233
|
+
let totalCriteria = 0, totalCovered = 0, totalDangling = 0, totalConditional = 0, totalDeferred = 0;
|
|
234
|
+
const perCap = [];
|
|
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 });
|
|
246
|
+
}
|
|
123
247
|
const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
|
|
124
|
-
return { perCap, totalCriteria, totalCovered, totalDangling, totalConditional, pct };
|
|
248
|
+
return { perCap, totalCriteria, totalCovered, totalDangling, totalConditional, totalDeferred, pct };
|
|
125
249
|
}
|
|
126
250
|
|
|
127
251
|
// Pull the numbered items out of the "## Acceptance criteria" section.
|
|
@@ -182,11 +306,14 @@ function classify(criterion, n, projectRoot, specDir) {
|
|
|
182
306
|
resolved.push({ token, isTest, skipped });
|
|
183
307
|
}
|
|
184
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);
|
|
185
312
|
// Real proof = a non-test artifact, or a test file whose suite runs. If the
|
|
186
313
|
// only thing that resolves is a skipped test, the criterion is conditional.
|
|
187
314
|
const hasRealProof = resolved.some((r) => !r.isTest || !r.skipped);
|
|
188
|
-
if (hasRealProof) return { kind: "covered", n };
|
|
189
|
-
return { kind: "conditional", n, skipped: resolved.map((r) => r.token) };
|
|
315
|
+
if (hasRealProof) return { kind: "covered", n, evidence };
|
|
316
|
+
return { kind: "conditional", n, skipped: resolved.map((r) => r.token), evidence: [] };
|
|
190
317
|
}
|
|
191
318
|
|
|
192
319
|
// A cited path is a test file when it sits under a tests directory or carries
|
|
@@ -241,7 +368,7 @@ function looksLikePath(s) {
|
|
|
241
368
|
}
|
|
242
369
|
|
|
243
370
|
export const help = `
|
|
244
|
-
Usage: doctrina coverage [--strict]
|
|
371
|
+
Usage: doctrina coverage [--strict] [--only <cap,cap>] [--run] [--json]
|
|
245
372
|
|
|
246
373
|
Report how many acceptance criteria across .doctrina/specs/ cite an
|
|
247
374
|
artifact or test that exists on disk. Each numbered criterion may cite
|
|
@@ -251,9 +378,23 @@ A criterion is covered when a cited path resolves to real proof,
|
|
|
251
378
|
conditional when its only resolving proof is a test file whose suite is
|
|
252
379
|
skipped (\`describe.skip\` / \`xit\` / \`@pytest.mark.skip\` — proves
|
|
253
380
|
nothing), dangling when a cited path is missing on disk, and bare when
|
|
254
|
-
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).
|
|
255
385
|
|
|
256
386
|
Flags:
|
|
257
|
-
--strict
|
|
258
|
-
|
|
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.
|
|
259
400
|
`;
|