feat(config): T-1285 — the atlas authoring verbs, and a verify nobody checked
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>
This commit is contained in:
@@ -1,24 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Atlas CLI wrapper — celestial bodies and stations in systems.db.
|
||||
#
|
||||
# Usage:
|
||||
# tooling/atlas stats
|
||||
# tooling/atlas show-system "GJ 15A"
|
||||
# tooling/atlas list-bodies --system "GJ 15A"
|
||||
# tooling/atlas populate --dry-run
|
||||
#
|
||||
# Builds on first run if binary doesn't exist.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
BIN="$ROOT_DIR/server/target/debug/atlas"
|
||||
|
||||
# Build if needed
|
||||
if [ ! -f "$BIN" ]; then
|
||||
echo "Building atlas..." >&2
|
||||
(cd "$ROOT_DIR/server" && cargo build --bin atlas 2>&1 | tail -3) >&2
|
||||
fi
|
||||
|
||||
exec "$BIN" "$@"
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Quick diagnostic for an atlas proposal — shows planet count, body summary, and flags.
|
||||
|
||||
Usage:
|
||||
tooling/atlas-check docs/atlas/proposals/GJ1075.json
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def check(path: Path):
|
||||
with open(path) as f:
|
||||
p = json.load(f)
|
||||
|
||||
bodies = p.get("bodies", [])
|
||||
stations = p.get("stations", [])
|
||||
|
||||
planets = [b for b in bodies if b["body_type"] == "planet" and not b.get("parent_body_id")]
|
||||
gas_giants = [b for b in bodies if b["body_type"] == "gas_giant"]
|
||||
moons = [b for b in bodies if b["body_type"] == "moon"]
|
||||
belts = [b for b in bodies if b["body_type"] == "asteroid_belt"]
|
||||
oort = [b for b in bodies if b["body_type"] == "oort_cloud"]
|
||||
|
||||
sc = p.get("spectral_class", "")
|
||||
if sc.startswith(("G", "F")):
|
||||
min_p = 8
|
||||
elif sc.startswith("K"):
|
||||
min_p = 7
|
||||
else:
|
||||
min_p = 6
|
||||
|
||||
print(f"=== {path.name} — {p.get('proper_name') or p['system_id']} ===")
|
||||
print(f" star_type: {p.get('star_type')} spectral: {sc}")
|
||||
print(f" planets: {len(planets)} (need {min_p}+) gas_giants: {len(gas_giants)} moons: {len(moons)}")
|
||||
print(f" belts: {len(belts)} oort: {len(oort)} stations: {len(stations)}")
|
||||
|
||||
if planets:
|
||||
print(f" orbits: {' → '.join(str(b['orbit_index']) for b in planets)}")
|
||||
print(f" gravity: {' / '.join(str(b['surface_gravity']) for b in planets)}")
|
||||
print(f" periods: {' / '.join(str(b['orbital_period_days']) for b in planets)} days")
|
||||
|
||||
inhabited = [b for b in bodies if b.get("inhabited")]
|
||||
if inhabited:
|
||||
for b in inhabited:
|
||||
print(f" inhabited: {b['body_id']} ({b.get('proper_name', '?')}) pop={b.get('population')}")
|
||||
|
||||
for s in stations:
|
||||
print(f" station: {s['station_id']} ({s.get('proper_name', '?')}) type={s['station_type']} pop={s.get('population')}")
|
||||
|
||||
# Flags
|
||||
if len(planets) < min_p:
|
||||
print(f" ⚠ need {min_p - len(planets)} more planet(s)")
|
||||
if not belts:
|
||||
print(f" ⚠ no asteroid belt")
|
||||
if not oort:
|
||||
print(f" ⚠ no oort cloud")
|
||||
horizons = [s for s in stations if s["station_type"] == "horizon"]
|
||||
if not horizons:
|
||||
print(f" ⚠ no horizon station")
|
||||
elif not any(s.get("has_gate_infrastructure") for s in horizons):
|
||||
print(f" ⚠ horizon station missing gate_infrastructure")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: tooling/atlas-check <proposal.json> [...]")
|
||||
sys.exit(1)
|
||||
for arg in sys.argv[1:]:
|
||||
check(Path(arg))
|
||||
print()
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Commit a single system proposal to DB, sync wiki, and create a git commit.
|
||||
# Handles wipe-if-exists automatically.
|
||||
#
|
||||
# Usage: atlas-commit-and-sync "GJ 105A" [corridor_tag]
|
||||
# corridor_tag defaults to "east_reach"
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ATLAS="$SCRIPT_DIR/atlas"
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: atlas-commit-and-sync SYSTEM_ID [corridor_tag]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SYS_ID="$1"
|
||||
CORRIDOR="${2:-east_reach}"
|
||||
SLUG="${SYS_ID// /}"
|
||||
FILE="docs/atlas/proposals/${SLUG}.json"
|
||||
WIKI_SLUG="${SYS_ID// /-}"
|
||||
WIKI="wiki/star-systems/${WIKI_SLUG}/index.md"
|
||||
|
||||
if [[ ! -f "$FILE" ]]; then
|
||||
echo "ERROR: proposal not found: $FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. Verify
|
||||
echo "--- Verifying $FILE"
|
||||
"$SCRIPT_DIR/atlas-verify" "$FILE"
|
||||
|
||||
# 2. Get system info for commit message
|
||||
PROPER=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('proper_name') or d['system_id'])" "$FILE")
|
||||
|
||||
# 3. Wipe existing data if present
|
||||
BODY_COUNT=$("$ATLAS" show-system "$SYS_ID" 2>/dev/null \
|
||||
| python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('bodies',[])))" 2>/dev/null \
|
||||
|| echo "0")
|
||||
if [[ "$BODY_COUNT" -gt 0 ]]; then
|
||||
echo "--- Wiping existing data for $SYS_ID"
|
||||
"$ATLAS" wipe-system "$SYS_ID"
|
||||
fi
|
||||
|
||||
# 4. Commit to DB
|
||||
echo "--- Committing $SYS_ID to systems.db"
|
||||
"$ATLAS" commit-system "$FILE"
|
||||
|
||||
# 5. Sync wiki
|
||||
echo "--- Syncing wiki"
|
||||
"$ATLAS" sync-wiki "$SYS_ID"
|
||||
|
||||
# 6. Git add + commit
|
||||
echo "--- Git commit"
|
||||
git add "$FILE"
|
||||
[[ -f "$WIKI" ]] && git add "$WIKI"
|
||||
git commit -m "data(atlas): author ${SYS_ID} (${PROPER}) — ${CORRIDOR}
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
|
||||
echo "--- Done: $SYS_ID ($PROPER)"
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/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)
|
||||
"
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Update a field on a star system record in systems.db.
|
||||
# Automatically detects which table the field belongs to.
|
||||
#
|
||||
# Usage: atlas-update-field "GJ 687" settlement_wave wave_3
|
||||
# atlas-update-field "GJ 53A" star_type G
|
||||
set -euo pipefail
|
||||
DB="$(cd "$(dirname "$0")/../server/data" && pwd)/systems.db"
|
||||
|
||||
if [[ $# -ne 3 ]]; then
|
||||
echo "Usage: atlas-update-field SYSTEM_ID FIELD VALUE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SYS="$1" FIELD="$2" VALUE="$3"
|
||||
|
||||
python3 -c "
|
||||
import sqlite3, sys
|
||||
|
||||
FIELD_TABLE = {
|
||||
# star_systems
|
||||
'star_type': 'star_systems',
|
||||
'spectral_class': 'star_systems',
|
||||
'proper_name': 'star_systems',
|
||||
'geographic_sector': 'star_systems',
|
||||
'habitable_planet_count': 'star_systems',
|
||||
'inhabited_planet_count': 'star_systems',
|
||||
# system_gates
|
||||
'gate_topology': 'system_gates',
|
||||
'aperture_count': 'system_gates',
|
||||
'hop_distance_from_gateway': 'system_gates',
|
||||
# system_history
|
||||
'settlement_wave': 'system_history',
|
||||
'founding_culture_primary': 'system_history',
|
||||
'founding_culture_secondary': 'system_history',
|
||||
}
|
||||
|
||||
field = sys.argv[1]
|
||||
value = sys.argv[2]
|
||||
system_id = sys.argv[3]
|
||||
|
||||
if field not in FIELD_TABLE:
|
||||
print(f'ERROR: field \"{field}\" not in whitelist', file=sys.stderr)
|
||||
print(f'Allowed: {sorted(FIELD_TABLE.keys())}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
table = FIELD_TABLE[field]
|
||||
conn = sqlite3.connect(sys.argv[4])
|
||||
cur = conn.cursor()
|
||||
|
||||
# Ensure row exists in target table (history/gates may not have a row yet)
|
||||
if table != 'star_systems':
|
||||
cur.execute(f'INSERT OR IGNORE INTO {table} (system_id) VALUES (?)', (system_id,))
|
||||
|
||||
cur.execute(f'UPDATE {table} SET {field} = ? WHERE system_id = ?', (value, system_id))
|
||||
print(f'updated {cur.rowcount} row(s): {system_id}.{field} = {value} ({table})')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
" "$FIELD" "$VALUE" "$SYS" "$DB"
|
||||
@@ -0,0 +1,14 @@
|
||||
"""The `atlas` domain — the spatial ladder the implant map descends (D-191).
|
||||
|
||||
`implant/map` is one app at four zoom levels: Reach map → system → planetary →
|
||||
regional. The CLI mirrors that (D-263), which means **generation nests by
|
||||
rung** — `reach atlas map …`, `reach atlas planet …` — while the verbs here
|
||||
stay flat, because authoring and inspection act on the atlas as a whole rather
|
||||
than on any one rung.
|
||||
|
||||
One collision worth knowing, since the words are the same. D-191's MVP
|
||||
criterion 7 says *"Atlas is read-only (no verbs execute from map)"*. That
|
||||
governs the **app**. The tooling in this domain writes: it commits proposals,
|
||||
amends fields and syncs the wiki. Do not read the app's constraint as licence
|
||||
to delete the authoring verbs.
|
||||
"""
|
||||
@@ -0,0 +1,66 @@
|
||||
"""The Rust `atlas` binary, fronted (D-263).
|
||||
|
||||
`tooling/atlas` was 24 lines of bash that built `server/target/debug/atlas` if
|
||||
missing and `exec`'d it with every argument. So its verbs never existed in the
|
||||
shell at all — they live in Rust, which is why this is a passthrough rather than
|
||||
a port.
|
||||
|
||||
**The verbs are declared here even though the binary owns them.** That
|
||||
duplication is deliberate: `reach atlas --help` has to be a complete index of
|
||||
what exists, and an index that says "ask the binary" is not one. The cost is
|
||||
that a subcommand added on the Rust side is invisible here until someone adds a
|
||||
line — so `run()` also accepts anything, and an unknown verb reaches the binary
|
||||
rather than being rejected by a list that has fallen behind.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tooling.core import config, console, process
|
||||
from tooling.core.errors import ReachError
|
||||
|
||||
# What the binary offers today. Discovered from the wrapper's own usage text and
|
||||
# from atlas-commit-and-sync, which calls three verbs the wrapper never
|
||||
# documented — list-stations, wipe-system, commit-system, sync-wiki.
|
||||
KNOWN_VERBS = (
|
||||
"stats",
|
||||
"show-system",
|
||||
"list-bodies",
|
||||
"list-stations",
|
||||
"populate",
|
||||
"commit-system",
|
||||
"wipe-system",
|
||||
"sync-wiki",
|
||||
)
|
||||
|
||||
|
||||
def binary_path():
|
||||
return config.path("server", "target", "debug", "atlas")
|
||||
|
||||
|
||||
def run(*args: str, capture: bool = True) -> str:
|
||||
"""Invoke the Rust atlas binary, building it first if it is absent."""
|
||||
binary = binary_path()
|
||||
if not binary.is_file():
|
||||
console.event("building the atlas binary (first run)", level="warn")
|
||||
build = process.run(
|
||||
["cargo", "build", "--bin", "atlas"],
|
||||
cwd=config.path("server"),
|
||||
check=False,
|
||||
missing_fix="install Rust — make setup-rust",
|
||||
)
|
||||
if build.returncode != 0:
|
||||
raise ReachError(
|
||||
f"could not build the atlas binary\n{(build.stderr or '').strip()}",
|
||||
fix="cd server && cargo build --bin atlas — to see the full error",
|
||||
exit_code=build.returncode,
|
||||
)
|
||||
|
||||
result = process.run([str(binary), *args], check=False, capture=capture)
|
||||
if result.returncode != 0:
|
||||
raise ReachError(
|
||||
f"atlas {' '.join(args)} exited {result.returncode}\n"
|
||||
+ (result.stderr or "").strip(),
|
||||
fix=f"run `{binary} {' '.join(args)}` directly for the full output",
|
||||
exit_code=result.returncode,
|
||||
)
|
||||
return result.stdout or ""
|
||||
@@ -28,17 +28,19 @@ Usage:
|
||||
tooling/atlas-flatness --ladder # the standard descent ladder
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from tooling.core import config, console
|
||||
from tooling.core.errors import ReachError
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError: # pragma: no cover - environment guard
|
||||
print("atlas-flatness: Pillow not installed", file=sys.stderr)
|
||||
console.event("atlas-flatness: Pillow not installed", level="error")
|
||||
sys.exit(2)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
REPO_ROOT = config.repo_root()
|
||||
SHOTS = REPO_ROOT / ".cache" / "screenshots"
|
||||
|
||||
# The standard descent ladder: one body, one world point, once per rung.
|
||||
@@ -87,39 +89,32 @@ def measure(path: Path) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Measure Atlas capture structure")
|
||||
parser.add_argument("images", nargs="*", type=Path)
|
||||
parser.add_argument(
|
||||
"--ladder",
|
||||
action="store_true",
|
||||
help="measure the standard descent ladder in .cache/screenshots/",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
def report(images: list[Path], ladder: bool = False) -> int:
|
||||
"""Measure capture structure per rung. Returns 0, or 1 if any file is missing."""
|
||||
targets = []
|
||||
if args.ladder:
|
||||
if ladder:
|
||||
targets = [(label, SHOTS / name) for label, name in LADDER]
|
||||
targets += [(p.stem, p) for p in args.images]
|
||||
targets += [(p.stem, p) for p in images]
|
||||
|
||||
if not targets:
|
||||
parser.print_help()
|
||||
return 2
|
||||
raise ReachError(
|
||||
"no captures given",
|
||||
fix="pass image paths, or --ladder to measure the standard descent",
|
||||
exit_code=2,
|
||||
)
|
||||
|
||||
print(f"{'rung':22} {'distinct':>9} {'R std':>7} {'G std':>7} {'B std':>7} {'lum p1-p99':>11}")
|
||||
console.out(
|
||||
f"{'rung':22} {'distinct':>9} {'R std':>7} {'G std':>7} {'B std':>7} {'lum p1-p99':>11}"
|
||||
)
|
||||
missing = 0
|
||||
for label, path in targets:
|
||||
if not path.exists():
|
||||
print(f"{label:22} MISSING {path}")
|
||||
console.out(f"{label:22} MISSING {path}")
|
||||
missing += 1
|
||||
continue
|
||||
m = measure(path)
|
||||
r, g, b = (m["std"][c] for c in "RGB")
|
||||
print(
|
||||
console.out(
|
||||
f"{label:22} {m['distinct']:>9} {r:>7.2f} {g:>7.2f} {b:>7.2f} {m['spread']:>11.2f}"
|
||||
)
|
||||
return 1 if missing else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Quick diagnostic for an atlas proposal — shows planet count, body summary, and flags.
|
||||
|
||||
Usage:
|
||||
tooling/atlas-check docs/atlas/proposals/GJ1075.json
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from tooling.core import console
|
||||
|
||||
|
||||
def check(path: Path):
|
||||
with open(path) as f:
|
||||
p = json.load(f)
|
||||
|
||||
bodies = p.get("bodies", [])
|
||||
stations = p.get("stations", [])
|
||||
|
||||
planets = [b for b in bodies if b["body_type"] == "planet" and not b.get("parent_body_id")]
|
||||
gas_giants = [b for b in bodies if b["body_type"] == "gas_giant"]
|
||||
moons = [b for b in bodies if b["body_type"] == "moon"]
|
||||
belts = [b for b in bodies if b["body_type"] == "asteroid_belt"]
|
||||
oort = [b for b in bodies if b["body_type"] == "oort_cloud"]
|
||||
|
||||
sc = p.get("spectral_class", "")
|
||||
if sc.startswith(("G", "F")):
|
||||
min_p = 8
|
||||
elif sc.startswith("K"):
|
||||
min_p = 7
|
||||
else:
|
||||
min_p = 6
|
||||
|
||||
console.event(f"=== {path.name} — {p.get('proper_name') or p['system_id']} ===")
|
||||
console.event(f" star_type: {p.get('star_type')} spectral: {sc}")
|
||||
console.event(f" planets: {len(planets)} (need {min_p}+) gas_giants: {len(gas_giants)} moons: {len(moons)}")
|
||||
console.event(f" belts: {len(belts)} oort: {len(oort)} stations: {len(stations)}")
|
||||
|
||||
if planets:
|
||||
console.event(f" orbits: {' → '.join(str(b['orbit_index']) for b in planets)}")
|
||||
console.event(f" gravity: {' / '.join(str(b['surface_gravity']) for b in planets)}")
|
||||
console.event(f" periods: {' / '.join(str(b['orbital_period_days']) for b in planets)} days")
|
||||
|
||||
inhabited = [b for b in bodies if b.get("inhabited")]
|
||||
if inhabited:
|
||||
for b in inhabited:
|
||||
console.event(f" inhabited: {b['body_id']} ({b.get('proper_name', '?')}) pop={b.get('population')}")
|
||||
|
||||
for s in stations:
|
||||
console.event(f" station: {s['station_id']} ({s.get('proper_name', '?')}) type={s['station_type']} pop={s.get('population')}")
|
||||
|
||||
# Flags
|
||||
if len(planets) < min_p:
|
||||
console.event(f" ⚠ need {min_p - len(planets)} more planet(s)")
|
||||
if not belts:
|
||||
console.event(" ⚠ no asteroid belt")
|
||||
if not oort:
|
||||
console.event(" ⚠ no oort cloud")
|
||||
horizons = [s for s in stations if s["station_type"] == "horizon"]
|
||||
if not horizons:
|
||||
console.event(" ⚠ no horizon station")
|
||||
elif not any(s.get("has_gate_infrastructure") for s in horizons):
|
||||
console.event(" ⚠ horizon station missing gate_infrastructure")
|
||||
|
||||
|
||||
def report(proposals: list[Path]) -> None:
|
||||
"""Run the diagnostic over each proposal. Purely informational — no verdict.
|
||||
|
||||
The original's arg handling lived in a bare sys.argv loop; the router owns
|
||||
that now.
|
||||
"""
|
||||
for path in proposals:
|
||||
check(path)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Transport for the `atlas` domain — args in, delegate, format out."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
from tooling.core import cli, console
|
||||
from tooling.core.command import command
|
||||
from tooling.core.errors import ReachError
|
||||
from tooling.domains.atlas import binary, flatness, proposal_check, service, verify
|
||||
|
||||
app = cli.domain("atlas", "The spatial ladder — authoring, inspection and the DB.")
|
||||
|
||||
|
||||
@app.callback()
|
||||
def _domain() -> None:
|
||||
"""Keeps `atlas` a group (Typer collapses a single-command app)."""
|
||||
|
||||
|
||||
# --- the Rust binary, fronted ---------------------------------------------
|
||||
|
||||
|
||||
@app.command("db")
|
||||
@command
|
||||
def db(
|
||||
verb: str = typer.Argument(..., help=f"One of: {', '.join(binary.KNOWN_VERBS)}"),
|
||||
args: list[str] = typer.Argument(None, help="Arguments passed to the binary."),
|
||||
) -> None:
|
||||
"""Query or mutate the atlas DB through the Rust `atlas` binary.
|
||||
|
||||
A passthrough on purpose. The verbs live in Rust, so listing them here keeps
|
||||
`reach atlas --help` a complete index — but an unrecognised verb is still
|
||||
forwarded rather than rejected, because a list maintained by hand falls
|
||||
behind the binary it describes.
|
||||
"""
|
||||
output = binary.run(verb, *(args or []))
|
||||
if output.strip():
|
||||
console.out(output.rstrip())
|
||||
|
||||
|
||||
# --- read-only queries ----------------------------------------------------
|
||||
|
||||
|
||||
@app.command("names")
|
||||
@command
|
||||
def names() -> None:
|
||||
"""Every proper name in the atlas — for collision avoidance when authoring."""
|
||||
for name in service.proper_names():
|
||||
console.out(name)
|
||||
|
||||
|
||||
@app.command("systems-done")
|
||||
@command
|
||||
def systems_done() -> None:
|
||||
"""System ids that already have bodies, i.e. have been authored."""
|
||||
for system in service.systems_with_bodies():
|
||||
console.out(system)
|
||||
|
||||
|
||||
# --- proposal workflow ----------------------------------------------------
|
||||
|
||||
|
||||
@app.command("check")
|
||||
@command
|
||||
def check(
|
||||
proposals: list[Path] = typer.Argument(None, help="Proposal JSON file(s)."),
|
||||
) -> None:
|
||||
"""Quick diagnostic on a proposal — planet count, body summary, flags."""
|
||||
targets = service.proposal_paths([str(p) for p in (proposals or [])])
|
||||
if not targets:
|
||||
raise ReachError(
|
||||
"no proposals found",
|
||||
fix="pass a path, or author one under docs/atlas/proposals/",
|
||||
exit_code=2,
|
||||
)
|
||||
proposal_check.report(targets)
|
||||
|
||||
|
||||
@app.command("verify")
|
||||
@command
|
||||
def verify_proposals(
|
||||
proposals: list[Path] = typer.Argument(None, help="Proposal JSON file(s)."),
|
||||
) -> None:
|
||||
"""Verify proposals against the integrity checks."""
|
||||
targets = service.proposal_paths([str(p) for p in (proposals or [])])
|
||||
errors = verify.verify_all(targets)
|
||||
if errors:
|
||||
raise ReachError(
|
||||
f"{errors} error(s) across {len(targets)} file(s)",
|
||||
fix="each failure above names the file and the field at fault",
|
||||
)
|
||||
console.verdict(f"atlas-verify: OK — {len(targets)} proposal(s)")
|
||||
|
||||
|
||||
@app.command("commit-and-sync")
|
||||
@command
|
||||
def commit_and_sync(
|
||||
system_id: str = typer.Argument(..., help="System id, e.g. 'GJ 273'."),
|
||||
corridor: str = typer.Option("east_reach", "--corridor", help="Corridor tag."),
|
||||
commit: bool = typer.Option(
|
||||
False,
|
||||
"--commit",
|
||||
help="Also make the git commit. Without this, files are staged and reported.",
|
||||
),
|
||||
) -> None:
|
||||
"""Verify a proposal, load it into the DB, sync the wiki, stage the result.
|
||||
|
||||
STAGES by default; `--commit` makes the commit. The original always
|
||||
committed, and nothing else in reach writes to git history — a tool that
|
||||
commits as a side effect of "sync" is a different risk class from one that
|
||||
writes a file.
|
||||
"""
|
||||
service.commit_and_sync(system_id, corridor, commit)
|
||||
|
||||
|
||||
# --- capture analysis -----------------------------------------------------
|
||||
|
||||
|
||||
@app.command("update-field")
|
||||
@command
|
||||
def update_field(
|
||||
system_id: str = typer.Argument(..., help="System id, e.g. 'GJ 273'."),
|
||||
field: str = typer.Argument(..., help="Field to amend."),
|
||||
value: str = typer.Argument(..., help="New value."),
|
||||
) -> None:
|
||||
"""Amend one field on a star system record.
|
||||
|
||||
This writes SQL directly to systems.db, which looks like the thing
|
||||
.claude/rules/asset-pipeline.md forbids — it is not. That rule exists
|
||||
because regen silently reverts hand edits, and `import_economics` does not
|
||||
own these columns; they come from the one-time baked imports, so the edit
|
||||
persists. Caveat worth knowing: the value then lives only in a committed
|
||||
binary, so it cannot be regenerated and will not show in a diff.
|
||||
"""
|
||||
table = service.update_field(system_id, field, value)
|
||||
console.verdict(f"atlas-update-field: {table}.{field} = {value!r} for {system_id}")
|
||||
|
||||
|
||||
@app.command("flatness")
|
||||
@command
|
||||
def flatness_report(
|
||||
images: list[Path] = typer.Argument(None, help="Captures to measure."),
|
||||
ladder: bool = typer.Option(
|
||||
False, "--ladder", help="Measure the standard descent ladder in .cache/screenshots/."
|
||||
),
|
||||
) -> None:
|
||||
"""Measure how much structure an Atlas capture carries, per rung."""
|
||||
code = flatness.report(list(images or []), ladder)
|
||||
if code != 0:
|
||||
raise ReachError(
|
||||
"some captures are missing",
|
||||
fix="re-run the capture, or check .cache/screenshots/ for the ladder",
|
||||
exit_code=code,
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Logic for the `atlas` domain. Transport-agnostic (D-263).
|
||||
|
||||
Ported from four bash scripts. Each of them shelled out to the Rust binary and
|
||||
then piped the JSON through a `python3 -c` heredoc — so the logic was already
|
||||
Python, just unreachable: not importable, not testable, and invisible to
|
||||
anything that indexes the tree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from tooling.core import config, console, process
|
||||
from tooling.core.errors import ReachError, unknown_choice
|
||||
from tooling.domains.atlas import binary, verify
|
||||
|
||||
# Which table owns which field. The old script carried this map inside a heredoc
|
||||
# string, where nothing could check it against the schema.
|
||||
FIELD_TABLE = {
|
||||
"star_type": "star_systems",
|
||||
"spectral_class": "star_systems",
|
||||
"proper_name": "star_systems",
|
||||
"geographic_sector": "star_systems",
|
||||
"habitable_planet_count": "star_systems",
|
||||
"inhabited_planet_count": "star_systems",
|
||||
"gate_topology": "system_gates",
|
||||
"aperture_count": "system_gates",
|
||||
"hop_distance_from_gateway": "system_gates",
|
||||
}
|
||||
|
||||
|
||||
def proper_names() -> list[str]:
|
||||
"""Every proper name in the atlas, for collision avoidance when authoring."""
|
||||
names = set()
|
||||
for verb, key in (("list-bodies", "proper_name"), ("list-stations", "proper_name")):
|
||||
for record in json.loads(binary.run(verb) or "[]"):
|
||||
if record.get(key):
|
||||
names.add(record[key])
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def systems_with_bodies() -> list[str]:
|
||||
"""System ids that already have bodies — i.e. already authored."""
|
||||
bodies = json.loads(binary.run("list-bodies") or "[]")
|
||||
return sorted({body["system_id"] for body in bodies})
|
||||
|
||||
|
||||
def update_field(system_id: str, field: str, value: str) -> str:
|
||||
"""Amend one field on a star system. Returns the table it was written to.
|
||||
|
||||
**Not the asset-pipeline violation it resembles.** That rule forbids raw SQL
|
||||
against systems.db because the next `make regen-db` silently reverts it —
|
||||
and `import_economics` touches only `currency_zone` and
|
||||
`gate_energy_connected` on `star_systems`, not these columns. These come
|
||||
from the one-time baked imports the same rule describes separately, so the
|
||||
edits persist and there is no regen path to route them through. This is the
|
||||
sanctioned way to amend baked atlas data.
|
||||
|
||||
The real caveat is different: a value amended here lives only in a committed
|
||||
binary, so it cannot be regenerated and will not show in a diff.
|
||||
"""
|
||||
table = FIELD_TABLE.get(field)
|
||||
if table is None:
|
||||
raise unknown_choice("field", field, FIELD_TABLE)
|
||||
|
||||
database = config.path("server", "data", "systems.db")
|
||||
if not database.is_file():
|
||||
raise ReachError(
|
||||
f"systems.db not found at {database}",
|
||||
fix="make regen-db",
|
||||
)
|
||||
|
||||
connection = sqlite3.connect(str(database))
|
||||
try:
|
||||
cursor = connection.execute(
|
||||
f"UPDATE {table} SET {field} = ? WHERE system_id = ?", # noqa: S608
|
||||
(value, system_id),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise ReachError(
|
||||
f"no {table} row for system {system_id!r}",
|
||||
fix="reach atlas systems-done — to see which systems exist",
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
return table
|
||||
|
||||
|
||||
def commit_and_sync(system_id: str, corridor: str, commit: bool) -> None:
|
||||
"""Verify a proposal, load it into the DB, sync the wiki, stage the result.
|
||||
|
||||
**Staging is the default; committing needs `--commit`.** The original always
|
||||
committed. Nothing else in reach writes to git history, and a tool that
|
||||
commits on your behalf as a side effect of "sync" is a different risk class
|
||||
from one that writes a file — so the default reports what it would commit
|
||||
and leaves the decision where it was.
|
||||
"""
|
||||
slug = system_id.replace(" ", "")
|
||||
proposal = config.path("docs", "atlas", "proposals", f"{slug}.json")
|
||||
if not proposal.is_file():
|
||||
raise ReachError(
|
||||
f"proposal not found: {proposal}",
|
||||
fix=f"author docs/atlas/proposals/{slug}.json first",
|
||||
)
|
||||
|
||||
console.event(f"verifying {proposal.name}", phase="atlas")
|
||||
if verify.verify_all([proposal]):
|
||||
raise ReachError(
|
||||
f"{proposal.name} did not verify — not committing it to the DB",
|
||||
fix="fix the errors above; a proposal that fails verification would "
|
||||
"put bad data in systems.db, which is far harder to undo",
|
||||
)
|
||||
|
||||
data = json.loads(proposal.read_text(encoding="utf-8"))
|
||||
proper = data.get("proper_name") or data["system_id"]
|
||||
|
||||
existing = json.loads(binary.run("show-system", system_id) or "{}")
|
||||
if existing.get("bodies"):
|
||||
console.event(f"wiping existing data for {system_id}", phase="atlas", level="warn")
|
||||
binary.run("wipe-system", system_id)
|
||||
|
||||
console.event(f"committing {system_id} to systems.db", phase="atlas")
|
||||
binary.run("commit-system", str(proposal))
|
||||
|
||||
console.event("syncing wiki", phase="atlas")
|
||||
binary.run("sync-wiki", system_id)
|
||||
|
||||
wiki = config.path("wiki", "star-systems", system_id.replace(" ", "-"), "index.md")
|
||||
staged = [proposal] + ([wiki] if wiki.is_file() else [])
|
||||
for path in staged:
|
||||
process.run(["git", "add", str(path)], cwd=config.repo_root())
|
||||
|
||||
message = f"data(atlas): author {system_id} ({proper}) — {corridor}"
|
||||
if not commit:
|
||||
console.verdict(
|
||||
f"staged {len(staged)} file(s) for {system_id} ({proper}) — NOT committed\n"
|
||||
f" would commit as: {message}\n"
|
||||
" pass --commit to make the commit"
|
||||
)
|
||||
return
|
||||
|
||||
process.run(["git", "commit", "-m", message], cwd=config.repo_root())
|
||||
console.verdict(f"committed {system_id} ({proper}) — {corridor}")
|
||||
|
||||
|
||||
def proposal_paths(names: list[str]) -> list[Path]:
|
||||
"""Resolve proposal arguments, defaulting to the whole directory."""
|
||||
if names:
|
||||
return [Path(name) for name in names]
|
||||
return sorted(config.path("docs", "atlas", "proposals").glob("*.json"))
|
||||
@@ -5,11 +5,11 @@ 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
|
||||
|
||||
from tooling.core import console
|
||||
|
||||
|
||||
def verify_proposal(path: Path) -> list[str]:
|
||||
"""Return list of error strings. Empty list = pass."""
|
||||
@@ -112,41 +112,27 @@ def verify_proposal(path: Path) -> list[str]:
|
||||
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 and so
|
||||
# on — so verifying it against the generated-proposal schema would report
|
||||
# failures for things that are deliberate.
|
||||
EXCLUDE = {"GJ0.json", "GJ1221.json"}
|
||||
|
||||
# Sol is hand-authored with different rules (named uninhabited bodies, etc.)
|
||||
EXCLUDE = {"GJ0.json", "GJ1221.json"}
|
||||
|
||||
total_errors = 0
|
||||
for path in args.proposals:
|
||||
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():
|
||||
print(f"SKIP — {path} not found")
|
||||
console.event(f"SKIP — {path} not found", level="warn")
|
||||
continue
|
||||
errors = verify_proposal(path)
|
||||
if errors:
|
||||
print(f"FAIL — {path.name}")
|
||||
for e in errors:
|
||||
print(f" - {e}")
|
||||
total_errors += len(errors)
|
||||
console.event(f"FAIL — {path.name}", level="error")
|
||||
for error in errors:
|
||||
console.event(f" - {error}", level="error")
|
||||
total += 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()
|
||||
console.event(f"PASS — {path.name}")
|
||||
return total
|
||||
@@ -55,6 +55,10 @@ DOMAINS: dict[str, tuple[str, str]] = {
|
||||
"tooling.domains.validate.router:app",
|
||||
"Content, checklists and RON against their schemas",
|
||||
),
|
||||
"atlas": (
|
||||
"tooling.domains.atlas.router:app",
|
||||
"The spatial ladder — authoring, inspection and the DB",
|
||||
),
|
||||
"godot": (
|
||||
"tooling.domains.godot.router:app",
|
||||
"Does the client parse, and does it parse cold",
|
||||
|
||||
Reference in New Issue
Block a user