#!/usr/bin/env bash # tests/e2e-fix-round-4-5b-part-3.sh # End to end for fix round 4-5b, part 3 (plan # .claude/projects/2026-10-28-fix-round-4-5b-part-3, End-to-end:, decision # 89): the shipped products run the way a session runs them, in a temp # directory. # 1. CQW, the shipped way: the parser block of commands/trekreview.md # (## Phase 1), with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in, passes # the Bash hook, and /bin/bash runs it to JSON that keeps a value closed # on its own line. # 2. CQW through the parser CLI's --stdin: `"my dir "` pairs, and # `--project "my dir"/x --since "HEAD~1"` keeps --since = HEAD~1. # 3. PQO through the Bash hook (hooks/scripts/pre-bash-executor.mjs): # partly quoted, $'...' and backslash-newline openers are blocked; a # string-borne << hides no real opener; the shipped form with a typed # `fix "<<" handling` argument and the agents/review-orchestrator.md # block pass. # 4. PQO premise: /bin/bash itself runs the line after the closing line of # a partly quoted opener, so blocking it is not a false positive. # 5. PQO fix rounds 1 and 2 of step 2, through the Bash hook: a # continuation between a $ and its quote (at the word's start and inside # it) is blocked; a partly quoted opener whose word ends in a CR (CRLF # lines) or a VT, closed by the same line, is blocked when a line follows # the closing line; with CRLF lines and the closing line last it passes. # Prints one "ok - " or "FAIL - : " 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)" ARGS="$ROOT/lib/parsers/arg-parser.mjs" HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs" unset CLAUDE_CODE_SESSION_ID TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b-part-3.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)); } # jsonok : stdin is JSON; exit 0 when the expression is true. jsonok() { node -e 'let s="";process.stdin.on("data",(d)=>{s+=d;}).on("end",()=>{let j;try{j=JSON.parse(s);}catch(e){process.exit(3);}process.exit(Function("j","return ("+process.argv[1]+");")(j)?0:1);});' "$1" } # hookrun ...: the Bash hook with a Bash tool input whose command # is the given lines joined by newlines; stderr goes to . 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 : 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' # The typed arguments: a quoted value closed on its own line. TYPED="$(printf '%s\n%s' '--project "notes' '" --since HEAD')" # ---- 1. CQW, the shipped way ---- case_cqw_shipped() { name="trekreview Phase 1 block: passes the hook and keeps a value closed on its own line" W="$TMP/shipped" mkdir -p "$W" || { bad "$name: mkdir"; return; } # 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" "$TYPED" > "$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")" return fi ( cd "$W" && /bin/bash "$TMP/phase1.sh" ) > "$TMP/shipped.json" 2> "$TMP/shipped.out" c=$? if [ "$c" -ne 0 ]; then bad "$name: the block exited $c: $(cat "$TMP/shipped.out")" elif ! jsonok 'j.flags["--project"] === "notes\n" && j.flags["--since"] === "HEAD" && j.positional.length === 0' < "$TMP/shipped.json"; then bad "$name: the parse is not --project = notes + line break, --since = HEAD, no positional: $(cat "$TMP/shipped.json")" else pass "$name (hook exit 0; --project = notes + line break, --since = HEAD)" fi } # ---- 2. CQW through the parser CLI's --stdin ---- case_cqw_cli() { name="arg-parser --stdin: \"my dir \" pairs, and --since stays HEAD~1" printf '%s\n' '--project "my dir " --since HEAD' | node "$ARGS" --command trekreview --stdin > "$TMP/cli-1.json" 2>&1 c1=$? printf '%s\n' '--project "my dir"/x --since "HEAD~1"' | node "$ARGS" --command trekreview --stdin > "$TMP/cli-2.json" 2>&1 c2=$? if [ "$c1" -ne 0 ] || [ "$c2" -ne 0 ]; then bad "$name: the parser exited $c1 / $c2: $(cat "$TMP/cli-1.json" "$TMP/cli-2.json")" elif ! jsonok 'j.flags["--project"] === "my dir " && j.flags["--since"] === "HEAD"' < "$TMP/cli-1.json"; then bad "$name: --project is not \"my dir \": $(cat "$TMP/cli-1.json")" elif ! jsonok 'j.flags["--since"] === "HEAD~1"' < "$TMP/cli-2.json"; then bad "$name: --since is not HEAD~1: $(cat "$TMP/cli-2.json")" else pass "$name" fi } # ---- 3. PQO through the Bash hook ---- # blocked ...: 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 ...: 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 ...: 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 } case_pqo_partly_quoted() { name='Bash hook: a partly quoted opener <<"VOYAGE"_ARGS_EOF_7F3C9E that closes early is blocked' blocked "$name" "$TMP/pqo-1.err" "$PARSER_LINE "'<<"VOYAGE"_ARGS_EOF_7F3C9E' 'brief.md' "$D" 'touch PWNED' \ && pass "$name (exit 2, args-heredoc-closed-early)" } case_pqo_dollar() { name="Bash hook: a \$'...' opener that closes early is blocked" blocked "$name" "$TMP/pqo-2.err" "$PARSER_LINE <<\$'$D'" 'brief.md' "$D" 'touch PWNED' \ && pass "$name (exit 2, args-heredoc-closed-early)" } case_pqo_continuation() { name='Bash hook: a backslash-newline opener that closes early is blocked' blocked "$name" "$TMP/pqo-3.err" "$PARSER_LINE"' <<\' "$D" 'brief.md' "$D" 'touch PWNED' \ && pass "$name (exit 2, args-heredoc-closed-early)" } case_pqo_continuation_last() { name='Bash hook: a backslash-newline opener with the closing line last is blocked as unquoted' unquoted "$name" "$TMP/pqo-4.err" "$PARSER_LINE"' <<\' "$D" 'brief.md' "$D" \ && pass "$name (exit 2, opens $D unquoted)" } case_pqo_fake_accepted() { name='Bash hook: a fake accepted opener in a string hides no real opener' unquoted "$name" "$TMP/pqo-5.err" "echo \"<<'$D' x\"" "$PARSER_LINE"' < l.includes(opener)); if (s === -1) { console.error("no line holds " + opener); process.exit(3); } const e = lines.findIndex((l, i) => i > s && l === delim); if (e === -1) { console.error("no closing line after the opener"); process.exit(3); } process.stdout.write(lines.slice(s, e + 1).join("\n").split("${CLAUDE_PLUGIN_ROOT}").join(root).split("$ARGUMENTS").join(args) + "\n"); ' "$ROOT/agents/review-orchestrator.md" "$ROOT" "$TYPED" "$D" > "$TMP/orchestrator.sh" \ || { bad "$name: could not read the parser block"; return; } hookfile "$TMP/pqo-8.err" "$TMP/orchestrator.sh" c=$? if [ "$c" -ne 0 ]; then bad "$name: the hook exited $c: $(cat "$TMP/pqo-8.err")" else pass "$name (exit 0)" fi } # ---- 4. PQO premise: bash runs the line after the closing line ---- case_pqo_premise() { name='/bin/bash runs the line after the closing line of a partly quoted opener' P="$TMP/premise" mkdir -p "$P" || { bad "$name: mkdir"; return; } printf '%s\n' 'cat > /dev/null <<"VOYAGE"_ARGS_EOF_7F3C9E' 'brief.md' "$D" "touch \"$TMP/ran\"" > "$TMP/premise.sh" \ || { bad "$name: could not write the script"; return; } ( cd "$P" && /bin/bash "$TMP/premise.sh" ) > "$TMP/premise.out" 2>&1 if [ ! -e "$TMP/ran" ]; then bad "$name: the line after the closing line did not run: $(cat "$TMP/premise.out")" else pass "$name (the block is not a false positive)" fi } # ---- 5. PQO fix rounds: a continuation after $, a CR or VT word end ---- # The CR and VT bytes, built with printf escapes so this file stays ASCII. CR="$(printf '\r')" VT="$(printf '\013')" case_fix_bytes() { name='fix-round cases: the CR and VT bytes were built' if [ "${#CR}" -ne 1 ] || [ "${#VT}" -ne 1 ]; then bad "$name: CR is ${#CR} byte(s), VT is ${#VT} byte(s), not 1 each" else pass "$name" fi } case_fix_dollar_continuation() { name="Bash hook: <<\$ + backslash-newline + '$D' that closes early is blocked as unquoted" unquoted "$name" "$TMP/fix-1.err" "$PARSER_LINE"' <<$\' "'$D'" 'brief.md' "$D" 'touch PWNED' \ && pass "$name (exit 2, opens $D unquoted)" } case_fix_midword_dollar_continuation() { name="Bash hook: <