merge: trekexecute fix-round-4-5b-part-4

This commit is contained in:
Kjell Tore Guttormsen 2026-10-31 18:58:13 +01:00
commit e2d6beccb7
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
13 changed files with 1188 additions and 24 deletions

View file

@ -73,7 +73,7 @@ Fix round 4.5b (decisions 80 to 87): the round's 39 findings, from the 4.5a revi
Part 2 fixes the four defects that part 1 introduced and its own review found, plus the Bash guard's first-opener-only check; each has a test seen failing first at part 1's end point `52c382c` (`tests/fix-round-4-5b-part-2-red.sh` proves it).
- **A quoted value stops at a quote that starts a word, and never swallows a later flag** (TOK). In `lib/parsers/arg-parser.mjs`, a closing quote must end a word, and the search for it stops at a quote of the same kind that starts a word; with no pair, the opening quote stays a literal character. Before, `--project "my dir"/x --since "HEAD~1"` gave one `--project` value that held `--since`, so `/trekreview` reviewed the wrong range; now `--since` is `HEAD~1`. An apostrophe inside a word keeps its value (`'Fix the user's login'` is one value), and nothing is expanded.
- **A quoted value stops at a quote that starts a word** (TOK). In `lib/parsers/arg-parser.mjs`, a closing quote must end a word, and the search for it stops at a quote of the same kind that starts a word; with no pair, the opening quote stays a literal character. Before, `--project "my dir"/x --since "HEAD~1"` gave one `--project` value that held `--since`, so `/trekreview` reviewed the wrong range; now `--since` is `HEAD~1`. An apostrophe inside a word keeps its value (`'Fix the user's login'` is one value), and nothing is expanded.
- **The Bash guard checks every opener of the parser's heredoc that has a line after it, and lets a one-line mention through** (HDG). `hooks/scripts/pre-bash-executor.mjs` checks an opener of `VOYAGE_ARGS_EOF_7F3C9E` only when a non-blank line follows the opener's line, because bash reads a heredoc body only from the lines after it, so a one-line `grep`, `sed` or `git commit -m` that mentions the opener is no longer refused. It now checks every opener, not only the first, so a quoted first mention can no longer hide a later unquoted heredoc whose arguments would run as commands.
- **A failed `git check-ignore` writes no lessons pointer, whatever language git speaks** (CKI). `lib/review/recurrence.mjs` no longer reads every `git check-ignore` exit 128 as "no repository": on 128 it runs `git rev-parse --git-dir` and looks for a `.git` entry from the repo up to the nearest `GIT_CEILING_DIRECTORIES` entry above it, and writes the `CLAUDE.md` pointer only when rev-parse also exits 128, `GIT_DIR` is unset and no `.git` entry is found. Anything else gives `check-failed` with a note that names the reason, and no branch reads git's message text. A `.claude` that is a symlink inside the repo now gives `check-failed` (no pointer), also when it points at a tracked directory.
- **The executor records the full commit id for the done line** (DLC). `/trekexecute` Sub-step G says `{hash}` is the full commit id from `git rev-parse HEAD` after the step's last commit, read again after any later commit of the step, never the short id `git commit` prints: `lib/verification/done-line.mjs --check` refuses a short id with exit 2, which sent a passing step to the plan. The progress line stores that full id, and Handover 5's `commit` row says so.
@ -85,6 +85,15 @@ Part 3 fixes two MAJOR findings of part 2's review, in the two places part 2 rew
- **A quoted value closes at the first quote of its kind that ends a word, whatever stands before it** (CQW). In `lib/parsers/arg-parser.mjs`, the closing quote no longer has to follow a non-blank character. So `"my dir "`, `" "` and a value closed on its own line pair again. A quote that starts a word still stops the search, so `--since` after `"my dir"/x` stays `HEAD~1`. One qualification: a value whose text ends in whitespace may now hold what follows it, up to a quote that ends a word (see Known issues). It names review finding `87a693f0`.
- **The Bash guard also reads a heredoc opener's word as the shell does** (PQO). `hooks/scripts/pre-bash-executor.mjs` reads the word when it is partly quoted, `$'...'`, `$"..."`, or holds a backslash-newline anywhere from the first `<` to the end of the word. It checks every opener whose word is the parser's delimiter, and treats a word it cannot read as unquoted. Every command it blocked before stays blocked. A typed argument whose `<<` runs into a quote with no partner (`fix "<<" handling`) still passes inside the commands' argument heredoc. It names review finding `021996c6`.
**Fix round 4.5b, part 4 (decision 90)**
Part 4 fixes four MAJOR findings of part 3's review in the Bash guard's check of the argument heredoc; each has a test seen failing first at part 3's end point `591f3c4` (`tests/fix-round-4-5b-part-4-red.sh` proves it). The tokenizer headline in part 2's group above no longer claims more than the tokenizer does (see the tokenizer Known issue).
- **The guard checks the parser's delimiter when a backslash-newline splits it** (CSG). `hooks/scripts/pre-bash-executor.mjs` checks a command that names the parser's delimiter only once a backslash-newline is removed, so an unquoted opener split that way with a body and no closing line is blocked as unquoted; such a command with a quoted opener and no closing line, or with an unreadable opener elsewhere, is now checked too and refused, as it would be with the delimiter written out. It names review finding `3a34adbe`.
- **A `<<` cut by the quote that closes a string on its line is text** (SLT). A `<<` inside a string on the opener's line, whose cutting quote closes that string, is text and passes, so commit messages, grep patterns and file writes that hold `"<<"` and name the delimiter pass again; a quote with no string open before it stays unreadable and blocked; a string opened on an earlier line is still refused (see Known issues). It names review finding `ad8ad828`.
- **A backslash-escaped `<` with no quote or heredoc operator before it starts no opener** (ESC). A `<` after an odd run of backslashes starts no opener when no quote, backquote or heredoc operator stands before the run, in both readings, so `echo \<<` followed by the delimiter and a line passes; the `<` after it is read as usual, so a `<<` right after an escaped `<`, which the guard took for part of a here-string before, is now checked; an escaped `<` inside a heredoc body fed to a shell is still checked. It names review finding `797784e0`.
- **The guard's error path is tested** (CTB). A test forces an error inside the guard's shell reading and sees it block with exit 2, through a test-side preload; the hook gains no switch. It names review finding `1658fc64`.
Fix round 4.5a (decisions 57, 67 and 68): the round's 30 findings, where a Voyage control could be bypassed or was too lax, or where the text gave a guarantee no code held. Each is fixed, and each has a test that fails on the code before it, except DJ, whose sentences already stood and whose pins fail when they are deleted (`tests/fix-round-4-5a-red.sh` proves every other id red at the round's start point, and DJ under its two mutations).
**Execute**
@ -169,10 +178,16 @@ Fix round 4.5a (decisions 57, 67 and 68): the round's 30 findings, where a Voyag
- **The Bash guard knows one heredoc delimiter** (MINOR `8959d2cd`, `hooks/scripts/pre-bash-executor.mjs`; decision 88). It has no general rule for a quoted heredoc's closing line, so another heredoc in the commands, such as a stats record's, can still be closed early by a typed line equal to its delimiter.
- **A command that mentions the parser's heredoc opener with a line after it stays blocked** (fix round 4.5b part 2, decision 88). The guard checks every opener of `VOYAGE_ARGS_EOF_7F3C9E` that has a non-blank line after the opener's line, so such a command stays blocked unless it is a quoted heredoc whose closing line is the one last non-empty line, for example a commit message written as a heredoc that mentions the opener. Write it on one line, or read it from a file.
- **A `bash -c $'...'` command on one physical line is not checked** (fix round 4.5b part 2, decision 88). It carries no literal newline, so the guard sees no line after the opener even when `bash -c` expands it into a heredoc of the parser's delimiter; part 1 blocked it by accident. The shipped commands never produce it from `$ARGUMENTS`.
- **The tokenizer splits a quoted value at a quote of the same kind that starts a word** (TOK, decision 88). `'the '90s were great'` gives `'the` and `90s were great` (accepted by the operator on 2026-10-27, part 2's brief). The argv form's self-quoting of a word that holds spaces (`-- 'say "hi"'`) splits (accepted on 2026-10-28). Since part 3, a quoted value that ends in whitespace pairs again, so such a value may also hold what follows it up to a quote that ends a word: `--project "x --since " HEAD` gives `--project` = `x --since `.
- **The tokenizer splits a quoted value at a quote of the same kind that starts a word** (TOK, decision 88). `'the '90s were great'` gives `'the` and `90s were great` (accepted by the operator on 2026-10-27, part 2's brief). The argv form's self-quoting of a word that holds spaces (`-- 'say "hi"'`) splits (accepted on 2026-10-28). Since part 3, a quoted value that ends in whitespace pairs again, so such a value may also hold what follows it up to a quote that ends a word: `--project "x --since " HEAD` gives `--project` = `x --since `. Review finding `564d6dff` (decision 90) names this case: such a value can swallow a later flag with no error.
- **`gitEntryDir` does not follow git's `GIT_CEILING_DIRECTORIES` rules** (MAJOR `3b98d18b`, `lib/review/recurrence.mjs`; decision 89). It resolves the entries after an empty one through realpath, and it keeps relative entries and entries whose realpath fails. So a ceiling written that way can give `no-git-repo` and a written pointer where git sees a repository.
- **A failed checkpoint commit leaves `{hash}` on the previous step's commit** (MAJOR `69b086eb`, `commands/trekexecute.md`; decision 89). When the checkpoint commit fails, `{hash}` is the current HEAD, which is the previous step's commit. So the done line and the progress record name a commit without step N's changes.
- **Some typed arguments that hold `<<` are refused** (fix round 4.5b part 3, decision 89). A typed argument holding `<<` followed by a word the guard cannot read, other than through a quote with no partner (for example `<<$'\t'` or a backslash inside `"..."`), is refused by `/trekreview`, `/trekcontinue` and `/trekendsession`; write the argument another way.
- **Part 3 added a reader rule without a plan revision** (MAJOR `4a880f70`, `hooks/scripts/pre-bash-executor.mjs`; decision 90). Part 3's execution added the reader rule for a `$` followed by a backslash-newline and a quote (the word is unreadable) without a plan revision; it stays, and part 4 runs its pins under `npm test`.
- **Part 3 compares the opener's word after `trim()`** (MAJOR `fb643fb0`, `hooks/scripts/pre-bash-executor.mjs`; decision 90). Part 3's execution compares the opener's word after `trim()`, not exactly as its plan said, without a plan revision; it stays, pinned the same way.
- **An unreadable opener gets the unquoted reason** (MAJOR `73eed642`, `hooks/scripts/pre-bash-executor.mjs`; decision 90). An opener blocked because its word cannot be read is given the unquoted reason and told to open the heredoc quoted, also when its word is not the delimiter and the argument heredoc is already quoted; write the argument another way.
- **Part 3's entry did not name the false stops of a string-borne `<<`** (MAJOR `6f09b931`, `CHANGELOG.md`; decision 90). Part 4's SLT bullet lets most of them through, and a command whose string opens on an earlier line than its `<<`, for example a commit message heredoc whose quoted text spans two lines, is still refused.
- **An error inside the guard is reported as the command's fault** (MAJOR `161545f6`, `hooks/scripts/pre-bash-executor.mjs`; decision 90). An error inside the guard's own reading is reported as an unquoted opener whose word could not be read, which blames the command for the guard's fault.
- **The guard re-splits the command for each candidate opener** (MINOR `fe3fe55b`, `hooks/scripts/pre-bash-executor.mjs`; decision 90). For each candidate opener the guard re-splits the command to find its line, quadratic work on a large command.
### Doors
@ -184,6 +199,7 @@ Fix round 4.5a (decisions 57, 67 and 68): the round's 30 findings, where a Voyag
- two-way: fix round 4.5b (decisions 80 to 87) — a revert restores the old checks, texts and red script and takes out the Bash guard's heredoc rule; `review.md` keeps `review_version` 1.1 either way.
- two-way: fix round 4.5b part 2 (decision 88) — a revert restores part 1's four behaviours (the tokenizer's quote search, the guard's refusal of a one-line mention, the pointer written on any `git check-ignore` exit 128, the undefined `{hash}`) and the guard's first-opener-only check.
- two-way: fix round 4.5b part 3 (decision 89) — a revert restores part 2's closing-quote search (a value whose closing quote follows whitespace splits again) and the guard's regex-only opener check.
- two-way: fix round 4.5b part 4 (decision 90) — a revert restores part 3's literal-delimiter gate and its refusal of a heredoc operator inside a string or after a backslash; the catch-branch test and the TOK wording go with it.
## v6.5.0 — 2026-10-18 — Three skills for brief, criteria and review, English labels, and three trial-5 fixes

View file

@ -264,8 +264,10 @@ const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g,
// delimiter would close the heredoc there, and the shell would run every line
// after it as a command (fix round 4.5b, R6). This check reads the raw
// command: `normalized` and `commandView` collapse lines and drop heredoc
// bodies (hooks/lib/bash-view.mjs). A command that never names the delimiter
// skips the whole check.
// bodies (hooks/lib/bash-view.mjs). The check runs when the command names the
// delimiter once every backslash-newline is removed: the shell removes a
// continuation inside the opener's word, and an unquoted body with no closing
// line is still expanded (fix round 4.5b, part 4).
// Every opener in the command is read in two ways, and the command is blocked
// when either reading blocks it (the union):
// 1. A regex finds <<D, <<'D', <<"D" and <<\D, with an optional - and
@ -305,6 +307,23 @@ const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g,
// so a typed argument such as fix "<<" handling passes. Every other later
// opener is checked, because the accepted opener may sit inside a string and
// must not hide a real opener after it.
// Two kinds of << are text to the shell and are skipped (fix round 4.5b,
// part 4):
// - A cut word whose quote closes a string opened earlier on its line (an
// odd count of that quote from the line start to the opener's first <),
// as in grep -n '<<' or a commit message holding "<<". A cut word with
// no string open before it stays unreadable and is blocked as unquoted.
// The skip is safe: the quote has no partner, so the word's body is
// never read as a heredoc body, and each opener is read on its own, so
// skipping it hides no later opener.
// - A < after an odd run of backslashes (counted on the raw text, a line
// break ends the run) starts no opener, in the regex reading and in the
// shell reading alike, but only when no ', " or backquote and no <<
// stands before the run: each of those can make the shell read the
// backslashes once more (inside a backquote, or in an unquoted heredoc
// body fed to a shell, measured), so there the < is read as unescaped.
// The < after the escaped one is read as usual, so a << right after it
// is still checked.
// The shell reading fails closed: an error in it blocks (exit 2), because an
// exit 1 is a non-blocking error and the command would run.
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
@ -324,9 +343,37 @@ function blockArgs(unquoted, unreadable) {
process.exit(2);
}
// Where the backslash rule stops applying in text: the first ', " or
// backquote, and the end of the first << (continuations between the two <).
function argsEscapeLimit(text) {
const h = /<(?:\\\n)*</.exec(text);
return { quoteAt: text.search(/['"`]/), heredocEnd: h ? h.index + h[0].length : -1 };
}
// A < after an odd run of backslashes in the raw text (a line break ends the
// run) is a literal < to the shell, when no ', " or backquote and no <<
// stands before the run: inside quotes, backquotes or a heredoc body fed to
// a shell, a backslash may be read once more.
function argsEscapedLt(text, i, lim) {
let b = 0;
while (i - 1 - b >= 0 && text[i - 1 - b] === '\\') b += 1;
const s = i - b;
return b % 2 === 1 && (lim.quoteAt === -1 || lim.quoteAt >= s) &&
(lim.heredocEnd === -1 || lim.heredocEnd > s);
}
// A cut opener (its quote has no partner after it) whose quote closes a
// string opened earlier on the raw line holding the opener's first <: an
// odd count of that quote character from the line start to the <.
function argsCutInString(text, o) {
const q = text[o.end];
const lineStart = text.lastIndexOf('\n', o.index) + 1;
return text.slice(lineStart, o.index).split(q).length % 2 === 0;
}
// Each heredoc operator in `text`, in text order, as the shell reads its word:
// { index (the operator's first <), end, word, quoted, unreadable, cutOnly }.
function argsShellOpeners(text) {
function argsShellOpeners(text, lim = argsEscapeLimit(text)) {
const n = text.length;
// The index after any run of continuations that starts at k.
const skip = (k) => {
@ -336,6 +383,7 @@ function argsShellOpeners(text) {
const openers = [];
let i = text.indexOf('<');
while (i !== -1) {
if (argsEscapedLt(text, i, lim)) { i = text.indexOf('<', i + 1); continue; }
let next = i + 1;
let k = skip(i + 1);
if (text[k] === '<') {
@ -407,27 +455,30 @@ function argsShellOpeners(text) {
return openers;
}
if (command.includes(ARGS_DELIM)) {
const argsOpeners = command.matchAll(new RegExp(`<<-?\\s*(['"\\\\]?)${ARGS_DELIM}(['"]?)`, 'g'));
const argsLines = command.split('\n');
const argsClosing = argsLines.flatMap((l, i) => (l.trim() === ARGS_DELIM ? [i] : []));
const closesOnceAtEnd = argsClosing.length === 1 &&
argsLines.slice(argsClosing[0] + 1).every((line) => line.trim() === '');
for (const argsOpener of argsOpeners) {
// Some line after the opener's line is non-blank (\s is the set trim() strips).
const hasBody = /\n\s*\S/.test(command.slice(argsOpener.index));
if (!hasBody) continue;
const [, open, close] = argsOpener;
const quoted = open === '\\' ? close === '' : open !== '' && close === open;
if (!quoted || !closesOnceAtEnd) blockArgs(!quoted, false);
}
// The shell reading. The loop records the first failing verdict; an error
// anywhere in it blocks.
if (command.replace(/\\\n/g, '').includes(ARGS_DELIM)) {
// The regex reading and the shell reading. The shell reading's loop records
// the first failing verdict; an error anywhere in either reading blocks.
let argsVerdict = null;
try {
const argsLim = argsEscapeLimit(command);
const argsOpeners = command.matchAll(new RegExp(`<<-?\\s*(['"\\\\]?)${ARGS_DELIM}(['"]?)`, 'g'));
const argsLines = command.split('\n');
const argsClosing = argsLines.flatMap((l, i) => (l.trim() === ARGS_DELIM ? [i] : []));
const closesOnceAtEnd = argsClosing.length === 1 &&
argsLines.slice(argsClosing[0] + 1).every((line) => line.trim() === '');
for (const argsOpener of argsOpeners) {
if (argsEscapedLt(command, argsOpener.index, argsLim)) continue;
// Some line after the opener's line is non-blank (\s is the set trim() strips).
const hasBody = /\n\s*\S/.test(command.slice(argsOpener.index));
if (!hasBody) continue;
const [, open, close] = argsOpener;
const quoted = open === '\\' ? close === '' : open !== '' && close === open;
if (!quoted || !closesOnceAtEnd) blockArgs(!quoted, false);
}
let bodyFrom = Infinity;
for (const o of argsShellOpeners(command)) {
for (const o of argsShellOpeners(command, argsLim)) {
if (o.index >= bodyFrom && o.cutOnly) continue;
if (o.cutOnly && argsCutInString(command, o)) continue;
// The word is compared trimmed, as the body lines are: a word of D plus
// characters trim() strips (a CR from a CRLF line, a VT, an FF, a
// no-break space) is closed by a line equal to it, which trims to D.

View file

@ -82,7 +82,7 @@ PARSER_LINE='node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --
# ---- 1. TOK: the arg parser keeps a later flag ----
case_tok() {
name="arg-parser --stdin: a quoted value never swallows --since"
name="arg-parser --stdin: a quoted value stops at the quote that starts --since's value"
W="$TMP/args"
mkdir -p "$W" || { bad "$name: mkdir"; return; }
( cd "$W" && node "$ARGS" --command trekreview --stdin > "$TMP/args.json" ) <<'VOYAGE_ARGS_EOF_7F3C9E'

View file

@ -0,0 +1,219 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5b-part-4.sh
# End to end for fix round 4-5b, part 4 (plan
# .claude/projects/2026-10-31-fix-round-4-5b-part-4, End-to-end:, decision
# 90): the Bash hook (hooks/scripts/pre-bash-executor.mjs) reads commands the
# way a session sends them, and /bin/bash shows in a temp directory that each
# block is real and each pass is text to the shell.
# 1. CSG: a continuation-split unquoted opener with a body and no closing
# line is blocked as unquoted; /bin/bash expands its body.
# 2. SLT: a grep pattern '<<' on a line that names the delimiter, and a
# string holding << before the shipped argument heredoc, pass.
# 3. ESC: a backslash-escaped < starts no opener: the hook passes it, and
# /bin/bash runs the next line as a command.
# 4. ESC, the heredoc fed to a shell: an odd run of backslashes after a <<
# is still blocked as unquoted; /bin/bash expands the inner body.
# 5. The shipped form still passes: the parser block of
# commands/trekreview.md (## Phase 1), filled with the repo root and the
# typed argument fix "<<" handling.
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per case and exits 1
# when any case failed, else 0. Starts nothing but node and /bin/bash (and the
# usual file tools). bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
ROOT="$(pwd)"
HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs"
unset CLAUDE_CODE_SESSION_ID
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b-part-4.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
trap 'rm -rf "$TMP"' EXIT
TMP="$(cd "$TMP" && pwd -P)" || { echo "FAIL - setup: cannot enter the temp directory"; exit 1; }
export LC_ALL=C
export LANG=C
FAILED=0
pass() { echo "ok - $1"; }
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
# hookrun <out> <line>...: the Bash hook with a Bash tool input whose command
# is the given lines joined by newlines; stderr goes to <out>. Returns the
# hook's exit status.
hookrun() {
out="$1"
shift
node -e 'process.stdout.write(JSON.stringify({ tool_name: "Bash", tool_input: { command: process.argv.slice(1).join("\n") } }));' "$@" \
| node "$HOOK" > /dev/null 2> "$out"
}
# hookfile <out> <file>: the Bash hook with the file's text as the command.
hookfile() {
node -e 'process.stdout.write(JSON.stringify({ tool_name: "Bash", tool_input: { command: require("fs").readFileSync(process.argv[1], "utf8") } }));' "$2" \
| node "$HOOK" > /dev/null 2> "$1"
}
D=VOYAGE_ARGS_EOF_7F3C9E
PARSER_LINE='node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin'
# blocked <name> <out> <line>...: the hook exits 2 and names args-heredoc-closed-early.
blocked() {
bname="$1"
bout="$2"
shift 2
hookrun "$bout" "$@"
c=$?
if [ "$c" -ne 2 ]; then
bad "$bname: the hook exited $c, not 2: $(cat "$bout")"
return 1
elif ! grep -q 'args-heredoc-closed-early' "$bout"; then
bad "$bname: the hook did not name args-heredoc-closed-early: $(cat "$bout")"
return 1
fi
return 0
}
# unquoted <name> <out> <line>...: blocked, with the unquoted reason.
unquoted() {
qname="$1"
uout="$2"
shift 2
blocked "$qname" "$uout" "$@" || return 1
if ! grep -q "opens $D unquoted" "$uout"; then
bad "$qname: the hook did not give the unquoted reason: $(cat "$uout")"
return 1
fi
return 0
}
# allowed <name> <out> <line>...: the hook exits 0.
allowed() {
aname="$1"
aout="$2"
shift 2
hookrun "$aout" "$@"
c=$?
if [ "$c" -ne 0 ]; then
bad "$aname: the hook exited $c: $(cat "$aout")"
return 1
fi
return 0
}
# premise <dir> <script> <out> <line>...: writes the lines to <script> and
# runs it with /bin/bash in <dir>; stdout and stderr go to <out>.
premise() {
pdir="$1"
pscript="$2"
pout="$3"
shift 3
mkdir -p "$pdir" || return 1
printf '%s\n' "$@" > "$pscript" || return 1
( cd "$pdir" && /bin/bash "$pscript" ) > "$pout" 2>&1 < /dev/null
return 0
}
# ---- 1. CSG ----
case_csg() {
name='CSG: a continuation-split unquoted opener with a body and no closing line is blocked as unquoted'
L1='cat <<VOYAGE_ARGS\'
L2='_EOF_7F3C9E'
L3='$(touch "'"$TMP"'/ran-csg")'
unquoted "$name" "$TMP/csg.err" "$L1" "$L2" "$L3" || return
premise "$TMP/csg" "$TMP/csg.sh" "$TMP/csg.out" "$L1" "$L2" "$L3" \
|| { bad "$name: could not write the premise script"; return; }
if [ ! -e "$TMP/ran-csg" ]; then
bad "$name: /bin/bash did not expand the body: $(cat "$TMP/csg.out")"
else
pass "$name (exit 2; /bin/bash expands the body, so the block is not a false positive)"
fi
}
# ---- 2. SLT ----
case_slt() {
name="SLT: a grep pattern '<<' that names the delimiter, and a string holding << before the shipped heredoc, pass"
allowed "$name (grep)" "$TMP/slt-1.err" "grep -n '<<' file.md | grep $D" 'echo done' || return
allowed "$name (string)" "$TMP/slt-2.err" "echo \"a<<b\" && $PARSER_LINE <<'$D'" 'brief.md' "$D" || return
pass "$name (exit 0 for each)"
}
# ---- 3. ESC ----
case_esc() {
name='ESC: a backslash-escaped < starts no opener'
L1='echo \<<'"$D"
L2='echo next'
allowed "$name" "$TMP/esc.err" "$L1" "$L2" || return
premise "$TMP/esc" "$TMP/esc.sh" "$TMP/esc.out" "$L1" "$L2" \
|| { bad "$name: could not write the premise script"; return; }
if ! grep -qx 'next' "$TMP/esc.out"; then
bad "$name: /bin/bash did not run the second line as a command: $(cat "$TMP/esc.out")"
else
pass "$name (exit 0; /bin/bash prints next, so no heredoc took the second line)"
fi
}
# ---- 4. ESC, the heredoc fed to a shell ----
case_esc_fed() {
name='ESC: an odd run of backslashes after a << in a heredoc fed to a shell is still blocked as unquoted'
L1='/bin/bash <<EOF'
L2='cat - \\\<<'"$D"
L3='\$(touch "'"$TMP"'/ran-esc")'
L4="$D"
L5='EOF'
unquoted "$name" "$TMP/esc-fed.err" "$L1" "$L2" "$L3" "$L4" "$L5" || return
premise "$TMP/esc-fed" "$TMP/esc-fed.sh" "$TMP/esc-fed.out" "$L1" "$L2" "$L3" "$L4" "$L5" \
|| { bad "$name: could not write the premise script"; return; }
if [ ! -e "$TMP/ran-esc" ]; then
bad "$name: /bin/bash did not expand the inner body: $(cat "$TMP/esc-fed.out")"
else
pass "$name (exit 2; /bin/bash expands the inner body, so the block is not a false positive)"
fi
}
# ---- 5. The shipped form still passes ----
case_shipped() {
name='shipped: the trekreview Phase 1 parser block with the typed argument fix "<<" handling passes'
# The block as written, with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in
# the way Claude Code fills them.
node -e '
const fs = require("fs");
const [md, root, args] = process.argv.slice(1);
const text = fs.readFileSync(md, "utf8");
const s = text.indexOf("## Phase 1 ");
if (s === -1) process.exit(3);
const rest = text.slice(s);
const e = rest.search(/\n## /);
const phase = e === -1 ? rest : rest.slice(0, e);
const blocks = phase.split("\n```bash\n").slice(1).map((b) => b.split("\n```")[0]).filter((b) => b.includes("<<" + String.fromCharCode(39) + "VOYAGE_ARGS_EOF_7F3C9E" + String.fromCharCode(39)));
if (blocks.length !== 1) { console.error("expected one Phase 1 parser block, found " + blocks.length); process.exit(3); }
process.stdout.write(blocks[0].split("${CLAUDE_PLUGIN_ROOT}").join(root).split("$ARGUMENTS").join(args) + "\n");
' "$ROOT/commands/trekreview.md" "$ROOT" 'fix "<<" handling' > "$TMP/phase1.sh" \
|| { bad "$name: could not read the Phase 1 block"; return; }
hookfile "$TMP/shipped.err" "$TMP/phase1.sh"
c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the hook exited $c: $(cat "$TMP/shipped.err")"
else
pass "$name (exit 0)"
fi
}
case_csg
case_slt
case_esc
case_esc_fed
case_shipped
if [ "$FAILED" -ne 0 ]; then
echo "e2e: $FAILED case(s) failed"
exit 1
fi
echo "e2e: ok"
exit 0

View file

@ -0,0 +1,52 @@
// tests/fix-round-4-5b-part-4-mutations.mjs
// The mutations of fix round 4-5b, part 4 (decision 90), for the shared red
// script (tests/helpers/red-round.sh, through tests/helpers/red-mutate.mjs).
// An id named here has tests that pass at the round's start point 591f3c4 and
// is proven red under its mutations instead:
// - CTB: the catch of the shell reading already blocks at 591f3c4 (the
// round adds only its test, through a preload); the mutation makes the
// catch let the command through, in 591f3c4's hook.
// - NVR: the [TOK] title and part 2's case name are already reworded in the
// working tree's tests/ that the script copies into the clone; each
// mutation puts the old "never" wording back into one of the two files.
// Every span must match exactly once in the clone (591f3c4's code with the
// working tree's tests/). Data only; never matched by `npm test`.
export const MUTATIONS = [
{
key: 'CTB-catch',
id: 'CTB',
what: 'the catch of the shell reading lets the command through',
spans: [
{
file: 'hooks/scripts/pre-bash-executor.mjs',
find: 'blockArgs(true, true);',
replace: 'void 0;',
},
],
},
{
key: 'NVR-title',
id: 'NVR',
what: 'the [TOK] title claims never again',
spans: [
{
file: 'tests/lib/arg-parser-tokenize.test.mjs',
find: '[TOK] a quoted value stops at a quote that starts a word, and',
replace: '[TOK] a quoted value never swallows a later flag, and',
},
],
},
{
key: 'NVR-e2e',
id: 'NVR',
what: "part 2's case name claims never again",
spans: [
{
file: 'tests/e2e-fix-round-4-5b-part-2.sh',
find: "a quoted value stops at the quote that starts --since's value",
replace: 'a quoted value never swallows --since',
},
],
},
];

View file

@ -0,0 +1,57 @@
#!/usr/bin/env bash
# tests/fix-round-4-5b-part-4-red.sh
# The red proof of fix round 4-5b, part 4 (plan
# .claude/projects/2026-10-31-fix-round-4-5b-part-4, decision 90), as a
# wrapper over the shared red script tests/helpers/red-round.sh. Every id of
# the round has tests whose names start with "[id]"; CSG, SLT, ESC and KI4
# must each fail on an assertion at the round's start point 591f3c4, and CTB
# and NVR, whose tests pass there, are proven red under their mutations.
# This wrapper passes the shared script:
# --base BASE, --ids IDS (the round's 6 ids), the round's FILES (the
# three tagged test files; the untagged reader pins stay out);
# --other-ids: the ids of fix round 4-5a, of 4-5b part 1, of part 2 and of
# part 3, read with sed from the IDS= lines of tests/fix-round-4-5a-red.sh,
# tests/fix-round-4-5b-red.sh, tests/fix-round-4-5b-part-2-red.sh and
# tests/fix-round-4-5b-part-3-red.sh and passed as ONE value, in that
# order (the shared script keeps only the last --other-ids it is given);
# a tag on that list is not judged;
# --mutations tests/fix-round-4-5b-part-4-mutations.mjs (CTB-catch,
# NVR-title, NVR-e2e).
# No --require-sha256: the round has no SHA-256 test.
# Exit 0 all red, 1 a rule broken (the judge names it), 2 a setup failure
# (also: no ids on one of the four older IDS= lines); never 0 on a setup
# failure.
# bash 3.2 compatible, ASCII only, no arrays.
set -u
set -f
cd "$(dirname "$0")/.." || { echo "red script: setup failure: cannot cd to the repo root" >&2; exit 2; }
ROOT="$(pwd -P)"
BASE=591f3c4793c751ae3c12fae403b9735341cd7591
IDS='CSG SLT ESC CTB KI4 NVR'
FILES='tests/hooks/bash-guard-args-gate.test.mjs tests/hooks/bash-guard-false-stops.test.mjs tests/lib/fix-round-4-5b-part-4-prose.test.mjs'
OTHER_A=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5a-red.sh")
if [ -z "$OTHER_A" ]; then
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5a-red.sh" >&2
exit 2
fi
OTHER_B=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5b-red.sh")
if [ -z "$OTHER_B" ]; then
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5b-red.sh" >&2
exit 2
fi
OTHER_C=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5b-part-2-red.sh")
if [ -z "$OTHER_C" ]; then
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5b-part-2-red.sh" >&2
exit 2
fi
OTHER_D=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5b-part-3-red.sh")
if [ -z "$OTHER_D" ]; then
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5b-part-3-red.sh" >&2
exit 2
fi
exec /bin/bash "$ROOT/tests/helpers/red-round.sh" --base "$BASE" --ids "$IDS" --other-ids "$OTHER_A $OTHER_B $OTHER_C $OTHER_D" --mutations "$ROOT/tests/fix-round-4-5b-part-4-mutations.mjs" $FILES

View file

@ -0,0 +1,51 @@
#!/usr/bin/env bash
# tests/fix-round-4-5b-part-4-regression.sh
# The Regression: of fix round 4-5b, part 4 (decision 90): the whole suite
# (npm test), part 4's end to end (tests/e2e-fix-round-4-5b-part-4.sh),
# part 3's (tests/e2e-fix-round-4-5b-part-3.sh), part 2's
# (tests/e2e-fix-round-4-5b-part-2.sh), part 1's (tests/e2e-fix-round-4-5b.sh)
# and part 4's red proof (tests/fix-round-4-5b-part-4-red.sh). The 4-5a,
# part-1, part-2 and part-3 red proofs are not run here: one Regression
# command has the criteria runner's 15-minute cap; they run as their own
# Verification lines.
# All six run, in that order; the script prints each exit and exits with the
# first non-zero status, or 0 when all six exit 0.
# bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "regression: cannot cd to the repo root" >&2; exit 2; }
FIRST=0
npm test
rc=$?
echo "regression: npm test exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
/bin/bash tests/e2e-fix-round-4-5b-part-4.sh
rc=$?
echo "regression: tests/e2e-fix-round-4-5b-part-4.sh exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
/bin/bash tests/e2e-fix-round-4-5b-part-3.sh
rc=$?
echo "regression: tests/e2e-fix-round-4-5b-part-3.sh exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
/bin/bash tests/e2e-fix-round-4-5b-part-2.sh
rc=$?
echo "regression: tests/e2e-fix-round-4-5b-part-2.sh exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
/bin/bash tests/e2e-fix-round-4-5b.sh
rc=$?
echo "regression: tests/e2e-fix-round-4-5b.sh exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
/bin/bash tests/fix-round-4-5b-part-4-red.sh
rc=$?
echo "regression: tests/fix-round-4-5b-part-4-red.sh exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
exit "$FIRST"

View file

@ -0,0 +1,104 @@
// tests/hooks/bash-guard-args-gate.test.mjs
// [CSG] [CTB] Fix round 4.5b, part 4 (decision 90).
// Defects: the args-heredoc guard in hooks/scripts/pre-bash-executor.mjs ran
// its whole check only when the command held the delimiter literally, so an
// unquoted opener whose delimiter is split by a backslash-newline, with a body
// and no closing line, was never read: the guard exited 0 and the shell
// expanded the body (CSG). And the branch that makes the guard fail closed
// when its own shell reading throws had no test (CTB).
// 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. The guard is only
// spawned, so this file loads at 591f3c4 and its red is an assertion.
import { test, after } from 'node:test';
import { strict as assert } from 'node:assert';
import { writeFileSync, rmSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { runHook, runHookWithEnv } from '../helpers/hook-helper.mjs';
import { tmp } 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) {
const { code, stderr } = await runHook(PRE_BASH, 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) {
const { code, stderr } = await runHook(PRE_BASH, 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) {
const stderr = await expectArgsBlocked(command);
assert.match(stderr, UNQUOTED, `the guard did not give the unquoted reason for: ${JSON.stringify(command)}`);
}
const D = ARGS_DELIM;
test('[CSG] the guard checks an opener whose delimiter is split by a backslash-newline, with a body and no closing line', async () => {
// Red: the command never holds the delimiter literally; the shell removes
// the continuation, reads the delimiter as the word and expands the body.
await expectUnquoted(`${PARSER_LINE} <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED)`);
await expectUnquoted(`${PARSER_LINE} <<VOYAGE_ARGS_EOF\\\n_7F3C9E\nbrief.md $(touch PWNED)`);
// Control: an ordinary unquoted heredoc that names no delimiter passes.
await expectArgsAllowed('cat <<EOF\n$(date)\nEOF');
});
// The preload makes String.prototype.indexOf throw when it is called with '<'
// from inside argsShellOpeners, so the shell reading throws. It is written
// into a temp directory, never under tests/, and only makes the hook block.
const PRELOAD = `const orig = String.prototype.indexOf;
String.prototype.indexOf = function (search, ...rest) {
if (search === '<' && /argsShellOpeners/.test(new Error().stack)) {
process.stderr.write('[ctb-preload] thrown\\n');
throw new Error('CTB forced read error');
}
return orig.call(this, search, ...rest);
};
`;
const preloadDir = tmp('voyage-ctb-');
after(() => rmSync(preloadDir, { recursive: true, force: true }));
test('[CTB] an error inside the shell reading blocks the command (exit 2), never lets it through', async () => {
const file = join(preloadDir, 'ctb-preload.mjs');
writeFileSync(file, PRELOAD);
const forced = { NODE_OPTIONS: '--import=' + pathToFileURL(file).href };
const shipped = `${PARSER_LINE} <<'${D}'\nbrief.md\n${D}`;
// Red, forced: the shipped heredoc under the preload is blocked by the catch.
const { code, stderr } = await runHookWithEnv(PRE_BASH, bashInput(shipped), forced);
assert.ok(stderr.includes('[ctb-preload] thrown'), `the preload did not throw inside the shell reading: ${stderr}`);
assert.strictEqual(code, 2, `the guard did not block when its shell reading threw (exit ${code}): ${stderr}`);
assert.match(stderr, ARGS_RULE, `the guard did not name args-heredoc-closed-early: ${stderr}`);
assert.ok(stderr.includes("The guard could not read the opener's word"), `the could-not-read suffix is missing: ${stderr}`);
assert.ok(!stderr.includes('CTB forced read error'), `the error was not caught (an uncaught throw exits 1): ${stderr}`);
// Control: the same command with no preload passes, so the block can come
// only from the catch.
await expectArgsAllowed(shipped);
// Control: a command with no delimiter passes under the preload.
const plain = await runHookWithEnv(PRE_BASH, bashInput('ls -la'), forced);
assert.strictEqual(plain.code, 0, `ls -la was refused under the preload: ${plain.stderr}`);
});

View file

@ -0,0 +1,133 @@
// tests/hooks/bash-guard-false-stops.test.mjs
// [SLT] [ESC] Fix round 4.5b, part 4 (decision 90).
// Defects: the args-heredoc guard in hooks/scripts/pre-bash-executor.mjs
// refused commands in which the shell reads a << as text. A << inside a
// string, whose cutting quote closes that string, was read as an unreadable
// opener and blocked (SLT). A backslash-escaped < was read as the start of an
// opener (ESC); and the shell reading took an escaped < plus the real << for a
// here-string, so a real opener after it was not checked. The guard must skip
// both, and still check every real opener around them.
// The helpers ARGS_DELIM, ARGS_RULE, PARSER_LINE, bashInput,
// expectArgsBlocked, expectArgsAllowed, UNQUOTED and expectUnquoted are copied
// from tests/hooks/bash-guard-args-gate.test.mjs:27-54, not imported, because
// importing a test file would run its tests. The guard is only spawned, so
// this file loads at 12bca8b and its red is an assertion.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runHook } from '../helpers/hook-helper.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) {
const { code, stderr } = await runHook(PRE_BASH, 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) {
const { code, stderr } = await runHook(PRE_BASH, 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) {
const stderr = await expectArgsBlocked(command);
assert.match(stderr, UNQUOTED, `the guard did not give the unquoted reason for: ${JSON.stringify(command)}`);
}
// The closed-early reason: a line equal to the closing line, then more text.
const CLOSED_EARLY = /hold a line equal to the heredoc's closing line/;
async function expectClosedEarly(command) {
const stderr = await expectArgsBlocked(command);
assert.match(stderr, CLOSED_EARLY, `the guard did not give the closed-early reason for: ${JSON.stringify(command)}`);
assert.doesNotMatch(stderr, UNQUOTED, `the guard gave the unquoted reason for: ${JSON.stringify(command)}`);
}
const D = ARGS_DELIM;
// The body line of the forms with a << after an escaped <.
const HDBODY = 'HDBODY #$(touch PWNED1)';
// The body line and the closing line of the ESC controls.
const BODY = `\nbrief.md $(touch PWNED)\n${D}`;
test('[SLT] a commit message whose string holds "<<" passes', async () => {
// Red: the << sits in a "..." string; its cutting " closes that string.
await expectArgsAllowed(`git commit -F - <<'EOF'\nfix: let fix "<<" handling pass for ${D}\n\nbody\nEOF`);
// Control: no string is open before the cut quote (the regex reading blocks it too).
await expectUnquoted(`${PARSER_LINE} <<"${D}\nbrief.md`);
// Control: an even, non-zero count of " before the <, a spelling the regex
// reading cannot see, stays blocked.
await expectUnquoted(`echo "x" && ${PARSER_LINE} <<V"OYAGE_ARGS_EOF_7F3C9E\nbrief.md\n${D}`);
});
test("[SLT] a grep pattern '<<' on a line that names the delimiter passes", async () => {
// Red: the << sits in a '...' string; its cutting ' closes that string.
await expectArgsAllowed(`grep -n '<<' file.md | grep ${D}\necho done`);
});
test('[SLT] a file write whose body holds "<<" in a string passes', async () => {
// Red: a heredoc body line holds "<<" in a string.
await expectArgsAllowed(`cat <<'EOF' > t.mjs\nconst s = "<<";\nconst d = '${D}';\nEOF`);
});
test('[SLT] a string holding << before the shipped argument heredoc passes, and hides no later opener', async () => {
// Red: the string's << is text; the shipped heredoc after it passes.
await expectArgsAllowed(`echo "a<<b" && ${PARSER_LINE} <<'${D}'\nbrief.md\n${D}`);
// Control: the skipped string hides no continuation-split unquoted opener after it.
await expectUnquoted(`echo "a<<b" && ${PARSER_LINE} <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED)\n${D}`);
});
test('[ESC] a backslash-escaped < starts no opener, and every real opener around it is still checked', async () => {
// Red: \<< is a literal < and a lone <; the next line runs as a command.
await expectArgsAllowed(`echo \\<<${D}\necho next`);
// Red: the << after an escaped < is read as usual, so each is blocked.
await expectUnquoted(`cat \\<<<VOYAGE_ARGS\\\n_EOF_7F3C9E\n${HDBODY}`);
await expectUnquoted(`cat \\<\\\n<<VOYAGE_ARGS\\\n_EOF_7F3C9E\n${HDBODY}`);
await expectUnquoted(`cat \\<<\\\n<${D}\n${HDBODY}\n${D}`);
await expectUnquoted(`cat \\<<<VOYAGE_ARGS\\\n_EOF\\\n_7F3C9E\n${HDBODY}\n${D}`);
await expectClosedEarly(`cat \\<<<V"OYAGE_ARGS_EOF_7F3C9E"\nx\n${D}\ntouch PWNED2`);
// Control: the opener line, the body line and the closing line.
// An even run of backslashes: the < is not escaped.
await expectUnquoted(`echo \\\\<<${D}${BODY}`);
// A quote right before the <, no backslash run.
await expectUnquoted(`echo '\\'<<${D}${BODY}`);
// The same, continuation-split.
await expectUnquoted(`echo '\\'<<VOYAGE_ARGS\\\n_EOF_7F3C9E${BODY}`);
// A ' before an odd run: the rule is off.
await expectUnquoted(`echo 'x' && echo \\<<${D}${BODY}`);
// The << after the escaped <.
await expectUnquoted(`echo \\<<<${D}${BODY}`);
// A raw run of two: a line break ends the run, a continuation does not lengthen it.
await expectUnquoted(`echo a\\\\\n\\\\<<${D}${BODY}`);
// Control: exactly the lines given.
// A " before an odd run of three: inside "..." \< keeps its backslash, so
// the inner shell opens an unquoted heredoc.
await expectUnquoted(`bash -c "cat \\\\\\<<${D}\n\\$(touch PWNED)\n${D}"`);
// A backquote before an odd run of three.
await expectUnquoted(`x=\`cat - \\\\\\<<${D}\n$(echo EXPANDED)\n${D}\``);
// A heredoc fed to a shell: the outer heredoc strips one backslash level
// (operator decision 2026-10-31).
await expectUnquoted(`bash <<EOF\ncat - \\\\\\<<${D}\n\\$(touch PWNED)\n${D}\nEOF`);
});

View file

@ -0,0 +1,193 @@
// 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: <<VOYAGE$ + backslash-newline + '_ARGS_EOF_7F3C9E' that closes early is blocked",
lines: [`${PARSER_LINE} <<VOYAGE$\\`, "'_ARGS_EOF_7F3C9E'", 'brief.md', D, 'touch PWNED'],
expect: 'blocked',
cr: 0,
vt: 0,
},
{
n: 3,
name: 'Bash hook: a partly quoted opener with CRLF lines that closes early is blocked',
lines: [`${PARTLY}${CR}`, `brief.md${CR}`, `${D}${CR}`, `touch PWNED${CR}`],
expect: 'blocked',
cr: 4,
vt: 0,
},
{
n: 4,
name: 'Bash hook: a partly quoted opener whose word ends in a VT, closed by D + VT, is blocked',
lines: [`${PARTLY}${VT}`, 'brief.md', `${D}${VT}`, 'touch PWNED'],
expect: 'blocked',
cr: 0,
vt: 2,
},
{
n: 5,
name: 'Bash hook: a partly quoted opener with CRLF lines and the closing line last passes',
lines: [`${PARTLY}${CR}`, `brief.md${CR}`, `${D}${CR}`],
expect: 'allowed',
cr: 3,
vt: 0,
},
];
const commandOf = (pin) => 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: expectPasses,
},
{
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: expectPasses,
},
{
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);
});
}

View file

@ -4,7 +4,7 @@ import { parseArgs, tokenize } from '../../lib/parsers/arg-parser.mjs';
const values = (text) => tokenize(text).map((t) => t.value);
test('[TOK] a quoted value never swallows a later flag, and an apostrophe inside a word keeps its value', () => {
test('[TOK] a quoted value stops at a quote that starts a word, and an apostrophe inside a word keeps its value', () => {
const text = '--project "my dir"/x --since "HEAD~1"';
assert.equal(parseArgs(text, 'trekreview').flags['--since'], 'HEAD~1',
'the quote after `dir` is followed by `/`, so it does not close; the search stops at the quote that starts `"HEAD~1"`');

View file

@ -0,0 +1,149 @@
// tests/lib/fix-round-4-5b-part-4-prose.test.mjs
// Prose pins for fix round 4-5b, part 4 (plan
// .claude/projects/2026-10-31-fix-round-4-5b-part-4, decision 90): the fixes
// that are text a session reads. The tag in brackets is the part-4 id from the
// round's brief; the tagged test fails on an assertion at 591f3c4, part 3's end
// point. The keep-behaviour checks sit inside the tagged test as "Control:"
// assertions.
// KI4 the CHANGELOG lists part 4's fixes (CSG, SLT, ESC, CTB) under ### Fixed,
// its door under ### Doors, the review's other findings under
// ### Known issues, and the TOK headline no longer claims `never`.
// NVR the [TOK] test title and part 2's end-to-end case name no longer claim
// `never`.
// No static import of a new export: the file must load at 591f3c4.
// Fix the SOURCE a pin guards, not the pin.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const readCache = new Map();
/** A repo file's text; the file must exist. Read from disk once per run. */
function read(rel) {
if (!readCache.has(rel)) {
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
readCache.set(rel, readFileSync(join(ROOT, rel), 'utf8'));
}
return readCache.get(rel);
}
/** Whitespace (line breaks too) collapsed to one space. */
const flat = (s) => s.replace(/\s+/g, ' ').trim();
// --- KI4: the round's CHANGELOG entry ----------------------------------------------
// Part 4 stands in the same Unreleased section as parts 1 to 3. The section is
// found by its title, by the rule tests/lib/fix-round-4-5b-part-3-prose.test.mjs
// uses (copied, not imported): `## Unreleased — <title>` before the release,
// `## vX.Y.Z — <date> — <title>` after it, exactly once, up to the next `## `.
const CHANGELOG_TITLE = "The annotating skill, the run coupling and P10's model-free part";
const CHANGELOG_HEADING = new RegExp(`^## (?:Unreleased|v\\d+\\.\\d+\\.\\d+ — \\d{4}-\\d{2}-\\d{2}) — ${CHANGELOG_TITLE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
/** The lines of the round's CHANGELOG section, heading excluded. */
function changelogSection() {
const lines = read('CHANGELOG.md').split('\n');
const at = [];
lines.forEach((l, i) => { if (CHANGELOG_HEADING.test(l.replace(/\s+$/, ''))) at.push(i); });
assert.equal(at.length, 1, `CHANGELOG.md must hold the "${CHANGELOG_TITLE}" section exactly once (found ${at.length})`);
let end = lines.length;
for (let i = at[0] + 1; i < lines.length; i++) if (/^## /.test(lines[i])) { end = i; break; }
return lines.slice(at[0] + 1, end);
}
/** The bullets of one `### ` subsection of the round's CHANGELOG section, each flattened to one line. */
function changelogBullets(heading) {
const lines = changelogSection();
const s = lines.findIndex((l) => l.replace(/\s+$/, '') === heading);
assert.ok(s !== -1, `the "${CHANGELOG_TITLE}" section of CHANGELOG.md must have "${heading}"`);
const out = [];
for (const l of lines.slice(s + 1)) {
if (/^### /.test(l)) break;
if (/^- /.test(l)) out.push(l);
else if (out.length && l.trim() !== '' && /^\s/.test(l)) out[out.length - 1] += ` ${l}`;
else if (l.trim() !== '') out.push('');
}
return out.filter((b) => b !== '').map(flat);
}
// The review's four MAJOR findings part 4 fixes, with their tags, and the
// review's other findings it lists as open; the ids are the review's, written
// out here.
const FIXED = [['(CSG)', '3a34adbe'], ['(SLT)', 'ad8ad828'], ['(ESC)', '797784e0'], ['(CTB)', '1658fc64']];
const OPEN_IDS = ['4a880f70', 'fb643fb0', '73eed642', '6f09b931', '161545f6', 'fe3fe55b'];
const NEVER = /\bnever\b/i;
test('[KI4] the CHANGELOG lists part 4\'s four fixes, the Doors line and a TOK headline without never', () => {
const fixed = changelogBullets('### Fixed');
// Red: one ### Fixed bullet per part-4 tag, each naming its review finding.
for (const [tag, id] of FIXED) {
const hits = fixed.filter((b) => b.includes(tag) && b.includes(id));
assert.equal(hits.length, 1, `### Fixed must hold exactly one bullet marked ${tag} naming ${id}; found ${hits.length}`);
}
// Red: a door for the round.
const doors = changelogBullets('### Doors').filter((b) => b.includes('decision 90'));
assert.equal(doors.length, 1, `### Doors must hold one bullet naming decision 90; found ${doors.length}`);
// Control: exactly one Fixed bullet is marked (TOK).
const tok = fixed.filter((b) => b.includes('(TOK)'));
assert.equal(tok.length, 1, `Control: ### Fixed must hold exactly one bullet marked (TOK); found ${tok.length}`);
// Red: its bold headline no longer claims `never`.
const head = /^- \*\*(.+?)\*\*/.exec(tok[0]);
assert.ok(head, `the ### Fixed bullet marked (TOK) must open with a bold headline: ${tok[0]}`);
assert.ok(!NEVER.test(head[1]), `the (TOK) headline must not hold the word never: ${head[1]}`);
// Control: no fixed finding is listed as open.
const known = changelogBullets('### Known issues');
for (const [, id] of FIXED) {
assert.deepEqual(known.filter((b) => b.includes(id)), [], `Control: the fixed finding ${id} must not stand under ### Known issues`);
}
});
test('[KI4] the CHANGELOG lists the review\'s other findings as known issues', () => {
const known = changelogBullets('### Known issues');
// Red: each open finding has exactly one Known-issues bullet, and that bullet names decision 90.
for (const id of OPEN_IDS) {
const hits = known.filter((b) => b.includes(id));
assert.equal(hits.length, 1, `### Known issues must hold exactly one bullet naming ${id}; found ${hits.length}`);
assert.ok(hits[0].includes('decision 90'), `the Known-issues bullet naming ${id} does not say decision 90: ${hits[0]}`);
}
// Red: the tokenizer bullet names review finding 564d6dff.
const tok = known.filter((b) => b.includes("'90s"));
assert.equal(tok.length, 1, `### Known issues must hold exactly one bullet holding '90s; found ${tok.length}`);
assert.ok(tok[0].includes('564d6dff'), `the tokenizer Known-issues bullet must name review finding 564d6dff: ${tok[0]}`);
// Control: the tokenizer bullet keeps what part 3 pinned.
for (const part of ['say "hi"', 'x --since ', '2026-10-27', '2026-10-28']) {
assert.ok(tok[0].includes(part), `Control: the tokenizer Known-issues bullet must hold ${JSON.stringify(part)}: ${tok[0]}`);
}
for (const gone of ['"a "', 'accepted these']) {
assert.ok(!tok[0].includes(gone), `Control: the tokenizer Known-issues bullet must not hold ${JSON.stringify(gone)}: ${tok[0]}`);
}
});
// --- NVR: the [TOK] test title and part 2's case name ------------------------------
test('[NVR] the [TOK] test title and part 2\'s case name no longer claim never', () => {
// The one test title in the tokenizer tests that starts with [TOK], read as text.
const titles = [...read('tests/lib/arg-parser-tokenize.test.mjs').matchAll(/test\('\[TOK\] ([^']*)'/g)].map((m) => m[1]);
assert.equal(titles.length, 1, `tests/lib/arg-parser-tokenize.test.mjs must hold exactly one test title starting with [TOK]; found ${titles.length}`);
assert.ok(!NEVER.test(titles[0]), `the [TOK] test title must not hold the word never: ${titles[0]}`);
// The first name=" line after `case_tok() {` in part 2's end-to-end script.
const lines = read('tests/e2e-fix-round-4-5b-part-2.sh').split('\n');
const starts = lines.map((l, i) => (l.trim() === 'case_tok() {' ? i : -1)).filter((i) => i !== -1);
assert.equal(starts.length, 1, `tests/e2e-fix-round-4-5b-part-2.sh must define case_tok() exactly once; found ${starts.length}`);
const names = lines.slice(starts[0] + 1).filter((l) => /^\s*name="/.test(l));
assert.ok(names.length >= 1, 'case_tok() in tests/e2e-fix-round-4-5b-part-2.sh must set name=');
assert.ok(!NEVER.test(names[0]), `the name= line of case_tok() must not hold the word never: ${names[0]}`);
});

View file

@ -0,0 +1,139 @@
// tests/lib/fix-round-4-5b-part-4-scripts.test.mjs
// The form of fix round 4-5b part 4's shell scripts and mutation module (plan
// .claude/projects/2026-10-31-fix-round-4-5b-part-4, Step 4d): the red
// wrapper, the regression script, the end-to-end run and the mutations.
// Untagged on purpose and listed in no red wrapper's FILES: these checks are
// not a part-4 fix.
// - each script exists, is ASCII only, passes /bin/bash -n and uses no
// bash 4 form;
// - the red wrapper hands the shared red script exactly one --other-ids,
// whose words are the 4-5a IDS= words, then part 1's, then part 2's, then
// part 3's, and --ids CSG SLT ESC CTB KI4 NVR (run from a temp root next
// to a stub red-round.sh that prints its arguments, the method of
// tests/lib/red-script.test.mjs);
// - with the 4-5a, the part-1, the part-2 or the part-3 wrapper lacking its
// IDS= line, the red wrapper exits 2 with a setup failure and never runs
// the shared script;
// - red-mutate.mjs list prints exactly the three part-4 mutations.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } 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-5b-part-4-red.sh';
const REGRESSION = 'tests/fix-round-4-5b-part-4-regression.sh';
const E2E = 'tests/e2e-fix-round-4-5b-part-4.sh';
const MUTATIONS = 'tests/fix-round-4-5b-part-4-mutations.mjs';
const WRAP_A = 'tests/fix-round-4-5a-red.sh';
const WRAP_B = 'tests/fix-round-4-5b-red.sh';
const WRAP_C = 'tests/fix-round-4-5b-part-2-red.sh';
const WRAP_D = 'tests/fix-round-4-5b-part-3-red.sh';
const PART4_IDS = ['CSG', 'SLT', 'ESC', 'CTB', 'KI4', 'NVR'];
/** A repo file's bytes; the file must exist. */
function bytes(rel) {
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
return readFileSync(join(ROOT, rel));
}
/** The words of a wrapper's one IDS='...' line. */
function idsOf(rel) {
const lines = bytes(rel).toString('utf8').split('\n').filter((l) => l.startsWith('IDS='));
assert.equal(lines.length, 1, `expected one IDS= line in ${rel}; found ${lines.length}`);
const m = lines[0].match(/^IDS='([^']*)'$/);
assert.ok(m, `the IDS= line of ${rel} is not one single-quoted list: ${lines[0]}`);
return m[1].split(/\s+/).filter(Boolean);
}
test('part-4 scripts: each exists, is ASCII only, passes /bin/bash -n and uses no bash 4 form', () => {
for (const rel of [RED, REGRESSION, 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/, /\|&/, /^\s*[A-Za-z_]+=\(/m,
/\$\{[A-Za-z_]+,,\}/, /\$\{[A-Za-z_]+\^\^\}/]) {
assert.doesNotMatch(text, bash4, `${rel} uses ${bash4}, which bash 3.2 lacks`);
}
}
});
/** 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-4 wrapper, the four older 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, WRAP_A, WRAP_B, WRAP_C, WRAP_D]) writeFileSync(join(dir, rel), bytes(rel));
writeFileSync(join(dir, 'tests', 'helpers', 'red-round.sh'), STUB, { mode: 0o755 });
return dir;
}
/** The part-4 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 };
}
test('part-4 scripts: the red wrapper passes one --other-ids, the 4-5a, part-1, part-2 and part-3 ids in order, and its own six ids', () => {
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(), 'part4-red-wrap-'));
try {
const r = runWrapper(stubRoot(base));
assert.equal(r.status, 0, `${RED} did not run the shared script:\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, [...idsOf(WRAP_A), ...idsOf(WRAP_B), ...idsOf(WRAP_C), ...idsOf(WRAP_D)],
`${RED}: --other-ids is not the IDS= words of ${WRAP_A}, then ${WRAP_B}, then ${WRAP_C}, then ${WRAP_D}\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), PART4_IDS, `${RED}: --ids is not ${PART4_IDS.join(' ')}\n${r.out}`);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test('part-4 scripts: the red wrapper exits 2 with a setup failure when an older wrapper has no IDS= line', () => {
for (const missing of [WRAP_A, WRAP_B, WRAP_C, WRAP_D]) {
const base = mkdtempSync(join(tmpdir(), 'part4-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.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-4 scripts: red-mutate.mjs list prints exactly the three part-4 mutations', () => {
assert.ok(existsSync(join(ROOT, MUTATIONS)), `missing file ${MUTATIONS}`);
const r = spawnSync(process.execPath, [join(ROOT, 'tests', 'helpers', 'red-mutate.mjs'), 'list', 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, ['CTB-catch CTB', 'NVR-title NVR', 'NVR-e2e NVR'],
`red-mutate.mjs list ${MUTATIONS} does not print exactly the three part-4 mutations:\n${r.stdout}${r.stderr}`);
});