cadet-agent 0.30.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.
@@ -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
+ }