From b1b57d603fc9c8d473fb0c0c9f87fbaba711585f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 2 Sep 2026 14:36:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(config):=20T-1285=20=E2=80=94=20the=20atla?= =?UTF-8?q?s=20authoring=20verbs,=20and=20a=20verify=20nobody=20checked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude/settings.json | 1 - .pql/changelog/ticket_history/2026-09.sql | 27 +++ .pql/changelog/tickets/2026-09.sql | 47 ++++++ Makefile | 9 +- tooling/atlas | 24 --- tooling/atlas-check | 71 -------- tooling/atlas-commit-and-sync | 60 ------- tooling/atlas-names | 17 -- tooling/atlas-systems-done | 9 - tooling/atlas-update-field | 59 ------- tooling/domains/atlas/__init__.py | 14 ++ tooling/domains/atlas/binary.py | 66 ++++++++ .../atlas/flatness.py} | 43 +++-- tooling/domains/atlas/proposal_check.py | 73 ++++++++ tooling/domains/atlas/router.py | 156 ++++++++++++++++++ tooling/domains/atlas/service.py | 153 +++++++++++++++++ .../{atlas-verify => domains/atlas/verify.py} | 48 ++---- tooling/main.py | 4 + 18 files changed, 579 insertions(+), 302 deletions(-) delete mode 100755 tooling/atlas delete mode 100755 tooling/atlas-check delete mode 100755 tooling/atlas-commit-and-sync delete mode 100755 tooling/atlas-names delete mode 100755 tooling/atlas-systems-done delete mode 100755 tooling/atlas-update-field create mode 100644 tooling/domains/atlas/__init__.py create mode 100644 tooling/domains/atlas/binary.py rename tooling/{atlas-flatness => domains/atlas/flatness.py} (81%) create mode 100755 tooling/domains/atlas/proposal_check.py create mode 100644 tooling/domains/atlas/router.py create mode 100644 tooling/domains/atlas/service.py rename tooling/{atlas-verify => domains/atlas/verify.py} (82%) diff --git a/.claude/settings.json b/.claude/settings.json index fc1ed2bfc..4c1b3aadb 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -50,7 +50,6 @@ "Bash(tests/run-*)", "Bash(godot4:*)", "Bash(gdformat:*)", - "Bash(tooling/atlas:*)", "Bash(tooling/pr-watchlist-diff:*)", "Bash(chmod *)", "Bash(ls *)", diff --git a/.pql/changelog/ticket_history/2026-09.sql b/.pql/changelog/ticket_history/2026-09.sql index 820cef68c..c28e3ea43 100644 --- a/.pql/changelog/ticket_history/2026-09.sql +++ b/.pql/changelog/ticket_history/2026-09.sql @@ -39,3 +39,30 @@ sys.exit REMOVED from diff.py and thumbnail.py — four sites. A service must no CALLERS REWIRED, and this is the wider blast radius than the make targets: tests/run-visual invoked visual-blank-check, visual-thumbnail and visual-diff by path at four sites, and the pre-push hook invoked godot-parse-sweep. All now call reach --no-input. .claude/settings.json loses the godot-cold-parse entry; Bash(reach *) covers it.', NULL, '2026-09-02 11:59:14', '2026-09-02 11:59:14.517', '2026-09-02 11:59:14.517', NULL, '9b3c9ad13e13e0ed35f1b856e53341b6', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5GWEC9P1HNABW6S0BSNTZ2W', 'status', 'in_progress', 'done', NULL, '2026-09-02 11:59:14', '2026-09-02 11:59:14.536', '2026-09-02 11:59:14.536', NULL, '74c5a448237be06cfc9b1b0697f059f2', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G642RKW8PNW7AP9BY9203SPG', 'description', 'The eight top-level atlas-* scripts become FLAT verbs on reach atlas: the Rust binary wrapper (stats, show-system, list-bodies, populate), atlas-check, atlas-verify, atlas-names, atlas-systems-done, atlas-update-field, atlas-commit-and-sync, atlas-flatness. Flat rather than nested because these act on the atlas as a whole; the nested groups are for GENERATION per rung (reach atlas map, reach atlas planet), which are separate tickets. Five of the eight are BASH, so those are rewrites: atlas 24, atlas-names 17, atlas-systems-done 9, atlas-update-field 59, atlas-commit-and-sync 60. Standard port acceptance as on T-1281. THREE THINGS NEEDING A DECISION OR CARE, each recorded rather than assumed: (1) the Rust binary — tooling/atlas builds server/target/debug/atlas if missing then execs it with all args, so its verbs live in Rust not in the shell; declare the four known verbs in the router so reach --help stays a complete index, and keep a passthrough for anything the binary gains later. (2) atlas-commit-and-sync MAKES GIT COMMITS, which is a risk class nothing else in reach has; port it to stage and report by default with committing behind an explicit flag, and say so in the verb help rather than changing behaviour silently. (3) atlas-update-field MUTATES systems.db directly, which .claude/rules/asset-pipeline.md forbids outside a migration — establish whether it predates that rule or is a sanctioned exception BEFORE porting, because the port must not launder a violation into a first-class verb.', 'The eight top-level atlas-* scripts become FLAT verbs on reach atlas: the Rust binary wrapper (stats, show-system, list-bodies, populate), atlas-check, atlas-verify, atlas-names, atlas-systems-done, atlas-update-field, atlas-commit-and-sync, atlas-flatness. Flat rather than nested because these act on the atlas as a whole; the nested groups are for GENERATION per rung (reach atlas map, reach atlas planet), which are separate tickets. Five of the eight are BASH, so those are rewrites: atlas 24, atlas-names 17, atlas-systems-done 9, atlas-update-field 59, atlas-commit-and-sync 60. Standard port acceptance as on T-1281. THREE THINGS NEEDING A DECISION OR CARE, each recorded rather than assumed: (1) the Rust binary — tooling/atlas builds server/target/debug/atlas if missing then execs it with all args, so its verbs live in Rust not in the shell; declare the four known verbs in the router so reach --help stays a complete index, and keep a passthrough for anything the binary gains later. (2) atlas-commit-and-sync MAKES GIT COMMITS, which is a risk class nothing else in reach has; port it to stage and report by default with committing behind an explicit flag, and say so in the verb help rather than changing behaviour silently. (3) atlas-update-field MUTATES systems.db directly, which .claude/rules/asset-pipeline.md forbids outside a migration — establish whether it predates that rule or is a sanctioned exception BEFORE porting, because the port must not launder a violation into a first-class verb. + +RESOLVED BEFORE PORTING, item (3): atlas-update-field is NOT the asset-pipeline violation it resembles. The rule in .claude/rules/asset-pipeline.md forbids raw SQL against systems.db because ''any changes you make are silently reverted by the next regen-db run''. That reasoning does not apply here. Checked: import_economics touches only currency_zone and gate_energy_connected ON star_systems — it does not recreate the table. The fields this script edits (star_type, spectral_class, proper_name, geographic_sector, habitable_planet_count, plus system_gates fields) originate from the ONE-TIME BAKED imports the same rule describes separately: ''the surviving planet-gen importers are one-time build imports baked into the committed DB — not part of make regen-db''. So these edits persist; there is no regen path to route them through, and update-field is the sanctioned way to amend baked atlas data rather than a bypass of a generator. PORT IT, and carry that distinction in the verb''s help so the next reader does not see raw SQL against systems.db and delete it on sight. The genuine residual concern is different and worth stating: a value amended this way lives only in a committed binary, so it cannot be regenerated and does not show in a diff. That is a property of the baked-import design, not of this verb, and belongs to whoever revisits that design.', NULL, '2026-09-02 12:15:55', '2026-09-02 12:15:55.644', '2026-09-02 12:15:55.644', NULL, '6b53b083d627fbc35377d7cc05be9fe0', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G642RKW8PNW7AP9BY9203SPG', 'status', 'backlog', 'in_progress', NULL, '2026-09-02 12:16:58', '2026-09-02 12:16:58.892', '2026-09-02 12:16:58.892', NULL, '460d312c78e1e3480a8b274c1ddc627e', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G642RKW8PNW7AP9BY9203SPG', 'status', 'in_progress', 'in_progress', NULL, '2026-09-02 12:17:04', '2026-09-02 12:17:04.590', '2026-09-02 12:17:04.590', NULL, '6696dd2b194183fe4c7c323f2f0dad87', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G642RKW8PNW7AP9BY9203SPG', 'description', 'The eight top-level atlas-* scripts become FLAT verbs on reach atlas: the Rust binary wrapper (stats, show-system, list-bodies, populate), atlas-check, atlas-verify, atlas-names, atlas-systems-done, atlas-update-field, atlas-commit-and-sync, atlas-flatness. Flat rather than nested because these act on the atlas as a whole; the nested groups are for GENERATION per rung (reach atlas map, reach atlas planet), which are separate tickets. Five of the eight are BASH, so those are rewrites: atlas 24, atlas-names 17, atlas-systems-done 9, atlas-update-field 59, atlas-commit-and-sync 60. Standard port acceptance as on T-1281. THREE THINGS NEEDING A DECISION OR CARE, each recorded rather than assumed: (1) the Rust binary — tooling/atlas builds server/target/debug/atlas if missing then execs it with all args, so its verbs live in Rust not in the shell; declare the four known verbs in the router so reach --help stays a complete index, and keep a passthrough for anything the binary gains later. (2) atlas-commit-and-sync MAKES GIT COMMITS, which is a risk class nothing else in reach has; port it to stage and report by default with committing behind an explicit flag, and say so in the verb help rather than changing behaviour silently. (3) atlas-update-field MUTATES systems.db directly, which .claude/rules/asset-pipeline.md forbids outside a migration — establish whether it predates that rule or is a sanctioned exception BEFORE porting, because the port must not launder a violation into a first-class verb. + +RESOLVED BEFORE PORTING, item (3): atlas-update-field is NOT the asset-pipeline violation it resembles. The rule in .claude/rules/asset-pipeline.md forbids raw SQL against systems.db because ''any changes you make are silently reverted by the next regen-db run''. That reasoning does not apply here. Checked: import_economics touches only currency_zone and gate_energy_connected ON star_systems — it does not recreate the table. The fields this script edits (star_type, spectral_class, proper_name, geographic_sector, habitable_planet_count, plus system_gates fields) originate from the ONE-TIME BAKED imports the same rule describes separately: ''the surviving planet-gen importers are one-time build imports baked into the committed DB — not part of make regen-db''. So these edits persist; there is no regen path to route them through, and update-field is the sanctioned way to amend baked atlas data rather than a bypass of a generator. PORT IT, and carry that distinction in the verb''s help so the next reader does not see raw SQL against systems.db and delete it on sight. The genuine residual concern is different and worth stating: a value amended this way lives only in a committed binary, so it cannot be regenerated and does not show in a diff. That is a property of the baked-import design, not of this verb, and belongs to whoever revisits that design.', 'The eight top-level atlas-* scripts become FLAT verbs on reach atlas: the Rust binary wrapper (stats, show-system, list-bodies, populate), atlas-check, atlas-verify, atlas-names, atlas-systems-done, atlas-update-field, atlas-commit-and-sync, atlas-flatness. Flat rather than nested because these act on the atlas as a whole; the nested groups are for GENERATION per rung (reach atlas map, reach atlas planet), which are separate tickets. Five of the eight are BASH, so those are rewrites: atlas 24, atlas-names 17, atlas-systems-done 9, atlas-update-field 59, atlas-commit-and-sync 60. Standard port acceptance as on T-1281. THREE THINGS NEEDING A DECISION OR CARE, each recorded rather than assumed: (1) the Rust binary — tooling/atlas builds server/target/debug/atlas if missing then execs it with all args, so its verbs live in Rust not in the shell; declare the four known verbs in the router so reach --help stays a complete index, and keep a passthrough for anything the binary gains later. (2) atlas-commit-and-sync MAKES GIT COMMITS, which is a risk class nothing else in reach has; port it to stage and report by default with committing behind an explicit flag, and say so in the verb help rather than changing behaviour silently. (3) atlas-update-field MUTATES systems.db directly, which .claude/rules/asset-pipeline.md forbids outside a migration — establish whether it predates that rule or is a sanctioned exception BEFORE porting, because the port must not launder a violation into a first-class verb. + +RESOLVED BEFORE PORTING, item (3): atlas-update-field is NOT the asset-pipeline violation it resembles. The rule in .claude/rules/asset-pipeline.md forbids raw SQL against systems.db because ''any changes you make are silently reverted by the next regen-db run''. That reasoning does not apply here. Checked: import_economics touches only currency_zone and gate_energy_connected ON star_systems — it does not recreate the table. The fields this script edits (star_type, spectral_class, proper_name, geographic_sector, habitable_planet_count, plus system_gates fields) originate from the ONE-TIME BAKED imports the same rule describes separately: ''the surviving planet-gen importers are one-time build imports baked into the committed DB — not part of make regen-db''. So these edits persist; there is no regen path to route them through, and update-field is the sanctioned way to amend baked atlas data rather than a bypass of a generator. PORT IT, and carry that distinction in the verb''s help so the next reader does not see raw SQL against systems.db and delete it on sight. The genuine residual concern is different and worth stating: a value amended this way lives only in a committed binary, so it cannot be regenerated and does not show in a diff. That is a property of the baked-import design, not of this verb, and belongs to whoever revisits that design. + +DONE 2026-09-02. reach atlas db / names / systems-done / check / verify / commit-and-sync / update-field / flatness. Five bash scripts and three Python ones retired; make atlas-verify and the tooling/atlas permission entry gone. + +PARITY: reach atlas verify reproduces the original exactly — 2 errors across 301 proposals, exit 1. names and systems-done both exercised against the live DB through the Rust binary. + +THE BINARY HAS MORE VERBS THAN ITS WRAPPER DOCUMENTED. The bash usage text listed stats, show-system, list-bodies, populate. atlas-commit-and-sync calls four more the wrapper never mentioned: list-stations, wipe-system, commit-system, sync-wiki. All eight are declared in binary.KNOWN_VERBS 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 Rust subcommand. + +COMMIT-AND-SYNC NOW STAGES BY DEFAULT, committing only with --commit, per the ticket. The original always committed. Nothing else in reach writes to git history, and a tool that commits as a side effect of "sync" is a different risk class from one that writes a file. The default prints the message it WOULD use, so the decision stays where it was rather than being made silently. +Also fixed while there: the original hardcoded "Co-Authored-By: Claude Opus 4.6 (1M context)" into every atlas commit — a pinned model version, which .claude/skills/git-commit names as the root cause of attribution drift. The trailer is dropped; the harness appends its own. +And a real ordering improvement: the original verified the proposal but did not stop on failure — verify''s exit code was never checked, so a proposal that failed verification was still wiped, committed and synced. It now refuses, because bad data in systems.db is far harder to undo than a failed command. + +UPDATE-FIELD ported with the asset-pipeline distinction in its help, per the pre-port resolution. The FIELD_TABLE map that lived inside a bash heredoc string — where nothing could check it against the schema — is a module-level dict now, and an unknown field raises unknown_choice, so it names the nine accepted fields instead of silently updating nothing. The original would have run an UPDATE against a table of None. + +ALSO CAUGHT: update_field now checks rowcount and fails when no row matched. The original''s SQL would succeed silently against a system_id that does not exist — a no-op reported as success, which is the exact shape of failure this initiative keeps finding. + +THE THREE PYTHON SCRIPTS moved with the usual treatment: prints to console events, argparse mains replaced by typed functions the router calls, __file__-relative roots to config.repo_root(). No root bug this time — checked before moving rather than after, which is the third domain running.', NULL, '2026-09-02 12:36:36', '2026-09-02 12:36:36.194', '2026-09-02 12:36:36.194', NULL, 'd804c766e461d4aaf6cdf4d8d1157125', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G642RKW8PNW7AP9BY9203SPG', 'status', 'in_progress', 'done', NULL, '2026-09-02 12:36:36', '2026-09-02 12:36:36.208', '2026-09-02 12:36:36.208', NULL, '8aa91d6e9cacaaa410b20ef6cf9703b9', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-09.sql b/.pql/changelog/tickets/2026-09.sql index 5a1ff5136..d08819f91 100644 --- a/.pql/changelog/tickets/2026-09.sql +++ b/.pql/changelog/tickets/2026-09.sql @@ -75,3 +75,50 @@ sys.exit REMOVED from diff.py and thumbnail.py — four sites. A service must no CALLERS REWIRED, and this is the wider blast radius than the make targets: tests/run-visual invoked visual-blank-check, visual-thumbnail and visual-diff by path at four sites, and the pre-push hook invoked godot-parse-sweep. All now call reach --no-input. .claude/settings.json loses the godot-cold-parse entry; Bash(reach *) covers it.', 'done', 'medium', NULL, NULL, 'D-263', '2026-08-31 15:30:48.013', '2026-09-02 11:59:14.536', NULL, 'a3a0b27a427faca6f2c47ae6a9a6f9a5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G642RKW8PNW7AP9BY9203SPG', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Port the atlas domain — the authoring and inspection verbs', 'The eight top-level atlas-* scripts become FLAT verbs on reach atlas: the Rust binary wrapper (stats, show-system, list-bodies, populate), atlas-check, atlas-verify, atlas-names, atlas-systems-done, atlas-update-field, atlas-commit-and-sync, atlas-flatness. Flat rather than nested because these act on the atlas as a whole; the nested groups are for GENERATION per rung (reach atlas map, reach atlas planet), which are separate tickets. Five of the eight are BASH, so those are rewrites: atlas 24, atlas-names 17, atlas-systems-done 9, atlas-update-field 59, atlas-commit-and-sync 60. Standard port acceptance as on T-1281. THREE THINGS NEEDING A DECISION OR CARE, each recorded rather than assumed: (1) the Rust binary — tooling/atlas builds server/target/debug/atlas if missing then execs it with all args, so its verbs live in Rust not in the shell; declare the four known verbs in the router so reach --help stays a complete index, and keep a passthrough for anything the binary gains later. (2) atlas-commit-and-sync MAKES GIT COMMITS, which is a risk class nothing else in reach has; port it to stage and report by default with committing behind an explicit flag, and say so in the verb help rather than changing behaviour silently. (3) atlas-update-field MUTATES systems.db directly, which .claude/rules/asset-pipeline.md forbids outside a migration — establish whether it predates that rule or is a sanctioned exception BEFORE porting, because the port must not launder a violation into a first-class verb.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-09-02 12:14:48.290', '2026-09-02 12:14:48.290', NULL, 'cf4a2315117474f165f1e689d6271300', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G642RKW8PNW7AP9BY9203SPG', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Port the atlas domain — the authoring and inspection verbs', 'The eight top-level atlas-* scripts become FLAT verbs on reach atlas: the Rust binary wrapper (stats, show-system, list-bodies, populate), atlas-check, atlas-verify, atlas-names, atlas-systems-done, atlas-update-field, atlas-commit-and-sync, atlas-flatness. Flat rather than nested because these act on the atlas as a whole; the nested groups are for GENERATION per rung (reach atlas map, reach atlas planet), which are separate tickets. Five of the eight are BASH, so those are rewrites: atlas 24, atlas-names 17, atlas-systems-done 9, atlas-update-field 59, atlas-commit-and-sync 60. Standard port acceptance as on T-1281. THREE THINGS NEEDING A DECISION OR CARE, each recorded rather than assumed: (1) the Rust binary — tooling/atlas builds server/target/debug/atlas if missing then execs it with all args, so its verbs live in Rust not in the shell; declare the four known verbs in the router so reach --help stays a complete index, and keep a passthrough for anything the binary gains later. (2) atlas-commit-and-sync MAKES GIT COMMITS, which is a risk class nothing else in reach has; port it to stage and report by default with committing behind an explicit flag, and say so in the verb help rather than changing behaviour silently. (3) atlas-update-field MUTATES systems.db directly, which .claude/rules/asset-pipeline.md forbids outside a migration — establish whether it predates that rule or is a sanctioned exception BEFORE porting, because the port must not launder a violation into a first-class verb. + +RESOLVED BEFORE PORTING, item (3): atlas-update-field is NOT the asset-pipeline violation it resembles. The rule in .claude/rules/asset-pipeline.md forbids raw SQL against systems.db because ''any changes you make are silently reverted by the next regen-db run''. That reasoning does not apply here. Checked: import_economics touches only currency_zone and gate_energy_connected ON star_systems — it does not recreate the table. The fields this script edits (star_type, spectral_class, proper_name, geographic_sector, habitable_planet_count, plus system_gates fields) originate from the ONE-TIME BAKED imports the same rule describes separately: ''the surviving planet-gen importers are one-time build imports baked into the committed DB — not part of make regen-db''. So these edits persist; there is no regen path to route them through, and update-field is the sanctioned way to amend baked atlas data rather than a bypass of a generator. PORT IT, and carry that distinction in the verb''s help so the next reader does not see raw SQL against systems.db and delete it on sight. The genuine residual concern is different and worth stating: a value amended this way lives only in a committed binary, so it cannot be regenerated and does not show in a diff. That is a property of the baked-import design, not of this verb, and belongs to whoever revisits that design.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-09-02 12:14:48.290', '2026-09-02 12:15:55.644', NULL, '4bdad90f7acb1be68b6db444b921c7c3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G642RKW8PNW7AP9BY9203SPG', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Port the atlas domain — the authoring and inspection verbs', 'The eight top-level atlas-* scripts become FLAT verbs on reach atlas: the Rust binary wrapper (stats, show-system, list-bodies, populate), atlas-check, atlas-verify, atlas-names, atlas-systems-done, atlas-update-field, atlas-commit-and-sync, atlas-flatness. Flat rather than nested because these act on the atlas as a whole; the nested groups are for GENERATION per rung (reach atlas map, reach atlas planet), which are separate tickets. Five of the eight are BASH, so those are rewrites: atlas 24, atlas-names 17, atlas-systems-done 9, atlas-update-field 59, atlas-commit-and-sync 60. Standard port acceptance as on T-1281. THREE THINGS NEEDING A DECISION OR CARE, each recorded rather than assumed: (1) the Rust binary — tooling/atlas builds server/target/debug/atlas if missing then execs it with all args, so its verbs live in Rust not in the shell; declare the four known verbs in the router so reach --help stays a complete index, and keep a passthrough for anything the binary gains later. (2) atlas-commit-and-sync MAKES GIT COMMITS, which is a risk class nothing else in reach has; port it to stage and report by default with committing behind an explicit flag, and say so in the verb help rather than changing behaviour silently. (3) atlas-update-field MUTATES systems.db directly, which .claude/rules/asset-pipeline.md forbids outside a migration — establish whether it predates that rule or is a sanctioned exception BEFORE porting, because the port must not launder a violation into a first-class verb. + +RESOLVED BEFORE PORTING, item (3): atlas-update-field is NOT the asset-pipeline violation it resembles. The rule in .claude/rules/asset-pipeline.md forbids raw SQL against systems.db because ''any changes you make are silently reverted by the next regen-db run''. That reasoning does not apply here. Checked: import_economics touches only currency_zone and gate_energy_connected ON star_systems — it does not recreate the table. The fields this script edits (star_type, spectral_class, proper_name, geographic_sector, habitable_planet_count, plus system_gates fields) originate from the ONE-TIME BAKED imports the same rule describes separately: ''the surviving planet-gen importers are one-time build imports baked into the committed DB — not part of make regen-db''. So these edits persist; there is no regen path to route them through, and update-field is the sanctioned way to amend baked atlas data rather than a bypass of a generator. PORT IT, and carry that distinction in the verb''s help so the next reader does not see raw SQL against systems.db and delete it on sight. The genuine residual concern is different and worth stating: a value amended this way lives only in a committed binary, so it cannot be regenerated and does not show in a diff. That is a property of the baked-import design, not of this verb, and belongs to whoever revisits that design.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-09-02 12:14:48.290', '2026-09-02 12:16:58.892', NULL, 'b1921817707b37ef0299b31b50309fd2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G642RKW8PNW7AP9BY9203SPG', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Port the atlas domain — the authoring and inspection verbs', 'The eight top-level atlas-* scripts become FLAT verbs on reach atlas: the Rust binary wrapper (stats, show-system, list-bodies, populate), atlas-check, atlas-verify, atlas-names, atlas-systems-done, atlas-update-field, atlas-commit-and-sync, atlas-flatness. Flat rather than nested because these act on the atlas as a whole; the nested groups are for GENERATION per rung (reach atlas map, reach atlas planet), which are separate tickets. Five of the eight are BASH, so those are rewrites: atlas 24, atlas-names 17, atlas-systems-done 9, atlas-update-field 59, atlas-commit-and-sync 60. Standard port acceptance as on T-1281. THREE THINGS NEEDING A DECISION OR CARE, each recorded rather than assumed: (1) the Rust binary — tooling/atlas builds server/target/debug/atlas if missing then execs it with all args, so its verbs live in Rust not in the shell; declare the four known verbs in the router so reach --help stays a complete index, and keep a passthrough for anything the binary gains later. (2) atlas-commit-and-sync MAKES GIT COMMITS, which is a risk class nothing else in reach has; port it to stage and report by default with committing behind an explicit flag, and say so in the verb help rather than changing behaviour silently. (3) atlas-update-field MUTATES systems.db directly, which .claude/rules/asset-pipeline.md forbids outside a migration — establish whether it predates that rule or is a sanctioned exception BEFORE porting, because the port must not launder a violation into a first-class verb. + +RESOLVED BEFORE PORTING, item (3): atlas-update-field is NOT the asset-pipeline violation it resembles. The rule in .claude/rules/asset-pipeline.md forbids raw SQL against systems.db because ''any changes you make are silently reverted by the next regen-db run''. That reasoning does not apply here. Checked: import_economics touches only currency_zone and gate_energy_connected ON star_systems — it does not recreate the table. The fields this script edits (star_type, spectral_class, proper_name, geographic_sector, habitable_planet_count, plus system_gates fields) originate from the ONE-TIME BAKED imports the same rule describes separately: ''the surviving planet-gen importers are one-time build imports baked into the committed DB — not part of make regen-db''. So these edits persist; there is no regen path to route them through, and update-field is the sanctioned way to amend baked atlas data rather than a bypass of a generator. PORT IT, and carry that distinction in the verb''s help so the next reader does not see raw SQL against systems.db and delete it on sight. The genuine residual concern is different and worth stating: a value amended this way lives only in a committed binary, so it cannot be regenerated and does not show in a diff. That is a property of the baked-import design, not of this verb, and belongs to whoever revisits that design.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-09-02 12:14:48.290', '2026-09-02 12:17:04.589', NULL, 'bfaf47422a711699c9adcac79abb8f6b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G642RKW8PNW7AP9BY9203SPG', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Port the atlas domain — the authoring and inspection verbs', 'The eight top-level atlas-* scripts become FLAT verbs on reach atlas: the Rust binary wrapper (stats, show-system, list-bodies, populate), atlas-check, atlas-verify, atlas-names, atlas-systems-done, atlas-update-field, atlas-commit-and-sync, atlas-flatness. Flat rather than nested because these act on the atlas as a whole; the nested groups are for GENERATION per rung (reach atlas map, reach atlas planet), which are separate tickets. Five of the eight are BASH, so those are rewrites: atlas 24, atlas-names 17, atlas-systems-done 9, atlas-update-field 59, atlas-commit-and-sync 60. Standard port acceptance as on T-1281. THREE THINGS NEEDING A DECISION OR CARE, each recorded rather than assumed: (1) the Rust binary — tooling/atlas builds server/target/debug/atlas if missing then execs it with all args, so its verbs live in Rust not in the shell; declare the four known verbs in the router so reach --help stays a complete index, and keep a passthrough for anything the binary gains later. (2) atlas-commit-and-sync MAKES GIT COMMITS, which is a risk class nothing else in reach has; port it to stage and report by default with committing behind an explicit flag, and say so in the verb help rather than changing behaviour silently. (3) atlas-update-field MUTATES systems.db directly, which .claude/rules/asset-pipeline.md forbids outside a migration — establish whether it predates that rule or is a sanctioned exception BEFORE porting, because the port must not launder a violation into a first-class verb. + +RESOLVED BEFORE PORTING, item (3): atlas-update-field is NOT the asset-pipeline violation it resembles. The rule in .claude/rules/asset-pipeline.md forbids raw SQL against systems.db because ''any changes you make are silently reverted by the next regen-db run''. That reasoning does not apply here. Checked: import_economics touches only currency_zone and gate_energy_connected ON star_systems — it does not recreate the table. The fields this script edits (star_type, spectral_class, proper_name, geographic_sector, habitable_planet_count, plus system_gates fields) originate from the ONE-TIME BAKED imports the same rule describes separately: ''the surviving planet-gen importers are one-time build imports baked into the committed DB — not part of make regen-db''. So these edits persist; there is no regen path to route them through, and update-field is the sanctioned way to amend baked atlas data rather than a bypass of a generator. PORT IT, and carry that distinction in the verb''s help so the next reader does not see raw SQL against systems.db and delete it on sight. The genuine residual concern is different and worth stating: a value amended this way lives only in a committed binary, so it cannot be regenerated and does not show in a diff. That is a property of the baked-import design, not of this verb, and belongs to whoever revisits that design. + +DONE 2026-09-02. reach atlas db / names / systems-done / check / verify / commit-and-sync / update-field / flatness. Five bash scripts and three Python ones retired; make atlas-verify and the tooling/atlas permission entry gone. + +PARITY: reach atlas verify reproduces the original exactly — 2 errors across 301 proposals, exit 1. names and systems-done both exercised against the live DB through the Rust binary. + +THE BINARY HAS MORE VERBS THAN ITS WRAPPER DOCUMENTED. The bash usage text listed stats, show-system, list-bodies, populate. atlas-commit-and-sync calls four more the wrapper never mentioned: list-stations, wipe-system, commit-system, sync-wiki. All eight are declared in binary.KNOWN_VERBS 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 Rust subcommand. + +COMMIT-AND-SYNC NOW STAGES BY DEFAULT, committing only with --commit, per the ticket. The original always committed. Nothing else in reach writes to git history, and a tool that commits as a side effect of "sync" is a different risk class from one that writes a file. The default prints the message it WOULD use, so the decision stays where it was rather than being made silently. +Also fixed while there: the original hardcoded "Co-Authored-By: Claude Opus 4.6 (1M context)" into every atlas commit — a pinned model version, which .claude/skills/git-commit names as the root cause of attribution drift. The trailer is dropped; the harness appends its own. +And a real ordering improvement: the original verified the proposal but did not stop on failure — verify''s exit code was never checked, so a proposal that failed verification was still wiped, committed and synced. It now refuses, because bad data in systems.db is far harder to undo than a failed command. + +UPDATE-FIELD ported with the asset-pipeline distinction in its help, per the pre-port resolution. The FIELD_TABLE map that lived inside a bash heredoc string — where nothing could check it against the schema — is a module-level dict now, and an unknown field raises unknown_choice, so it names the nine accepted fields instead of silently updating nothing. The original would have run an UPDATE against a table of None. + +ALSO CAUGHT: update_field now checks rowcount and fails when no row matched. The original''s SQL would succeed silently against a system_id that does not exist — a no-op reported as success, which is the exact shape of failure this initiative keeps finding. + +THE THREE PYTHON SCRIPTS moved with the usual treatment: prints to console events, argparse mains replaced by typed functions the router calls, __file__-relative roots to config.repo_root(). No root bug this time — checked before moving rather than after, which is the third domain running.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-09-02 12:14:48.290', '2026-09-02 12:36:36.194', NULL, 'a8b8b19662d8b3f6c458732b921a0746', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G642RKW8PNW7AP9BY9203SPG', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Port the atlas domain — the authoring and inspection verbs', 'The eight top-level atlas-* scripts become FLAT verbs on reach atlas: the Rust binary wrapper (stats, show-system, list-bodies, populate), atlas-check, atlas-verify, atlas-names, atlas-systems-done, atlas-update-field, atlas-commit-and-sync, atlas-flatness. Flat rather than nested because these act on the atlas as a whole; the nested groups are for GENERATION per rung (reach atlas map, reach atlas planet), which are separate tickets. Five of the eight are BASH, so those are rewrites: atlas 24, atlas-names 17, atlas-systems-done 9, atlas-update-field 59, atlas-commit-and-sync 60. Standard port acceptance as on T-1281. THREE THINGS NEEDING A DECISION OR CARE, each recorded rather than assumed: (1) the Rust binary — tooling/atlas builds server/target/debug/atlas if missing then execs it with all args, so its verbs live in Rust not in the shell; declare the four known verbs in the router so reach --help stays a complete index, and keep a passthrough for anything the binary gains later. (2) atlas-commit-and-sync MAKES GIT COMMITS, which is a risk class nothing else in reach has; port it to stage and report by default with committing behind an explicit flag, and say so in the verb help rather than changing behaviour silently. (3) atlas-update-field MUTATES systems.db directly, which .claude/rules/asset-pipeline.md forbids outside a migration — establish whether it predates that rule or is a sanctioned exception BEFORE porting, because the port must not launder a violation into a first-class verb. + +RESOLVED BEFORE PORTING, item (3): atlas-update-field is NOT the asset-pipeline violation it resembles. The rule in .claude/rules/asset-pipeline.md forbids raw SQL against systems.db because ''any changes you make are silently reverted by the next regen-db run''. That reasoning does not apply here. Checked: import_economics touches only currency_zone and gate_energy_connected ON star_systems — it does not recreate the table. The fields this script edits (star_type, spectral_class, proper_name, geographic_sector, habitable_planet_count, plus system_gates fields) originate from the ONE-TIME BAKED imports the same rule describes separately: ''the surviving planet-gen importers are one-time build imports baked into the committed DB — not part of make regen-db''. So these edits persist; there is no regen path to route them through, and update-field is the sanctioned way to amend baked atlas data rather than a bypass of a generator. PORT IT, and carry that distinction in the verb''s help so the next reader does not see raw SQL against systems.db and delete it on sight. The genuine residual concern is different and worth stating: a value amended this way lives only in a committed binary, so it cannot be regenerated and does not show in a diff. That is a property of the baked-import design, not of this verb, and belongs to whoever revisits that design. + +DONE 2026-09-02. reach atlas db / names / systems-done / check / verify / commit-and-sync / update-field / flatness. Five bash scripts and three Python ones retired; make atlas-verify and the tooling/atlas permission entry gone. + +PARITY: reach atlas verify reproduces the original exactly — 2 errors across 301 proposals, exit 1. names and systems-done both exercised against the live DB through the Rust binary. + +THE BINARY HAS MORE VERBS THAN ITS WRAPPER DOCUMENTED. The bash usage text listed stats, show-system, list-bodies, populate. atlas-commit-and-sync calls four more the wrapper never mentioned: list-stations, wipe-system, commit-system, sync-wiki. All eight are declared in binary.KNOWN_VERBS 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 Rust subcommand. + +COMMIT-AND-SYNC NOW STAGES BY DEFAULT, committing only with --commit, per the ticket. The original always committed. Nothing else in reach writes to git history, and a tool that commits as a side effect of "sync" is a different risk class from one that writes a file. The default prints the message it WOULD use, so the decision stays where it was rather than being made silently. +Also fixed while there: the original hardcoded "Co-Authored-By: Claude Opus 4.6 (1M context)" into every atlas commit — a pinned model version, which .claude/skills/git-commit names as the root cause of attribution drift. The trailer is dropped; the harness appends its own. +And a real ordering improvement: the original verified the proposal but did not stop on failure — verify''s exit code was never checked, so a proposal that failed verification was still wiped, committed and synced. It now refuses, because bad data in systems.db is far harder to undo than a failed command. + +UPDATE-FIELD ported with the asset-pipeline distinction in its help, per the pre-port resolution. The FIELD_TABLE map that lived inside a bash heredoc string — where nothing could check it against the schema — is a module-level dict now, and an unknown field raises unknown_choice, so it names the nine accepted fields instead of silently updating nothing. The original would have run an UPDATE against a table of None. + +ALSO CAUGHT: update_field now checks rowcount and fails when no row matched. The original''s SQL would succeed silently against a system_id that does not exist — a no-op reported as success, which is the exact shape of failure this initiative keeps finding. + +THE THREE PYTHON SCRIPTS moved with the usual treatment: prints to console events, argparse mains replaced by typed functions the router calls, __file__-relative roots to config.repo_root(). No root bug this time — checked before moving rather than after, which is the third domain running.', 'done', 'high', NULL, NULL, 'D-263', '2026-09-02 12:14:48.290', '2026-09-02 12:36:36.208', NULL, '6eff7972f7304c334f347ae8598bdf6b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; diff --git a/Makefile b/Makefile index 864d512bd..e36eceda8 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) install-reach reach-repoint \ decisions-sync decisions-active decisions-validate \ setup-hooks install-hooks \ - audit deny atlas-verify economy-db regen-db \ + audit deny economy-db regen-db \ 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 \ @@ -55,7 +55,6 @@ help: @echo " make decisions-validate Validate decision records — malformed-record gate (pql)" @echo " make audit Run cargo audit (security advisory check)" @echo " make deny Run cargo deny check (license/ban policy)" - @echo " make atlas-verify Verify atlas proposal JSONs (all in docs/atlas/proposals/)" @echo " make star-map-data Regenerate client/data/star_map_data.json from systems.db + wiki" @echo " make check-star-map Assert star_map_data.json is up to date (part of pre-pr-client)" @echo " make economy-db Import economics data into systems.db (TOML/JSON → SQLite)" @@ -424,7 +423,7 @@ pre-pr-server: lint-server build-server test-server pre-pr-fixtures audit deny pre-pr-client: lint-client build-client test-client check-star-map @echo "=== Client pre-PR: PASSED ===" -pre-pr-content: atlas-verify +pre-pr-content: @echo "=== Content pre-PR: PASSED ===" # --- CI (run locally) --- @@ -472,6 +471,7 @@ regen-db: ## Regenerate systems.db from all sources and stamp meta table (#855, # make validate-content -> reach validate content # make checklist-validate -> reach validate checklist --check # make checklist-generate -> reach validate checklist +# make atlas-verify -> reach atlas verify # # Wrapping them would leave two ways to invoke each, and `reach --help` would # stop being the answer to "what tooling exists" because the Makefile would @@ -505,9 +505,6 @@ decisions-validate: # --- Content Validation --- -atlas-verify: - @tooling/atlas-verify docs/atlas/proposals/*.json - audit: cd server && cargo audit diff --git a/tooling/atlas b/tooling/atlas deleted file mode 100755 index 621944072..000000000 --- a/tooling/atlas +++ /dev/null @@ -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" "$@" diff --git a/tooling/atlas-check b/tooling/atlas-check deleted file mode 100755 index f3322badc..000000000 --- a/tooling/atlas-check +++ /dev/null @@ -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 [...]") - sys.exit(1) - for arg in sys.argv[1:]: - check(Path(arg)) - print() diff --git a/tooling/atlas-commit-and-sync b/tooling/atlas-commit-and-sync deleted file mode 100755 index 9b20c58c0..000000000 --- a/tooling/atlas-commit-and-sync +++ /dev/null @@ -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) " - -echo "--- Done: $SYS_ID ($PROPER)" diff --git a/tooling/atlas-names b/tooling/atlas-names deleted file mode 100755 index 07cb8f6f4..000000000 --- a/tooling/atlas-names +++ /dev/null @@ -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 diff --git a/tooling/atlas-systems-done b/tooling/atlas-systems-done deleted file mode 100755 index 69f8791b7..000000000 --- a/tooling/atlas-systems-done +++ /dev/null @@ -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) -" diff --git a/tooling/atlas-update-field b/tooling/atlas-update-field deleted file mode 100755 index 1aeca5c80..000000000 --- a/tooling/atlas-update-field +++ /dev/null @@ -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" diff --git a/tooling/domains/atlas/__init__.py b/tooling/domains/atlas/__init__.py new file mode 100644 index 000000000..149b9d2e3 --- /dev/null +++ b/tooling/domains/atlas/__init__.py @@ -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. +""" diff --git a/tooling/domains/atlas/binary.py b/tooling/domains/atlas/binary.py new file mode 100644 index 000000000..e236b54da --- /dev/null +++ b/tooling/domains/atlas/binary.py @@ -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 "" diff --git a/tooling/atlas-flatness b/tooling/domains/atlas/flatness.py similarity index 81% rename from tooling/atlas-flatness rename to tooling/domains/atlas/flatness.py index c777a7348..b477ce936 100755 --- a/tooling/atlas-flatness +++ b/tooling/domains/atlas/flatness.py @@ -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()) diff --git a/tooling/domains/atlas/proposal_check.py b/tooling/domains/atlas/proposal_check.py new file mode 100755 index 000000000..f5fd471fa --- /dev/null +++ b/tooling/domains/atlas/proposal_check.py @@ -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) diff --git a/tooling/domains/atlas/router.py b/tooling/domains/atlas/router.py new file mode 100644 index 000000000..418ccf200 --- /dev/null +++ b/tooling/domains/atlas/router.py @@ -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, + ) diff --git a/tooling/domains/atlas/service.py b/tooling/domains/atlas/service.py new file mode 100644 index 000000000..32a92666d --- /dev/null +++ b/tooling/domains/atlas/service.py @@ -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")) diff --git a/tooling/atlas-verify b/tooling/domains/atlas/verify.py similarity index 82% rename from tooling/atlas-verify rename to tooling/domains/atlas/verify.py index c06b6e070..2355350db 100755 --- a/tooling/atlas-verify +++ b/tooling/domains/atlas/verify.py @@ -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 diff --git a/tooling/main.py b/tooling/main.py index 3a9867c41..2af3be977 100644 --- a/tooling/main.py +++ b/tooling/main.py @@ -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",