bareloop 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/kinds.js ADDED
@@ -0,0 +1,1445 @@
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 arbiter books, and the shape of each exclusion is the whole point.
350
+ *
351
+ * `.litectx/` and `.smoke/` are PREFIXES — everything beneath either store
352
+ * belongs to the arbiter. `gate-audit.jsonl` is an EXACT repo-relative path and
353
+ * never a 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. `src/smoke.js` and
358
+ * `tests/smoke.test.js` are that same decoy for the store below.
359
+ *
360
+ * `.smoke/` — F98's parked residual, closed here. The fence has always denied
361
+ * `join(workdir, '.smoke')` on every worker (src/planrun.js, src/authorscout.js)
362
+ * and the persona now states the rule; this reader, which decides what COUNTS AS
363
+ * WORK, was the one place that never knew the name. On patients whose .gitignore
364
+ * default-denies dot-directories the store never reaches `ls-files --others` and
365
+ * the gap is masked — by the PATIENT's config, not by anything here. Incidental
366
+ * masking is not an exclusion: on a patient without that line the arbiter's own
367
+ * store would enter the changed set as the worker's writing.
368
+ * @param {string} rel a repo-relative POSIX path
369
+ * @returns {string|null} the book it belongs to, or null
370
+ */
371
+ export function isArbiterBook(rel) {
372
+ for (const store of ARBITER_BOOK_STORES) {
373
+ if (rel === store || rel.startsWith(`${store}/`)) return `${store}/`;
374
+ }
375
+ if (rel === GATE_AUDIT_FILE) return GATE_AUDIT_FILE;
376
+ return null;
377
+ }
378
+
379
+ /**
380
+ * The arbiter's book NAMES, one home. F98's mechanism was exactly this list
381
+ * living as independent literals: the fence deny arrays (src/planrun.js,
382
+ * src/authorscout.js) knew `.smoke` while `isArbiterBook` — the reader that
383
+ * decides what COUNTS AS WORK — did not, and the drift was masked by the
384
+ * patient's own .gitignore. Every consumer (the fence deny lists, this file's
385
+ * reader, the persona's stated law, run.js's smoke-store root) now spells the
386
+ * names from here, so adding or renaming a book is one edit that cannot leave
387
+ * a site behind.
388
+ *
389
+ * `ARBITER_BOOK_STORES` are directory PREFIXES; `GATE_AUDIT_FILE` is an EXACT
390
+ * repo-relative path (see `isArbiterBook`'s docstring for why never a
391
+ * basename). Order is the fence deny-list order and the persona's prose order.
392
+ */
393
+ export const SMOKE_STORE = '.smoke';
394
+ export const LITECTX_STORE = '.litectx';
395
+ export const GATE_AUDIT_FILE = 'gate-audit.jsonl';
396
+ export const ARBITER_BOOK_STORES = Object.freeze([SMOKE_STORE, LITECTX_STORE]);
397
+
398
+ /**
399
+ * Everything this run changed against the seed: the tracked diff PLUS
400
+ * untracked files (an untracked file has no diff — the case a naive
401
+ * `git diff` scan silently misses), minus the arbiter's own books.
402
+ * @param {string} workdir @param {string} seedRef
403
+ * @returns {Promise<{stop: string, fault: string}|{stop: null, paths: string[], tracked: string[], untracked: string[], excluded: {path: string, book: string}[]}>}
404
+ */
405
+ export async function changedSet(workdir, seedRef) {
406
+ const verify = await git(workdir, ['rev-parse', '--verify', `${seedRef}^{commit}`]);
407
+ if (!verify.ok) return { stop: `INSTRUMENT: the seed commit "${seedRef}" does not exist in ${workdir}`, fault: STOP_FAULTS.FAILED };
408
+ const d = await git(workdir, ['diff', '--name-only', seedRef]);
409
+ if (!d.ok) return { stop: `INSTRUMENT: git diff against "${seedRef}" failed: ${String(d.err).trim()}`, fault: d.fault };
410
+ const u = await git(workdir, ['ls-files', '--others', '--exclude-standard']);
411
+ if (!u.ok) return { stop: `INSTRUMENT: git ls-files --others failed: ${String(u.err).trim()}`, fault: u.fault };
412
+ const split = (/** @type {string} */ s) => s.split('\n').map((x) => x.trim()).filter(Boolean);
413
+ /** @type {{path: string, book: string}[]} */
414
+ const excluded = [];
415
+ const keep = (/** @type {string[]} */ list) => list.filter((p) => {
416
+ const book = isArbiterBook(p);
417
+ if (book) { excluded.push({ path: p, book }); return false; }
418
+ return true;
419
+ });
420
+ const tracked = keep(split(d.out));
421
+ const untracked = keep(split(u.out));
422
+ return { stop: null, tracked, untracked, excluded, paths: [...new Set([...tracked, ...untracked])].sort() };
423
+ }
424
+
425
+ /**
426
+ * The lines this run ADDED to one file. A tracked file's additions come from
427
+ * its own unified diff; an UNTRACKED file has no diff at all, so its ENTIRE
428
+ * body is added — the half of this primitive a diff-only scan misses.
429
+ * @param {string} workdir @param {string} seedRef @param {string} rel
430
+ * @param {{untracked?: boolean}} [o]
431
+ * @returns {Promise<{stop: string, fault: string}|{stop: null, lines: {n: number, text: string}[], note: string|null}>}
432
+ */
433
+ export async function addedLines(workdir, seedRef, rel, { untracked = false } = {}) {
434
+ if (untracked) {
435
+ const abs = join(workdir, rel);
436
+ let st;
437
+ // NOT a stop: a dangling symlink or a file that vanished between the
438
+ // changed-set read and this one has no content to hide, so refusing to grade
439
+ // the run over it would be the wrong direction. But it is not SCANNED either,
440
+ // and every sibling branch below (the cap, the binary check) says so out
441
+ // loud — returning note:null here made an unscannable file read as clean.
442
+ try { st = await stat(abs); } catch (e) {
443
+ const code = String(/** @type {any} */ (e)?.code ?? /** @type {any} */ (e)?.message ?? e);
444
+ return { stop: null, lines: [], note: `${rel}: could not stat (${code}) — NOT scanned` };
445
+ }
446
+ if (!st.isFile()) return { stop: null, lines: [], note: null };
447
+ 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` };
448
+ let buf;
449
+ 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 }; }
450
+ if (buf.includes(0)) return { stop: null, lines: [], note: `${rel}: binary — NOT scanned` };
451
+ return { stop: null, lines: buf.toString('utf8').split('\n').map((text, i) => ({ n: i + 1, text })), note: null };
452
+ }
453
+ const d = await git(workdir, ['diff', '-U0', '--no-color', seedRef, '--', rel]);
454
+ // `ok` IS `code === 0` (see `git`), and a spawn fault comes back `code: null`,
455
+ // so a second `code !== 0` clause was a tautology reading as a live guard.
456
+ if (!d.ok) return { stop: `INSTRUMENT: git diff -U0 for ${rel} failed: ${String(d.err).trim()}`, fault: d.fault };
457
+ /** @type {{n: number, text: string}[]} */
458
+ const lines = [];
459
+ let n = 0;
460
+ // The `+++`/`---` skip is scoped to the PREAMBLE, and the scope is the fix for a
461
+ // real fake-green: git renders an ADDED line whose own text begins with `++` as
462
+ // `+++i;`, so a header test applied inside a hunk swallowed it and the guard read
463
+ // GREEN over a line it never opened. File headers only ever appear before the
464
+ // first `@@`, so `inHunk` says exactly where a header can legally be — the guard's
465
+ // dangerous direction, closed by construction rather than by a sharper pattern.
466
+ let inHunk = false;
467
+ for (const raw of d.out.split('\n')) {
468
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
469
+ if (hunk) { n = Number(hunk[1]); inHunk = true; continue; }
470
+ if (!inHunk && (raw.startsWith('+++') || raw.startsWith('---'))) continue;
471
+ if (raw.startsWith('+')) { lines.push({ n, text: raw.slice(1) }); n += 1; }
472
+ }
473
+ return { stop: null, lines, note: null };
474
+ }
475
+
476
+ // ── scope: one prefix spelling, the shipped one ──────────────────────────────
477
+
478
+ /** normalise a declared prefix through the SHIPPED spelling, never a second
479
+ * one — `globToPrefix`'s collapse order is fence territory (F9). */
480
+ const normPrefix = (/** @type {string} */ p) => globToPrefix(String(p)).replace(/^\.\//, '');
481
+
482
+ /** @param {string} rel @param {string} prefix */
483
+ function underPrefix(rel, prefix) {
484
+ const pre = normPrefix(prefix);
485
+ if (pre === '' || pre === '.' || pre === '/') return true;
486
+ const p = pre.endsWith('/') ? pre : `${pre}/`;
487
+ return rel === pre.replace(/\/$/, '') || rel.startsWith(p);
488
+ }
489
+
490
+ /**
491
+ * Physical identity, for when two SPELLINGS name one file. A live aurora run
492
+ * measured the cost of not having this: the patient's layout carries a tracked
493
+ * `src/aurora_spawner -> ../packages/spawner/src/aurora_spawner` symlink (the
494
+ * ordinary python monorepo shape), mypy printed every error as
495
+ * `src/aurora_spawner/...`, the declared scope said
496
+ * `packages/spawner/src/aurora_spawner/`, and a lexical matcher dropped all 16
497
+ * REAL error lines — 0 against a baseline of 0 is a fake green, and only the
498
+ * crash-stop caught it.
499
+ *
500
+ * `fs.realpathSync` and nothing else: lexical `..` resolution (and
501
+ * `path.posix.normalize`, which does it silently) HIDES escapes, which is why
502
+ * `globToPrefix` refuses to do it. The kernel's answer cannot be spoofed.
503
+ * Resolution failure is not an answer — the caller falls back to the lexical
504
+ * reading, so a path that is not in the measured tree can never GAIN scope
505
+ * membership it did not already have.
506
+ * @param {string} workdir
507
+ * @returns {{real: (rel: string) => string|null}|null} null when the measuring
508
+ * directory itself cannot be resolved — then there is no physical reading at all
509
+ */
510
+ function physicalIn(workdir) {
511
+ if (!workdir) return null;
512
+ /** one realpath per distinct path per stage/parse call — a tool names the same
513
+ * file on many lines, and the scope filter reads every one of them */
514
+ const cache = new Map();
515
+ const real = (/** @type {string} */ abs) => {
516
+ if (cache.has(abs)) return /** @type {string|null} */ (cache.get(abs));
517
+ /** @type {string|null} */
518
+ let r = null;
519
+ try { r = realpathSync(abs); } catch { r = null; }
520
+ cache.set(abs, r);
521
+ return r;
522
+ };
523
+ const root = real(workdir);
524
+ if (root === null) return null;
525
+ return { real: (/** @type {string} */ rel) => real(join(root, rel)) };
526
+ }
527
+
528
+ /**
529
+ * Is `rel` the same PHYSICAL file as something under `prefix`? Both sides are
530
+ * resolved, so a symlinked report matches a physical prefix AND a physical
531
+ * report matches a symlinked prefix. Containment is checked on two realpaths,
532
+ * where a segment-prefix test means what it says — no symlink can sit inside
533
+ * either spelling any more, so this cannot widen scope to an escape: a file
534
+ * only matches when the prefix PHYSICALLY contains it.
535
+ * @param {string} rel @param {string} prefix
536
+ * @param {{real: (rel: string) => string|null}} phys
537
+ */
538
+ function underPrefixPhysically(rel, prefix, phys) {
539
+ const pre = normPrefix(prefix).replace(/\/+$/, '');
540
+ // '' / '.' / '/' already answered TRUE lexically, and an absolute prefix
541
+ // matches nothing lexically (linePath never returns one) — resolving either
542
+ // against the workdir would invent a membership the declaration never made
543
+ if (pre === '' || pre === '.' || pre.startsWith('/')) return false;
544
+ const preAbs = phys.real(pre);
545
+ if (preAbs === null) return false;
546
+ const fileAbs = phys.real(rel);
547
+ if (fileAbs === null) return false;
548
+ return fileAbs === preAbs || fileAbs.startsWith(preAbs.endsWith(sep) ? preAbs : `${preAbs}${sep}`);
549
+ }
550
+
551
+ /**
552
+ * The fence's ONE containment reading, shared by every site that has one.
553
+ * Lexical FIRST and physical only on a miss: every spelling that matched
554
+ * before still matches, so resolution can only ever unify two names for one
555
+ * file — it never takes membership away, and it never reaches past a prefix
556
+ * that does not physically contain the file.
557
+ *
558
+ * Without a resolver (`phys === null`) this IS the old lexical fence, byte for
559
+ * byte.
560
+ * @param {string} rel @param {string} prefix
561
+ * @param {{real: (rel: string) => string|null}|null} phys
562
+ */
563
+ function underPrefixEither(rel, prefix, phys) {
564
+ return underPrefix(rel, prefix) || (phys !== null && underPrefixPhysically(rel, prefix, phys));
565
+ }
566
+
567
+ /** @param {string} rel @param {any} scope
568
+ * @param {{real: (rel: string) => string|null}|null} [phys] physical resolver;
569
+ * absent leaves the comparison purely lexical */
570
+ function inScope(rel, scope, phys = null) {
571
+ if (!scope || typeof scope !== 'object') return true;
572
+ const inc = Array.isArray(scope.includePrefixes) ? scope.includePrefixes : null;
573
+ const exc = Array.isArray(scope.excludePrefixes) ? scope.excludePrefixes : null;
574
+ const under = (/** @type {string} */ p) => underPrefixEither(rel, p, phys);
575
+ if (inc && !inc.some(under)) return false;
576
+ // exclusion resolves too: a symlink spelling must not smuggle a file past an
577
+ // exclude the physical spelling would have caught
578
+ if (exc && exc.some(under)) return false;
579
+ return true;
580
+ }
581
+
582
+ /** does this declared scope actually filter anything? — one spelling of the
583
+ * question, so no caller can decide it differently from another
584
+ * @param {any} scope */
585
+ const scopeFilters = (scope) => !!scope && (Array.isArray(scope.includePrefixes) || Array.isArray(scope.excludePrefixes));
586
+
587
+ /**
588
+ * The first repository-relative path a tool named on a line, or null. At
589
+ * least one `/` is required, so prose ("Found 3 errors in 2 files") never
590
+ * reads as a path — a false path would silently steer the scope filter.
591
+ * @param {string} line @param {string} workdir
592
+ */
593
+ function linePath(line, workdir) {
594
+ const m = /(?:^|[\s"'(\[<])((?:\.\/)?\/?(?:[\w.@+~-]+\/)+[\w.@+~-]+)/.exec(line);
595
+ if (!m) return null;
596
+ let p = m[1].replace(/^\.\//, '');
597
+ const wd = workdir.endsWith(sep) ? workdir : `${workdir}${sep}`;
598
+ if (p.startsWith(wd)) p = p.slice(wd.length);
599
+ else if (p.startsWith('/')) return null; // absolute, outside the patient — attributes to nothing
600
+ return p;
601
+ }
602
+
603
+ // ── the parser: ONE spelling, shared by validator and executor ───────────────
604
+
605
+ /**
606
+ * Count a pattern's capture groups without running it against real input:
607
+ * `src + '|'` always matches the empty string, so the result array's length
608
+ * names the group count. Also the ONE place a declared regex is compiled at
609
+ * validation time, so a broken pattern reds before any process is spawned.
610
+ * @param {any} src
611
+ * @returns {{count: number, red: string|null}}
612
+ */
613
+ export function regexGroups(src) {
614
+ if (typeof src !== 'string' || src === '') return { count: 0, red: 'a regular expression, as a string' };
615
+ try {
616
+ const m = new RegExp(`${src}|`).exec('');
617
+ return { count: (m?.length ?? 1) - 1, red: null };
618
+ } catch (e) {
619
+ return { count: 0, red: `/${src}/ is not a valid regular expression: ${String(/** @type {any} */ (e)?.message ?? e)}` };
620
+ }
621
+ }
622
+
623
+ /**
624
+ * THE normalisation of a `count-not-worse` parser (gate-1 amendment #5).
625
+ * The declaration validator and this executor both call it, so there is
626
+ * exactly one spelling of what a parser MEANS — two normalisers that
627
+ * disagree are two instruments, and the one that grades is the one nobody
628
+ * validated.
629
+ *
630
+ * value = Σ over terms of sign × (first | sum of that term's captures,
631
+ * inside that term's region)
632
+ *
633
+ * Backwards compatible by construction: the short form
634
+ * `{lineMatch, capture?}` resolves to exactly one term with
635
+ * `sign +1, aggregate "first", region "whole-output"`, which is the
636
+ * pre-amendment semantics unchanged.
637
+ *
638
+ * Never throws — reds come back in the shipped `{ok, reds, ...}` shape.
639
+ * @param {any} parser @param {string} [at] path prefix for reds
640
+ * @returns {{ok: boolean, reds: Red[], terms: any[]|null}}
641
+ */
642
+ export function normalizeParser(parser, at = 'parser') {
643
+ /** @type {Red[]} */
644
+ const reds = [];
645
+ const red = (/** @type {string} */ path, /** @type {string} */ detail, code = 'invalid-value') => reds.push({ code, path, detail });
646
+ if (parser === null || typeof parser !== 'object' || Array.isArray(parser)) {
647
+ red(at, 'a parser object: {terms:[...]} or the short form {lineMatch, capture?}', 'missing-field');
648
+ return { ok: false, reds, terms: null };
649
+ }
650
+ const hasTerms = Object.hasOwn(parser, 'terms');
651
+ const hasShort = Object.hasOwn(parser, 'lineMatch');
652
+ if (hasTerms && hasShort) {
653
+ red(at, 'a parser declares EITHER terms or the short form lineMatch — never both');
654
+ return { ok: false, reds, terms: null };
655
+ }
656
+ /** @type {any[]} */
657
+ let raw;
658
+ if (hasShort) {
659
+ for (const k of Object.keys(parser)) if (!['lineMatch', 'capture'].includes(k)) red(`${at}.${k}`, 'the short form takes lineMatch and capture only');
660
+ raw = [{ lineMatch: parser.lineMatch, ...(Object.hasOwn(parser, 'capture') ? { capture: parser.capture } : {}), sign: 1, aggregate: 'first', region: 'whole-output' }];
661
+ } else if (hasTerms) {
662
+ for (const k of Object.keys(parser)) if (k !== 'terms') red(`${at}.${k}`, 'the long form takes terms only');
663
+ if (!Array.isArray(parser.terms) || parser.terms.length === 0) {
664
+ red(`${at}.terms`, 'a non-empty array of terms');
665
+ return { ok: false, reds, terms: null };
666
+ }
667
+ if (parser.terms.length > MAX_TERMS) red(`${at}.terms`, `${parser.terms.length} terms exceeds the ceiling of ${MAX_TERMS}`);
668
+ raw = parser.terms;
669
+ } else {
670
+ red(at, 'a parser object: {terms:[...]} or the short form {lineMatch, capture?}', 'missing-field');
671
+ return { ok: false, reds, terms: null };
672
+ }
673
+
674
+ /** @type {any[]} */
675
+ const terms = [];
676
+ raw.forEach((t, i) => {
677
+ const p = `${at}${hasShort ? '' : `.terms[${i}]`}`;
678
+ if (t === null || typeof t !== 'object' || Array.isArray(t)) { red(p, 'a term object'); return; }
679
+ 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');
680
+ const groups = regexGroups(t.lineMatch);
681
+ if (groups.red) { red(`${p}.lineMatch`, groups.red); return; }
682
+ // capture defaults to group 1; a pattern with NO group tallies 1 per match,
683
+ // which is how a stage COUNTS occurrences instead of reading a printed figure
684
+ const capture = Object.hasOwn(t, 'capture') && t.capture !== null && t.capture !== undefined
685
+ ? t.capture
686
+ : (groups.count === 0 ? null : 1);
687
+ if (capture !== null && !(Number.isInteger(capture) && capture >= 0)) { red(`${p}.capture`, 'a non-negative integer capture-group index'); return; }
688
+ if (capture !== null && capture > groups.count) { red(`${p}.capture`, `group ${capture} does not exist — /${t.lineMatch}/ has ${groups.count} capture group(s)`); return; }
689
+ const sign = Object.hasOwn(t, 'sign') ? t.sign : undefined;
690
+ if (!SIGNS.includes(sign)) { red(`${p}.sign`, `one of ${SIGNS.join(' | ')}`, Object.hasOwn(t, 'sign') ? 'invalid-value' : 'missing-field'); return; }
691
+ const aggregate = Object.hasOwn(t, 'aggregate') ? t.aggregate : undefined;
692
+ if (!AGGREGATES.includes(aggregate)) { red(`${p}.aggregate`, `one of ${AGGREGATES.join(' | ')}`, Object.hasOwn(t, 'aggregate') ? 'invalid-value' : 'missing-field'); return; }
693
+ const rawRegion = Object.hasOwn(t, 'region') ? t.region : undefined;
694
+ /** @type {any} */
695
+ let region;
696
+ if (rawRegion === 'whole-output') region = 'whole-output';
697
+ else if (rawRegion !== null && typeof rawRegion === 'object' && !Array.isArray(rawRegion)) {
698
+ const ag = regexGroups(rawRegion.anchor);
699
+ if (ag.red) { red(`${p}.region.anchor`, ag.red); return; }
700
+ const rc = Object.hasOwn(rawRegion, 'capture') && rawRegion.capture !== null ? rawRegion.capture : 0;
701
+ if (!(Number.isInteger(rc) && rc >= 0)) { red(`${p}.region.capture`, 'a non-negative integer capture-group index (0 = the whole anchor match)'); return; }
702
+ if (rc > ag.count) { red(`${p}.region.capture`, `group ${rc} does not exist — /${rawRegion.anchor}/ has ${ag.count} capture group(s)`); return; }
703
+ for (const k of Object.keys(rawRegion)) if (!['anchor', 'capture'].includes(k)) red(`${p}.region.${k}`, 'a region takes anchor and capture only');
704
+ region = { anchor: rawRegion.anchor, capture: rc };
705
+ } else { red(`${p}.region`, '"whole-output" or {anchor, capture}', Object.hasOwn(t, 'region') ? 'invalid-value' : 'missing-field'); return; }
706
+ terms.push({ lineMatch: t.lineMatch, capture, sign, aggregate, region });
707
+ });
708
+
709
+ if (reds.length) return { ok: false, reds, terms: null };
710
+ return { ok: true, reds, terms };
711
+ }
712
+
713
+ /**
714
+ * Read ONE number out of a command's output, as signed arithmetic over
715
+ * normalised terms.
716
+ *
717
+ * The zero-match split is contract (a), not a convenience:
718
+ * - `sum` over zero matches is a COUNTED zero. The region was searched and
719
+ * nothing was there; that is legitimately 0.
720
+ * - `first` over zero matches is UNKNOWN — the number was never reported —
721
+ * and unknown is an instrument stop, never a defaulted 0 (F6).
722
+ * - a region whose anchor matches nothing was never LOCATED: stop.
723
+ * The scope filter runs per OUTPUT LINE, before aggregation, and both its
724
+ * drop count and its unattributable count are ANNOUNCED: a line naming no
725
+ * path is KEPT (no filter can exclude what it cannot locate) and the notes
726
+ * say so, rather than the number quietly meaning something else.
727
+ *
728
+ * `preScopeMatches` is reported alongside the reading because a POST-scope
729
+ * count cannot answer "did the tool run at all". It tallies (term, line) pairs
730
+ * that matched BEFORE the filter judged them — kept, unattributable and dropped
731
+ * alike — and it is the only evidence of liveness a caller has once the scope
732
+ * has removed the whole population. It is never the number being read.
733
+ *
734
+ * `matched` is the KEPT lines themselves, PER TERM and index-aligned with
735
+ * `breakdown`: entry i holds, in output order, exactly the lines term i's
736
+ * subtotal was computed FROM. Run u-msn227nq paid for the echo — the executor
737
+ * counted 8 real `error TS…` lines and told the worker "8 match(es)", discarding
738
+ * every address, and a worker with a number and no file to open went looking for
739
+ * the arbiter's own books instead.
740
+ *
741
+ * TWO EXCLUSIONS, both of them "this line is not what the count is made of":
742
+ * - a scope-DROPPED line is never in here. It was never counted, so aiming the
743
+ * worker at it would aim it outside its own population.
744
+ * - under `first`, only the line the aggregate actually READ. The later matches
745
+ * are real matches (`breakdown[i].matches` still counts them) but the value
746
+ * never contained them, and echoing them points the worker at a number that
747
+ * is not in the reading.
748
+ *
749
+ * And the GROUPING is the third: a term's sign lives in `breakdown[i]`, so lines
750
+ * pooled flat across terms lose the one fact that says what they DID to the
751
+ * number. A subtracted term's line rendered beside an added one reads as a wall
752
+ * to go fix, and fixing it moves the count the wrong way. Deduplication is
753
+ * therefore within a term only — one line matched by two terms plays two
754
+ * arithmetic roles and is named under each. The count is computed from `values`,
755
+ * never from this list.
756
+ * @param {string} output @param {any[]} terms normalised terms
757
+ * Every stop here is `crashed` in the arbiter's vocabulary — the command RAN,
758
+ * came back, and the number could not be read out of it. Nothing in this
759
+ * function can observe a spawn failure, a timeout or a signal, so it never
760
+ * claims one.
761
+ * @param {{scope?: any, workdir?: string}} [o]
762
+ * @returns {{stop: string, fault: string, notes?: string[]}|{stop: null, value: number, breakdown: any[], notes: string[], preScopeMatches: number, matched: string[][]}}
763
+ */
764
+ export function parseValue(output, terms, { scope = null, workdir = '' } = {}) {
765
+ let value = 0;
766
+ // liveness, tallied before the filter has an opinion — see the note above
767
+ let preScopeMatches = 0;
768
+ /** @type {any[]} */
769
+ const breakdown = [];
770
+ /** @type {string[]} */
771
+ const notes = [];
772
+ // the KEPT lines PER TERM, in output order and deduplicated within the term —
773
+ // see the note above
774
+ /** @type {string[][]} */
775
+ const matched = [];
776
+ const filtering = scopeFilters(scope);
777
+ // one resolver (and one realpath cache) per call, built only when a filter
778
+ // will actually read it
779
+ const phys = filtering ? physicalIn(workdir) : null;
780
+
781
+ for (const [i, t] of terms.entries()) {
782
+ // 1. locate the region
783
+ let region = output;
784
+ if (t.region !== 'whole-output') {
785
+ let anchor;
786
+ 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 }; }
787
+ const m = anchor.exec(output);
788
+ 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 };
789
+ const got = m[t.region.capture];
790
+ 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 };
791
+ region = got;
792
+ }
793
+
794
+ // 2. collect matches line by line, so the scope filter can read each
795
+ // line's own path
796
+ let re;
797
+ 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 }; }
798
+ /** @type {number[]} */
799
+ const values = [];
800
+ /** @type {string[]} */
801
+ const kept = [];
802
+ const seen = new Set();
803
+ let dropped = 0;
804
+ let unattributable = 0;
805
+ for (const line of region.split('\n')) {
806
+ const hits = [...line.matchAll(re)];
807
+ if (!hits.length) continue;
808
+ preScopeMatches += 1;
809
+ if (filtering) {
810
+ const p = linePath(line, workdir);
811
+ if (p === null) unattributable += 1;
812
+ else if (!inScope(p, scope, phys)) { dropped += 1; continue; }
813
+ }
814
+ // past the filter, so this line is a CANDIDATE for the harvest — kept here
815
+ // rather than at aggregation because this is the only place the raw line
816
+ // still exists, and narrowed to what the aggregate actually read below
817
+ if (!seen.has(line)) { seen.add(line); kept.push(line); }
818
+ for (const h of hits) {
819
+ if (t.capture === null) { values.push(1); continue; }
820
+ const rawCapture = h[t.capture];
821
+ 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 };
822
+ const n = Number.parseInt(rawCapture, 10);
823
+ if (!Number.isFinite(n)) return { stop: `INSTRUMENT: term ${i} captured "${rawCapture}", which is not an integer`, fault: STOP_FAULTS.CRASHED };
824
+ values.push(n);
825
+ }
826
+ }
827
+ if (filtering) {
828
+ if (dropped) notes.push(`term ${i}: ${dropped} matching line(s) dropped by the scope filter`);
829
+ if (unattributable) notes.push(`term ${i}: ${unattributable} matching line(s) named no path, so the scope filter could not judge them — they were KEPT`);
830
+ }
831
+
832
+ // 3. aggregate
833
+ let subtotal;
834
+ if (t.aggregate === 'sum') subtotal = values.reduce((a, b) => a + b, 0);
835
+ else {
836
+ if (!values.length) {
837
+ // TWO DIFFERENT FAULTS, and they used to read as one. "Matched nothing"
838
+ // sends the reader to the parser; lines that matched and were then
839
+ // EXCLUDED BY SCOPE send them to the scope, which is where the problem
840
+ // actually is. The counts are named mechanically (F38's genre split:
841
+ // a wall with a number converts, a description does not), and the notes
842
+ // this function already computed ride out with the stop instead of being
843
+ // dropped on the floor — the diagnostic exists either way.
844
+ // `dropped` alone, never dropped+unattributable: an unattributable line
845
+ // is KEPT, so it would already be in `values` — claiming it was excluded
846
+ // would be the message lying about which lane the line went down.
847
+ return {
848
+ stop: dropped > 0
849
+ ? `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`
850
+ : `INSTRUMENT: term ${i} (/${t.lineMatch}/, aggregate "first") matched nothing — the number was never reported, so it is unknown, not zero`,
851
+ fault: STOP_FAULTS.CRASHED,
852
+ notes,
853
+ };
854
+ }
855
+ subtotal = values[0];
856
+ }
857
+ // `first` read ONE line — `kept[0]`, since `values[0]` is the first hit on
858
+ // the first kept line. The rest matched (and `matches` below still says so)
859
+ // but are not what this subtotal is made of.
860
+ matched.push(t.aggregate === 'first' ? kept.slice(0, 1) : kept);
861
+ breakdown.push({ term: i, lineMatch: t.lineMatch, aggregate: t.aggregate, sign: t.sign, matches: values.length, subtotal, contribution: t.sign * subtotal });
862
+ value += t.sign * subtotal;
863
+ }
864
+ return { stop: null, value, breakdown, notes, preScopeMatches, matched };
865
+ }
866
+
867
+ // ── D12: the baseline is MEASURED at this run's own seed, every run ──────────
868
+
869
+ /**
870
+ * A caller-scoped holder for detached seed worktrees. Scoped rather than
871
+ * module-global on purpose: a global cache in a published library leaks
872
+ * worktrees into whatever process imported it, and nothing would ever remove
873
+ * them. `seedRead`/`runClose` create one, share it across stages (so a
874
+ * multi-stage close pays for one checkout), and remove it in a `finally`.
875
+ * @returns {SeedTrees}
876
+ */
877
+ export function makeSeedTrees() {
878
+ /** @type {Map<string, {workdir: string, root: string, dir: string}>} */
879
+ const made = new Map();
880
+ /** @type {Map<string, {stop: string, fault: string}|{stop: null, dir: string}>} */
881
+ const answers = new Map();
882
+ return {
883
+ async ensure(workdir, seedRef) {
884
+ const key = `${workdir}::${seedRef}`;
885
+ const cached = answers.get(key);
886
+ if (cached) return cached;
887
+ const verify = await git(workdir, ['rev-parse', '--verify', `${seedRef}^{commit}`]);
888
+ if (!verify.ok) {
889
+ const v = { stop: `INSTRUMENT: the seed commit "${seedRef}" does not exist in ${workdir}`, fault: STOP_FAULTS.FAILED };
890
+ answers.set(key, v);
891
+ return v;
892
+ }
893
+ const root = await mkdtemp(join(tmpdir(), 'bareloop-seed-'));
894
+ const dir = join(root, 'tree');
895
+ const add = await git(workdir, ['worktree', 'add', '--detach', dir, seedRef]);
896
+ if (!add.ok) {
897
+ await rm(root, { recursive: true, force: true });
898
+ const v = { stop: `INSTRUMENT: could not create a seed worktree at ${seedRef}: ${String(add.err).trim()}`, fault: add.fault };
899
+ answers.set(key, v);
900
+ return v;
901
+ }
902
+ // A checkout without its toolchain measures a BROKEN toolchain, which
903
+ // would read as an instrument stop on every single run rather than a
904
+ // baseline. Best effort: a missing link surfaces loudly as that stop.
905
+ for (const dep of ['node_modules', '.venv', 'venv']) {
906
+ try { await symlink(join(workdir, dep), join(dir, dep)); } catch { /* absent, or already there */ }
907
+ }
908
+ made.set(key, { workdir, root, dir });
909
+ const v = { stop: null, dir };
910
+ answers.set(key, v);
911
+ return v;
912
+ },
913
+ /**
914
+ * Removal — and it NEVER THROWS, which is the load-bearing half.
915
+ *
916
+ * EVERY call site runs this inside a `finally` (`runStage`, `seedRead`,
917
+ * `runDeclaredClose` below; `src/declaredclose.js` and `src/authorflow.js`
918
+ * outside). A rejection from a `finally` REPLACES whatever the try block
919
+ * was returning, so an unremovable directory — EACCES, EBUSY, a mount that
920
+ * went away — would discard an ALREADY-COMPUTED verdict and surface
921
+ * downstream as something that looks like a transport failure. A paid green
922
+ * would simply vanish. A cleanup hiccup never outranks a paid verdict.
923
+ *
924
+ * It is not swallowed either, which is the other half: the failure comes
925
+ * back as DATA (`ok:false` plus the leaked roots and the real error) that a
926
+ * caller who cares can read and attach a note to. Never `undefined`, never a
927
+ * silent success — and, because it is only a return value, never able to
928
+ * move a verdict. One unremovable tree also never strands the rest.
929
+ * @returns {Promise<{ok: boolean, leaked: string[], detail: string|null}>}
930
+ */
931
+ async cleanup() {
932
+ /** @type {string[]} */
933
+ const leaked = [];
934
+ /** @type {string[]} */
935
+ const details = [];
936
+ for (const { workdir, root, dir } of made.values()) {
937
+ try {
938
+ // git's own refusal is a LEAK the instrument must be able to see. `git`
939
+ // never throws — it hands failure back as `ok:false` — so a bare call
940
+ // here left `git worktree remove` failing invisibly: the directory went
941
+ // away under `rm` and a stale admin entry stayed behind in
942
+ // `.git/worktrees/`, while this reported `ok:true, leaked:[]`. That is
943
+ // the blind-instrument class on the one report that exists to name a
944
+ // leak, so the answer is READ rather than discarded.
945
+ const wr = await git(workdir, ['worktree', 'remove', '--force', dir]);
946
+ await rm(root, { recursive: true, force: true });
947
+ if (!wr.ok) {
948
+ leaked.push(root);
949
+ 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`);
950
+ }
951
+ } catch (e) {
952
+ leaked.push(root);
953
+ details.push(`${root}: ${String(/** @type {any} */ (e)?.message ?? e)}`);
954
+ }
955
+ }
956
+ made.clear();
957
+ answers.clear();
958
+ return { ok: leaked.length === 0, leaked, detail: details.length ? details.join('; ') : null };
959
+ },
960
+ };
961
+ }
962
+
963
+ /**
964
+ * Is the working tree byte-identical to the seed (the arbiter's own books
965
+ * aside)? When it is, the baseline can honestly be taken in place — and the
966
+ * record SAYS which route was taken.
967
+ * @param {string} workdir @param {string} seedRef
968
+ */
969
+ async function treeIsAtSeed(workdir, seedRef) {
970
+ const cs = await changedSet(workdir, seedRef);
971
+ if (cs.stop !== null) return { at: false, stop: cs.stop, fault: cs.fault };
972
+ return { at: cs.paths.length === 0, stop: null, fault: STOP_FAULTS.FAILED };
973
+ }
974
+
975
+ // ── results ─────────────────────────────────────────────────────────────────
976
+
977
+ /**
978
+ * @param {any} stage @param {Partial<StageResult>} over
979
+ * @returns {StageResult}
980
+ */
981
+ const result = (stage, over) => ({
982
+ verdict: 'green',
983
+ exitCode: EXIT_GREEN,
984
+ value: null,
985
+ baseline: null,
986
+ baselineSource: null,
987
+ gapLines: [],
988
+ judged: true,
989
+ stage: stage?.name ?? null,
990
+ kind: stage?.kind ?? null,
991
+ detail: {},
992
+ ...over,
993
+ });
994
+
995
+ /**
996
+ * Contract (a). The stop's own reason still rides the gap channel — an
997
+ * operator reading the close's output must see WHY nothing was judged — and
998
+ * what it never carries is the judged marker.
999
+ *
1000
+ * `fault` is the arbiter's four-row vocabulary (`STOP_FAULTS`), stamped by the
1001
+ * site that OBSERVED the fault. It defaults to `failed` — "the close cannot
1002
+ * run" is the safe reading — and it is carried on `detail` so a consumer routes
1003
+ * the stop by a typed field instead of pattern-matching the stop's prose.
1004
+ * `notes` are diagnostics a measurement had ALREADY COMPUTED when it stopped —
1005
+ * the scope filter's own arithmetic, chiefly. They ride out on the same gap
1006
+ * channel as the stop rather than being dropped on the floor: the stop says the
1007
+ * number could not be read, and the notes say which lane the lines went down,
1008
+ * which is usually the difference between hunting a parser bug and reading the
1009
+ * scope. Default empty, so every other stop site is unchanged.
1010
+ * @param {any} stage @param {Ctx} ctx @param {string} stop @param {Record<string, any>} [detail]
1011
+ * @param {string} [fault] @param {string[]} [notes]
1012
+ * @returns {StageResult}
1013
+ */
1014
+ const stopped = (stage, ctx, stop, detail = {}, fault = STOP_FAULTS.FAILED, notes = []) => {
1015
+ const gap = new Gap(ctx.gapKeep, ctx.gapCap).push(stop);
1016
+ for (const n of notes) gap.push(` ${n}`);
1017
+ return result(stage, {
1018
+ verdict: 'instrument-stop',
1019
+ exitCode: EXIT_STOP,
1020
+ judged: false,
1021
+ gapLines: gap.render(),
1022
+ detail: { ...detail, stop, fault },
1023
+ });
1024
+ };
1025
+
1026
+ // ── the four kinds ──────────────────────────────────────────────────────────
1027
+
1028
+ /**
1029
+ * The bound a stage ACTUALLY runs under: the tighter of what the declaration
1030
+ * asked for and what the operator allows.
1031
+ *
1032
+ * `timeoutMs` is AUTHORED — it rides in the declaration, on the emergent side
1033
+ * of the line. `ctx.timeoutMsDefault` is the operator's ceiling and is not.
1034
+ * This is the budget rule applied to time: the advertised bound and the
1035
+ * enforced bound must be the same number, and the authored side may only ever
1036
+ * TIGHTEN it (a declaration that could widen its own ceiling is a second,
1037
+ * silent ceiling above the signed one). A declared value that is unusable —
1038
+ * absent, non-finite, zero or negative — falls back to the ceiling rather than
1039
+ * to `sh`'s "no timer at all", which is the same direction: never wider.
1040
+ * @param {any} declared @param {Ctx} ctx
1041
+ * @returns {number}
1042
+ */
1043
+ function effectiveTimeoutMs(declared, ctx) {
1044
+ const ceiling = ctx.timeoutMsDefault ?? DEFAULT_TIMEOUT_MS;
1045
+ return typeof declared === 'number' && Number.isFinite(declared) && declared > 0
1046
+ ? Math.min(declared, ceiling)
1047
+ : ceiling;
1048
+ }
1049
+
1050
+ /** @param {any} stage @param {Ctx} ctx @returns {Promise<StageResult>} */
1051
+ async function runCommandExit(stage, ctx) {
1052
+ const p = stage.params;
1053
+ const r = await sh(p.cmd, p.args ?? [], {
1054
+ cwd: ctx.workdir,
1055
+ env: p.env ?? {},
1056
+ timeoutMs: effectiveTimeoutMs(p.timeoutMs, ctx),
1057
+ maxBuffer: ctx.maxBuffer ?? MAX_BUFFER,
1058
+ });
1059
+ if (r.stop !== null) return stopped(stage, ctx, r.stop, {}, r.fault);
1060
+ const gap = new Gap(ctx.gapKeep, ctx.gapCap);
1061
+ for (const name of r.dropped) gap.push(`${stage.name}: declared env var ${name} was DROPPED — the close never hands worker code a credential`);
1062
+ const green = r.code === p.expectExit;
1063
+ if (!green) {
1064
+ gap.push(`${stage.name}: "${p.cmd}${(p.args ?? []).length ? ` ${(p.args ?? []).join(' ')}` : ''}" exited ${r.code}, expected ${p.expectExit}`);
1065
+ for (const l of r.out.split('\n').filter((x) => x.trim() !== '')) gap.push(` ${l}`);
1066
+ }
1067
+ return result(stage, {
1068
+ verdict: green ? 'green' : 'red',
1069
+ exitCode: green ? EXIT_GREEN : EXIT_RED,
1070
+ gapLines: gap.render(),
1071
+ detail: { exit: r.code, expected: p.expectExit, envDropped: r.dropped },
1072
+ });
1073
+ }
1074
+
1075
+ /**
1076
+ * The counting kind. `measure` below carries the broken-ruler rule and its one
1077
+ * ACCEPTED LIMIT: a tool whose convention is "non-zero exit MEANS zero found"
1078
+ * (`grep -c`, pytest's exit 5) stops here rather than counting zero. That is
1079
+ * deliberate and fail-safe — unreachable on the shipped TYPES genre, a loud
1080
+ * false stop at worst, and never a false green. A genre that needs the other
1081
+ * behaviour declares it; this default is not softened. See `measure`.
1082
+ * @param {any} stage @param {Ctx} ctx @returns {Promise<StageResult>} */
1083
+ async function runCountNotWorse(stage, ctx) {
1084
+ const p = stage.params;
1085
+ const norm = normalizeParser(p.parser, `${stage.name}.parser`);
1086
+ if (!norm.ok) {
1087
+ return stopped(stage, ctx, `INSTRUMENT: ${stage.name}'s parser does not normalise: ${norm.reds.map((r) => `${r.path} — ${r.detail}`).join('; ')}`,
1088
+ {}, STOP_FAULTS.CRASHED);
1089
+ }
1090
+ const terms = /** @type {any[]} */ (norm.terms);
1091
+
1092
+ /** @param {string} cwd @param {string} where */
1093
+ const measure = async (cwd, where) => {
1094
+ const r = await sh(p.cmd, p.args ?? [], {
1095
+ cwd,
1096
+ env: p.env ?? {},
1097
+ timeoutMs: effectiveTimeoutMs(p.timeoutMs, ctx),
1098
+ maxBuffer: ctx.maxBuffer ?? MAX_BUFFER,
1099
+ });
1100
+ if (r.stop !== null) return { stop: `${r.stop} (measuring ${where})`, fault: r.fault };
1101
+ const v = parseValue(r.out, terms, { scope: p.scope, workdir: cwd });
1102
+ if (v.stop !== null) return { stop: `${v.stop} (measuring ${where})`, fault: v.fault, notes: v.notes ?? [] };
1103
+ // A BROKEN RULER NEVER CERTIFIES ZERO — the zero-match split (contract (a))
1104
+ // finished. `sum` over nothing is a COUNTED zero because the region was
1105
+ // searched, and that reading is only believable when the tool itself came
1106
+ // back clean: a checker that died before it ever looked at the tree also
1107
+ // matches nothing, and 0 against a baseline of 0 reads GREEN. `first`
1108
+ // already stops on zero matches; `sum` did not, and the exit code the
1109
+ // measurement was already carrying was never consulted.
1110
+ //
1111
+ // So: zero matched AND a non-zero exit is could-not-run — the existing stop
1112
+ // lane, in BOTH directions. A crash that would have flattered the count and
1113
+ // one that would have faked a red are the same casualty, and a casualty is
1114
+ // never evidence either way (F45). Non-zero exit WITH matches stays a normal
1115
+ // reading: that is a checker reporting real findings, which is its job.
1116
+ //
1117
+ // "Matched" here is `preScopeMatches`, and the distinction is the whole
1118
+ // guard. A POST-scope count cannot tell a dead tool from a live one whose
1119
+ // every finding belonged to another population — they are TWO DIFFERENT
1120
+ // FAULTS and they read as one, the same class the `first` aggregate's own
1121
+ // stop already split above. Run mslsnnzk paid for it twice: tsc exited 2
1122
+ // and printed 67 real error lines, all under `src/`, against a stage scoped
1123
+ // `excludePrefixes:["src/"]`; post-scope the count was 0 and a good close
1124
+ // was refused as a crashed instrument. So liveness is read where liveness
1125
+ // lives — BEFORE the filter — and a live tool over an empty population is a
1126
+ // counted zero carrying the filter's own dropped-line note, which is what
1127
+ // the paragraph above always claimed and the code did not do. The fail-safe
1128
+ // is untouched in the direction that matters: a genuinely silent non-zero
1129
+ // exit still matched nothing pre-scope, and still stops.
1130
+ //
1131
+ // ACCEPTED LIMIT, stated rather than fixed. Some tools spell "I found
1132
+ // nothing" as a NON-ZERO EXIT — `grep -c` exits 1 on no match, pytest exits
1133
+ // 5 when it collects no tests. Declared as a count stage, one of those would
1134
+ // stop here instead of recording a true zero. No such misfire is
1135
+ // CONSTRUCTIBLE on the shipped TYPES genre (mypy and tsc exit 0 on a clean
1136
+ // tree and print the "found 0 errors" line the parser matches), so today the
1137
+ // rule can only produce a false STOP, never a false green — and that is
1138
+ // exactly the direction it is chosen for. A stop is loud, honest and
1139
+ // re-runnable; counting a crashed tool's silence as zero grades a red tree
1140
+ // GREEN. The F49 precedent governs: the fail-safe direction is never traded
1141
+ // away to chase the sharper one, and this default is not softened.
1142
+ //
1143
+ // A genre that genuinely needs exit-N-means-zero DECLARES it — a catalogue
1144
+ // parameter added at that rework, so the exception rides the signed spec and
1145
+ // is visible per stage. Never by loosening the default here, which would
1146
+ // silently re-open the fake-green lane under every close already signed.
1147
+ if (r.code !== 0 && v.preScopeMatches === 0) {
1148
+ return {
1149
+ 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})`,
1150
+ fault: STOP_FAULTS.CRASHED,
1151
+ // empty BY CONSTRUCTION, not by oversight: every scope note requires a
1152
+ // matching line, and this branch requires that there were none
1153
+ notes: [],
1154
+ };
1155
+ }
1156
+ return { stop: null, value: v.value, breakdown: v.breakdown, notes: v.notes, matched: v.matched, exit: r.code, dropped: r.dropped, fault: STOP_FAULTS.FAILED };
1157
+ };
1158
+
1159
+ const now = await measure(ctx.workdir, 'the current tree');
1160
+ if (now.stop !== null) return stopped(stage, ctx, now.stop, {}, now.fault, now.notes);
1161
+
1162
+ // D12 — the close stores the COUNTING RULE, never the number, so "seed" is
1163
+ // MEASURED at this run's own seed. Which route measured it is recorded.
1164
+ let baseline = 0;
1165
+ let baselineSource = 'declared 0';
1166
+ /** @type {any} */
1167
+ let baselineDetail = null;
1168
+ if (p.baseline === 'seed') {
1169
+ const at = await treeIsAtSeed(ctx.workdir, ctx.seedRef);
1170
+ if (at.stop !== null) return stopped(stage, ctx, at.stop, {}, at.fault);
1171
+ if ((ctx.baselineMode ?? 'auto') === 'auto' && at.at) {
1172
+ baseline = /** @type {number} */ (now.value);
1173
+ baselineSource = 'measured IN PLACE — the working tree is verifiably identical to the seed';
1174
+ baselineDetail = { breakdown: now.breakdown };
1175
+ } else {
1176
+ const seedTrees = /** @type {SeedTrees} */ (ctx.seedTrees);
1177
+ const st = await seedTrees.ensure(ctx.workdir, ctx.seedRef);
1178
+ if (st.stop !== null) return stopped(stage, ctx, st.stop, {}, st.fault);
1179
+ const b = await measure(st.dir, `the seed tree at ${ctx.seedRef}`);
1180
+ if (b.stop !== null) return stopped(stage, ctx, b.stop, {}, b.fault, b.notes);
1181
+ baseline = /** @type {number} */ (b.value);
1182
+ baselineSource = `measured in a detached worktree at ${ctx.seedRef}`;
1183
+ baselineDetail = { breakdown: b.breakdown, notes: b.notes };
1184
+ }
1185
+ }
1186
+
1187
+ const value = /** @type {number} */ (now.value);
1188
+ const worse = p.direction === 'lower-is-better' ? value > baseline : value < baseline;
1189
+ const gap = new Gap(ctx.gapKeep, ctx.gapCap);
1190
+ 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`);
1191
+ // the scope filter's own arithmetic is announced whichever way the stage
1192
+ // lands: a green measured over a filtered population is still a filtered
1193
+ // number, and the operator reads why
1194
+ for (const n of /** @type {string[]} */ (now.notes)) gap.push(`${stage.name}: ${n}`);
1195
+ if (worse) {
1196
+ gap.push(`${stage.name}: ${value} against a baseline of ${baseline} (${p.direction}) — worse`);
1197
+ gap.push(` baseline ${baselineSource}`);
1198
+ // …each term stating itself, and THEN the lines that term's subtotal is made
1199
+ // of, nested under it. A count with no addresses is the semantic genre F38
1200
+ // measured as inert: run u-msn227nq's worker was handed "8 match(es)", had
1201
+ // nowhere to open, and probed the arbiter's books until the deny guard ended
1202
+ // the run. The instrument NAMED these lines — echoing what it named is the
1203
+ // same licence `pattern-absent-in-diff` already runs under, not the barred
1204
+ // move of naming a culprit the instrument never reported.
1205
+ //
1206
+ // NESTED, not pooled, because a line's arithmetic role is the term's and only
1207
+ // the term's row carries it. `value` is a SIGNED sum: a term declared
1208
+ // `sign: -1` subtracts, so its lines are ones the count went DOWN for, and a
1209
+ // worker handed them flat beside the added ones reads every line as a wall
1210
+ // and "fixes" one that moves the number the wrong way. The row directly above
1211
+ // each block already says `contributes +n` / `-n`; sitting the lines under it
1212
+ // is what makes that statement reach them. Nothing is withheld — under
1213
+ // `higher-is-better` the subtracted term is exactly where the news usually is
1214
+ // — and nothing is relabelled: the lines are still verbatim, one indent in.
1215
+ //
1216
+ // They ride the Gap like every other line: the stage prefix (Layer R's
1217
+ // redKeep derives from it), the cap, and the trim marker on overflow.
1218
+ const perTerm = /** @type {string[][]} */ (now.matched ?? []);
1219
+ for (const [i, b] of /** @type {any[]} */ (now.breakdown).entries()) {
1220
+ gap.push(` term ${b.term} /${b.lineMatch}/ ${b.aggregate}: ${b.matches} match(es), subtotal ${b.subtotal}, contributes ${b.contribution >= 0 ? '+' : ''}${b.contribution}`);
1221
+ for (const l of perTerm[i] ?? []) gap.push(` ${l}`);
1222
+ }
1223
+ }
1224
+ return result(stage, {
1225
+ verdict: worse ? 'red' : 'green',
1226
+ exitCode: worse ? EXIT_RED : EXIT_GREEN,
1227
+ value,
1228
+ baseline,
1229
+ baselineSource,
1230
+ gapLines: gap.render(),
1231
+ detail: {
1232
+ direction: p.direction,
1233
+ breakdown: now.breakdown,
1234
+ baselineBreakdown: baselineDetail,
1235
+ notes: now.notes,
1236
+ commandExit: now.exit,
1237
+ envDropped: now.dropped ?? [],
1238
+ },
1239
+ });
1240
+ }
1241
+
1242
+ /** @param {any} stage @param {Ctx} ctx @returns {Promise<StageResult>} */
1243
+ async function runPatternAbsentInDiff(stage, ctx) {
1244
+ const p = stage.params;
1245
+ const cs = await changedSet(ctx.workdir, ctx.seedRef);
1246
+ if (cs.stop !== null) return stopped(stage, ctx, cs.stop, {}, cs.fault);
1247
+
1248
+ const untracked = new Set(cs.untracked);
1249
+ const exts = p.extensions.map((/** @type {string} */ e) => (String(e).startsWith('.') ? String(e) : `.${e}`));
1250
+ // git spells the tree it walked; the declared scope spells the job's own
1251
+ // target, and on a symlinked package those are two names for one file. The
1252
+ // lexical-only reading dropped the real file out of the scanned set and read
1253
+ // GREEN over a suppression it never opened — a guard's fake-green direction.
1254
+ const phys = scopeFilters(p.scope) ? physicalIn(ctx.workdir) : null;
1255
+ const scanned = cs.paths.filter((rel) => exts.some((e) => rel.endsWith(e)) && inScope(rel, p.scope, phys));
1256
+
1257
+ /** @type {RegExp[]} */
1258
+ const res = [];
1259
+ for (const q of p.patterns) {
1260
+ 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); }
1261
+ }
1262
+
1263
+ const gap = new Gap(ctx.gapKeep, ctx.gapCap);
1264
+ /** @type {any[]} */
1265
+ const hits = [];
1266
+ /** @type {string[]} */
1267
+ const notes = [];
1268
+ for (const rel of scanned) {
1269
+ const a = await addedLines(ctx.workdir, ctx.seedRef, rel, { untracked: untracked.has(rel) });
1270
+ if (a.stop !== null) return stopped(stage, ctx, a.stop, {}, a.fault);
1271
+ if (a.note) notes.push(a.note);
1272
+ for (const { n, text } of a.lines) {
1273
+ p.patterns.forEach((/** @type {any} */ q, /** @type {number} */ i) => {
1274
+ if (res[i].test(text)) hits.push({ id: q.id, path: rel, line: n, text: text.trim().slice(0, 200) });
1275
+ });
1276
+ }
1277
+ }
1278
+ for (const n of notes) gap.push(`${stage.name}: ${n}`);
1279
+ if (hits.length) {
1280
+ gap.push(`${stage.name}: ${hits.length} forbidden pattern occurrence(s) in lines this run ADDED`);
1281
+ // the file is named because the SCAN itself reported it — contract (b)'s
1282
+ // limit bars naming a culprit the instrument did not name, not echoing
1283
+ // one it did
1284
+ for (const h of hits) gap.push(` [${h.id}] ${h.path}:${h.line}: ${h.text}`);
1285
+ }
1286
+ return result(stage, {
1287
+ verdict: hits.length ? 'red' : 'green',
1288
+ exitCode: hits.length ? EXIT_RED : EXIT_GREEN,
1289
+ gapLines: gap.render(),
1290
+ detail: { scannedFiles: scanned, hits, notes, changedTotal: cs.paths.length, excluded: cs.excluded },
1291
+ });
1292
+ }
1293
+
1294
+ /** @param {any} stage @param {Ctx} ctx @returns {Promise<StageResult>} */
1295
+ async function runFilesChanged(stage, ctx) {
1296
+ const p = stage.params;
1297
+ const cs = await changedSet(ctx.workdir, ctx.seedRef);
1298
+ if (cs.stop !== null) return stopped(stage, ctx, cs.stop, {}, cs.fault);
1299
+ // the containment fence resolves too. Only ONE of its two sides is git's
1300
+ // spelling — `allowPrefixes` is the job's own target, and on the aurora shape
1301
+ // (`src/pkg -> ../packages/x/src/pkg`) the declaration says `src/pkg/` while
1302
+ // git says `packages/x/src/pkg/...`: a real in-target edit read as a stray.
1303
+ // Physical only on a lexical miss, both sides resolved, so a spelling that
1304
+ // lands outside every prefix is still outside. ACCEPTED, stated rather than
1305
+ // fixed: a link that LIVES outside a prefix and POINTS inside it now counts
1306
+ // as inside — the same reading the scope filter has shipped since de61e87,
1307
+ // and the mild direction; the escaping one stays shut.
1308
+ const phys = physicalIn(ctx.workdir);
1309
+ const outside = cs.paths.filter((rel) => !p.allowPrefixes.some((/** @type {string} */ pre) => underPrefixEither(rel, pre, phys)));
1310
+ const empty = p.requireNonEmpty === true && cs.paths.length === 0;
1311
+ const gap = new Gap(ctx.gapKeep, ctx.gapCap);
1312
+ if (empty) gap.push(`${stage.name}: nothing changed against the seed — the changed set is empty`);
1313
+ if (outside.length) {
1314
+ gap.push(`${stage.name}: ${outside.length} changed file(s) lie outside ${p.allowPrefixes.join(', ')}`);
1315
+ for (const o of outside) gap.push(` ${o}`);
1316
+ }
1317
+ const red = empty || outside.length > 0;
1318
+ return result(stage, {
1319
+ verdict: red ? 'red' : 'green',
1320
+ exitCode: red ? EXIT_RED : EXIT_GREEN,
1321
+ gapLines: gap.render(),
1322
+ detail: { changed: cs.paths, outside, excluded: cs.excluded, empty },
1323
+ });
1324
+ }
1325
+
1326
+ /** @type {Record<string, (stage: any, ctx: Ctx) => Promise<StageResult>>} */
1327
+ const RUNNERS = {
1328
+ 'command-exit': runCommandExit,
1329
+ 'count-not-worse': runCountNotWorse,
1330
+ 'pattern-absent-in-diff': runPatternAbsentInDiff,
1331
+ 'files-changed': runFilesChanged,
1332
+ };
1333
+
1334
+ /**
1335
+ * Run ONE declared stage against a workdir at a seed.
1336
+ *
1337
+ * It never throws. An unexpected throw inside a kind — a malformed parameter
1338
+ * the validator did not catch, an unreachable path — is an INSTRUMENT STOP,
1339
+ * not a red: that is the casualty-vs-evidence split at its narrowest, and
1340
+ * the direction that matters, because a crash rendered as a red is fake
1341
+ * evidence about the worker.
1342
+ * @param {any} stage `{name, kind, params}`
1343
+ * @param {Ctx} ctx
1344
+ * @returns {Promise<StageResult>}
1345
+ */
1346
+ export async function runStage(stage, ctx) {
1347
+ const owned = !ctx.seedTrees;
1348
+ const seedTrees = ctx.seedTrees ?? makeSeedTrees();
1349
+ try {
1350
+ return await runStageIn(stage, { ...ctx, seedTrees });
1351
+ } finally {
1352
+ if (owned) await seedTrees.cleanup();
1353
+ }
1354
+ }
1355
+
1356
+ /** @param {any} stage @param {Ctx} ctx @returns {Promise<StageResult>} */
1357
+ async function runStageIn(stage, ctx) {
1358
+ 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);
1359
+ const fn = RUNNERS[stage.kind];
1360
+ if (!fn) {
1361
+ 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(', ')}`,
1362
+ { kind: stage.kind }, STOP_FAULTS.CRASHED);
1363
+ }
1364
+ if (stage.params === null || typeof stage.params !== 'object') return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" declares no params object`, {}, STOP_FAULTS.CRASHED);
1365
+ try {
1366
+ return await fn(stage, ctx);
1367
+ } catch (e) {
1368
+ return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" threw: ${String(/** @type {any} */ (e)?.stack ?? e)}`, {}, STOP_FAULTS.CRASHED);
1369
+ }
1370
+ }
1371
+
1372
+ /**
1373
+ * D12's seed-verdict read: run EVERY declared stage at the seed, regardless
1374
+ * of reds, and hand back one row per stage.
1375
+ *
1376
+ * Not first-red-wins, and the reason is structural rather than a preference:
1377
+ * every shipped close opens with `changed-from-seed`, which is RED at its own
1378
+ * seed by construction (nothing has changed yet), so a first-red-wins seed
1379
+ * read measures exactly one stage and mints no baseline for any other. The
1380
+ * baseline every `baseline: "seed"` stage is later graded against comes from
1381
+ * here, so a stage that never ran is a stage with no bar.
1382
+ * @param {any} declaration `{stages: [...]}`
1383
+ * @param {Ctx} ctx
1384
+ * @returns {Promise<StageResult[]>}
1385
+ */
1386
+ export async function seedRead(declaration, ctx) {
1387
+ const owned = !ctx.seedTrees;
1388
+ const seedTrees = ctx.seedTrees ?? makeSeedTrees();
1389
+ const inner = { ...ctx, seedTrees };
1390
+ try {
1391
+ /** @type {StageResult[]} */
1392
+ const rows = [];
1393
+ for (const stage of declaration?.stages ?? []) rows.push(await runStageIn(stage, inner));
1394
+ return rows;
1395
+ } finally {
1396
+ if (owned) await seedTrees.cleanup();
1397
+ }
1398
+ }
1399
+
1400
+ /**
1401
+ * The GRADING path: stages in declared order, FIRST RED WINS. The deciding
1402
+ * stage names the verdict; everything after it is `not-reached` and is
1403
+ * reported as such rather than as a green nobody ran.
1404
+ *
1405
+ * An instrument stop ends the close UNJUDGED — the run has no verdict to
1406
+ * carry, which is the whole point of keeping a casualty out of the evidence.
1407
+ *
1408
+ * NAMED `runDeclaredClose`, not `runClose` (M4's export-surface decision): the
1409
+ * arbiter's shell already ships a public `runClose(argv, redact, opts)` that
1410
+ * adopters import, and the two take different things and mean different things
1411
+ * — one runs a DECLARATION, the other an argv. Renaming the shipped one to make
1412
+ * room would break a documented adopter contract for nothing; the new name says
1413
+ * exactly what differs. `src/declaredclose.js` is what bridges the two shapes.
1414
+ * @param {any} declaration @param {Ctx} ctx
1415
+ * @returns {Promise<{verdict: 'green'|'red'|'instrument-stop', exitCode: number, judged: boolean, firstRed: string|null, stages: StageResult[]}>}
1416
+ */
1417
+ export async function runDeclaredClose(declaration, ctx) {
1418
+ const owned = !ctx.seedTrees;
1419
+ const seedTrees = ctx.seedTrees ?? makeSeedTrees();
1420
+ const inner = { ...ctx, seedTrees };
1421
+ try {
1422
+ /** @type {StageResult[]} */
1423
+ const stages = [];
1424
+ let ended = false;
1425
+ for (const stage of declaration?.stages ?? []) {
1426
+ if (ended) {
1427
+ stages.push(result(stage, { verdict: 'not-reached', exitCode: null, judged: false }));
1428
+ continue;
1429
+ }
1430
+ const r = await runStageIn(stage, inner);
1431
+ stages.push(r);
1432
+ if (r.verdict !== 'green') ended = true;
1433
+ }
1434
+ const deciding = stages.find((s) => s.verdict === 'red' || s.verdict === 'instrument-stop');
1435
+ return {
1436
+ verdict: /** @type {'green'|'red'|'instrument-stop'} */ (deciding ? deciding.verdict : 'green'),
1437
+ exitCode: deciding ? /** @type {number} */ (deciding.exitCode) : EXIT_GREEN,
1438
+ judged: deciding ? deciding.judged : true,
1439
+ firstRed: deciding ? deciding.stage : null,
1440
+ stages,
1441
+ };
1442
+ } finally {
1443
+ if (owned) await seedTrees.cleanup();
1444
+ }
1445
+ }