voyage/tests/e2e-fix-round-4-5b-part-4.sh
2026-10-31 16:42:30 +01:00

219 lines
7.7 KiB
Bash

#!/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