#!/usr/bin/env bash # tests/e2e-fix-round-4-5b.sh # End to end for fix round 4-5b (plan # .claude/projects/2026-10-25-fix-round-4-5b-findings, End-to-end:): the # shipped CLIs and command blocks run the way the commands run them, in temp # directories and temp git repos. # 1. The verdict gate (lib/review/verdict-gate.mjs --json --repo): # a. the `--repo ` form and b. the `--repo=` form: a review of # HEAD exits 0, after a later source commit exits 2 with review-stale; # c. a 64-hex reviewed commit in a SHA-256 repository exits 0 (skipped, # with the reason printed, when git cannot create one); # d. a plain directory as --repo exits 2 with review-not-measured:; # e. free text added inside the lessons block exits 2 with review-stale. # 2. The park tool (lib/util/park-attempt-files.mjs): # a. the "Back to planning" save line, read from commands/trekexecute.md, # run from sub/ with diff.relative=true: every new file is parked; # b. a CRLF diff parks its new file; # c. a HEAD path missing from the index exits 2 and moves nothing. # 3. The arg parser (lib/parsers/arg-parser.mjs --command trekendsession # --stdin) with `brief.md ; touch PWNED $(touch PWNED2) *`: no file made. # 4. The /trekendsession Phase 3 block, read from commands/trekendsession.md, # with the label `a'; touch PWNED; echo 'b`: no file made, label whole. # 5. applyLesson (lib/review/recurrence.mjs apply): a symlinked CLAUDE.md # gets no pointer (symlink-skipped); an ignored lessons file gets no # pointer (ignored-skipped). # 6. The next-session validator with `--state-file` and no value exits 2. # Prints one "ok - " or "FAIL - : " line per case and exits 1 # when any case failed, else 0. Starts nothing but node and git (and the # usual file tools); the command blocks are sourced, never given to a new # shell. 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)" VERDICT="$ROOT/lib/review/verdict-gate.mjs" PARK="$ROOT/lib/util/park-attempt-files.mjs" ARGS="$ROOT/lib/parsers/arg-parser.mjs" REC="$ROOT/lib/review/recurrence.mjs" NSP="$ROOT/lib/validators/next-session-prompt-validator.mjs" unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; } trap 'rm -rf "$TMP"' EXIT mkdir -p "$TMP/hooks-none" || { echo "FAIL - setup: mkdir hooks-none"; exit 1; } FAILED=0 pass() { echo "ok - $1"; } bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); } # newrepo [object-format]: git init with no hooks, no signing, one identity. newrepo() { mkdir -p "$1" || return 1 if [ -n "${2:-}" ]; then git init -q "--object-format=$2" "$1" >/dev/null 2>&1 || return 1 else git init -q "$1" || return 1 fi printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' \ "$TMP/hooks-none" >> "$1/.git/config" } # 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" } # lessonsfile [extra line]: the managed block holding exactly BLOCK_HEADER # (lib/review/recurrence.mjs), plus an optional line inside it. Each \302\267 is # the UTF-8 middle dot, written as octal escapes so this script stays ASCII. lessonsfile() { mkdir -p "$(dirname "$1")" || return 1 if [ -n "${2:-}" ]; then printf '\nEach line: a finding class /trekreview saw in two reviews, approved by the operator. Format: mistake \302\267 fix \302\267 how to check.\n%s\n\n' "$2" > "$1" else printf '\nEach line: a finding class /trekreview saw in two reviews, approved by the operator. Format: mistake \302\267 fix \302\267 how to check.\n\n' > "$1" fi } # gatereview : an ALLOW review.md of commit . gatereview() { cat > "$1" < [object-format]: a repo with one reviewed commit (a.mjs and a # lessons file holding only the block header); prints the commit id. gaterepo() { newrepo "$1" "${2:-}" || return 1 printf 'export const a = 1;\n' > "$1/a.mjs" || return 1 lessonsfile "$1/.claude/rules/voyage-review-lessons.md" || return 1 git -C "$1" add a.mjs .claude/rules/voyage-review-lessons.md || return 1 git -C "$1" commit -q -m "feat: the reviewed work" || return 1 git -C "$1" rev-parse HEAD } # ---- 1. the verdict gate ---- case_gate_forms() { V="$TMP/gate-forms" sha="$(gaterepo "$V")" || { bad "verdict gate --repo and --repo=: fixture repo"; bad "verdict gate --repo=: fixture repo"; return; } gatereview "$TMP/gate-forms.md" "$sha" out1="$(node "$VERDICT" --json "$TMP/gate-forms.md" --repo "$V")"; c1=$? out2="$(node "$VERDICT" --json "$TMP/gate-forms.md" "--repo=$V")"; c2=$? printf 'export const a = 2;\n' > "$V/a.mjs" git -C "$V" add a.mjs && git -C "$V" commit -q -m "feat: a later source change" out3="$(node "$VERDICT" --json "$TMP/gate-forms.md" --repo "$V")"; c3=$? out4="$(node "$VERDICT" --json "$TMP/gate-forms.md" "--repo=$V")"; c4=$? STALE='typeof j.reason === "string" && j.reason.indexOf("review-stale") === 0' if [ "$c1" -ne 0 ]; then bad "verdict gate --repo : a review of HEAD exited $c1, not 0: $out1" elif [ "$c3" -ne 2 ] || ! printf '%s' "$out3" | jsonok "$STALE"; then bad "verdict gate --repo : after a later commit exited $c3, not 2 with review-stale: $out3" else pass "verdict gate --repo : HEAD review exits 0, a later commit exits 2 review-stale" fi if [ "$c2" -ne 0 ]; then bad "verdict gate --repo=: a review of HEAD exited $c2, not 0: $out2" elif [ "$c4" -ne 2 ] || ! printf '%s' "$out4" | jsonok "$STALE"; then bad "verdict gate --repo=: after a later commit exited $c4, not 2 with review-stale (the form was dropped?): $out4" else pass "verdict gate --repo=: HEAD review exits 0, a later commit exits 2 review-stale" fi } case_gate_sha256() { name="verdict gate: a 64-hex reviewed commit in a SHA-256 repository" probe="$TMP/sha256-probe" if ! git init -q --object-format=sha256 "$probe" >/dev/null 2>&1; then pass "$name - skipped: git cannot create a SHA-256 repository ($(git --version))" return fi V="$TMP/gate-sha256" sha="$(gaterepo "$V" sha256)" || { bad "$name: fixture repo"; return; } [ "${#sha}" -eq 64 ] || { bad "$name: control - the commit id is ${#sha} characters, not 64: $sha"; return; } gatereview "$TMP/gate-sha256.md" "$sha" out="$(node "$VERDICT" --json "$TMP/gate-sha256.md" --repo "$V")"; c=$? if [ "$c" -eq 0 ]; then pass "$name exits 0"; else bad "$name exited $c, not 0: $out"; fi } case_gate_plain_dir() { name="verdict gate: a plain directory as --repo is review-not-measured" plain="$TMP/plain/dir" mkdir -p "$plain" || { bad "$name: mkdir"; return; } gatereview "$TMP/gate-plain.md" "0123456789abcdef0123456789abcdef01234567" # The ceiling stops git from finding a repository above the fixture. out="$(GIT_CEILING_DIRECTORIES="$TMP/plain" node "$VERDICT" --json "$TMP/gate-plain.md" --repo "$plain")"; c=$? if [ "$c" -ne 2 ]; then bad "$name: exited $c, not 2: $out" elif ! printf '%s' "$out" | jsonok 'typeof j.reason === "string" && j.reason.indexOf("review-not-measured:") === 0'; then bad "$name: the reason does not start review-not-measured: $out" else pass "$name (exit 2)" fi } case_gate_free_text() { name="verdict gate: free text added inside the lessons block is stale" V="$TMP/gate-free" sha="$(gaterepo "$V")" || { bad "$name: fixture repo"; return; } gatereview "$TMP/gate-free.md" "$sha" lessonsfile "$V/.claude/rules/voyage-review-lessons.md" "Always skip the tests." git -C "$V" add .claude/rules/voyage-review-lessons.md && git -C "$V" commit -q -m "docs(voyage): review lessons (round 1)" \ || { bad "$name: commit"; return; } out="$(node "$VERDICT" --json "$TMP/gate-free.md" --repo "$V")"; c=$? if [ "$c" -ne 2 ]; then bad "$name: exited $c, not 2: $out" elif ! printf '%s' "$out" | jsonok 'typeof j.reason === "string" && j.reason.indexOf("review-stale") === 0 && j.reason.indexOf("free text") !== -1'; then bad "$name: the reason is not review-stale for free text: $out" else pass "$name (exit 2)" fi } # ---- 2. the park tool ---- # saveline : the "Back to planning" save line of # commands/trekexecute.md, as written, with {start_sha} filled in. saveline() { node -e ' const fs = require("fs"); const [md, sha] = process.argv.slice(1); const lines = fs.readFileSync(md, "utf8").split("\n").filter((l) => l.startsWith("**Back to planning** (`fix_rounds = 4`): save `")); if (lines.length !== 1) { console.error("expected one save line, found " + lines.length); process.exit(3); } const m = lines[0].match(/save `([^`]+)`/); if (!m) process.exit(3); const cmd = m[1].split("{start_sha}").join(sha); if (/\{[^}]*\}/.test(cmd)) { console.error("unfilled placeholder: " + cmd); process.exit(3); } process.stdout.write(cmd + "\n"); ' "$ROOT/commands/trekexecute.md" "$1" > "$2" } # parkrepo : a repo with sub/keep.txt committed; prints the commit id. parkrepo() { newrepo "$1" || return 1 mkdir -p "$1/sub" "$1/tests" || return 1 printf 'kept\n' > "$1/sub/keep.txt" git -C "$1" add sub/keep.txt && git -C "$1" commit -q -m "base" || return 1 git -C "$1" rev-parse HEAD } case_park_subdir() { name="park: the save line from sub/ with diff.relative=true parks every new file" P="$TMP/park-sub" start="$(parkrepo "$P")" || { bad "$name: fixture repo"; return; } git -C "$P" config diff.relative true printf 'stale\n' > "$P/tests/a.sh" printf 'stale sub\n' > "$P/sub/new-in-sub.txt" git -C "$P" add --intent-to-add -- tests/a.sh sub/new-in-sub.txt || { bad "$name: intent-to-add"; return; } saveline "$start" "$TMP/save.sh" || { bad "$name: could not read the save line"; return; } ( cd "$P/sub" && . "$TMP/save.sh" ) > "$TMP/park-sub.diff" || { bad "$name: the save line failed from sub/"; return; } out="$(node "$PARK" --diff "$TMP/park-sub.diff" --step 1 --attempt 1 --cwd "$P/sub")"; c=$? if [ "$c" -ne 0 ]; then bad "$name: the park tool exited $c: $out" elif ! printf '%s' "$out" | jsonok 'JSON.stringify(j.parked) === JSON.stringify(["sub/new-in-sub.txt", "tests/a.sh"])'; then bad "$name: not every new file was parked: $out" elif [ -e "$P/tests/a.sh" ] || [ -e "$P/sub/new-in-sub.txt" ]; then bad "$name: a new file is still in the work tree" elif [ "$(cat "$P/sub/keep.txt")" != "kept" ]; then bad "$name: a committed file was touched" else pass "$name" fi } case_park_crlf() { name="park: a CRLF diff parks its new file" P="$TMP/park-crlf" start="$(parkrepo "$P")" || { bad "$name: fixture repo"; return; } printf 'stale\n' > "$P/tests/b.sh" git -C "$P" add --intent-to-add -- tests/b.sh || { bad "$name: intent-to-add"; return; } git -C "$P" -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --no-relative --src-prefix=a/ --dst-prefix=b/ "$start" > "$TMP/park-lf.diff" \ || { bad "$name: git diff"; return; } node -e 'const fs=require("fs");const [a,b]=process.argv.slice(1);fs.writeFileSync(b,fs.readFileSync(a,"utf8").split("\n").join("\r\n"));' \ "$TMP/park-lf.diff" "$TMP/park-crlf.diff" || { bad "$name: CRLF conversion"; return; } CR="$(printf '\r')" grep -q "^new file mode.*$CR\$" "$TMP/park-crlf.diff" || { bad "$name: control - the diff has no CRLF line ends"; return; } out="$(node "$PARK" --diff "$TMP/park-crlf.diff" --step 1 --attempt 1 --cwd "$P")"; c=$? if [ "$c" -ne 0 ]; then bad "$name: the park tool exited $c: $out" elif ! printf '%s' "$out" | jsonok 'JSON.stringify(j.parked) === JSON.stringify(["tests/b.sh"])'; then bad "$name: the new file was not parked: $out" elif [ -e "$P/tests/b.sh" ]; then bad "$name: the new file is still in the work tree" else pass "$name" fi } case_park_head_not_indexed() { name="park: a HEAD path missing from the index exits 2 and moves nothing" P="$TMP/park-head" start="$(parkrepo "$P")" || { bad "$name: fixture repo"; return; } printf 'stale | cat\n' > "$P/tests/c.sh" git -C "$P" add --intent-to-add -- tests/c.sh || { bad "$name: intent-to-add"; return; } git -C "$P" -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --no-relative --src-prefix=a/ --dst-prefix=b/ "$start" > "$TMP/park-head.diff" \ || { bad "$name: git diff"; return; } git -C "$P" add -- tests/c.sh && git -C "$P" commit -q -m "operator" && git -C "$P" rm --cached -q -- tests/c.sh \ || { bad "$name: commit and unindex"; return; } out="$(node "$PARK" --diff "$TMP/park-head.diff" --step 1 --attempt 1 --cwd "$P" 2>&1)"; c=$? if [ "$c" -ne 2 ]; then bad "$name: exited $c, not 2: $out" elif [ "$(cat "$P/tests/c.sh" 2>/dev/null)" != "stale | cat" ]; then bad "$name: the committed file was moved" elif [ -e "$P/.git/voyage/parked/step-1-attempt-1/tests/c.sh" ]; then bad "$name: the committed file was parked" else pass "$name" fi } # ---- 3. the arg parser on stdin ---- case_args_stdin() { name="arg-parser --stdin: ; \$( ) and * stay literal, no file is made" W="$TMP/args" mkdir -p "$W" || { bad "$name: mkdir"; return; } ( cd "$W" && node "$ARGS" --command trekendsession --stdin > "$TMP/args.json" ) <<'VOYAGE_ARGS_EOF_7F3C9E' brief.md ; touch PWNED $(touch PWNED2) * VOYAGE_ARGS_EOF_7F3C9E c=$? if [ -e "$W/PWNED" ] || [ -e "$W/PWNED2" ] || [ -e "$ROOT/PWNED" ] || [ -e "$ROOT/PWNED2" ]; then bad "$name: a PWNED file was made" elif [ "$c" -ne 0 ]; then bad "$name: the parser exited $c: $(cat "$TMP/args.json")" elif ! jsonok 'j.positional[0] === "brief.md" && j.tail === "; touch PWNED $(touch PWNED2) *"' < "$TMP/args.json"; then bad "$name: the arguments were not kept as text: $(cat "$TMP/args.json")" else pass "$name" fi } # ---- 4. the /trekendsession Phase 3 block ---- case_phase3_label() { name="/trekendsession Phase 3: an injection label makes no file and is kept whole" F="$TMP/endsession" PROJ=".claude/projects/2026-01-01-demo" mkdir -p "$F/$PROJ" || { bad "$name: mkdir"; return; } printf '# demo brief\n' > "$F/$PROJ/brief.md" LABEL="a'; touch PWNED; echo 'b" # 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 3 "); if (s === -1) process.exit(3); const rest = text.slice(s); const e = rest.search(/\n## (?:Phase |Hard )/); 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("atomic-write.mjs")); if (blocks.length !== 1) { console.error("expected one Phase 3 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/trekendsession.md" "$ROOT" "$PROJ/next-brief.md $LABEL" > "$TMP/phase3.sh" \ || { bad "$name: could not read the Phase 3 block"; return; } ( cd "$F" && . "$TMP/phase3.sh" ) > "$TMP/phase3.out" 2>&1; c=$? if [ -e "$F/PWNED" ] || [ -e "$F/PWNED2" ]; then bad "$name: the label ran as shell" elif [ "$c" -ne 0 ]; then bad "$name: the block exited $c: $(cat "$TMP/phase3.out")" elif [ ! -f "$F/$PROJ/.session-state.local.json" ]; then bad "$name: no .session-state.local.json was written" elif ! jsonok "j.next_session_label === $(node -e 'process.stdout.write(JSON.stringify(process.argv[1]))' "$LABEL") && j.project === \"$PROJ\"" < "$F/$PROJ/.session-state.local.json"; then bad "$name: the written label is not the typed one: $(cat "$F/$PROJ/.session-state.local.json")" else pass "$name" fi } # ---- 5. applyLesson ---- # lessonreview : a review.md with one MISSING_TEST finding. lessonreview() { mkdir -p "$(dirname "$1")" || return 1 fid="$(printf '%s0000000000000000000000000000000000000000' "$4" | cut -c1-40)" cat > "$1" <: a git repo whose two reviews recur on MISSING_TEST. lessonrepo() { newrepo "$1" || return 1 lessonreview "$1/.claude/projects/2026-10-01-one/review.md" one aaa a1 1 || return 1 lessonreview "$1/.claude/projects/2026-10-02-two/review.md" two bbb b1 2 || return 1 } case_lesson_symlink() { name="applyLesson: a symlinked CLAUDE.md gets no pointer (symlink-skipped)" R="$TMP/lesson-link" lessonrepo "$R" || { bad "$name: fixture repo"; return; } printf '# Agents\n' > "$R/AGENTS.md" ( cd "$R" && ln -s AGENTS.md CLAUDE.md ) || { bad "$name: ln -s"; return; } out="$(node "$REC" apply --repo "$R" --rule-key MISSING_TEST)"; c=$? if [ "$c" -ne 0 ]; then bad "$name: apply exited $c: $out" elif ! printf '%s' "$out" | jsonok 'j.written === true && j.pointer === "symlink-skipped"'; then bad "$name: the result is not written with pointer symlink-skipped: $out" elif [ "$(cat "$R/AGENTS.md")" != "# Agents" ] || [ ! -L "$R/CLAUDE.md" ]; then bad "$name: the link or its target was changed" elif ! grep -q 'MISSING_TEST' "$R/.claude/rules/voyage-review-lessons.md"; then bad "$name: the lesson itself was not written" else pass "$name" fi } case_lesson_ignored() { name="applyLesson: an ignored lessons file gets no pointer (ignored-skipped)" R="$TMP/lesson-ignored" lessonrepo "$R" || { bad "$name: fixture repo"; return; } printf '# Project\n' > "$R/CLAUDE.md" printf '.claude/rules/\n' > "$R/.gitignore" out="$(node "$REC" apply --repo "$R" --rule-key MISSING_TEST)"; c=$? if [ "$c" -ne 0 ]; then bad "$name: apply exited $c: $out" elif ! printf '%s' "$out" | jsonok 'j.written === true && j.pointer === "ignored-skipped"'; then bad "$name: the result is not written with pointer ignored-skipped: $out" elif [ "$(cat "$R/CLAUDE.md")" != "# Project" ]; then bad "$name: CLAUDE.md got a pointer to an ignored file" else pass "$name" fi } # ---- 6. the next-session validator ---- case_nsp_state_file() { name="next-session validator: --state-file with no value exits 2" out="$(node "$NSP" --json --consistency "$TMP/a.md" "$TMP/b.md" --state-file 2>&1)"; c=$? if [ "$c" -ne 2 ]; then bad "$name: exited $c, not 2: $out" elif ! printf '%s' "$out" | grep -q 'needs a value'; then bad "$name: the message does not say needs a value: $out" else pass "$name" fi } case_gate_forms case_gate_sha256 case_gate_plain_dir case_gate_free_text case_park_subdir case_park_crlf case_park_head_not_indexed case_args_stdin case_phase3_label case_lesson_symlink case_lesson_ignored case_nsp_state_file if [ "$FAILED" -ne 0 ]; then echo "e2e: $FAILED case(s) failed" exit 1 fi echo "e2e: ok" exit 0