@codedology/code-forge 0.9.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.
- package/CHANGELOG.md +58 -0
- package/README.md +29 -8
- package/docs/reference/blocks.json +385 -1
- package/docs/reference/config.md +36 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +89 -0
- 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 +8 -3
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +11 -4
- package/skill/references/security.md +4 -4
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +2 -0
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +164 -24
- package/src/cli/jev.mjs +22 -3
- package/src/cli/keys.mjs +156 -21
- package/src/cli/proof.mjs +35 -7
- 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 +82 -30
- package/src/review/fixloop.mjs +45 -13
- package/src/review/gate-check.mjs +3 -2
- package/src/review/lenses/A.md +1 -0
- package/src/review/lenses/B.md +1 -0
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +1 -0
- package/src/review/reopen.mjs +4 -6
- 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.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/loop.mjs +79 -14
- package/src/worker/ticket.mjs +9 -1
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `proof.runners` (issue #7 part 2, red-green plan §1.2, B87): how `proof red-green` runs a test
|
|
3
|
+
* file that is not a node:test file, and reads its result from a JUnit XML report instead of the
|
|
4
|
+
* console text.
|
|
5
|
+
*
|
|
6
|
+
* The list comes from the run snapshot only (`proofRunners`, never the live `.code-forge.yml`):
|
|
7
|
+
* `run start` freezes it, `run reload` refuses to change it, and with it set no block may own
|
|
8
|
+
* `.code-forge.yml`. A coder cannot change the command that proves its own work.
|
|
9
|
+
*
|
|
10
|
+
* - Selection: the first entry whose `match` covers the `--test` file, in list order. No entry ⇒
|
|
11
|
+
* the caller uses the built-in node:test command, or refuses (`unsupported-test`).
|
|
12
|
+
* - Tokens: an argv item that is exactly `{test_file}`, `{test_id}`, `{test_filter}` or
|
|
13
|
+
* `{report}` is replaced; nothing else is (no shell, no substring expansion).
|
|
14
|
+
* - Each step (RED, GREEN) gets a fresh report dir under the run temp root, outside the work dir;
|
|
15
|
+
* the dir is removed once the report is read.
|
|
16
|
+
* - Fail closed: an entry the validator refuses is never run (`runner-invalid`); a `junit`
|
|
17
|
+
* `{test_filter}` with no `::case`, or a test path or case that starts with `-`, is a usage error.
|
|
18
|
+
* - The env is the test env without any inherited `GIT_*` variable, plus the entry's variables. Values of 8 or more characters are
|
|
19
|
+
* masked in every printed line; only the names are shown (`env_keys`).
|
|
20
|
+
*
|
|
21
|
+
* The `swift` adapter (plan §1.3, B88) differs from `junit` in three ways:
|
|
22
|
+
* - `{test_filter}` with no `::case` is the file's base name without `.swift` (an XCTest class
|
|
23
|
+
* or Swift Testing suite named after its file), regex-escaped and anchored as `\.<name>/`:
|
|
24
|
+
* `swift test --filter` searches `Module.Suite/test` without anchors, so the bare name would
|
|
25
|
+
* also select `BarFooTests` and `FooTestsHelpers`; with a case it is the case, regex-escaped,
|
|
26
|
+
* with `(?:^|[./])` before it and `(?![^/])` after it (`swiftCaseFilter`);
|
|
27
|
+
* - every `*.xml` in the step's report dir is read: `swift test --xunit-output <dir>/report.xml`
|
|
28
|
+
* writes the XCTest report there (SwiftPM 6.3 writes it only with `--parallel`) and the Swift
|
|
29
|
+
* Testing report next to it as `report-swift-testing.xml`; a failure in either counts; a
|
|
30
|
+
* sibling without the main report is `RED_INVALID error` (`--parallel` missing) and carries
|
|
31
|
+
* no failed data; GREEN needs every report RED had;
|
|
32
|
+
* - a failed run that wrote no report and printed a `swiftc` error is `RED_INVALID compile`
|
|
33
|
+
* (`swiftCompileError`): a test of an API that does not exist yet needs the owner's waiver.
|
|
34
|
+
*
|
|
35
|
+
* The `xcodebuild` adapter (plan §1.6, B102) runs `xcodebuild test` and reads its result bundle:
|
|
36
|
+
* - its tokens come from the entry's `xcode` object (`{workspace}`, `{project}`, `{scheme}`,
|
|
37
|
+
* `{destination}`); `{only_testing}` is one item `-only-testing:<test_target>/<Suite>[/<test>]`
|
|
38
|
+
* (the `::case`, or the file's base name without `.swift`); as seen on Xcode 26.6, a Swift
|
|
39
|
+
* Testing test needs its `()` (`capsHigh()`; without it nothing is selected), an XCTest test
|
|
40
|
+
* works with or without them, and a near-name suite is not selected; a nested Swift Testing
|
|
41
|
+
* suite (`Outer/Inner/test()`) is refused as a case: give the outer `<Suite>`;
|
|
42
|
+
* `{result_bundle}` is `<step dir>/r.xcresult`, which does not exist yet;
|
|
43
|
+
* - the precheck (`adapterPrecheck`) runs before the export is built: `xcode-select -p` must not
|
|
44
|
+
* be Command Line Tools (`xcode-missing`), and a simulator destination must name a device that
|
|
45
|
+
* `xcrun simctl list devices available -j` lists (`destination-unavailable`); it never boots,
|
|
46
|
+
* creates or deletes a simulator;
|
|
47
|
+
* - the result is read with `xcresulttool` and classified by `./xcresult.mjs`; every test must sit
|
|
48
|
+
* under `<test_target>/<Suite>/`.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { mkdtemp, readdir, rm } from 'node:fs/promises';
|
|
52
|
+
import path from 'node:path';
|
|
53
|
+
import { RUNNER_ADAPTER_TOKENS, runnerMessages } from '../config/validate.mjs';
|
|
54
|
+
import { MASK_MIN_LENGTH } from '../gates/expand.mjs';
|
|
55
|
+
import { StateError } from '../state/paths.mjs';
|
|
56
|
+
import { globMatch } from '../state/registry.mjs';
|
|
57
|
+
import { exec } from '../util/exec.mjs';
|
|
58
|
+
import { registerSecret } from '../util/redact.mjs';
|
|
59
|
+
import { currentRunRoot } from '../util/tmp.mjs';
|
|
60
|
+
import { classifyGreen, classifyReport, loadReport, swiftCompileError } from './junit.mjs';
|
|
61
|
+
import { classifyXcodeGreen, classifyXcodeRed, loadXcresult } from './xcresult.mjs';
|
|
62
|
+
|
|
63
|
+
/** `proof.runners[].timeout_s` when an entry leaves it out (the schema default). */
|
|
64
|
+
export const DEFAULT_RUNNER_TIMEOUT_S = 600;
|
|
65
|
+
/** The report file name inside a step's dir. */
|
|
66
|
+
const REPORT_NAME = 'report.xml';
|
|
67
|
+
/** B102: the result bundle name inside a step's dir (`{result_bundle}`). */
|
|
68
|
+
const RESULT_BUNDLE_NAME = 'r.xcresult';
|
|
69
|
+
/** B102: each precheck command may run this long. */
|
|
70
|
+
export const PRECHECK_TIMEOUT_MS = 30_000;
|
|
71
|
+
/** B102: the precheck names at most this many available simulators. */
|
|
72
|
+
const MAX_NAMED_SIMULATORS = 10;
|
|
73
|
+
/** B102: a simulator platform of a destination, and its CoreSimulator runtime prefix. */
|
|
74
|
+
const SIMULATOR_RUNTIMES = Object.freeze({
|
|
75
|
+
'iOS Simulator': 'iOS',
|
|
76
|
+
'watchOS Simulator': 'watchOS',
|
|
77
|
+
'tvOS Simulator': 'tvOS',
|
|
78
|
+
'visionOS Simulator': 'xrOS',
|
|
79
|
+
});
|
|
80
|
+
const RUNTIME_KEY = /^com\.apple\.CoreSimulator\.SimRuntime\.([A-Za-z]+)-(\d+(?:-\d+)*)$/;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* B88: the report files a swift RED step wrote, keyed by its result. GREEN needs each of them
|
|
84
|
+
* again: a report that RED had and GREEN lacks is NOT_GREEN, never skipped.
|
|
85
|
+
* @type {WeakMap<object, string[]>}
|
|
86
|
+
*/
|
|
87
|
+
const SWIFT_RED_REPORTS = new WeakMap();
|
|
88
|
+
/** B88: the swift step wrote the Swift Testing report but not the XCTest one at `{report}`. */
|
|
89
|
+
export const SWIFT_NO_MAIN_REPORT =
|
|
90
|
+
'swift test wrote a Swift Testing report but no XCTest report at {report}: keep --parallel in the runner argv (SwiftPM 6.3 writes the XCTest report only with it)';
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @typedef {object} RunnerEntry
|
|
94
|
+
* @property {string[]} match
|
|
95
|
+
* @property {'junit' | 'swift' | 'xcodebuild'} adapter
|
|
96
|
+
* @property {string[]} argv
|
|
97
|
+
* @property {Record<string, string>} [env]
|
|
98
|
+
* @property {number} [timeout_s]
|
|
99
|
+
* @property {{workspace?: string, project?: string, scheme: string, destination: string, test_target: string}} [xcode]
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @typedef {object} RunnerHook - what `runRedGreen` calls in place of its node:test steps.
|
|
104
|
+
* @property {string} adapter
|
|
105
|
+
* @property {string[]} env_keys - the entry's env variable names, sorted (never values)
|
|
106
|
+
* @property {(step: {cwd: string, env: NodeJS.ProcessEnv, timeoutMs: number}) => Promise<import('./junit.mjs').JUnitRedParse>} red
|
|
107
|
+
* @property {(step: {cwd: string, env: NodeJS.ProcessEnv, timeoutMs: number, red: import('./junit.mjs').JUnitRedParse}) => Promise<'GREEN' | 'NOT_GREEN'>} green
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check the snapshot list and every entry again (fail closed: the snapshot was checked at run
|
|
112
|
+
* start, but a record is a file on disk). A value that is not a list, or an empty list, refuses
|
|
113
|
+
* the proof too: never a fallback. The message names key paths only.
|
|
114
|
+
* @param {unknown} runners
|
|
115
|
+
* @returns {RunnerEntry[]}
|
|
116
|
+
* @throws {StateError} `runner-invalid`
|
|
117
|
+
*/
|
|
118
|
+
function checkedRunners(runners) {
|
|
119
|
+
if (!Array.isArray(runners)) throw new StateError('runner-invalid', "the run snapshot's proof.runners is not a list; no proof was run");
|
|
120
|
+
if (runners.length === 0) throw new StateError('runner-invalid', "the run snapshot's proof.runners is an empty list; no proof was run");
|
|
121
|
+
const problems = runners.flatMap((entry, i) => runnerMessages(entry, i));
|
|
122
|
+
if (problems.length > 0) throw new StateError('runner-invalid', `the run snapshot's proof.runners is invalid (${problems.join('; ')}); no proof was run`);
|
|
123
|
+
return /** @type {RunnerEntry[]} */ (runners);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The first runner whose `match` covers `file`, or null.
|
|
128
|
+
* @param {unknown} runners - the snapshot's `proof.runners`, or null without one
|
|
129
|
+
* @param {string} file - repo-relative POSIX path
|
|
130
|
+
* @returns {RunnerEntry | null}
|
|
131
|
+
* @throws {StateError} `runner-invalid`
|
|
132
|
+
*/
|
|
133
|
+
export function selectRunner(runners, file) {
|
|
134
|
+
if (runners === null) return null;
|
|
135
|
+
return checkedRunners(runners).find((entry) => entry.match.some((glob) => globMatch(file, glob))) ?? null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Is `file` a test file of any configured runner? Such a file is never reverted as a source.
|
|
140
|
+
* @param {unknown} runners @param {string} file
|
|
141
|
+
* @returns {boolean}
|
|
142
|
+
* @throws {StateError} `runner-invalid`
|
|
143
|
+
*/
|
|
144
|
+
export function runnerCovers(runners, file) {
|
|
145
|
+
if (runners === null) return false;
|
|
146
|
+
return checkedRunners(runners).some((entry) => entry.match.some((glob) => globMatch(file, glob)));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** @param {string} s @returns {string} `s` with every regex metacharacter escaped */
|
|
150
|
+
const regexEscape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* B88: the `swift` adapter's `{test_filter}` when no `::case` is given: the file's base name without
|
|
154
|
+
* `.swift` (an XCTest class or a Swift Testing suite named after its file), regex-escaped, with a
|
|
155
|
+
* `.` before it and a `/` after it, so it matches the `Module.Suite/test` id of that suite only.
|
|
156
|
+
* @param {string} file @returns {string}
|
|
157
|
+
*/
|
|
158
|
+
const swiftDefaultFilter = (file) => `\\.${regexEscape(path.posix.basename(file, '.swift'))}/`;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* B88: the `swift` adapter's `{test_filter}` for a `::case`: the case, regex-escaped, starting the
|
|
162
|
+
* id or after a `.` or `/`, and ending it or followed by a `/`. The end is not `$`: SwiftPM 6.3.3
|
|
163
|
+
* matches a Swift Testing test against an id with more after the test name, so `$` selects no
|
|
164
|
+
* Swift Testing test (checked live; `\.Suite/test$` still selects an XCTest test). `Suite/capsHigh()` then never selects
|
|
165
|
+
* `MoreSuite/capsHigh()`, and `testFoo` never selects `testFooBar`.
|
|
166
|
+
* @param {string} testCase @returns {string}
|
|
167
|
+
*/
|
|
168
|
+
const swiftCaseFilter = (testCase) => `(?:^|[./])${regexEscape(testCase)}(?![^/])`;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* B102: the `xcodebuild` adapter's test path within its target: the `::case` (`<Suite>` or
|
|
172
|
+
* `<Suite>/<test>`), or the file's base name without `.swift`.
|
|
173
|
+
* @param {string} file @param {string | undefined} testCase @returns {string}
|
|
174
|
+
* @throws {StateError} `usage`
|
|
175
|
+
*/
|
|
176
|
+
function xcodeTestPath(file, testCase) {
|
|
177
|
+
if (testCase === undefined) return path.posix.basename(file, '.swift');
|
|
178
|
+
if (testCase.startsWith('-')) throw new StateError('usage', 'the test case starts with "-" (it would be read as an option)');
|
|
179
|
+
if (testCase.startsWith('/') || testCase.endsWith('/') || testCase.includes('//')) throw new StateError('usage', 'an xcodebuild test case is <Suite> or <Suite>/<test>, with no empty part');
|
|
180
|
+
if (testCase.split('/').length > 2) {
|
|
181
|
+
throw new StateError('usage', 'an xcodebuild test case is <Suite> or <Suite>/<test>; a nested Swift Testing suite (Outer/Inner/test()) is not supported — give the outer <Suite> to run all of it');
|
|
182
|
+
}
|
|
183
|
+
return testCase;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* B102: the entry's `xcode.test_target`, which `{only_testing}` and the scope start with.
|
|
188
|
+
* @param {RunnerEntry} entry @returns {string}
|
|
189
|
+
* @throws {StateError} `runner-invalid` when it is not one target name (the validator refuses this, but a record is a file on disk)
|
|
190
|
+
*/
|
|
191
|
+
function xcodeTestTarget(entry) {
|
|
192
|
+
const target = entry.xcode?.test_target;
|
|
193
|
+
if (typeof target !== 'string' || target === '' || target.includes('/')) {
|
|
194
|
+
throw new StateError('runner-invalid', "the xcodebuild runner's xcode.test_target is not set to one target name; no proof was run");
|
|
195
|
+
}
|
|
196
|
+
return target;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* B102: the prefix every test id of an `xcodebuild` result must have: `<test_target>/<Suite>/`.
|
|
201
|
+
* @param {RunnerEntry} entry @param {{file: string, testCase?: string}} test @returns {string}
|
|
202
|
+
*/
|
|
203
|
+
export function xcodeScope(entry, { file, testCase }) {
|
|
204
|
+
return `${xcodeTestTarget(entry)}/${xcodeTestPath(file, testCase).split('/')[0]}/`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The entry's argv with its tokens replaced, for a report at `report` (and, for `xcodebuild`, a
|
|
209
|
+
* result bundle at `resultBundle`).
|
|
210
|
+
* @param {RunnerEntry} entry @param {{file: string, testCase?: string, report: string, resultBundle?: string}} values
|
|
211
|
+
* @returns {string[]}
|
|
212
|
+
* @throws {StateError} `usage`, or `runner-invalid` for an `xcodebuild` entry whose token has no value
|
|
213
|
+
*/
|
|
214
|
+
export function expandRunnerArgv(entry, { file, testCase, report, resultBundle }) {
|
|
215
|
+
if (file.startsWith('-')) throw new StateError('usage', `the test path ${JSON.stringify(file)} starts with "-" (it would be read as an option)`);
|
|
216
|
+
// one source of truth: the adapter's own token list (the validator refuses any other token)
|
|
217
|
+
const known = RUNNER_ADAPTER_TOKENS[entry.adapter];
|
|
218
|
+
const uses = (/** @type {string} */ token) => known.includes(token) && entry.argv.slice(1).includes(token);
|
|
219
|
+
if (testCase === '') throw new StateError('usage', '--test <file>::<case> needs a case name after ::');
|
|
220
|
+
if (entry.adapter === 'xcodebuild') {
|
|
221
|
+
const xcode = entry.xcode;
|
|
222
|
+
const target = xcodeTestTarget(entry);
|
|
223
|
+
/** @type {Record<string, string | undefined>} */
|
|
224
|
+
const xvalues = {
|
|
225
|
+
'{workspace}': xcode?.workspace,
|
|
226
|
+
'{project}': xcode?.project,
|
|
227
|
+
'{scheme}': xcode?.scheme,
|
|
228
|
+
'{destination}': xcode?.destination,
|
|
229
|
+
'{only_testing}': `-only-testing:${target}/${xcodeTestPath(file, testCase)}`,
|
|
230
|
+
'{result_bundle}': resultBundle ?? path.join(path.dirname(report), RESULT_BUNDLE_NAME),
|
|
231
|
+
};
|
|
232
|
+
return entry.argv.map((item, i) => {
|
|
233
|
+
if (i === 0 || !known.includes(item)) return item;
|
|
234
|
+
const value = xvalues[item];
|
|
235
|
+
// fail closed: the validator refuses this, but a record is a file on disk
|
|
236
|
+
if (typeof value !== 'string') throw new StateError('runner-invalid', `the xcodebuild runner's ${item} has no value in its xcode object; no proof was run`);
|
|
237
|
+
return value;
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (uses('{test_filter}')) {
|
|
241
|
+
if (testCase === undefined && entry.adapter !== 'swift') throw new StateError('usage', `the ${entry.adapter} runner for ${file} filters by test case — give --test <file>::<case>`);
|
|
242
|
+
if (testCase !== undefined && testCase.startsWith('-')) throw new StateError('usage', 'the test case starts with "-" (it would be read as an option)');
|
|
243
|
+
}
|
|
244
|
+
/** @type {Record<string, string>} */
|
|
245
|
+
const values = {
|
|
246
|
+
'{test_file}': file,
|
|
247
|
+
'{test_id}': testCase === undefined ? file : `${file}::${testCase}`,
|
|
248
|
+
'{test_filter}': testCase === undefined ? swiftDefaultFilter(file) : entry.adapter === 'swift' ? swiftCaseFilter(testCase) : regexEscape(testCase),
|
|
249
|
+
'{report}': report,
|
|
250
|
+
};
|
|
251
|
+
return entry.argv.map((item, i) => (i > 0 && known.includes(item) ? values[item] : item));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** @param {RunnerEntry} entry @returns {string[]} the sorted env variable names (never values) */
|
|
255
|
+
export function runnerEnvKeys(entry) {
|
|
256
|
+
return entry.env ? Object.keys(entry.env).sort() : [];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The runner child's env: the test env with every inherited `GIT_*` variable removed (an inherited
|
|
261
|
+
* `GIT_DIR` or `GIT_WORK_TREE` would point a runner's git at another repository than the work
|
|
262
|
+
* dir), then the entry's own variables, which the owner set on purpose.
|
|
263
|
+
* @param {NodeJS.ProcessEnv} env @param {RunnerEntry} entry @returns {NodeJS.ProcessEnv}
|
|
264
|
+
*/
|
|
265
|
+
export function runnerEnv(env, entry) {
|
|
266
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
267
|
+
const out = {};
|
|
268
|
+
for (const [key, value] of Object.entries(env)) if (!key.startsWith('GIT_')) out[key] = value;
|
|
269
|
+
return { ...out, ...(entry.env ?? {}) };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** @param {RunnerEntry} entry @returns {number} the step timeout in ms */
|
|
273
|
+
export function runnerTimeoutMs(entry) {
|
|
274
|
+
return (entry.timeout_s ?? DEFAULT_RUNNER_TIMEOUT_S) * 1000;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Run a precheck command: argv only, the runner env (no `GIT_*`), a timeout. Never throws; a
|
|
279
|
+
* command that runs past the timeout is stopped and read as failed.
|
|
280
|
+
* @param {string[]} argv @param {NodeJS.ProcessEnv} env @param {number} timeoutMs
|
|
281
|
+
* @returns {Promise<{ok: boolean, stdout: string}>}
|
|
282
|
+
*/
|
|
283
|
+
async function precheckRun(argv, env, timeoutMs) {
|
|
284
|
+
try {
|
|
285
|
+
const res = await exec(argv, { env, timeoutMs });
|
|
286
|
+
return { ok: res.result === 'ok' && res.code === 0 && !res.signal && !res.timedOut, stdout: res.stdout };
|
|
287
|
+
} catch {
|
|
288
|
+
return { ok: false, stdout: '' };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* B102: a destination string's `key=value` pairs, or null when it is not such a list.
|
|
294
|
+
* @param {string} destination @returns {Map<string, string> | null}
|
|
295
|
+
*/
|
|
296
|
+
function destinationPairs(destination) {
|
|
297
|
+
const pairs = new Map();
|
|
298
|
+
for (const part of destination.split(',')) {
|
|
299
|
+
const eq = part.indexOf('=');
|
|
300
|
+
if (eq <= 0) return null;
|
|
301
|
+
const key = part.slice(0, eq).trim();
|
|
302
|
+
if (key === '' || pairs.has(key)) return null;
|
|
303
|
+
pairs.set(key, part.slice(eq + 1).trim());
|
|
304
|
+
}
|
|
305
|
+
return pairs;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* B102: the available simulators of one runtime family from `xcrun simctl list devices available -j`,
|
|
310
|
+
* or null when the output is not the shape this reader knows.
|
|
311
|
+
* @param {string} text @param {string} family - `iOS`, `watchOS`, `tvOS`, `xrOS`
|
|
312
|
+
* @returns {{name: string, udid: string, os: string}[] | null}
|
|
313
|
+
*/
|
|
314
|
+
function availableSimulators(text, family) {
|
|
315
|
+
let doc;
|
|
316
|
+
try {
|
|
317
|
+
doc = JSON.parse(text);
|
|
318
|
+
} catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
if (doc === null || typeof doc !== 'object' || doc.devices === null || typeof doc.devices !== 'object' || Array.isArray(doc.devices)) return null;
|
|
322
|
+
const out = [];
|
|
323
|
+
for (const [runtime, devices] of Object.entries(doc.devices)) {
|
|
324
|
+
if (!Array.isArray(devices)) return null;
|
|
325
|
+
const m = RUNTIME_KEY.exec(runtime);
|
|
326
|
+
if (!m || m[1] !== family) continue;
|
|
327
|
+
for (const device of devices) {
|
|
328
|
+
if (device === null || typeof device !== 'object' || typeof device.name !== 'string' || typeof device.udid !== 'string') return null;
|
|
329
|
+
// `available` already filters; a device that still says otherwise is not offered
|
|
330
|
+
if (device.isAvailable !== true) continue;
|
|
331
|
+
out.push({ name: device.name, udid: device.udid, os: m[2].replaceAll('-', '.') });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The adapter's precheck, run before the export is built and before any file is reverted. The
|
|
339
|
+
* `junit` and `swift` adapters have none. `xcodebuild` (B102) needs full Xcode, and a simulator
|
|
340
|
+
* destination needs a device `simctl` lists as available (by `name` or `id`, and `OS` when given;
|
|
341
|
+
* `OS=latest` matches any). It only reads: it never boots, creates or deletes a simulator.
|
|
342
|
+
* Messages never repeat a config value.
|
|
343
|
+
* @param {RunnerEntry} entry
|
|
344
|
+
* @param {{env?: NodeJS.ProcessEnv, timeoutMs?: number}} [opts] - the env the runner would get
|
|
345
|
+
* (default `process.env`), and how long each command may run (default `PRECHECK_TIMEOUT_MS`;
|
|
346
|
+
* tests shorten it)
|
|
347
|
+
* @returns {Promise<void>}
|
|
348
|
+
* @throws {StateError} `xcode-missing`, `destination-unavailable`
|
|
349
|
+
*/
|
|
350
|
+
export async function adapterPrecheck(entry, opts = {}) {
|
|
351
|
+
if (entry.adapter !== 'xcodebuild') return;
|
|
352
|
+
const env = runnerEnv(opts.env ?? process.env, entry);
|
|
353
|
+
const timeoutMs = opts.timeoutMs ?? PRECHECK_TIMEOUT_MS;
|
|
354
|
+
const select = await precheckRun(['xcode-select', '-p'], env, timeoutMs);
|
|
355
|
+
const developerDir = select.stdout.trim();
|
|
356
|
+
if (!select.ok || developerDir === '' || developerDir.includes('CommandLineTools')) {
|
|
357
|
+
throw new StateError(
|
|
358
|
+
'xcode-missing',
|
|
359
|
+
'xcode-missing: xcode-select -p does not point at a full Xcode (Command Line Tools only, or no developer directory); the xcodebuild runner needs Xcode — install it and run xcode-select -s; no proof was run',
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
const pairs = destinationPairs(entry.xcode?.destination ?? '');
|
|
363
|
+
if (pairs === null) throw new StateError('destination-unavailable', "destination-unavailable: the runner's xcode.destination is not a list of key=value pairs; no proof was run");
|
|
364
|
+
const family = SIMULATOR_RUNTIMES[/** @type {keyof typeof SIMULATOR_RUNTIMES} */ (pairs.get('platform') ?? '')];
|
|
365
|
+
// platform=macOS and every other non-simulator destination need no simulator
|
|
366
|
+
if (family === undefined) return;
|
|
367
|
+
const name = pairs.get('name');
|
|
368
|
+
const id = pairs.get('id');
|
|
369
|
+
const os = pairs.get('OS');
|
|
370
|
+
if (name === undefined && id === undefined) {
|
|
371
|
+
throw new StateError(
|
|
372
|
+
'destination-unavailable',
|
|
373
|
+
"destination-unavailable: the runner's xcode.destination names no simulator: a simulator destination needs name= or id=; no proof was run",
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
const list = await precheckRun(['xcrun', 'simctl', 'list', 'devices', 'available', '-j'], env, timeoutMs);
|
|
377
|
+
const simulators = list.ok ? availableSimulators(list.stdout, family) : null;
|
|
378
|
+
if (simulators === null) throw new StateError('destination-unavailable', 'destination-unavailable: xcrun simctl did not list the available simulators; no proof was run');
|
|
379
|
+
const fits = simulators.some(
|
|
380
|
+
(s) => (name === undefined || s.name === name) && (id === undefined || s.udid === id) && (os === undefined || os === 'latest' || s.os === os || s.os.startsWith(`${os}.`)),
|
|
381
|
+
);
|
|
382
|
+
if (fits) return;
|
|
383
|
+
const names = [...new Set(simulators.map((s) => s.name))].sort().slice(0, MAX_NAMED_SIMULATORS);
|
|
384
|
+
throw new StateError(
|
|
385
|
+
'destination-unavailable',
|
|
386
|
+
`destination-unavailable: the runner's xcode.destination names no simulator that xcrun simctl lists as available (by name or id, and OS when given); available: ${names.length > 0 ? names.join(', ') : 'none'} — code-forge never boots, creates or deletes a simulator; no proof was run`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Validate the expansion up front (so a usage error changes nothing) and build the hook
|
|
392
|
+
* `runRedGreen` calls for the RED and GREEN steps.
|
|
393
|
+
* @param {RunnerEntry} entry @param {{file: string, testCase?: string}} test
|
|
394
|
+
* @returns {RunnerHook}
|
|
395
|
+
* @throws {StateError} `usage`
|
|
396
|
+
*/
|
|
397
|
+
export function runnerHook(entry, { file, testCase }) {
|
|
398
|
+
expandRunnerArgv(entry, { file, testCase, report: path.join(currentRunRoot(), REPORT_NAME) });
|
|
399
|
+
// gate env is not a secret store, and neither is this; long values are still masked in output
|
|
400
|
+
for (const value of Object.values(entry.env ?? {})) if (value.length >= MASK_MIN_LENGTH) registerSecret(value);
|
|
401
|
+
if (entry.adapter === 'xcodebuild') return xcodeHook(entry, { file, testCase });
|
|
402
|
+
|
|
403
|
+
/** @param {{cwd: string, env: NodeJS.ProcessEnv, timeoutMs: number}} step */
|
|
404
|
+
const runStep = async ({ cwd, env, timeoutMs }) => {
|
|
405
|
+
const dir = await mkdtemp(path.join(currentRunRoot(), 'proof-report-'));
|
|
406
|
+
try {
|
|
407
|
+
const report = path.join(dir, REPORT_NAME);
|
|
408
|
+
const argv = expandRunnerArgv(entry, { file, testCase, report });
|
|
409
|
+
const run = await exec(argv, { cwd, env: runnerEnv(env, entry), timeoutMs });
|
|
410
|
+
if (entry.adapter !== 'swift') return { run, reports: [loadReport(report)], noMain: false, names: [] };
|
|
411
|
+
const names = (await readdir(dir)).filter((name) => name.endsWith('.xml')).sort();
|
|
412
|
+
// none written ⇒ the missing main report, read as NO_REPORT
|
|
413
|
+
if (names.length === 0) return { run, reports: [loadReport(report)], noMain: false, names };
|
|
414
|
+
// a sibling without the main report: unreadable, so it never proves anything
|
|
415
|
+
const noMain = !names.includes(REPORT_NAME);
|
|
416
|
+
return { run, reports: [...(noMain ? [{ unreadable: SWIFT_NO_MAIN_REPORT }] : []), ...names.map((name) => loadReport(path.join(dir, name)))], noMain, names };
|
|
417
|
+
} finally {
|
|
418
|
+
await rm(dir, { recursive: true, force: true });
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
return {
|
|
422
|
+
adapter: entry.adapter,
|
|
423
|
+
env_keys: runnerEnvKeys(entry),
|
|
424
|
+
red: async (step) => {
|
|
425
|
+
const { run, reports, noMain, names } = await runStep(step);
|
|
426
|
+
const parsed = classifyReport({ run, reports, ...(entry.adapter === 'swift' ? { compileError: swiftCompileError } : {}) });
|
|
427
|
+
// an invalid RED carries no failed data: the sibling's failures never reach the row
|
|
428
|
+
/** @type {import('./junit.mjs').JUnitRedParse} */
|
|
429
|
+
const result = noMain ? { ...parsed, verdict: 'RED_INVALID', red_kind: 'error', failed: null, failed_tests: [], failed_ids: [], reason: SWIFT_NO_MAIN_REPORT } : parsed;
|
|
430
|
+
if (entry.adapter === 'swift') SWIFT_RED_REPORTS.set(result, names);
|
|
431
|
+
return result;
|
|
432
|
+
},
|
|
433
|
+
green: async ({ red, ...step }) => {
|
|
434
|
+
const { run, reports, names } = await runStep(step);
|
|
435
|
+
if (entry.adapter === 'swift') {
|
|
436
|
+
// fail closed: a RED no swift hook classified, or a report RED had and GREEN lacks
|
|
437
|
+
const had = SWIFT_RED_REPORTS.get(red);
|
|
438
|
+
if (!had || had.some((name) => !names.includes(name))) return 'NOT_GREEN';
|
|
439
|
+
}
|
|
440
|
+
return classifyGreen({ run, reports, redFailed: red.failed_ids }).verdict;
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* B102: the `xcodebuild` hook. Each step runs `xcodebuild` with a fresh `{result_bundle}` in its
|
|
447
|
+
* own dir under the run temp root, reads the bundle with `xcresulttool`, and removes the dir.
|
|
448
|
+
* @param {RunnerEntry} entry @param {{file: string, testCase?: string}} test
|
|
449
|
+
* @returns {RunnerHook}
|
|
450
|
+
*/
|
|
451
|
+
function xcodeHook(entry, { file, testCase }) {
|
|
452
|
+
const scope = xcodeScope(entry, { file, testCase });
|
|
453
|
+
/** @param {{cwd: string, env: NodeJS.ProcessEnv, timeoutMs: number}} step */
|
|
454
|
+
const runStep = async ({ cwd, env, timeoutMs }) => {
|
|
455
|
+
const dir = await mkdtemp(path.join(currentRunRoot(), 'proof-report-'));
|
|
456
|
+
try {
|
|
457
|
+
const resultBundle = path.join(dir, RESULT_BUNDLE_NAME);
|
|
458
|
+
const argv = expandRunnerArgv(entry, { file, testCase, report: path.join(dir, REPORT_NAME), resultBundle });
|
|
459
|
+
const childEnv = runnerEnv(env, entry);
|
|
460
|
+
const run = await exec(argv, { cwd, env: childEnv, timeoutMs });
|
|
461
|
+
return { run, report: await loadXcresult(resultBundle, { cwd: dir, env: childEnv }) };
|
|
462
|
+
} finally {
|
|
463
|
+
await rm(dir, { recursive: true, force: true });
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
return {
|
|
467
|
+
adapter: entry.adapter,
|
|
468
|
+
env_keys: runnerEnvKeys(entry),
|
|
469
|
+
red: async (step) => classifyXcodeRed({ ...(await runStep(step)), scope }),
|
|
470
|
+
green: async ({ red, ...step }) => classifyXcodeGreen({ ...(await runStep(step)), redFailed: red.failed_ids, scope }).verdict,
|
|
471
|
+
};
|
|
472
|
+
}
|