current_schema_version() line-scanned res://../project.yaml at runtime. That resolves to the repo root in a dev run and to nothing in an exported build, so a shipped game got the "?.?.?" fallback every time. Since that tag is the Atlas disk cache's ONLY invalidation signal, every exported build stamped and compared the same sentinel: a canvas cached by one build would be served by every later build, forever. T-1239 is what that failure looks like once it happens. loading_screen.gd carried a byte-for-byte copy of the same function, so the version shown to the player was "?.?.?" in exactly the builds where a version string is worth showing. Both call sites now share client/scripts/build_version.gd, which reads application/config/version out of ProjectSettings — a value Godot bakes into the PCK, identical in the editor and in an export by construction rather than by luck. No file IO, no fallback branch. project.yaml stays the source of truth (CLAUDE.md); client/project.godot mirrors it. A mirror nobody checks would be worse than the bug it replaces -- the old code failed loudly everywhere, a stale mirror fails silently -- so tooling/check-client-version compares the two and the pre-push hook runs it unconditionally. Not gated on "were those files in this push": drift persists on main once introduced, and gating would let an existing drift ride along. The test this replaces asserted that current_schema_version() did not return its fallback, and passed -- in the one environment where the code under test worked. Three tests now pin the property that actually matters: a real version, sourced from the baked setting, matching project.yaml. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
346 lines
16 KiB
Bash
Executable File
346 lines
16 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
|
|
FAILED_CHECKS=""
|
|
|
|
# Record a failed check BY NAME, not just as a tally.
|
|
#
|
|
# Why (2026-07-27): this hook used to increment a bare counter at 13 sites and
|
|
# end with "N check(s) failed. Fix the errors above." — which named nothing. Six
|
|
# of those sites (fmt, clippy, cargo test, deny, ruff, tooling) print no FAIL
|
|
# line at all, so a failure was only inferable from the ABSENCE of an "— OK".
|
|
# A real push aborted on `cargo fmt` and the verdict was indistinguishable from
|
|
# any other failure; finding it meant scrolling past thousands of lines of
|
|
# unrelated test-fixture output.
|
|
#
|
|
# The consequence that matters for tooling: the final block is now
|
|
# self-contained, so `tail` on this log always shows WHAT failed. Verbosity was
|
|
# never the problem — detail is exactly what you want when something breaks;
|
|
# the problem was that the verdict didn't say anything actionable.
|
|
fail_check() {
|
|
ERRORS=$((ERRORS + 1))
|
|
FAILED_CHECKS="${FAILED_CHECKS} - $1
|
|
"
|
|
}
|
|
|
|
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"
|
|
fail_check "GDScript parse (startup)"
|
|
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"
|
|
fail_check "GDScript parse sweep"
|
|
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"
|
|
fail_check "client test suite (gdUnit4)"
|
|
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
|
|
fail_check "cargo fmt"
|
|
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
|
|
fail_check "cargo clippy"
|
|
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
|
|
fail_check "cargo test"
|
|
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
|
|
fail_check "cargo deny"
|
|
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
|
|
fail_check "ruff (python lint)"
|
|
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
|
|
fail_check "tooling tests (make test-tooling)"
|
|
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"
|
|
fail_check "JSON syntax"
|
|
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."
|
|
fail_check "systems.db stamp (stale)"
|
|
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
|
|
|
|
# --- Client version mirror (T-1241) ---
|
|
# project.yaml is the version source of truth, but the client cannot read it at
|
|
# runtime (an exported build has no repo root), so it is mirrored into
|
|
# client/project.godot's `application/config/version`. The Atlas disk cache keys
|
|
# its ONLY invalidation signal on that value, so a stale mirror makes a build
|
|
# serve canvases generated by code it no longer runs — the T-1239 failure.
|
|
#
|
|
# Deliberately UNCONDITIONAL, unlike the systems.db check above. Drift is
|
|
# introduced by touching one file without the other, but it PERSISTS on main
|
|
# until someone notices, so gating on "was either file in this push" would let
|
|
# an existing drift ride along indefinitely. The check is two file reads.
|
|
if [ -f "$REPO_ROOT/tooling/check-client-version" ]; then
|
|
echo "pre-push: checking client version mirror..."
|
|
if python3 "$REPO_ROOT/tooling/check-client-version"; then
|
|
:
|
|
else
|
|
fail_check "client version mirror (drifted from project.yaml)"
|
|
fi
|
|
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)"
|
|
fail_check "clerk review (REJECTED)"
|
|
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)"
|
|
fail_check "clerk review (unrecognized verdict)"
|
|
fi
|
|
else
|
|
echo "pre-push: clerk review — DISABLED (#965; force-run with SR_RUN_CLERK=1)"
|
|
fi
|
|
|
|
if [ "$ERRORS" -gt 0 ]; then
|
|
echo ""
|
|
echo "=============================================================="
|
|
echo "pre-push: PUSH ABORTED — $ERRORS check(s) failed:"
|
|
printf '%s' "$FAILED_CHECKS"
|
|
echo ""
|
|
echo " Search the log above for the failing check's own output, e.g."
|
|
echo " grep -A20 'checking Rust (fmt)' <logfile>"
|
|
echo " cargo fmt is auto-fixable: cd server && cargo fmt"
|
|
echo "=============================================================="
|
|
exit 1
|
|
fi
|
|
|
|
echo "pre-push: all checks passed."
|