From 73c81c121738c9cf802f9575a3d0b793d2f60e80 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 26 Mar 2026 08:06:44 +0100 Subject: [PATCH] chore(tooling): consolidate atlas tooling + harden pre-push hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite atlas-verify as proper Python script (was inline Python in bash with path injection risk). Adds star_type/spectral_class consistency check. Supports multiple files via glob. - Add atlas-names and atlas-systems-done query helpers (clean versions of what the copy branch created — supersedes atlas-helpers.sh) - Wire atlas-verify into Makefile (make atlas-verify, pre-pr-content) - Update atlas skill references to point to the script - Merge diff-based skip into pre-push hook: only lint client/ or server/ when those dirs actually changed in the push. Combined with existing directory-existence guards for cold worktrees. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../skills/atlas/references/author-system.md | 6 +- .../atlas/references/integrity-check.md | 13 +- .config/hooks/pre-push | 24 ++- Makefile | 8 +- tooling/atlas-names | 17 ++ tooling/atlas-systems-done | 9 ++ tooling/atlas-verify | 150 ++++++++++++++++++ 7 files changed, 218 insertions(+), 9 deletions(-) create mode 100755 tooling/atlas-names create mode 100755 tooling/atlas-systems-done create mode 100755 tooling/atlas-verify diff --git a/.claude/skills/atlas/references/author-system.md b/.claude/skills/atlas/references/author-system.md index e3ab07147..10f731909 100644 --- a/.claude/skills/atlas/references/author-system.md +++ b/.claude/skills/atlas/references/author-system.md @@ -82,7 +82,11 @@ Either can flag issues that loop back to Step 3. ## Step 6: Integrity Check -Run automated checks per `references/integrity-check.md`. Fix any +```bash +tooling/atlas-verify docs/atlas/proposals/{id}.json +``` + +All checks from `references/integrity-check.md` must pass. Fix any failures before proceeding. ## Step 7: Commit diff --git a/.claude/skills/atlas/references/integrity-check.md b/.claude/skills/atlas/references/integrity-check.md index 7e6cf4ed5..fc736fa76 100644 --- a/.claude/skills/atlas/references/integrity-check.md +++ b/.claude/skills/atlas/references/integrity-check.md @@ -46,5 +46,14 @@ Run these checks on the proposal JSON before commit. All must pass. ## Running the Check -Parse the proposal JSON and verify each rule programmatically. Report -all failures, not just the first one. +```bash +tooling/atlas-verify docs/atlas/proposals/{id}.json +``` + +Or verify all proposals at once: +```bash +make atlas-verify +``` + +The script reports all failures per file, not just the first one. +Exit code 1 if any file has errors. diff --git a/.config/hooks/pre-push b/.config/hooks/pre-push index 7835dca1e..35dc919cf 100755 --- a/.config/hooks/pre-push +++ b/.config/hooks/pre-push @@ -8,9 +8,23 @@ ERRORS=0 echo "pre-push: running lint checks..." +# --- Detect which directories have changes vs remote --- +BRANCH=$(git branch --show-current) +REMOTE_REF="origin/$BRANCH" +if git rev-parse --verify "$REMOTE_REF" >/dev/null 2>&1; 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) +else + # New branch or no remote ref — fall through to directory checks + CLIENT_CHANGED=1 + SERVER_CHANGED=1 +fi + # --- GDScript parse check (headless Godot) --- GODOT="${GODOT:-godot}" -if command -v "$GODOT" >/dev/null 2>&1 && [ -d "$REPO_ROOT/client/.godot" ]; then +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 @@ -25,7 +39,7 @@ else fi # --- GDScript lint (gdlint static analysis) — advisory only until codebase is clean --- -if command -v gdlint >/dev/null 2>&1 && [ -d "$REPO_ROOT/client/scripts" ]; then +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 @@ -36,7 +50,7 @@ if command -v gdlint >/dev/null 2>&1 && [ -d "$REPO_ROOT/client/scripts" ]; then fi # --- GDScript format check (gdformat) — advisory only until codebase is clean --- -if command -v gdformat >/dev/null 2>&1 && [ -d "$REPO_ROOT/client/scripts" ]; then +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 @@ -47,7 +61,9 @@ if command -v gdformat >/dev/null 2>&1 && [ -d "$REPO_ROOT/client/scripts" ]; th fi # --- Rust lint (clippy + fmt) --- -if command -v cargo >/dev/null 2>&1 && [ -d "$REPO_ROOT/server" ]; then +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 diff --git a/Makefile b/Makefile index 07510e9d0..779edff74 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) .PHONY: help setup build check-protocol client server game stop test lint ci ci-client ci-server clean \ decisions-sync decisions-coverage decisions-active decisions-orphan \ db-backup db-install validate-content check-fact-ids setup-hooks \ - audit \ + audit atlas-verify \ pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \ pre-pr-server pre-pr-client pre-pr-content \ fixtures-client fixtures-gauntlet golden-diff golden-update \ @@ -49,6 +49,7 @@ help: @echo " make audit Run cargo audit (security advisory check)" @echo " make validate-content Validate content YAML against schemas" @echo " make check-fact-ids Check fact_id references against knowledge catalogs" + @echo " make atlas-verify Verify atlas proposal JSONs (all in docs/atlas/proposals/)" @echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)" @echo " make golden-diff Show diff if golden file output has changed" @echo " make golden-update Regenerate golden file and stage for commit" @@ -291,7 +292,7 @@ pre-pr-server: lint-server build-server test-server pre-pr-fixtures audit pre-pr-client: lint-client build-client test-client @echo "=== Client pre-PR: PASSED ===" -pre-pr-content: validate-content check-fact-ids checklist-validate +pre-pr-content: validate-content check-fact-ids checklist-validate atlas-verify @echo "=== Content pre-PR: PASSED ===" # --- CI (run locally) --- @@ -332,6 +333,9 @@ validate-content: check-fact-ids: @tooling/check-fact-ids +atlas-verify: + @tooling/atlas-verify docs/atlas/proposals/*.json + audit: cd server && cargo audit diff --git a/tooling/atlas-names b/tooling/atlas-names new file mode 100755 index 000000000..07cb8f6f4 --- /dev/null +++ b/tooling/atlas-names @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# List all existing proper names in the atlas DB (for collision avoidance). +# Usage: tooling/atlas-names +set -euo pipefail +cd "$(dirname "$0")/.." +{ + tooling/atlas list-bodies 2>/dev/null | python3 -c " +import sys, json +for b in json.load(sys.stdin): + if b.get('proper_name'): print(b['proper_name']) +" + tooling/atlas list-stations 2>/dev/null | python3 -c " +import sys, json +for s in json.load(sys.stdin): + if s.get('proper_name'): print(s['proper_name']) +" +} | sort -u diff --git a/tooling/atlas-systems-done b/tooling/atlas-systems-done new file mode 100755 index 000000000..69f8791b7 --- /dev/null +++ b/tooling/atlas-systems-done @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# List systems that already have bodies in the atlas DB. +# Usage: tooling/atlas-systems-done +set -euo pipefail +cd "$(dirname "$0")/.." +tooling/atlas list-bodies 2>/dev/null | python3 -c " +import sys, json +for s in sorted(set(b['system_id'] for b in json.load(sys.stdin))): print(s) +" diff --git a/tooling/atlas-verify b/tooling/atlas-verify new file mode 100755 index 000000000..0438203a1 --- /dev/null +++ b/tooling/atlas-verify @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Verify an atlas proposal JSON against integrity checks. + +Usage: + tooling/atlas-verify docs/atlas/proposals/GJ273.json + tooling/atlas-verify docs/atlas/proposals/*.json +""" +import argparse +import json +import sys +from pathlib import Path + + +def verify_proposal(path: Path) -> list[str]: + """Return list of error strings. Empty list = pass.""" + with open(path) as f: + p = json.load(f) + + errors = [] + bodies = p.get("bodies", []) + stations = p.get("stations", []) + + # 1. Inhabited bodies must have names + for b in bodies: + if b.get("inhabited") and not b.get("proper_name"): + errors.append(f"Inhabited body {b['body_id']} has no proper_name") + + # 2. Uninhabited bodies should have null names + for b in bodies: + if not b.get("inhabited") and b.get("proper_name"): + errors.append( + f"Uninhabited body {b['body_id']} has proper_name " + f"'{b['proper_name']}' (should be null)" + ) + + # 3. All stations must have names + for s in stations: + if not s.get("proper_name"): + errors.append(f"Station {s['station_id']} has no proper_name") + + # 4. Body count minimum by star type + planets = [b for b in bodies if b["body_type"] == "planet"] + star_type = p.get("spectral_class", "M") + if star_type.startswith(("G", "F")): + min_planets = 8 + elif star_type.startswith("K"): + min_planets = 7 + else: + min_planets = 6 + if len(planets) < min_planets: + errors.append( + f"Only {len(planets)} planets, need {min_planets}+ " + f"for {star_type} star" + ) + + # 5. Required structures + if not any(b["body_type"] == "oort_cloud" for b in bodies): + errors.append("No oort cloud") + + horizons = [s for s in stations if s["station_type"] == "horizon"] + if not horizons: + errors.append("No horizon station") + elif not any(s.get("has_gate_infrastructure") for s in horizons): + errors.append("Horizon station missing has_gate_infrastructure: true") + + if not any(b["body_type"] == "asteroid_belt" for b in bodies): + errors.append("No asteroid belt (add one unless wiki contradicts)") + + # 6. Orbit consistency — top-level bodies + top_level = [b for b in bodies if not b.get("parent_body_id")] + orbits = [b["orbit_index"] for b in top_level] + if orbits != sorted(orbits): + errors.append(f"Top-level orbit_index not monotonic: {orbits}") + if len(orbits) != len(set(orbits)): + errors.append(f"Duplicate orbit_index in top-level: {orbits}") + + # 7. Moon orbit consistency + parents: dict[str, list[int]] = {} + for b in bodies: + pid = b.get("parent_body_id") + if pid: + parents.setdefault(pid, []).append(b["orbit_index"]) + for pid, idxs in parents.items(): + if idxs != sorted(idxs): + errors.append(f"Moon orbits under {pid} not monotonic: {idxs}") + if len(idxs) != len(set(idxs)): + errors.append(f"Duplicate moon orbit_index under {pid}: {idxs}") + + # 8. Parent references valid + body_ids = {b["body_id"] for b in bodies} + for b in bodies: + pid = b.get("parent_body_id") + if pid and pid not in body_ids: + errors.append( + f"Body {b['body_id']} references missing parent {pid}" + ) + for s in stations: + oid = s.get("orbits_body_id") + if oid and oid not in body_ids: + errors.append( + f"Station {s['station_id']} references missing body {oid}" + ) + + # 9. star_type vs spectral_class consistency + star_type_field = p.get("star_type", "") + spectral = p.get("spectral_class", "") + if star_type_field and spectral: + spectral_letter = spectral[0] if spectral else "" + if spectral_letter and star_type_field[0] != spectral_letter: + errors.append( + f"star_type '{star_type_field}' conflicts with " + f"spectral_class '{spectral}'" + ) + + return errors + + +def main(): + parser = argparse.ArgumentParser( + description="Verify atlas proposal JSON files" + ) + parser.add_argument( + "proposals", + nargs="+", + type=Path, + help="Proposal JSON file(s) to verify", + ) + args = parser.parse_args() + + total_errors = 0 + for path in args.proposals: + if not path.exists(): + print(f"SKIP — {path} not found") + continue + errors = verify_proposal(path) + if errors: + print(f"FAIL — {path.name}") + for e in errors: + print(f" - {e}") + total_errors += len(errors) + else: + print(f"PASS — {path.name}") + + if total_errors: + print(f"\n{total_errors} error(s) across {len(args.proposals)} file(s)") + sys.exit(1) + + +if __name__ == "__main__": + main()