@codedology/code-forge 0.8.0 → 0.10.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 (64) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +46 -7
  3. package/docs/reference/blocks.json +618 -1
  4. package/docs/reference/config.md +36 -0
  5. package/package.json +1 -1
  6. package/schema/code-forge.schema.json +89 -0
  7. package/skill/references/adapters/claude-code.md +1 -1
  8. package/skill/references/adapters/solo.md +2 -2
  9. package/skill/references/adapters/subprocess.md +12 -2
  10. package/skill/references/code.md +21 -7
  11. package/skill/references/ledger.md +2 -2
  12. package/skill/references/proof.md +77 -2
  13. package/skill/references/review.md +29 -4
  14. package/skill/references/security.md +5 -5
  15. package/skill/scripts/forge +1 -1
  16. package/skill/templates/coder-brief.md +4 -2
  17. package/src/autopilot/binnacle.mjs +9 -3
  18. package/src/cli/block.mjs +300 -33
  19. package/src/cli/jev.mjs +22 -3
  20. package/src/cli/keys.mjs +156 -21
  21. package/src/cli/proof.mjs +35 -7
  22. package/src/cli/review-file.mjs +264 -13
  23. package/src/cli/spawn.mjs +44 -3
  24. package/src/config/validate.mjs +320 -5
  25. package/src/doctor/index.mjs +7 -5
  26. package/src/doctor/local.mjs +116 -8
  27. package/src/doctor/probes.mjs +38 -0
  28. package/src/engines/builders/codex.mjs +44 -4
  29. package/src/engines/sandbox-dirs.mjs +257 -0
  30. package/src/keys/store.mjs +128 -21
  31. package/src/ledger/write.mjs +7 -1
  32. package/src/proof/junit.mjs +530 -0
  33. package/src/proof/red-green.mjs +57 -9
  34. package/src/proof/red-parse.mjs +2 -0
  35. package/src/proof/runners.mjs +472 -0
  36. package/src/proof/xcresult.mjs +285 -0
  37. package/src/review/answers.mjs +445 -0
  38. package/src/review/deletion.mjs +106 -0
  39. package/src/review/engine.mjs +117 -31
  40. package/src/review/fixloop.mjs +70 -28
  41. package/src/review/gate-check.mjs +378 -80
  42. package/src/review/lenses/A.md +1 -0
  43. package/src/review/lenses/B.md +1 -0
  44. package/src/review/lenses/full.md +1 -0
  45. package/src/review/lenses/quick.md +1 -0
  46. package/src/review/lenses/recheck.md +1 -0
  47. package/src/review/reopen.mjs +256 -0
  48. package/src/review/route.mjs +232 -0
  49. package/src/review/schema-fallback.mjs +77 -22
  50. package/src/review/session-retry.mjs +44 -9
  51. package/src/review/stop-exits.mjs +15 -0
  52. package/src/review/test-quality.mjs +201 -0
  53. package/src/review/verb-forms.mjs +39 -0
  54. package/src/session/spawn.mjs +39 -2
  55. package/src/state/block.mjs +13 -3
  56. package/src/state/config-snapshot.mjs +39 -0
  57. package/src/state/run.mjs +4 -0
  58. package/src/util/forbidden.mjs +11 -1
  59. package/src/util/tmp.mjs +16 -4
  60. package/src/worker/engine.mjs +75 -9
  61. package/src/worker/loop.mjs +82 -15
  62. package/src/worker/queue.mjs +265 -10
  63. package/src/worker/review-state.mjs +346 -51
  64. package/src/worker/ticket.mjs +9 -1
