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 27dbecee7..35dc919cf 100755 --- a/.config/hooks/pre-push +++ b/.config/hooks/pre-push @@ -15,13 +15,16 @@ 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, no remote ref — check everything + # 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 @@ -36,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 @@ -47,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 @@ -58,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 index 5563ed0ce..07cb8f6f4 100755 --- a/tooling/atlas-names +++ b/tooling/atlas-names @@ -1,15 +1,17 @@ -#!/bin/bash -# List all existing proper names in the atlas DB (for collision avoidance) +#!/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 +import sys, json for b in json.load(sys.stdin): - if b.get('proper_name'): print(b['proper_name']) + if b.get('proper_name'): print(b['proper_name']) " tooling/atlas list-stations 2>/dev/null | python3 -c " -import sys,json +import sys, json for s in json.load(sys.stdin): - if s.get('proper_name'): print(s['proper_name']) + if s.get('proper_name'): print(s['proper_name']) " } | sort -u diff --git a/tooling/atlas-systems-done b/tooling/atlas-systems-done index 4c2c1cdb3..69f8791b7 100755 --- a/tooling/atlas-systems-done +++ b/tooling/atlas-systems-done @@ -1,7 +1,9 @@ -#!/bin/bash -# List systems that already have bodies in the atlas DB +#!/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 +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 index c6ac437c4..999f0c8f5 100755 --- a/tooling/atlas-verify +++ b/tooling/atlas-verify @@ -1,93 +1,158 @@ -#!/bin/bash -# Verify a proposal JSON against integrity checks -# Usage: tooling/atlas-verify docs/atlas/proposals/GJ273.json -cd "$(dirname "$0")/.." -python3 -c " -import json, sys +#!/usr/bin/env python3 +"""Verify an atlas proposal JSON against integrity checks. -with open('$1') as f: - p = json.load(f) +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 -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\") +def verify_proposal(path: Path) -> list[str]: + """Return list of error strings. Empty list = pass.""" + with open(path) as f: + p = json.load(f) -# 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 '{b['proper_name']}' (should be null)\") + errors = [] + bodies = p.get("bodies", []) + stations = p.get("stations", []) -# 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\") + # 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") -# 4. Body count check -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}+ for {star_type} star\") + # 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)" + ) -# 5. Required structures -oort = [b for b in bodies if b['body_type'] == 'oort_cloud'] -if not oort: - errors.append('No oort cloud') -horizon = [s for s in stations if s['station_type'] == 'horizon'] -if not horizon: - errors.append('No horizon station') -elif not any(s.get('has_gate_infrastructure') for s in horizon): - errors.append('Horizon station missing has_gate_infrastructure: true') -belts = [b for b in bodies if b['body_type'] == 'asteroid_belt'] -if not belts: - errors.append('No asteroid belt') + # 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") -# 6. Orbit consistency -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}\") + # 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" + ) -# 7. Moon orbit consistency -parents = {} -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}\") + # 5. Required structures + if not any(b["body_type"] == "oort_cloud" for b in bodies): + errors.append("No oort cloud") -# 8. Parent references valid -body_ids = set(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}\") + 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 errors: - print('FAIL') - for e in errors: - print(f' - {e}') - sys.exit(1) -else: - print('PASS') -" + 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 and star_type_field != "binary": + # Strip dwarf/subdwarf prefixes (d, sd) to get actual class letter + s = spectral.lstrip("sd").upper() + spectral_letter = s[0] if s else "" + if spectral_letter and spectral_letter.isalpha(): + if star_type_field[0].upper() != 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() + + # Sol is hand-authored with different rules (named uninhabited bodies, etc.) + EXCLUDE = {"GJ0.json", "GJ1221.json"} + + total_errors = 0 + for path in args.proposals: + if path.name in EXCLUDE: + continue + 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()