#!/usr/bin/env bash # tests/e2e-fix-round-4-5b2-b.sh # End to end for fix round 4.5b2, part B (plan # .claude/projects/2026-10-31-review-gate-4-5b2, Step 11, End-to-end:). On # throwaway fixture repos it runs the verdict gate the way /trekexecute's # merge lock does (node lib/review/verdict-gate.mjs --json --repo ) # and the Bash guard the way Claude Code does: # 1. an unanswered counting MAJOR keeps the gate at exit 2; after # review-answers.mjs record --answer accepted it exits 0; # 2. a MAJOR answered "fixed" keeps exit 2 (fixed-pending), and a round-2 # review (review-rounds.mjs prepare, then review-finalize.mjs) keeps # exit 2 while the finding is carried and exits 0 once it is resolved; # 3. a pre-existing BLOCKER (a line the range did not change) exits 1; # 3b. an engine MAJOR in a payload beside a WARN review that does not hold it # keeps exit 2 (engine finding not in review, Step 15) until finalize puts # it back, then it is unanswered; # 4. merge-evidence.mjs write records a fixture run, and a # `git merge --no-ff feat` hook input into hooks/scripts/pre-bash-executor.mjs # answers "ask" with the Tested by and Undone by lines. # Every fixture keeps HEAD at the reviewed commit when it asks the gate (R6). # Prints one "ok - " or "FAIL - : " line per check and # "e2e: ok" at the end, exit 0; else exit 1. Starts nothing but node, git 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" GATE="$ROOT/lib/review/verdict-gate.mjs" ANSWERS="$ROOT/lib/review/review-answers.mjs" ROUNDS="$ROOT/lib/review/review-rounds.mjs" FINALIZE="$ROOT/lib/review/review-finalize.mjs" FILL="$ROOT/lib/review/fill-finding-ids.mjs" WRITER="$ROOT/lib/review/merge-evidence.mjs" unset CLAUDE_CODE_SESSION_ID TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b2-b.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)); } TRAILER='Co-Authored-By: Claude Test ' PROJ_REL=.claude/projects/2026-11-02-e2e mkdir -p "$TMP/hooks-none" || { echo "FAIL - setup: cannot make the temp directories"; exit 1; } # A review_version 1.2 review with @@ID_@@ placeholders, filled afterwards # by fill-finding-ids.mjs as /trekreview Phase 7 fills them. # node mkreview.mjs [rstep]: with rstep, # finding 1 gets a "### Step R1" (every BLOCKER gets one anyway). cat > "$TMP/mkreview.mjs" <<'EOF' import { writeFileSync } from 'node:fs'; const [out, start, end, findingsJson, rstep] = process.argv.slice(2); const list = JSON.parse(findingsJson).map((f, i) => ({ id: `@@ID_${i + 1}@@`, severity: f.severity, rule_key: f.rule_key || 'MISSING_TEST', file: f.file, line: f.line, brief_ref: 'SC1', title: f.title, detail: f.detail || `detail ${i + 1}`, recommended_action: 'Fix it.', })); const counts = { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 }; for (const f of list) counts[f.severity] += 1; const verdict = counts.BLOCKER > 0 ? 'BLOCK' : counts.MAJOR > 0 ? 'WARN' : 'ALLOW'; const sections = ['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION'].map((sev) => { const of = list.filter((f) => f.severity === sev); if (of.length === 0) return ''; return `## Findings (${sev})\n\n${of.map((f) => `### ${f.id}\n\n- file: ${f.file}\n- line: ${f.line}\n- rule_key: ${f.rule_key}\n- title: ${f.title}\n`).join('\n')}\n`; }).join(''); const stepFor = list.filter((f, i) => f.severity === 'BLOCKER' || (rstep === 'rstep' && i === 0)); const steps = stepFor.map((f, i) => `### Step R${i + 1}: fix ${f.title}\n\n- **Files:** \`${f.file}\`\n- **Source finding:** \`${f.id}\`\n- **Test first:** a test that fails today\n- **Verify:** re-run the review\n- **On failure:** escalate\n`).join('\n'); const json = JSON.stringify({ verdict, counts, allow_blocked_by: [], findings: list }, null, 2); const fmList = list.length ? `\n${list.map((f) => ` - ${f.id}`).join('\n')}` : ' []'; writeFileSync(out, `--- type: trekreview review_version: "1.2" task: "t" slug: e2e project_dir: .claude/projects/2026-11-02-e2e/ brief_path: .claude/projects/2026-11-02-e2e/brief.md scope_sha_start: ${start} scope_sha_end: ${end} reviewed_files_count: 2 verdict: ${verdict} findings:${fmList} --- # Review: t ## Executive Summary x ## Coverage | File | Treatment | Reason | |------|-----------|--------| | a.mjs | deep-review | default | ${sections}## Declined to Judge None. Trimmed: 0 ## Remediation Steps ${steps || 'None.\n'} ## Remediation Summary x \`\`\`json ${json} \`\`\` `); EOF # jfield : prints the JS expression over the parsed JSON # value `j` read from (empty when it does not parse). jfield() { node -e ' const fs = require("fs"); let j; try { j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); } catch (e) { process.exit(0); } const v = Function("j", "return (" + process.argv[2] + ");")(j); process.stdout.write(v === undefined || v === null ? "" : String(v)); ' "$1" "$2" } # reviewjson : over the review's trailing JSON block `j`. reviewjson() { node -e ' const fs = require("fs"); const t = fs.readFileSync(process.argv[1], "utf8"); const j = JSON.parse(t.slice(t.lastIndexOf("```json\n") + 8, t.lastIndexOf("```"))); const v = Function("j", "return (" + process.argv[2] + ");")(j); process.stdout.write(v === undefined || v === null ? "" : String(v)); ' "$1" "$2" } # ten [ ]: ten lines `export const = ;`, line replaced. ten() { tag="$1" at="${2:-0}" txt="${3:-}" n=1 while [ "$n" -le 10 ]; do if [ "$n" -eq "$at" ]; then echo "$txt"; else echo "export const $tag$n = $n;"; fi n=$((n + 1)) done } # mkrepo : a git repo on main with start (a.mjs, b.mjs) and c1 (line 3 of # both changed); the project folder excluded from git. Sets START and C1. mkrepo() { r="$1" mkdir -p "$r" || return 1 git -C "$r" init -q -b main || return 1 printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' "$TMP/hooks-none" >> "$r/.git/config" printf '.claude/projects/\n' >> "$r/.git/info/exclude" ten a > "$r/a.mjs" ten b > "$r/b.mjs" git -C "$r" add a.mjs b.mjs && git -C "$r" commit -q -m 'chore: start' || return 1 START="$(git -C "$r" rev-parse HEAD)" git -C "$r" checkout -q -b feat || return 1 ten a 3 'export const a3 = 33;' > "$r/a.mjs" ten b 3 'export const b3 = 33;' > "$r/b.mjs" git -C "$r" add a.mjs b.mjs && git -C "$r" commit -q -m 'feat: round 1' || return 1 C1="$(git -C "$r" rev-parse HEAD)" mkdir -p "$r/$PROJ_REL" || return 1 } # gate : the merge lock's gate line; prints its exit status. gate() { node "$GATE" --json "$1" --repo "$2" > "$3" 2>&1 echo $? } # reviewer : the code-correctness reviewer's saved answer. reviewer() { printf 'Reviewer prose.\n\n```json\n{"reviewer": "code-correctness-reviewer", "findings": [], "previous": %s}\n```\n' "$2" \ > "$1/.review-out-code-correctness-reviewer.md" } MAJ='[{"severity":"MAJOR","file":"a.mjs","line":3,"title":"Parser drops the last token","detail":"parse() stops one token early"}]' case_accepted() { name="unanswered MAJOR, then accepted" R="$TMP/accepted" mkrepo "$R" || { bad "$name: setup"; return; } P="$R/$PROJ_REL" node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C1" "$MAJ" && node "$FILL" "$P/review.md" > /dev/null \ || { bad "$name: could not write the review"; return; } reviewer "$P" '[]' node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 1 > "$TMP/acc-fin.out" 2>&1 \ || { bad "$name: finalize: $(cat "$TMP/acc-fin.out")"; return; } c="$(gate "$P/review.md" "$R" "$TMP/acc-g1.out")" if [ "$c" -eq 2 ] && grep -q '"reason":"review-unanswered:' "$TMP/acc-g1.out"; then pass "$name: an unanswered MAJOR exits 2" else bad "$name: the unanswered MAJOR exited $c: $(cat "$TMP/acc-g1.out")" fi id="$(reviewjson "$P/review.md" 'j.findings[0].id')" printf 'the parser keeps the old contract on purpose' | node "$ANSWERS" record --review "$P/review.md" --finding "$id" \ --answer accepted --repo "$R" --round 1 --store local --trailer "$TRAILER" > "$TMP/acc-rec.out" 2>&1 rc=$? if [ "$rc" -ne 0 ]; then bad "$name: record exited $rc: $(cat "$TMP/acc-rec.out")"; return; fi c="$(gate "$P/review.md" "$R" "$TMP/acc-g2.out")" if [ "$c" -eq 0 ]; then pass "$name: accepted, the gate exits 0" else bad "$name: after the accepted answer the gate exited $c: $(cat "$TMP/acc-g2.out")" fi } case_fixed() { name="MAJOR answered fixed" R="$TMP/fixed" mkrepo "$R" || { bad "$name: setup"; return; } P="$R/$PROJ_REL" node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C1" "$MAJ" rstep && node "$FILL" "$P/review.md" > /dev/null \ || { bad "$name: could not write the review"; return; } reviewer "$P" '[]' node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 1 > "$TMP/fix-fin1.out" 2>&1 \ || { bad "$name: round-1 finalize: $(cat "$TMP/fix-fin1.out")"; return; } id="$(reviewjson "$P/review.md" 'j.findings[0].id')" printf 'the parser gets the missing token back' | node "$ANSWERS" record --review "$P/review.md" --finding "$id" \ --answer fixed --repo "$R" --round 1 --store local --trailer "$TRAILER" > "$TMP/fix-rec.out" 2>&1 rc=$? if [ "$rc" -ne 0 ]; then bad "$name: record exited $rc: $(cat "$TMP/fix-rec.out")"; return; fi c="$(gate "$P/review.md" "$R" "$TMP/fix-g1.out")" if [ "$c" -eq 2 ] && grep -q '"reason":"review-unanswered: fixed-pending 1' "$TMP/fix-g1.out"; then pass "$name: round 1 exits 2 (fixed-pending)" else bad "$name: round 1 exited $c: $(cat "$TMP/fix-g1.out")" fi # The fix: round 2's work, committed (only a.mjs; the local approval log stays untracked). ten a 3 'export const a3 = 333;' > "$R/a.mjs" git -C "$R" add a.mjs && git -C "$R" commit -q -m 'fix: the parser' || { bad "$name: commit"; return; } C2="$(git -C "$R" rev-parse HEAD)" (cd "$R" && node "$ROUNDS" prepare --project "$P" --since "$START" --json) > "$TMP/fix-prep.out" 2>&1 \ || { bad "$name: prepare: $(cat "$TMP/fix-prep.out")"; return; } round="$(jfield "$TMP/fix-prep.out" 'j.round')" base="$(jfield "$TMP/fix-prep.out" 'j.round_base')" prev="$(jfield "$TMP/fix-prep.out" 'j.previous')" if [ "$round" != 2 ] || [ "$base" != "$C1" ] || [ -z "$prev" ]; then bad "$name: prepare gave round $round base $base previous $prev" return fi # Round 2, the reviewer not (yet) marking the finding resolved: carried, still open. node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C2" '[]' && node "$FILL" "$P/review.md" > /dev/null \ || { bad "$name: could not write the round-2 review"; return; } reviewer "$P" "[{\"id\": \"$id\", \"resolved\": false}]" node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 2 --round-base "$base" --previous "$P/$prev" \ > "$TMP/fix-fin2.out" 2>&1 || { bad "$name: round-2 finalize: $(cat "$TMP/fix-fin2.out")"; return; } carried="$(reviewjson "$P/review.md" 'j.findings.map((f) => f.carried_from).join(",")')" c="$(gate "$P/review.md" "$R" "$TMP/fix-g2.out")" if [ "$carried" = "$id" ] && [ "$c" -eq 2 ]; then pass "$name: round 2 carries the finding and keeps exit 2" else bad "$name: round 2 not resolved: carried [$carried], gate exit $c: $(cat "$TMP/fix-g2.out")" fi # Round 2 again, the reviewer marking it resolved. node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C2" '[]' && node "$FILL" "$P/review.md" > /dev/null \ || { bad "$name: could not rewrite the round-2 review"; return; } reviewer "$P" "[{\"id\": \"$id\", \"resolved\": true}]" node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 2 --round-base "$base" --previous "$P/$prev" \ > "$TMP/fix-fin3.out" 2>&1 || { bad "$name: round-2 finalize: $(cat "$TMP/fix-fin3.out")"; return; } resolved="$(reviewjson "$P/review.md" '(j.resolved || []).join(",")')" c="$(gate "$P/review.md" "$R" "$TMP/fix-g3.out")" if [ "$resolved" = "$id" ] && [ "$c" -eq 0 ]; then pass "$name: round 2 marks it resolved, the gate exits 0" else bad "$name: resolved [$resolved], gate exit $c: $(cat "$TMP/fix-g3.out")" fi } case_preexisting() { name="pre-existing BLOCKER" R="$TMP/pre" mkrepo "$R" || { bad "$name: setup"; return; } P="$R/$PROJ_REL" # b.mjs line 9: a line start..c1 did not change. node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C1" \ '[{"severity":"BLOCKER","file":"b.mjs","line":9,"rule_key":"BROKEN_SUCCESS_CRITERION","title":"Build output is never written","detail":"write() returns before the flush"}]' \ && node "$FILL" "$P/review.md" > /dev/null || { bad "$name: could not write the review"; return; } reviewer "$P" '[]' node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 1 > "$TMP/pre-fin.out" 2>&1 \ || { bad "$name: finalize: $(cat "$TMP/pre-fin.out")"; return; } mark="$(reviewjson "$P/review.md" 'j.findings[0].pre_existing')" c="$(gate "$P/review.md" "$R" "$TMP/pre-g.out")" if [ "$mark" = true ] && [ "$c" -eq 1 ]; then pass "$name: marked pre-existing, the gate exits 1" else bad "$name: pre_existing [$mark], gate exit $c: $(cat "$TMP/pre-g.out")" fi } case_dropped_engine() { name="dropped engine finding" R="$TMP/dropped" mkrepo "$R" || { bad "$name: setup"; return; } P="$R/$PROJ_REL" # A WARN review: its one MAJOR is answered accepted, so the gate passes it alone. node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C1" "$MAJ" && node "$FILL" "$P/review.md" > /dev/null \ || { bad "$name: could not write the review"; return; } id="$(reviewjson "$P/review.md" 'j.findings[0].id')" printf 'the parser keeps the old contract on purpose' | node "$ANSWERS" record --review "$P/review.md" --finding "$id" \ --answer accepted --repo "$R" --round 1 --store local --trailer "$TRAILER" > "$TMP/drop-rec.out" 2>&1 rc=$? if [ "$rc" -ne 0 ]; then bad "$name: record exited $rc: $(cat "$TMP/drop-rec.out")"; return; fi c="$(gate "$P/review.md" "$R" "$TMP/drop-g0.out")" if [ "$c" -ne 0 ]; then bad "$name: the answered WARN alone exited $c: $(cat "$TMP/drop-g0.out")"; return; fi # Phase 4.7's payload: a /code-review MAJOR on b.mjs line 3 that the review does not hold; finalize not run. printf 'a.mjs\nb.mjs\n' > "$TMP/drop-scope.txt" printf '{"engine": "code-review", "findings": [{"file": "b.mjs", "line": 3, "summary": "The counter overflows past 2^31", "failure_scenario": "a long run wraps the count"}]}\n' \ > "$TMP/drop-engine.json" node "$ROOT/lib/review/engine-findings.mjs" --scope "$TMP/drop-scope.txt" "$TMP/drop-engine.json" \ > "$P/.review-engine-payload-code-review.json" 2> "$TMP/drop-eng.err" \ || { bad "$name: engine-findings: $(cat "$TMP/drop-eng.err")"; return; } c="$(gate "$P/review.md" "$R" "$TMP/drop-g1.out")" if [ "$c" -eq 2 ] && grep -q 'engine finding not in review' "$TMP/drop-g1.out"; then pass "$name: not finalized, the gate exits 2 (engine finding not in review)" else bad "$name: the dropped engine MAJOR exited $c: $(cat "$TMP/drop-g1.out")" fi reviewer "$P" '[]' node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 1 > "$TMP/drop-fin.out" 2>&1 \ || { bad "$name: finalize: $(cat "$TMP/drop-fin.out")"; return; } c="$(gate "$P/review.md" "$R" "$TMP/drop-g2.out")" if [ "$c" -eq 2 ] && grep -q '"reason":"review-unanswered:' "$TMP/drop-g2.out"; then pass "$name: finalized, the put-back MAJOR is unanswered" else bad "$name: after finalize the gate exited $c: $(cat "$TMP/drop-g2.out")" fi } case_guard() { name="merge question" R="$TMP/merge" mkdir -p "$R" || { bad "$name: setup"; return; } git -C "$R" init -q -b main || { bad "$name: git init"; return; } printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' "$TMP/hooks-none" >> "$R/.git/config" ten a > "$R/a.mjs" git -C "$R" add a.mjs && git -C "$R" commit -q -m 'chore: start' || { bad "$name: commit"; return; } MAIN_SHA="$(git -C "$R" rev-parse HEAD)" git -C "$R" checkout -q -b feat || { bad "$name: branch"; return; } ten a 3 'export const a3 = 33;' > "$R/a.mjs" git -C "$R" add a.mjs && git -C "$R" commit -q -m 'feat: the work' || { bad "$name: commit"; return; } FEAT="$(git -C "$R" rev-parse HEAD)" # The run's artifacts, outside the repo. P="$TMP/merge-project" mkdir -p "$P" || { bad "$name: setup"; return; } printf '# Plan\n\n### Step 1: End to end\n\n- **End-to-end:** `/bin/bash tests/e2e-demo.sh` -> expected: `e2e: ok`, exit 0.\n\n## Doors\n\n- two-way: a revert restores the old parser\n' > "$P/plan.md" printf '{"schema_version": "1", "status": "completed", "end_to_end": "verified", "plan_verification": {"status": "passed", "summary": {"total": 3, "passed": 3, "failed": 0, "blocked": 0, "unrunnable": 0}}}\n' > "$P/progress.json" node "$TMP/mkreview.mjs" "$P/review.md" "$MAIN_SHA" "$FEAT" '[]' && node "$FILL" "$P/review.md" > /dev/null \ || { bad "$name: could not write the review"; return; } # HEAD at the reviewed commit while the writer asks the verdict gate. node "$WRITER" write --repo "$R" --branch feat --project "$P" --json > "$TMP/ev.out" 2>&1 rc=$? written="$(jfield "$TMP/ev.out" 'j.written')" path="$(jfield "$TMP/ev.out" 'j.path')" if [ "$rc" -eq 0 ] && [ "$written" = true ] && [ "$path" = "$R/.git/voyage/merge-evidence/$FEAT.json" ] && [ -f "$path" ]; then pass "$name: the evidence file is written under the git common dir" else bad "$name: the writer exited $rc: $(cat "$TMP/ev.out")" fi if [ -z "$(git -C "$R" status --porcelain --untracked-files=all)" ]; then pass "$name: git status shows nothing new" else bad "$name: git status: $(git -C "$R" status --porcelain --untracked-files=all)" fi git -C "$R" checkout -q main || { bad "$name: checkout main"; return; } node -e 'process.stdout.write(JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd: process.argv[1], tool_input: { command: "git merge --no-ff feat" } }));' "$R" \ | node "$HOOK" > "$TMP/hook.out" 2> "$TMP/hook.err" rc=$? decision="$(jfield "$TMP/hook.out" 'j.hookSpecificOutput.permissionDecision')" reason="$(jfield "$TMP/hook.out" 'j.hookSpecificOutput.permissionDecisionReason')" sha8="$(printf '%s' "$FEAT" | cut -c1-8)" tested="Tested by: End-to-end \`/bin/bash tests/e2e-demo.sh\` exit 0; Verification 3/3; review ALLOW at $sha8." undone='Undone by: `git revert -m 1 ` (two-way).' if [ "$rc" -eq 0 ] && [ "$decision" = ask ]; then pass "$name: the guard asks" else bad "$name: the guard exited $rc with decision [$decision]: $(cat "$TMP/hook.out" "$TMP/hook.err")" fi case "$reason" in *"$tested"*) pass "$name: the Tested by line" ;; *) bad "$name: no Tested by line [$tested] in: $reason" ;; esac case "$reason" in *"$undone"*) pass "$name: the Undone by line" ;; *) bad "$name: no Undone by line [$undone] in: $reason" ;; esac } case_accepted case_fixed case_preexisting case_dropped_engine case_guard if [ "$FAILED" -ne 0 ]; then echo "e2e: $FAILED case(s) failed" exit 1 fi echo "e2e: ok" exit 0