Files
settled-reach/tooling/domains/atlas/service.py
T
jpmschweitzerandClaude Opus 5 b1b57d603f 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>
2026-09-02 14:36:51 +02:00

154 lines
6.0 KiB
Python

"""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"))