Files
settled-reach/.config/hooks/pre-push
T
jpmschweitzerandClaude bf1976613f chore(config): enforce the parse sweep at the push gate, ahead of the suite
Placed in the pre-push hook rather than /pr-process, because the hook is where
enforcement actually lives — and notably the hook never ran godot-cold-parse
at all, so until now nothing enforced "does this script parse" for any file
outside the startup path.

Ordered BEFORE the test suite deliberately. That makes failures cheaper rather
than the gate slower: a script that does not parse is caught in ~4s instead of
after ~135s of tests that could never have covered it. A clean push pays 3.7s;
a broken one saves over two minutes.

Not redundant with the suite. gdUnit4 reports the suites that DID load as a
clean pass, so an unparseable file reads as success — guarded now in
tests/run-godot, but only for test files. The sweep covers all 226 scripts,
including the roughly half of the codebase no test ever loads.

/pr-process gains a scope note instead of a second invocation: cold-parse sees
only the startup path and filters "Cannot infer the type" (which hid a
genuinely broken file for five months), so it must not be read as a general
parse check. Per team-patterns.md the skill does not duplicate the gate.

Hooks run from .config/hooks via core.hooksPath, so this is live without an
install step.

Pair session with Jeroen, 2026-07-27.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 00:42:19 +02:00

