doctrina-cli 0.13.0 → 0.15.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 +3 -2
- package/src/commands/adapter.js +205 -0
- package/src/commands/analyze.js +120 -17
- package/src/commands/change.js +100 -42
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +57 -2
- package/src/commands/completion.js +6 -0
- package/src/commands/constitution.js +8 -1
- package/src/commands/context.js +391 -58
- package/src/commands/contract.js +76 -43
- package/src/commands/coverage.js +126 -12
- package/src/commands/decision.js +172 -40
- package/src/commands/doctor.js +92 -11
- package/src/commands/handoff.js +8 -1
- package/src/commands/hooks.js +6 -0
- package/src/commands/index-rebuild.js +8 -1
- package/src/commands/init.js +139 -6
- package/src/commands/intake.js +11 -2
- package/src/commands/intent.js +8 -1
- package/src/commands/metrics.js +80 -7
- package/src/commands/next.js +38 -3
- package/src/commands/prime.js +8 -1
- package/src/commands/report.js +8 -1
- package/src/commands/review.js +8 -1
- package/src/commands/search.js +8 -1
- package/src/commands/show.js +8 -1
- package/src/commands/skill.js +201 -8
- package/src/commands/spec.js +13 -6
- package/src/commands/status.js +14 -2
- package/src/commands/templates.js +193 -59
- package/src/commands/trace.js +15 -3
- package/src/commands/triage.js +248 -0
- package/src/commands/upgrade.js +8 -1
- package/src/commands/validate.js +148 -5
- package/src/commands/verify.js +126 -5
- package/src/commands/watch.js +8 -1
- package/src/commands/why.js +10 -24
- package/src/commands/work.js +44 -3
- package/src/index.js +61 -6
- package/src/lib/adapters.js +140 -0
- package/src/lib/adr-guard.js +1 -0
- package/src/lib/args.js +10 -0
- package/src/lib/clarity.js +1 -0
- package/src/lib/colors.js +1 -0
- package/src/lib/commands.js +252 -27
- package/src/lib/criteria.js +1 -0
- package/src/lib/dates.js +1 -0
- package/src/lib/diff.js +1 -0
- package/src/lib/doc-model.js +287 -0
- package/src/lib/docs-impact.js +167 -0
- package/src/lib/ears.js +1 -0
- package/src/lib/exit-codes.js +103 -0
- package/src/lib/flag-catalog.js +106 -0
- package/src/lib/fs-ops.js +1 -0
- package/src/lib/gates.js +123 -0
- package/src/lib/git.js +97 -0
- package/src/lib/index-json.js +14 -6
- package/src/lib/json-out.js +83 -0
- package/src/lib/pipeline.js +161 -0
- package/src/lib/prompt.js +18 -1
- package/src/lib/runtime.js +683 -0
- package/src/lib/scan.js +81 -33
- package/src/lib/spec-ops.js +94 -9
- package/src/lib/suggest.js +1 -0
- package/src/lib/templates.js +89 -0
- package/src/lib/usage.js +89 -0
- package/src/lib/version.js +1 -0
- package/templates/AGENTS.md.template +59 -53
- package/templates/contract.md.template +74 -3
- package/templates/spec.md.template +22 -0
package/src/commands/contract.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { getSection } from "../lib/doc-model.js";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
import process from "node:process";
|
|
3
5
|
import { readdirSync } from "node:fs";
|
|
4
6
|
import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
5
|
-
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
7
|
+
import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
6
8
|
import { specHeader } from "../lib/scan.js";
|
|
7
9
|
import * as idx from "../lib/index-json.js";
|
|
8
10
|
import { today } from "../lib/dates.js";
|
|
9
11
|
import { flagBool } from "../lib/args.js";
|
|
10
12
|
import { c } from "../lib/colors.js";
|
|
11
13
|
import { suggest } from "../lib/suggest.js";
|
|
14
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
15
|
+
import {
|
|
16
|
+
parseTable, parseRuntimeDeclaration, checkWiring, checkEmptySemantics,
|
|
17
|
+
checkEnums, checkSelectors,
|
|
18
|
+
} from "../lib/runtime.js";
|
|
12
19
|
|
|
13
20
|
// Contracts are the first-class home for the integration/runtime surface
|
|
14
21
|
// no single capability owns: the port map, the env/dependency contract,
|
|
@@ -20,6 +27,11 @@ import { suggest } from "../lib/suggest.js";
|
|
|
20
27
|
|
|
21
28
|
const SUBCOMMANDS = ["new", "list", "check"];
|
|
22
29
|
|
|
30
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
31
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
32
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
33
|
+
export const flags = { boolean: ["json", "force"], string: [] };
|
|
34
|
+
|
|
23
35
|
export async function run(positional, flags) {
|
|
24
36
|
const sub = positional[0];
|
|
25
37
|
switch (sub) {
|
|
@@ -55,11 +67,12 @@ function contractNew(args, flags) {
|
|
|
55
67
|
return 1;
|
|
56
68
|
}
|
|
57
69
|
|
|
58
|
-
const
|
|
70
|
+
const tpl = readTemplate(projectRoot, "contract.md.template");
|
|
59
71
|
const date = today();
|
|
60
|
-
const body = substitute(
|
|
72
|
+
const body = substitute(tpl.body, { CONTRACT_NAME: name, DATE: date });
|
|
61
73
|
write(targetPath, body, { force });
|
|
62
|
-
console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}`
|
|
74
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}` +
|
|
75
|
+
(tpl.source === "project" ? c.gray(" (project template)") : ""));
|
|
63
76
|
|
|
64
77
|
const index = idx.load(projectRoot);
|
|
65
78
|
idx.addContract(index, {
|
|
@@ -73,6 +86,7 @@ function contractNew(args, flags) {
|
|
|
73
86
|
console.log(c.green("indexed") + ` contract "${name}"`);
|
|
74
87
|
console.log("");
|
|
75
88
|
console.log("Fill in the Ports / Environment / Interfaces tables, then run " + c.cyan("doctrina contract check") + ".");
|
|
89
|
+
console.log(c.gray("Declaring Wiring and Selectors rows is what makes the RUNTIME half checkable — without them, check can only verify the prose."));
|
|
76
90
|
return 0;
|
|
77
91
|
}
|
|
78
92
|
|
|
@@ -135,7 +149,7 @@ function contractCheck(args, _flags) {
|
|
|
135
149
|
console.log(c.bold(name) + c.gray(` (${relPath(projectRoot, file)})`));
|
|
136
150
|
|
|
137
151
|
// 1. Port collisions — two services must not claim the same port.
|
|
138
|
-
const ports = parseTable(
|
|
152
|
+
const ports = parseTable(getSection(text, "Ports"));
|
|
139
153
|
if (ports) {
|
|
140
154
|
const portCol = colIndex(ports.headers, "port");
|
|
141
155
|
const svcCol = colIndex(ports.headers, "service");
|
|
@@ -155,7 +169,7 @@ function contractCheck(args, _flags) {
|
|
|
155
169
|
|
|
156
170
|
// 2. Environment drift — every declared variable must exist in
|
|
157
171
|
// .env.example (when present), so code, example, and infra agree.
|
|
158
|
-
const env = parseTable(
|
|
172
|
+
const env = parseTable(getSection(text, "Environment"));
|
|
159
173
|
if (env && envExample !== null) {
|
|
160
174
|
const varCol = colIndex(env.headers, "variable");
|
|
161
175
|
for (const row of env.rows) {
|
|
@@ -170,13 +184,42 @@ function contractCheck(args, _flags) {
|
|
|
170
184
|
}
|
|
171
185
|
|
|
172
186
|
// 3. Referenced capability specs must exist.
|
|
173
|
-
for (const refCap of referencedCapabilities(
|
|
187
|
+
for (const refCap of referencedCapabilities(getSection(text, "References"))) {
|
|
174
188
|
const specPath = path.join(projectRoot, ".doctrina", "specs", refCap, "spec.md");
|
|
175
189
|
if (!isFile(specPath)) {
|
|
176
190
|
console.log(c.red(" ✗ ") + `references spec "${refCap}" but ${relPath(projectRoot, specPath)} does not exist`);
|
|
177
191
|
errors += 1;
|
|
178
192
|
}
|
|
179
193
|
}
|
|
194
|
+
|
|
195
|
+
// 4. The RUNTIME half: a declaration is only worth what binds it to the
|
|
196
|
+
// running system. These checks read the contract's Wiring, Selectors
|
|
197
|
+
// and Values declarations and hold the implementation to them — the
|
|
198
|
+
// "I set the secret in GitHub and nothing happened" class, the
|
|
199
|
+
// default that an empty value never triggers, the enum nothing
|
|
200
|
+
// validates, and the selector that matches zero cases and exits 0.
|
|
201
|
+
// Every section is optional, so a contract written before they
|
|
202
|
+
// existed checks exactly as it did before.
|
|
203
|
+
const decl = parseRuntimeDeclaration(text);
|
|
204
|
+
for (const f of [
|
|
205
|
+
...checkWiring(projectRoot, decl),
|
|
206
|
+
...checkEmptySemantics(projectRoot, decl),
|
|
207
|
+
...checkEnums(projectRoot, decl),
|
|
208
|
+
...checkSelectors(projectRoot, decl),
|
|
209
|
+
]) {
|
|
210
|
+
const mark = f.level === "error" ? c.red(" ✗ ") : c.yellow(" ! ");
|
|
211
|
+
console.log(mark + f.message + c.gray(` [${f.code}]`));
|
|
212
|
+
console.log(` ${c.gray(`fix: ${f.remedy}`)}`);
|
|
213
|
+
if (f.level === "error") errors += 1;
|
|
214
|
+
else warnings += 1;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (decl.wiring.length + decl.selectors.length === 0) {
|
|
218
|
+
// Silence here is not proof of correctness — it is proof that nothing
|
|
219
|
+
// was declared. Saying so is what stops a green check from being read
|
|
220
|
+
// as "the wiring is verified".
|
|
221
|
+
console.log(c.gray(" · no Wiring or Selectors declared — the runtime surface is unchecked"));
|
|
222
|
+
}
|
|
180
223
|
}
|
|
181
224
|
|
|
182
225
|
console.log("");
|
|
@@ -189,37 +232,6 @@ function contractCheck(args, _flags) {
|
|
|
189
232
|
return errors === 0 ? 0 : 1;
|
|
190
233
|
}
|
|
191
234
|
|
|
192
|
-
// Return the body of a "## <name>" section, up to the next "## " heading.
|
|
193
|
-
function sectionOf(text, name) {
|
|
194
|
-
const lines = text.split(/\r?\n/);
|
|
195
|
-
const head = new RegExp(`^##\\s+${name}\\b`, "i");
|
|
196
|
-
let inSection = false;
|
|
197
|
-
const out = [];
|
|
198
|
-
for (const line of lines) {
|
|
199
|
-
if (/^##\s+/.test(line)) {
|
|
200
|
-
inSection = head.test(line);
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
if (inSection) out.push(line);
|
|
204
|
-
}
|
|
205
|
-
return out.join("\n");
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Parse a GitHub-flavoured Markdown table into { headers, rows }.
|
|
209
|
-
function parseTable(text) {
|
|
210
|
-
const lines = text.split(/\r?\n/).filter((l) => /^\s*\|/.test(l));
|
|
211
|
-
if (lines.length < 2) return null;
|
|
212
|
-
const cells = (line) => line.trim().replace(/^\||\|$/g, "").split("|").map((cl) => cl.trim());
|
|
213
|
-
const headers = cells(lines[0]);
|
|
214
|
-
const rows = [];
|
|
215
|
-
for (let i = 1; i < lines.length; i++) {
|
|
216
|
-
const row = cells(lines[i]);
|
|
217
|
-
if (row.every((cl) => /^:?-+:?$/.test(cl) || cl === "")) continue; // separator row
|
|
218
|
-
rows.push(row);
|
|
219
|
-
}
|
|
220
|
-
return { headers, rows };
|
|
221
|
-
}
|
|
222
|
-
|
|
223
235
|
function colIndex(headers, name) {
|
|
224
236
|
const i = headers.findIndex((h) => h.toLowerCase() === name.toLowerCase());
|
|
225
237
|
return i < 0 ? 0 : i;
|
|
@@ -234,7 +246,7 @@ function referencedCapabilities(text) {
|
|
|
234
246
|
|
|
235
247
|
function ensureDoctrinaProject(projectRoot) {
|
|
236
248
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
237
|
-
throw
|
|
249
|
+
throw notADoctrinaProject();
|
|
238
250
|
}
|
|
239
251
|
}
|
|
240
252
|
|
|
@@ -247,8 +259,29 @@ the port map, the environment contract, and API/WS/event interfaces.
|
|
|
247
259
|
Subcommands:
|
|
248
260
|
new <name> Scaffold .doctrina/contracts/<name>.md and index it
|
|
249
261
|
list One line per contract: id, status, last updated
|
|
250
|
-
check [<name>] Verify the mechanically checkable parts
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
262
|
+
check [<name>] Verify the mechanically checkable parts.
|
|
263
|
+
|
|
264
|
+
Structure: no two services share a port, every
|
|
265
|
+
declared env var exists in .env.example, and every
|
|
266
|
+
referenced spec exists.
|
|
267
|
+
|
|
268
|
+
Runtime — the declaration held to the implementation:
|
|
269
|
+
RT01/02 a variable declared with origin vars or
|
|
270
|
+
secrets must be exported by an env: block
|
|
271
|
+
in the workflow the Wiring row names, from
|
|
272
|
+
the origin and name it declares.
|
|
273
|
+
RT03 its consumer must not give it a default
|
|
274
|
+
that only applies when ABSENT: CI injects
|
|
275
|
+
the empty string, so the default never
|
|
276
|
+
fires (textual lint).
|
|
277
|
+
RT04 a declared Values enum must hold in
|
|
278
|
+
.env.example, and the consumer should
|
|
279
|
+
validate it.
|
|
280
|
+
RT05 every declared selector must match at
|
|
281
|
+
least one target, or a run dispatched on
|
|
282
|
+
it executes nothing and still exits 0.
|
|
283
|
+
|
|
284
|
+
No CI system or test runner is parsed natively: each
|
|
285
|
+
check reads the glob, pattern and origin the contract
|
|
286
|
+
declares. Exits 1 on errors, 0 on warnings only.
|
|
254
287
|
`;
|
package/src/commands/coverage.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { readdirSync } from "node:fs";
|
|
@@ -6,6 +7,8 @@ import { exists, isDir, isFile, read, relPath } from "../lib/fs-ops.js";
|
|
|
6
7
|
import { specHeader } from "../lib/scan.js";
|
|
7
8
|
import { flagBool, flagString } from "../lib/args.js";
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
10
|
+
import { emitJson } from "../lib/json-out.js";
|
|
11
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
9
12
|
|
|
10
13
|
// Traceability report: how many acceptance criteria cite an artifact or
|
|
11
14
|
// test that actually exists on disk. Doctrina otherwise has no link
|
|
@@ -22,10 +25,19 @@ import { c } from "../lib/colors.js";
|
|
|
22
25
|
// proves nothing — review G3: existence ≠ a passing test), "dangling" when
|
|
23
26
|
// a cited path does not resolve, and "bare" when nothing is cited.
|
|
24
27
|
|
|
28
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
29
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
30
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
31
|
+
// This command builds its own JSON payload; the entrypoint must not
|
|
32
|
+
// wrap it in the generic envelope.
|
|
33
|
+
export const jsonNative = true;
|
|
34
|
+
|
|
35
|
+
export const flags = { boolean: ["json", "run", "strict"], string: ["only"] };
|
|
36
|
+
|
|
25
37
|
export async function run(_positional, flags) {
|
|
26
38
|
const projectRoot = process.cwd();
|
|
27
39
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
28
|
-
throw
|
|
40
|
+
throw notADoctrinaProject();
|
|
29
41
|
}
|
|
30
42
|
const strict = flagBool(flags, "strict", false);
|
|
31
43
|
const json = flagBool(flags, "json", false);
|
|
@@ -48,7 +60,7 @@ export async function run(_positional, flags) {
|
|
|
48
60
|
|
|
49
61
|
if (reports.length === 0) {
|
|
50
62
|
if (json) {
|
|
51
|
-
|
|
63
|
+
emitJson("coverage", { specs: [], summary: { criteria: 0, covered: 0, dangling: 0, conditional: 0, pct: 100 } });
|
|
52
64
|
return 0;
|
|
53
65
|
}
|
|
54
66
|
console.log(c.gray("no acceptance criteria found under .doctrina/specs/"));
|
|
@@ -59,23 +71,26 @@ export async function run(_positional, flags) {
|
|
|
59
71
|
let totalCovered = 0;
|
|
60
72
|
let totalDangling = 0;
|
|
61
73
|
let totalConditional = 0;
|
|
74
|
+
let totalUnguarded = 0;
|
|
62
75
|
let totalDeferred = 0;
|
|
63
76
|
for (const rep of reports) {
|
|
64
77
|
totalCriteria += rep.rows.length;
|
|
65
78
|
totalCovered += rep.rows.filter((r) => r.kind === "covered").length;
|
|
66
79
|
totalDangling += rep.rows.filter((r) => r.kind === "dangling").length;
|
|
67
80
|
totalConditional += rep.rows.filter((r) => r.kind === "conditional").length;
|
|
81
|
+
totalUnguarded += rep.rows.filter((r) => r.kind === "unguarded").length;
|
|
68
82
|
totalDeferred += rep.rows.filter((r) => r.kind === "deferred").length;
|
|
69
83
|
}
|
|
70
84
|
const jsonPct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
|
|
71
85
|
// Deferred criteria are visible but never gate: declared debt ≠ hidden debt.
|
|
72
|
-
const jsonClean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0
|
|
86
|
+
const jsonClean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0
|
|
87
|
+
&& totalConditional === 0 && totalUnguarded === 0;
|
|
73
88
|
|
|
74
89
|
if (json) {
|
|
75
|
-
|
|
90
|
+
emitJson("coverage", {
|
|
76
91
|
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
|
-
}
|
|
92
|
+
summary: { criteria: totalCriteria, covered: totalCovered, dangling: totalDangling, conditional: totalConditional, unguarded: totalUnguarded, deferred: totalDeferred, pct: jsonPct },
|
|
93
|
+
});
|
|
79
94
|
return jsonClean ? 0 : strict ? 1 : 0;
|
|
80
95
|
}
|
|
81
96
|
|
|
@@ -86,10 +101,12 @@ export async function run(_positional, flags) {
|
|
|
86
101
|
const covered = rep.rows.filter((r) => r.kind === "covered").length;
|
|
87
102
|
const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
|
|
88
103
|
const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
|
|
104
|
+
const unguarded = rep.rows.filter((r) => r.kind === "unguarded").length;
|
|
89
105
|
const deferredN = rep.rows.filter((r) => r.kind === "deferred").length;
|
|
90
106
|
|
|
91
107
|
const notes = [];
|
|
92
108
|
if (conditional > 0) notes.push(c.yellow(`${conditional} conditional`));
|
|
109
|
+
if (unguarded > 0) notes.push(c.yellow(`${unguarded} unguarded`));
|
|
93
110
|
if (dangling > 0) notes.push(c.yellow(`${dangling} dangling`));
|
|
94
111
|
if (deferredN > 0) notes.push(c.gray(`${deferredN} deferred`));
|
|
95
112
|
const note = notes.length > 0 ? ` (${notes.join(", ")})` : "";
|
|
@@ -102,6 +119,8 @@ export async function run(_positional, flags) {
|
|
|
102
119
|
console.log(` ${c.red("✗")} #${r.n} no evidence linked — cite the file/test that proves it in backticks`);
|
|
103
120
|
} else if (r.kind === "conditional") {
|
|
104
121
|
console.log(` ${c.yellow("!")} #${r.n} evidence is a skipped test (proves nothing): ${r.skipped.map((m) => `\`${m}\``).join(", ")}`);
|
|
122
|
+
} else if (r.kind === "unguarded") {
|
|
123
|
+
console.log(` ${c.red("✗")} #${r.n} orchestration claim is not fail-closed: ${r.reason}`);
|
|
105
124
|
} else {
|
|
106
125
|
console.log(` ${c.yellow("!")} #${r.n} evidence not found on disk: ${r.missing.map((m) => `\`${m}\``).join(", ")}`);
|
|
107
126
|
}
|
|
@@ -111,9 +130,11 @@ export async function run(_positional, flags) {
|
|
|
111
130
|
const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
|
|
112
131
|
console.log("");
|
|
113
132
|
const summary = `${totalCovered} of ${totalCriteria} acceptance criteria across ${reports.length} spec${reports.length === 1 ? "" : "s"} have linked evidence (${pct}%)`;
|
|
114
|
-
const clean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0
|
|
133
|
+
const clean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0
|
|
134
|
+
&& totalConditional === 0 && totalUnguarded === 0;
|
|
115
135
|
const extras = [];
|
|
116
136
|
if (totalConditional > 0) extras.push(`${totalConditional} conditional`);
|
|
137
|
+
if (totalUnguarded > 0) extras.push(`${totalUnguarded} unguarded`);
|
|
117
138
|
if (totalDangling > 0) extras.push(`${totalDangling} dangling`);
|
|
118
139
|
if (totalDeferred > 0) extras.push(`${totalDeferred} deferred (not gated)`);
|
|
119
140
|
const extraSummary = extras.length > 0 ? `, ${extras.join(", ")}` : "";
|
|
@@ -236,13 +257,14 @@ export function summarize(projectRoot) {
|
|
|
236
257
|
const covered = rep.rows.filter((r) => r.kind === "covered").length;
|
|
237
258
|
const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
|
|
238
259
|
const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
|
|
260
|
+
const unguarded = rep.rows.filter((r) => r.kind === "unguarded").length;
|
|
239
261
|
const deferred = rep.rows.filter((r) => r.kind === "deferred").length;
|
|
240
262
|
totalCriteria += rep.rows.length;
|
|
241
263
|
totalCovered += covered;
|
|
242
264
|
totalDangling += dangling;
|
|
243
265
|
totalConditional += conditional;
|
|
244
266
|
totalDeferred += deferred;
|
|
245
|
-
perCap.push({ cap: rep.cap, total: rep.rows.length, covered, dangling, conditional, deferred });
|
|
267
|
+
perCap.push({ cap: rep.cap, total: rep.rows.length, covered, dangling, conditional, unguarded, deferred });
|
|
246
268
|
}
|
|
247
269
|
const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
|
|
248
270
|
return { perCap, totalCriteria, totalCovered, totalDangling, totalConditional, totalDeferred, pct };
|
|
@@ -285,8 +307,84 @@ function extractAcceptanceCriteria(text) {
|
|
|
285
307
|
return out.filter((s) => s.length > 0);
|
|
286
308
|
}
|
|
287
309
|
|
|
310
|
+
// ORCHESTRATION criteria (change 0029). Coverage measures CITATION: a
|
|
311
|
+
// criterion is covered when it cites a file that exists (and, since G3, a
|
|
312
|
+
// test whose suite is not skipped). That is the right test for "this
|
|
313
|
+
// function behaves", and the wrong one for "the pipeline ran at all".
|
|
314
|
+
//
|
|
315
|
+
// A criterion like "absence of the report is explicit and the step does not
|
|
316
|
+
// fail" is satisfied, on paper, by a job that executed zero cases and
|
|
317
|
+
// printed a well-written empty state — the citation resolves, the suite is
|
|
318
|
+
// not skipped, and the only visible signal is a tidy message saying nothing
|
|
319
|
+
// happened. Coverage calls that proven.
|
|
320
|
+
//
|
|
321
|
+
// Marking a criterion `[orchestration]` says: the claim is that a RUN
|
|
322
|
+
// happened, so the proof must be a fail-closed one. It cites a verify check
|
|
323
|
+
// by name (`verify:<check>`), and that check must declare an `expect` guard
|
|
324
|
+
// — the thing that turns "exit 0" into "exit 0 having actually done
|
|
325
|
+
// something". A named check without a guard is UNGUARDED: the criterion is
|
|
326
|
+
// not proven, and says so, instead of quietly counting as covered.
|
|
327
|
+
const ORCHESTRATION_MARKER = /^orchestration\b/;
|
|
328
|
+
|
|
329
|
+
function citedVerifyChecks(criterion) {
|
|
330
|
+
const out = new Set();
|
|
331
|
+
for (const m of criterion.matchAll(/`verify:([A-Za-z0-9_.-]+)`/g)) out.add(m[1]);
|
|
332
|
+
return [...out];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function guardedVerifyChecks(projectRoot) {
|
|
336
|
+
const configPath = path.join(projectRoot, ".doctrina", "verify.json");
|
|
337
|
+
if (!isFile(configPath)) return null;
|
|
338
|
+
let config;
|
|
339
|
+
try {
|
|
340
|
+
config = JSON.parse(read(configPath));
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
const map = new Map();
|
|
345
|
+
for (const ch of Array.isArray(config?.checks) ? config.checks : []) {
|
|
346
|
+
if (!ch?.name) continue;
|
|
347
|
+
const exp = ch.expect;
|
|
348
|
+
const guarded = Boolean(exp && typeof exp === "object"
|
|
349
|
+
&& (typeof exp.fail_if_output_matches === "string" || typeof exp.require_output_matches === "string"));
|
|
350
|
+
map.set(ch.name, guarded);
|
|
351
|
+
}
|
|
352
|
+
return map;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function classifyOrchestration(criterion, n, projectRoot) {
|
|
356
|
+
const named = citedVerifyChecks(criterion);
|
|
357
|
+
if (named.length === 0) {
|
|
358
|
+
return { kind: "unguarded", n, reason: "cites no verify check — an orchestration claim is proven by a fail-closed check, cited as `verify:<check>`" };
|
|
359
|
+
}
|
|
360
|
+
const checks = guardedVerifyChecks(projectRoot);
|
|
361
|
+
if (checks === null) {
|
|
362
|
+
return { kind: "dangling", n, missing: named.map((x) => `verify:${x}`) };
|
|
363
|
+
}
|
|
364
|
+
const unknown = named.filter((x) => !checks.has(x));
|
|
365
|
+
if (unknown.length > 0) {
|
|
366
|
+
return { kind: "dangling", n, missing: unknown.map((x) => `verify:${x}`) };
|
|
367
|
+
}
|
|
368
|
+
const unguarded = named.filter((x) => !checks.get(x));
|
|
369
|
+
if (unguarded.length > 0) {
|
|
370
|
+
return {
|
|
371
|
+
kind: "unguarded",
|
|
372
|
+
n,
|
|
373
|
+
reason: `verify check${unguarded.length === 1 ? "" : "s"} ${unguarded.join(", ")} declare${unguarded.length === 1 ? "s" : ""} no "expect" guard — a check that exits 0 having run nothing would still pass it`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
return { kind: "covered", n, evidence: [] };
|
|
377
|
+
}
|
|
378
|
+
|
|
288
379
|
// Decide whether a single criterion is covered, conditional, dangling, or bare.
|
|
289
380
|
function classify(criterion, n, projectRoot, specDir) {
|
|
381
|
+
// An orchestration criterion is judged on its GUARD, not on whether a
|
|
382
|
+
// cited file exists — the whole point is that existence proves nothing here.
|
|
383
|
+
const marker = criterion.match(/^\[([^\]]+)\]/)?.[1]?.toLowerCase() ?? "";
|
|
384
|
+
if (ORCHESTRATION_MARKER.test(marker)) {
|
|
385
|
+
return classifyOrchestration(criterion, n, projectRoot);
|
|
386
|
+
}
|
|
387
|
+
|
|
290
388
|
const cited = extractBacktickPaths(criterion);
|
|
291
389
|
if (cited.length === 0) return { kind: "bare", n };
|
|
292
390
|
const missing = [];
|
|
@@ -378,15 +476,31 @@ A criterion is covered when a cited path resolves to real proof,
|
|
|
378
476
|
conditional when its only resolving proof is a test file whose suite is
|
|
379
477
|
skipped (\`describe.skip\` / \`xit\` / \`@pytest.mark.skip\` — proves
|
|
380
478
|
nothing), dangling when a cited path is missing on disk, and bare when
|
|
381
|
-
nothing is cited.
|
|
479
|
+
nothing is cited.
|
|
480
|
+
|
|
481
|
+
ORCHESTRATION criteria. Citation is the right proof for "this function
|
|
482
|
+
behaves" and the wrong one for "the pipeline ran at all": a criterion
|
|
483
|
+
like "absence of the report is explicit and the step does not fail" is
|
|
484
|
+
satisfied on paper by a job that executed zero cases and printed a
|
|
485
|
+
well-written empty state. Mark such a criterion [orchestration] and cite
|
|
486
|
+
a verify check by name:
|
|
487
|
+
|
|
488
|
+
3. [orchestration] the e2e suite actually executes scenarios —
|
|
489
|
+
verified by \`verify:e2e\`
|
|
490
|
+
|
|
491
|
+
That check must declare an "expect" guard (see \`doctrina verify --help\`).
|
|
492
|
+
A cited check with no guard is reported UNGUARDED and fails --strict: a
|
|
493
|
+
check that exits 0 having run nothing would otherwise satisfy the claim.
|
|
494
|
+
|
|
495
|
+
A spec that declares a deliberate deferral —
|
|
382
496
|
\`Implementation: planned — <why>\`, the same escape hatch validate
|
|
383
497
|
honours — has its unproven criteria reported as DEFERRED: visible, but
|
|
384
498
|
never a --strict failure (declared debt is not hidden debt).
|
|
385
499
|
|
|
386
500
|
Flags:
|
|
387
|
-
--strict Exit 1 when any criterion is bare, dangling,
|
|
388
|
-
conditional (CI gate). Deferred never
|
|
389
|
-
it the command always exits 0 (a report).
|
|
501
|
+
--strict Exit 1 when any criterion is bare, dangling,
|
|
502
|
+
conditional, or unguarded (CI gate). Deferred never
|
|
503
|
+
fails. Without it the command always exits 0 (a report).
|
|
390
504
|
--only <cap,cap> Scope the report/gate to specific capabilities
|
|
391
505
|
(\`doctrina close\` uses this so an unrelated deferred
|
|
392
506
|
spec cannot block a change's close).
|