// tests/hooks/bash-guard-reader-pins.test.mjs // Fix round 4.5b, part 4 (decision 90): part 3's five fix-round reader cases // (tests/e2e-fix-round-4-5b-part-3.sh:277-305) as pins under npm test, so a // later edit of the args-heredoc guard in hooks/scripts/pre-bash-executor.mjs // cannot drop them unseen. Untagged: they pass at 591f3c4, and no red // wrapper lists this file. // Each case is built from the same lines as the e2e case, joined with \n. // Three more tests prove the pins can fail: each mutates one line of a copy // of the hook (pluginCopy, tests/hooks/guard-helpers.mjs) and sees the pins it // guards flip. A later edit of a mutated line makes its test fail with // "matched 0 times", never pass silently. // The helpers ARGS_DELIM, ARGS_RULE, PARSER_LINE, bashInput, // expectArgsBlocked, expectArgsAllowed, UNQUOTED and expectUnquoted are copied // from tests/hooks/bash-guard-opener-word.test.mjs:22-53, not imported, // because importing a test file would run its tests; here they take the hook // to run as a second argument. import { test, after } from 'node:test'; import { strict as assert } from 'node:assert'; import { readFileSync, writeFileSync, rmSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runHook } from '../helpers/hook-helper.mjs'; import { pluginCopy } from './guard-helpers.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..', '..'); const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs'); function bashInput(command) { return { tool_name: 'Bash', tool_input: { command } }; } const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E'; const ARGS_RULE = /args-heredoc-closed-early/; const PARSER_LINE = 'node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin'; async function expectArgsBlocked(command, hook = PRE_BASH) { const { code, stderr } = await runHook(hook, bashInput(command)); assert.match(stderr, ARGS_RULE, `the guard did not name args-heredoc-closed-early for: ${JSON.stringify(command)}`); assert.strictEqual(code, 2, `the guard did not block: ${JSON.stringify(command)}`); return stderr; } async function expectArgsAllowed(command, hook = PRE_BASH) { const { code, stderr } = await runHook(hook, bashInput(command)); assert.doesNotMatch(stderr, ARGS_RULE, `the guard refused a well-formed heredoc: ${JSON.stringify(command)}`); assert.strictEqual(code, 0, `the guard refused a well-formed heredoc: ${stderr}`); } // The unquoted reason: the guard blocks and says the opener is unquoted. const UNQUOTED = /opens VOYAGE_ARGS_EOF_7F3C9E unquoted/; async function expectUnquoted(command, hook = PRE_BASH) { const stderr = await expectArgsBlocked(command, hook); assert.match(stderr, UNQUOTED, `the guard did not give the unquoted reason for: ${JSON.stringify(command)}`); } const D = ARGS_DELIM; const CR = '\r'; const VT = '\x0b'; const PARTLY = `${PARSER_LINE} <<"VOYAGE"_ARGS_EOF_7F3C9E`; // The five cases, numbered as the plan numbers them. `expect` is the normal // result; `cr` and `vt` are the bytes the case intends. const PINS = [ { n: 1, name: `Bash hook: <<$ + backslash-newline + '${D}' that closes early is blocked as unquoted`, lines: [`${PARSER_LINE} <<$\\`, `'${D}'`, 'brief.md', D, 'touch PWNED'], expect: 'unquoted', cr: 0, vt: 0, }, { n: 2, name: "Bash hook: < pin.lines.join('\n'); const pin = (n) => PINS.find((p) => p.n === n); async function expectNormal(p, hook = PRE_BASH) { const command = commandOf(p); if (p.expect === 'unquoted') await expectUnquoted(command, hook); else if (p.expect === 'blocked') await expectArgsBlocked(command, hook); else await expectArgsAllowed(command, hook); } for (const p of PINS) { test(`part-4 reader pins: ${p.name}`, async () => { await expectNormal(p); }); } test('part-4 reader pins: each built command holds exactly the CR and VT bytes its case intends', () => { for (const p of PINS) { const command = commandOf(p); assert.strictEqual(command.split(CR).length - 1, p.cr, `pin ${p.n} holds the wrong number of CR bytes`); assert.strictEqual(command.split(VT).length - 1, p.vt, `pin ${p.n} holds the wrong number of VT bytes`); } }); // The copies, removed after the file's tests. const copies = []; after(() => { for (const dir of copies) rmSync(dir, { recursive: true, force: true }); }); // A copy of the plugin and its hook path; `find` must occur exactly once in // the copy's hook. `mutate()` then writes `replace` in its place, so the same // hook is run before and after the mutation. function mutatedCopy(find, replace) { const root = pluginCopy(); copies.push(root); const hook = join(root, 'hooks', 'scripts', 'pre-bash-executor.mjs'); const src = readFileSync(hook, 'utf8'); const count = src.split(find).length - 1; assert.strictEqual(count, 1, `the mutation text matched ${count} times, not once: ${find}`); return { hook, mutate: () => writeFileSync(hook, src.replace(find, () => replace)) }; } async function expectPasses(p, hook) { const { code, stderr } = await runHook(hook, bashInput(commandOf(p))); assert.doesNotMatch(stderr, ARGS_RULE, `pin ${p.n} was still blocked under the mutation`); assert.strictEqual(code, 0, `pin ${p.n} did not exit 0 under the mutation: ${stderr}`); } const MUTATIONS = [ { name: 'pins 1 and 2 go red under const q1 = k + 1', find: "const q1 = c === '$' ? skip(k + 1) : k + 1;", replace: 'const q1 = k + 1;', pins: [1, 2], flipped: async (p, hook) => expectPasses(p, hook), }, { name: 'pins 3 and 4 go red under the untrimmed word comparison', find: 'o.word.trim() !== ARGS_DELIM', replace: 'o.word !== ARGS_DELIM', pins: [3, 4], flipped: async (p, hook) => expectPasses(p, hook), }, { name: 'pin 5 goes red under the untrimmed closing-line count', find: 'body.flatMap((l, i) => (l.trim() === ARGS_DELIM', replace: 'body.flatMap((l, i) => (l === ARGS_DELIM', pins: [5], flipped: async (p, hook) => { await expectArgsBlocked(commandOf(p), hook); }, }, ]; for (const m of MUTATIONS) { test(`part-4 reader pins: ${m.name}`, async () => { const { hook, mutate } = mutatedCopy(m.find, m.replace); // The unmutated copy gives each pin its normal result, so a broken copy // cannot pass for a flip. for (const n of m.pins) await expectNormal(pin(n), hook); mutate(); for (const n of m.pins) await m.flipped(pin(n), hook); }); }