Add reviewed-binary push check and scratch regression suite (#1825)

This commit is contained in:
jason.woltje committed 2026-10-24 01:40:50 -05:00
1 parent 6a5e125113
commit 72a3125a6e
8 files changed
+453

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
# Row 327 — reviewed binary push check (#1825)
2026-10-24 Bradford, branch bradford/row327, base b617b80f6.
## Plan and boundaries
Implement the scratch check's ordered tokens in scripts/push-check.sh, using an
embedded Node program for byte-safe git paths and SHA-256 blob hashing. Bash is
the public entry point; Node is already a repository requirement. Git commands
use explicit resolved object IDs, disable external diff/textconv, and inspect
committed bytes. No queue writes, hook changes, binary evidence, or live state.
Fetch is opt-in. Read reviewed rows with the queue package rows() API rooted in
the caller's repository; this preserves canonical/witness/replay checks.
Only the latest round counts; any changes receipt blocks that round. A matching
approval authorizes exactly the manifest path/hash, or a path changed from the
commit candidate's first parent with matching committed blob hash and mode.
Deleted binaries need no candidate. Multiple --row values form a union.
Add scratch-only integration coverage in scripts/test-push-check.sh. Reuse queue
test fixture helpers to bootstrap real scratch queues, then use queue verbs to
open rounds and record receipts. All git writes, bare remotes and queues remain
under TMPDIR. Test fail-closed ordering and five required mutants, generated as
text copies in scratch. No old tests are modified. Add the TOOLS entry.
Every run needs Sage CLEAR; run timeout -k 10. Record plain-text results here or
in this packet. After green checks, commit and push only bradford/row327 through
the repository helper, send Sage and Poe tip/base/path sha256 manifest, and move
327 in-review. Sage owns request posting and landing; Dewey reviews once there.
## Recovery and choices
Initial worktree queue next refused linked-worktree access as designed. Queue
claim is performed from the canonical checkout; production reads in the guard
retain the same refusal rather than invent a worktree override.
Requested Sage ruling on AGENTS log appends outside the brief's owned paths.
Sage sage-3989: lead handles BUILD-LOG and SESSIONS at landing. Session entry:
2026-10-24 Bradford — row 327 / #1825 — implementation and scratch verification
in this packet; outcome to be appended after acceptance.
Implementation note: commit candidates use an explicit first-parent diff (empty
tree for a root commit), with rename detection disabled, like verify-commit.
The changes-veto case has both an approval and a changes receipt; a veto mutant
would otherwise still fail for lack of approval and provide false confidence.
A receipt for another candidate cannot be produced through queue verbs: the
suite corrupts only a scratch queue and expects the production reader to refuse.
Diff color, external diff and textconv are disabled so scans inspect plain git
output. Paths are read from NUL-delimited records (including spaces/tabs).
## Verification (Sage CLEAR sage-3991)
First new-suite run red: my whitespace-path fixture attempted a manifest path
that the queue intentionally forbids. Corrected the fixture to a commit
candidate, which can contain that git path. Production queue code and old tests
remain unchanged. Raw first-run output is test-run-1.txt.
Second run green: PASS push-check 22 checks; KILLED skip-hash,
accept-changes, earlier-round, working-tree, no-row. test-run-2.txt holds the
plain-text receipt. bash -n both scripts and git diff --check passed. Runs used
TMPDIR recorded in tmpdir.txt and timeout -k 10 240; all generated binary bytes
were in disposable scratch repositories, never in this checkout or evidence.
The slot was used for only the authorized new suite and syntax/diff checks.
No claim of the lead's full scripts/test-*.sh landing gate is made here.
Final inspection after the first green run found a path-decoding concern: lossy
UTF-8 decoding can conflate distinct git filenames. Hardened NUL-delimited path
decoding to reject invalid UTF-8, and quote newline-containing paths in refusal
output to preserve one STOP line. Added scratch cases for both. Requested a new
CLEAR (bradford-0005) because the previous slot had already been released.
Final run (sage-3994 CLEAR) green on final code: PASS push-check 24 checks,
all five mutants KILLED again, bash -n both scripts and git diff --check passed.
Receipt: test-run-3.txt. The guard rejects non-UTF-8 git paths as environment
errors rather than comparing replacement characters; newline paths remain
checkable through commit candidates and refusals quote them on one line.
Acceptance for the builder stage: ordered tokens and stop behavior, explicit
base/fetch, exact blob manifest/commit checks, latest-round/veto checks, deletion,
log/FF/secrets/attribution refusals, and all required mutants are covered. Queue
reads use rows() with production validation; tests use only scratch queues.
Candidate-mismatched receipts are impossible through the queue API and are
covered by a corrupt scratch queue refusing its read. The lead's full-suite gate,
Poe first review, Dewey landing review, and adoption for Sage's next push remain
landing stages. No hooks, role policy, credentials or other seats' paths changed.
@@ -0,0 +1,21 @@
node:internal/modules/run_main:107
triggerUncaughtException(
^
AssertionError [ERR_ASSERTION]: queue: candidate manifest path must be a repo-relative path: "space tab\tasset.bin"
2 !== 1
at Object.round (file:///home/jwoltje/.t3/worktrees/mosaic-stack/bradford-row327/[eval1]:61:12)
at file:///home/jwoltje/.t3/worktrees/mosaic-stack/bradford-row327/[eval1]:116:7
at process.processTicksAndRejections (node:internal/process/task_queues:104:5) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 2,
expected: 1,
operator: 'strictEqual',
diff: 'simple'
}
Node.js v26.8.1
@@ -0,0 +1,6 @@
PASS push-check 22 checks
KILLED skip-hash
KILLED accept-changes
KILLED earlier-round
KILLED working-tree
KILLED no-row
@@ -0,0 +1,6 @@
PASS push-check 24 checks
KILLED skip-hash
KILLED accept-changes
KILLED earlier-round
KILLED working-tree
KILLED no-row
@@ -0,0 +1 @@
/mnt/storage/scratch/tmp/YPrPwR
+20
View File
@@ -1365,3 +1365,23 @@ public sign-in smoke and a forged-loopback 401 check; rollback drops flags
before middleware. Same-uid callers can still read the file; separate service
uid is Jason's question on #385. The Authentik allowed-uid file remains an
identity input and is never reused for proof.
## `scripts/push-check.sh` — verify a prospective push
`scripts/push-check.sh SHA EXPECTED_LOG [--row N]... [--base-ref REF] [--fetch]`
checks the repository at the caller's cwd; it never pushes or writes the queue.
EXPECTED_LOG must contain exactly `git log --oneline BASE..SHA` (including its
newlines). BASE defaults to `refs/remotes/origin/refactor`; `--base-ref` overrides
it. Only `--fetch` fetches origin, before resolving BASE.
Success prints `LOG_OK`, `FF_OK`, `NO_BINARY` or `BINARY_REVIEWED n`,
`SECRETS_CLEAN`, `NO_ATTRIBUTION`, then `PUSH_READY BASE..SHA` as the last line.
Each present binary needs its committed blob sha256 and exact path in an approved
latest-round candidate from a supplied row. A `changes` receipt blocks that round.
Commit candidates authorize only changed paths with matching bytes and mode.
Deleted binaries need no review. Queue reads retain canonical-checkout checks.
Exit 0 means ready; 1 means refusal (`STOP reason`, always the last line); 2 means
usage or environment error. A refusal never prints later success tokens.
`timeout -k 10 240 bash scripts/test-push-check.sh` exercises isolated scratch
repositories and the five required mutants; set TMPDIR to a scratch filesystem.
+110
View File
@@ -0,0 +1,110 @@
#!/bin/bash
# Check an explicit committed push range. Fetch is opt-in; never pushes.
command -v node >/dev/null 2>&1 || { echo 'STOP node unavailable'; exit 2; }
exec node --input-type=module - "$@" <<'JS'
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
const stop = (reason, code = 1) => { console.log(`STOP ${reason}`); process.exit(code); };
const git = (...args) => {
const r = spawnSync('git', args, { maxBuffer: 256 * 1024 * 1024 });
if (r.error || r.status !== 0) throw new Error('git');
return r.stdout;
};
// Git paths must round-trip as UTF-8 before comparing them with queue paths.
const pathsText = bytes => new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
const shownPath = path => /[\r\n]/.test(path) ? JSON.stringify(path) : path;
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
const text = (...args) => git(...args).toString('utf8').trimEnd();
const resolve = ref => text('rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`);
const argv = process.argv.slice(2);
if (argv.length < 2) stop('usage: push-check.sh SHA EXPECTED_LOG [--row N]... [--base-ref REF] [--fetch]', 2);
const [ref, expectedFile] = argv.splice(0, 2);
let baseRef = 'refs/remotes/origin/refactor', fetch = false;
const rowIds = new Set();
while (argv.length) {
const flag = argv.shift();
if (flag === '--fetch' && !fetch) fetch = true;
else if (flag === '--row' && /^[1-9][0-9]*$/.test(argv[0] ?? '') && Number.isSafeInteger(Number(argv[0]))) rowIds.add(Number(argv.shift()));
else if (flag === '--base-ref' && argv[0] && !argv[0].startsWith('-')) baseRef = argv.shift();
else stop('usage', 2);
}
let sha, base, expected;
try { sha = resolve(ref); expected = readFileSync(expectedFile); }
catch { stop('sha or expected log unavailable', 2); }
if (fetch) { try { git('fetch', '-q', 'origin'); } catch { stop('fetch', 2); } }
try { base = resolve(baseRef); } catch { stop('base ref unavailable', 2); }
try {
if (!git('log', '--oneline', `${base}..${sha}`).equals(expected)) stop('log');
console.log('LOG_OK');
try { git('merge-base', '--is-ancestor', base, sha); } catch { stop('ff'); }
console.log('FF_OK');
// --no-renames keeps -z numstat to exactly one path per record. Never parse
// quoted path output or consult working-tree bytes.
const binaries = pathsText(git('diff', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', '--numstat', '-z', base, sha))
.split('\0').filter(Boolean).filter(s => s.startsWith('-\t-\t')).map(s => s.slice(4));
const tree = commit => {
const entries = new Map();
for (const entry of pathsText(git('ls-tree', '-r', '-z', commit)).split('\0').filter(Boolean)) {
const tab = entry.indexOf('\t');
const [mode, type, oid] = entry.slice(0, tab).split(' ');
entries.set(entry.slice(tab + 1), { mode, type, oid });
}
return entries;
};
const have = tree(sha);
const present = binaries.filter(path => have.has(path));
let approved = [];
if (present.length && rowIds.size) {
let rows;
try {
const root = text('rev-parse', '--show-toplevel');
const { rows: readRows } = await import(pathToFileURL(join(root, 'packages/queue/src/store.mjs')).href);
const result = readRows({ cwd: process.cwd(), env: process.env });
if (result.code !== 0) throw new Error('queue');
rows = result.rows;
for (const id of rowIds) if (!rows.some(r => r.id === id)) throw new Error('row');
} catch { stop('queue unavailable or invalid', 2); }
for (const row of rows.filter(r => rowIds.has(r.id))) {
const round = row.review?.rounds.at(-1);
if (!round || round.receipts?.some(r => r.verdict === 'changes')) continue;
if (!round.receipts?.some(r => r.verdict === 'approve' && r.candidate === round.candidate.digest)) continue;
approved.push(round.candidate);
}
}
const candidates = new Map();
for (const c of approved) {
if (c.kind !== 'commit') continue;
const candidateTree = tree(c.digest);
let parent;
try { parent = text('rev-parse', '--verify', `${c.digest}^1`); }
catch { parent = text('hash-object', '-t', 'tree', '/dev/null'); }
const changed = new Set(pathsText(git('diff-tree', '-r', '--no-renames', '--name-only', '-z', parent, c.digest)).split('\0').filter(Boolean));
candidates.set(c.digest, { candidateTree, changed });
}
for (const path of present) {
const h = have.get(path);
const digest = hash(git('cat-file', 'blob', h.oid));
const matches = approved.some(c => {
if (c.kind === 'manifest') return c.text.split('\n').includes(`${digest} ${path}`);
const { candidateTree, changed } = candidates.get(c.digest);
const entry = candidateTree.get(path);
return changed.has(path) && entry?.type === 'blob' && entry.mode === h.mode && hash(git('cat-file', 'blob', entry.oid)) === digest;
});
if (!matches) stop(`binary ${shownPath(path)} ${digest} not in a reviewed candidate`);
}
console.log(present.length ? `BINARY_REVIEWED ${present.length}` : 'NO_BINARY');
const diff = git('diff', '--no-color', '--no-ext-diff', '--no-textconv', base, sha).toString('utf8');
const secret = /BEGIN [A-Z ]*PRIVATE KEY|ghp_[A-Za-z0-9]{20}|sk-ant-[A-Za-z0-9-]{10}|xox[bp]-|AKIA[0-9A-Z]{16}|(token|secret|password)["' ]*[:=] *["'][A-Za-z0-9_\-]{24,}/i;
if (diff.split('\n').some(line => line.startsWith('+') && secret.test(line))) stop('secrets');
console.log('SECRETS_CLEAN');
if (/co-authored-by|generated with/i.test(text('log', '--format=%B', `${base}..${sha}`))) stop('attribution');
console.log('NO_ATTRIBUTION');
console.log(`PUSH_READY ${base}..${sha}`);
} catch { stop('git or environment error', 2); }
JS
+203
View File
@@ -0,0 +1,203 @@
#!/bin/bash
# All repositories, queue writes, remotes and mutants are disposable scratch.
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "$0")/.." && pwd)
exec node --input-type=module - "$ROOT" "$@" <<'JS'
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
const root = process.argv[2];
const { scratchRepo, genesisCommitted, cli } = await import(pathToFileURL(join(root, 'packages/queue/tests/helpers.mjs')));
const target = process.argv[3] ?? join(root, 'scripts/push-check.sh');
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
const binary = Buffer.from([0, 1, 2, 3]);
const digest = sha256(binary);
const cleanups = [];
let count = 0;
function fixture() {
const repo = scratchRepo({ after: fn => cleanups.push(fn) });
genesisCommitted(repo);
const run = (args, by = 'darkwing', allow = false) => {
const r = cli(repo, args, { by });
assert.ok(r.code === 0 || allow, r.err);
return r;
};
run(['move', '6', 'blocked', '--reason', 'paused', '--op', 'block-six-0001']);
run(['move', '9', 'in-progress', '--op', 'start-nine-0001']);
run(['set', '9', 'reviewers', 'dewey,filbert', '--op', 'set-reviewers-0001'], 'sage');
// A real local bare remote and clone, with no network or origin access.
const bare = join(repo.base, 'remote.git');
repo.g('init', '--bare', '-q', bare);
repo.g('clone', '-q', bare, join(repo.base, 'clone'));
repo.g('remote', 'add', 'origin', bare);
const base = repo.g('rev-parse', 'HEAD').trim();
repo.g('update-ref', 'refs/remotes/origin/refactor', base);
const commit = (path, bytes, message = 'content') => {
if (bytes === null) repo.g('rm', '--', path);
else { writeFileSync(join(repo.root, path), bytes); repo.g('add', '--', path); }
repo.g('commit', '-q', '-m', message);
return repo.g('rev-parse', 'HEAD').trim();
};
const check = (sha, extra = [], expectedCode = 0, reason = null, script = target) => {
const log = join(repo.base, 'expected.log');
writeFileSync(log, repo.g('log', '--oneline', `${base}..${sha}`));
const r = spawnSync('bash', [script, sha, log, ...extra], { cwd: repo.root, env: repo.env, encoding: 'utf8' });
assert.equal(r.status, expectedCode, `${r.stdout}\n${r.stderr}`);
const lines = r.stdout.trim().split('\n');
if (expectedCode === 0) assert.match(lines.at(-1), /^PUSH_READY /);
else {
assert.equal(lines.filter(s => s.startsWith('STOP ')).length, 1, r.stdout);
assert.match(lines.at(-1), reason ?? /^STOP /);
assert.ok(!r.stdout.includes('PUSH_READY'), r.stdout);
}
count++;
return r.stdout;
};
let n = 0;
const round = (candidate, verdict = 'approve') => {
n++;
const r = run(['move', '9', 'in-review', '--candidate', candidate, '--op', `review-nine-000${n}`], 'darkwing', true);
// Missing dummy transport credentials fails the request, after opening a
// valid round. Recording a receipt is cooperative and needs no credential.
assert.equal(r.code, 1, r.out + r.err);
const row = JSON.parse(run(['show', '9']).out);
const c = row.review.rounds.at(-1).candidate.digest;
run(['review', 'record', '9', '--verdict', verdict, '--candidate', c, '--comment', String(n), '--op', `verdict-nine-00${n}`], 'filbert');
};
const manifest = (d = digest, path = 'asset.bin') => {
const file = join(repo.base, 'manifest.sha256');
writeFileSync(file, `${d} ${path}\n`);
return file;
};
return { repo, run, base, commit, check, round, manifest };
}
try {
{
const f = fixture();
const sha = f.commit('text.txt', 'hello\n');
assert.match(f.check(sha), /NO_BINARY\nSECRETS_CLEAN\nNO_ATTRIBUTION\nPUSH_READY /);
f.check(sha, ['--base-ref', f.base]);
f.check(sha, ['--bad-option'], 2, /^STOP usage/);
// Explicit fetch goes only to the scratch remote.
f.check(sha, ['--fetch']);
const wrong = join(f.repo.base, 'wrong.log'); writeFileSync(wrong, 'wrong\n');
const r = spawnSync('bash', [target, sha, wrong], { cwd: f.repo.root, env: f.repo.env, encoding: 'utf8' });
assert.equal(r.status, 1); assert.equal(r.stdout, 'STOP log\n'); count++;
f.repo.g('update-ref', 'refs/remotes/origin/refactor', sha);
const log = join(f.repo.base, 'empty.log'); writeFileSync(log, '');
const ff = spawnSync('bash', [target, f.base, log], { cwd: f.repo.root, env: f.repo.env, encoding: 'utf8' });
assert.equal(ff.status, 1); assert.equal(ff.stdout, 'LOG_OK\nSTOP ff\n'); count++;
}
{
const f = fixture(), sha = f.commit('asset.bin', binary);
f.check(sha, [], 1, /^STOP binary asset.bin /);
f.round(f.manifest('0'.repeat(64)));
f.check(sha, ['--row', '9'], 1, /^STOP binary asset.bin /);
}
{
const f = fixture(), sha = f.commit('asset.bin', binary);
f.round(f.manifest());
writeFileSync(join(f.repo.root, 'asset.bin'), 'working tree differs');
assert.match(f.check(sha, ['--row', '9']), /BINARY_REVIEWED 1\n/);
f.check(sha, [], 1, /^STOP binary asset.bin /);
const other = f.commit('asset.bin', Buffer.from([0, 8, 9]));
// The working tree is the approved version; the pushed blob isn't.
writeFileSync(join(f.repo.root, 'asset.bin'), binary);
f.check(other, ['--row', '9'], 1, /^STOP binary asset.bin /);
}
{
const f = fixture(), sha = f.commit('asset.bin', binary);
f.round(sha);
assert.match(f.check(sha, ['--row', '9']), /BINARY_REVIEWED 1\n/);
const other = f.commit('asset.bin', Buffer.from([0, 7]));
f.check(other, ['--row', '9'], 1, /^STOP binary asset.bin /);
}
{
const f = fixture(), sha = f.commit('space tab\tasset.bin', binary);
f.round(sha);
assert.match(f.check(sha, ['--row', '9']), /BINARY_REVIEWED 1\n/);
}
{
const f = fixture(), sha = f.commit('line\nbreak.bin', binary);
f.check(sha, [], 1, /^STOP binary "line\\nbreak.bin" /);
writeFileSync(Buffer.concat([Buffer.from(f.repo.root + '/'), Buffer.from([255]), Buffer.from('.bin')]), binary);
f.repo.g('add', '-A'); f.repo.g('commit', '-q', '-m', 'invalid UTF-8 path');
f.check(f.repo.g('rev-parse', 'HEAD').trim(), [], 2, /^STOP git or environment error$/);
}
{
const f = fixture();
f.commit('asset.bin', binary);
const unrelated = f.commit('text.txt', 'unrelated\n');
f.round(unrelated);
f.check(unrelated, ['--row', '9'], 1, /^STOP binary asset.bin /);
}
{
const f = fixture(), sha = f.commit('asset.bin', binary);
f.round(f.manifest(), 'changes');
const row = JSON.parse(f.run(['show', '9']).out);
f.run(['review', 'record', '9', '--verdict', 'approve', '--candidate', row.review.rounds.at(-1).candidate.digest, '--comment', '2', '--op', 'approve-dewey-0001'], 'dewey');
f.check(sha, ['--row', '9'], 1, /^STOP binary asset.bin /);
}
{
const f = fixture(), sha = f.commit('asset.bin', binary);
f.round(f.manifest());
f.run(['move', '9', 'in-progress', '--op', 'revise-nine-0001']);
f.round(f.manifest('0'.repeat(64)));
f.check(sha, ['--row', '9'], 1, /^STOP binary asset.bin /);
}
{
const f = fixture(), sha = f.commit('asset.bin', binary);
f.round(f.manifest());
// This shape cannot be recorded by the queue. Its read API must reject
// tampered receipts before the guard can treat them as authority.
const path = f.repo.queuePath;
const doc = JSON.parse(readFileSync(path));
doc.rows.find(r => r.id === 9).review.rounds.at(-1).receipts[0].candidate = '0'.repeat(64);
writeFileSync(path, JSON.stringify(doc));
f.check(sha, ['--row', '9'], 2, /^STOP queue unavailable or invalid$/);
}
{
const f = fixture(), added = f.commit('asset.bin', binary);
f.repo.g('update-ref', 'refs/remotes/origin/refactor', added);
const deleted = f.commit('asset.bin', null);
// Explicit base retains the check helper's original expected log range.
f.repo.g('update-ref', 'refs/remotes/origin/refactor', f.base);
assert.match(f.check(deleted, ['--base-ref', added], 1, /^STOP log$/), /STOP log/);
const log = join(f.repo.base, 'delete.log'); writeFileSync(log, f.repo.g('log', '--oneline', `${added}..${deleted}`));
const r = spawnSync('bash', [target, deleted, log, '--base-ref', added], { cwd: f.repo.root, env: f.repo.env, encoding: 'utf8' });
assert.equal(r.status, 0, r.stdout); assert.match(r.stdout, /NO_BINARY\n/); assert.match(r.stdout.trim().split('\n').at(-1), /^PUSH_READY /); count++;
}
for (const [message, content, reason, prefix] of [
['content', 'password="' + 'a'.repeat(30) + '"\n', 'secrets', 'LOG_OK\nFF_OK\nNO_BINARY\n'],
['Generated with example', 'hello\n', 'attribution', 'LOG_OK\nFF_OK\nNO_BINARY\nSECRETS_CLEAN\n'],
]) {
const f = fixture(), sha = f.commit('text.txt', content, message);
assert.equal(f.check(sha, [], 1), `${prefix}STOP ${reason}\n`);
}
console.log(`PASS push-check ${count} checks`);
if (!process.argv[3]) {
const dir = mkdtempSync(join(tmpdir(), 'push-check-mutants-'));
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
const source = readFileSync(target, 'utf8');
const mutants = [
['skip-hash', "c.text.split('\\n').includes(`${digest} ${path}`)", "c.text.split('\\n').some(line => line.endsWith(` ${path}`))"],
['accept-changes', "round.receipts?.some(r => r.verdict === 'changes')", 'false'],
['earlier-round', 'row.review?.rounds.at(-1)', 'row.review?.rounds.at(0)'],
['working-tree', "hash(git('cat-file', 'blob', h.oid))", 'hash(readFileSync(path))'],
['no-row', 'if (!matches) stop(', 'if (!matches && rowIds.size) stop('],
];
for (const [name, before, after] of mutants) {
assert.ok(source.includes(before), name);
const file = join(dir, `${name}.sh`); writeFileSync(file, source.replace(before, after));
const r = spawnSync('timeout', ['-k', '10', '120', 'bash', join(root, 'scripts/test-push-check.sh'), file], { encoding: 'utf8', maxBuffer: 4 << 20 });
assert.equal(r.status, 1, `${name} survived or infrastructure failed: ${r.status}\n${r.stdout}\n${r.stderr}`);
assert.match(r.stderr, /AssertionError/, r.stderr);
console.log(`KILLED ${name}`);
}
}
} finally { for (const fn of cleanups.reverse()) fn(); }
JS