// tests/lib/fix-round-4-5b2-c-scripts.test.mjs // The form of fix round 4.5b2 part C's shell scripts and mutation module (plan // .claude/plans/trekplan-2026-11-03-review-gate-4-5b2-fixes.md, Step 7): the // red wrapper, the mutations and the round's end-to-end run. // Untagged on purpose and listed in no red wrapper's FILES: these checks are // not a part-C fix. // - each script exists, is ASCII only, passes /bin/bash -n and uses no // bash 4 form; the mutation module exists and is ASCII only; // - the red wrapper hands the shared red script the base f1f5ab5, exactly // one --other-ids, whose words are the IDS= words of the seven earlier // wrappers in order (4-5a, 4-5b, part 2, part 3, part 4, 4-5b2-a, // 4-5b2-b), the round's nine ids, the part-C mutation module and exactly // the seven tagged test files of this round (run from a temp root next to // a stub red-round.sh that prints its arguments, the method of // tests/lib/fix-round-4-5b2-b-scripts.test.mjs); // - with any earlier wrapper lacking its IDS= line, the red wrapper exits 2 // with a setup failure and never runs the shared script; // - red-mutate.mjs list exits 0 and prints no mutation (no case of this // round reads a file under tests/, so none needs one); // - every test file under tests/ that holds one of the round's tags is one // of the seven, and none of the seven is in an earlier wrapper's FILES. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from '../helpers/child-env.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const RED = 'tests/fix-round-4-5b2-c-red.sh'; const E2E = 'tests/e2e-fix-round-4-5b2-c.sh'; const MUTATIONS = 'tests/fix-round-4-5b2-c-mutations.mjs'; const SELF = 'tests/lib/fix-round-4-5b2-c-scripts.test.mjs'; const BASE = 'f1f5ab5528a376050dd03b7f7ac43f887596c9b4'; const OLDER = [ 'tests/fix-round-4-5a-red.sh', 'tests/fix-round-4-5b-red.sh', 'tests/fix-round-4-5b-part-2-red.sh', 'tests/fix-round-4-5b-part-3-red.sh', 'tests/fix-round-4-5b-part-4-red.sh', 'tests/fix-round-4-5b2-a-red.sh', 'tests/fix-round-4-5b2-b-red.sh', ]; const PARTC_IDS = ['DUP', 'RSP', 'NRL', 'SYM', 'WHD', 'AMB', 'CLU', 'MMG', 'PRC']; const PARTC_FILES = [ 'tests/lib/unique-finding-ids.test.mjs', 'tests/lib/review-rounds-stopped-prepare.test.mjs', 'tests/lib/instruction-scope-depth.test.mjs', 'tests/lib/merge-evidence-writer-head.test.mjs', 'tests/hooks/merge-evidence-ambiguous.test.mjs', 'tests/hooks/merge-evidence-many.test.mjs', 'tests/lib/fix-round-4-5b2-c-prose.test.mjs', ]; /** The shared script's options that take a value; every other argument after them is a test file. */ const VALUE_OPTIONS = ['--base', '--ids', '--other-ids', '--mutations']; /** A repo file's bytes; the file must exist. */ // Copied from tests/lib/fix-round-4-5b2-b-scripts.test.mjs (bytes). function bytes(rel) { assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`); return readFileSync(join(ROOT, rel)); } /** The single-quoted words of a wrapper's one ='...' line. */ // Copied from tests/lib/fix-round-4-5b2-b-scripts.test.mjs (wordsOf). function wordsOf(rel, name) { const lines = bytes(rel).toString('utf8').split('\n').filter((l) => l.startsWith(`${name}=`)); assert.equal(lines.length, 1, `expected one ${name}= line in ${rel}; found ${lines.length}`); const m = lines[0].match(new RegExp(`^${name}='([^']*)'$`)); assert.ok(m, `the ${name}= line of ${rel} is not one single-quoted list: ${lines[0]}`); return m[1].split(/\s+/).filter(Boolean); } test('part-C scripts: each exists, is ASCII only, passes /bin/bash -n and uses no bash 4 form', () => { for (const rel of [RED, E2E]) { const buf = bytes(rel); assert.equal(buf.findIndex((b) => b > 127), -1, `${rel} is not ASCII only`); const r = spawnSync('/bin/bash', ['-n', join(ROOT, rel)], { encoding: 'utf8', env: childEnv() }); assert.equal(r.status, 0, `${rel} does not pass /bin/bash -n: ${r.stderr}`); const text = buf.toString('utf8'); for (const bash4 of [/\bdeclare -A\b/, /\bmapfile\b/, /\breadarray\b/, /\|&/]) { assert.doesNotMatch(text, bash4, `${rel} uses ${bash4}, which bash 3.2 lacks`); } } const mod = bytes(MUTATIONS); assert.equal(mod.findIndex((b) => b > 127), -1, `${MUTATIONS} is not ASCII only`); }); /** A stand-in for the shared script: one ARG: line per argument, exit 0. */ const STUB = `#!/bin/bash for a in "$@"; do printf 'ARG:%s\\n' "$a"; done exit 0 `; /** A temp root with the part-C wrapper, the seven earlier wrappers and the stub as tests/helpers/red-round.sh. */ function stubRoot(base) { const dir = join(base, 'root'); mkdirSync(join(dir, 'tests', 'helpers'), { recursive: true }); for (const rel of [RED, ...OLDER]) writeFileSync(join(dir, rel), bytes(rel)); writeFileSync(join(dir, 'tests', 'helpers', 'red-round.sh'), STUB, { mode: 0o755 }); return dir; } /** The part-C wrapper run from a temp root: status, output and the arguments the stub saw. */ function runWrapper(dir) { const r = spawnSync('/bin/bash', [join(dir, RED)], { cwd: tmpdir(), encoding: 'utf8', timeout: 30000, env: childEnv() }); const argv = (r.stdout || '').split('\n').filter((l) => l.startsWith('ARG:')).map((l) => l.slice(4)); return { status: r.status, out: `${r.stdout}${r.stderr}`, stderr: r.stderr || '', argv }; } /** The arguments that are neither an option nor an option's value: the test files. */ function filesOf(argv) { const files = []; for (let i = 0; i < argv.length; i += 1) { if (VALUE_OPTIONS.includes(argv[i])) { i += 1; continue; } if (argv[i].startsWith('--')) continue; files.push(argv[i]); } return files; } test('part-C scripts: the red wrapper passes the base, one --other-ids with the seven earlier rounds\' ids in order, its nine ids, its mutations and its seven tagged files', () => { const text = bytes(RED).toString('utf8'); const execLines = text.split('\n').filter((l) => /^exec /.test(l)); assert.equal(execLines.length, 1, `${RED} must hold one exec line; found ${execLines.length}`); assert.equal(execLines[0].split('--other-ids').length - 1, 1, `${RED}: the exec line must pass --other-ids exactly once: ${execLines[0]}`); const base = mkdtempSync(join(tmpdir(), 'partC-red-wrap-')); try { const dir = stubRoot(base); const r = runWrapper(dir); assert.equal(r.status, 0, `${RED} did not run the shared script:\n${r.out}`); const baseAt = r.argv.indexOf('--base'); assert.ok(baseAt >= 0, `${RED} passed no --base:\n${r.out}`); assert.equal(r.argv[baseAt + 1], BASE, `${RED}: --base is not ${BASE}\n${r.out}`); const at = r.argv.map((a, i) => (a === '--other-ids' ? i : -1)).filter((i) => i >= 0); assert.equal(at.length, 1, `${RED} handed the shared script --other-ids ${at.length} times:\n${r.out}`); const other = (r.argv[at[0] + 1] || '').split(/\s+/).filter(Boolean); assert.deepEqual(other, OLDER.flatMap((rel) => wordsOf(rel, 'IDS')), `${RED}: --other-ids is not the IDS= words of ${OLDER.join(', then ')}\n${r.out}`); for (const id of PARTC_IDS) { assert.ok(!other.includes(id), `${RED}: the round's tag ${id} is on --other-ids, so it would never be judged\n${r.out}`); } const idsAt = r.argv.indexOf('--ids'); assert.ok(idsAt >= 0, `${RED} passed no --ids:\n${r.out}`); assert.deepEqual(r.argv[idsAt + 1].split(/\s+/).filter(Boolean), PARTC_IDS, `${RED}: --ids is not ${PARTC_IDS.join(' ')}\n${r.out}`); const mutAt = r.argv.indexOf('--mutations'); assert.ok(mutAt >= 0, `${RED} passed no --mutations:\n${r.out}`); assert.ok(r.argv[mutAt + 1].startsWith('/'), `${RED}: --mutations is not an absolute path: ${r.argv[mutAt + 1]}`); assert.ok(r.argv[mutAt + 1].endsWith(`/${MUTATIONS}`), `${RED}: --mutations is not ${MUTATIONS}: ${r.argv[mutAt + 1]}`); assert.deepEqual([...filesOf(r.argv)].sort(), [...PARTC_FILES].sort(), `${RED}: the test files are not exactly the seven tagged files of this round\n${r.out}`); } finally { rmSync(base, { recursive: true, force: true }); } }); test('part-C scripts: the red wrapper exits 2 with a setup failure when an earlier wrapper has no IDS= line', () => { for (const missing of OLDER) { const base = mkdtempSync(join(tmpdir(), 'partC-red-noids-')); try { const dir = stubRoot(base); const text = bytes(missing).toString('utf8'); writeFileSync(join(dir, missing), text.split('\n').filter((l) => !l.startsWith('IDS=')).join('\n')); const r = runWrapper(dir); assert.equal(r.status, 2, `${RED}: ${missing} without an IDS= line is not exit 2\n${r.out}`); assert.match(r.stderr, /setup failure/, `${RED}: no setup failure line when ${missing} has no IDS= line\n${r.out}`); assert.ok(r.stderr.includes(missing), `${RED}: the setup failure line does not name ${missing}\n${r.out}`); assert.deepEqual(r.argv, [], `${RED} ran the shared script though ${missing} has no IDS= line\n${r.out}`); } finally { rmSync(base, { recursive: true, force: true }); } } }); test('part-C scripts: red-mutate.mjs list exits 0 and prints no mutation', () => { assert.ok(existsSync(join(ROOT, MUTATIONS)), `missing file ${MUTATIONS}`); const r = spawnSync(process.execPath, [join(ROOT, 'tests', 'helpers', 'red-mutate.mjs'), 'list', join(ROOT, MUTATIONS)], { cwd: ROOT, encoding: 'utf8', timeout: 30000, env: childEnv() }); assert.equal(r.status, 0, `red-mutate.mjs list ${MUTATIONS} exited ${r.status}:\n${r.stdout}${r.stderr}`); const lines = (r.stdout || '').split('\n').filter((l) => l !== ''); assert.deepEqual(lines, [], `red-mutate.mjs list ${MUTATIONS} must print no mutation:\n${r.stdout}${r.stderr}`); }); /** Every .mjs file under tests/, as a repo-relative path. */ // Copied from tests/lib/fix-round-4-5b2-b-scripts.test.mjs (testModules). function testModules(dir = join(ROOT, 'tests')) { const out = []; for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isDirectory()) out.push(...testModules(p)); else if (e.name.endsWith('.mjs')) out.push(relative(ROOT, p).split('\\').join('/')); } return out; } test('part-C scripts: every file holding a part-C tag is one of the seven, and none of the seven is in an earlier wrapper\'s FILES', () => { const tag = new RegExp(`\\[(?:${PARTC_IDS.join('|')})\\]`); const tagged = testModules().filter((rel) => rel !== SELF && tag.test(readFileSync(join(ROOT, rel), 'utf8'))); assert.deepEqual([...tagged].sort(), [...PARTC_FILES].sort(), `the files under tests/ that hold a part-C tag are not exactly the seven of ${RED}`); for (const rel of OLDER) { const files = wordsOf(rel, 'FILES'); for (const f of PARTC_FILES) { assert.ok(!files.includes(f), `${f} is in the FILES of the earlier wrapper ${rel}`); } } });