Twelve scripts retired, three domains registered. `reach` now covers nine. generate: `generate-brands` and `generate-corporations` were the second and third copies of the same 24-line build-if-missing-then-exec bash `tooling/atlas` carried, so they collapsed into `core.process.cargo_binary` rather than being ported. `import_economics` shelled out to the first of those, so it now calls that helper — `generated_brands.toml` comes back byte-identical, and the stamp registry swaps the retired wrapper for `core/process.py`. pr: `watchlist-diff` derives its watched set from `generator_sources.py` instead of restating it, so it cannot drift from the stamp check. dev: the environment scripts split decision from performing, per D-263's guarded-exec rule. `godot_plan()` and `worktree_plan()` decide what would happen; `install_godot()`, `install_rust()` and `setup_worktree()` do it. `tooling/test_environment.py` pins the version pin, both override precedences, the already-current skip, the platform refusal and both worktree refusals — none of them performed. `make setup` now installs reach first, since the targets that install rust and godot are reach verbs. Two live bugs found while porting: - The clerk read its decision index from `decisions/README.md`, a path that stopped existing when the DQR tree moved to `governance/`. Every clerk agent has been grepping blind; its prompt pointed at the same dead directory. - The conformance exec-check matched any `x.system()` regardless of receiver, so `platform.system()` read as `os.system()`. Narrowed and re-proved against a real mutant. `process.run` gains `input=`, `timeout=` and a `ProcessTimeout` subclass so a killed run stays distinguishable from a verdict. The pre-push hook no longer merges the clerk's stderr into its stdout — under streaming the last merged line is a JSONL event, which would read as an unrecognised verdict and block. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
381 lines
17 KiB
Bash
Executable File
381 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 command -v reach >/dev/null 2>&1 && [ "${SR_RUN_CLERK:-0}" = "1" ]; then
|
|
echo "pre-push: running clerk review..."
|
|
# stdout carries the verdict, stderr carries progress. They must NOT be
|
|
# merged: under the streaming model (D-263) the last merged line would be a
|
|
# JSONL progress event, and an unrecognised verdict blocks the push.
|
|
CLERK_VERDICT=$(reach dev clerk | tail -1) || true
|
|
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."
|