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.
Files changed (84) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +24 -1
  4. package/src/commands/analyze.js +8 -212
  5. package/src/commands/change.js +143 -217
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +58 -21
  8. package/src/commands/close.js +291 -42
  9. package/src/commands/constitution.js +14 -91
  10. package/src/commands/context.js +278 -107
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +54 -7
  14. package/src/commands/doctor.js +292 -129
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +11 -2
  17. package/src/commands/index-rebuild.js +9 -48
  18. package/src/commands/init.js +70 -13
  19. package/src/commands/intake.js +30 -92
  20. package/src/commands/metrics.js +53 -84
  21. package/src/commands/next.js +105 -147
  22. package/src/commands/prime.js +24 -91
  23. package/src/commands/report.js +50 -108
  24. package/src/commands/review.js +68 -36
  25. package/src/commands/search.js +18 -4
  26. package/src/commands/show.js +24 -9
  27. package/src/commands/skill.js +16 -25
  28. package/src/commands/spec.js +26 -5
  29. package/src/commands/status.js +51 -172
  30. package/src/commands/templates.js +31 -138
  31. package/src/commands/trace.js +36 -71
  32. package/src/commands/triage.js +4 -82
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +60 -138
  35. package/src/commands/why.js +2 -2
  36. package/src/commands/work.js +234 -263
  37. package/src/index.js +106 -7
  38. package/src/lib/actions.js +484 -0
  39. package/src/lib/adapters.js +0 -11
  40. package/src/lib/adr-guard.js +19 -0
  41. package/src/lib/agent-changelog.js +133 -0
  42. package/src/lib/analysis.js +264 -0
  43. package/src/lib/change-ops.js +131 -0
  44. package/src/lib/commands.js +56 -4
  45. package/src/lib/config.js +206 -0
  46. package/src/lib/constitution-model.js +102 -0
  47. package/src/lib/coverage-model.js +392 -0
  48. package/src/lib/criteria.js +23 -0
  49. package/src/lib/doc-model.js +326 -0
  50. package/src/lib/docs-impact.js +196 -27
  51. package/src/lib/flag-catalog.js +0 -16
  52. package/src/lib/fs-ops.js +58 -1
  53. package/src/lib/gates.js +181 -23
  54. package/src/lib/git.js +132 -0
  55. package/src/lib/index-json.js +2 -19
  56. package/src/lib/intake-model.js +86 -0
  57. package/src/lib/json-out.js +100 -2
  58. package/src/lib/ledger.js +192 -0
  59. package/src/lib/lexicon.js +191 -0
  60. package/src/lib/metrics-model.js +159 -0
  61. package/src/lib/names.js +42 -0
  62. package/src/lib/playbook.js +97 -0
  63. package/src/lib/project.js +57 -0
  64. package/src/lib/reproducibility.js +137 -0
  65. package/src/lib/runtime.js +191 -5
  66. package/src/lib/scan.js +204 -13
  67. package/src/lib/signoff.js +147 -0
  68. package/src/lib/snapshot.js +209 -0
  69. package/src/lib/spec-ops.js +50 -5
  70. package/src/lib/templates-model.js +262 -0
  71. package/src/lib/trace-model.js +95 -0
  72. package/src/lib/triage-model.js +121 -0
  73. package/src/lib/usage.js +6 -4
  74. package/src/lib/validation-model.js +1299 -0
  75. package/src/lib/version.js +22 -0
  76. package/src/lib/views.js +410 -0
  77. package/src/lib/work-model.js +77 -0
  78. package/templates/AGENTS.md.template +20 -26
  79. package/templates/README.md +35 -0
  80. package/templates/change/proposal.md.template +1 -0
  81. package/templates/hooks/pre-commit.sample +6 -2
  82. package/templates/playbooks/bootstrap.md.template +43 -0
  83. package/templates/playbooks/chore.md.template +19 -0
  84. package/templates/playbooks/work.md.template +76 -0
