doctrina-cli 0.15.1 → 0.16.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/adapter.js +24 -1
- package/src/commands/analyze.js +8 -212
- package/src/commands/change.js +143 -217
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +58 -21
- package/src/commands/close.js +291 -42
- package/src/commands/constitution.js +14 -91
- package/src/commands/context.js +278 -107
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +54 -7
- package/src/commands/doctor.js +292 -129
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +11 -2
- package/src/commands/index-rebuild.js +9 -48
- package/src/commands/init.js +70 -13
- package/src/commands/intake.js +30 -92
- package/src/commands/metrics.js +53 -84
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +24 -91
- package/src/commands/report.js +50 -108
- package/src/commands/review.js +68 -36
- package/src/commands/search.js +18 -4
- package/src/commands/show.js +24 -9
- package/src/commands/skill.js +16 -25
- package/src/commands/spec.js +26 -5
- package/src/commands/status.js +51 -172
- package/src/commands/templates.js +31 -138
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/validate.js +14 -871
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +234 -263
- package/src/index.js +106 -7
- package/src/lib/actions.js +484 -0
- package/src/lib/adapters.js +0 -11
- package/src/lib/adr-guard.js +19 -0
- package/src/lib/agent-changelog.js +133 -0
- package/src/lib/analysis.js +264 -0
- package/src/lib/change-ops.js +131 -0
- package/src/lib/commands.js +56 -4
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +392 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +326 -0
- package/src/lib/docs-impact.js +196 -27
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +181 -23
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +2 -19
- package/src/lib/intake-model.js +86 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +191 -0
- package/src/lib/metrics-model.js +159 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +57 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +204 -13
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +209 -0
- package/src/lib/spec-ops.js +50 -5
- package/src/lib/templates-model.js +262 -0
- package/src/lib/trace-model.js +95 -0
- package/src/lib/triage-model.js +121 -0
- package/src/lib/usage.js +6 -4
- package/src/lib/validation-model.js +1299 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +410 -0
- package/src/lib/work-model.js +77 -0
- package/templates/AGENTS.md.template +20 -26
- package/templates/README.md +35 -0
- package/templates/change/proposal.md.template +1 -0
- package/templates/hooks/pre-commit.sample +6 -2
- package/templates/playbooks/bootstrap.md.template +43 -0
- package/templates/playbooks/chore.md.template +19 -0
- package/templates/playbooks/work.md.template +76 -0
package/src/commands/contract.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import { getSection } from "../lib/doc-model.js";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
import process from "node:process";
|
|
5
4
|
import { readdirSync } from "node:fs";
|
|
@@ -12,10 +11,12 @@ import { flagBool } from "../lib/args.js";
|
|
|
12
11
|
import { c } from "../lib/colors.js";
|
|
13
12
|
import { suggest } from "../lib/suggest.js";
|
|
14
13
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
14
|
+
import { wantsJson, emitJson } from "../lib/json-out.js";
|
|
15
15
|
import {
|
|
16
|
-
|
|
16
|
+
parseRuntimeDeclaration, checkStructure, checkWiring, checkEmptySemantics,
|
|
17
17
|
checkEnums, checkSelectors,
|
|
18
18
|
} from "../lib/runtime.js";
|
|
19
|
+
import { artifactNameError } from "../lib/names.js";
|
|
19
20
|
|
|
20
21
|
// Contracts are the first-class home for the integration/runtime surface
|
|
21
22
|
// no single capability owns: the port map, the env/dependency contract,
|
|
@@ -30,6 +31,10 @@ const SUBCOMMANDS = ["new", "list", "check"];
|
|
|
30
31
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
31
32
|
// adding a command never requires editing the entrypoint — the gap that
|
|
32
33
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
34
|
+
// `check` builds a real payload; `new` and `list` have nothing but their
|
|
35
|
+
// prose, so they keep the captured envelope (change 0068).
|
|
36
|
+
export const jsonNative = (args) => args[0] === "check";
|
|
37
|
+
|
|
33
38
|
export const flags = { boolean: ["json", "force"], string: [] };
|
|
34
39
|
|
|
35
40
|
export async function run(positional, flags) {
|
|
@@ -53,8 +58,9 @@ export async function run(positional, flags) {
|
|
|
53
58
|
|
|
54
59
|
function contractNew(args, flags) {
|
|
55
60
|
const name = args[0];
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
const nameError = artifactNameError(name, "contract name");
|
|
62
|
+
if (nameError) {
|
|
63
|
+
console.error(c.red("error:") + ` ${nameError} (e.g. "system", "api")`);
|
|
58
64
|
return 2;
|
|
59
65
|
}
|
|
60
66
|
const force = flagBool(flags, "force", false);
|
|
@@ -114,7 +120,11 @@ function contractList() {
|
|
|
114
120
|
return 0;
|
|
115
121
|
}
|
|
116
122
|
|
|
117
|
-
function contractCheck(args,
|
|
123
|
+
function contractCheck(args, cmdFlags) {
|
|
124
|
+
const json = wantsJson(cmdFlags);
|
|
125
|
+
// `--json` means the payload IS the answer: prose on stdout ahead of it
|
|
126
|
+
// would corrupt the very output it describes.
|
|
127
|
+
const say = (...parts) => { if (!json) console.log(...parts); };
|
|
118
128
|
const projectRoot = process.cwd();
|
|
119
129
|
ensureDoctrinaProject(projectRoot);
|
|
120
130
|
const dir = path.join(projectRoot, ".doctrina", "contracts");
|
|
@@ -128,68 +138,44 @@ function contractCheck(args, _flags) {
|
|
|
128
138
|
names = [];
|
|
129
139
|
}
|
|
130
140
|
if (names.length === 0) {
|
|
131
|
-
|
|
141
|
+
say(c.gray("no contracts to check in .doctrina/contracts/"));
|
|
132
142
|
return 0;
|
|
133
143
|
}
|
|
134
144
|
|
|
135
|
-
// The env contract is checked against .env.example when one exists.
|
|
136
|
-
const envExamplePath = path.join(projectRoot, ".env.example");
|
|
137
|
-
const envExample = isFile(envExamplePath) ? read(envExamplePath) : null;
|
|
138
|
-
|
|
139
145
|
let errors = 0;
|
|
140
146
|
let warnings = 0;
|
|
147
|
+
// The runtime half is counted, not just printed. A contract that declares
|
|
148
|
+
// no Wiring and no Selectors was not checked — and the summary line is the
|
|
149
|
+
// one thing the CI log and the close print, so it is the line that has to
|
|
150
|
+
// say so (change 0029 decided the per-contract line; this is the same
|
|
151
|
+
// decision applied to the total).
|
|
152
|
+
let declaredRows = 0;
|
|
153
|
+
let uncheckedContracts = 0;
|
|
154
|
+
/** @type {Array<{contract: string, code: string, level: string, message: string, remedy: string}>} */
|
|
155
|
+
const findings = [];
|
|
156
|
+
/** @type {string[]} */
|
|
157
|
+
const unchecked = [];
|
|
141
158
|
for (const name of names) {
|
|
142
159
|
const file = path.join(dir, `${name}.md`);
|
|
143
160
|
if (!isFile(file)) {
|
|
144
|
-
|
|
161
|
+
say(c.red("error: ") + `contract "${name}" not found at ${relPath(projectRoot, file)}`);
|
|
145
162
|
errors += 1;
|
|
146
163
|
continue;
|
|
147
164
|
}
|
|
148
165
|
const text = read(file);
|
|
149
|
-
|
|
166
|
+
say(c.bold(name) + c.gray(` (${relPath(projectRoot, file)})`));
|
|
150
167
|
|
|
151
|
-
// 1.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
console.log(c.red(" ✗ ") + `port ${port} is claimed by both "${seen.get(port)}" and "${svc}"`);
|
|
163
|
-
errors += 1;
|
|
164
|
-
} else {
|
|
165
|
-
seen.set(port, svc);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// 2. Environment drift — every declared variable must exist in
|
|
171
|
-
// .env.example (when present), so code, example, and infra agree.
|
|
172
|
-
const env = parseTable(getSection(text, "Environment"));
|
|
173
|
-
if (env && envExample !== null) {
|
|
174
|
-
const varCol = colIndex(env.headers, "variable");
|
|
175
|
-
for (const row of env.rows) {
|
|
176
|
-
const name2 = (row[varCol] ?? "").trim();
|
|
177
|
-
if (!/^[A-Z][A-Z0-9_]*$/.test(name2)) continue;
|
|
178
|
-
const declared = new RegExp(`^\\s*(export\\s+)?${name2}\\s*=`, "m").test(envExample);
|
|
179
|
-
if (!declared) {
|
|
180
|
-
console.log(c.yellow(" ! ") + `env var ${name2} is in the contract but absent from .env.example`);
|
|
181
|
-
warnings += 1;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// 3. Referenced capability specs must exist.
|
|
187
|
-
for (const refCap of referencedCapabilities(getSection(text, "References"))) {
|
|
188
|
-
const specPath = path.join(projectRoot, ".doctrina", "specs", refCap, "spec.md");
|
|
189
|
-
if (!isFile(specPath)) {
|
|
190
|
-
console.log(c.red(" ✗ ") + `references spec "${refCap}" but ${relPath(projectRoot, specPath)} does not exist`);
|
|
191
|
-
errors += 1;
|
|
192
|
-
}
|
|
168
|
+
// 1-3. The STRUCTURAL half — port collisions, environment drift against
|
|
169
|
+
// .env.example, references to specs that do not exist. One
|
|
170
|
+
// collection in lib/runtime.js (change 0103), shared with the
|
|
171
|
+
// close's runtime step and `doctor`, so a contract this command
|
|
172
|
+
// fails cannot pass the close.
|
|
173
|
+
for (const f of checkStructure(projectRoot, text)) {
|
|
174
|
+
const mark = f.level === "error" ? c.red(" ✗ ") : c.yellow(" ! ");
|
|
175
|
+
say(mark + f.message + c.gray(` [${f.code}]`));
|
|
176
|
+
findings.push({ contract: name, code: f.code, level: f.level, message: f.message, remedy: f.remedy });
|
|
177
|
+
if (f.level === "error") errors += 1;
|
|
178
|
+
else warnings += 1;
|
|
193
179
|
}
|
|
194
180
|
|
|
195
181
|
// 4. The RUNTIME half: a declaration is only worth what binds it to the
|
|
@@ -208,40 +194,74 @@ function contractCheck(args, _flags) {
|
|
|
208
194
|
...checkSelectors(projectRoot, decl),
|
|
209
195
|
]) {
|
|
210
196
|
const mark = f.level === "error" ? c.red(" ✗ ") : c.yellow(" ! ");
|
|
211
|
-
|
|
212
|
-
|
|
197
|
+
say(mark + f.message + c.gray(` [${f.code}]`));
|
|
198
|
+
say(` ${c.gray(`fix: ${f.remedy}`)}`);
|
|
199
|
+
findings.push({ contract: name, code: f.code, level: f.level, message: f.message, remedy: f.remedy });
|
|
213
200
|
if (f.level === "error") errors += 1;
|
|
214
201
|
else warnings += 1;
|
|
215
202
|
}
|
|
216
203
|
|
|
217
|
-
|
|
204
|
+
const rows = decl.wiring.length + decl.selectors.length;
|
|
205
|
+
declaredRows += rows;
|
|
206
|
+
if (rows === 0) {
|
|
218
207
|
// Silence here is not proof of correctness — it is proof that nothing
|
|
219
208
|
// was declared. Saying so is what stops a green check from being read
|
|
220
209
|
// as "the wiring is verified".
|
|
221
|
-
|
|
210
|
+
uncheckedContracts += 1;
|
|
211
|
+
unchecked.push(name);
|
|
212
|
+
say(c.gray(" · no Wiring or Selectors declared — the runtime surface is unchecked"));
|
|
222
213
|
}
|
|
223
214
|
}
|
|
224
215
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
216
|
+
say("");
|
|
217
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
218
|
+
|
|
219
|
+
// The MACHINE answer, in a field of its own (change 0068). Change 0056 took
|
|
220
|
+
// the word "consistent" out of the human summary for an undeclared surface;
|
|
221
|
+
// the envelope kept saying `ok: true`, because the only machine signal was
|
|
222
|
+
// the exit code — and the exit code is 0 by the deliberate decision of
|
|
223
|
+
// change 0029 (an undeclared surface is REPORTED, not failed). Right for the
|
|
224
|
+
// status, wrong as the only signal: a consumer reading only the envelope was
|
|
225
|
+
// exactly where the human reader stood before 0056. So it branches on
|
|
226
|
+
// `unchecked`, the same way change 0061 gave deprecation a field of its own.
|
|
227
|
+
const emit = (verdict, code) => {
|
|
228
|
+
if (json) {
|
|
229
|
+
emitJson("contract check", {
|
|
230
|
+
contracts: names,
|
|
231
|
+
checked: names.length - unchecked.length,
|
|
232
|
+
unchecked,
|
|
233
|
+
declared_rows: declaredRows,
|
|
234
|
+
findings,
|
|
235
|
+
verdict,
|
|
236
|
+
}, { ok: errors === 0, exitCode: code });
|
|
237
|
+
}
|
|
238
|
+
return code;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
if (errors > 0) {
|
|
242
|
+
say(c.red("fail") + ` ${plural(errors, "error")}, ${plural(warnings, "warning")}`);
|
|
243
|
+
return emit("failed", 1);
|
|
229
244
|
}
|
|
230
|
-
const summary = `${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}`;
|
|
231
|
-
console.log((errors === 0 ? c.green("ok") : c.red("fail")) + " " + summary);
|
|
232
|
-
return errors === 0 ? 0 : 1;
|
|
233
|
-
}
|
|
234
245
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
246
|
+
// "consistent" is a claim about something that was verified. Where nothing
|
|
247
|
+
// was declared there is nothing to be consistent WITH, so the word does not
|
|
248
|
+
// appear — the same state `doctor` calls "unchecked" and `triage` calls
|
|
249
|
+
// "UNCHECKED, not verified" is not allowed to read as approval here just
|
|
250
|
+
// because this is the copy that runs in CI. It stays exit 0: change 0029
|
|
251
|
+
// decided an undeclared surface is reported, not failed.
|
|
252
|
+
if (uncheckedContracts > 0) {
|
|
253
|
+
const scope = uncheckedContracts === names.length
|
|
254
|
+
? `${plural(names.length, "contract")}`
|
|
255
|
+
: `${uncheckedContracts} of ${plural(names.length, "contract")}`;
|
|
256
|
+
const verb = uncheckedContracts === 1 && names.length === 1 ? "declares" : "declare";
|
|
257
|
+
say(c.yellow("warn") + ` ${scope} ${verb} no Wiring/Selectors rows — the runtime surface is unchecked` +
|
|
258
|
+
(warnings > 0 ? c.gray(`; ${plural(warnings, "warning")} above`) : ""));
|
|
259
|
+
return emit("unchecked", 0);
|
|
260
|
+
}
|
|
239
261
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
for (const m of text.matchAll(/specs\/([a-z][a-z0-9-]*)/g)) out.add(m[1]);
|
|
244
|
-
return [...out];
|
|
262
|
+
const held = `${plural(names.length, "contract")} consistent, ${plural(declaredRows, "declared row")} ${declaredRows === 1 ? "holds" : "hold"}`;
|
|
263
|
+
say(c.green("ok") + ` ${held}` + (warnings > 0 ? c.gray(`; ${plural(warnings, "warning")} above`) : ""));
|
|
264
|
+
return emit("consistent", 0);
|
|
245
265
|
}
|
|
246
266
|
|
|
247
267
|
function ensureDoctrinaProject(projectRoot) {
|
package/src/commands/coverage.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import { readdirSync } from "node:fs";
|
|
5
4
|
import { spawnSync } from "node:child_process";
|
|
6
|
-
import { exists,
|
|
7
|
-
import { specHeader } from "../lib/scan.js";
|
|
5
|
+
import { exists, isFile, read, relPath } from "../lib/fs-ops.js";
|
|
8
6
|
import { flagBool, flagString } from "../lib/args.js";
|
|
9
7
|
import { c } from "../lib/colors.js";
|
|
10
8
|
import { emitJson } from "../lib/json-out.js";
|
|
11
|
-
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
9
|
+
import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
|
|
10
|
+
import { suggest } from "../lib/suggest.js";
|
|
11
|
+
import { collect, summarize } from "../lib/coverage-model.js";
|
|
12
|
+
import { knownCapabilities } from "../lib/scan.js";
|
|
13
|
+
|
|
14
|
+
// The numbers this command reports come from lib/coverage-model.js, which
|
|
15
|
+
// `status`, `review`, `validate`, `close` and `spec set` read too — so no
|
|
16
|
+
// two surfaces can disagree about what "covered" means, and none of them
|
|
17
|
+
// has to import out of this file to find out (audit finding F7).
|
|
18
|
+
export { collect, summarize, deriveImplementation, derivedImplementations, implementationMismatch } from "../lib/coverage-model.js";
|
|
12
19
|
|
|
13
20
|
// Traceability report: how many acceptance criteria cite an artifact or
|
|
14
21
|
// test that actually exists on disk. Doctrina otherwise has no link
|
|
@@ -48,6 +55,28 @@ export async function run(_positional, flags) {
|
|
|
48
55
|
const onlyRaw = flagString(flags, "only");
|
|
49
56
|
const only = onlyRaw ? new Set(onlyRaw.split(",").map((s) => s.trim()).filter(Boolean)) : null;
|
|
50
57
|
|
|
58
|
+
// A filter that matches no capability is a USAGE error, not a clean gate.
|
|
59
|
+
// It used to fall through to "no acceptance criteria found under
|
|
60
|
+
// .doctrina/specs/" and exit 0 — on THIS repository, which declares over
|
|
61
|
+
// two hundred of them — `--strict` included. So a CI job running
|
|
62
|
+
// `coverage --only billing --strict` stayed green forever once the
|
|
63
|
+
// capability was renamed or split: the failure looked exactly like
|
|
64
|
+
// success. RT05 already refuses a contract selector that matches zero
|
|
65
|
+
// targets, for the same reason (change 0090).
|
|
66
|
+
if (only) {
|
|
67
|
+
const known = knownCapabilities(projectRoot);
|
|
68
|
+
const missing = [...only].filter((cap) => !known.includes(cap));
|
|
69
|
+
if (missing.length > 0) {
|
|
70
|
+
for (const cap of missing) {
|
|
71
|
+
console.error(c.red("error:") + ` --only names no capability with a spec: "${cap}"`);
|
|
72
|
+
const guess = suggest(cap, known);
|
|
73
|
+
if (guess) console.error(c.gray("hint: ") + `did you mean "${guess}"?`);
|
|
74
|
+
}
|
|
75
|
+
if (known.length > 0) console.error(c.gray("known: ") + known.sort().join(", "));
|
|
76
|
+
return EXIT.USAGE;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
51
80
|
const reports = collect(projectRoot, { only });
|
|
52
81
|
|
|
53
82
|
// --run: execute the cited evidence instead of only checking it exists —
|
|
@@ -60,10 +89,14 @@ export async function run(_positional, flags) {
|
|
|
60
89
|
|
|
61
90
|
if (reports.length === 0) {
|
|
62
91
|
if (json) {
|
|
63
|
-
emitJson("coverage", { specs: [], summary: { criteria: 0, covered: 0, dangling: 0, conditional: 0, pct:
|
|
92
|
+
emitJson("coverage", { specs: [], summary: { criteria: 0, covered: 0, dangling: 0, conditional: 0, pct: null } });
|
|
64
93
|
return 0;
|
|
65
94
|
}
|
|
66
|
-
|
|
95
|
+
// The two cases used to share one sentence, and the shared one was the
|
|
96
|
+
// false one whenever a filter was in play.
|
|
97
|
+
console.log(c.gray(only
|
|
98
|
+
? `no acceptance criteria declared by ${[...only].sort().join(", ")}`
|
|
99
|
+
: "no acceptance criteria found under .doctrina/specs/"));
|
|
67
100
|
return 0;
|
|
68
101
|
}
|
|
69
102
|
|
|
@@ -81,7 +114,8 @@ export async function run(_positional, flags) {
|
|
|
81
114
|
totalUnguarded += rep.rows.filter((r) => r.kind === "unguarded").length;
|
|
82
115
|
totalDeferred += rep.rows.filter((r) => r.kind === "deferred").length;
|
|
83
116
|
}
|
|
84
|
-
|
|
117
|
+
// null, not 100: a ratio over nothing is not a perfect score (change 0057).
|
|
118
|
+
const jsonPct = totalCriteria === 0 ? null : Math.round((totalCovered / totalCriteria) * 100);
|
|
85
119
|
// Deferred criteria are visible but never gate: declared debt ≠ hidden debt.
|
|
86
120
|
const jsonClean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0
|
|
87
121
|
&& totalConditional === 0 && totalUnguarded === 0;
|
|
@@ -112,7 +146,18 @@ export async function run(_positional, flags) {
|
|
|
112
146
|
const note = notes.length > 0 ? ` (${notes.join(", ")})` : "";
|
|
113
147
|
console.log(` ${c.cyan(rep.cap.padEnd(20))} ${covered}/${rep.rows.length} criteria${note}`);
|
|
114
148
|
for (const r of rep.rows) {
|
|
115
|
-
if (r.kind === "covered")
|
|
149
|
+
if (r.kind === "covered") {
|
|
150
|
+
// Linked is not certified (change 0105), and one citation resolving
|
|
151
|
+
// does not make the other one true (change 0106): both are said,
|
|
152
|
+
// neither lowers the count — the evidence IS linked.
|
|
153
|
+
if (r.unverified) {
|
|
154
|
+
console.log(` ${c.gray("○")} #${r.n} proof resolves but the criterion is still marked [unverified] — flip the mark when the test proves it (spec set <cap> --criterion ${r.n}:verified)`);
|
|
155
|
+
}
|
|
156
|
+
if (r.missing?.length > 0) {
|
|
157
|
+
console.log(` ${c.yellow("!")} #${r.n} also cites evidence that does not resolve: ${r.missing.map((m) => `\`${m}\``).join(", ")}`);
|
|
158
|
+
}
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
116
161
|
if (r.kind === "deferred") {
|
|
117
162
|
console.log(` ${c.gray("○")} #${r.n} deferred — spec declares "Implementation: planned — <why>" (visible, not gated)`);
|
|
118
163
|
} else if (r.kind === "bare") {
|
|
@@ -127,9 +172,12 @@ export async function run(_positional, flags) {
|
|
|
127
172
|
}
|
|
128
173
|
}
|
|
129
174
|
|
|
130
|
-
const pct = totalCriteria === 0 ?
|
|
175
|
+
const pct = totalCriteria === 0 ? null : Math.round((totalCovered / totalCriteria) * 100);
|
|
131
176
|
console.log("");
|
|
132
|
-
const
|
|
177
|
+
const specCount = `${reports.length} spec${reports.length === 1 ? "" : "s"}`;
|
|
178
|
+
const summary = pct === null
|
|
179
|
+
? `no acceptance criteria declared across ${specCount} — nothing to cover yet`
|
|
180
|
+
: `${totalCovered} of ${totalCriteria} acceptance criteria across ${specCount} have linked evidence (${pct}%)`;
|
|
133
181
|
const clean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0
|
|
134
182
|
&& totalConditional === 0 && totalUnguarded === 0;
|
|
135
183
|
const extras = [];
|
|
@@ -201,269 +249,6 @@ function runEvidence(projectRoot, reports, strict) {
|
|
|
201
249
|
return 1;
|
|
202
250
|
}
|
|
203
251
|
|
|
204
|
-
// Per-spec criterion rows — the full classification behind both the report
|
|
205
|
-
// and the --json output. Each row: { n, kind, missing?, skipped? }.
|
|
206
|
-
//
|
|
207
|
-
// A spec that declares a deliberate deferral — `Implementation: planned —
|
|
208
|
-
// <why>`, the exact escape hatch `validate` already honours — has its
|
|
209
|
-
// non-covered criteria remapped to kind "deferred": visible in every report,
|
|
210
|
-
// never a --strict failure. Declared debt and hidden debt stop being punished
|
|
211
|
-
// identically (0.11.0 field review item 4: one deferred capability poisoned
|
|
212
|
-
// the close of every unrelated change).
|
|
213
|
-
export function collect(projectRoot, { only = null } = {}) {
|
|
214
|
-
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
215
|
-
const reports = [];
|
|
216
|
-
if (isDir(specsDir)) {
|
|
217
|
-
for (const cap of readdirSync(specsDir).sort()) {
|
|
218
|
-
if (only && !only.has(cap)) continue;
|
|
219
|
-
const specPath = path.join(specsDir, cap, "spec.md");
|
|
220
|
-
if (!isFile(specPath)) continue;
|
|
221
|
-
const text = read(specPath);
|
|
222
|
-
const criteria = extractAcceptanceCriteria(text);
|
|
223
|
-
if (criteria.length === 0) continue;
|
|
224
|
-
const deferred = isDeclaredDeferral(text);
|
|
225
|
-
const rows = criteria.map((crit, i) => {
|
|
226
|
-
const row = classify(crit, i + 1, projectRoot, path.dirname(specPath));
|
|
227
|
-
if (deferred && row.kind !== "covered") {
|
|
228
|
-
return { ...row, kind: "deferred", was: row.kind };
|
|
229
|
-
}
|
|
230
|
-
return row;
|
|
231
|
-
});
|
|
232
|
-
reports.push({ cap, specPath, rows, deferred });
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
return reports;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// The declared-deferral escape hatch, matching validate's two-axis check:
|
|
239
|
-
// Implementation is "planned" WITH an explanatory note after the state word.
|
|
240
|
-
// A bare "planned" is an inventory claim, not a deferral, and gets no pass.
|
|
241
|
-
function isDeclaredDeferral(specText) {
|
|
242
|
-
const implRaw = specHeader(specText, "Implementation");
|
|
243
|
-
if (!implRaw) return false;
|
|
244
|
-
const tokens = implRaw.trim().split(/\s+/);
|
|
245
|
-
const word = (tokens[0] ?? "").replace(/[—-]+$/, "").toLowerCase();
|
|
246
|
-
return word === "planned" && tokens.length > 1;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Pure summary of coverage across the spec tree, for other commands
|
|
250
|
-
// (`status`, `review`) that need the numbers without the report output.
|
|
251
|
-
// Deferred criteria (declared deferral, see collect) are counted separately
|
|
252
|
-
// and excluded from the problem counts, matching the gate semantics.
|
|
253
|
-
export function summarize(projectRoot) {
|
|
254
|
-
let totalCriteria = 0, totalCovered = 0, totalDangling = 0, totalConditional = 0, totalDeferred = 0;
|
|
255
|
-
const perCap = [];
|
|
256
|
-
for (const rep of collect(projectRoot)) {
|
|
257
|
-
const covered = rep.rows.filter((r) => r.kind === "covered").length;
|
|
258
|
-
const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
|
|
259
|
-
const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
|
|
260
|
-
const unguarded = rep.rows.filter((r) => r.kind === "unguarded").length;
|
|
261
|
-
const deferred = rep.rows.filter((r) => r.kind === "deferred").length;
|
|
262
|
-
totalCriteria += rep.rows.length;
|
|
263
|
-
totalCovered += covered;
|
|
264
|
-
totalDangling += dangling;
|
|
265
|
-
totalConditional += conditional;
|
|
266
|
-
totalDeferred += deferred;
|
|
267
|
-
perCap.push({ cap: rep.cap, total: rep.rows.length, covered, dangling, conditional, unguarded, deferred });
|
|
268
|
-
}
|
|
269
|
-
const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
|
|
270
|
-
return { perCap, totalCriteria, totalCovered, totalDangling, totalConditional, totalDeferred, pct };
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Pull the numbered items out of the "## Acceptance criteria" section.
|
|
274
|
-
// Each item may span multiple lines (continuation prose); accumulate until
|
|
275
|
-
// the next number or the next "## " heading. Returns an array of strings.
|
|
276
|
-
function extractAcceptanceCriteria(text) {
|
|
277
|
-
const lines = text.split(/\r?\n/);
|
|
278
|
-
const out = [];
|
|
279
|
-
let inSection = false;
|
|
280
|
-
let buf = null;
|
|
281
|
-
const flush = () => {
|
|
282
|
-
if (buf !== null) out.push(buf.trim());
|
|
283
|
-
buf = null;
|
|
284
|
-
};
|
|
285
|
-
for (const line of lines) {
|
|
286
|
-
if (/^##\s+/.test(line)) {
|
|
287
|
-
// Entering or leaving a section.
|
|
288
|
-
if (inSection) {
|
|
289
|
-
flush();
|
|
290
|
-
inSection = false;
|
|
291
|
-
}
|
|
292
|
-
if (/^##\s+Acceptance criteria\b/i.test(line)) inSection = true;
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
295
|
-
if (!inSection) continue;
|
|
296
|
-
if (/^\s*\d+\.\s+/.test(line)) {
|
|
297
|
-
flush();
|
|
298
|
-
buf = line.replace(/^\s*\d+\.\s+/, "");
|
|
299
|
-
} else if (buf !== null) {
|
|
300
|
-
// Continuation line of the current criterion.
|
|
301
|
-
if (line.trim() === "") buf += " ";
|
|
302
|
-
else buf += " " + line.trim();
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
flush();
|
|
306
|
-
// Drop empty placeholder items (a lone "1." with no text).
|
|
307
|
-
return out.filter((s) => s.length > 0);
|
|
308
|
-
}
|
|
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
|
-
|
|
379
|
-
// Decide whether a single criterion is covered, conditional, dangling, or bare.
|
|
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
|
-
|
|
388
|
-
const cited = extractBacktickPaths(criterion);
|
|
389
|
-
if (cited.length === 0) return { kind: "bare", n };
|
|
390
|
-
const missing = [];
|
|
391
|
-
const resolved = [];
|
|
392
|
-
for (const token of cited) {
|
|
393
|
-
const candidates = [
|
|
394
|
-
path.resolve(projectRoot, token),
|
|
395
|
-
path.resolve(specDir, token),
|
|
396
|
-
];
|
|
397
|
-
const hit = candidates.find(exists);
|
|
398
|
-
if (!hit) {
|
|
399
|
-
missing.push(token);
|
|
400
|
-
continue;
|
|
401
|
-
}
|
|
402
|
-
const isTest = looksLikeTestFile(token);
|
|
403
|
-
const skipped = isTest && isFile(hit) ? testSuiteIsSkipped(read(hit)) : false;
|
|
404
|
-
resolved.push({ token, isTest, skipped });
|
|
405
|
-
}
|
|
406
|
-
if (resolved.length === 0) return { kind: "dangling", n, missing };
|
|
407
|
-
// Runnable evidence: the resolving test-shaped citations, recorded so
|
|
408
|
-
// `coverage --run` can execute them (existence → passing proof).
|
|
409
|
-
const evidence = resolved.filter((r) => r.isTest && !r.skipped).map((r) => r.token);
|
|
410
|
-
// Real proof = a non-test artifact, or a test file whose suite runs. If the
|
|
411
|
-
// only thing that resolves is a skipped test, the criterion is conditional.
|
|
412
|
-
const hasRealProof = resolved.some((r) => !r.isTest || !r.skipped);
|
|
413
|
-
if (hasRealProof) return { kind: "covered", n, evidence };
|
|
414
|
-
return { kind: "conditional", n, skipped: resolved.map((r) => r.token), evidence: [] };
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// A cited path is a test file when it sits under a tests directory or carries
|
|
418
|
-
// a test/spec/e2e filename marker — the only files skip-detection applies to.
|
|
419
|
-
function looksLikeTestFile(token) {
|
|
420
|
-
if (/(?:^|\/)(?:tests?|__tests__|specs?|e2e)\//i.test(token)) return true;
|
|
421
|
-
if (/(?:\.|_|-)(?:test|spec|e2e)\.[a-z0-9]+$/i.test(token)) return true;
|
|
422
|
-
if (/(?:^|\/)test_[^/]+\.py$/i.test(token)) return true;
|
|
423
|
-
return false;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// Heuristic, dependency-free "is this whole test file gated off?" check: the
|
|
427
|
-
// first test construct in the file is a skip/todo (e.g. the Prisma e2e suite
|
|
428
|
-
// wrapped in `describe.skip`). A file whose first suite runs is treated as
|
|
429
|
-
// real proof even if it has an incidental `it.skip` later — that keeps the
|
|
430
|
-
// false-positive rate low without parsing the file. Python skip decorators
|
|
431
|
-
// and a module-level `pytest.skip` count too.
|
|
432
|
-
function testSuiteIsSkipped(text) {
|
|
433
|
-
const m = text.match(
|
|
434
|
-
/\b(x(?:describe|context|it|test)|(?:describe|context|suite|it|test|specify)\s*\.\s*(?:skip|todo)|(?:describe|context|suite|it|test|specify))\s*\(/,
|
|
435
|
-
);
|
|
436
|
-
if (m) {
|
|
437
|
-
const head = m[1];
|
|
438
|
-
if (/^x/.test(head) || /\.\s*(?:skip|todo)/.test(head)) return true;
|
|
439
|
-
}
|
|
440
|
-
if (/@(?:pytest\.mark\.skip|unittest\.skip)\b/.test(text)) return true;
|
|
441
|
-
if (/^\s*pytest\.skip\s*\(/m.test(text)) return true;
|
|
442
|
-
return false;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// Backtick spans that look like repository file paths. Mirrors the
|
|
446
|
-
// validate stale-link heuristic: a token is a path if it has a slash or a
|
|
447
|
-
// file extension and is not a URL/placeholder.
|
|
448
|
-
function extractBacktickPaths(text) {
|
|
449
|
-
const out = new Set();
|
|
450
|
-
for (const m of text.matchAll(/`([^`]+)`/g)) {
|
|
451
|
-
const token = m[1].trim();
|
|
452
|
-
if (looksLikePath(token)) out.add(token);
|
|
453
|
-
}
|
|
454
|
-
return [...out];
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
function looksLikePath(s) {
|
|
458
|
-
if (!s) return false;
|
|
459
|
-
if (/^(https?:|mailto:|ftp:|#|@)/i.test(s)) return false;
|
|
460
|
-
if (/[\s<>*?{}]/.test(s)) return false;
|
|
461
|
-
const hasSlash = s.includes("/");
|
|
462
|
-
const hasExt = /\.[a-z0-9]{1,8}$/i.test(s);
|
|
463
|
-
if (!hasSlash && !hasExt) return false;
|
|
464
|
-
if (s.startsWith("-")) return false;
|
|
465
|
-
return true;
|
|
466
|
-
}
|
|
467
252
|
|
|
468
253
|
export const help = `
|
|
469
254
|
Usage: doctrina coverage [--strict] [--only <cap,cap>] [--run] [--json]
|