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>
299 lines
13 KiB
Bash
Executable File
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."
|