@@ -0,0 +1,530 @@
1
+ /**
2
+ * `junit` (issue #7, red-green plan §1.1, B86): read a JUnit XML test report and classify the RED
3
+ * and GREEN steps of a red→green run from it, instead of from the console text.
4
+ *
5
+ * Fail closed everywhere: a report that is missing, empty, over 8 MiB, malformed, not UTF-8, has a
6
+ * `<!DOCTYPE`/`<!ENTITY` (no entity expansion, no XXE) or an element this reader does not know is
7
+ * unreadable, and an unreadable report never proves anything. Pure apart from `loadReport`, which
8
+ * reads one file. No dependency.
9
+ *
10
+ * The common shape (`ReportResult`): `{tests: [{id, status, kind?, needs_console?}]}` or
11
+ * `{unreadable: reason}`. `id` is `classname.name` (or `name` with no classname). A `<failure>` is
12
+ * `assertion` only when its `type` names an assertion, or it has no `type` and its `message` names
13
+ * one; any other `<failure>` and every `<error>` is `error`.
14
+ *
15
+ * SwiftPM's XCTest report says nothing about the cause: every failed test is
16
+ * `<failure message="failure"></failure>` (SwiftPM 6.3 on macOS, captured by the B88 live check;
17
+ * older and Linux builds write `message="failed"`), with no `type` and no body. Such a test is read
18
+ * as `kind: error`
19
+ * with `needs_console: 'xctest'`, and the RED step counts it as an assertion only when the console
20
+ * text has an XCTest assertion line for that very test (`<file>:<line>: error: -[Module.Suite test]
21
+ * : XCTAssert… failed` on macOS, `… error: Suite.test : XCTAssert… failed` on Linux, or XCTFail's
22
+ * `: failed - <message>`) and no crash line (`Fatal error`, `Precondition failed`, `Assertion
23
+ * failed`, `Unexpectedly found nil`, `signal code <n>`, `Illegal instruction`, `Segmentation
24
+ * fault`). A thrown error (`: failed: caught error`) is not an assertion line. Else it stays `error`.
25
+ *
26
+ * `classifyReport` (RED step), first row that applies wins:
27
+ *
28
+ * verdict red_kind when
29
+ * ─────────── ────────── ──────────────────────────────────────────────────────────────────
30
+ * NOT_RED none exit 0, no signal
31
+ * RED_INVALID fatal a timeout or a signal
32
+ * RED_INVALID import|compile|fatal the console text hits a `parseRed` invalid family
33
+ * RED_INVALID compile no report was written (every report is `NO_REPORT`) and the
34
+ * caller's compile check hits the console text (`swiftCompileError`
35
+ * for the `swift` adapter, B88)
36
+ * RED_INVALID error no report, or one is unreadable
37
+ * RED_INVALID error the report has no test that ran (zero, or only skipped)
38
+ * RED_INVALID error a test errored, or failed without an assertion
39
+ * RED assertion at least one test failed on an assertion
40
+ * RED_INVALID error the run failed, but the report has no failure
41
+ *
42
+ * The result keeps names only: `failed_tests` holds at most 20 test ids of at most 200
43
+ * characters, never a message or an excerpt. `failed_ids` is the full list, for the GREEN step
44
+ * of the same call only; it never goes into a ledger row.
45
+ */
46
+
47
+ import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs';
48
+ import { parseRed } from './red-parse.mjs';
49
+
50
+ /** @typedef {'passed' | 'failed' | 'error' | 'skipped'} TestStatus */
51
+ /** @typedef {{id: string, status: TestStatus, kind?: 'assertion' | 'error', needs_console?: 'xctest'}} ReportTest */
52
+ /** @typedef {{tests: ReportTest[]} | {unreadable: string}} ReportResult */
53
+ /** @typedef {{code: number | null, signal?: string | null, timedOut?: boolean, stdout?: string, stderr?: string}} RunResult */
54
+ /**
55
+ * @typedef {{verdict: 'NOT_RED' | 'RED' | 'RED_INVALID', red_kind: 'none' | 'assertion' | 'import' | 'compile' | 'fatal' | 'error',
56
+ * framework: 'junit', failed: number | null, failed_tests: string[], failed_ids: string[], reason: string}} JUnitRedParse
57
+ */
58
+ /** @typedef {{verdict: 'GREEN' | 'NOT_GREEN', failed: number | null, failed_tests: string[], reason: string}} JUnitGreenParse */
59
+
60
+ /** A report larger than this many bytes is refused. */
61
+ export const MAX_REPORT_BYTES = 8 * 1024 * 1024;
62
+ /**
63
+ * What `loadReport` returns for a report file that does not exist: one shared frozen object, so
64
+ * the compile rule can tell "no report was written" apart from every other unreadable report by
65
+ * identity, never by its message.
66
+ */
67
+ export const NO_REPORT = Object.freeze({ unreadable: 'no report was written' });
68
+ /** At most this many failed test names are kept. */
69
+ export const MAX_FAILED_TESTS = 20;
70
+ /** Each kept name is cut to this many characters. */
71
+ export const MAX_TEST_NAME = 200;
72
+
73
+ /** The `type` or `message` of an assertion failure, per runner. */
74
+ const ASSERTION_PATTERNS = Object.freeze([
75
+ /\bAssertionError\b/, // node:assert and pytest. Not Jest: its report names no assertion type, so Jest RED is not supported yet
76
+ /\bERR_ASSERTION\b/, // node:assert
77
+ /\bExpectationFailedException\b/, // PHPUnit and Pest
78
+ /\bAssertionFailedError\b/, // PHPUnit's assertion base class ($this->fail())
79
+ /\bFailed asserting that\b/, // PHPUnit and Pest messages
80
+ /\bXCTAssert\w*/, // XCTest
81
+ /\bXCTFail\b/, // XCTest
82
+ /\bExpectation failed\b/, // Swift Testing #expect
83
+ /^\s*assert\s/, // pytest's rewritten assert message
84
+ ]);
85
+
86
+ const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
87
+ /** An XCTest assertion line: `-[Module.Suite test] : …` (macOS) or `Suite.test : …` (Linux). */
88
+ const XCTEST_LINE = /:\d+: error: (?:-\[([^\s\]]+) ([^\s\]]+)\]|([^\s:]+)) : (?:XCTAssert\w* failed\b|failed - )/g;
89
+ /**
90
+ * `swiftc` and SwiftPM build-failure lines: `<file>.swift:<line>:<col>: error:` (a compiler
91
+ * diagnostic always has a column; XCTest's assertion line `<file>.swift:<line>: error: -[…]` has
92
+ * none), and SwiftPM's own `error: emit-module command failed` and `error: compile command
93
+ * failed`. SwiftPM 6.3's closing `error: fatalError` is left out: it is SwiftPM's generic exit
94
+ * line, not a compiler diagnostic, and in the B88 live check it only follows the `swiftc` line.
95
+ */
96
+ const SWIFT_COMPILE = [/\.swift:\d+:\d+: error: /, /^error: (?:emit-module command failed|compile command failed)\b/m];
97
+ /** A crash in the test process: a bare XCTest failure next to one of these is never an assertion. */
98
+ const SWIFT_CRASH = /\bFatal error\b|\bPrecondition failed\b|\bAssertion failed\b|\bUnexpectedly found nil\b|\bsignal code \d+|\bIllegal instruction\b|\bSegmentation fault\b/;
99
+
100
+ /** Which parents each known element may have (`null` = the document root). */
101
+ const PARENTS = Object.freeze({
102
+ testsuites: [null],
103
+ testsuite: [null, 'testsuites', 'testsuite'],
104
+ testcase: ['testsuite'],
105
+ failure: ['testcase'],
106
+ error: ['testcase'],
107
+ skipped: ['testcase'],
108
+ 'system-out': ['testsuites', 'testsuite', 'testcase'],
109
+ 'system-err': ['testsuites', 'testsuite', 'testcase'],
110
+ properties: ['testsuites', 'testsuite', 'testcase'],
111
+ property: ['properties'],
112
+ });
113
+
114
+ const NAME = /[A-Za-z_][A-Za-z0-9_.:-]*/y;
115
+ const SPACE = /[ \t\r\n]*/y;
116
+ const PREDEFINED = Object.freeze({ lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" });
117
+
118
+ /** @param {string} reason @returns {never} */
119
+ function unreadable(reason) {
120
+ const err = new Error(`report-unreadable: ${reason}`);
121
+ /** @type {any} */ (err).code = 'report-unreadable';
122
+ throw err;
123
+ }
124
+
125
+ /** @param {number} cp @returns {boolean} an XML 1.0 `Char` */
126
+ const isXmlChar = (cp) => cp === 0x9 || cp === 0xa || cp === 0xd || (cp >= 0x20 && cp <= 0xd7ff) || (cp >= 0xe000 && cp <= 0xfffd) || (cp >= 0x10000 && cp <= 0x10ffff);
127
+ /** Any raw code point (or lone surrogate) that `isXmlChar` refuses: NUL, C0 controls but tab/LF/CR, U+FFFE, U+FFFF. */
128
+ const NOT_XML_CHAR = /[^\t\n\r\x20-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/u;
129
+
130
+ /**
131
+ * Decode the five predefined entities and character references; any other `&` refuses.
132
+ * @param {string} raw @returns {string}
133
+ */
134
+ function decode(raw) {
135
+ if (!raw.includes('&')) return raw;
136
+ let out = '';
137
+ let i = 0;
138
+ for (;;) {
139
+ const amp = raw.indexOf('&', i);
140
+ if (amp === -1) return out + raw.slice(i);
141
+ out += raw.slice(i, amp);
142
+ const semi = raw.indexOf(';', amp);
143
+ if (semi === -1 || semi - amp > 12) unreadable('a bare & or an unknown entity');
144
+ const ref = raw.slice(amp + 1, semi);
145
+ if (Object.hasOwn(PREDEFINED, ref)) out += PREDEFINED[ref];
146
+ else {
147
+ const m = /^#(?:([0-9]{1,7})|x([0-9A-Fa-f]{1,6}))$/.exec(ref);
148
+ if (!m) unreadable('an unknown entity');
149
+ const cp = m[1] !== undefined ? Number(m[1]) : Number.parseInt(m[2], 16);
150
+ if (!isXmlChar(cp)) unreadable('a character reference that is not an XML character');
151
+ out += String.fromCodePoint(cp);
152
+ }
153
+ i = semi + 1;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Parse a start tag's attributes from `pos` (just after the element name).
159
+ * @param {string} text @param {number} pos
160
+ * @returns {{attrs: Map<string, string>, end: number, selfClosing: boolean}}
161
+ */
162
+ function readAttributes(text, pos) {
163
+ const attrs = new Map();
164
+ let i = pos;
165
+ for (;;) {
166
+ SPACE.lastIndex = i;
167
+ SPACE.exec(text);
168
+ const hadSpace = SPACE.lastIndex > i;
169
+ i = SPACE.lastIndex;
170
+ if (text.startsWith('/>', i)) return { attrs, end: i + 2, selfClosing: true };
171
+ if (text[i] === '>') return { attrs, end: i + 1, selfClosing: false };
172
+ if (!hadSpace) unreadable('a malformed start tag');
173
+ NAME.lastIndex = i;
174
+ const name = NAME.exec(text)?.[0];
175
+ if (!name) unreadable('a malformed attribute');
176
+ i = NAME.lastIndex;
177
+ SPACE.lastIndex = i;
178
+ SPACE.exec(text);
179
+ i = SPACE.lastIndex;
180
+ if (text[i] !== '=') unreadable('an attribute without a value');
181
+ SPACE.lastIndex = i + 1;
182
+ SPACE.exec(text);
183
+ i = SPACE.lastIndex;
184
+ const quote = text[i];
185
+ if (quote !== '"' && quote !== "'") unreadable('an unquoted attribute value');
186
+ const close = text.indexOf(quote, i + 1);
187
+ if (close === -1) unreadable('an unterminated attribute value');
188
+ const raw = text.slice(i + 1, close);
189
+ if (raw.includes('<')) unreadable('a < inside an attribute value');
190
+ if (attrs.has(name)) unreadable('a duplicate attribute');
191
+ attrs.set(name, decode(raw));
192
+ i = close + 1;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * @param {Map<string, string>} attrs
198
+ * @returns {'assertion' | 'error' | 'bare'} `bare` = SwiftPM's XCTest shape, `message="failure"` (or
199
+ * `"failed"`) and no `type` (it also needs an empty body, checked at the end tag).
200
+ */
201
+ function failureKind(attrs) {
202
+ const type = (attrs.get('type') ?? '').trim();
203
+ if (type !== '') return ASSERTION_PATTERNS.some((p) => p.test(type)) ? 'assertion' : 'error';
204
+ const message = attrs.get('message') ?? '';
205
+ if (message.trim() === 'failed' || message.trim() === 'failure') return 'bare';
206
+ return ASSERTION_PATTERNS.some((p) => p.test(message)) ? 'assertion' : 'error';
207
+ }
208
+
209
+ /**
210
+ * Read a JUnit XML report.
211
+ * @param {string | Uint8Array} input - the report text, or its bytes (UTF-8).
212
+ * @returns {{tests: ReportTest[]}}
213
+ * @throws {Error} `code: 'report-unreadable'` for anything this reader does not accept; the
214
+ * message names the reason only, never report content.
215
+ */
216
+ export function readJUnit(input) {
217
+ let text;
218
+ if (typeof input === 'string') {
219
+ if (Buffer.byteLength(input, 'utf8') > MAX_REPORT_BYTES) unreadable('the report is over 8 MiB');
220
+ text = input;
221
+ } else if (input instanceof Uint8Array) {
222
+ if (input.byteLength > MAX_REPORT_BYTES) unreadable('the report is over 8 MiB');
223
+ try {
224
+ text = new TextDecoder('utf-8', { fatal: true }).decode(input);
225
+ } catch {
226
+ unreadable('the report is not UTF-8');
227
+ }
228
+ } else unreadable('the report is not text');
229
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
230
+ // raw text, attribute values, CDATA and comments must hold XML characters only, as character references must
231
+ if (NOT_XML_CHAR.test(text)) unreadable('a character that is not an XML character');
232
+
233
+ /** @type {ReportTest[]} */
234
+ const tests = [];
235
+ /** @typedef {{id: string, failures: Array<'assertion' | 'error' | 'bare'>, errors: number, skipped: number}} OpenTest */
236
+ /** @type {{name: string, test?: OpenTest, failure?: number, body?: boolean}[]} */
237
+ const stack = [];
238
+ let rootSeen = false;
239
+ let i = 0;
240
+
241
+ /** @param {string} name @param {Map<string, string>} attrs */
242
+ const open = (name, attrs) => {
243
+ if (!Object.hasOwn(PARENTS, name)) unreadable('an element this reader does not know');
244
+ const parent = stack.length ? stack[stack.length - 1].name : null;
245
+ if (parent === null && rootSeen) unreadable('more than one root element');
246
+ if (!PARENTS[name].includes(parent)) unreadable('an element in the wrong place');
247
+ if (parent === null) rootSeen = true;
248
+ /** @type {{name: string, test?: OpenTest, failure?: number, body?: boolean}} */
249
+ const frame = { name };
250
+ if (name === 'testcase') {
251
+ const caseName = attrs.get('name');
252
+ if (!caseName) unreadable('a testcase without a name');
253
+ const classname = attrs.get('classname');
254
+ frame.test = { id: classname ? `${classname}.${caseName}` : caseName, failures: [], errors: 0, skipped: 0 };
255
+ } else if (name === 'failure' || name === 'error' || name === 'skipped') {
256
+ const test = stack[stack.length - 1].test;
257
+ if (name === 'failure') frame.failure = test.failures.push(failureKind(attrs)) - 1;
258
+ else if (name === 'error') test.errors += 1;
259
+ else test.skipped += 1;
260
+ }
261
+ stack.push(frame);
262
+ };
263
+
264
+ /** @param {string} name */
265
+ const close = (name) => {
266
+ const frame = stack.pop();
267
+ if (!frame || frame.name !== name) unreadable('a mismatched end tag');
268
+ if (frame.failure !== undefined && frame.body) {
269
+ const owner = stack[stack.length - 1].test;
270
+ if (owner.failures[frame.failure] === 'bare') owner.failures[frame.failure] = 'error';
271
+ }
272
+ const t = frame.test;
273
+ if (!t) return;
274
+ if (t.errors > 0) tests.push({ id: t.id, status: 'error', kind: 'error' });
275
+ else if (t.failures.length > 0 && t.failures.every((k) => k === 'assertion')) tests.push({ id: t.id, status: 'failed', kind: 'assertion' });
276
+ else if (t.failures.length > 0 && t.failures.every((k) => k !== 'error')) tests.push({ id: t.id, status: 'failed', kind: 'error', needs_console: 'xctest' });
277
+ else if (t.failures.length > 0) tests.push({ id: t.id, status: 'failed', kind: 'error' });
278
+ else if (t.skipped > 0) tests.push({ id: t.id, status: 'skipped' });
279
+ else tests.push({ id: t.id, status: 'passed' });
280
+ };
281
+
282
+ while (i < text.length) {
283
+ const lt = text.indexOf('<', i);
284
+ const chunk = text.slice(i, lt === -1 ? text.length : lt);
285
+ if (stack.length === 0) {
286
+ if (!/^[ \t\r\n]*$/.test(chunk)) unreadable('text outside the root element');
287
+ } else {
288
+ decode(chunk);
289
+ if (/[^ \t\r\n]/.test(chunk)) stack[stack.length - 1].body = true;
290
+ }
291
+ if (lt === -1) break;
292
+ i = lt;
293
+ if (text.startsWith('<!--', i)) {
294
+ const end = text.indexOf('-->', i + 4);
295
+ if (end === -1) unreadable('an unterminated comment');
296
+ i = end + 3;
297
+ } else if (text.startsWith('<![CDATA[', i)) {
298
+ if (stack.length === 0) unreadable('CDATA outside the root element');
299
+ const end = text.indexOf(']]>', i + 9);
300
+ if (end === -1) unreadable('an unterminated CDATA section');
301
+ if (/[^ \t\r\n]/.test(text.slice(i + 9, end))) stack[stack.length - 1].body = true;
302
+ i = end + 3;
303
+ } else if (text.startsWith('<!', i)) {
304
+ unreadable('a DOCTYPE or entity declaration');
305
+ } else if (text.startsWith('<?', i)) {
306
+ const end = text.indexOf('?>', i + 2);
307
+ if (end === -1) unreadable('an unterminated processing instruction');
308
+ i = end + 2;
309
+ } else if (text.startsWith('</', i)) {
310
+ NAME.lastIndex = i + 2;
311
+ const name = NAME.exec(text)?.[0];
312
+ if (!name) unreadable('a malformed end tag');
313
+ SPACE.lastIndex = NAME.lastIndex;
314
+ SPACE.exec(text);
315
+ if (text[SPACE.lastIndex] !== '>') unreadable('a malformed end tag');
316
+ close(name);
317
+ i = SPACE.lastIndex + 1;
318
+ } else {
319
+ NAME.lastIndex = i + 1;
320
+ const name = NAME.exec(text)?.[0];
321
+ if (!name) unreadable('a malformed start tag');
322
+ const { attrs, end, selfClosing } = readAttributes(text, NAME.lastIndex);
323
+ open(name, attrs);
324
+ if (selfClosing) close(name);
325
+ i = end;
326
+ }
327
+ }
328
+ if (stack.length > 0) unreadable('an unclosed element');
329
+ if (!rootSeen) unreadable('no root element');
330
+ return { tests };
331
+ }
332
+
333
+ /**
334
+ * `readJUnit` that never throws.
335
+ * @param {string | Uint8Array} input @returns {ReportResult}
336
+ */
337
+ export function parseReport(input) {
338
+ try {
339
+ return readJUnit(input);
340
+ } catch (err) {
341
+ const message = err instanceof Error ? err.message : '';
342
+ return { unreadable: message.startsWith('report-unreadable: ') ? message.slice('report-unreadable: '.length) : 'the report could not be read' };
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Read and parse one report file. Never throws. A symlink, a non-regular file (a FIFO is opened
348
+ * non-blocking, so it never hangs), a missing file or a file over `MAX_REPORT_BYTES` is
349
+ * unreadable.
350
+ * @param {string} file @returns {ReportResult}
351
+ */
352
+ export function loadReport(file) {
353
+ let fd;
354
+ try {
355
+ fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
356
+ } catch (err) {
357
+ return /** @type {any} */ (err)?.code === 'ENOENT' ? NO_REPORT : { unreadable: 'the report could not be opened' };
358
+ }
359
+ try {
360
+ const st = fstatSync(fd);
361
+ if (!st.isFile()) return { unreadable: 'the report is not a regular file' };
362
+ return readReportFd(fd, st.size);
363
+ } catch {
364
+ return { unreadable: 'the report could not be read' };
365
+ } finally {
366
+ closeSync(fd);
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Read an open report whose `fstat` size was `size`, into a buffer of `min(size, cap) + 1` bytes.
372
+ * A file over the cap, or one that grew or shrank after `fstat`, is unreadable. Never throws.
373
+ * @param {number} fd @param {number} size @returns {ReportResult}
374
+ */
375
+ export function readReportFd(fd, size) {
376
+ try {
377
+ if (size > MAX_REPORT_BYTES) return { unreadable: 'the report is over 8 MiB' };
378
+ const buf = Buffer.alloc(size + 1);
379
+ let n = 0;
380
+ for (;;) {
381
+ const got = readSync(fd, buf, n, buf.length - n, null);
382
+ if (got === 0 || (n += got) === buf.length) break;
383
+ }
384
+ if (n !== size) return { unreadable: 'the report changed while it was read' };
385
+ return parseReport(buf.subarray(0, n));
386
+ } catch {
387
+ return { unreadable: 'the report could not be read' };
388
+ }
389
+ }
390
+
391
+ /** @param {string} id @returns {string} the id as a row may keep it: no C0 or C1 control characters, at most 200 characters */
392
+ function boundedName(id) {
393
+ // eslint-disable-next-line no-control-regex
394
+ return Array.from(id.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')).slice(0, MAX_TEST_NAME).join('');
395
+ }
396
+
397
+ /**
398
+ * Merge the reports of one step. Fail closed: no report or any unreadable one ⇒ `null`.
399
+ * @param {unknown} reports @returns {ReportTest[] | null}
400
+ */
401
+ function mergeReports(reports) {
402
+ if (!Array.isArray(reports) || reports.length === 0) return null;
403
+ /** @type {ReportTest[]} */
404
+ const all = [];
405
+ for (const r of reports) {
406
+ if (!r || typeof r !== 'object' || !Array.isArray(/** @type {any} */ (r).tests)) return null;
407
+ for (const t of /** @type {any} */ (r).tests) {
408
+ if (!t || typeof t.id !== 'string' || !['passed', 'failed', 'error', 'skipped'].includes(t.status)) return null;
409
+ if (t.needs_console !== undefined && t.needs_console !== 'xctest') return null;
410
+ all.push(t);
411
+ }
412
+ }
413
+ return all;
414
+ }
415
+
416
+ /** @param {ReportTest[] | null} tests */
417
+ function failures(tests) {
418
+ const failedIds = tests ? tests.filter((t) => t.status === 'failed' || t.status === 'error').map((t) => t.id) : [];
419
+ return {
420
+ failed: tests ? failedIds.length : null,
421
+ failed_tests: failedIds.slice(0, MAX_FAILED_TESTS).map(boundedName),
422
+ failed_ids: failedIds,
423
+ };
424
+ }
425
+
426
+ /**
427
+ * The ids the console text shows an XCTest assertion line for, or none when it shows a crash.
428
+ * @param {string} text - the run's stdout and stderr, colours stripped.
429
+ * @returns {{ids: Set<string>, tails: Set<string>}} `-[Module.Suite test]` ids, and `Suite.test` tails
430
+ */
431
+ function xctestAssertions(text) {
432
+ const ids = new Set();
433
+ const tails = new Set();
434
+ if (SWIFT_CRASH.test(text)) return { ids, tails };
435
+ for (const m of text.matchAll(XCTEST_LINE)) {
436
+ if (m[1] !== undefined) ids.add(`${m[1]}.${m[2]}`);
437
+ else tails.add(m[3]);
438
+ }
439
+ return { ids, tails };
440
+ }
441
+
442
+ /**
443
+ * A bare XCTest failure counts as an assertion only with its own assertion line in the console.
444
+ * @param {ReportTest[]} tests @param {RunResult} run @returns {ReportTest[]}
445
+ */
446
+ function confirmXCTest(tests, run) {
447
+ if (!tests.some((t) => t.needs_console === 'xctest')) return tests;
448
+ const { ids, tails } = xctestAssertions(`${run.stdout ?? ''}\n${run.stderr ?? ''}`.replace(ANSI, ''));
449
+ const seen = (/** @type {string} */ id) => ids.has(id) || [...tails].some((tail) => id === tail || id.endsWith(`.${tail}`));
450
+ return tests.map((t) => (t.needs_console === 'xctest' && seen(t.id) ? { id: t.id, status: t.status, kind: 'assertion' } : t));
451
+ }
452
+
453
+ /**
454
+ * Does the console text show a Swift compile or build failure (§1.3 item 3)? Colours are stripped
455
+ * first. An XCTest assertion line is not one. Used by the `swift` adapter and, next to its own
456
+ * build-failure lines, by the `xcodebuild` one (`./xcresult.mjs`, B102).
457
+ * @param {string} text @returns {boolean}
458
+ */
459
+ export function swiftCompileError(text) {
460
+ const plain = String(text).replace(ANSI, '');
461
+ return SWIFT_COMPILE.some((p) => p.test(plain));
462
+ }
463
+
464
+ /** @param {RunResult} run @returns {boolean} */
465
+ const exitedClean = (run) => run.code === 0 && !run.signal && !run.timedOut;
466
+
467
+ /**
468
+ * Classify the RED step from the run and its parsed reports. Never throws.
469
+ * @param {{run: RunResult, reports: ReportResult[], compileError?: (text: string) => boolean}} input -
470
+ * `compileError`: the adapter's compile check, asked only when no report was written at all
471
+ * (a non-empty `reports` that holds only `NO_REPORT`)
472
+ * @returns {JUnitRedParse}
473
+ */
474
+ export function classifyReport(input) {
475
+ try {
476
+ const { run, reports, compileError } = input;
477
+ const tests = mergeReports(reports);
478
+ const base = { framework: /** @type {const} */ ('junit'), ...failures(tests) };
479
+ /** @param {JUnitRedParse['verdict']} verdict @param {JUnitRedParse['red_kind']} red_kind @param {string} reason @returns {JUnitRedParse} */
480
+ const out = (verdict, red_kind, reason) => ({ verdict, red_kind, ...base, reason });
481
+ if (exitedClean(run)) return out('NOT_RED', 'none', 'the test passed without the change');
482
+ if (run.timedOut || run.signal) return out('RED_INVALID', 'fatal', run.timedOut ? 'the test run timed out' : 'the test run was killed by a signal');
483
+ const text = parseRed(run);
484
+ if (text.verdict === 'RED_INVALID' && (text.red_kind === 'import' || text.red_kind === 'compile' || text.red_kind === 'fatal')) {
485
+ return out('RED_INVALID', text.red_kind, text.reason);
486
+ }
487
+ const noneWritten = Array.isArray(reports) && reports.length > 0 && reports.every((r) => r === NO_REPORT);
488
+ if (!tests && noneWritten && typeof compileError === 'function' && compileError(`${run.stdout ?? ''}\n${run.stderr ?? ''}`)) {
489
+ return out('RED_INVALID', 'compile', 'the test did not compile');
490
+ }
491
+ if (!tests) return out('RED_INVALID', 'error', 'no readable test report');
492
+ if (!tests.some((t) => t.status !== 'skipped')) return out('RED_INVALID', 'error', 'the filter ran no test');
493
+ const confirmed = confirmXCTest(tests, run);
494
+ if (confirmed.some((t) => t.kind === 'error' || t.status === 'error')) return out('RED_INVALID', 'error', 'a test errored or failed without an assertion');
495
+ if (confirmed.some((t) => t.status === 'failed' && t.kind === 'assertion')) return out('RED', 'assertion', 'an assertion failed');
496
+ return out('RED_INVALID', 'error', 'the run failed, but the report has no failure');
497
+ } catch {
498
+ return { verdict: 'RED_INVALID', red_kind: 'error', framework: 'junit', failed: null, failed_tests: [], failed_ids: [], reason: 'no readable test report' };
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Classify the GREEN step: `GREEN` only when the run exited 0, every report is readable, at least
504
+ * one test ran, none failed or errored, and every id that failed at RED is in the report and
505
+ * passed. Never throws.
506
+ * @param {{run: RunResult, reports: ReportResult[], redFailed: string[]}} input
507
+ * @returns {JUnitGreenParse}
508
+ */
509
+ export function classifyGreen(input) {
510
+ try {
511
+ const { run, reports, redFailed } = input;
512
+ const tests = mergeReports(reports);
513
+ const { failed, failed_tests } = failures(tests);
514
+ const base = { failed, failed_tests };
515
+ /** @param {JUnitGreenParse['verdict']} verdict @param {string} reason @returns {JUnitGreenParse} */
516
+ const out = (verdict, reason) => ({ verdict, ...base, reason });
517
+ if (!exitedClean(run)) return out('NOT_GREEN', 'the test run did not exit 0');
518
+ if (!tests) return out('NOT_GREEN', 'no readable test report');
519
+ if (!tests.some((t) => t.status !== 'skipped')) return out('NOT_GREEN', 'the filter ran no test');
520
+ if (tests.some((t) => t.status === 'failed' || t.status === 'error')) return out('NOT_GREEN', 'a test failed');
521
+ if (!Array.isArray(redFailed) || redFailed.length === 0) return out('NOT_GREEN', 'no test failed at RED');
522
+ for (const id of redFailed) {
523
+ const runs = tests.filter((t) => t.id === id);
524
+ if (runs.length === 0 || runs.some((t) => t.status !== 'passed')) return out('NOT_GREEN', 'a test that failed at RED did not pass');
525
+ }
526
+ return out('GREEN', 'every test that failed at RED passed');
527
+ } catch {
528
+ return { verdict: 'NOT_GREEN', failed: null, failed_tests: [], reason: 'no readable test report' };
529
+ }
530
+ }
@@ -24,6 +24,11 @@
24
24
  * ordinary error of the red step (a write error, a command that does not start) restores too;
25
25
  * 6. when the red step was `RED`, run `argv` again: exit 0 ⇒ `GREEN`.
26
26
  *
27
+ * B87: with a `runner` (a `proof.runners` entry, `runners.mjs`), steps 4 and 6 run the runner's
28
+ * argv with a fresh JUnit report per step and classify from the report (`junit.mjs`): RED with
29
+ * `classifyReport`, GREEN with `classifyGreen` (exit 0 and every test that failed at RED passed).
30
+ * The journal, restore and signal rules are the same.
31
+ *
27
32
  * Signals: while the test child runs, `exec` forwards SIGINT/SIGTERM/SIGHUP to its process group
28
33
  * and the runner dies of the signal. The journal is left in place on purpose: restoring from a
29
34
  * signal handler could itself be interrupted, so the one recovery path is `proof restore`, which
@@ -432,33 +437,51 @@ function testEnv() {
432
437
  return env;
433
438
  }
434
439
 
440
+ /**
441
+ * Is `runner` a usable hook: `red` and `green` functions, a string `adapter`, an `env_keys` list of strings?
442
+ * @param {unknown} runner @returns {boolean}
443
+ */
444
+ function isRunnerHook(runner) {
445
+ if (runner === null || typeof runner !== 'object') return false;
446
+ const r = /** @type {any} */ (runner);
447
+ return typeof r.red === 'function' && typeof r.green === 'function' && typeof r.adapter === 'string'
448
+ && Array.isArray(r.env_keys) && r.env_keys.every((/** @type {unknown} */ k) => typeof k === 'string');
449
+ }
450
+
435
451
  /**
436
452
  * @typedef {object} RedGreenOptions
437
453
  * @property {string} workDir - absolute: the export dir, or the workspace under the proof lock
438
454
  * @property {string} [repoDir] - the git repository the base is read from (default `workDir`)
439
455
  * @property {string} base - the block's base sha
440
- * @property {string[]} argv - the test command, already filtered to this test
456
+ * @property {string[]} [argv] - the test command, already filtered to this test (exactly one of
457
+ * `argv` and `runner` is given)
441
458
  * @property {{file: string, case?: string, characterization?: boolean}} test
442
459
  * @property {string[]} [sources] - the block's non-test changed files (repo-relative)
443
460
  * @property {'export' | 'lock'} isolation
444
461
  * @property {number} [timeoutMs]
445
462
  * @property {(line: string) => void} [print]
446
463
  * @property {{runId: string, blockId: string, writeRow: import('../state/run.mjs').WriteRow}} [record]
464
+ * @property {import('./runners.mjs').RunnerHook} [runner] - B87: a `proof.runners` entry runs both
465
+ * steps in place of `argv` (a fresh report per step) and classifies them from its JUnit report
447
466
  */
448
467
 
449
468
  /**
450
469
  * Run one red→green proof. See the module comment for the steps.
451
470
  * @param {RedGreenOptions} opts
452
- * @returns {Promise<{label: string, mechanism: 'revert' | 'assertion-deletion', red: import('./red-parse.mjs').RedParse, green: 'GREEN' | 'NOT_GREEN' | null, proven: boolean}>}
471
+ * @returns {Promise<{label: string, mechanism: 'revert' | 'assertion-deletion', red: import('./red-parse.mjs').RedParse | import('./junit.mjs').JUnitRedParse, green: 'GREEN' | 'NOT_GREEN' | null, proven: boolean}>}
453
472
  * @throws {StateError} `journal-present`, `bad-path`, `bad-isolation`, `bad-ref`, and the assertion-deletion refusals
454
473
  */
455
474
  export async function runRedGreen(opts) {
456
- const { workDir, base, argv, test, sources = [], isolation, timeoutMs = DEFAULT_TEST_TIMEOUT_MS, print = () => {}, record } = opts;
475
+ const { workDir, base, argv, test, sources = [], isolation, timeoutMs = DEFAULT_TEST_TIMEOUT_MS, print = () => {}, record, runner } = opts;
457
476
  if (typeof workDir !== 'string' || !path.isAbsolute(workDir)) throw new StateError('bad-path', 'red-green: workDir must be an absolute path');
458
477
  const repoDir = opts.repoDir ?? workDir;
459
478
  if (!path.isAbsolute(repoDir)) throw new StateError('bad-path', 'red-green: repoDir must be an absolute path');
460
479
  if (!ISOLATIONS.includes(/** @type {any} */ (isolation))) throw new StateError('bad-isolation', `red-green: isolation must be one of ${ISOLATIONS.join(', ')}`);
461
- if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === 'string')) throw new StateError('usage', 'red-green: argv must be a non-empty array of strings');
480
+ // B87: exactly one way to run the test — never a runner with a stray argv, never neither
481
+ // B88: a runner that is given must be a whole hook (a `null` never falls back to argv)
482
+ if (runner !== undefined && !isRunnerHook(runner)) throw new StateError('usage', 'red-green: runner must be an object with red and green functions, an adapter name and an env_keys list');
483
+ if ((argv === undefined) === !runner) throw new StateError('usage', 'red-green: give exactly one of argv and runner');
484
+ if (!runner && (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === 'string'))) throw new StateError('usage', 'red-green: argv must be a non-empty array of strings');
462
485
  assertRelPath(test?.file, 'test.file');
463
486
  for (const s of sources) assertRelPath(s, 'sources');
464
487
  if (sources.includes(test.file)) throw new StateError('bad-path', 'red-green: the test file cannot also be a source file');
@@ -482,7 +505,7 @@ export async function runRedGreen(opts) {
482
505
  }
483
506
 
484
507
  await writeJournal(workDir, [...redState.keys()], { mechanism, test: label, ...(record ? { block: record.blockId } : {}) });
485
- /** @type {import('./red-parse.mjs').RedParse} */
508
+ /** @type {import('./red-parse.mjs').RedParse | import('./junit.mjs').JUnitRedParse} */
486
509
  let red;
487
510
  try {
488
511
  for (const [rel, bytes] of redState) {
@@ -493,7 +516,7 @@ export async function runRedGreen(opts) {
493
516
  await writeFile(abs, bytes);
494
517
  }
495
518
  }
496
- red = parseRed(await exec(argv, { cwd: workDir, env: testEnv(), timeoutMs }));
519
+ red = runner ? await runner.red({ cwd: workDir, env: testEnv(), timeoutMs }) : parseRed(await exec(/** @type {string[]} */ (argv), { cwd: workDir, env: testEnv(), timeoutMs }));
497
520
  } finally {
498
521
  // Any ordinary error in the red step still restores (signals keep the journal: module comment).
499
522
  await restoreJournal({ workDir });
@@ -503,8 +526,20 @@ export async function runRedGreen(opts) {
503
526
  /** @type {'GREEN' | 'NOT_GREEN' | null} */
504
527
  let green = null;
505
528
  if (red.verdict === 'RED') {
506
- const greenRun = await exec(argv, { cwd: workDir, env: testEnv(), timeoutMs });
507
- green = greenRun.result === 'ok' ? 'GREEN' : 'NOT_GREEN';
529
+ if (runner) {
530
+ // B87: GREEN needs exit 0 AND a report in which every test that failed at RED passed. A
531
+ // GREEN step that throws is NOT_GREEN (fail closed), and the row is still written.
532
+ try {
533
+ const value = await runner.green({ cwd: workDir, env: testEnv(), timeoutMs, red: /** @type {import('./junit.mjs').JUnitRedParse} */ (red) });
534
+ // B88: only the exact word GREEN proves; any other value is NOT_GREEN
535
+ green = value === 'GREEN' ? 'GREEN' : 'NOT_GREEN';
536
+ } catch {
537
+ green = 'NOT_GREEN';
538
+ }
539
+ } else {
540
+ const greenRun = await exec(/** @type {string[]} */ (argv), { cwd: workDir, env: testEnv(), timeoutMs });
541
+ green = greenRun.result === 'ok' ? 'GREEN' : 'NOT_GREEN';
542
+ }
508
543
  print(`${green} ${label}`);
509
544
  }
510
545
  const proven = red.verdict === 'RED' && green === 'GREEN';
@@ -512,7 +547,20 @@ export async function runRedGreen(opts) {
512
547
  await recordProof({
513
548
  runId: record.runId,
514
549
  writeRow: record.writeRow,
515
- row: { block: record.blockId, isolation, step: 'red-green', test: label, mechanism, red_kind: red.red_kind, red: red.verdict, green, proven, failed: red.failed },
550
+ row: {
551
+ block: record.blockId,
552
+ isolation,
553
+ step: 'red-green',
554
+ test: label,
555
+ mechanism,
556
+ red_kind: red.red_kind,
557
+ red: red.verdict,
558
+ green,
559
+ proven,
560
+ failed: red.failed,
561
+ // B87: names only (`failed_ids` stays in memory, never in a row)
562
+ ...(runner ? { runner: runner.adapter, failed_tests: /** @type {import('./junit.mjs').JUnitRedParse} */ (red).failed_tests, env_keys: [...runner.env_keys] } : {}),
563
+ },
516
564
  });
517
565
  }
518
566
  return { label, mechanism, red, green, proven };
@@ -40,6 +40,8 @@ const INVALID_FAMILIES = /** @type {const} */ ([
40
40
  /\bClass "?[A-Za-z_\\][\w\\]*"? not found\b/,
41
41
  /\bFailed opening required\b/,
42
42
  /\bInterface "?[A-Za-z_\\][\w\\]*"? not found\b/,
43
+ // Laravel 13's one-line JSON summary under an AI agent: `Class \"App\\Support\\Greeting\" not found`
44
+ /\b(?:Class|Interface) \\"[A-Za-z_\\][\w\\]*\\" not found\b/,
43
45
  ],
44
46
  ],
45
47
  ['compile', 'a syntax or parse error', [/^\s*SyntaxError\b/m, /\bPHP Parse error\b/, /\bParseError\b/, /^\s*Parse error:/m]],