cadet-agent 0.31.0 → 0.32.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 CHANGED
@@ -1,37 +1,37 @@
1
- {
2
- "name": "cadet-agent",
3
- "version": "0.31.0",
4
- "description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
5
- "type": "module",
6
- "bin": {
7
- "cadet-agent": "bin/cli.mjs"
8
- },
9
- "scripts": {
10
- "test": "node --test test/*.test.mjs",
11
- "lint": "lychee --offline --include-fragments \"**/*.md\"",
12
- "verify": "npm test && npm run lint"
13
- },
14
- "files": [
15
- "bin/",
16
- "src/"
17
- ],
18
- "keywords": [
19
- "cadet",
20
- "cadet-agent",
21
- "unity",
22
- "game-development",
23
- "ai-agent",
24
- "copilot",
25
- "cursor",
26
- "claude-code"
27
- ],
28
- "license": "CC-BY-4.0",
29
- "repository": {
30
- "type": "git",
31
- "url": "git+https://github.com/naishtech/cadet-agent.git"
32
- },
33
- "homepage": "https://github.com/naishtech/cadet-agent#readme",
34
- "engines": {
35
- "node": ">=18.0.0"
36
- }
37
- }
1
+ {
2
+ "name": "cadet-agent",
3
+ "version": "0.32.0",
4
+ "description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
5
+ "type": "module",
6
+ "bin": {
7
+ "cadet-agent": "bin/cli.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test test/*.test.mjs",
11
+ "lint": "lychee --offline --include-fragments \"**/*.md\"",
12
+ "verify": "npm test && npm run lint"
13
+ },
14
+ "files": [
15
+ "bin/",
16
+ "src/"
17
+ ],
18
+ "keywords": [
19
+ "cadet",
20
+ "cadet-agent",
21
+ "unity",
22
+ "game-development",
23
+ "ai-agent",
24
+ "copilot",
25
+ "cursor",
26
+ "claude-code"
27
+ ],
28
+ "license": "CC-BY-4.0",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/naishtech/cadet-agent.git"
32
+ },
33
+ "homepage": "https://github.com/naishtech/cadet-agent#readme",
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ }
37
+ }
package/src/cli.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { install, sync } from './install.mjs';
@@ -7,6 +7,8 @@ import {
7
7
  workItemIdOf, loadPolicy, RunLedger, loadRun, listRuns, cleanupRuns, buildReport, formatReport,
8
8
  runVerificationLoop, commandForGate, detectCapabilities, runsDir, gitChangedFiles, PolicyError, StateError,
9
9
  detectRepoRole, describeRepoRole, GATES, manualConfirmation,
10
+ parseTestInventory, parseStoryCriteria, compareCoverage, describeCoverageGaps,
11
+ createEvidence, newId, computeInputTreeHash, hashCriteria,
10
12
  } from './harness/index.mjs';
11
13
 
12
14
  const __filename = fileURLToPath(import.meta.url);
