reach atlas db / names / systems-done / check / verify / commit-and-sync / update-field / flatness. Eight scripts retired, five of them bash. verify reproduces the original exactly: 2 errors across 301 proposals, exit 1. The binary has more verbs than its wrapper documented. The bash usage text listed four; atlas-commit-and-sync calls four more it never mentioned. All eight are declared so reach atlas --help is a complete index, and unknown verbs are still forwarded — a hand-maintained list falls behind the binary it describes, so rejecting on it would break the day someone adds a subcommand. commit-and-sync now stages by default and commits only with --commit. Nothing else in reach writes to git history, and committing as a side effect of "sync" is a different risk class from writing a file; the default prints the message it would use, leaving the decision where it was. Two real bugs found in that script while porting it. It ran atlas-verify and never checked the exit code, so a proposal that FAILED verification was still wiped, committed and synced — bad data in systems.db is far harder to undo than a failed command, and it now refuses. And it hardcoded a pinned "Co-Authored-By: Claude Opus 4.6" into every atlas commit, which the git-commit skill names as the root cause of attribution drift. update-field gains two guards the original lacked. Its field→table map lived inside a bash heredoc string where nothing could check it, and an unknown field produced an UPDATE against a table of None; it now names the nine accepted fields. And it checks rowcount, so a system_id that does not exist is a failure rather than a silent no-op reported as success. The three Python scripts moved with the usual treatment — prints to console events, argparse replaced by typed functions, __file__ roots to config.repo_root(). No root bug this time: checked before moving rather than after, three domains running. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
139 lines
4.9 KiB
Python
Executable File
139 lines
4.9 KiB
Python
Executable File
#!/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 json
|
|
from pathlib import Path
|
|
|
|
from tooling.core import console
|
|
|
|
|
|
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
|
|
|
|
|
|
# Sol is hand-authored with different rules — named uninhabited bodies and so
|
|
# on — so verifying it against the generated-proposal schema would report
|
|
# failures for things that are deliberate.
|
|
EXCLUDE = {"GJ0.json", "GJ1221.json"}
|
|
|
|
|
|
def verify_all(proposals: list[Path]) -> int:
|
|
"""Verify proposal JSONs. Returns the total error count across all files."""
|
|
total = 0
|
|
for path in proposals:
|
|
if path.name in EXCLUDE:
|
|
continue
|
|
if not path.exists():
|
|
console.event(f"SKIP — {path} not found", level="warn")
|
|
continue
|
|
errors = verify_proposal(path)
|
|
if errors:
|
|
console.event(f"FAIL — {path.name}", level="error")
|
|
for error in errors:
|
|
console.event(f" - {error}", level="error")
|
|
total += len(errors)
|
|
else:
|
|
console.event(f"PASS — {path.name}")
|
|
return total
|