@@ -0,0 +1,392 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { readdirSync } from "node:fs";
4
+ import { exists, isDir, isFile, read } from "./fs-ops.js";
5
+ import { specHeader } from "./scan.js";
6
+ import { parseAcceptanceCriteria } from "./criteria.js";
7
+
8
+ // The coverage MODEL: how a spec's acceptance criteria are classified, what
9
+ // the tree's coverage adds up to, and the implementation state that
10
+ // arithmetic supports.
11
+ //
12
+ // It lives in lib/ rather than in `commands/coverage.js` because five other
13
+ // surfaces need these numbers — `status`, `review`, `validate`, `close` and
14
+ // `spec set --implementation auto` — and a command module is a RENDERER, not
15
+ // a place other commands reach into for data (audit finding F7). Before this
16
+ // split, six command modules imported functions out of each other's files;
17
+ // a test now forbids the pattern outright.
18
+ //
19
+ // Nothing here prints, and nothing here writes.
20
+
21
+ // Per-spec criterion rows — the full classification behind both the report
22
+ // and the --json output. Each row: { n, kind, missing?, skipped? }.
23
+ //
24
+ // A spec that declares a deliberate deferral — `Implementation: planned —
25
+ // <why>`, the exact escape hatch `validate` already honours — has its
26
+ // non-covered criteria remapped to kind "deferred": visible in every report,
27
+ // never a --strict failure. Declared debt and hidden debt stop being punished
28
+ // identically (0.11.0 field review item 4: one deferred capability poisoned
29
+ // the close of every unrelated change).
30
+ export function collect(projectRoot, { only = null } = {}) {
31
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
32
+ const reports = [];
33
+ if (isDir(specsDir)) {
34
+ for (const cap of readdirSync(specsDir).sort()) {
35
+ if (only && !only.has(cap)) continue;
36
+ const specPath = path.join(specsDir, cap, "spec.md");
37
+ if (!isFile(specPath)) continue;
38
+ const text = read(specPath);
39
+ const criteria = parseAcceptanceCriteria(text).map((row) => row.body);
40
+ if (criteria.length === 0) continue;
41
+ const deferred = isDeclaredDeferral(text);
42
+ const rows = criteria.map((crit, i) => {
43
+ const row = classify(crit, i + 1, projectRoot, path.dirname(specPath));
44
+ if (deferred && row.kind !== "covered") {
45
+ return { ...row, kind: "deferred", was: row.kind };
46
+ }
47
+ return row;
48
+ });
49
+ reports.push({ cap, specPath, rows, deferred });
50
+ }
51
+ }
52
+ return reports;
53
+ }
54
+
55
+ // The declared-deferral escape hatch, matching validate's two-axis check:
56
+ // Implementation is "planned" WITH an explanatory note after the state word.
57
+ // A bare "planned" is an inventory claim, not a deferral, and gets no pass.
58
+ function isDeclaredDeferral(specText) {
59
+ const implRaw = specHeader(specText, "Implementation");
60
+ if (!implRaw) return false;
61
+ const tokens = implRaw.trim().split(/\s+/);
62
+ const word = (tokens[0] ?? "").replace(/[—-]+$/, "").toLowerCase();
63
+ return word === "planned" && tokens.length > 1;
64
+ }
65
+
66
+ // Pure summary of coverage across the spec tree, for other commands
67
+ // (`status`, `review`) that need the numbers without the report output.
68
+ // Deferred criteria (declared deferral, see collect) are counted separately
69
+ // and excluded from the problem counts, matching the gate semantics.
70
+ export function summarize(projectRoot) {
71
+ let totalCriteria = 0, totalCovered = 0, totalDangling = 0, totalConditional = 0, totalDeferred = 0;
72
+ const perCap = [];
73
+ for (const rep of collect(projectRoot)) {
74
+ const covered = rep.rows.filter((r) => r.kind === "covered").length;
75
+ const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
76
+ const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
77
+ const unguarded = rep.rows.filter((r) => r.kind === "unguarded").length;
78
+ const deferred = rep.rows.filter((r) => r.kind === "deferred").length;
79
+ totalCriteria += rep.rows.length;
80
+ totalCovered += covered;
81
+ totalDangling += dangling;
82
+ totalConditional += conditional;
83
+ totalDeferred += deferred;
84
+ perCap.push({ cap: rep.cap, total: rep.rows.length, covered, dangling, conditional, unguarded, deferred });
85
+ }
86
+ // Absence is not approval (change 0057). Zero criteria used to project to
87
+ // 100%, so the first number a new project read about itself was a perfect
88
+ // score over nothing — and `doctor`, reading this same collection, warned.
89
+ // `null` is the honest value: there is no ratio, and every view renders
90
+ // that as "no criteria declared" the way trace already renders no anchors.
91
+ const pct = totalCriteria === 0 ? null : Math.round((totalCovered / totalCriteria) * 100);
92
+ return { perCap, totalCriteria, totalCovered, totalDangling, totalConditional, totalDeferred, pct };
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // The derived Implementation state (audit finding F10)
97
+ // ---------------------------------------------------------------------------
98
+ //
99
+ // `**Implementation:**` was maintained from memory, and the `work` playbook
100
+ // asked the agent TWICE to advance a field whose correct value was already
101
+ // computed in the file next door: coverage knows, per spec, how many criteria
102
+ // cite proof that resolves — which is what "verified" means.
103
+ //
104
+ // So the value is derived, and only PROPOSED. Nothing here rewrites a header:
105
+ // `close` prints the `set-header` op, `validate` warns when the written value
106
+ // contradicts the arithmetic, and `spec set --implementation auto` applies it
107
+ // when a human or agent asks for it. A gate that silently edited the claim it
108
+ // is checking would be marking its own homework.
109
+ //
110
+ // "Resolves on disk" is the bar, not "was executed": `coverage --run` is the
111
+ // opt-in that runs the proof, and making a structural read depend on a test
112
+ // run would put a test suite inside `validate`. A criterion whose only proof
113
+ // is a skipped suite is already `conditional`, so it never counts as covered.
114
+
115
+ /**
116
+ * The Implementation state a spec's coverage supports.
117
+ *
118
+ * verified every criterion is covered — no dangling, conditional or
119
+ * unguarded row, and at least one criterion exists.
120
+ * partial at least one criterion is covered, but not all.
121
+ * planned none is.
122
+ *
123
+ * A spec with no acceptance criteria has nothing to derive from, so it
124
+ * returns null and every surface stays quiet about it.
125
+ *
126
+ * @returns {"verified"|"implemented"|"partial"|"planned"|null}
127
+ */
128
+ export function deriveImplementation(row) {
129
+ if (!row || row.total === 0) return null;
130
+ const problems = row.dangling + row.conditional + row.unguarded + row.deferred;
131
+ if (row.covered === row.total && problems === 0) {
132
+ // Every proof resolves — but a criterion the author still marks
133
+ // [unverified] is linked, not certified (change 0105). The ladder's
134
+ // rung for "the code is there; I have not certified it" is
135
+ // `implemented`, and the arithmetic may not climb past the mark.
136
+ return (row.unverified ?? 0) > 0 ? "implemented" : "verified";
137
+ }
138
+ return row.covered > 0 ? "partial" : "planned";
139
+ }
140
+
141
+ /**
142
+ * The derived state per capability, keyed by capability name — the one
143
+ * arithmetic `validate`, `close` and `spec set --implementation auto` all
144
+ * read, so the three can never propose different values.
145
+ *
146
+ * @returns {Map<string, {derived: string, covered: number, total: number, deferred: boolean, unverified: number}>}
147
+ */
148
+ export function derivedImplementations(projectRoot, { only = null } = {}) {
149
+ const out = new Map();
150
+ for (const rep of collect(projectRoot, { only })) {
151
+ const row = {
152
+ total: rep.rows.length,
153
+ covered: rep.rows.filter((r) => r.kind === "covered").length,
154
+ dangling: rep.rows.filter((r) => r.kind === "dangling").length,
155
+ conditional: rep.rows.filter((r) => r.kind === "conditional").length,
156
+ unguarded: rep.rows.filter((r) => r.kind === "unguarded").length,
157
+ deferred: rep.rows.filter((r) => r.kind === "deferred").length,
158
+ unverified: rep.rows.filter((r) => r.kind === "covered" && r.unverified).length,
159
+ };
160
+ const derived = deriveImplementation(row);
161
+ if (derived) out.set(rep.cap, { derived, covered: row.covered, total: row.total, deferred: rep.deferred, unverified: row.unverified });
162
+ }
163
+ return out;
164
+ }
165
+
166
+ /**
167
+ * Whether a written Implementation header contradicts what coverage supports,
168
+ * and the op that would settle it.
169
+ *
170
+ * Two exemptions, both deliberate:
171
+ *
172
+ * 1. A state carrying a NOTE (`planned — backend deferred, see ADR 0007`).
173
+ * That is the declared-deferral escape hatch the coverage gate already
174
+ * honours, generalised: a note is where a human explains why the
175
+ * arithmetic is not the whole story, and prose written on purpose is not
176
+ * overruled by a count.
177
+ * 2. `implemented` where the arithmetic supports `verified`. The ladder is
178
+ * planned -> partial -> implemented -> verified, and `implemented` is the
179
+ * rung that says "the code is there; I have not certified it". Understating
180
+ * by exactly that rung is the ladder working, not a stale header.
181
+ *
182
+ * Everything else that disagrees is reported — in BOTH directions. Claiming
183
+ * `verified` with half the criteria bare is the obvious dishonesty; leaving
184
+ * `planned` on a fully proven capability is the one that actually happens,
185
+ * and it makes every reader distrust the field.
186
+ */
187
+ export function implementationMismatch(written, derived) {
188
+ if (!derived) return null;
189
+ const raw = (written ?? "").trim();
190
+ if (!raw) return { written: null, derived, op: `set-header Implementation: ${derived}` };
191
+ const tokens = raw.split(/\s+/);
192
+ const word = (tokens[0] ?? "").replace(/[—-]+$/, "").toLowerCase();
193
+ if (tokens.length > 1) return null;
194
+ if (word === derived) return null;
195
+ if (word === "implemented" && derived === "verified") return null;
196
+ return { written: word, derived, op: `set-header Implementation: ${derived}` };
197
+ }
198
+
199
+ // The criteria parser lives in ONE place (`lib/criteria.js`), whose own
200
+ // header has always claimed exactly that. It did not: this module carried a
201
+ // second implementation, so `coverage` and `show`/`why`/`validate` could
202
+ // disagree about what a criterion is — and did, the moment fences started
203
+ // being skipped in one of them (third audit, finding 4).
204
+
205
+ // ORCHESTRATION criteria (change 0029). Coverage measures CITATION: a
206
+ // criterion is covered when it cites a file that exists (and, since G3, a
207
+ // test whose suite is not skipped). That is the right test for "this
208
+ // function behaves", and the wrong one for "the pipeline ran at all".
209
+ //
210
+ // A criterion like "absence of the report is explicit and the step does not
211
+ // fail" is satisfied, on paper, by a job that executed zero cases and
212
+ // printed a well-written empty state — the citation resolves, the suite is
213
+ // not skipped, and the only visible signal is a tidy message saying nothing
214
+ // happened. Coverage calls that proven.
215
+ //
216
+ // Marking a criterion `[orchestration]` says: the claim is that a RUN
217
+ // happened, so the proof must be a fail-closed one. It cites a verify check
218
+ // by name (`verify:<check>`), and that check must declare an `expect` guard
219
+ // — the thing that turns "exit 0" into "exit 0 having actually done
220
+ // something". A named check without a guard is UNGUARDED: the criterion is
221
+ // not proven, and says so, instead of quietly counting as covered.
222
+ const ORCHESTRATION_MARKER = /^orchestration\b/;
223
+
224
+ function citedVerifyChecks(criterion) {
225
+ const out = new Set();
226
+ for (const m of criterion.matchAll(/`verify:([A-Za-z0-9_.-]+)`/g)) out.add(m[1]);
227
+ return [...out];
228
+ }
229
+
230
+ function guardedVerifyChecks(projectRoot) {
231
+ const configPath = path.join(projectRoot, ".doctrina", "verify.json");
232
+ if (!isFile(configPath)) return null;
233
+ let config;
234
+ try {
235
+ config = JSON.parse(read(configPath));
236
+ } catch {
237
+ return null;
238
+ }
239
+ const map = new Map();
240
+ for (const ch of Array.isArray(config?.checks) ? config.checks : []) {
241
+ if (!ch?.name) continue;
242
+ const exp = ch.expect;
243
+ const guarded = Boolean(exp && typeof exp === "object"
244
+ && (typeof exp.fail_if_output_matches === "string" || typeof exp.require_output_matches === "string"));
245
+ map.set(ch.name, guarded);
246
+ }
247
+ return map;
248
+ }
249
+
250
+ function classifyOrchestration(criterion, n, projectRoot) {
251
+ const named = citedVerifyChecks(criterion);
252
+ if (named.length === 0) {
253
+ return { kind: "unguarded", n, reason: "cites no verify check — an orchestration claim is proven by a fail-closed check, cited as `verify:<check>`" };
254
+ }
255
+ const checks = guardedVerifyChecks(projectRoot);
256
+ if (checks === null) {
257
+ return { kind: "dangling", n, missing: named.map((x) => `verify:${x}`) };
258
+ }
259
+ const unknown = named.filter((x) => !checks.has(x));
260
+ if (unknown.length > 0) {
261
+ return { kind: "dangling", n, missing: unknown.map((x) => `verify:${x}`) };
262
+ }
263
+ const unguarded = named.filter((x) => !checks.get(x));
264
+ if (unguarded.length > 0) {
265
+ return {
266
+ kind: "unguarded",
267
+ n,
268
+ 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`,
269
+ };
270
+ }
271
+ return { kind: "covered", n, evidence: [] };
272
+ }
273
+
274
+ // Decide whether a single criterion is covered, conditional, dangling, or bare.
275
+ function classify(criterion, n, projectRoot, specDir) {
276
+ // An orchestration criterion is judged on its GUARD, not on whether a
277
+ // cited file exists — the whole point is that existence proves nothing here.
278
+ const marker = criterion.match(/^\[([^\]]+)\]/)?.[1]?.toLowerCase() ?? "";
279
+ if (ORCHESTRATION_MARKER.test(marker)) {
280
+ return classifyOrchestration(criterion, n, projectRoot);
281
+ }
282
+
283
+ const cited = extractBacktickPaths(criterion);
284
+ if (cited.length === 0) return { kind: "bare", n };
285
+ const missing = [];
286
+ const resolved = [];
287
+ // The author's own mark, carried on the row (change 0105): a criterion
288
+ // marked [unverified] whose proof resolves is evidence LINKED, not a
289
+ // certification — the mark is flipped when the test proves it, and until
290
+ // then the derived Implementation may not read "verified" off it.
291
+ const unverified = /^unverified\b/.test(marker);
292
+ // Proof lives in the project (change 0106). `path.resolve` happily
293
+ // followed `../other/app.py` and `C:/Windows/notepad.exe` out of the tree,
294
+ // and `exists` accepted a directory — `tests/` — as the file that proves a
295
+ // claim. A path outside the root, or a directory, is not evidence: it is
296
+ // reported as missing with the reason, and the criterion is not covered
297
+ // by it.
298
+ const root = path.resolve(projectRoot);
299
+ const inside = (abs) => abs === root || abs.startsWith(root + path.sep);
300
+ // Directories and bare names cited next to a resolving proof are prose
301
+ // MENTIONS ("every file under `src/`"), not claims of evidence; they are
302
+ // not reported on a covered row. Alone, they leave the criterion dangling.
303
+ const mentions = [];
304
+ for (const token of cited) {
305
+ const candidates = path.isAbsolute(token)
306
+ ? [path.resolve(token)]
307
+ : [path.resolve(projectRoot, token), path.resolve(specDir, token)];
308
+ const hit = candidates.find(exists);
309
+ if (!hit) {
310
+ missing.push(token);
311
+ continue;
312
+ }
313
+ if (!inside(hit)) {
314
+ missing.push(`${token} (outside the project)`);
315
+ continue;
316
+ }
317
+ if (!isFile(hit)) {
318
+ missing.push(`${token} (a directory, not a file)`);
319
+ mentions.push(token);
320
+ continue;
321
+ }
322
+ const isTest = looksLikeTestFile(token);
323
+ const skipped = isTest && isFile(hit) ? testSuiteIsSkipped(read(hit)) : false;
324
+ resolved.push({ token, isTest, skipped });
325
+ }
326
+ if (resolved.length === 0) return { kind: "dangling", n, missing };
327
+ // Runnable evidence: the resolving test-shaped citations, recorded so
328
+ // `coverage --run` can execute them (existence → passing proof).
329
+ const evidence = resolved.filter((r) => r.isTest && !r.skipped).map((r) => r.token);
330
+ // Real proof = a non-test artifact, or a test file whose suite runs. If the
331
+ // only thing that resolves is a skipped test, the criterion is conditional.
332
+ const hasRealProof = resolved.some((r) => !r.isTest || !r.skipped);
333
+ // `missing` rides along on a covered row: one citation resolving does
334
+ // not make the other one true, and the report names it (change 0106).
335
+ if (hasRealProof) {
336
+ const claims = missing.filter((m) =>
337
+ !mentions.some((d) => m.startsWith(`${d} `)) && /[\\/]/.test(m) && !/[\\/]$/.test(m));
338
+ return { kind: "covered", n, evidence, unverified, missing: claims };
339
+ }
340
+ return { kind: "conditional", n, skipped: resolved.map((r) => r.token), evidence: [], unverified, missing };
341
+ }
342
+
343
+ // A cited path is a test file when it sits under a tests directory or carries
344
+ // a test/spec/e2e filename marker — the only files skip-detection applies to.
345
+ function looksLikeTestFile(token) {
346
+ if (/(?:^|\/)(?:tests?|__tests__|specs?|e2e)\//i.test(token)) return true;
347
+ if (/(?:\.|_|-)(?:test|spec|e2e)\.[a-z0-9]+$/i.test(token)) return true;
348
+ if (/(?:^|\/)test_[^/]+\.py$/i.test(token)) return true;
349
+ return false;
350
+ }
351
+
352
+ // Heuristic, dependency-free "is this whole test file gated off?" check: the
353
+ // first test construct in the file is a skip/todo (e.g. the Prisma e2e suite
354
+ // wrapped in `describe.skip`). A file whose first suite runs is treated as
355
+ // real proof even if it has an incidental `it.skip` later — that keeps the
356
+ // false-positive rate low without parsing the file. Python skip decorators
357
+ // and a module-level `pytest.skip` count too.
358
+ function testSuiteIsSkipped(text) {
359
+ const m = text.match(
360
+ /\b(x(?:describe|context|it|test)|(?:describe|context|suite|it|test|specify)\s*\.\s*(?:skip|todo)|(?:describe|context|suite|it|test|specify))\s*\(/,
361
+ );
362
+ if (m) {
363
+ const head = m[1];
364
+ if (/^x/.test(head) || /\.\s*(?:skip|todo)/.test(head)) return true;
365
+ }
366
+ if (/@(?:pytest\.mark\.skip|unittest\.skip)\b/.test(text)) return true;
367
+ if (/^\s*pytest\.skip\s*\(/m.test(text)) return true;
368
+ return false;
369
+ }
370
+
371
+ // Backtick spans that look like repository file paths. Mirrors the
372
+ // validate stale-link heuristic: a token is a path if it has a slash or a
373
+ // file extension and is not a URL/placeholder.
374
+ function extractBacktickPaths(text) {
375
+ const out = new Set();
376
+ for (const m of text.matchAll(/`([^`]+)`/g)) {
377
+ const token = m[1].trim();
378
+ if (looksLikePath(token)) out.add(token);
379
+ }
380
+ return [...out];
381
+ }
382
+
383
+ function looksLikePath(s) {
384
+ if (!s) return false;
385
+ if (/^(https?:|mailto:|ftp:|#|@)/i.test(s)) return false;
386
+ if (/[\s<>*?{}]/.test(s)) return false;
387
+ const hasSlash = s.includes("/");
388
+ const hasExt = /\.[a-z0-9]{1,8}$/i.test(s);
389
+ if (!hasSlash && !hasExt) return false;
390
+ if (s.startsWith("-")) return false;
391
+ return true;
392
+ }
@@ -18,6 +18,13 @@ export function parseAcceptanceCriteria(text) {
18
18
  let inSection = false;
19
19
  const rows = [];
20
20
  let cur = null;
21
+ // The opening marker while inside a fenced block, else null. A spec that
22
+ // documents the criterion format writes an EXAMPLE criterion inside a
23
+ // fence, and without this the example counted: a `[verified]` sample
24
+ // citing `pkg/x.js` inflated coverage and `show <cap>-C2` answered with
25
+ // the example instead of the criterion (third audit, finding 4). Content
26
+ // inside a fence is a picture of a criterion, never one.
27
+ let fence = null;
21
28
  const flush = () => {
22
29
  if (cur) {
23
30
  cur.proofPaths = backtickPaths(cur.body);
@@ -26,6 +33,22 @@ export function parseAcceptanceCriteria(text) {
26
33
  cur = null;
27
34
  };
28
35
  for (const line of lines) {
36
+ // Fence state is tracked over the WHOLE file, not just the section: an
37
+ // earlier fence containing a `## Acceptance criteria` line must not be
38
+ // mistaken for the section starting.
39
+ const marker = line.match(/^\s*(`{3,}|~{3,})/);
40
+ if (fence) {
41
+ // Closed by the same character, at least as long, alone on the line.
42
+ if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length
43
+ && line.trim() === marker[1]) {
44
+ fence = null;
45
+ }
46
+ continue;
47
+ }
48
+ if (marker) {
49
+ fence = marker[1];
50
+ continue;
51
+ }
29
52
  if (/^##\s+/.test(line)) {
30
53
  if (inSection) {
31
54
  flush();