reach godot parse-sweep / cold-parse, reach visual diff / blank-check / thumbnail. Five scripts retired, and the callers rewired — tests/run-visual invoked three of them by path at four sites, which is a wider blast radius than the make targets were. The godot pair were grep pipelines encoding five hard-won lessons as comments nobody could test. They are Python filters now, with the reasons attached, and the engine invocation is a guarded exec. Verified on the real client: 229 scripts, clean. Their three not-ok states stay distinct, because only one is a verdict about the code. An engine that crashed or is missing is not a parse failure — reporting it as one blames the tree for a broken toolchain. A sweep that emitted no completion marker checked nothing, and zero errors from a check that never ran reads as clean, which is the false-green the sweep exists to close. The deliberate asymmetry between the two checks is preserved and documented: cold-parse filters "Cannot infer the type", the sweep does not, because that suppression is why cold-parse stayed silent about a helper that genuinely does not parse. All three visual scripts carried the same root bug as validate-checklist: Path(__file__).parent.parent, correct at tooling/ and two levels too deep at tooling/domains/visual. Fixed during the move rather than after, having learned that it fails silently — paths resolve to nothing, the work appears to have nothing to do, and the tool reports success. Three domains now where that would have shipped a false pass. Two bugs my own transformation introduced, both found by running rather than reading. Multi-line print(..., file=sys.stderr) became console.event(..., file=sys.stderr), and console puts unknown kwargs into the payload — a file object would have reached json.dumps at the exact moment something was already being reported as an error. And the replacement script wrote escaped quotes into three files. Mechanical transformations need mechanical verification. sys.exit removed from four sites: a service must not end the process. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
378 lines
17 KiB
Bash
Executable File
378 lines
17 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 ] && command -v reach >/dev/null 2>&1; then
|
|
echo "pre-push: sweeping GDScript parse (reach godot parse-sweep)..."
|
|
if ! reach --no-input 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 ] && command -v reach >/dev/null 2>&1; then
|
|
echo "pre-push: checking systems.db stamp..."
|
|
rc=0
|
|
reach --no-input 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 command -v reach >/dev/null 2>&1; then
|
|
echo "pre-push: checking client version mirror..."
|
|
if reach --no-input check client-version; then
|
|
:
|
|
else
|
|
fail_check "client version mirror (drifted from project.yaml)"
|
|
fi
|
|
fi
|
|
|
|
# --- Canvas-generation / version pairing (T-1242) ---
|
|
# A change to how a canvas is GENERATED is half a change; the other half is
|
|
# bumping project.yaml's version, or every warm Atlas cache keeps serving
|
|
# canvases built by code that no longer exists. That pairing broke five times
|
|
# (0.4.2 through 0.4.6), every one bumped after the fact, the last costing the
|
|
# eight-day T-1239 diagnosis. The path registry is tooling/canvas_sources.py.
|
|
#
|
|
# Self-check first: the registry fails closed on an empty glob, and a registry
|
|
# that cannot load must not be read as "nothing to enforce".
|
|
if command -v reach >/dev/null 2>&1; then
|
|
echo "pre-push: checking canvas-generation version pairing..."
|
|
if reach --no-input check canvas-version; then
|
|
:
|
|
else
|
|
fail_check "canvas generation changed without a project.yaml version bump"
|
|
fi
|
|
fi
|
|
|
|
# --- Data-flow diagram paths (D-262) ---
|
|
# A diagram that names files goes stale SILENTLY — nothing fails when a path
|
|
# moves, so the map keeps asserting a layout that is no longer true. This checks
|
|
# only that the paths still resolve; whether an EDGE still means what it says is
|
|
# a human check against the tool's source. Cheap (no subprocess beyond python).
|
|
if command -v reach >/dev/null 2>&1; then
|
|
echo "pre-push: checking data-flow diagram paths..."
|
|
if reach --no-input check dataflow-graph; then
|
|
:
|
|
else
|
|
fail_check "a path named in a data-flow diagram no longer resolves"
|
|
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."
|