#!/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 MAY have names (lore reasons) — no check needed # 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 and star_type_field not in ("binary", "unusual"): # 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()