doctrina-cli 0.11.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/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/context.js +10 -1
- package/src/commands/coverage.js +157 -41
- 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/upgrade.js +129 -0
- package/src/commands/validate.js +117 -2
- package/src/commands/why.js +11 -1
- package/src/commands/work.js +45 -16
- package/src/index.js +11 -2
- package/src/lib/commands.js +5 -2
- package/src/lib/scan.js +16 -0
- package/templates/AGENTS.md.template +8 -4
- package/templates/spec.md.template +9 -1
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
|
`;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { exists, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
4
|
+
import { c } from "../lib/colors.js";
|
|
5
|
+
import { suggest } from "../lib/suggest.js";
|
|
6
|
+
|
|
7
|
+
// Post-intake intent evolution (0.11.0 field review item 8). The intake is
|
|
8
|
+
// converted once; capabilities born later — brainstorms, pivots, new asks —
|
|
9
|
+
// had no way to gain a product anchor, so they inevitably ended at
|
|
10
|
+
// `Realizes: n/a` and `trace` reported "14/14 ok" while blind to 40% of the
|
|
11
|
+
// system. `intent add` appends a new [SC*] anchor bullet to product.md's
|
|
12
|
+
// Success criteria (allocating the next number), so growth keeps provenance:
|
|
13
|
+
// new intent gets an anchor, the new spec Realizes it, `trace` sees it.
|
|
14
|
+
//
|
|
15
|
+
// The CLI allocates and appends; WHAT the intent says is the human/agent's
|
|
16
|
+
// words verbatim (ADR 0005).
|
|
17
|
+
|
|
18
|
+
const SUBCOMMANDS = ["add", "list"];
|
|
19
|
+
|
|
20
|
+
export async function run(positional, _flags) {
|
|
21
|
+
const sub = positional[0];
|
|
22
|
+
if (!SUBCOMMANDS.includes(sub)) {
|
|
23
|
+
console.error(c.red("error:") + ` unknown intent subcommand "${sub ?? ""}"`);
|
|
24
|
+
const guess = suggest(sub, SUBCOMMANDS);
|
|
25
|
+
console.error(c.gray("hint: ") + (guess
|
|
26
|
+
? `did you mean \`doctrina intent ${guess}\`?`
|
|
27
|
+
: `available: ${SUBCOMMANDS.join(", ")}`));
|
|
28
|
+
return 2;
|
|
29
|
+
}
|
|
30
|
+
const projectRoot = process.cwd();
|
|
31
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
32
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
33
|
+
}
|
|
34
|
+
if (sub === "list") return intentList(projectRoot);
|
|
35
|
+
return intentAdd(projectRoot, positional.slice(1).join(" ").trim());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function productPathOf(projectRoot) {
|
|
39
|
+
return path.join(projectRoot, ".doctrina", "product.md");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Every "[A-Z]+\d+" anchor at the head of a bullet, in document order —
|
|
43
|
+
// the same convention `trace` reads.
|
|
44
|
+
function collectAnchors(text) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const lines = text.split(/\r?\n/);
|
|
48
|
+
for (let i = 0; i < lines.length; i++) {
|
|
49
|
+
const m = lines[i].match(/^\s*[-*]\s+\[([A-Z]+)(\d+)\]\s+(.*)$/);
|
|
50
|
+
if (m && !seen.has(m[1] + m[2])) {
|
|
51
|
+
seen.add(m[1] + m[2]);
|
|
52
|
+
out.push({ prefix: m[1], num: Number(m[2]), id: m[1] + m[2], text: m[3].trim(), line: i });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function intentList(projectRoot) {
|
|
59
|
+
const p = productPathOf(projectRoot);
|
|
60
|
+
if (!isFile(p)) {
|
|
61
|
+
console.error(c.red("error:") + " no .doctrina/product.md");
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
const anchors = collectAnchors(read(p));
|
|
65
|
+
if (anchors.length === 0) {
|
|
66
|
+
console.log(c.gray("no intent anchors in product.md — tag bullets with `- [SC1] ...`, or add one: doctrina intent add \"<text>\""));
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
console.log(c.bold("Intent anchors") + c.gray(" — product.md, document order:"));
|
|
70
|
+
console.log("");
|
|
71
|
+
for (const a of anchors) {
|
|
72
|
+
console.log(` ${c.cyan(a.id.padEnd(6))} ${a.text}`);
|
|
73
|
+
}
|
|
74
|
+
console.log("");
|
|
75
|
+
console.log(c.gray(`${anchors.length} anchor${anchors.length === 1 ? "" : "s"}. Provenance: doctrina trace · doctrina why <id>`));
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function intentAdd(projectRoot, textArg) {
|
|
80
|
+
if (!textArg) {
|
|
81
|
+
console.error(c.red("error:") + " intent add requires the intent text (quote it)");
|
|
82
|
+
console.error(c.gray("hint: ") + `example: doctrina intent add "Risk map answers 'what should we test next?' in one read"`);
|
|
83
|
+
return 2;
|
|
84
|
+
}
|
|
85
|
+
const p = productPathOf(projectRoot);
|
|
86
|
+
if (!isFile(p)) {
|
|
87
|
+
console.error(c.red("error:") + " no .doctrina/product.md — run `doctrina init` / `doctrina intake` first");
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
const original = read(p);
|
|
91
|
+
const anchors = collectAnchors(original);
|
|
92
|
+
|
|
93
|
+
// Accept "SC15: text" to pin an explicit id; otherwise allocate the next
|
|
94
|
+
// number for the dominant prefix (SC by default).
|
|
95
|
+
let id = null;
|
|
96
|
+
let text = textArg;
|
|
97
|
+
const pinned = textArg.match(/^([A-Z]+)(\d+)\s*:\s*(.+)$/);
|
|
98
|
+
if (pinned) {
|
|
99
|
+
id = pinned[1] + pinned[2];
|
|
100
|
+
text = pinned[3].trim();
|
|
101
|
+
if (anchors.some((a) => a.id === id)) {
|
|
102
|
+
console.error(c.red("error:") + ` anchor [${id}] already exists in product.md`);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
const prefix = anchors.length > 0 ? dominantPrefix(anchors) : "SC";
|
|
107
|
+
const maxNum = anchors.filter((a) => a.prefix === prefix).reduce((m, a) => Math.max(m, a.num), 0);
|
|
108
|
+
id = `${prefix}${maxNum + 1}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const lines = original.split(/\r?\n/);
|
|
112
|
+
const bullet = `- [${id}] ${text}`;
|
|
113
|
+
|
|
114
|
+
// Insertion point: after the LAST existing anchor bullet (keeps them
|
|
115
|
+
// contiguous); else at the end of "## Success criteria"; else appended as a
|
|
116
|
+
// new Success criteria section (a product.md without one gains it).
|
|
117
|
+
const samePrefix = anchors.length > 0;
|
|
118
|
+
let inserted = false;
|
|
119
|
+
if (samePrefix) {
|
|
120
|
+
const last = anchors[anchors.length - 1];
|
|
121
|
+
lines.splice(last.line + 1, 0, bullet);
|
|
122
|
+
inserted = true;
|
|
123
|
+
} else {
|
|
124
|
+
for (let i = 0; i < lines.length; i++) {
|
|
125
|
+
if (/^##\s+Success criteria\b/i.test(lines[i])) {
|
|
126
|
+
// Insert before the next "## " heading (or at EOF).
|
|
127
|
+
let j = i + 1;
|
|
128
|
+
while (j < lines.length && !/^##\s+/.test(lines[j])) j += 1;
|
|
129
|
+
// Trim trailing blank lines inside the section for a tidy append.
|
|
130
|
+
let k = j;
|
|
131
|
+
while (k > i + 1 && lines[k - 1].trim() === "") k -= 1;
|
|
132
|
+
lines.splice(k, 0, bullet);
|
|
133
|
+
inserted = true;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!inserted) {
|
|
139
|
+
lines.push("", "## Success criteria", "", bullet);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
write(p, lines.join("\n"), { force: true });
|
|
143
|
+
console.log(c.green("added") + ` [${id}] to ${relPath(projectRoot, p)}`);
|
|
144
|
+
console.log("");
|
|
145
|
+
console.log(` ${c.cyan(id.padEnd(6))} ${text}`);
|
|
146
|
+
console.log("");
|
|
147
|
+
console.log(c.gray("Next: the spec that delivers this intent declares it —"));
|
|
148
|
+
console.log(` ${c.cyan(`**Realizes:** ${id}`)} ${c.gray("(in .doctrina/specs/<capability>/spec.md; `doctrina trace` closes the loop)")}`);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function dominantPrefix(anchors) {
|
|
153
|
+
const counts = new Map();
|
|
154
|
+
for (const a of anchors) counts.set(a.prefix, (counts.get(a.prefix) ?? 0) + 1);
|
|
155
|
+
return [...counts.entries()].sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]))[0][0];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const help = `
|
|
159
|
+
Usage: doctrina intent add "<text>"
|
|
160
|
+
doctrina intent add "SC15: <text>"
|
|
161
|
+
doctrina intent list
|
|
162
|
+
|
|
163
|
+
Post-intake intent evolution. \`add\` appends a new intent anchor bullet
|
|
164
|
+
(\`- [SC5] <text>\`) to product.md's Success criteria, allocating the next
|
|
165
|
+
number automatically (or pin one with the "SC15: ..." form). New
|
|
166
|
+
capabilities born after the intake — brainstorms, pivots — get an anchor
|
|
167
|
+
to Realize instead of inevitably landing at "Realizes: n/a", so
|
|
168
|
+
\`doctrina trace\` keeps seeing the whole system as it grows.
|
|
169
|
+
|
|
170
|
+
\`list\` prints every anchor in document order (the same convention trace
|
|
171
|
+
reads). The CLI allocates and appends; the intent's wording is yours,
|
|
172
|
+
verbatim (ADR 0005).
|
|
173
|
+
`;
|
package/src/commands/review.js
CHANGED
|
@@ -6,6 +6,7 @@ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
|
|
|
6
6
|
import { flagBool, flagString } from "../lib/args.js";
|
|
7
7
|
import { c } from "../lib/colors.js";
|
|
8
8
|
import { rankCapabilitiesByDiff } from "./work.js";
|
|
9
|
+
import { parseDependsOn } from "../lib/scan.js";
|
|
9
10
|
import { summarize as coverageSummary } from "./coverage.js";
|
|
10
11
|
import { summarize as traceSummary } from "./trace.js";
|
|
11
12
|
|
|
@@ -42,18 +43,38 @@ export async function run(_positional, flags) {
|
|
|
42
43
|
const notes = []; // advisory observations
|
|
43
44
|
|
|
44
45
|
// 1. Code changed under a capability whose spec was NOT touched — the spec
|
|
45
|
-
// may have drifted from the code it describes.
|
|
46
|
+
// may have drifted from the code it describes. Touched = every capability
|
|
47
|
+
// matched by the code diff (UNCAPPED — the top-3 hint truncation is how
|
|
48
|
+
// this missed 5 of 8 in the 0.11.0 field session) UNIONED with every
|
|
49
|
+
// capability whose spec.md itself changed.
|
|
46
50
|
const specChanged = new Set(
|
|
47
51
|
all.filter((f) => /(^|\/)\.doctrina\/specs\/[^/]+\/spec\.md$/.test(f.replace(/\\/g, "/")))
|
|
48
52
|
.map((f) => f.replace(/\\/g, "/").match(/\.doctrina\/specs\/([^/]+)\/spec\.md$/)[1]),
|
|
49
53
|
);
|
|
50
|
-
const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles);
|
|
54
|
+
const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles, { limit: Infinity });
|
|
55
|
+
const touched = new Set([...specChanged, ...ranked.map((m) => m.id)]);
|
|
51
56
|
for (const m of ranked) {
|
|
52
57
|
if (!specChanged.has(m.id)) {
|
|
53
58
|
breaks.push(`code under capability "${m.id}" changed, but its spec was not updated — confirm \`.doctrina/specs/${m.id}/spec.md\` still describes the code (update it, or note why not)`);
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
// 1b. Dependents of touched capabilities (the machine-readable
|
|
63
|
+
// **Depends on:** header): a spec that builds on something you changed
|
|
64
|
+
// may silently no longer hold. Advisory — a pointer, not a verdict.
|
|
65
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
66
|
+
if (isDir(specsDir) && touched.size > 0) {
|
|
67
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
68
|
+
if (touched.has(cap)) continue;
|
|
69
|
+
const specPath = path.join(specsDir, cap, "spec.md");
|
|
70
|
+
if (!isFile(specPath)) continue;
|
|
71
|
+
const deps = parseDependsOn(read(specPath)).filter((d) => touched.has(d));
|
|
72
|
+
if (deps.length > 0) {
|
|
73
|
+
notes.push(`capability "${cap}" depends on touched ${deps.map((d) => `"${d}"`).join(", ")} — confirm it still holds (\`doctrina why ${cap}\`)`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
57
78
|
// 2. New source files that map to no capability at all — code with no home
|
|
58
79
|
// in any spec (a likely new, unspecced capability).
|
|
59
80
|
if (ranked.length === 0 && sourceFiles.length > 0) {
|
|
@@ -83,8 +104,8 @@ export async function run(_positional, flags) {
|
|
|
83
104
|
|
|
84
105
|
// --- Output ---
|
|
85
106
|
console.log(c.gray(`${all.length} changed path(s); ${sourceFiles.length} source file(s) outside .doctrina/.`));
|
|
86
|
-
if (
|
|
87
|
-
console.log(c.gray("Capabilities touched: ") +
|
|
107
|
+
if (touched.size > 0) {
|
|
108
|
+
console.log(c.gray("Capabilities touched: ") + [...touched].sort().map((id) => c.cyan(id)).join(", "));
|
|
88
109
|
}
|
|
89
110
|
console.log("");
|
|
90
111
|
|
package/src/commands/skill.js
CHANGED
|
@@ -61,9 +61,23 @@ function skillSuggest(args, flags) {
|
|
|
61
61
|
const since = flagString(flags, "since");
|
|
62
62
|
|
|
63
63
|
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
const existingFiles = isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")) : [];
|
|
65
|
+
const existing = new Set(existingFiles.map((f) => path.basename(f, ".md")));
|
|
66
|
+
// A lesson may already be captured under a DIFFERENT slug (0.11.0 field
|
|
67
|
+
// review item 11: the human names the skill after the lesson, not after the
|
|
68
|
+
// change id — "tela-preta-crash-de-render..." for change 0049). Two extra
|
|
69
|
+
// dedup signals: an existing skill whose body cites the candidate's change
|
|
70
|
+
// id / commit hash, and token-overlap similarity between the slugs.
|
|
71
|
+
const existingBodies = existingFiles.map((f) => ({ slug: path.basename(f, ".md"), body: read(f) }));
|
|
72
|
+
const captured = (cand) => {
|
|
73
|
+
if (existing.has(cand.slug)) return true;
|
|
74
|
+
for (const sk of existingBodies) {
|
|
75
|
+
if (cand.id && sk.body.includes(cand.id)) return true;
|
|
76
|
+
if (cand.from && sk.body.includes(cand.from)) return true;
|
|
77
|
+
if (slugSimilarity(cand.slug, sk.slug) >= 0.5) return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
};
|
|
67
81
|
|
|
68
82
|
// Two deterministic sources, deduped by slug. `seen` guards against a lesson
|
|
69
83
|
// appearing twice (e.g. a fix committed and later archived as a change).
|
|
@@ -78,18 +92,20 @@ function skillSuggest(args, flags) {
|
|
|
78
92
|
const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
|
|
79
93
|
if (!FIX_SHAPED.test(id)) continue;
|
|
80
94
|
const slug = skillSlug(id);
|
|
81
|
-
if (
|
|
95
|
+
if (seen.has(slug)) continue;
|
|
96
|
+
const cand = { id, slug, source: "change", from: name };
|
|
97
|
+
if (captured(cand)) continue; // captured (any slug) or cited by an existing skill
|
|
82
98
|
const proposal = path.join(archiveDir, name, "proposal.md");
|
|
83
|
-
|
|
99
|
+
cand.why = isFile(proposal) ? firstWhyLine(read(proposal)) : "";
|
|
84
100
|
seen.add(slug);
|
|
85
|
-
candidates.push(
|
|
101
|
+
candidates.push(cand);
|
|
86
102
|
}
|
|
87
103
|
}
|
|
88
104
|
|
|
89
105
|
// Source 2 — fix-shaped commits in the git history (ADR 0013). Archive wins a
|
|
90
106
|
// slug collision: a `## Why` paragraph is a richer seed than a subject line.
|
|
91
107
|
for (const cand of gitFixCommits(projectRoot, { since, limit: GIT_LOG_LIMIT })) {
|
|
92
|
-
if (
|
|
108
|
+
if (seen.has(cand.slug) || captured(cand)) continue;
|
|
93
109
|
seen.add(cand.slug);
|
|
94
110
|
candidates.push(cand);
|
|
95
111
|
}
|
|
@@ -216,6 +232,29 @@ function skillSlug(id) {
|
|
|
216
232
|
return slug.length > 40 ? slug.slice(0, 40).replace(/-+$/g, "") : (slug || "lesson");
|
|
217
233
|
}
|
|
218
234
|
|
|
235
|
+
// Token-overlap similarity between two kebab slugs (Jaccard over meaningful
|
|
236
|
+
// tokens). "0049-fix-tela-preta-render" vs "tela-preta-crash-de-render-sem-
|
|
237
|
+
// error-boundary" share tela/preta/render → high overlap even though the
|
|
238
|
+
// human renamed the skill after the lesson. Deterministic; never semantic.
|
|
239
|
+
function slugSimilarity(a, b) {
|
|
240
|
+
const toks = (s) => new Set(
|
|
241
|
+
String(s).split("-").filter((t) => t.length >= 3 && !/^\d+$/.test(t) && !SLUG_NOISE.has(t)),
|
|
242
|
+
);
|
|
243
|
+
const A = toks(a);
|
|
244
|
+
const B = toks(b);
|
|
245
|
+
if (A.size === 0 || B.size === 0) return 0;
|
|
246
|
+
let inter = 0;
|
|
247
|
+
for (const t of A) if (B.has(t)) inter += 1;
|
|
248
|
+
return inter / (A.size + B.size - inter);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Fix-shaped verbs and connectives carry no lesson identity — without this,
|
|
252
|
+
// every "fix-..." candidate looks 30% similar to every "fix-..." skill.
|
|
253
|
+
const SLUG_NOISE = new Set([
|
|
254
|
+
"fix", "bug", "hotfix", "patch", "the", "and", "for", "with", "sem", "com",
|
|
255
|
+
"que", "nao", "por", "para", "una", "uma", "dos", "das",
|
|
256
|
+
]);
|
|
257
|
+
|
|
219
258
|
function firstWhyLine(proposalText) {
|
|
220
259
|
const m = proposalText.match(/##\s*Why\s*\r?\n+([^\r\n][^\r\n]*)/i);
|
|
221
260
|
if (!m) return "";
|