@codedology/code-forge 0.9.0 → 0.11.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/CHANGELOG.md +93 -0
- package/README.md +49 -9
- package/docs/reference/blocks.json +587 -1
- package/docs/reference/config.md +36 -0
- package/package.json +2 -2
- package/schema/code-forge.schema.json +89 -0
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +2 -2
- package/skill/references/adapters/subprocess.md +12 -2
- package/skill/references/code.md +14 -7
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +39 -7
- package/skill/references/security.md +4 -4
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +6 -6
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +262 -31
- package/src/cli/jev.mjs +22 -3
- package/src/cli/keys.mjs +156 -21
- package/src/cli/proof.mjs +35 -7
- package/src/cli/review-file.mjs +17 -11
- package/src/cli/spawn.mjs +44 -3
- package/src/config/validate.mjs +320 -5
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +27 -6
- package/src/doctor/probes.mjs +38 -0
- package/src/engines/builders/codex.mjs +44 -4
- package/src/engines/sandbox-dirs.mjs +257 -0
- package/src/keys/store.mjs +128 -21
- package/src/proof/junit.mjs +530 -0
- package/src/proof/red-green.mjs +57 -9
- package/src/proof/red-parse.mjs +2 -0
- package/src/proof/runners.mjs +472 -0
- package/src/proof/xcresult.mjs +285 -0
- package/src/review/engine.mjs +141 -42
- package/src/review/fixloop.mjs +75 -23
- package/src/review/gate-check.mjs +3 -2
- package/src/review/lenses/A.md +2 -0
- package/src/review/lenses/B.md +2 -0
- package/src/review/lenses/full.md +2 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +2 -0
- package/src/review/packet.mjs +48 -14
- package/src/review/paired.mjs +80 -0
- package/src/review/reopen.mjs +4 -6
- package/src/review/reviewed-lines.mjs +94 -0
- package/src/review/route.mjs +232 -0
- package/src/review/schema-fallback.mjs +77 -22
- package/src/review/session-retry.mjs +44 -9
- package/src/review/test-quality.mjs +201 -0
- package/src/review/verb-forms.mjs +39 -0
- package/src/session/spawn.mjs +39 -2
- package/src/state/block-end.mjs +10 -6
- package/src/state/block.mjs +13 -3
- package/src/state/config-snapshot.mjs +39 -0
- package/src/state/run.mjs +4 -0
- package/src/util/forbidden.mjs +2 -2
- package/src/worker/engine.mjs +43 -15
- package/src/worker/loop.mjs +98 -15
- 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
|
+
}
|
package/src/proof/red-green.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
507
|
-
|
|
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: {
|
|
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 };
|
package/src/proof/red-parse.mjs
CHANGED
|
@@ -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]],
|