299 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# Pre-push hook: lint GDScript and Rust before pushing.
# Installed via: git config core.hooksPath .config/hooks
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
ERRORS=0
echo "pre-push: running lint checks..."
# --- Detect which directories have changes vs remote ---
# Prefer origin/<branch> as the baseline (what the remote already has),
# but fall back to origin/main for first-push of a new branch — otherwise
# every check runs against nothing and the hook treats the whole repo as
# changed, spending tens of seconds on linters and JSON validation that
# have no diff to cover (e.g. pushing a wiki-only branch rebuilds GDScript
# and runs clippy + ruff + validates all 2762 JSON files).
BRANCH=$(git branch --show-current)
if git rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then
REMOTE_REF="origin/$BRANCH"
elif git rev-parse --verify "origin/main" >/dev/null 2>&1; then
REMOTE_REF="origin/main"
else
REMOTE_REF=""
fi
if [ -n "$REMOTE_REF" ]; then
CLIENT_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- client/ 2>/dev/null | wc -l)
SERVER_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- server/ 2>/dev/null | wc -l)
TOOLING_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- tooling/ pyproject.toml 2>/dev/null | wc -l)
else
# No remote at all (e.g. fresh clone before first fetch) — be safe, run everything
CLIENT_CHANGED=1
SERVER_CHANGED=1
TOOLING_CHANGED=1
fi
# --- GDScript parse check (headless Godot) ---
GODOT="${GODOT:-godot}"
if [ "$CLIENT_CHANGED" -eq 0 ]; then
echo "pre-push: no client/ changes — skipping GDScript checks"
elif command -v "$GODOT" >/dev/null 2>&1 && [ -d "$REPO_ROOT/client/.godot" ]; then
echo "pre-push: checking GDScript (parse)..."
SCRIPT_ERRORS=$("$GODOT" --headless --path "$REPO_ROOT/client" --quit 2>&1 | grep -ci "SCRIPT ERROR" || true)
if [ "$SCRIPT_ERRORS" -gt 0 ]; then
echo "pre-push: FAIL — $SCRIPT_ERRORS GDScript error(s) found"
"$GODOT" --headless --path "$REPO_ROOT/client" --quit 2>&1 | grep -i "SCRIPT ERROR"
ERRORS=$((ERRORS + 1))
else
echo "pre-push: GDScript parse — OK"
fi
else
echo "pre-push: skipping GDScript parse (no .godot/ import — run Godot once to enable)"
fi
# --- GDScript lint (gdlint static analysis) — advisory only until codebase is clean ---
if [ "$CLIENT_CHANGED" -gt 0 ] && command -v gdlint >/dev/null 2>&1 && [ -d "$REPO_ROOT/client/scripts" ]; then
echo "pre-push: checking GDScript (gdlint — advisory)..."
LINT_COUNT=$(gdlint "$REPO_ROOT/client/scripts/" "$REPO_ROOT/client/ui/" 2>&1 | grep -c "Error:" || true)
if [ "$LINT_COUNT" -gt 0 ]; then
echo "pre-push: gdlint — $LINT_COUNT issue(s) (advisory, not blocking)"
else
echo "pre-push: gdlint — OK"
fi
fi
# --- GDScript format check (gdformat) — advisory only until codebase is clean ---
if [ "$CLIENT_CHANGED" -gt 0 ] && command -v gdformat >/dev/null 2>&1 && [ -d "$REPO_ROOT/client/scripts" ]; then
echo "pre-push: checking GDScript (gdformat — advisory)..."
FORMAT_COUNT=$(gdformat --check "$REPO_ROOT/client/scripts/" "$REPO_ROOT/client/ui/" 2>&1 | grep -c "would reformat" || true)
if [ "$FORMAT_COUNT" -gt 0 ]; then
echo "pre-push: gdformat — $FORMAT_COUNT file(s) need formatting (advisory, not blocking)"
else
echo "pre-push: gdformat — OK"
fi
fi
# --- GDScript parse sweep — blocking, and deliberately BEFORE the suite ---
# Every project .gd must parse. ~3.7s against the suite's ~135s, and it runs
# first because that ordering makes failures CHEAPER, not the gate slower: a
# script that does not parse is now caught in 4s instead of after two minutes
# of tests that could never have covered it. A clean push pays 3.7s; a broken
# one saves over two minutes.
#
# Not redundant with the suite. gdUnit4 reports the suites that DID load as a
# clean pass, so a file that fails to parse reads as success (guarded now in
# tests/run-godot, but only for test files). This covers all 226 scripts,
# including the ~half of the codebase no test ever loads. It is also the only
# gate that covers them at all: godot-cold-parse is skill-only (never invoked
# by this hook) and sees only the startup path regardless.
if [ "$CLIENT_CHANGED" -gt 0 ] && [ -x "$REPO_ROOT/tooling/godot-parse-sweep" ]; then
echo "pre-push: sweeping GDScript parse (tooling/godot-parse-sweep)..."
if ! "$REPO_ROOT/tooling/godot-parse-sweep"; then
echo "pre-push: parse sweep FAILED — a script does not parse"
ERRORS=$((ERRORS + 1))
else
echo "pre-push: parse sweep — OK"
fi
fi
# --- Godot client test suite (gdUnit4) — blocking (T-1065) ---
# The push gate is the only automatic verification (no CI). The suite is
# ~100s, 300s-capped, and was made fully green by the T-973 debt clearance;
# binary-dependent e2e suites self-skip when the server binary is absent.
if [ "$CLIENT_CHANGED" -gt 0 ] && [ -x "$REPO_ROOT/tests/run-godot" ]; then
echo "pre-push: running client test suite (tests/run-godot)..."
if ! "$REPO_ROOT/tests/run-godot"; then
echo "pre-push: client test suite FAILED"
ERRORS=$((ERRORS + 1))
else
echo "pre-push: client tests — OK"
fi
fi
# --- Rust lint (clippy + fmt) ---
if [ "$SERVER_CHANGED" -eq 0 ]; then
echo "pre-push: no server/ changes — skipping Rust checks"
elif command -v cargo >/dev/null 2>&1 && [ -d "$REPO_ROOT/server" ]; then
# fmt only needs source files — always safe to run
echo "pre-push: checking Rust (fmt)..."
if ! (cd "$REPO_ROOT/server" && cargo fmt --check 2>&1); then
ERRORS=$((ERRORS + 1))
else
echo "pre-push: fmt — OK"
fi
# clippy needs a build — skip if target/ doesn't exist (cold worktree).
# --all-targets lints tests + examples too (#967 closed the gap where test
# code accumulated clippy debt unchecked).
if [ -d "$REPO_ROOT/server/target" ]; then
echo "pre-push: checking Rust (clippy)..."
if ! (cd "$REPO_ROOT/server" && cargo clippy --all-targets -- -D warnings 2>&1); then
ERRORS=$((ERRORS + 1))
else
echo "pre-push: clippy — OK"
fi
# cargo test — the ONLY automatic correctness gate: nothing else (no CI
# workflows exist) runs the suite, so without this a Rust regression
# reaches main unverified. Gated on server/ changes; shares the target/
# guard with clippy so a cold worktree isn't forced into a full build.
echo "pre-push: checking Rust (cargo test)..."
if ! (cd "$REPO_ROOT/server" && cargo test --quiet 2>&1); then
ERRORS=$((ERRORS + 1))
else
echo "pre-push: cargo test — OK"
fi
else
echo "pre-push: skipping clippy + test (no target/ — run 'cargo build' once to enable)"
fi
# --- Rust dependency audit (cargo deny) — requires deny.toml config ---
if command -v cargo-deny >/dev/null 2>&1 && [ -f "$REPO_ROOT/server/deny.toml" ]; then
echo "pre-push: checking Rust (cargo deny)..."
if ! (cd "$REPO_ROOT/server" && cargo deny check 2>&1); then
ERRORS=$((ERRORS + 1))
else
echo "pre-push: cargo deny — OK"
fi
fi
else
echo "pre-push: WARNING — cargo not found or server/ missing, skipping Rust lint"
fi
# --- Python lint (ruff) ---
if [ "$TOOLING_CHANGED" -eq 0 ]; then
echo "pre-push: no tooling/ changes — skipping Python lint"
elif command -v ruff >/dev/null 2>&1 && [ -d "$REPO_ROOT/tooling" ]; then
echo "pre-push: checking Python (ruff)..."
if ! (cd "$REPO_ROOT" && ruff check tooling/ 2>&1); then
ERRORS=$((ERRORS + 1))
else
echo "pre-push: ruff — OK"
fi
else
echo "pre-push: skipping Python lint (ruff not found — install with: pip install 'ruff>=0.9')"
fi
# --- Tooling test gate (T-1066) ---
# make test-tooling = planet-gen determinism guard (#963) + import_economics
# --dry-run validation against the committed DB. Only worth the ~90 s when the
# push actually touches tooling/ (or pyproject.toml), same scope as ruff above.
if [ "$TOOLING_CHANGED" -eq 0 ]; then
echo "pre-push: no tooling/ changes — skipping tooling tests"
elif command -v make >/dev/null 2>&1; then
echo "pre-push: running tooling tests (make test-tooling)..."
if ! (cd "$REPO_ROOT" && make test-tooling); then
ERRORS=$((ERRORS + 1))
else
echo "pre-push: tooling tests — OK"
fi
else
echo "pre-push: WARNING — make not found, skipping tooling tests"
fi
# --- JSON syntax validation ---
# Use the same REMOTE_REF the directory-change detection above settled on
# (origin/<branch> preferred, origin/main fallback). Without this, a first
# push of a new branch falls through to "validate every JSON in the repo,"
# which on this repo means 2762 Python parses — tens of seconds of churn
# for a push that might not have touched any JSON at all.
if [ -n "$REMOTE_REF" ]; then
JSON_FILES=$(git diff --name-only "$REMOTE_REF"..HEAD -- '*.json' 2>/dev/null || true)
else
JSON_FILES=$(git ls-files '*.json')
fi
if [ -n "$JSON_FILES" ]; then
echo "pre-push: checking JSON syntax..."
JSON_FAIL=0
while IFS= read -r f; do
if [ -f "$REPO_ROOT/$f" ] && ! python3 -m json.tool "$REPO_ROOT/$f" >/dev/null 2>&1; then
echo " FAIL: $f"
JSON_FAIL=$((JSON_FAIL + 1))
fi
done <<< "$JSON_FILES"
if [ "$JSON_FAIL" -gt 0 ]; then
echo "pre-push: FAIL — $JSON_FAIL JSON file(s) have syntax errors"
ERRORS=$((ERRORS + 1))
else
echo "pre-push: JSON — OK ($(echo "$JSON_FILES" | wc -l) file(s))"
fi
else
echo "pre-push: no JSON changes — skipping"
fi
# --- systems.db stamp check (#857) ---
# If the branch touches server/data/systems.db and the meta stamp does not
# match current generator sources, reject the push. Prevents pushing a
# stale DB snapshot where generator source was modified but the DB was not
# regenerated.
#
# Runs whenever systems.db was modified in ANY branch commit vs. main —
# including on a branch's very first push (review T5: the previous version
# skipped the check for new branches because it compared against origin/$BRANCH,
# which didn't exist yet, leaving a gap where a stale DB could ship via the
# first push). We compare against origin/main — which always exists — so the
# check covers the first-push case.
DB_IN_PUSH=$(git diff --name-only origin/main...HEAD -- server/data/systems.db 2>/dev/null | wc -l)
if [ "$DB_IN_PUSH" -gt 0 ] && [ -f "$REPO_ROOT/tooling/check-systems-db-stamp" ]; then
echo "pre-push: checking systems.db stamp..."
rc=0
python3 "$REPO_ROOT/tooling/check-systems-db-stamp" || rc=$?
if [ "$rc" -eq 1 ]; then
# rc=1 means stale / unknown generator / missing source; message on stderr
echo " Fix: run 'make regen-db' then stage server/data/systems.db"
echo " Or use /pr-push — it handles regen automatically before pushing."
ERRORS=$((ERRORS + 1))
elif [ "$rc" -eq 2 ]; then
# rc=2 means no meta table — treat as unstamped, warn but don't block.
# This is legitimate immediately after the meta table is introduced;
# the next `make regen-db` will populate it (H4).
echo "pre-push: WARNING — systems.db has no meta stamp — run 'make regen-db' now if this DB was generated by you"
else
echo "pre-push: systems.db stamp — OK"
fi
else
echo "pre-push: systems.db not in push — skipping stamp check"
fi
# --- Clerk review (D-221) ---
# DISABLED 2026-05-23 (#965) pending rework. Two problems made it net-negative:
# 1. Non-exhaustive — a single run reports ~the first contradiction it finds
# and stops, so distinct real issues surfaced only on the 2nd/3rd/4th
# re-push. An APPROVED verdict therefore can't be trusted as "clean".
# 2. Re-reviews the entire origin/main..HEAD range on every push (token burn);
# no per-commit verdict cache.
# Off by default until reworked (exhaustive enumeration + holistic decisions/
# diff check + a SHA-keyed verdict cache invalidated on decisions/ change, with
# skip tracked in the cache rather than a commit-message trailer — see #965).
# Force-run with SR_RUN_CLERK=1.
if [ -x "$REPO_ROOT/tooling/clerk-review" ] && [ "${SR_RUN_CLERK:-0}" = "1" ]; then
echo "pre-push: running clerk review..."
CLERK_VERDICT=$("$REPO_ROOT/tooling/clerk-review" 2>&1 | tee /dev/stderr | tail -1)
if [ "$CLERK_VERDICT" = "APPROVED" ]; then
echo "pre-push: clerk — APPROVED"
elif [ "$CLERK_VERDICT" = "REJECTED" ]; then
echo "pre-push: clerk — REJECTED (see .cache/pre-push-review.md)"
ERRORS=$((ERRORS + 1))
elif [ "$CLERK_VERDICT" = "INCOMPLETE" ]; then
echo "pre-push: clerk — INCOMPLETE (some reviews didn't finish; NOT blocking)"
echo " See .cache/pre-push-review.md. For a full verdict: raise SR_CLERK_MAX_TURNS / SR_CLERK_TIMEOUT,"
echo " or add a 'Clerk-Skip:' trailer to bulk-content commits."
else
echo "pre-push: clerk — '$CLERK_VERDICT' unrecognized; treating as block (see .cache/pre-push-review.md)"
ERRORS=$((ERRORS + 1))
fi
else
echo "pre-push: clerk review — DISABLED (#965; force-run with SR_RUN_CLERK=1)"
fi
if [ "$ERRORS" -gt 0 ]; then
echo ""
echo "pre-push: $ERRORS check(s) failed. Push aborted."
echo " Fix the errors above, then try again."
exit 1
fi
echo "pre-push: all checks passed."