pmacs/scripts/gate

599 lines
26 KiB
Bash
Executable File

#!/bin/sh
# scripts/gate --- run the fixed gate suite in a per-worktree build
# directory, with isolated ambient roots and durable logs.
#
# scripts/gate [--acceptance SUITE]... [--protocol] [--print-plan]
# scripts/gate [--acceptance SUITE]... [--protocol] --print-plan-named
# scripts/gate --print-target-dir
# scripts/gate --init
# scripts/gate --prune [--force]
# scripts/gate --self-test
#
# Framing: docs/gate-script-framing.md (revision 4, approved), and
# docs/gate-protocol-build-framing.md (revision 5, approved) for the
# crdt build step, --self-test and --print-plan-named.
#
# WHY A PER-WORKTREE TARGET DIRECTORY. This machine exports one
# CARGO_TARGET_DIR for every checkout, and cargo takes an EXCLUSIVE LOCK
# on it. Two worktrees building at once do not run in parallel --- the
# second blocks --- and they invalidate each other's artifacts, so
# alternating between lanes recompiles from scratch each time. Parallel
# worktree development under that arrangement is slower than serial.
# Measured cost of the fix: a cold `cargo test --workspace --no-run` is
# 80s and 19G, so per-lane directories are cheap and there is no shared
# cache worth preserving.
#
# NOTE ON PRECEDENCE, because the obvious alternative silently fails.
# CARGO_TARGET_DIR (the environment) OVERRIDES `build.target-dir` in any
# config.toml. A per-worktree `.cargo/config.toml` therefore does
# nothing at all while that variable is exported --- which is worse than
# doing nothing visibly, because it looks configured. Only a
# per-invocation value beats the environment, which is why this exists
# as a script rather than a config file.
#
# THIS IS A CONVENTION, NOT AN ENFORCEMENT. A bare `cargo test` still
# uses the shared directory and still takes the lock. Nothing in-repo
# can prevent that while the variable is exported globally; real
# enforcement would mean dropping the export or adopting direnv, both
# machine-config changes outside this repository.
set -eu
usage() {
cat >&2 <<'EOF'
usage: scripts/gate [--acceptance SUITE]... [--protocol] [--print-plan]
scripts/gate [--acceptance SUITE]... [--protocol] --print-plan-named
scripts/gate --print-target-dir
scripts/gate --init
scripts/gate --prune [--force]
scripts/gate --self-test
--acceptance SUITE a touched acceptance suite to run (repeatable).
docs/agent-handoff.md section 3 stays authoritative
for CHOOSING these; this script only runs what it
is handed. A script cannot infer them from a
working tree, and one that guessed would report
coverage it does not have.
--protocol the change touches PROTOCOL_VERSION; adds the CRDT
workspace sweep on top of the default one, plus
the build that sweep needs (see build-crdt below).
--print-plan print the exact gate commands and exit. Names are
stripped, so every line is runnable as printed.
--print-plan-named print the plan as `name<TAB>command` lines and exit
--- the same text the runner reads. Exists because
a step's NAME is half its contract (a build failure
must be attributed to build-crdt, not sweep-crdt)
and --print-plan cannot show it.
--print-target-dir print this worktree's build directory and exit.
Creates nothing.
--init create the build directory and ownership marker,
print it, exit. Runs no gates.
--prune list managed directories whose worktree is gone.
Deletes NOTHING without --force.
--force with --prune, actually delete.
--self-test drive the real runner with a HARDCODED synthetic
plan --- true, false, true --- to witness that a
failing gate is named as ITSELF and that the suite
CONTINUES past it. Runs no real gates. EXITS
NON-ZERO BY DESIGN: the middle step fails on
purpose, so a non-zero status is this mode
working, not this mode broken.
EOF
exit 2
}
# ---------------------------------------------------------------------
# One canonical path representation.
#
# `pwd -P` semantics: physical, symlinks resolved. Used at ALL THREE
# points that compare or derive from a worktree path --- directory
# derivation, marker creation, and prune comparison. Using it at only
# some of them is a real bug and not a tidiness point: a symlinked
# invocation would then derive a DIFFERENT directory whose marker
# records the CANONICAL path, producing a second build directory for a
# live worktree that is indistinguishable from an orphan.
#
# A path that cannot be entered yields the empty string, which never
# matches a marker --- correct, since a directory that is gone is not a
# live worktree.
#
# HONEST NOTE ON HOW MUCH THIS CURRENTLY DOES. Measured on the git in
# use here, BOTH `git rev-parse --show-toplevel` and
# `git worktree list --porcelain` already report resolved physical
# paths, including when a worktree was registered through a symlinked
# parent. So on this git, canon() is defence in depth rather than
# load-bearing, and the acceptance test for symlinked spellings passes
# with or without it --- recorded so nobody reads a mutation-test
# "vacuous" result as a hole in the suite.
#
# It stays because the property it guarantees is one this script's
# correctness rests on, that guarantee is not contractual in git, and
# a marker can also be written by hand. The cost is one subshell.
# ---------------------------------------------------------------------
canon() {
( cd "$1" 2>/dev/null && pwd -P ) || true
}
# Short digest of a string. sha256sum on Linux, shasum on macOS, and
# cksum as the POSIX floor so this degrades rather than failing.
digest8() {
if command -v sha256sum >/dev/null 2>&1; then
printf '%s' "$1" | sha256sum | cut -c1-8
elif command -v shasum >/dev/null 2>&1; then
printf '%s' "$1" | shasum -a 256 | cut -c1-8
else
printf '%s' "$1" | cksum | awk '{printf "%08x", $1}'
fi
}
MARKER_NAME='.pmacs-gate-target'
# ---------------------------------------------------------------------
# Acceptance suite names are INTERPOLATED INTO A COMMAND that the runner
# evaluates, so they are an injection surface and are validated as one.
# `--acceptance 'x; rm -rf ~'` must be refused, not executed.
#
# The allowlist is what a cargo test target can actually be named:
# letters, digits, underscore, hyphen. That excludes every shell
# metacharacter, whitespace, and path separators, so nothing reaching
# the plan can be anything but a bare target name. A leading hyphen is
# refused separately --- it is not dangerous, it would just become a
# stray flag to cargo and fail confusingly.
# ---------------------------------------------------------------------
validate_suite() {
case $1 in
'')
echo "gate: --acceptance needs a suite name" >&2
exit 2 ;;
-*)
echo "gate: acceptance suite may not start with '-': $1" >&2
exit 2 ;;
*[!A-Za-z0-9_-]*)
echo "gate: refusing acceptance suite name: $1" >&2
echo "gate: names are cargo test targets --- letters, digits," >&2
echo "gate: underscore and hyphen only. The name is interpolated" >&2
echo "gate: into a command, so anything else is rejected rather" >&2
echo "gate: than escaped." >&2
exit 2 ;;
esac
}
# The managed root. PMACS_GATE_TARGET_ROOT exists for the behaviour
# tests and is documented test-only: it is what keeps them from being
# able to reach the real root.
gate_root() {
if [ -n "${PMACS_GATE_TARGET_ROOT:-}" ]; then
printf '%s' "$PMACS_GATE_TARGET_ROOT"
else
printf '%s' "${HOME}/build/pmacs-gate-targets"
fi
}
worktree_root() {
git rev-parse --show-toplevel 2>/dev/null || {
echo "gate: not inside a git worktree" >&2
exit 2
}
}
# The derived build directory for this worktree. Pure: creates nothing.
target_dir_for() {
_wt=$1
_base=$(basename "$_wt")
printf '%s/%s-%s' "$(gate_root)" "$_base" "$(digest8 "$_wt")"
}
# Create the directory and its ownership marker. Idempotent. The gate
# path calls this too, so --init is not a second implementation.
ensure_target_dir() {
_wt=$1
_dir=$(target_dir_for "$_wt")
mkdir -p "$_dir"
printf '%s\n' "$_wt" > "$_dir/$MARKER_NAME"
printf '%s' "$_dir"
}
# ---------------------------------------------------------------------
# The plan.
#
# Emits one `name<TAB>command` line per gate. docs/agent-handoff.md
# section 3 owns the REASONING for each of these --- why --workspace and
# never --tests, why --no-fail-fast, why --skip basedpyright, when
# --protocol applies. What lives here is the executable form, so it
# cannot be retyped differently each time.
#
# --print-plan renders this without running anything, which is what
# makes drift from section 3 testable.
# ---------------------------------------------------------------------
emit_plan() {
printf 'fmt\tcargo fmt --check\n'
printf 'clippy\tcargo clippy --workspace --all-targets -- -D warnings\n'
printf 'lib\tcargo test --lib\n'
printf 'lib-crdt\tcargo test --lib --features crdt\n'
for _s in $ACCEPTANCE; do
printf 'acceptance-%s\tcargo test --test %s\n' "$_s" "$_s"
done
printf 'm4\tcargo test --test m4_acceptance -- --skip basedpyright\n'
printf 'gpu\tPMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu\n'
printf 'sweep\tcargo test --workspace --no-fail-fast -- --skip basedpyright\n'
if [ "$PROTOCOL" = 1 ]; then
# Section 3: touching PROTOCOL_VERSION STRENGTHENS the sweep
# line, it does not replace it. Both sweeps run.
#
# THE BUILD IS A PRECONDITION OF THE SWEEP, not a courtesy. The
# crdt sweep spawns `pmacs-gpu` as a PROCESS, and no `cargo
# test` run produces that binary: pmacs-gpu has no tests/
# directory, so cargo never uplifts its bin to debug/pmacs-gpu.
# On a cold target directory the sweep therefore fails twelve
# gpu_invocation_acceptance::crdt::* tests on "build pmacs-gpu
# before this acceptance suite" --- and, worse, other crdt tests
# that render through the real binary SKIP THEMSELVES and report
# ok, so the missing build also voids coverage silently.
#
# WHY ONLY UNDER --protocol, measured rather than reasoned. On
# 2026-08-09, on a disposable target directory with
# debug/pmacs-gpu asserted ABSENT before each run and each sweep
# run alone from that cold state: the DEFAULT sweep exited 0 and
# left debug/pmacs-gpu still absent --- it never builds the
# binary and never needs it --- while the crdt sweep exited 101
# with exactly those twelve failures. So the default gate does
# not pay for this build.
#
# A SEPARATE NAMED STEP, never folded into the sweep command.
# `cargo build ... && cargo test ...` would report a BUILD
# failure under the name `sweep-crdt`, which is a wrong
# attribution in the one place this script exists to be
# trustworthy about. --self-test is what witnesses that the
# runner names the failing gate as itself.
printf 'build-crdt\tcargo build --workspace --no-default-features --features luajit,crdt\n'
printf 'sweep-crdt\tcargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright\n'
fi
printf 'diff-check\tgit diff --check\n'
}
# ---------------------------------------------------------------------
# The synthetic plan behind --self-test: the runner held to its own
# contract.
#
# WHY A MODE EXISTS AT ALL. tests/gate_script_acceptance.rs drives only
# NO-GATES paths --- a test that ran the real suite would run the gate
# suite inside the gate suite --- so plan assertions can prove a step's
# name and its order and NOTHING about what the runner does when a step
# fails. That left two stated properties with no way to observe them:
# a failing gate is attributed to ITSELF (which is the whole reason
# build-crdt is a separate step rather than `cargo build && cargo
# test`), and the suite CONTINUES past it rather than aborting. Same
# shape of argument as --init: verification needs a path it can drive
# safely, and this one is not a second implementation --- it hands the
# REAL runner loop a different plan file.
#
# THE PLAN IS A LITERAL, and that is the design, not a shortcut. The
# obvious seam --- letting a caller supply PLAN_FILE --- would work,
# and it would turn the runner's `eval` into a general command
# executor. That is the same class of defect this script's own review
# caught in --acceptance and fixed with a refusal at parse time;
# reintroducing it in the tool whose purpose is to be trustworthy is
# not a trade worth making. Nothing external supplies a command here.
#
# THREE LINES, AND THE THIRD IS LOAD-BEARING. With the failure LAST, a
# runner that aborts and a runner that continues produce IDENTICAL
# output, so the witness would pass on a runner doing the opposite of
# the stated policy. The sentinel after the failure, asserted to have
# written its own log, is the only thing that separates them.
#
# `true` and `false` are the entire workload, so this stays on the
# cheap side of the suite. Whether `cargo build` really fails is
# cargo's business; whether THIS SCRIPT names the right gate when a
# command fails is the criterion, and that is orthogonal to which
# command failed.
# ---------------------------------------------------------------------
emit_self_test_plan() {
printf 'self-pass\ttrue\n'
printf 'build-crdt\tfalse\n'
printf 'self-sentinel\ttrue\n'
}
# ---------------------------------------------------------------------
# Pruning.
#
# Dry run by default; --force to delete. Never automatic, never on the
# gate path.
#
# "LIVE" IS NARROWER THAN "LISTED", and that difference is the whole
# correctness of this. `git worktree list --porcelain` keeps reporting a
# worktree that was administratively registered but whose directory was
# manually deleted --- it adds a `prunable <reason>` line to that
# record. Treating every listed path as live would make exactly the
# directories most worth reclaiming permanently ineligible.
# ---------------------------------------------------------------------
#
# FAILS LOUDLY RATHER THAN RETURNING NOTHING. An empty live set means
# "every managed directory is an orphan", so a silent failure here is a
# command to delete all of them. The first version piped git straight
# into awk and the caller masked the result with `|| true`; run from
# outside any repository that produced an empty set and
# `--prune --force` would have deleted every marked directory. The
# capture-then-check shape below is what makes that unrepresentable.
live_worktrees() {
_porc=$(git worktree list --porcelain 2>/dev/null) || return 1
# A repository always has at least its own worktree, so empty output
# is a failure, not an answer.
[ -n "$_porc" ] || return 1
# Records are blank-line separated. Emit the canonical path of every
# record that carries NO `prunable` line.
printf '%s\n' "$_porc" | awk '
/^worktree / { path = substr($0, 10); prunable = 0; next }
/^prunable/ { prunable = 1; next }
/^$/ { if (path != "" && !prunable) print path; path = ""; next }
END { if (path != "" && !prunable) print path }
' | while IFS= read -r p; do
c=$(canon "$p")
[ -n "$c" ] && printf '%s\n' "$c"
done
}
do_prune() {
# SAFETY GATE, and it is the most important thing in this file.
# Pruning decides what to DELETE by subtracting the live worktree
# set from the managed root. If that set cannot be established, the
# correct answer is not "nothing is live" --- it is "refuse".
if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
echo "gate: refusing to prune --- not inside a git worktree." >&2
echo "gate: the live-worktree set cannot be established from here," >&2
echo "gate: and an empty one would mark every managed directory an" >&2
echo "gate: orphan. Run --prune from inside a checkout." >&2
exit 2
fi
if ! _live=$(live_worktrees); then
echo "gate: refusing to prune --- could not enumerate git worktrees." >&2
echo "gate: nothing was examined and nothing was deleted." >&2
exit 2
fi
_root=$(gate_root)
if [ ! -d "$_root" ]; then
echo "gate: no managed root at $_root; nothing to prune"
return 0
fi
_found=0
for _d in "$_root"/*; do
[ -d "$_d" ] || continue
_found=1
if [ ! -r "$_d/$MARKER_NAME" ]; then
# The protection that matters: a directory that merely
# RESEMBLES a managed one is never touched.
echo "gate: skip $_d --- no readable $MARKER_NAME"
continue
fi
# The marker is DOCUMENTED as one line, so enforce that rather
# than reading the first line of whatever is there. Reading only
# line 1 would accept a multi-line file and act on its head ---
# which is exactly the shape a corrupted or hand-edited marker
# takes, and acting on it means deleting a directory on the
# strength of a file we did not understand.
_lines=$(wc -l < "$_d/$MARKER_NAME" 2>/dev/null || echo 0)
if [ "$_lines" -ne 1 ]; then
echo "gate: skip $_d --- marker is not exactly one line ($_lines)"
continue
fi
_owner=$(cat "$_d/$MARKER_NAME" 2>/dev/null || true)
case $_owner in
/*) ;;
*) echo "gate: skip $_d --- marker is not an absolute path"
continue ;;
esac
if printf '%s\n' "$_live" | grep -qxF "$_owner"; then
echo "gate: skip $_d --- worktree is live ($_owner)"
continue
fi
if [ "$FORCE" = 1 ]; then
rm -rf "$_d"
echo "gate: deleted $_d (worktree gone: $_owner)"
else
echo "gate: WOULD delete $_d (worktree gone: $_owner)"
fi
done
[ "$_found" = 1 ] || echo "gate: managed root is empty"
if [ "$FORCE" != 1 ]; then
echo "gate: dry run --- nothing was deleted. Re-run with --force."
fi
}
# ---------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------
ACCEPTANCE=''
PROTOCOL=0
FORCE=0
MODE=run
while [ $# -gt 0 ]; do
case $1 in
--acceptance)
[ $# -ge 2 ] || usage
validate_suite "$2"
ACCEPTANCE="$ACCEPTANCE $2"
shift 2 ;;
--protocol) PROTOCOL=1; shift ;;
--print-plan) MODE=plan; shift ;;
--print-plan-named) MODE=plannamed; shift ;;
--print-target-dir) MODE=printdir; shift ;;
--init) MODE=init; shift ;;
--prune) MODE=prune; shift ;;
--self-test) MODE=selftest; shift ;;
--force) FORCE=1; shift ;;
-h|--help) usage ;;
*) echo "gate: unknown argument: $1" >&2; usage ;;
esac
done
# ---------------------------------------------------------------------
# Mode dispatch.
#
# TWO RENDERINGS OF ONE PLAN, and the second exists because the first
# hid a defect. `--print-plan` pipes through `cut -f2-` so every printed
# line is a command a reader can copy and run --- and that same cut is
# why the emitted NAMES never reached a test. Not cosmetic: the entire
# reason build-crdt is a separate step is that a build failure must be
# attributed to `build-crdt` rather than to `sweep-crdt`, and with the
# names stripped, RENAMING THE REAL BUILD STEP TO `sweep-crdt` left the
# ordering assertion green. The witness could not reach the step it
# named. --self-test could not either --- it hardcodes the string
# `build-crdt` in its own synthetic plan, which proves things about the
# RUNNER and nothing about this emitter.
#
# --print-plan-named prints emit_plan VERBATIM: the same
# `name<TAB>command` text the runner reads back from PLAN_FILE, so a
# test can assert both halves of a real step together and a rename
# cannot pass.
#
# WHY THIS RATHER THAN THE TWO ALTERNATIVES.
#
# Injecting PLAN_FILE would let a test hand the runner a plan and read
# the names back, and it would turn the runner's `eval` into a general
# command executor --- the same class of defect this script's own
# review caught in --acceptance and fixed with a parse-time refusal.
# Declined there; declined here for the same reason.
#
# Re-deriving the plan test-side (sourcing this file, or parsing
# emit_plan out of it) would be a SECOND implementation of the thing
# under test, which is the exact failure being repaired one level up.
#
# A DISTINCT MODE, not a modifier on --print-plan: there is then no
# `--with-names` without `--print-plan` whose behaviour has to be
# defined, and --print-plan's contract --- runnable lines --- is left
# exactly as it was. Both modes call emit_plan, and so does the runner,
# so neither rendering can drift from what actually executes;
# tests/gate_script_acceptance.rs pins that the stripped rendering is
# the named one minus its names, so this stays true by test and not
# only by reading.
# ---------------------------------------------------------------------
case $MODE in
plan)
emit_plan | cut -f2-
exit 0 ;;
plannamed)
emit_plan
exit 0 ;;
printdir)
target_dir_for "$(canon "$(worktree_root)")"
echo
exit 0 ;;
init)
ensure_target_dir "$(canon "$(worktree_root)")"
echo
exit 0 ;;
prune)
do_prune
exit 0 ;;
esac
# ---------------------------------------------------------------------
# Run
# ---------------------------------------------------------------------
WT=$(canon "$(worktree_root)")
cd "$WT"
TARGET=$(ensure_target_dir "$WT")
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
# $$ as well as the timestamp: two invocations in the same worktree
# within one second would otherwise share a log directory and overwrite
# each other's evidence --- which is precisely the U2/U3 failure this
# logging exists to prevent, reintroduced by a naming choice.
LOGDIR="$TARGET/gate-logs/$STAMP-$$"
mkdir -p "$LOGDIR"
# Ambient roots: all five, one fresh directory, reaped on exit.
#
# THE FIFTH IS NOT REDUNDANT. PMACS_STATE_HOME outranks XDG_STATE_HOME
# (src/state.rs), so redirecting only the four XDG variables leaves the
# real state root live on a machine that exports it.
#
# Belt and braces rather than a workaround: ambient-root isolation
# merged as #206 and the in-crate paths resolve roots explicitly. What
# this covers is everything OUTSIDE that guarantee --- integration
# suites that spawn the real binary, PTY and daemon fixtures, anything
# reaching a production resolution path --- where the process under test
# reads the environment it was handed. HOME is deliberately left alone.
AMBIENT="$TARGET/gate-ambient/$STAMP-$$"
mkdir -p "$AMBIENT"
cleanup() { rm -rf "$AMBIENT"; }
trap cleanup EXIT INT TERM
export CARGO_TARGET_DIR="$TARGET"
export XDG_CONFIG_HOME="$AMBIENT" XDG_DATA_HOME="$AMBIENT" \
XDG_STATE_HOME="$AMBIENT" XDG_CACHE_HOME="$AMBIENT" \
PMACS_STATE_HOME="$AMBIENT"
echo "gate: worktree $WT"
echo "gate: target dir $TARGET"
echo "gate: logs $LOGDIR"
# Printed so the isolation is OBSERVABLE rather than merely asserted:
# this is the directory all five ambient roots point at, and the exit
# trap removes it, so it should be gone once the run finishes.
echo "gate: ambient $AMBIENT"
[ -n "$ACCEPTANCE" ] && echo "gate: acceptance $ACCEPTANCE"
[ "$PROTOCOL" = 1 ] && echo "gate: protocol yes (CRDT build + workspace sweep added)"
if [ "$MODE" = selftest ]; then
echo "gate: SELF-TEST hardcoded synthetic plan --- NO real gate runs."
echo "gate: the middle step fails ON PURPOSE, so a non-zero"
echo "gate: exit is this mode working, not this mode broken."
fi
echo
# The self-test hands the REAL runner loop below a different plan file.
# Everything after this point is shared, which is the point: a witness
# that exercised its own copy of the runner would witness nothing.
PLAN_FILE="$LOGDIR/plan.txt"
if [ "$MODE" = selftest ]; then
emit_self_test_plan > "$PLAN_FILE"
else
emit_plan > "$PLAN_FILE"
fi
N=0
FAILED=''
while IFS="$(printf '\t')" read -r name cmd; do
N=$((N + 1))
log=$(printf '%s/%02d-%s.log' "$LOGDIR" "$N" "$name")
printf 'gate: [%02d] %-14s ' "$N" "$name"
# THE RUNNER MUST SURVIVE `set -e`, and the obvious forms do not.
#
# cmd | tee log --- reports TEE's status, so a failing gate
# exits 0 and the suite reads green.
# `set -o pipefail` is not POSIX; dash
# lacks it and this script targets sh.
# cmd > log; rc=$? --- under `set -e` the shell exits AT the
# failing command, so `rc=$?` never runs
# and nothing prints which gate failed or
# where its log is --- destroying the
# entire point of capturing it.
#
# A failing command is exempt from `set -e` only as an `if`
# condition. Hence this shape: no pipeline, so no status is lost to
# tee; no bare failing command, so no status is lost to set -e.
if eval "$cmd" > "$log" 2>&1; then
echo "ok"
else
rc=$?
echo "FAILED (exit $rc)"
echo "gate: log: $log" >&2
FAILED="$FAILED $name"
fi
done < "$PLAN_FILE"
echo
echo "gate: sweep logs (the U2/U3 remedy --- read these, do not re-run and grep):"
for f in "$LOGDIR"/*-sweep.log "$LOGDIR"/*-sweep-crdt.log; do
[ -f "$f" ] && echo "gate: $f"
done
if [ -n "$FAILED" ]; then
echo >&2
echo "gate: FAILED:$FAILED" >&2
echo "gate: logs in $LOGDIR" >&2
exit 1
fi
echo
echo "gate: all gates passed"