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/CHANGELOG.md +421 -0
- package/README.md +10 -3
- package/bareloop.context.md +198 -17
- package/package.json +1 -1
- package/src/authorflow.js +1280 -0
- package/src/authoring.js +1296 -0
- package/src/authorjob.js +793 -0
- package/src/authorscout.js +706 -0
- package/src/bridges.js +2 -2
- package/src/declaredclose.js +519 -0
- package/src/index.js +72 -0
- package/src/job.js +48 -4
- package/src/kinds.js +1354 -0
- package/src/ledger.js +13 -0
- package/src/plan.js +26 -1
- package/src/planrun.js +239 -16
- package/src/ralph.js +19 -4
- package/src/reuse.js +68 -7
- package/src/root.js +8 -1
- package/src/run.js +10 -3
- package/src/text.js +120 -0
- package/src/workbranch.js +261 -0
- package/types/authorflow.d.ts +720 -0
- package/types/authoring.d.ts +447 -0
- package/types/authorjob.d.ts +241 -0
- package/types/authorscout.d.ts +307 -0
- package/types/declaredclose.d.ts +174 -0
- package/types/index.d.ts +9 -1
- package/types/job.d.ts +1 -0
- package/types/kinds.d.ts +343 -0
- package/types/plan.d.ts +18 -0
- package/types/planrun.d.ts +10 -3
- package/types/ralph.d.ts +1 -0
- package/types/run.d.ts +10 -2
- package/types/text.d.ts +84 -0
- package/types/workbranch.d.ts +102 -0
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
|
+
}
|