voyage/tests/e2e-fix-round-4-5b.sh
2026-10-26 17:27:33 +01:00

495 lines
20 KiB
Bash

#!/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 <review> --repo):
# a. the `--repo <dir>` form and b. the `--repo=<dir>` 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 - <case>" or "FAIL - <case>: <why>" 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 <dir> [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 <js expression on j>: 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 <path> [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 '<!-- voyage:review-lessons:start -->\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<!-- voyage:review-lessons:end -->\n' "$2" > "$1"
else
printf '<!-- voyage:review-lessons:start -->\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<!-- voyage:review-lessons:end -->\n' > "$1"
fi
}
# gatereview <path> <sha>: an ALLOW review.md of commit <sha>.
gatereview() {
cat > "$1" <<EOF
---
type: trekreview
review_version: "1.1"
task: "t"
slug: s
project_dir: .claude/projects/2026-10-25-s/
brief_path: .claude/projects/2026-10-25-s/brief.md
scope_sha_end: $2
reviewed_files_count: 1
verdict: ALLOW
findings: []
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|---|---|---|
| a.mjs | summary-only | default |
## Remediation Summary
x
\`\`\`json
{
"verdict": "ALLOW",
"counts": { "BLOCKER": 0, "MAJOR": 0, "MINOR": 0, "SUGGESTION": 0 },
"allow_blocked_by": [],
"findings": []
}
\`\`\`
EOF
}
# gaterepo <dir> [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 <dir> and --repo=<dir>: fixture repo"; bad "verdict gate --repo=<dir>: 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 <dir>: a review of HEAD exited $c1, not 0: $out1"
elif [ "$c3" -ne 2 ] || ! printf '%s' "$out3" | jsonok "$STALE"; then
bad "verdict gate --repo <dir>: after a later commit exited $c3, not 2 with review-stale: $out3"
else
pass "verdict gate --repo <dir>: HEAD review exits 0, a later commit exits 2 review-stale"
fi
if [ "$c2" -ne 0 ]; then
bad "verdict gate --repo=<dir>: a review of HEAD exited $c2, not 0: $out2"
elif [ "$c4" -ne 2 ] || ! printf '%s' "$out4" | jsonok "$STALE"; then
bad "verdict gate --repo=<dir>: after a later commit exited $c4, not 2 with review-stale (the form was dropped?): $out4"
else
pass "verdict gate --repo=<dir>: 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 <start_sha> <file>: 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 <dir>: 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 <file> <slug> <sha> <id> <n>: a review.md with one MISSING_TEST finding.
lessonreview() {
mkdir -p "$(dirname "$1")" || return 1
fid="$(printf '%s0000000000000000000000000000000000000000' "$4" | cut -c1-40)"
cat > "$1" <<EOF
---
type: trekreview
review_version: "1.1"
task: "$2"
slug: $2
project_dir: .claude/projects/2026-10-0$5-$2/
brief_path: .claude/projects/x/brief.md
scope_sha_end: $3
reviewed_files_count: 1
verdict: WARN
findings:
- $fid
---
# Review: $2
## Executive Summary
x
## Coverage
x
## Remediation Summary
x
\`\`\`json
{
"verdict": "WARN",
"counts": { "BLOCKER": 0, "MAJOR": 1, "MINOR": 0, "SUGGESTION": 0 },
"findings": [
{ "id": "$fid", "severity": "MAJOR", "rule_key": "MISSING_TEST", "file": "lib/a.mjs", "line": 3,
"title": "MISSING_TEST title $4", "detail": "d", "recommended_action": "Fix it ($4)." }
]
}
\`\`\`
EOF
}
# lessonrepo <dir>: 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