bareloop 0.8.0 → 0.9.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/src/kinds.js ADDED
@@ -0,0 +1,1354 @@
1
+ // THE KIND EXECUTOR — close-authoring v1, module M1 (design record
2
+ // 2026-08-07, gate-1 Result 3; gate-2 POC closed 2026-08-08).
3
+ //
4
+ // A close stops being a hand-written script and becomes a DECLARATION over
5
+ // kinds whose implementations we own. This module is those implementations.
6
+ // The agent never authors a stage body — it parameterises four kinds, and
7
+ // anything it could not say here it cannot make the arbiter do (the
8
+ // `check-passes(name)` move: illegal is inexpressible, not rejected late).
9
+ //
10
+ // The four kinds are the small part. The load-bearing part is the THREE
11
+ // RUNTIME CONTRACTS every kind inherits — they live HERE, in the executor,
12
+ // never in a kind, and no declaration can weaken them:
13
+ //
14
+ // (a) THE INSTRUMENT-STOP CONTRACT. exit 97 with `judged` deliberately
15
+ // WITHHELD — for a timeout, a null exit code, unreadable or
16
+ // unparseable required output, a buffer overrun, a missing seed
17
+ // commit, a region anchor that located nothing, and a `first` term
18
+ // that was never reported. A broken instrument is a CASUALTY, never a
19
+ // red (F45); a close that reports one as a red manufactures fake
20
+ // evidence, and the judged floor (F17) is what makes the difference
21
+ // visible downstream. The sharp edge is the zero-match split: `sum`
22
+ // over nothing is a COUNTED zero (the region was searched), `first`
23
+ // over nothing is UNKNOWN — and unknown is never zero (F6).
24
+ //
25
+ // (b) THE GAP OUTPUT CONTRACT. Every line carries the spec's `gapKeep`, a
26
+ // trim is ANNOUNCED with its counts and never silent (F28 — a bound
27
+ // that eats the failure names deletes exactly the mechanical detail
28
+ // that converts, F38/F46), and no culprit is named beyond what the
29
+ // instrument itself reported.
30
+ //
31
+ // (c) THE CHANGED-SET PRIMITIVE. `git diff --name-only <seed>` PLUS
32
+ // untracked files, MINUS two arbiter books. Both exclusions were paid
33
+ // for live: the `gate-audit.jsonl` one red-carded the arbiter's own
34
+ // file mid-run (u-msdonzxl) and burned a run to the wall. No user
35
+ // could know to exclude them, which is why they sit below the stage
36
+ // level in a shared primitive rather than in anyone's parameters.
37
+ //
38
+ // D12 rides on top of (a): the close stores the COUNTING RULE, never the
39
+ // number. `baseline: "seed"` is MEASURED at this run's own seed every run —
40
+ // in a detached worktree, or in place when the tree is verifiably identical
41
+ // to the seed — and WHICH route was taken is recorded (`baselineSource`),
42
+ // never silent. A frozen constant would judge run 5 against run 1's tree.
43
+ //
44
+ // Two deliberate divergences from the gate-2 POC executor, both for shipped
45
+ // reasons (this module is a graduation — a rewrite, never a copy):
46
+ // - it is ASYNC. `spawnSync` blocks the host event loop for the child's
47
+ // whole duration (F68 measured 9 loop freezes in one green run, worst
48
+ // 74.3s), which is precisely when the stall fuse's timers cannot fire.
49
+ // Only the WAIT is async; every observable semantic is spawnSync's.
50
+ // - seed worktrees live in a caller-scoped holder under the OS temp dir
51
+ // and are removed, never in a module-global cache inside the package.
52
+ //
53
+ // Secrets: this module does NOT scrub. A stage's output is redacted by the
54
+ // CALLER at the emission boundary, through the one `SECRET_PATTERNS`
55
+ // inventory, before anything reaches the append-only spine — the same split
56
+ // `src/exits.js` documents. The env handed to the child IS blinded here,
57
+ // because a stage runs worker-authored code (`npm test`, `pytest`) and the
58
+ // operator's credentials have no business in it: `CLOSE_ENV_DENY` is
59
+ // imported from the arbiter's shell, never re-spelled — two inventories are
60
+ // two instruments.
61
+
62
+ import { spawn } from 'node:child_process';
63
+ import { realpathSync } from 'node:fs';
64
+ import { readFile, stat, mkdtemp, rm, symlink } from 'node:fs/promises';
65
+ import { tmpdir } from 'node:os';
66
+ import { join, sep } from 'node:path';
67
+ import { globToPrefix } from './validate.js';
68
+ import { CLOSE_ENV_DENY } from './ralph.js';
69
+
70
+ /** the close's two judgment exits — a stage's verdict, in the shape the
71
+ * hand-written closes already spoke (exit code is truth) */
72
+ export const EXIT_GREEN = 0;
73
+ export const EXIT_RED = 1;
74
+ /** contract (a): the instrument-stop exit. `judged` is withheld with it. */
75
+ export const EXIT_STOP = 97;
76
+ /** The judgment-rendered marker a rendered close prints, for ralph's
77
+ * `runClose` judged floor (src/ralph.js). It rides EVERY real judgment, green or red,
78
+ * and NEVER a stop — this module reports the same fact as the boolean
79
+ * `judged`; the marker exists so a renderer and the floor agree on one
80
+ * spelling. */
81
+ export const JUDGED_MARKER = 'judged=1';
82
+
83
+ /** contract (b): the per-stage gap ceiling. Trims are announced, never silent. */
84
+ export const GAP_LINE_CAP = 40;
85
+ /**
86
+ * The exact phrase this executor's trim announcement carries. Exported for the
87
+ * same reason ralph exports `GAP_KEEP_TRIM_MARKER`: Layer R's detector must be
88
+ * able to see that a gap window was TRIMMED, because failures beyond the window
89
+ * can move while the visible lines stay identical — and an undetected trim makes
90
+ * the detector claim "reds unchanged" off an instrument that was blind (F43,
91
+ * finding 5). A magic string on the reading side would drift the day this
92
+ * wording changes; the renderer below is its only other use.
93
+ */
94
+ export const GAP_TRIM_MARKER = 'gap trimmed:';
95
+ /** used when neither the stage nor the ctx names a bound. A stage that could
96
+ * run forever is not a red — it is a stop, and something must decide when. */
97
+ export const DEFAULT_TIMEOUT_MS = 300_000;
98
+ /** PER STREAM, like the arbiter's own close runner. An overrun truncates the
99
+ * output mid-summary, so any number read out of it is unknown, not zero. */
100
+ export const MAX_BUFFER = 16 * 1024 * 1024;
101
+ /** an untracked file larger than this is not scanned line-by-line; the skip
102
+ * is ANNOUNCED in the gap rather than passing as a clean scan */
103
+ export const MAX_SCAN_BYTES = 2 * 1024 * 1024;
104
+ /** ceiling on a parser's term list — a runaway parser is a red, not a bill */
105
+ export const MAX_TERMS = 8;
106
+
107
+ /**
108
+ * WHICH KIND of instrument stop, in the ARBITER's own four-row vocabulary
109
+ * (`CLOSE_FAULTS`, src/ralph.js). Stamped at the site that OBSERVES the fault —
110
+ * never sniffed out of the stop's prose downstream (the typed-attribution rule:
111
+ * a `lib`/fault field is stamped at the throw site, and prose is a fallback for
112
+ * pre-typed spines only).
113
+ *
114
+ * The rows are kept apart for the reason `CLOSE_FAULTS` keeps them apart: they
115
+ * are DIFFERENT HUMAN DECISIONS. "raise the close timeout", "fix the argv" and
116
+ * "re-run, it was OOM-killed" are three answers, and a declared close that
117
+ * pooled all three into `failed` would hand every one of them the same option
118
+ * list. `failed` is the default because "the close cannot run" is the safe
119
+ * reading of a fault nothing else claimed.
120
+ */
121
+ export const STOP_FAULTS = Object.freeze({
122
+ /** the stage cannot RUN at all: spawn failure, a missing seed commit, git refusing */
123
+ FAILED: 'failed',
124
+ /** it ran and never finished judging */
125
+ TIMED_OUT: 'timed-out',
126
+ /** it died by signal — no opinion was ever rendered */
127
+ KILLED: 'killed',
128
+ /** it ran, came back, and judged nothing: unreadable output, a blown buffer,
129
+ * a number that was never reported, a parser that does not normalise */
130
+ CRASHED: 'crashed',
131
+ });
132
+
133
+ export const AGGREGATES = Object.freeze(['first', 'sum']);
134
+ export const SIGNS = Object.freeze([1, -1]);
135
+ /** every kind this executor implements. `judged-floor` / `human-confirms`
136
+ * are catalogue entries and are deliberately absent here: declaring one is
137
+ * counted demand (the request-red path), and reaching one at runtime is a
138
+ * stop — never a red, and never a silent pass. */
139
+ export const LIVE_KINDS = Object.freeze(['command-exit', 'count-not-worse', 'pattern-absent-in-diff', 'files-changed']);
140
+
141
+ /**
142
+ * @typedef {{code: string, path: string, detail: string}} Red
143
+ * @typedef {{workdir: string, seedRef: string, gapKeep: string,
144
+ * timeoutMsDefault?: number, baselineMode?: 'auto'|'worktree',
145
+ * gapCap?: number, maxBuffer?: number, seedTrees?: SeedTrees}} Ctx
146
+ * The arbiter's half of a stage run. Every field here is operator/runner
147
+ * territory: a declaration parameterises kinds, never the contracts.
148
+ * @typedef {{verdict: 'green'|'red'|'instrument-stop'|'not-reached',
149
+ * exitCode: number|null, value: number|null, baseline: number|null,
150
+ * baselineSource: string|null, gapLines: string[], judged: boolean,
151
+ * stage: string|null, kind: string|null, detail: Record<string, any>}} StageResult
152
+ * @typedef {{ensure: (workdir: string, seedRef: string) => Promise<{stop: string, fault: string}|{stop: null, dir: string}>,
153
+ * cleanup: () => Promise<{ok: boolean, leaked: string[], detail: string|null}>}} SeedTrees
154
+ */
155
+
156
+ // ── contract (b): the gap ────────────────────────────────────────────────────
157
+
158
+ /**
159
+ * Gap lines, prefixed and capped, with the trim ANNOUNCED. Embedded newlines
160
+ * are split so one pushed blob cannot smuggle past the line cap.
161
+ */
162
+ class Gap {
163
+ /** @param {string} keep @param {number} [cap] */
164
+ constructor(keep, cap = GAP_LINE_CAP) {
165
+ this.keep = keep;
166
+ this.cap = cap > 0 ? cap : GAP_LINE_CAP;
167
+ /** @type {string[]} */
168
+ this.lines = [];
169
+ }
170
+
171
+ /** @param {string} line */
172
+ push(line) {
173
+ for (const part of String(line).split('\n')) this.lines.push(part);
174
+ return this;
175
+ }
176
+
177
+ /** @returns {string[]} */
178
+ render() {
179
+ const p = (/** @type {string} */ l) => `${this.keep}${l}`;
180
+ if (this.lines.length <= this.cap) return this.lines.map(p);
181
+ const kept = this.lines.slice(0, this.cap - 1);
182
+ const withheld = this.lines.length - kept.length;
183
+ return [...kept.map(p), p(`[${GAP_TRIM_MARKER} ${withheld} of ${this.lines.length} lines withheld — the cap is ${this.cap}]`)];
184
+ }
185
+ }
186
+
187
+ // ── the child process: only the WAIT is async (F68) ──────────────────────────
188
+
189
+ /**
190
+ * The environment a stage's child receives. Two strips, both deliberate:
191
+ *
192
+ * - `NODE_TEST_CONTEXT`: inherited from a host test runner, a `node --test`
193
+ * stage silently no-ops — a confident fake green.
194
+ * - `CLOSE_ENV_DENY` (imported, never re-spelled): a stage runs
195
+ * worker-authored code, and none of git/tsc/mypy/pytest needs the
196
+ * operator's provider key to judge a tree.
197
+ *
198
+ * A DECLARED env var whose name is on the deny list is dropped too — the
199
+ * declaration is authored, and an authored close must not be able to hand
200
+ * worker code a credential — and the drop is ANNOUNCED by name (never the
201
+ * value) rather than applied silently.
202
+ * @param {Record<string, string>} [declared]
203
+ * @returns {{env: NodeJS.ProcessEnv, dropped: string[]}}
204
+ */
205
+ function stageEnv(declared = {}) {
206
+ const denied = (/** @type {string} */ name) => CLOSE_ENV_DENY.names.includes(name)
207
+ || CLOSE_ENV_DENY.prefixes.some((p) => name.startsWith(p))
208
+ || CLOSE_ENV_DENY.shape.test(name);
209
+ /** @type {NodeJS.ProcessEnv} */
210
+ const env = { ...process.env };
211
+ delete env.NODE_TEST_CONTEXT;
212
+ for (const name of Object.keys(env)) if (denied(name)) delete env[name];
213
+ // NO_COLOR keeps ANSI escapes out of the text every parser reads. Anything
214
+ // the stage declared rides on top of it and may override it.
215
+ env.NO_COLOR = '1';
216
+ env.FORCE_COLOR = '0';
217
+ /** @type {string[]} */
218
+ const dropped = [];
219
+ for (const [name, value] of Object.entries(declared)) {
220
+ if (denied(name)) { dropped.push(name); continue; }
221
+ env[name] = String(value);
222
+ }
223
+ return { env, dropped };
224
+ }
225
+
226
+ /**
227
+ * Run a command and read its world. Faithful to `spawnSync`'s observable
228
+ * result — the same `{status, signal, stdout, stderr}`, the same per-stream
229
+ * ceiling, SIGTERM at the deadline — but without freezing the host loop
230
+ * (F68). Every fault comes back as a `stop` STRING; nothing throws, because
231
+ * a throw here would have to be caught and turned back into a stop anyway.
232
+ * @param {string} cmd @param {string[]} args
233
+ * @param {{cwd: string, env?: Record<string,string>, timeoutMs?: number, maxBuffer?: number}} o
234
+ * @returns {Promise<{stop: string, fault: string}|{stop: null, code: number, out: string, dropped: string[]}>}
235
+ */
236
+ async function sh(cmd, args, { cwd, env: declared = {}, timeoutMs = DEFAULT_TIMEOUT_MS, maxBuffer = MAX_BUFFER }) {
237
+ const { env, dropped } = stageEnv(declared);
238
+ const r = await new Promise((/** @type {(v: {error: (Error & {code?: string})|null, status: number|null, signal: string|null, stdout: string, stderr: string}) => void} */ resolve) => {
239
+ /** @type {import('node:child_process').ChildProcess} */
240
+ let child;
241
+ try {
242
+ child = spawn(cmd, args, { cwd, env });
243
+ } catch (e) {
244
+ resolve({ error: /** @type {any} */ (e), status: null, signal: null, stdout: '', stderr: '' });
245
+ return;
246
+ }
247
+ /** @type {Buffer[]} */ const outChunks = [];
248
+ /** @type {Buffer[]} */ const errChunks = [];
249
+ let outBytes = 0; let errBytes = 0;
250
+ /** @type {(Error & {code?: string})|null} */
251
+ let error = null;
252
+ /** @type {NodeJS.Timeout|undefined} */ let timer;
253
+ /** @type {NodeJS.Timeout|undefined} */ let killTimer;
254
+ // FIRST FAULT WINS: a child that blew the buffer and then hit the
255
+ // deadline is one story, not two.
256
+ const fault = (/** @type {string} */ code) => {
257
+ if (error) return;
258
+ error = Object.assign(new Error(`spawn ${cmd} ${code}`), { code });
259
+ try { child.kill('SIGTERM'); } catch { /* already gone */ }
260
+ killTimer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 2000);
261
+ killTimer.unref();
262
+ };
263
+ child.stdout?.on('data', (/** @type {Buffer} */ c) => { outBytes += c.length; outChunks.push(c); if (outBytes > maxBuffer) fault('ENOBUFS'); });
264
+ child.stderr?.on('data', (/** @type {Buffer} */ c) => { errBytes += c.length; errChunks.push(c); if (errBytes > maxBuffer) fault('ENOBUFS'); });
265
+ child.on('error', (e) => { if (!error) error = e; });
266
+ child.on('close', (status, signal) => {
267
+ clearTimeout(timer);
268
+ clearTimeout(killTimer);
269
+ // Concat-then-decode, never per chunk: a multi-byte character across a
270
+ // chunk boundary must not become two replacement characters in the text
271
+ // every parser reads.
272
+ resolve({ error, status, signal, stdout: Buffer.concat(outChunks).toString('utf8'), stderr: Buffer.concat(errChunks).toString('utf8') });
273
+ });
274
+ if (typeof timeoutMs === 'number' && timeoutMs > 0 && Number.isFinite(timeoutMs)) timer = setTimeout(() => fault('ETIMEDOUT'), timeoutMs);
275
+ child.stdin?.on('error', () => {});
276
+ child.stdin?.end();
277
+ });
278
+
279
+ const argv = `${cmd}${args.length ? ` ${args.join(' ')}` : ''}`;
280
+ if (r.error) {
281
+ const code = /** @type {any} */ (r.error).code;
282
+ if (code === 'ETIMEDOUT') return { stop: `INSTRUMENT: "${cmd}" did not finish inside ${timeoutMs}ms`, fault: STOP_FAULTS.TIMED_OUT };
283
+ if (code === 'ENOBUFS') return { stop: `INSTRUMENT: "${cmd}" output exceeded the ${maxBuffer}B ceiling — the output is truncated, so any number read from it is unknown, not zero`, fault: STOP_FAULTS.CRASHED };
284
+ return { stop: `INSTRUMENT: "${argv}" could not be run (${code ?? String(r.error.message)})`, fault: STOP_FAULTS.FAILED };
285
+ }
286
+ if (r.status === null) return { stop: `INSTRUMENT: "${cmd}" returned a null exit code (killed by ${r.signal ?? 'an unknown signal'})`, fault: STOP_FAULTS.KILLED };
287
+ if (typeof r.stdout !== 'string' || typeof r.stderr !== 'string') return { stop: `INSTRUMENT: "${cmd}" produced unreadable output`, fault: STOP_FAULTS.CRASHED };
288
+ return { stop: null, code: r.status, out: `${r.stdout}${r.stderr}`, dropped };
289
+ }
290
+
291
+ /**
292
+ * git, read-only, in a repository. Faults come back as `ok:false` with the
293
+ * stderr — a caller decides whether that is a stop.
294
+ * @param {string} cwd @param {string[]} args
295
+ */
296
+ async function git(cwd, args) {
297
+ const r = await sh('git', args, { cwd, timeoutMs: 120_000 });
298
+ // git refusing IS "the close cannot run": the changed-set primitive is not an
299
+ // optional enrichment, so a non-zero git carries FAILED rather than inheriting
300
+ // whatever the spawn layer said about the process itself.
301
+ if (r.stop !== null) return { ok: false, out: '', err: r.stop, code: null, fault: r.fault };
302
+ return { ok: r.code === 0, out: r.out, err: r.out, code: r.code, fault: STOP_FAULTS.FAILED };
303
+ }
304
+
305
+ /**
306
+ * D8's seed, READ rather than typed: HEAD at run start. A close stores the
307
+ * counting rule and never the number (D12), so which commit the run is measured
308
+ * from is a fact about THIS run — recorded and shown, never carried in a signed
309
+ * spec where it would judge run 5 against run 1's tree.
310
+ *
311
+ * A fault is a named `stop`, never a fallback ref: measuring against the wrong
312
+ * baseline is the failure direction that reads as a clean tree.
313
+ * @param {string} workdir
314
+ * @returns {Promise<{stop: string, seedRef: null}|{stop: null, seedRef: string}>}
315
+ */
316
+ export async function seedAtHead(workdir) {
317
+ const r = await git(workdir, ['rev-parse', 'HEAD']);
318
+ if (!r.ok) return { stop: `INSTRUMENT: could not read HEAD in ${workdir}: ${String(r.err).trim()}`, seedRef: null };
319
+ const sha = r.out.trim().split('\n')[0].trim();
320
+ if (!/^[0-9a-f]{7,64}$/.test(sha)) {
321
+ return { stop: `INSTRUMENT: git rev-parse HEAD in ${workdir} did not return a commit sha`, seedRef: null };
322
+ }
323
+ return { stop: null, seedRef: sha };
324
+ }
325
+
326
+ /**
327
+ * The files that exist AT THE SEED COMMIT. Deliberately not `ls-files`, which
328
+ * reads the index and the working tree: this listing is what a declared path is
329
+ * judged against, and it must describe the tree the close measures from rather
330
+ * than whatever is lying around today.
331
+ *
332
+ * ONE spelling, here beside the other git reads (`src/authorscout.js`'s
333
+ * `seedFileList` delegates to it) — a second listing command is a second
334
+ * instrument, and the one that grades would be the one nobody validated.
335
+ * @param {string} workdir @param {string} seedRef
336
+ * @returns {Promise<{stop: string, files: null}|{stop: null, files: string[]}>}
337
+ */
338
+ export async function seedListing(workdir, seedRef) {
339
+ const r = await git(workdir, ['ls-tree', '-r', '--name-only', seedRef]);
340
+ if (!r.ok) {
341
+ return { stop: `INSTRUMENT: git ls-tree -r ${seedRef} failed in ${workdir}: ${String(r.err).trim()}`, files: null };
342
+ }
343
+ return { stop: null, files: r.out.split('\n').map((s) => s.trim()).filter(Boolean) };
344
+ }
345
+
346
+ // ── contract (c): the changed-set primitive ──────────────────────────────────
347
+
348
+ /**
349
+ * The two arbiter books, and the shape of each exclusion is the whole point.
350
+ *
351
+ * `.litectx/` is a PREFIX — everything beneath the store belongs to the
352
+ * arbiter. `gate-audit.jsonl` is an EXACT repo-relative path and never a
353
+ * basename or a pattern: the gate audit is written to
354
+ * `join(workdir, 'gate-audit.jsonl')` and nowhere else, so a worker-authored
355
+ * `src/gate-audit.jsonl` is NOT an arbiter book and must still count as work.
356
+ * Matching by basename would swallow it — and swallowing a worker's real
357
+ * write is the failure direction that reads as a clean tree.
358
+ * @param {string} rel a repo-relative POSIX path
359
+ * @returns {string|null} the book it belongs to, or null
360
+ */
361
+ export function isArbiterBook(rel) {
362
+ if (rel === '.litectx' || rel.startsWith('.litectx/')) return '.litectx/';
363
+ if (rel === 'gate-audit.jsonl') return 'gate-audit.jsonl';
364
+ return null;
365
+ }
366
+
367
+ /**
368
+ * Everything this run changed against the seed: the tracked diff PLUS
369
+ * untracked files (an untracked file has no diff — the case a naive
370
+ * `git diff` scan silently misses), minus the arbiter's own books.
371
+ * @param {string} workdir @param {string} seedRef
372
+ * @returns {Promise<{stop: string, fault: string}|{stop: null, paths: string[], tracked: string[], untracked: string[], excluded: {path: string, book: string}[]}>}
373
+ */
374
+ export async function changedSet(workdir, seedRef) {
375
+ const verify = await git(workdir, ['rev-parse', '--verify', `${seedRef}^{commit}`]);
376
+ if (!verify.ok) return { stop: `INSTRUMENT: the seed commit "${seedRef}" does not exist in ${workdir}`, fault: STOP_FAULTS.FAILED };
377
+ const d = await git(workdir, ['diff', '--name-only', seedRef]);
378
+ if (!d.ok) return { stop: `INSTRUMENT: git diff against "${seedRef}" failed: ${String(d.err).trim()}`, fault: d.fault };
379
+ const u = await git(workdir, ['ls-files', '--others', '--exclude-standard']);
380
+ if (!u.ok) return { stop: `INSTRUMENT: git ls-files --others failed: ${String(u.err).trim()}`, fault: u.fault };
381
+ const split = (/** @type {string} */ s) => s.split('\n').map((x) => x.trim()).filter(Boolean);
382
+ /** @type {{path: string, book: string}[]} */
383
+ const excluded = [];
384
+ const keep = (/** @type {string[]} */ list) => list.filter((p) => {
385
+ const book = isArbiterBook(p);
386
+ if (book) { excluded.push({ path: p, book }); return false; }
387
+ return true;
388
+ });
389
+ const tracked = keep(split(d.out));
390
+ const untracked = keep(split(u.out));
391
+ return { stop: null, tracked, untracked, excluded, paths: [...new Set([...tracked, ...untracked])].sort() };
392
+ }
393
+
394
+ /**
395
+ * The lines this run ADDED to one file. A tracked file's additions come from
396
+ * its own unified diff; an UNTRACKED file has no diff at all, so its ENTIRE
397
+ * body is added — the half of this primitive a diff-only scan misses.
398
+ * @param {string} workdir @param {string} seedRef @param {string} rel
399
+ * @param {{untracked?: boolean}} [o]
400
+ * @returns {Promise<{stop: string, fault: string}|{stop: null, lines: {n: number, text: string}[], note: string|null}>}
401
+ */
402
+ export async function addedLines(workdir, seedRef, rel, { untracked = false } = {}) {
403
+ if (untracked) {
404
+ const abs = join(workdir, rel);
405
+ let st;
406
+ // NOT a stop: a dangling symlink or a file that vanished between the
407
+ // changed-set read and this one has no content to hide, so refusing to grade
408
+ // the run over it would be the wrong direction. But it is not SCANNED either,
409
+ // and every sibling branch below (the cap, the binary check) says so out
410
+ // loud — returning note:null here made an unscannable file read as clean.
411
+ try { st = await stat(abs); } catch (e) {
412
+ const code = String(/** @type {any} */ (e)?.code ?? /** @type {any} */ (e)?.message ?? e);
413
+ return { stop: null, lines: [], note: `${rel}: could not stat (${code}) — NOT scanned` };
414
+ }
415
+ if (!st.isFile()) return { stop: null, lines: [], note: null };
416
+ if (st.size > MAX_SCAN_BYTES) return { stop: null, lines: [], note: `${rel}: ${st.size}B exceeds the ${MAX_SCAN_BYTES}B scan cap — NOT scanned` };
417
+ let buf;
418
+ try { buf = await readFile(abs); } catch (e) { return { stop: `INSTRUMENT: ${rel} could not be read: ${String(/** @type {any} */ (e)?.message ?? e)}`, fault: STOP_FAULTS.CRASHED }; }
419
+ if (buf.includes(0)) return { stop: null, lines: [], note: `${rel}: binary — NOT scanned` };
420
+ return { stop: null, lines: buf.toString('utf8').split('\n').map((text, i) => ({ n: i + 1, text })), note: null };
421
+ }
422
+ const d = await git(workdir, ['diff', '-U0', '--no-color', seedRef, '--', rel]);
423
+ // `ok` IS `code === 0` (see `git`), and a spawn fault comes back `code: null`,
424
+ // so a second `code !== 0` clause was a tautology reading as a live guard.
425
+ if (!d.ok) return { stop: `INSTRUMENT: git diff -U0 for ${rel} failed: ${String(d.err).trim()}`, fault: d.fault };
426
+ /** @type {{n: number, text: string}[]} */
427
+ const lines = [];
428
+ let n = 0;
429
+ // The `+++`/`---` skip is scoped to the PREAMBLE, and the scope is the fix for a
430
+ // real fake-green: git renders an ADDED line whose own text begins with `++` as
431
+ // `+++i;`, so a header test applied inside a hunk swallowed it and the guard read
432
+ // GREEN over a line it never opened. File headers only ever appear before the
433
+ // first `@@`, so `inHunk` says exactly where a header can legally be — the guard's
434
+ // dangerous direction, closed by construction rather than by a sharper pattern.
435
+ let inHunk = false;
436
+ for (const raw of d.out.split('\n')) {
437
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
438
+ if (hunk) { n = Number(hunk[1]); inHunk = true; continue; }
439
+ if (!inHunk && (raw.startsWith('+++') || raw.startsWith('---'))) continue;
440
+ if (raw.startsWith('+')) { lines.push({ n, text: raw.slice(1) }); n += 1; }
441
+ }
442
+ return { stop: null, lines, note: null };
443
+ }
444
+
445
+ // ── scope: one prefix spelling, the shipped one ──────────────────────────────
446
+
447
+ /** normalise a declared prefix through the SHIPPED spelling, never a second
448
+ * one — `globToPrefix`'s collapse order is fence territory (F9). */
449
+ const normPrefix = (/** @type {string} */ p) => globToPrefix(String(p)).replace(/^\.\//, '');
450
+
451
+ /** @param {string} rel @param {string} prefix */
452
+ function underPrefix(rel, prefix) {
453
+ const pre = normPrefix(prefix);
454
+ if (pre === '' || pre === '.' || pre === '/') return true;
455
+ const p = pre.endsWith('/') ? pre : `${pre}/`;
456
+ return rel === pre.replace(/\/$/, '') || rel.startsWith(p);
457
+ }
458
+
459
+ /**
460
+ * Physical identity, for when two SPELLINGS name one file. A live aurora run
461
+ * measured the cost of not having this: the patient's layout carries a tracked
462
+ * `src/aurora_spawner -> ../packages/spawner/src/aurora_spawner` symlink (the
463
+ * ordinary python monorepo shape), mypy printed every error as
464
+ * `src/aurora_spawner/...`, the declared scope said
465
+ * `packages/spawner/src/aurora_spawner/`, and a lexical matcher dropped all 16
466
+ * REAL error lines — 0 against a baseline of 0 is a fake green, and only the
467
+ * crash-stop caught it.
468
+ *
469
+ * `fs.realpathSync` and nothing else: lexical `..` resolution (and
470
+ * `path.posix.normalize`, which does it silently) HIDES escapes, which is why
471
+ * `globToPrefix` refuses to do it. The kernel's answer cannot be spoofed.
472
+ * Resolution failure is not an answer — the caller falls back to the lexical
473
+ * reading, so a path that is not in the measured tree can never GAIN scope
474
+ * membership it did not already have.
475
+ * @param {string} workdir
476
+ * @returns {{real: (rel: string) => string|null}|null} null when the measuring
477
+ * directory itself cannot be resolved — then there is no physical reading at all
478
+ */
479
+ function physicalIn(workdir) {
480
+ if (!workdir) return null;
481
+ /** one realpath per distinct path per stage/parse call — a tool names the same
482
+ * file on many lines, and the scope filter reads every one of them */
483
+ const cache = new Map();
484
+ const real = (/** @type {string} */ abs) => {
485
+ if (cache.has(abs)) return /** @type {string|null} */ (cache.get(abs));
486
+ /** @type {string|null} */
487
+ let r = null;
488
+ try { r = realpathSync(abs); } catch { r = null; }
489
+ cache.set(abs, r);
490
+ return r;
491
+ };
492
+ const root = real(workdir);
493
+ if (root === null) return null;
494
+ return { real: (/** @type {string} */ rel) => real(join(root, rel)) };
495
+ }
496
+
497
+ /**
498
+ * Is `rel` the same PHYSICAL file as something under `prefix`? Both sides are
499
+ * resolved, so a symlinked report matches a physical prefix AND a physical
500
+ * report matches a symlinked prefix. Containment is checked on two realpaths,
501
+ * where a segment-prefix test means what it says — no symlink can sit inside
502
+ * either spelling any more, so this cannot widen scope to an escape: a file
503
+ * only matches when the prefix PHYSICALLY contains it.
504
+ * @param {string} rel @param {string} prefix
505
+ * @param {{real: (rel: string) => string|null}} phys
506
+ */
507
+ function underPrefixPhysically(rel, prefix, phys) {
508
+ const pre = normPrefix(prefix).replace(/\/+$/, '');
509
+ // '' / '.' / '/' already answered TRUE lexically, and an absolute prefix
510
+ // matches nothing lexically (linePath never returns one) — resolving either
511
+ // against the workdir would invent a membership the declaration never made
512
+ if (pre === '' || pre === '.' || pre.startsWith('/')) return false;
513
+ const preAbs = phys.real(pre);
514
+ if (preAbs === null) return false;
515
+ const fileAbs = phys.real(rel);
516
+ if (fileAbs === null) return false;
517
+ return fileAbs === preAbs || fileAbs.startsWith(preAbs.endsWith(sep) ? preAbs : `${preAbs}${sep}`);
518
+ }
519
+
520
+ /**
521
+ * The fence's ONE containment reading, shared by every site that has one.
522
+ * Lexical FIRST and physical only on a miss: every spelling that matched
523
+ * before still matches, so resolution can only ever unify two names for one
524
+ * file — it never takes membership away, and it never reaches past a prefix
525
+ * that does not physically contain the file.
526
+ *
527
+ * Without a resolver (`phys === null`) this IS the old lexical fence, byte for
528
+ * byte.
529
+ * @param {string} rel @param {string} prefix
530
+ * @param {{real: (rel: string) => string|null}|null} phys
531
+ */
532
+ function underPrefixEither(rel, prefix, phys) {
533
+ return underPrefix(rel, prefix) || (phys !== null && underPrefixPhysically(rel, prefix, phys));
534
+ }
535
+
536
+ /** @param {string} rel @param {any} scope
537
+ * @param {{real: (rel: string) => string|null}|null} [phys] physical resolver;
538
+ * absent leaves the comparison purely lexical */
539
+ function inScope(rel, scope, phys = null) {
540
+ if (!scope || typeof scope !== 'object') return true;
541
+ const inc = Array.isArray(scope.includePrefixes) ? scope.includePrefixes : null;
542
+ const exc = Array.isArray(scope.excludePrefixes) ? scope.excludePrefixes : null;
543
+ const under = (/** @type {string} */ p) => underPrefixEither(rel, p, phys);
544
+ if (inc && !inc.some(under)) return false;
545
+ // exclusion resolves too: a symlink spelling must not smuggle a file past an
546
+ // exclude the physical spelling would have caught
547
+ if (exc && exc.some(under)) return false;
548
+ return true;
549
+ }
550
+
551
+ /** does this declared scope actually filter anything? — one spelling of the
552
+ * question, so no caller can decide it differently from another
553
+ * @param {any} scope */
554
+ const scopeFilters = (scope) => !!scope && (Array.isArray(scope.includePrefixes) || Array.isArray(scope.excludePrefixes));
555
+
556
+ /**
557
+ * The first repository-relative path a tool named on a line, or null. At
558
+ * least one `/` is required, so prose ("Found 3 errors in 2 files") never
559
+ * reads as a path — a false path would silently steer the scope filter.
560
+ * @param {string} line @param {string} workdir
561
+ */
562
+ function linePath(line, workdir) {
563
+ const m = /(?:^|[\s"'(\[<])((?:\.\/)?\/?(?:[\w.@+~-]+\/)+[\w.@+~-]+)/.exec(line);
564
+ if (!m) return null;
565
+ let p = m[1].replace(/^\.\//, '');
566
+ const wd = workdir.endsWith(sep) ? workdir : `${workdir}${sep}`;
567
+ if (p.startsWith(wd)) p = p.slice(wd.length);
568
+ else if (p.startsWith('/')) return null; // absolute, outside the patient — attributes to nothing
569
+ return p;
570
+ }
571
+
572
+ // ── the parser: ONE spelling, shared by validator and executor ───────────────
573
+
574
+ /**
575
+ * Count a pattern's capture groups without running it against real input:
576
+ * `src + '|'` always matches the empty string, so the result array's length
577
+ * names the group count. Also the ONE place a declared regex is compiled at
578
+ * validation time, so a broken pattern reds before any process is spawned.
579
+ * @param {any} src
580
+ * @returns {{count: number, red: string|null}}
581
+ */
582
+ export function regexGroups(src) {
583
+ if (typeof src !== 'string' || src === '') return { count: 0, red: 'a regular expression, as a string' };
584
+ try {
585
+ const m = new RegExp(`${src}|`).exec('');
586
+ return { count: (m?.length ?? 1) - 1, red: null };
587
+ } catch (e) {
588
+ return { count: 0, red: `/${src}/ is not a valid regular expression: ${String(/** @type {any} */ (e)?.message ?? e)}` };
589
+ }
590
+ }
591
+
592
+ /**
593
+ * THE normalisation of a `count-not-worse` parser (gate-1 amendment #5).
594
+ * The declaration validator and this executor both call it, so there is
595
+ * exactly one spelling of what a parser MEANS — two normalisers that
596
+ * disagree are two instruments, and the one that grades is the one nobody
597
+ * validated.
598
+ *
599
+ * value = Σ over terms of sign × (first | sum of that term's captures,
600
+ * inside that term's region)
601
+ *
602
+ * Backwards compatible by construction: the short form
603
+ * `{lineMatch, capture?}` resolves to exactly one term with
604
+ * `sign +1, aggregate "first", region "whole-output"`, which is the
605
+ * pre-amendment semantics unchanged.
606
+ *
607
+ * Never throws — reds come back in the shipped `{ok, reds, ...}` shape.
608
+ * @param {any} parser @param {string} [at] path prefix for reds
609
+ * @returns {{ok: boolean, reds: Red[], terms: any[]|null}}
610
+ */
611
+ export function normalizeParser(parser, at = 'parser') {
612
+ /** @type {Red[]} */
613
+ const reds = [];
614
+ const red = (/** @type {string} */ path, /** @type {string} */ detail, code = 'invalid-value') => reds.push({ code, path, detail });
615
+ if (parser === null || typeof parser !== 'object' || Array.isArray(parser)) {
616
+ red(at, 'a parser object: {terms:[...]} or the short form {lineMatch, capture?}', 'missing-field');
617
+ return { ok: false, reds, terms: null };
618
+ }
619
+ const hasTerms = Object.hasOwn(parser, 'terms');
620
+ const hasShort = Object.hasOwn(parser, 'lineMatch');
621
+ if (hasTerms && hasShort) {
622
+ red(at, 'a parser declares EITHER terms or the short form lineMatch — never both');
623
+ return { ok: false, reds, terms: null };
624
+ }
625
+ /** @type {any[]} */
626
+ let raw;
627
+ if (hasShort) {
628
+ for (const k of Object.keys(parser)) if (!['lineMatch', 'capture'].includes(k)) red(`${at}.${k}`, 'the short form takes lineMatch and capture only');
629
+ raw = [{ lineMatch: parser.lineMatch, ...(Object.hasOwn(parser, 'capture') ? { capture: parser.capture } : {}), sign: 1, aggregate: 'first', region: 'whole-output' }];
630
+ } else if (hasTerms) {
631
+ for (const k of Object.keys(parser)) if (k !== 'terms') red(`${at}.${k}`, 'the long form takes terms only');
632
+ if (!Array.isArray(parser.terms) || parser.terms.length === 0) {
633
+ red(`${at}.terms`, 'a non-empty array of terms');
634
+ return { ok: false, reds, terms: null };
635
+ }
636
+ if (parser.terms.length > MAX_TERMS) red(`${at}.terms`, `${parser.terms.length} terms exceeds the ceiling of ${MAX_TERMS}`);
637
+ raw = parser.terms;
638
+ } else {
639
+ red(at, 'a parser object: {terms:[...]} or the short form {lineMatch, capture?}', 'missing-field');
640
+ return { ok: false, reds, terms: null };
641
+ }
642
+
643
+ /** @type {any[]} */
644
+ const terms = [];
645
+ raw.forEach((t, i) => {
646
+ const p = `${at}${hasShort ? '' : `.terms[${i}]`}`;
647
+ if (t === null || typeof t !== 'object' || Array.isArray(t)) { red(p, 'a term object'); return; }
648
+ for (const k of Object.keys(t)) if (!['lineMatch', 'capture', 'sign', 'aggregate', 'region'].includes(k)) red(`${p}.${k}`, 'a term takes lineMatch, capture, sign, aggregate, region');
649
+ const groups = regexGroups(t.lineMatch);
650
+ if (groups.red) { red(`${p}.lineMatch`, groups.red); return; }
651
+ // capture defaults to group 1; a pattern with NO group tallies 1 per match,
652
+ // which is how a stage COUNTS occurrences instead of reading a printed figure
653
+ const capture = Object.hasOwn(t, 'capture') && t.capture !== null && t.capture !== undefined
654
+ ? t.capture
655
+ : (groups.count === 0 ? null : 1);
656
+ if (capture !== null && !(Number.isInteger(capture) && capture >= 0)) { red(`${p}.capture`, 'a non-negative integer capture-group index'); return; }
657
+ if (capture !== null && capture > groups.count) { red(`${p}.capture`, `group ${capture} does not exist — /${t.lineMatch}/ has ${groups.count} capture group(s)`); return; }
658
+ const sign = Object.hasOwn(t, 'sign') ? t.sign : undefined;
659
+ if (!SIGNS.includes(sign)) { red(`${p}.sign`, `one of ${SIGNS.join(' | ')}`, Object.hasOwn(t, 'sign') ? 'invalid-value' : 'missing-field'); return; }
660
+ const aggregate = Object.hasOwn(t, 'aggregate') ? t.aggregate : undefined;
661
+ if (!AGGREGATES.includes(aggregate)) { red(`${p}.aggregate`, `one of ${AGGREGATES.join(' | ')}`, Object.hasOwn(t, 'aggregate') ? 'invalid-value' : 'missing-field'); return; }
662
+ const rawRegion = Object.hasOwn(t, 'region') ? t.region : undefined;
663
+ /** @type {any} */
664
+ let region;
665
+ if (rawRegion === 'whole-output') region = 'whole-output';
666
+ else if (rawRegion !== null && typeof rawRegion === 'object' && !Array.isArray(rawRegion)) {
667
+ const ag = regexGroups(rawRegion.anchor);
668
+ if (ag.red) { red(`${p}.region.anchor`, ag.red); return; }
669
+ const rc = Object.hasOwn(rawRegion, 'capture') && rawRegion.capture !== null ? rawRegion.capture : 0;
670
+ if (!(Number.isInteger(rc) && rc >= 0)) { red(`${p}.region.capture`, 'a non-negative integer capture-group index (0 = the whole anchor match)'); return; }
671
+ if (rc > ag.count) { red(`${p}.region.capture`, `group ${rc} does not exist — /${rawRegion.anchor}/ has ${ag.count} capture group(s)`); return; }
672
+ for (const k of Object.keys(rawRegion)) if (!['anchor', 'capture'].includes(k)) red(`${p}.region.${k}`, 'a region takes anchor and capture only');
673
+ region = { anchor: rawRegion.anchor, capture: rc };
674
+ } else { red(`${p}.region`, '"whole-output" or {anchor, capture}', Object.hasOwn(t, 'region') ? 'invalid-value' : 'missing-field'); return; }
675
+ terms.push({ lineMatch: t.lineMatch, capture, sign, aggregate, region });
676
+ });
677
+
678
+ if (reds.length) return { ok: false, reds, terms: null };
679
+ return { ok: true, reds, terms };
680
+ }
681
+
682
+ /**
683
+ * Read ONE number out of a command's output, as signed arithmetic over
684
+ * normalised terms.
685
+ *
686
+ * The zero-match split is contract (a), not a convenience:
687
+ * - `sum` over zero matches is a COUNTED zero. The region was searched and
688
+ * nothing was there; that is legitimately 0.
689
+ * - `first` over zero matches is UNKNOWN — the number was never reported —
690
+ * and unknown is an instrument stop, never a defaulted 0 (F6).
691
+ * - a region whose anchor matches nothing was never LOCATED: stop.
692
+ * The scope filter runs per OUTPUT LINE, before aggregation, and both its
693
+ * drop count and its unattributable count are ANNOUNCED: a line naming no
694
+ * path is KEPT (no filter can exclude what it cannot locate) and the notes
695
+ * say so, rather than the number quietly meaning something else.
696
+ *
697
+ * `preScopeMatches` is reported alongside the reading because a POST-scope
698
+ * count cannot answer "did the tool run at all". It tallies (term, line) pairs
699
+ * that matched BEFORE the filter judged them — kept, unattributable and dropped
700
+ * alike — and it is the only evidence of liveness a caller has once the scope
701
+ * has removed the whole population. It is never the number being read.
702
+ * @param {string} output @param {any[]} terms normalised terms
703
+ * Every stop here is `crashed` in the arbiter's vocabulary — the command RAN,
704
+ * came back, and the number could not be read out of it. Nothing in this
705
+ * function can observe a spawn failure, a timeout or a signal, so it never
706
+ * claims one.
707
+ * @param {{scope?: any, workdir?: string}} [o]
708
+ * @returns {{stop: string, fault: string, notes?: string[]}|{stop: null, value: number, breakdown: any[], notes: string[], preScopeMatches: number}}
709
+ */
710
+ export function parseValue(output, terms, { scope = null, workdir = '' } = {}) {
711
+ let value = 0;
712
+ // liveness, tallied before the filter has an opinion — see the note above
713
+ let preScopeMatches = 0;
714
+ /** @type {any[]} */
715
+ const breakdown = [];
716
+ /** @type {string[]} */
717
+ const notes = [];
718
+ const filtering = scopeFilters(scope);
719
+ // one resolver (and one realpath cache) per call, built only when a filter
720
+ // will actually read it
721
+ const phys = filtering ? physicalIn(workdir) : null;
722
+
723
+ for (const [i, t] of terms.entries()) {
724
+ // 1. locate the region
725
+ let region = output;
726
+ if (t.region !== 'whole-output') {
727
+ let anchor;
728
+ try { anchor = new RegExp(t.region.anchor, 'm'); } catch (e) { return { stop: `INSTRUMENT: term ${i} region anchor /${t.region.anchor}/ is not a valid regex: ${String(/** @type {any} */ (e)?.message ?? e)}`, fault: STOP_FAULTS.CRASHED }; }
729
+ const m = anchor.exec(output);
730
+ if (!m) return { stop: `INSTRUMENT: term ${i} region anchor /${t.region.anchor}/ matched nothing — the region the number lives in could not be located`, fault: STOP_FAULTS.CRASHED };
731
+ const got = m[t.region.capture];
732
+ if (typeof got !== 'string') return { stop: `INSTRUMENT: term ${i} region anchor matched but capture group ${t.region.capture} did not participate`, fault: STOP_FAULTS.CRASHED };
733
+ region = got;
734
+ }
735
+
736
+ // 2. collect matches line by line, so the scope filter can read each
737
+ // line's own path
738
+ let re;
739
+ try { re = new RegExp(t.lineMatch, 'g'); } catch (e) { return { stop: `INSTRUMENT: term ${i} lineMatch /${t.lineMatch}/ is not a valid regex: ${String(/** @type {any} */ (e)?.message ?? e)}`, fault: STOP_FAULTS.CRASHED }; }
740
+ /** @type {number[]} */
741
+ const values = [];
742
+ let dropped = 0;
743
+ let unattributable = 0;
744
+ for (const line of region.split('\n')) {
745
+ const hits = [...line.matchAll(re)];
746
+ if (!hits.length) continue;
747
+ preScopeMatches += 1;
748
+ if (filtering) {
749
+ const p = linePath(line, workdir);
750
+ if (p === null) unattributable += 1;
751
+ else if (!inScope(p, scope, phys)) { dropped += 1; continue; }
752
+ }
753
+ for (const h of hits) {
754
+ if (t.capture === null) { values.push(1); continue; }
755
+ const rawCapture = h[t.capture];
756
+ if (typeof rawCapture !== 'string') return { stop: `INSTRUMENT: term ${i} matched but capture group ${t.capture} did not participate — the number is unknown, not zero`, fault: STOP_FAULTS.CRASHED };
757
+ const n = Number.parseInt(rawCapture, 10);
758
+ if (!Number.isFinite(n)) return { stop: `INSTRUMENT: term ${i} captured "${rawCapture}", which is not an integer`, fault: STOP_FAULTS.CRASHED };
759
+ values.push(n);
760
+ }
761
+ }
762
+ if (filtering) {
763
+ if (dropped) notes.push(`term ${i}: ${dropped} matching line(s) dropped by the scope filter`);
764
+ if (unattributable) notes.push(`term ${i}: ${unattributable} matching line(s) named no path, so the scope filter could not judge them — they were KEPT`);
765
+ }
766
+
767
+ // 3. aggregate
768
+ let subtotal;
769
+ if (t.aggregate === 'sum') subtotal = values.reduce((a, b) => a + b, 0);
770
+ else {
771
+ if (!values.length) {
772
+ // TWO DIFFERENT FAULTS, and they used to read as one. "Matched nothing"
773
+ // sends the reader to the parser; lines that matched and were then
774
+ // EXCLUDED BY SCOPE send them to the scope, which is where the problem
775
+ // actually is. The counts are named mechanically (F38's genre split:
776
+ // a wall with a number converts, a description does not), and the notes
777
+ // this function already computed ride out with the stop instead of being
778
+ // dropped on the floor — the diagnostic exists either way.
779
+ // `dropped` alone, never dropped+unattributable: an unattributable line
780
+ // is KEPT, so it would already be in `values` — claiming it was excluded
781
+ // would be the message lying about which lane the line went down.
782
+ return {
783
+ stop: dropped > 0
784
+ ? `INSTRUMENT: term ${i} (/${t.lineMatch}/, aggregate "first") matched ${dropped} line(s) and the scope filter excluded every one of them — the number was never reported FOR THIS POPULATION, so it is unknown, not zero`
785
+ : `INSTRUMENT: term ${i} (/${t.lineMatch}/, aggregate "first") matched nothing — the number was never reported, so it is unknown, not zero`,
786
+ fault: STOP_FAULTS.CRASHED,
787
+ notes,
788
+ };
789
+ }
790
+ subtotal = values[0];
791
+ }
792
+ breakdown.push({ term: i, lineMatch: t.lineMatch, aggregate: t.aggregate, sign: t.sign, matches: values.length, subtotal, contribution: t.sign * subtotal });
793
+ value += t.sign * subtotal;
794
+ }
795
+ return { stop: null, value, breakdown, notes, preScopeMatches };
796
+ }
797
+
798
+ // ── D12: the baseline is MEASURED at this run's own seed, every run ──────────
799
+
800
+ /**
801
+ * A caller-scoped holder for detached seed worktrees. Scoped rather than
802
+ * module-global on purpose: a global cache in a published library leaks
803
+ * worktrees into whatever process imported it, and nothing would ever remove
804
+ * them. `seedRead`/`runClose` create one, share it across stages (so a
805
+ * multi-stage close pays for one checkout), and remove it in a `finally`.
806
+ * @returns {SeedTrees}
807
+ */
808
+ export function makeSeedTrees() {
809
+ /** @type {Map<string, {workdir: string, root: string, dir: string}>} */
810
+ const made = new Map();
811
+ /** @type {Map<string, {stop: string, fault: string}|{stop: null, dir: string}>} */
812
+ const answers = new Map();
813
+ return {
814
+ async ensure(workdir, seedRef) {
815
+ const key = `${workdir}::${seedRef}`;
816
+ const cached = answers.get(key);
817
+ if (cached) return cached;
818
+ const verify = await git(workdir, ['rev-parse', '--verify', `${seedRef}^{commit}`]);
819
+ if (!verify.ok) {
820
+ const v = { stop: `INSTRUMENT: the seed commit "${seedRef}" does not exist in ${workdir}`, fault: STOP_FAULTS.FAILED };
821
+ answers.set(key, v);
822
+ return v;
823
+ }
824
+ const root = await mkdtemp(join(tmpdir(), 'bareloop-seed-'));
825
+ const dir = join(root, 'tree');
826
+ const add = await git(workdir, ['worktree', 'add', '--detach', dir, seedRef]);
827
+ if (!add.ok) {
828
+ await rm(root, { recursive: true, force: true });
829
+ const v = { stop: `INSTRUMENT: could not create a seed worktree at ${seedRef}: ${String(add.err).trim()}`, fault: add.fault };
830
+ answers.set(key, v);
831
+ return v;
832
+ }
833
+ // A checkout without its toolchain measures a BROKEN toolchain, which
834
+ // would read as an instrument stop on every single run rather than a
835
+ // baseline. Best effort: a missing link surfaces loudly as that stop.
836
+ for (const dep of ['node_modules', '.venv', 'venv']) {
837
+ try { await symlink(join(workdir, dep), join(dir, dep)); } catch { /* absent, or already there */ }
838
+ }
839
+ made.set(key, { workdir, root, dir });
840
+ const v = { stop: null, dir };
841
+ answers.set(key, v);
842
+ return v;
843
+ },
844
+ /**
845
+ * Removal — and it NEVER THROWS, which is the load-bearing half.
846
+ *
847
+ * EVERY call site runs this inside a `finally` (`runStage`, `seedRead`,
848
+ * `runDeclaredClose` below; `src/declaredclose.js` and `src/authorflow.js`
849
+ * outside). A rejection from a `finally` REPLACES whatever the try block
850
+ * was returning, so an unremovable directory — EACCES, EBUSY, a mount that
851
+ * went away — would discard an ALREADY-COMPUTED verdict and surface
852
+ * downstream as something that looks like a transport failure. A paid green
853
+ * would simply vanish. A cleanup hiccup never outranks a paid verdict.
854
+ *
855
+ * It is not swallowed either, which is the other half: the failure comes
856
+ * back as DATA (`ok:false` plus the leaked roots and the real error) that a
857
+ * caller who cares can read and attach a note to. Never `undefined`, never a
858
+ * silent success — and, because it is only a return value, never able to
859
+ * move a verdict. One unremovable tree also never strands the rest.
860
+ * @returns {Promise<{ok: boolean, leaked: string[], detail: string|null}>}
861
+ */
862
+ async cleanup() {
863
+ /** @type {string[]} */
864
+ const leaked = [];
865
+ /** @type {string[]} */
866
+ const details = [];
867
+ for (const { workdir, root, dir } of made.values()) {
868
+ try {
869
+ // git's own refusal is a LEAK the instrument must be able to see. `git`
870
+ // never throws — it hands failure back as `ok:false` — so a bare call
871
+ // here left `git worktree remove` failing invisibly: the directory went
872
+ // away under `rm` and a stale admin entry stayed behind in
873
+ // `.git/worktrees/`, while this reported `ok:true, leaked:[]`. That is
874
+ // the blind-instrument class on the one report that exists to name a
875
+ // leak, so the answer is READ rather than discarded.
876
+ const wr = await git(workdir, ['worktree', 'remove', '--force', dir]);
877
+ await rm(root, { recursive: true, force: true });
878
+ if (!wr.ok) {
879
+ leaked.push(root);
880
+ details.push(`${root}: git worktree remove refused (${String(wr.err).trim()}) — the checkout is gone but its admin entry survives; \`git worktree prune\` in ${workdir} clears it`);
881
+ }
882
+ } catch (e) {
883
+ leaked.push(root);
884
+ details.push(`${root}: ${String(/** @type {any} */ (e)?.message ?? e)}`);
885
+ }
886
+ }
887
+ made.clear();
888
+ answers.clear();
889
+ return { ok: leaked.length === 0, leaked, detail: details.length ? details.join('; ') : null };
890
+ },
891
+ };
892
+ }
893
+
894
+ /**
895
+ * Is the working tree byte-identical to the seed (the arbiter's own books
896
+ * aside)? When it is, the baseline can honestly be taken in place — and the
897
+ * record SAYS which route was taken.
898
+ * @param {string} workdir @param {string} seedRef
899
+ */
900
+ async function treeIsAtSeed(workdir, seedRef) {
901
+ const cs = await changedSet(workdir, seedRef);
902
+ if (cs.stop !== null) return { at: false, stop: cs.stop, fault: cs.fault };
903
+ return { at: cs.paths.length === 0, stop: null, fault: STOP_FAULTS.FAILED };
904
+ }
905
+
906
+ // ── results ─────────────────────────────────────────────────────────────────
907
+
908
+ /**
909
+ * @param {any} stage @param {Partial<StageResult>} over
910
+ * @returns {StageResult}
911
+ */
912
+ const result = (stage, over) => ({
913
+ verdict: 'green',
914
+ exitCode: EXIT_GREEN,
915
+ value: null,
916
+ baseline: null,
917
+ baselineSource: null,
918
+ gapLines: [],
919
+ judged: true,
920
+ stage: stage?.name ?? null,
921
+ kind: stage?.kind ?? null,
922
+ detail: {},
923
+ ...over,
924
+ });
925
+
926
+ /**
927
+ * Contract (a). The stop's own reason still rides the gap channel — an
928
+ * operator reading the close's output must see WHY nothing was judged — and
929
+ * what it never carries is the judged marker.
930
+ *
931
+ * `fault` is the arbiter's four-row vocabulary (`STOP_FAULTS`), stamped by the
932
+ * site that OBSERVED the fault. It defaults to `failed` — "the close cannot
933
+ * run" is the safe reading — and it is carried on `detail` so a consumer routes
934
+ * the stop by a typed field instead of pattern-matching the stop's prose.
935
+ * `notes` are diagnostics a measurement had ALREADY COMPUTED when it stopped —
936
+ * the scope filter's own arithmetic, chiefly. They ride out on the same gap
937
+ * channel as the stop rather than being dropped on the floor: the stop says the
938
+ * number could not be read, and the notes say which lane the lines went down,
939
+ * which is usually the difference between hunting a parser bug and reading the
940
+ * scope. Default empty, so every other stop site is unchanged.
941
+ * @param {any} stage @param {Ctx} ctx @param {string} stop @param {Record<string, any>} [detail]
942
+ * @param {string} [fault] @param {string[]} [notes]
943
+ * @returns {StageResult}
944
+ */
945
+ const stopped = (stage, ctx, stop, detail = {}, fault = STOP_FAULTS.FAILED, notes = []) => {
946
+ const gap = new Gap(ctx.gapKeep, ctx.gapCap).push(stop);
947
+ for (const n of notes) gap.push(` ${n}`);
948
+ return result(stage, {
949
+ verdict: 'instrument-stop',
950
+ exitCode: EXIT_STOP,
951
+ judged: false,
952
+ gapLines: gap.render(),
953
+ detail: { ...detail, stop, fault },
954
+ });
955
+ };
956
+
957
+ // ── the four kinds ──────────────────────────────────────────────────────────
958
+
959
+ /**
960
+ * The bound a stage ACTUALLY runs under: the tighter of what the declaration
961
+ * asked for and what the operator allows.
962
+ *
963
+ * `timeoutMs` is AUTHORED — it rides in the declaration, on the emergent side
964
+ * of the line. `ctx.timeoutMsDefault` is the operator's ceiling and is not.
965
+ * This is the budget rule applied to time: the advertised bound and the
966
+ * enforced bound must be the same number, and the authored side may only ever
967
+ * TIGHTEN it (a declaration that could widen its own ceiling is a second,
968
+ * silent ceiling above the signed one). A declared value that is unusable —
969
+ * absent, non-finite, zero or negative — falls back to the ceiling rather than
970
+ * to `sh`'s "no timer at all", which is the same direction: never wider.
971
+ * @param {any} declared @param {Ctx} ctx
972
+ * @returns {number}
973
+ */
974
+ function effectiveTimeoutMs(declared, ctx) {
975
+ const ceiling = ctx.timeoutMsDefault ?? DEFAULT_TIMEOUT_MS;
976
+ return typeof declared === 'number' && Number.isFinite(declared) && declared > 0
977
+ ? Math.min(declared, ceiling)
978
+ : ceiling;
979
+ }
980
+
981
+ /** @param {any} stage @param {Ctx} ctx @returns {Promise<StageResult>} */
982
+ async function runCommandExit(stage, ctx) {
983
+ const p = stage.params;
984
+ const r = await sh(p.cmd, p.args ?? [], {
985
+ cwd: ctx.workdir,
986
+ env: p.env ?? {},
987
+ timeoutMs: effectiveTimeoutMs(p.timeoutMs, ctx),
988
+ maxBuffer: ctx.maxBuffer ?? MAX_BUFFER,
989
+ });
990
+ if (r.stop !== null) return stopped(stage, ctx, r.stop, {}, r.fault);
991
+ const gap = new Gap(ctx.gapKeep, ctx.gapCap);
992
+ for (const name of r.dropped) gap.push(`${stage.name}: declared env var ${name} was DROPPED — the close never hands worker code a credential`);
993
+ const green = r.code === p.expectExit;
994
+ if (!green) {
995
+ gap.push(`${stage.name}: "${p.cmd}${(p.args ?? []).length ? ` ${(p.args ?? []).join(' ')}` : ''}" exited ${r.code}, expected ${p.expectExit}`);
996
+ for (const l of r.out.split('\n').filter((x) => x.trim() !== '')) gap.push(` ${l}`);
997
+ }
998
+ return result(stage, {
999
+ verdict: green ? 'green' : 'red',
1000
+ exitCode: green ? EXIT_GREEN : EXIT_RED,
1001
+ gapLines: gap.render(),
1002
+ detail: { exit: r.code, expected: p.expectExit, envDropped: r.dropped },
1003
+ });
1004
+ }
1005
+
1006
+ /**
1007
+ * The counting kind. `measure` below carries the broken-ruler rule and its one
1008
+ * ACCEPTED LIMIT: a tool whose convention is "non-zero exit MEANS zero found"
1009
+ * (`grep -c`, pytest's exit 5) stops here rather than counting zero. That is
1010
+ * deliberate and fail-safe — unreachable on the shipped TYPES genre, a loud
1011
+ * false stop at worst, and never a false green. A genre that needs the other
1012
+ * behaviour declares it; this default is not softened. See `measure`.
1013
+ * @param {any} stage @param {Ctx} ctx @returns {Promise<StageResult>} */
1014
+ async function runCountNotWorse(stage, ctx) {
1015
+ const p = stage.params;
1016
+ const norm = normalizeParser(p.parser, `${stage.name}.parser`);
1017
+ if (!norm.ok) {
1018
+ return stopped(stage, ctx, `INSTRUMENT: ${stage.name}'s parser does not normalise: ${norm.reds.map((r) => `${r.path} — ${r.detail}`).join('; ')}`,
1019
+ {}, STOP_FAULTS.CRASHED);
1020
+ }
1021
+ const terms = /** @type {any[]} */ (norm.terms);
1022
+
1023
+ /** @param {string} cwd @param {string} where */
1024
+ const measure = async (cwd, where) => {
1025
+ const r = await sh(p.cmd, p.args ?? [], {
1026
+ cwd,
1027
+ env: p.env ?? {},
1028
+ timeoutMs: effectiveTimeoutMs(p.timeoutMs, ctx),
1029
+ maxBuffer: ctx.maxBuffer ?? MAX_BUFFER,
1030
+ });
1031
+ if (r.stop !== null) return { stop: `${r.stop} (measuring ${where})`, fault: r.fault };
1032
+ const v = parseValue(r.out, terms, { scope: p.scope, workdir: cwd });
1033
+ if (v.stop !== null) return { stop: `${v.stop} (measuring ${where})`, fault: v.fault, notes: v.notes ?? [] };
1034
+ // A BROKEN RULER NEVER CERTIFIES ZERO — the zero-match split (contract (a))
1035
+ // finished. `sum` over nothing is a COUNTED zero because the region was
1036
+ // searched, and that reading is only believable when the tool itself came
1037
+ // back clean: a checker that died before it ever looked at the tree also
1038
+ // matches nothing, and 0 against a baseline of 0 reads GREEN. `first`
1039
+ // already stops on zero matches; `sum` did not, and the exit code the
1040
+ // measurement was already carrying was never consulted.
1041
+ //
1042
+ // So: zero matched AND a non-zero exit is could-not-run — the existing stop
1043
+ // lane, in BOTH directions. A crash that would have flattered the count and
1044
+ // one that would have faked a red are the same casualty, and a casualty is
1045
+ // never evidence either way (F45). Non-zero exit WITH matches stays a normal
1046
+ // reading: that is a checker reporting real findings, which is its job.
1047
+ //
1048
+ // "Matched" here is `preScopeMatches`, and the distinction is the whole
1049
+ // guard. A POST-scope count cannot tell a dead tool from a live one whose
1050
+ // every finding belonged to another population — they are TWO DIFFERENT
1051
+ // FAULTS and they read as one, the same class the `first` aggregate's own
1052
+ // stop already split above. Run mslsnnzk paid for it twice: tsc exited 2
1053
+ // and printed 67 real error lines, all under `src/`, against a stage scoped
1054
+ // `excludePrefixes:["src/"]`; post-scope the count was 0 and a good close
1055
+ // was refused as a crashed instrument. So liveness is read where liveness
1056
+ // lives — BEFORE the filter — and a live tool over an empty population is a
1057
+ // counted zero carrying the filter's own dropped-line note, which is what
1058
+ // the paragraph above always claimed and the code did not do. The fail-safe
1059
+ // is untouched in the direction that matters: a genuinely silent non-zero
1060
+ // exit still matched nothing pre-scope, and still stops.
1061
+ //
1062
+ // ACCEPTED LIMIT, stated rather than fixed. Some tools spell "I found
1063
+ // nothing" as a NON-ZERO EXIT — `grep -c` exits 1 on no match, pytest exits
1064
+ // 5 when it collects no tests. Declared as a count stage, one of those would
1065
+ // stop here instead of recording a true zero. No such misfire is
1066
+ // CONSTRUCTIBLE on the shipped TYPES genre (mypy and tsc exit 0 on a clean
1067
+ // tree and print the "found 0 errors" line the parser matches), so today the
1068
+ // rule can only produce a false STOP, never a false green — and that is
1069
+ // exactly the direction it is chosen for. A stop is loud, honest and
1070
+ // re-runnable; counting a crashed tool's silence as zero grades a red tree
1071
+ // GREEN. The F49 precedent governs: the fail-safe direction is never traded
1072
+ // away to chase the sharper one, and this default is not softened.
1073
+ //
1074
+ // A genre that genuinely needs exit-N-means-zero DECLARES it — a catalogue
1075
+ // parameter added at that rework, so the exception rides the signed spec and
1076
+ // is visible per stage. Never by loosening the default here, which would
1077
+ // silently re-open the fake-green lane under every close already signed.
1078
+ if (r.code !== 0 && v.preScopeMatches === 0) {
1079
+ return {
1080
+ stop: `INSTRUMENT: "${p.cmd}" exited ${r.code} and its output matched none of the parser's terms — a crashed tool reporting nothing is unknown, not zero (measuring ${where})`,
1081
+ fault: STOP_FAULTS.CRASHED,
1082
+ // empty BY CONSTRUCTION, not by oversight: every scope note requires a
1083
+ // matching line, and this branch requires that there were none
1084
+ notes: [],
1085
+ };
1086
+ }
1087
+ return { stop: null, value: v.value, breakdown: v.breakdown, notes: v.notes, exit: r.code, dropped: r.dropped, fault: STOP_FAULTS.FAILED };
1088
+ };
1089
+
1090
+ const now = await measure(ctx.workdir, 'the current tree');
1091
+ if (now.stop !== null) return stopped(stage, ctx, now.stop, {}, now.fault, now.notes);
1092
+
1093
+ // D12 — the close stores the COUNTING RULE, never the number, so "seed" is
1094
+ // MEASURED at this run's own seed. Which route measured it is recorded.
1095
+ let baseline = 0;
1096
+ let baselineSource = 'declared 0';
1097
+ /** @type {any} */
1098
+ let baselineDetail = null;
1099
+ if (p.baseline === 'seed') {
1100
+ const at = await treeIsAtSeed(ctx.workdir, ctx.seedRef);
1101
+ if (at.stop !== null) return stopped(stage, ctx, at.stop, {}, at.fault);
1102
+ if ((ctx.baselineMode ?? 'auto') === 'auto' && at.at) {
1103
+ baseline = /** @type {number} */ (now.value);
1104
+ baselineSource = 'measured IN PLACE — the working tree is verifiably identical to the seed';
1105
+ baselineDetail = { breakdown: now.breakdown };
1106
+ } else {
1107
+ const seedTrees = /** @type {SeedTrees} */ (ctx.seedTrees);
1108
+ const st = await seedTrees.ensure(ctx.workdir, ctx.seedRef);
1109
+ if (st.stop !== null) return stopped(stage, ctx, st.stop, {}, st.fault);
1110
+ const b = await measure(st.dir, `the seed tree at ${ctx.seedRef}`);
1111
+ if (b.stop !== null) return stopped(stage, ctx, b.stop, {}, b.fault, b.notes);
1112
+ baseline = /** @type {number} */ (b.value);
1113
+ baselineSource = `measured in a detached worktree at ${ctx.seedRef}`;
1114
+ baselineDetail = { breakdown: b.breakdown, notes: b.notes };
1115
+ }
1116
+ }
1117
+
1118
+ const value = /** @type {number} */ (now.value);
1119
+ const worse = p.direction === 'lower-is-better' ? value > baseline : value < baseline;
1120
+ const gap = new Gap(ctx.gapKeep, ctx.gapCap);
1121
+ for (const name of /** @type {string[]} */ (now.dropped ?? [])) gap.push(`${stage.name}: declared env var ${name} was DROPPED — the close never hands worker code a credential`);
1122
+ // the scope filter's own arithmetic is announced whichever way the stage
1123
+ // lands: a green measured over a filtered population is still a filtered
1124
+ // number, and the operator reads why
1125
+ for (const n of /** @type {string[]} */ (now.notes)) gap.push(`${stage.name}: ${n}`);
1126
+ if (worse) {
1127
+ gap.push(`${stage.name}: ${value} against a baseline of ${baseline} (${p.direction}) — worse`);
1128
+ gap.push(` baseline ${baselineSource}`);
1129
+ for (const b of /** @type {any[]} */ (now.breakdown)) {
1130
+ gap.push(` term ${b.term} /${b.lineMatch}/ ${b.aggregate}: ${b.matches} match(es), subtotal ${b.subtotal}, contributes ${b.contribution >= 0 ? '+' : ''}${b.contribution}`);
1131
+ }
1132
+ }
1133
+ return result(stage, {
1134
+ verdict: worse ? 'red' : 'green',
1135
+ exitCode: worse ? EXIT_RED : EXIT_GREEN,
1136
+ value,
1137
+ baseline,
1138
+ baselineSource,
1139
+ gapLines: gap.render(),
1140
+ detail: {
1141
+ direction: p.direction,
1142
+ breakdown: now.breakdown,
1143
+ baselineBreakdown: baselineDetail,
1144
+ notes: now.notes,
1145
+ commandExit: now.exit,
1146
+ envDropped: now.dropped ?? [],
1147
+ },
1148
+ });
1149
+ }
1150
+
1151
+ /** @param {any} stage @param {Ctx} ctx @returns {Promise<StageResult>} */
1152
+ async function runPatternAbsentInDiff(stage, ctx) {
1153
+ const p = stage.params;
1154
+ const cs = await changedSet(ctx.workdir, ctx.seedRef);
1155
+ if (cs.stop !== null) return stopped(stage, ctx, cs.stop, {}, cs.fault);
1156
+
1157
+ const untracked = new Set(cs.untracked);
1158
+ const exts = p.extensions.map((/** @type {string} */ e) => (String(e).startsWith('.') ? String(e) : `.${e}`));
1159
+ // git spells the tree it walked; the declared scope spells the job's own
1160
+ // target, and on a symlinked package those are two names for one file. The
1161
+ // lexical-only reading dropped the real file out of the scanned set and read
1162
+ // GREEN over a suppression it never opened — a guard's fake-green direction.
1163
+ const phys = scopeFilters(p.scope) ? physicalIn(ctx.workdir) : null;
1164
+ const scanned = cs.paths.filter((rel) => exts.some((e) => rel.endsWith(e)) && inScope(rel, p.scope, phys));
1165
+
1166
+ /** @type {RegExp[]} */
1167
+ const res = [];
1168
+ for (const q of p.patterns) {
1169
+ try { res.push(new RegExp(q.regex)); } catch (e) { return stopped(stage, ctx, `INSTRUMENT: pattern "${q.id}" is not a valid regex: ${String(/** @type {any} */ (e)?.message ?? e)}`, {}, STOP_FAULTS.CRASHED); }
1170
+ }
1171
+
1172
+ const gap = new Gap(ctx.gapKeep, ctx.gapCap);
1173
+ /** @type {any[]} */
1174
+ const hits = [];
1175
+ /** @type {string[]} */
1176
+ const notes = [];
1177
+ for (const rel of scanned) {
1178
+ const a = await addedLines(ctx.workdir, ctx.seedRef, rel, { untracked: untracked.has(rel) });
1179
+ if (a.stop !== null) return stopped(stage, ctx, a.stop, {}, a.fault);
1180
+ if (a.note) notes.push(a.note);
1181
+ for (const { n, text } of a.lines) {
1182
+ p.patterns.forEach((/** @type {any} */ q, /** @type {number} */ i) => {
1183
+ if (res[i].test(text)) hits.push({ id: q.id, path: rel, line: n, text: text.trim().slice(0, 200) });
1184
+ });
1185
+ }
1186
+ }
1187
+ for (const n of notes) gap.push(`${stage.name}: ${n}`);
1188
+ if (hits.length) {
1189
+ gap.push(`${stage.name}: ${hits.length} forbidden pattern occurrence(s) in lines this run ADDED`);
1190
+ // the file is named because the SCAN itself reported it — contract (b)'s
1191
+ // limit bars naming a culprit the instrument did not name, not echoing
1192
+ // one it did
1193
+ for (const h of hits) gap.push(` [${h.id}] ${h.path}:${h.line}: ${h.text}`);
1194
+ }
1195
+ return result(stage, {
1196
+ verdict: hits.length ? 'red' : 'green',
1197
+ exitCode: hits.length ? EXIT_RED : EXIT_GREEN,
1198
+ gapLines: gap.render(),
1199
+ detail: { scannedFiles: scanned, hits, notes, changedTotal: cs.paths.length, excluded: cs.excluded },
1200
+ });
1201
+ }
1202
+
1203
+ /** @param {any} stage @param {Ctx} ctx @returns {Promise<StageResult>} */
1204
+ async function runFilesChanged(stage, ctx) {
1205
+ const p = stage.params;
1206
+ const cs = await changedSet(ctx.workdir, ctx.seedRef);
1207
+ if (cs.stop !== null) return stopped(stage, ctx, cs.stop, {}, cs.fault);
1208
+ // the containment fence resolves too. Only ONE of its two sides is git's
1209
+ // spelling — `allowPrefixes` is the job's own target, and on the aurora shape
1210
+ // (`src/pkg -> ../packages/x/src/pkg`) the declaration says `src/pkg/` while
1211
+ // git says `packages/x/src/pkg/...`: a real in-target edit read as a stray.
1212
+ // Physical only on a lexical miss, both sides resolved, so a spelling that
1213
+ // lands outside every prefix is still outside. ACCEPTED, stated rather than
1214
+ // fixed: a link that LIVES outside a prefix and POINTS inside it now counts
1215
+ // as inside — the same reading the scope filter has shipped since de61e87,
1216
+ // and the mild direction; the escaping one stays shut.
1217
+ const phys = physicalIn(ctx.workdir);
1218
+ const outside = cs.paths.filter((rel) => !p.allowPrefixes.some((/** @type {string} */ pre) => underPrefixEither(rel, pre, phys)));
1219
+ const empty = p.requireNonEmpty === true && cs.paths.length === 0;
1220
+ const gap = new Gap(ctx.gapKeep, ctx.gapCap);
1221
+ if (empty) gap.push(`${stage.name}: nothing changed against the seed — the changed set is empty`);
1222
+ if (outside.length) {
1223
+ gap.push(`${stage.name}: ${outside.length} changed file(s) lie outside ${p.allowPrefixes.join(', ')}`);
1224
+ for (const o of outside) gap.push(` ${o}`);
1225
+ }
1226
+ const red = empty || outside.length > 0;
1227
+ return result(stage, {
1228
+ verdict: red ? 'red' : 'green',
1229
+ exitCode: red ? EXIT_RED : EXIT_GREEN,
1230
+ gapLines: gap.render(),
1231
+ detail: { changed: cs.paths, outside, excluded: cs.excluded, empty },
1232
+ });
1233
+ }
1234
+
1235
+ /** @type {Record<string, (stage: any, ctx: Ctx) => Promise<StageResult>>} */
1236
+ const RUNNERS = {
1237
+ 'command-exit': runCommandExit,
1238
+ 'count-not-worse': runCountNotWorse,
1239
+ 'pattern-absent-in-diff': runPatternAbsentInDiff,
1240
+ 'files-changed': runFilesChanged,
1241
+ };
1242
+
1243
+ /**
1244
+ * Run ONE declared stage against a workdir at a seed.
1245
+ *
1246
+ * It never throws. An unexpected throw inside a kind — a malformed parameter
1247
+ * the validator did not catch, an unreachable path — is an INSTRUMENT STOP,
1248
+ * not a red: that is the casualty-vs-evidence split at its narrowest, and
1249
+ * the direction that matters, because a crash rendered as a red is fake
1250
+ * evidence about the worker.
1251
+ * @param {any} stage `{name, kind, params}`
1252
+ * @param {Ctx} ctx
1253
+ * @returns {Promise<StageResult>}
1254
+ */
1255
+ export async function runStage(stage, ctx) {
1256
+ const owned = !ctx.seedTrees;
1257
+ const seedTrees = ctx.seedTrees ?? makeSeedTrees();
1258
+ try {
1259
+ return await runStageIn(stage, { ...ctx, seedTrees });
1260
+ } finally {
1261
+ if (owned) await seedTrees.cleanup();
1262
+ }
1263
+ }
1264
+
1265
+ /** @param {any} stage @param {Ctx} ctx @returns {Promise<StageResult>} */
1266
+ async function runStageIn(stage, ctx) {
1267
+ if (stage === null || typeof stage !== 'object' || Array.isArray(stage)) return stopped(null, ctx, 'INSTRUMENT: a stage must be an object {name, kind, params}', {}, STOP_FAULTS.CRASHED);
1268
+ const fn = RUNNERS[stage.kind];
1269
+ if (!fn) {
1270
+ return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" declares kind "${stage.kind}", which this executor does not implement (locked or unknown) — the live kinds are ${LIVE_KINDS.join(', ')}`,
1271
+ { kind: stage.kind }, STOP_FAULTS.CRASHED);
1272
+ }
1273
+ if (stage.params === null || typeof stage.params !== 'object') return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" declares no params object`, {}, STOP_FAULTS.CRASHED);
1274
+ try {
1275
+ return await fn(stage, ctx);
1276
+ } catch (e) {
1277
+ return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" threw: ${String(/** @type {any} */ (e)?.stack ?? e)}`, {}, STOP_FAULTS.CRASHED);
1278
+ }
1279
+ }
1280
+
1281
+ /**
1282
+ * D12's seed-verdict read: run EVERY declared stage at the seed, regardless
1283
+ * of reds, and hand back one row per stage.
1284
+ *
1285
+ * Not first-red-wins, and the reason is structural rather than a preference:
1286
+ * every shipped close opens with `changed-from-seed`, which is RED at its own
1287
+ * seed by construction (nothing has changed yet), so a first-red-wins seed
1288
+ * read measures exactly one stage and mints no baseline for any other. The
1289
+ * baseline every `baseline: "seed"` stage is later graded against comes from
1290
+ * here, so a stage that never ran is a stage with no bar.
1291
+ * @param {any} declaration `{stages: [...]}`
1292
+ * @param {Ctx} ctx
1293
+ * @returns {Promise<StageResult[]>}
1294
+ */
1295
+ export async function seedRead(declaration, ctx) {
1296
+ const owned = !ctx.seedTrees;
1297
+ const seedTrees = ctx.seedTrees ?? makeSeedTrees();
1298
+ const inner = { ...ctx, seedTrees };
1299
+ try {
1300
+ /** @type {StageResult[]} */
1301
+ const rows = [];
1302
+ for (const stage of declaration?.stages ?? []) rows.push(await runStageIn(stage, inner));
1303
+ return rows;
1304
+ } finally {
1305
+ if (owned) await seedTrees.cleanup();
1306
+ }
1307
+ }
1308
+
1309
+ /**
1310
+ * The GRADING path: stages in declared order, FIRST RED WINS. The deciding
1311
+ * stage names the verdict; everything after it is `not-reached` and is
1312
+ * reported as such rather than as a green nobody ran.
1313
+ *
1314
+ * An instrument stop ends the close UNJUDGED — the run has no verdict to
1315
+ * carry, which is the whole point of keeping a casualty out of the evidence.
1316
+ *
1317
+ * NAMED `runDeclaredClose`, not `runClose` (M4's export-surface decision): the
1318
+ * arbiter's shell already ships a public `runClose(argv, redact, opts)` that
1319
+ * adopters import, and the two take different things and mean different things
1320
+ * — one runs a DECLARATION, the other an argv. Renaming the shipped one to make
1321
+ * room would break a documented adopter contract for nothing; the new name says
1322
+ * exactly what differs. `src/declaredclose.js` is what bridges the two shapes.
1323
+ * @param {any} declaration @param {Ctx} ctx
1324
+ * @returns {Promise<{verdict: 'green'|'red'|'instrument-stop', exitCode: number, judged: boolean, firstRed: string|null, stages: StageResult[]}>}
1325
+ */
1326
+ export async function runDeclaredClose(declaration, ctx) {
1327
+ const owned = !ctx.seedTrees;
1328
+ const seedTrees = ctx.seedTrees ?? makeSeedTrees();
1329
+ const inner = { ...ctx, seedTrees };
1330
+ try {
1331
+ /** @type {StageResult[]} */
1332
+ const stages = [];
1333
+ let ended = false;
1334
+ for (const stage of declaration?.stages ?? []) {
1335
+ if (ended) {
1336
+ stages.push(result(stage, { verdict: 'not-reached', exitCode: null, judged: false }));
1337
+ continue;
1338
+ }
1339
+ const r = await runStageIn(stage, inner);
1340
+ stages.push(r);
1341
+ if (r.verdict !== 'green') ended = true;
1342
+ }
1343
+ const deciding = stages.find((s) => s.verdict === 'red' || s.verdict === 'instrument-stop');
1344
+ return {
1345
+ verdict: /** @type {'green'|'red'|'instrument-stop'} */ (deciding ? deciding.verdict : 'green'),
1346
+ exitCode: deciding ? /** @type {number} */ (deciding.exitCode) : EXIT_GREEN,
1347
+ judged: deciding ? deciding.judged : true,
1348
+ firstRed: deciding ? deciding.stage : null,
1349
+ stages,
1350
+ };
1351
+ } finally {
1352
+ if (owned) await seedTrees.cleanup();
1353
+ }
1354
+ }