@@ -41,6 +43,7 @@ function showHelp() {
41
43
  cadet-agent harness record Append a sanitized span/evidence/decision event
42
44
  cadet-agent harness confirm Record manual-confirmation evidence (writes ledger + state)
43
45
  cadet-agent harness verify Run a bounded, classified verification loop
46
+ cadet-agent harness verify-acs Verify declared AC↔test coverage against a test report
44
47
  cadet-agent harness report Summarize budget consumption and failures
45
48
  cadet-agent harness cleanup Apply the retention policy to .cadet/runs/
46
49
  cadet-agent harness capabilities Report available CLI/Unity/MCP/hook/token/cost telemetry
@@ -87,6 +90,9 @@ function parseArgs(argv) {
87
90
  case '--scope': opts.scope = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
88
91
  case '--evidence-status': opts.evidenceStatus = argv[++i]; break;
89
92
  case '--files': opts.files = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
93
+ case '--story': opts.story = argv[++i]; break;
94
+ case '--report': opts.report = argv[++i]; break;
95
+ case '--write-coverage': opts.writeCoverage = true; break;
90
96
  case '--older-than-ms': opts.olderThanMs = Number(argv[++i]); break;
91
97
  case '--agents-md': opts.agentsMd = argv[++i]; break;
92
98
  case '--yes': case '-y': opts.yes = true; break;
@@ -537,6 +543,164 @@ async function cmdHarness(opts) {
537
543
  return;
538
544
  }
539
545
 
546
+ if (sub === 'verify-acs') {
547
+ // Mechanical AC↔test verification (contract v4). Declared tests must appear
548
+ // in the inventory of a run that actually executed them; a name that was
549
+ // never written cannot be asserted into coverage.
550
+ if (!opts.story) fail(opts, 'harness verify-acs requires --story <path>');
551
+ const { exists, state } = readState(opts.targetDir);
552
+ const strict = policy.strictClosure?.enabled === true;
553
+ const workItemId = state ? workItemIdOf(state) : 'unscoped';
554
+ const phase = state?.session?.currentPhase || 'implementation';
555
+
556
+ let criteria;
557
+ try {
558
+ ({ criteria } = parseStoryCriteria(opts.story));
559
+ } catch (err) {
560
+ fail(opts, `cannot parse story "${opts.story}": ${err.message}`, () => 1, { ok: false, code: 'story-parse', story: opts.story });
561
+ }
562
+ if (criteria.length === 0) {
563
+ fail(opts, `story "${opts.story}" declares no acceptance criteria (expected a "## Acceptance Criteria" section).`, () => 1, { ok: false, code: 'no-criteria', story: opts.story });
564
+ }
565
+
566
+ // Resolve the inventory: an explicit --report, else the artifact of the most
567
+ // recent passing testsPassed evidence. Neither resolving is `blocked`, never
568
+ // a pass — an unproven inventory cannot satisfy coverage.
569
+ let reportText = null;
570
+ let reportSource = null;
571
+ let reportPath = null;
572
+ if (opts.report) {
573
+ try {
574
+ reportText = readFileSync(opts.report, 'utf-8');
575
+ reportSource = 'explicit';
576
+ reportPath = opts.report;
577
+ } catch (err) {
578
+ fail(opts, `cannot read --report "${opts.report}": ${err.message}`, () => 1, { ok: false, code: 'report-unreadable', report: opts.report });
579
+ }
580
+ } else if (exists) {
581
+ const prior = Array.isArray(state.gateEvidence) ? state.gateEvidence : [];
582
+ const passing = prior
583
+ .filter((e) => e.gate === 'testsPassed' && e.status === 'passed' && e.artifactPath)
584
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
585
+ const newest = passing[0];
586
+ if (newest) {
587
+ try {
588
+ reportText = readFileSync(newest.artifactPath, 'utf-8');
589
+ reportSource = 'testsPassed-evidence';
590
+ reportPath = newest.artifactPath;
591
+ } catch { /* fall through to blocked */ }
592
+ }
593
+ }
594
+ if (reportText === null) {
595
+ const detail = { ok: false, story: opts.story, blocked: true, code: 'no-test-report', reason: 'no test report available: pass --report <path>, or run `cadet-agent harness verify --gate testsPassed` first so its artifact can be read.' };
596
+ if (opts.format === 'json') emit(opts, '', detail);
597
+ else console.error(`❌ ${detail.reason}`);
598
+ process.exit(1);
599
+ }
600
+
601
+ const inventory = parseTestInventory(reportText);
602
+ const coverage = compareCoverage(criteria, inventory);
603
+ const gaps = describeCoverageGaps(coverage);
604
+
605
+ // Under strict closure an unknown/empty inventory can never prove coverage,
606
+ // even if every AC declared no tests in a way that looked consistent.
607
+ const unknownInventory = inventory.format === 'unknown' || inventory.names.length === 0;
608
+ const effectiveOk = coverage.ok && !unknownInventory;
609
+
610
+ if (!strict) {
611
+ // v2/v3 parity: report, write nothing, exit 0.
612
+ if (opts.format === 'json') {
613
+ emit(opts, '', { ok: effectiveOk, story: opts.story, ac: coverage.ac, inventorySize: coverage.inventorySize, format: inventory.format, gateSet: false, reportPath });
614
+ } else if (effectiveOk) {
615
+ console.log(`✅ AC coverage verified for ${opts.story} (${coverage.ac.length} criteria, ${coverage.inventorySize} tests in inventory).`);
616
+ console.log(' strictClosure is off — reported only, state.json unchanged.');
617
+ } else {
618
+ console.error(`⚠️ AC coverage gaps in ${opts.story} (strictClosure off — reported only):`);
619
+ if (unknownInventory) console.error(` no test inventory could be derived from ${reportPath || 'the report'} (format: ${inventory.format}).`);
620
+ for (const g of gaps) console.error(g);
621
+ }
622
+ if (!effectiveOk) process.exit(1);
623
+ return;
624
+ }
625
+
626
+ if (!effectiveOk) {
627
+ const detail = { ok: false, story: opts.story, ac: coverage.ac, inventorySize: coverage.inventorySize, format: inventory.format, gateSet: false, code: unknownInventory ? 'inventory-unknown' : 'coverage-gap' };
628
+ if (opts.format === 'json') emit(opts, '', detail);
629
+ else {
630
+ console.error(`❌ Cannot set acceptanceCriteriaValidated for ${opts.story}:`);
631
+ if (unknownInventory) console.error(` no test inventory could be derived from ${reportPath || 'the report'} (format: ${inventory.format}). An unparseable report proves nothing.`);
632
+ for (const g of gaps) console.error(g);
633
+ }
634
+ process.exit(1);
635
+ }
636
+
637
+ const at = new Date();
638
+ const criteriaStrings = coverage.ac.flatMap((a) => [a.id, ...a.declared]);
639
+ const nowIso = at.toISOString();
640
+ const evidence = createEvidence({
641
+ evidenceId: newId(),
642
+ workItemId,
643
+ acceptanceCriterionId: null,
644
+ phase,
645
+ gate: 'acceptanceCriteriaValidated',
646
+ status: 'passed',
647
+ command: `harness verify-acs --story ${opts.story}`,
648
+ result: `AC coverage verified: ${coverage.ac.length} criteria, inventory ${coverage.inventorySize} (${inventory.format})`,
649
+ exitCode: 0,
650
+ inputTreeHash: computeInputTreeHash(opts.targetDir, [opts.story, ...(reportPath ? [reportPath] : [])]),
651
+ criteriaHash: hashCriteria(criteriaStrings),
652
+ relevantFiles: [opts.story, ...(reportPath ? [reportPath] : [])].map((f) => f.replace(/\\/g, '/')),
653
+ createdAt: at,
654
+ expiresAt: null,
655
+ freshnessPolicy: 'current-story',
656
+ source: 'automated',
657
+ });
658
+
659
+ let coveragePath = null;
660
+ if (opts.writeCoverage) {
661
+ const base = opts.story.replace(/\.md$/, '');
662
+ coveragePath = `${base}.coverage.json`;
663
+ const doc = {
664
+ schemaVersion: 1,
665
+ story: opts.story.replace(/\\/g, '/'),
666
+ generatedAt: nowIso,
667
+ ac: coverage.ac,
668
+ inventorySize: coverage.inventorySize,
669
+ format: inventory.format,
670
+ };
671
+ try { writeFileSync(coveragePath, `${JSON.stringify(doc, null, 2)}\n`, 'utf-8'); } catch { coveragePath = null; }
672
+ }
673
+
674
+ // Ledger first, then state — the v3 ordering: fail toward "less proven".
675
+ const ledger = new RunLedger({ targetDir: opts.targetDir, policy, runId: state?.activeRunId || null, workItemId, phase });
676
+ ledger.addEvidence(evidence);
677
+ ledger.addDecision({ kind: 'stop', reason: `AC coverage verified via ${reportSource}`, scope: `${coverage.ac.length} criteria` });
678
+ ledger.finalize({ status: 'ok' });
679
+ const ledgerPath = ledger.persist();
680
+
681
+ if (exists) {
682
+ const next = { ...state };
683
+ const priorEv = Array.isArray(state.gateEvidence) ? state.gateEvidence : [];
684
+ next.gateEvidence = [
685
+ ...priorEv.map((e) => (e.gate === 'acceptanceCriteriaValidated' && (e.status === 'passed' || e.status === 'manual-confirmation')
686
+ ? { ...e, status: 'superseded', supersededBy: evidence.evidenceId }
687
+ : e)),
688
+ evidence,
689
+ ];
690
+ next.gates = { ...(state.gates || {}), acceptanceCriteriaValidated: true };
691
+ writeState(opts.targetDir, next);
692
+ }
693
+
694
+ if (opts.format === 'json') {
695
+ emit(opts, '', { ok: true, story: opts.story, ac: coverage.ac, inventorySize: coverage.inventorySize, format: inventory.format, gateSet: exists, coveragePath, evidenceId: evidence.evidenceId, runId: ledger.runId, path: ledgerPath });
696
+ } else {
697
+ console.log(`✅ acceptanceCriteriaValidated for ${opts.story} (${coverage.ac.length} criteria, inventory ${coverage.inventorySize}, ${inventory.format}).`);
698
+ console.log(` Ledger: ${ledgerPath}`);
699
+ if (coveragePath) console.log(` Coverage: ${coveragePath}`);
700
+ }
701
+ return;
702
+ }
703
+
540
704
  if (sub === 'report') {
541
705
  const runs = listRuns(opts.targetDir);
542
706
  const target = opts.runId || runs[0]?.runId;
@@ -556,7 +720,7 @@ async function cmdHarness(opts) {
556
720
  return;
557
721
  }
558
722
 
559
- fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|report|cleanup|capabilities.`);
723
+ fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|report|cleanup|capabilities.`);
560
724
  }
561
725
 
562
726
  export async function run(argv) {
@@ -63,3 +63,9 @@ export {
63
63
  export {
64
64
  REPO_ROLES, REPO_ROLE_MARKER, detectRepoRole, isFrameworkSourceWithoutWorkItem, describeRepoRole,
65
65
  } from './repo-role.mjs';
66
+
67
+ export {
68
+ INVENTORY_FORMATS, COVERAGE_STATUSES, DEFAULT_MAX_REPORT_BYTES, DEFAULT_MAX_INVENTORY_ENTRIES,
69
+ normalizeTestName, parseTestInventory, parseStoryCriteria, parseStoryCriteriaText,
70
+ compareCoverage, describeCoverageGaps,
71
+ } from './verify-acs.mjs';
@@ -0,0 +1,291 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ /**
4
+ * Mechanical AC↔test verification (Harness contract v4).
5
+ *
6
+ * Closes the defect class where a recorded claim names an artifact that does not
7
+ * exist and nothing re-checks the name: an epic's TDD matrix named tests that
8
+ * were never written, and the drift was only noticed at the validation gate,
9
+ * after the story had merged.
10
+ *
11
+ * Three responsibilities:
12
+ * 1. parseTestInventory — extract the identifiers of tests that ACTUALLY RAN
13
+ * from a verification run's report (TAP, JUnit XML, Unity JSON).
14
+ * 2. parseStoryCriteria — read the declared AC → test mapping from the story,
15
+ * which is the single source of truth for the coverage claim.
16
+ * 3. compareCoverage — declared vs found, with every gap reported together.
17
+ *
18
+ * Nothing here "passes" on unknown input: an unparseable report yields an empty
19
+ * inventory, which cannot satisfy coverage (Harness.md §3 — unknown is never
20
+ * silently zero/passing).
21
+ */
22
+
23
+ export const INVENTORY_FORMATS = Object.freeze(['tap', 'junit', 'unity-json', 'unknown']);
24
+
25
+ export const COVERAGE_STATUSES = Object.freeze(['covered', 'missing', 'undeclared']);
26
+
27
+ /** Bound on how many bytes of a report are scanned, so a huge report cannot blow the budget. */
28
+ export const DEFAULT_MAX_REPORT_BYTES = 4 * 1024 * 1024;
29
+
30
+ /** Upper bound on identifiers extracted, mirroring the archive file-count discipline. */
31
+ export const DEFAULT_MAX_INVENTORY_ENTRIES = 20000;
32
+
33
+ /**
34
+ * Normalize a test identifier for comparison.
35
+ *
36
+ * Deliberately conservative: trim, collapse internal whitespace, strip a trailing
37
+ * duplicate-index suffix such as ` (1)`. Case is preserved so `Grid_Foo` and
38
+ * `grid_foo` do NOT match — a fuzzier rule would defeat the purpose of the check.
39
+ * A project that needs looser matching must rename its tests, not loosen this.
40
+ */
41
+ export function normalizeTestName(name) {
42
+ if (name === null || name === undefined) return '';
43
+ return String(name)
44
+ .replace(/\s+/g, ' ')
45
+ .trim()
46
+ .replace(/\s*\(\d+\)$/, '');
47
+ }
48
+
49
+ /**
50
+ * Extract the identifiers of tests that ran from a report.
51
+ *
52
+ * Detection is by content, so a repository needs no extra configuration.
53
+ * Returns `{ format, names, partial, truncatedBytes }`.
54
+ * - `format` is one of INVENTORY_FORMATS.
55
+ * - `partial` is true when the report was truncated by a bound; a partial
56
+ * inventory cannot prove coverage for the truncated region.
57
+ */
58
+ export function parseTestInventory(
59
+ report,
60
+ { maxBytes = DEFAULT_MAX_REPORT_BYTES, maxEntries = DEFAULT_MAX_INVENTORY_ENTRIES } = {},
61
+ ) {
62
+ if (report === null || report === undefined) {
63
+ return { format: 'unknown', names: [], partial: false, truncatedBytes: 0 };
64
+ }
65
+ const text = typeof report === 'string' ? report : String(report);
66
+ if (text.trim() === '') {
67
+ return { format: 'unknown', names: [], partial: false, truncatedBytes: 0 };
68
+ }
69
+
70
+ let body = text;
71
+ let partial = false;
72
+ let truncatedBytes = 0;
73
+ if (Buffer.byteLength(body, 'utf-8') > maxBytes) {
74
+ body = Buffer.from(body, 'utf-8').subarray(0, maxBytes).toString('utf-8');
75
+ // Drop a possibly-torn final line.
76
+ const lastBreak = body.lastIndexOf('\n');
77
+ if (lastBreak !== -1) body = body.slice(0, lastBreak);
78
+ partial = true;
79
+ truncatedBytes = Buffer.byteLength(text, 'utf-8') - maxBytes;
80
+ }
81
+
82
+ const trimmed = body.trim();
83
+
84
+ // Unity JSON: a JSON document with a `tests` array of objects carrying `name`.
85
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
86
+ try {
87
+ const parsed = JSON.parse(trimmed);
88
+ const arr = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.tests) ? parsed.tests : null);
89
+ if (arr) {
90
+ const names = [];
91
+ for (const entry of arr) {
92
+ const n = entry && (entry.name ?? entry.fullName ?? entry.testName);
93
+ if (n) names.push(String(n));
94
+ if (names.length >= maxEntries) { partial = true; break; }
95
+ }
96
+ if (names.length > 0) {
97
+ return { format: 'unity-json', names: dedupe(names), partial, truncatedBytes };
98
+ }
99
+ }
100
+ } catch {
101
+ // Not JSON after all — fall through to the text formats.
102
+ }
103
+ }
104
+
105
+ // JUnit XML: <testcase ... name="...">
106
+ if (/<testcase\b/i.test(body)) {
107
+ const names = [];
108
+ const re = /<testcase\b[^>]*?\bname\s*=\s*"([^"]*)"/gi;
109
+ let m;
110
+ while ((m = re.exec(body)) !== null) {
111
+ names.push(m[1]);
112
+ if (names.length >= maxEntries) { partial = true; break; }
113
+ }
114
+ return { format: 'junit', names: dedupe(names), partial, truncatedBytes };
115
+ }
116
+
117
+ // Node TAP: `ok N - name` / `not ok N - name`. A failing test still ran, so
118
+ // both forms are included.
119
+ const tapRe = /^(?:not ok|ok)\s+\d+\s+-\s+(.*)$/gm;
120
+ const tapNames = [];
121
+ let t;
122
+ while ((t = tapRe.exec(body)) !== null) {
123
+ const name = normalizeTestName(t[1]);
124
+ if (name) tapNames.push(name);
125
+ if (tapNames.length >= maxEntries) { partial = true; break; }
126
+ }
127
+ if (tapNames.length > 0) {
128
+ return { format: 'tap', names: dedupe(tapNames), partial, truncatedBytes };
129
+ }
130
+
131
+ return { format: 'unknown', names: [], partial, truncatedBytes };
132
+ }
133
+
134
+ function dedupe(names) {
135
+ return [...new Set(names.map((n) => String(n)))];
136
+ }
137
+
138
+ /**
139
+ * Parse a story's acceptance criteria and their declared tests.
140
+ *
141
+ * Expected shape (per the story template):
142
+ *
143
+ * ### AC-1: <title>
144
+ * - Given ..., When ..., Then ...
145
+ * - Declared tests:
146
+ * - Test_Name_One
147
+ * - Test_Name_Two
148
+ *
149
+ * or inline: `- Declared tests: Test_Name_One, Test_Name_Two`
150
+ *
151
+ * Returns `{ criteria: [{ id, title, tests }] }`.
152
+ * Throws when an AC heading carries no id, or when two ACs share an id — a story
153
+ * that cannot be parsed is not a story that can be silently accepted.
154
+ */
155
+ export function parseStoryCriteria(storyPath) {
156
+ const text = readFileSync(storyPath, 'utf-8');
157
+ return parseStoryCriteriaText(text);
158
+ }
159
+
160
+ /** Text-in variant of parseStoryCriteria, for tests and in-memory use. */
161
+ export function parseStoryCriteriaText(text) {
162
+ const lines = String(text).split(/\r?\n/);
163
+
164
+ // Only look inside the Acceptance Criteria section, so a stray `### AC-` in
165
+ // notes cannot be picked up.
166
+ let start = lines.findIndex((l) => /^##\s+Acceptance Criteria\s*$/i.test(l.trim()));
167
+ if (start === -1) return { criteria: [] };
168
+ let end = lines.length;
169
+ for (let i = start + 1; i < lines.length; i++) {
170
+ if (/^##\s+/.test(lines[i].trim())) { end = i; break; }
171
+ }
172
+ const section = lines.slice(start + 1, end);
173
+
174
+ const criteria = [];
175
+ const seen = new Map(); // normalized id -> original
176
+ let current = null;
177
+
178
+ const headingRe = /^###\s+(.*)$/;
179
+ const declaredInlineRe = /^[-*]\s*Declared tests?\s*:\s*(.+)$/i;
180
+ const declaredBareRe = /^[-*]\s*Declared tests?\s*:?\s*$/i;
181
+ const bulletRe = /^[-*]\s+(.*)$/;
182
+
183
+ for (const raw of section) {
184
+ const line = raw.trim();
185
+ const heading = headingRe.exec(line);
186
+ if (heading) {
187
+ const rest = heading[1].trim();
188
+ const idMatch = /^(AC-[\w.-]+)\s*:?\s*(.*)$/i.exec(rest);
189
+ if (!idMatch) {
190
+ throw new Error(`acceptance criterion heading has no AC id: "${rest}"`);
191
+ }
192
+ const id = idMatch[1];
193
+ const key = id.toUpperCase();
194
+ if (seen.has(key)) {
195
+ throw new Error(`duplicate AC id "${id}" in story`);
196
+ }
197
+ seen.set(key, id);
198
+ current = { id, title: idMatch[2].trim(), tests: [] };
199
+ criteria.push(current);
200
+ continue;
201
+ }
202
+ if (!current) continue;
203
+
204
+ // `- Declared tests: A, B` (inline) — check before the bare form.
205
+ const inline = declaredInlineRe.exec(line);
206
+ if (inline) {
207
+ for (const name of splitTestList(inline[1])) current.tests.push(name);
208
+ current.bareDeclaredList = true; // allow following nested bullets too
209
+ continue;
210
+ }
211
+
212
+ // `- Declared tests:` on its own line — the following bullets are the tests.
213
+ if (declaredBareRe.test(line)) {
214
+ current.bareDeclaredList = true;
215
+ continue;
216
+ }
217
+
218
+ // A nested bullet while in the declared-tests list.
219
+ if (current.bareDeclaredList) {
220
+ const bullet = bulletRe.exec(line);
221
+ if (bullet) {
222
+ current.tests.push(bullet[1].trim());
223
+ continue;
224
+ }
225
+ current.bareDeclaredList = false;
226
+ }
227
+ }
228
+
229
+ for (const c of criteria) {
230
+ delete c.bareDeclaredList;
231
+ c.tests = dedupe(c.tests.map((t) => t.trim()).filter(Boolean));
232
+ }
233
+ return { criteria };
234
+ }
235
+
236
+ function splitTestList(text) {
237
+ return text.split(/[,;]/).map((s) => s.trim()).filter(Boolean);
238
+ }
239
+
240
+ /**
241
+ * Compare a story's declared tests against a run's inventory.
242
+ *
243
+ * Returns `{ ok, ac: [{ id, declared, found, status }], inventorySize, format }`.
244
+ * `status` is `covered` (all declared found), `missing` (some declared absent),
245
+ * or `undeclared` (the AC declares no test at all).
246
+ *
247
+ * Every gap is reported together; the caller renders all of them, never just the
248
+ * first.
249
+ */
250
+ export function compareCoverage(criteria, inventory) {
251
+ const found = new Set((inventory?.names || []).map((n) => normalizeTestName(n)));
252
+ const ac = [];
253
+ let ok = true;
254
+
255
+ for (const c of criteria) {
256
+ const declared = (c.tests || []).map((t) => String(t));
257
+ if (declared.length === 0) {
258
+ ok = false;
259
+ ac.push({ id: c.id, declared: [], found: [], status: 'undeclared' });
260
+ continue;
261
+ }
262
+ const present = declared.filter((t) => found.has(normalizeTestName(t)));
263
+ const status = present.length === declared.length ? 'covered' : 'missing';
264
+ if (status !== 'covered') ok = false;
265
+ ac.push({ id: c.id, declared, found: present, status });
266
+ }
267
+
268
+ return {
269
+ ok,
270
+ ac,
271
+ inventorySize: (inventory?.names || []).length,
272
+ format: inventory?.format || 'unknown',
273
+ };
274
+ }
275
+
276
+ /** Format the gaps as concrete, actionable lines (spec §5.1 step 4). */
277
+ export function describeCoverageGaps(coverage) {
278
+ const lines = [];
279
+ for (const entry of coverage.ac) {
280
+ if (entry.status === 'undeclared') {
281
+ lines.push(` ${entry.id}: declares no test — record the test identifier(s) that prove this criterion.`);
282
+ } else if (entry.status === 'missing') {
283
+ const found = new Set(entry.found.map((n) => normalizeTestName(n)));
284
+ const absent = entry.declared.filter((t) => !found.has(normalizeTestName(t)));
285
+ for (const t of absent) {
286
+ lines.push(` ${entry.id}: declared test "${t}" did not appear in the test report — either it was renamed (update the story) or it was never written.`);
287
+ }
288
+ }
289
+ }
290
+ return lines;
291
+ }