voyage/tests/e2e-fix-round-4-5b2-c.sh
2026-11-04 10:54:56 +01:00

351 lines
14 KiB
Bash

#!/usr/bin/env bash
# tests/e2e-fix-round-4-5b2-c.sh
# End to end for fix round 4.5b2, part C (plan
# .claude/plans/trekplan-2026-11-03-review-gate-4-5b2-fixes.md, Step 6,
# End-to-end:). On throwaway fixture repos it writes run evidence the way
# /trekexecute Phase 8 does (node lib/review/merge-evidence.mjs write) and
# feeds hooks/scripts/pre-bash-executor.mjs Bash inputs the way Claude Code does:
# 1. `git merge --no-ff feat -m "merge: trekexecute demo"` on main
# (/trekexecute's own order) asks with feat's Tested by line;
# 2. `git merge --no-ff feat && git merge --no-ff other` asks with the line
# that says the run evidence is not shown, and no `Tested by: End-to-end`;
# 3. `git merge --cleanup strip --no-ff feat` asks with feat's Tested by line;
# 4. `git merge --no-ff -m "Merge run branch" other` with evidence only for
# feat asks and never shows feat's evidence;
# 5. a tag named `feat` next to the branch: the writer refuses, and the
# question shows no run evidence;
# 6. a write from a second worktree whose HEAD lags the branch: the question
# reads `review not measured`.
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per case 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"
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-c.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)); }
mkdir -p "$TMP/hooks-none" || { echo "FAIL - setup: cannot make the temp directories"; exit 1; }
# A review_version 1.2 ALLOW review with no findings of <start>..<end>
# (shape copied from tests/e2e-fix-round-4-5b2-b.sh mkreview.mjs).
# node mkreview.mjs <out> <start> <end>
cat > "$TMP/mkreview.mjs" <<'EOF'
import { writeFileSync } from 'node:fs';
const [out, start, end] = process.argv.slice(2);
const json = JSON.stringify({ verdict: 'ALLOW', counts: { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 }, allow_blocked_by: [], findings: [] }, null, 2);
writeFileSync(out, `---
type: trekreview
review_version: "1.2"
task: "t"
slug: e2e
project_dir: .claude/projects/2026-11-03-e2e/
brief_path: .claude/projects/2026-11-03-e2e/brief.md
scope_sha_start: ${start}
scope_sha_end: ${end}
reviewed_files_count: 1
verdict: ALLOW
findings: []
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|------|-----------|--------|
| a.mjs | deep-review | default |
## Declined to Judge
None.
Trimmed: 0
## Remediation Steps
None.
## Remediation Summary
x
\`\`\`json
${json}
\`\`\`
`);
EOF
# jfield <file> <expr>: prints the JS expression <expr> over the parsed JSON
# value `j` read from <file> (empty when it does not parse).
# (Copied from tests/e2e-fix-round-4-5b2-b.sh.)
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"
}
# mkrepo <dir>: a git repo on main (a.mjs) with two branches one commit ahead
# each, feat (b.mjs) and other (c.mjs), checked out on main. Sets MAIN_SHA,
# FEAT and OTHER.
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 'export const a = 1;\n' > "$r/a.mjs"
git -C "$r" add a.mjs && git -C "$r" commit -q -m 'chore: start' || return 1
MAIN_SHA="$(git -C "$r" rev-parse HEAD)"
git -C "$r" checkout -q -b feat || return 1
printf 'export const b = 1;\n' > "$r/b.mjs"
git -C "$r" add b.mjs && git -C "$r" commit -q -m 'feat: the work' || return 1
FEAT="$(git -C "$r" rev-parse HEAD)"
git -C "$r" checkout -q main || return 1
git -C "$r" checkout -q -b other || return 1
printf 'export const c = 1;\n' > "$r/c.mjs"
git -C "$r" add c.mjs && git -C "$r" commit -q -m 'feat: the other work' || return 1
OTHER="$(git -C "$r" rev-parse HEAD)"
git -C "$r" checkout -q main || return 1
}
# mkproject <dir> <start> <end>: a run's artifacts (plan, progress, ALLOW
# review of <start>..<end>) outside the repo.
mkproject() {
mkdir -p "$1" || return 1
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' > "$1/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' > "$1/progress.json"
node "$TMP/mkreview.mjs" "$1/review.md" "$2" "$3" && node "$FILL" "$1/review.md" > /dev/null
}
# writeev <repo> <branch> <project> <out>: the writer's CLI run from <repo>;
# prints its exit status.
writeev() {
node "$WRITER" write --repo "$1" --branch "$2" --project "$3" --json > "$4" 2>&1
echo $?
}
# record <repo> <branch> <sha> <label>: check out <branch> (HEAD at the
# reviewed commit, as Phase 8 runs), write its evidence, back to main.
# Returns 1 with the reason in WHY when the file is not written.
record() {
WHY=""
mkproject "$TMP/$4-project" "$MAIN_SHA" "$3" || { WHY="could not write the project"; return 1; }
git -C "$1" checkout -q "$2" || { WHY="checkout $2"; return 1; }
rc="$(writeev "$1" "$2" "$TMP/$4-project" "$TMP/$4-ev.out")"
git -C "$1" checkout -q main || { WHY="checkout main"; return 1; }
if [ "$rc" -ne 0 ] || [ "$(jfield "$TMP/$4-ev.out" 'j.written')" != true ] || [ ! -f "$1/.git/voyage/merge-evidence/$3.json" ]; then
WHY="the writer exited $rc: $(cat "$TMP/$4-ev.out")"
return 1
fi
return 0
}
# askhook <repo> <command> <label>: the guard on a Bash call in <repo>; sets
# DECISION and REASON (and HOOK_RC).
askhook() {
node -e 'process.stdout.write(JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd: process.argv[1], tool_input: { command: process.argv[2] } }));' "$1" "$2" \
| node "$HOOK" > "$TMP/$3-hook.out" 2> "$TMP/$3-hook.err"
HOOK_RC=$?
DECISION="$(jfield "$TMP/$3-hook.out" 'j.hookSpecificOutput.permissionDecision')"
REASON="$(jfield "$TMP/$3-hook.out" 'j.hookSpecificOutput.permissionDecisionReason')"
}
# tested <sha>: the Tested by line the writer's evidence gives for <sha>.
tested() {
printf 'Tested by: End-to-end `/bin/bash tests/e2e-demo.sh` exit 0; Verification 3/3; review ALLOW at %s.' "$(printf '%s' "$1" | cut -c1-8)"
}
UNDONE='Undone by: `git revert -m 1 <merge>` (two-way).'
NOT_SHOWN="Tested by: not shown (this command merges 2 branches into main; merge one at a time to see each run's evidence).
Undone by: not shown."
NO_EVIDENCE='Tested by: no run evidence.
Undone by: no run evidence.'
# The shared fixture of cases 1-3: evidence for both feat and other.
R="$TMP/both"
BOTH_OK=1
if ! mkrepo "$R"; then
BOTH_OK=0
BOTH_WHY="setup"
elif ! record "$R" feat "$FEAT" both-feat; then
BOTH_OK=0
BOTH_WHY="feat: $WHY"
elif ! record "$R" other "$OTHER" both-other; then
BOTH_OK=0
BOTH_WHY="other: $WHY"
fi
BOTH_FEAT="$FEAT"
BOTH_OTHER="$OTHER"
case_order() {
name="the trekexecute merge order shows feat's Tested by line"
if [ "$BOTH_OK" -ne 1 ]; then bad "$name: $BOTH_WHY"; return; fi
askhook "$R" 'git merge --no-ff feat -m "merge: trekexecute demo"' order
want="$(tested "$BOTH_FEAT")
$UNDONE"
if [ "$HOOK_RC" -ne 0 ] || [ "$DECISION" != ask ]; then
bad "$name: the guard exited $HOOK_RC with decision [$DECISION]: $(cat "$TMP/order-hook.out" "$TMP/order-hook.err")"
return
fi
case "$REASON" in
*"$want") pass "$name" ;;
*) bad "$name: the reason does not end with [$want]: $REASON" ;;
esac
}
case_two() {
name="two merges in one command say the run evidence is not shown"
if [ "$BOTH_OK" -ne 1 ]; then bad "$name: $BOTH_WHY"; return; fi
askhook "$R" 'git merge --no-ff feat && git merge --no-ff other' two
if [ "$HOOK_RC" -ne 0 ] || [ "$DECISION" != ask ]; then
bad "$name: the guard exited $HOOK_RC with decision [$DECISION]: $(cat "$TMP/two-hook.out" "$TMP/two-hook.err")"
return
fi
case "$REASON" in
*'Tested by: End-to-end'*) bad "$name: a Tested by: End-to-end line is shown: $REASON"; return ;;
esac
case "$REASON" in
*"$NOT_SHOWN"*) pass "$name" ;;
*) bad "$name: no not-shown line [$NOT_SHOWN] in: $REASON" ;;
esac
}
case_cleanup() {
name="git merge --cleanup strip shows feat's Tested by line"
if [ "$BOTH_OK" -ne 1 ]; then bad "$name: $BOTH_WHY"; return; fi
askhook "$R" 'git merge --cleanup strip --no-ff feat' cleanup
want="$(tested "$BOTH_FEAT")
$UNDONE"
if [ "$HOOK_RC" -ne 0 ] || [ "$DECISION" != ask ]; then
bad "$name: the guard exited $HOOK_RC with decision [$DECISION]: $(cat "$TMP/cleanup-hook.out" "$TMP/cleanup-hook.err")"
return
fi
case "$REASON" in
*"$want"*) pass "$name" ;;
*) bad "$name: no [$want] in: $REASON" ;;
esac
}
case_message() {
name="a quoted -m before another branch never shows feat's evidence"
R4="$TMP/message"
mkrepo "$R4" || { bad "$name: setup"; return; }
record "$R4" feat "$FEAT" message-feat || { bad "$name: $WHY"; return; }
if [ -e "$R4/.git/voyage/merge-evidence/$OTHER.json" ]; then bad "$name: Control: other has evidence"; return; fi
askhook "$R4" 'git merge --no-ff -m "Merge run branch" other' message
if [ "$HOOK_RC" -ne 0 ] || [ "$DECISION" != ask ]; then
bad "$name: the guard exited $HOOK_RC with decision [$DECISION]: $(cat "$TMP/message-hook.out" "$TMP/message-hook.err")"
return
fi
case "$REASON" in
*"$(tested "$FEAT")"*) bad "$name: feat's Tested by line is shown: $REASON" ;;
*'Tested by: End-to-end'*) bad "$name: a Tested by: End-to-end line is shown, other has no evidence: $REASON" ;;
*'Tested by:'*) pass "$name" ;;
*) bad "$name: no Tested by line at all: $REASON" ;;
esac
}
case_tag() {
name="a tag named feat: the writer refuses and the question shows no run evidence"
R5="$TMP/tag"
mkrepo "$R5" || { bad "$name: setup"; return; }
# Evidence written while the name was a branch only.
record "$R5" feat "$FEAT" tag-feat || { bad "$name: Control: $WHY"; return; }
git -C "$R5" update-ref refs/tags/feat "$MAIN_SHA" || { bad "$name: update-ref"; return; }
git -C "$R5" checkout -q feat 2> "$TMP/tag-checkout.err" || { bad "$name: checkout feat: $(cat "$TMP/tag-checkout.err")"; return; }
rc="$(writeev "$R5" feat "$TMP/tag-feat-project" "$TMP/tag-ev2.out")"
git -C "$R5" checkout -q main || { bad "$name: checkout main"; return; }
written="$(jfield "$TMP/tag-ev2.out" 'j.written')"
why="$(jfield "$TMP/tag-ev2.out" 'j.reason')"
case "$why" in
*'branch name feat is ambiguous'*) ;;
*) bad "$name: the writer exited $rc, written [$written]: $(cat "$TMP/tag-ev2.out")"; return ;;
esac
if [ "$rc" -ne 1 ] || [ "$written" != false ]; then
bad "$name: the writer exited $rc, written [$written]: $(cat "$TMP/tag-ev2.out")"
return
fi
askhook "$R5" 'git merge --no-ff feat' tag
if [ "$HOOK_RC" -ne 0 ] || [ "$DECISION" != ask ]; then
bad "$name: the guard exited $HOOK_RC with decision [$DECISION]: $(cat "$TMP/tag-hook.out" "$TMP/tag-hook.err")"
return
fi
case "$REASON" in
*'Tested by: End-to-end'*) bad "$name: a Tested by: End-to-end line is shown: $REASON" ;;
*"$NO_EVIDENCE"*) pass "$name" ;;
*) bad "$name: no [$NO_EVIDENCE] in: $REASON" ;;
esac
}
case_worktree() {
name="a write from a worktree whose HEAD lags the branch reads review not measured"
R6="$TMP/wt"
mkrepo "$R6" || { bad "$name: setup"; return; }
REVIEWED="$FEAT"
mkproject "$TMP/wt-project" "$MAIN_SHA" "$REVIEWED" || { bad "$name: could not write the project"; return; }
git -C "$R6" worktree add -q --detach "$TMP/wt-second" "$REVIEWED" || { bad "$name: worktree add"; return; }
# The branch moves on in the first worktree.
git -C "$R6" checkout -q feat || { bad "$name: checkout feat"; return; }
printf 'export const b = 2;\n' > "$R6/b.mjs"
git -C "$R6" add b.mjs && git -C "$R6" commit -q -m 'feat: more' || { bad "$name: commit"; return; }
HEAD2="$(git -C "$R6" rev-parse HEAD)"
git -C "$R6" checkout -q main || { bad "$name: checkout main"; return; }
if [ "$(git -C "$TMP/wt-second" rev-parse HEAD)" != "$REVIEWED" ] || [ "$HEAD2" = "$REVIEWED" ]; then
bad "$name: Control: the second worktree's HEAD does not lag the branch"
return
fi
rc="$(writeev "$TMP/wt-second" feat "$TMP/wt-project" "$TMP/wt-ev.out")"
if [ "$rc" -ne 0 ] || [ "$(jfield "$TMP/wt-ev.out" 'j.written')" != true ] || [ ! -f "$R6/.git/voyage/merge-evidence/$HEAD2.json" ]; then
bad "$name: the writer exited $rc: $(cat "$TMP/wt-ev.out")"
return
fi
askhook "$R6" 'git merge --no-ff feat' wt
sha8="$(printf '%s' "$REVIEWED" | cut -c1-8)"
if [ "$HOOK_RC" -ne 0 ] || [ "$DECISION" != ask ]; then
bad "$name: the guard exited $HOOK_RC with decision [$DECISION]: $(cat "$TMP/wt-hook.out" "$TMP/wt-hook.err")"
return
fi
case "$REASON" in
*'review ALLOW'*) bad "$name: a review verdict is shown: $REASON" ;;
*"review not measured at $sha8."*) pass "$name" ;;
*) bad "$name: no [review not measured at $sha8.] in: $REASON" ;;
esac
}
case_order
case_two
case_cleanup
case_message
case_tag
case_worktree
if [ "$FAILED" -ne 0 ]; then
echo "e2e: $FAILED case(s) failed"
exit 1
fi
echo "e2e: ok"
exit 0