diff --git a/.pql/changelog/ticket_history/2026-08.sql b/.pql/changelog/ticket_history/2026-08.sql index f5128f14e..d53c5f0a3 100644 --- a/.pql/changelog/ticket_history/2026-08.sql +++ b/.pql/changelog/ticket_history/2026-08.sql @@ -2133,3 +2133,23 @@ WAVE 1 FILED 2026-08-31: T-1281 check (four remaining gates), T-1282 validate, T SCOPE CORRECTION 2026-08-31, found on starting the first port and materially larger than the epic assumed. SEVENTEEN of the 33 extensionless executables are BASH, not Python — about 900 lines. This description and the domain map both assumed a Python tree, so those are REWRITES, not moves. Decided with Jeroen: rewrite all of them in Python rather than wrap any. Wrapping would achieve one door while leaving half the CLI surface outside the contract — no @command, no remedy on failure, no streaming, no testable service — so reach --help would list verbs that behave differently from the ones beside them, which is worse than two doors because the inconsistency is invisible until something fails. D-263 amended. The bash inventory, by shape: THIN WRAPPERS that mostly invoke something else (atlas-systems-done 9, atlas-names 17, generate-brands 23, atlas 24, generate-corporations 23, blender 28) — these become routers calling the same thing, nearly free. LOGIC that computes a verdict (check-fact-ids 89, validate-ron 137, godot-parse-sweep 64, godot-cold-parse 96, pr-watchlist-diff 37, atlas-update-field 59, atlas-commit-and-sync 60, tea-comment 41) — these gain the most from becoming services. ENVIRONMENT scripts (install-godot 108, install-rust 33, worktree-setup 49) — these gain least and carry the most regression risk, because downloading and unzipping a Godot build or driving rustup is awkward to exercise in a gate; port their DECISION logic into a testable service and keep the irreducible external calls behind core/process. Per-domain port tickets must state which of their sources are bash, since a bash source changes a port from mechanical to a rewrite needing its own parity evidence. GUARDED EXEC RULE, clarified by Jeroen 2026-08-31 and now the operative reading of the rewrite decision: ''for proper OS level interactions I am okay with system calls in a well guarded exec here or there, but the logic needs to go into python''. So rewrite-in-Python does NOT mean reimplementing the operating system. A guarded exec is correct for rustup, curl, unzip, git, godot, blender. What must become Python is the LOGIC — which version is wanted, whether it is already present, what the output means, what to do when it fails. THE TEST OF A CORRECT PORT is not whether it calls anything external but whether the DECISIONS can be exercised without performing them. Delivered ahead of the remaining ports, since every one of them needs it: core/process.run() is the single sanctioned exec, with four guards that each exist because a per-domain subprocess call is where they go missing — (1) an argv LIST, never a shell string, with a string rejected outright rather than helpfully split, since the helpful split is the vulnerability; (2) shell=False always; (3) a non-zero exit becomes a ReachError naming the command, carrying its output and PRESERVING its exit code; (4) a missing binary reports WHAT TO INSTALL rather than raising FileNotFoundError at someone who wanted to know the next step. All four verified against real commands. New conformance invariant enforces the single door: nothing outside core/process.py may import subprocess or call os.system/popen/exec*. Proven to fail by importing subprocess into a domain service.', NULL, '2026-08-31 15:48:10', '2026-08-31 15:48:10.137', '2026-08-31 15:48:10.137', NULL, '62fd6c01a7403e4134ec19473d8893fa', 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 ('06G5GWCTS89VSST07DFVBVG064', 'description', 'Finish what T-1262 started. domains/check/ currently has one verb (client-version); the other four gates join it: check-canvas-version, check-systems-db-stamp, check-fact-ids and check-dataflow-graph.py. Per the domain map, canvas_sources.py and generator_sources.py move alongside as module-level REGISTRIES, not verbs — nothing types them, and putting them in reach --help would answer a question nobody has. schema_version.py goes to the db domain where it is defined; check imports it, which is what a service layer is for. STANDARD PORT ACCEPTANCE, applying to every per-domain ticket under this epic: (a) service.py holds the logic and is transport-agnostic — no printing, no sys.exit, no typer; (b) router.py holds no logic and every command wears @command; (c) a parity test per gate, copying the pattern in tooling/test_check_parity.py, which builds a throwaway fixture repo and runs BOTH implementations — exit codes must match exactly and no fact the old message carried may be lost; (d) the old scripts are RETIRED once parity passes, not left alongside; (e) that domain''s tooling make targets are retired too rather than left as wrappers, per the D-263 make/reach split — build orchestration stays with make. Note check-dataflow-graph.py and check-canvas-version both parse git output, so their fixtures need a git repo rather than a bare directory; that is the one thing here harder than client-version was. + +PROGRESS 2026-08-31 — fact-ids ported, the first bash-to-Python rewrite under the D-263 decision. It was 89 lines of grep/sed pipeline; it is now service.fact_ids() returning a FactIdCheck, with a router that renders it. PARITY IS EXACT ON THE LIVE TREE: both implementations print ''check-fact-ids: OK — 6 references validated against 61 canonical facts'' and exit 0. The matching COUNTS are the real evidence — if the line-matching regex differed from the grep chain even slightly, 6 and 61 would diverge. Kept line-matched rather than YAML-parsed on purpose: parsing properly would change which lines count (anchors, merge keys, multi-document files would start contributing ids the old check never saw), which is a different check wearing the same name. Three parity cases added to test_check_parity.py — ok, unknown fact_id, and the advisory mode where catalogs are unpopulated. Proven to fail: removing the entity-attributes.yaml exclusion was caught, and interestingly by the ADVISORY case rather than the exclusion assertion — including that file made the catalog non-empty, so the new implementation went to enforcing mode while the old stayed advisory. A real behavioural divergence, caught by exit code. RETIREMENT DEFERRED to the end of this ticket, and there is a reason worth recording. The rule is retire per DOMAIN, and three gates remain. It also resolves a tension: the parity test COPIES the old script into its fixture, so deleting the script early would delete the test''s own subject. A parity test is scaffolding with a defined lifetime — when the old script goes, its parity case goes with it and is replaced by a unit test of the new behaviour. Same will apply to every port under T-1250. NOTE the pre-commit hook calls tooling/check-fact-ids directly, so retirement means editing that hook — and a mistake there is felt on every commit rather than every push. Do it together with validate-content (T-1282), which is in the same hook, so it is edited once.', 'Finish what T-1262 started. domains/check/ currently has one verb (client-version); the other four gates join it: check-canvas-version, check-systems-db-stamp, check-fact-ids and check-dataflow-graph.py. Per the domain map, canvas_sources.py and generator_sources.py move alongside as module-level REGISTRIES, not verbs — nothing types them, and putting them in reach --help would answer a question nobody has. schema_version.py goes to the db domain where it is defined; check imports it, which is what a service layer is for. STANDARD PORT ACCEPTANCE, applying to every per-domain ticket under this epic: (a) service.py holds the logic and is transport-agnostic — no printing, no sys.exit, no typer; (b) router.py holds no logic and every command wears @command; (c) a parity test per gate, copying the pattern in tooling/test_check_parity.py, which builds a throwaway fixture repo and runs BOTH implementations — exit codes must match exactly and no fact the old message carried may be lost; (d) the old scripts are RETIRED once parity passes, not left alongside; (e) that domain''s tooling make targets are retired too rather than left as wrappers, per the D-263 make/reach split — build orchestration stays with make. Note check-dataflow-graph.py and check-canvas-version both parse git output, so their fixtures need a git repo rather than a bare directory; that is the one thing here harder than client-version was. + +PROGRESS 2026-08-31 — fact-ids ported, the first bash-to-Python rewrite under the D-263 decision. It was 89 lines of grep/sed pipeline; it is now service.fact_ids() returning a FactIdCheck, with a router that renders it. PARITY IS EXACT ON THE LIVE TREE: both implementations print ''check-fact-ids: OK — 6 references validated against 61 canonical facts'' and exit 0. The matching COUNTS are the real evidence — if the line-matching regex differed from the grep chain even slightly, 6 and 61 would diverge. Kept line-matched rather than YAML-parsed on purpose: parsing properly would change which lines count (anchors, merge keys, multi-document files would start contributing ids the old check never saw), which is a different check wearing the same name. Three parity cases added to test_check_parity.py — ok, unknown fact_id, and the advisory mode where catalogs are unpopulated. Proven to fail: removing the entity-attributes.yaml exclusion was caught, and interestingly by the ADVISORY case rather than the exclusion assertion — including that file made the catalog non-empty, so the new implementation went to enforcing mode while the old stayed advisory. A real behavioural divergence, caught by exit code. RETIREMENT DEFERRED to the end of this ticket, and there is a reason worth recording. The rule is retire per DOMAIN, and three gates remain. It also resolves a tension: the parity test COPIES the old script into its fixture, so deleting the script early would delete the test''s own subject. A parity test is scaffolding with a defined lifetime — when the old script goes, its parity case goes with it and is replaced by a unit test of the new behaviour. Same will apply to every port under T-1250. NOTE the pre-commit hook calls tooling/check-fact-ids directly, so retirement means editing that hook — and a mistake there is felt on every commit rather than every push. Do it together with validate-content (T-1282), which is in the same hook, so it is edited once. + +PROGRESS 2026-08-31, second increment — dataflow-graph and systems-db-stamp ported. Both were already Python, so these are moves rather than rewrites, and both produce BYTE-IDENTICAL output to their originals on the live tree: "check-dataflow-graph: OK — 1 diagram(s)" and "check-systems-db-stamp: OK — 1 generator(s) up to date", same exit codes. + +CORRECTION TO THIS TICKET''S OWN NOTE: it says check-dataflow-graph.py parses git output. It does not — it reads .d2 files and globs the filesystem. Only check-canvas-version parses git, so only that one needs a git repo in its fixture. + +THE E402 DEBT EVAPORATED, exactly as T-1274 predicted. check-systems-db-stamp reached generator_sources through sys.path.insert plus a noqa: E402, because tooling/ was not a package. It now imports as `from tooling import generator_sources` with no hack and no suppression. That is the first concrete evidence for T-1274''s premise; the map''s plan to move generator_sources into the db domain still stands, but it is already importable where it sits. + +SIX FAILURE MODES PRESERVED in the stamp port, modelled as a StampState enum rather than collapsed to pass/fail, because they carry different remedies and one carries a different EXIT CODE: UNSTAMPED exits 2 while every other failure exits 1, and the pre-push hook has relied on that distinction since T-857. The states are ok / absent / unstamped / bad_version / conflict / unknown / broken / stale, each with its own remedy string. + +ONE DELIBERATE BEHAVIOURAL DIFFERENCE, flagged rather than hidden: the old stamp script was SILENT on success unless given --verbose; the new one always prints its verdict. That is not a lost fact, so it does not violate the parity definition, and it makes the gate consistent with client-version and dataflow-graph, which both always print. The old script was the odd one out. Its per-command --verbose also disappears in favour of the global one, which is the consolidation the whole initiative is for. + +STILL OPEN on this ticket, and honest about it: +- check-canvas-version is not ported. It is the one that genuinely parses git, so its fixture needs a real git repo — the hardest of the four. +- NO PARITY TESTS yet for dataflow-graph or systems-db-stamp. Both were verified by running the two implementations side by side on the live tree, which proves the happy path only. The stamp gate''s six failure modes are entirely unexercised, and its fixture is awkward because the old script imports generator_sources from its own directory, so a fixture must copy both files and build a sqlite DB with a meta table. +- Retirement (scripts, parity cases, make targets, the pre-commit hook entry for fact-ids) waits for the whole domain, per the per-domain rule.', NULL, '2026-08-31 15:55:15', '2026-08-31 15:55:15.374', '2026-08-31 15:55:15.374', NULL, 'eed8836144005a06232829ab3dfad2e6', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-08.sql b/.pql/changelog/tickets/2026-08.sql index 6a6a0127e..cf9ef988a 100644 --- a/.pql/changelog/tickets/2026-08.sql +++ b/.pql/changelog/tickets/2026-08.sql @@ -2462,3 +2462,21 @@ WAVE 1 FILED 2026-08-31: T-1281 check (four remaining gates), T-1282 validate, T SCOPE CORRECTION 2026-08-31, found on starting the first port and materially larger than the epic assumed. SEVENTEEN of the 33 extensionless executables are BASH, not Python — about 900 lines. This description and the domain map both assumed a Python tree, so those are REWRITES, not moves. Decided with Jeroen: rewrite all of them in Python rather than wrap any. Wrapping would achieve one door while leaving half the CLI surface outside the contract — no @command, no remedy on failure, no streaming, no testable service — so reach --help would list verbs that behave differently from the ones beside them, which is worse than two doors because the inconsistency is invisible until something fails. D-263 amended. The bash inventory, by shape: THIN WRAPPERS that mostly invoke something else (atlas-systems-done 9, atlas-names 17, generate-brands 23, atlas 24, generate-corporations 23, blender 28) — these become routers calling the same thing, nearly free. LOGIC that computes a verdict (check-fact-ids 89, validate-ron 137, godot-parse-sweep 64, godot-cold-parse 96, pr-watchlist-diff 37, atlas-update-field 59, atlas-commit-and-sync 60, tea-comment 41) — these gain the most from becoming services. ENVIRONMENT scripts (install-godot 108, install-rust 33, worktree-setup 49) — these gain least and carry the most regression risk, because downloading and unzipping a Godot build or driving rustup is awkward to exercise in a gate; port their DECISION logic into a testable service and keep the irreducible external calls behind core/process. Per-domain port tickets must state which of their sources are bash, since a bash source changes a port from mechanical to a rewrite needing its own parity evidence. GUARDED EXEC RULE, clarified by Jeroen 2026-08-31 and now the operative reading of the rewrite decision: ''for proper OS level interactions I am okay with system calls in a well guarded exec here or there, but the logic needs to go into python''. So rewrite-in-Python does NOT mean reimplementing the operating system. A guarded exec is correct for rustup, curl, unzip, git, godot, blender. What must become Python is the LOGIC — which version is wanted, whether it is already present, what the output means, what to do when it fails. THE TEST OF A CORRECT PORT is not whether it calls anything external but whether the DECISIONS can be exercised without performing them. Delivered ahead of the remaining ports, since every one of them needs it: core/process.run() is the single sanctioned exec, with four guards that each exist because a per-domain subprocess call is where they go missing — (1) an argv LIST, never a shell string, with a string rejected outright rather than helpfully split, since the helpful split is the vulnerability; (2) shell=False always; (3) a non-zero exit becomes a ReachError naming the command, carrying its output and PRESERVING its exit code; (4) a missing binary reports WHAT TO INSTALL rather than raising FileNotFoundError at someone who wanted to know the next step. All four verified against real commands. New conformance invariant enforces the single door: nothing outside core/process.py may import subprocess or call os.system/popen/exec*. Proven to fail by importing subprocess into a domain service.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:58.880', '2026-08-31 15:48:10.137', NULL, '400f022495449b2a3a4136f8aa5ceb71', 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 ('06G5GWCTS89VSST07DFVBVG064', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Port the check domain — the four remaining gates', 'Finish what T-1262 started. domains/check/ currently has one verb (client-version); the other four gates join it: check-canvas-version, check-systems-db-stamp, check-fact-ids and check-dataflow-graph.py. Per the domain map, canvas_sources.py and generator_sources.py move alongside as module-level REGISTRIES, not verbs — nothing types them, and putting them in reach --help would answer a question nobody has. schema_version.py goes to the db domain where it is defined; check imports it, which is what a service layer is for. STANDARD PORT ACCEPTANCE, applying to every per-domain ticket under this epic: (a) service.py holds the logic and is transport-agnostic — no printing, no sys.exit, no typer; (b) router.py holds no logic and every command wears @command; (c) a parity test per gate, copying the pattern in tooling/test_check_parity.py, which builds a throwaway fixture repo and runs BOTH implementations — exit codes must match exactly and no fact the old message carried may be lost; (d) the old scripts are RETIRED once parity passes, not left alongside; (e) that domain''s tooling make targets are retired too rather than left as wrappers, per the D-263 make/reach split — build orchestration stays with make. Note check-dataflow-graph.py and check-canvas-version both parse git output, so their fixtures need a git repo rather than a bare directory; that is the one thing here harder than client-version was. + +PROGRESS 2026-08-31 — fact-ids ported, the first bash-to-Python rewrite under the D-263 decision. It was 89 lines of grep/sed pipeline; it is now service.fact_ids() returning a FactIdCheck, with a router that renders it. PARITY IS EXACT ON THE LIVE TREE: both implementations print ''check-fact-ids: OK — 6 references validated against 61 canonical facts'' and exit 0. The matching COUNTS are the real evidence — if the line-matching regex differed from the grep chain even slightly, 6 and 61 would diverge. Kept line-matched rather than YAML-parsed on purpose: parsing properly would change which lines count (anchors, merge keys, multi-document files would start contributing ids the old check never saw), which is a different check wearing the same name. Three parity cases added to test_check_parity.py — ok, unknown fact_id, and the advisory mode where catalogs are unpopulated. Proven to fail: removing the entity-attributes.yaml exclusion was caught, and interestingly by the ADVISORY case rather than the exclusion assertion — including that file made the catalog non-empty, so the new implementation went to enforcing mode while the old stayed advisory. A real behavioural divergence, caught by exit code. RETIREMENT DEFERRED to the end of this ticket, and there is a reason worth recording. The rule is retire per DOMAIN, and three gates remain. It also resolves a tension: the parity test COPIES the old script into its fixture, so deleting the script early would delete the test''s own subject. A parity test is scaffolding with a defined lifetime — when the old script goes, its parity case goes with it and is replaced by a unit test of the new behaviour. Same will apply to every port under T-1250. NOTE the pre-commit hook calls tooling/check-fact-ids directly, so retirement means editing that hook — and a mistake there is felt on every commit rather than every push. Do it together with validate-content (T-1282), which is in the same hook, so it is edited once. + +PROGRESS 2026-08-31, second increment — dataflow-graph and systems-db-stamp ported. Both were already Python, so these are moves rather than rewrites, and both produce BYTE-IDENTICAL output to their originals on the live tree: "check-dataflow-graph: OK — 1 diagram(s)" and "check-systems-db-stamp: OK — 1 generator(s) up to date", same exit codes. + +CORRECTION TO THIS TICKET''S OWN NOTE: it says check-dataflow-graph.py parses git output. It does not — it reads .d2 files and globs the filesystem. Only check-canvas-version parses git, so only that one needs a git repo in its fixture. + +THE E402 DEBT EVAPORATED, exactly as T-1274 predicted. check-systems-db-stamp reached generator_sources through sys.path.insert plus a noqa: E402, because tooling/ was not a package. It now imports as `from tooling import generator_sources` with no hack and no suppression. That is the first concrete evidence for T-1274''s premise; the map''s plan to move generator_sources into the db domain still stands, but it is already importable where it sits. + +SIX FAILURE MODES PRESERVED in the stamp port, modelled as a StampState enum rather than collapsed to pass/fail, because they carry different remedies and one carries a different EXIT CODE: UNSTAMPED exits 2 while every other failure exits 1, and the pre-push hook has relied on that distinction since T-857. The states are ok / absent / unstamped / bad_version / conflict / unknown / broken / stale, each with its own remedy string. + +ONE DELIBERATE BEHAVIOURAL DIFFERENCE, flagged rather than hidden: the old stamp script was SILENT on success unless given --verbose; the new one always prints its verdict. That is not a lost fact, so it does not violate the parity definition, and it makes the gate consistent with client-version and dataflow-graph, which both always print. The old script was the odd one out. Its per-command --verbose also disappears in favour of the global one, which is the consolidation the whole initiative is for. + +STILL OPEN on this ticket, and honest about it: +- check-canvas-version is not ported. It is the one that genuinely parses git, so its fixture needs a real git repo — the hardest of the four. +- NO PARITY TESTS yet for dataflow-graph or systems-db-stamp. Both were verified by running the two implementations side by side on the live tree, which proves the happy path only. The stamp gate''s six failure modes are entirely unexercised, and its fixture is awkward because the old script imports generator_sources from its own directory, so a fixture must copy both files and build a sqlite DB with a meta table. +- Retirement (scripts, parity cases, make targets, the pre-commit hook entry for fact-ids) waits for the whole domain, per the per-domain rule.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 15:30:35.338', '2026-08-31 15:55:15.374', NULL, '86fc272377f0603e44b7b5af742a5f90', 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/tooling/domains/check/router.py b/tooling/domains/check/router.py index 364593521..1475c8613 100644 --- a/tooling/domains/check/router.py +++ b/tooling/domains/check/router.py @@ -21,6 +21,7 @@ from tooling.core import cli, console from tooling.core.command import command from tooling.core.errors import ReachError from tooling.domains.check import service +from tooling.domains.check.schemas import StampState app = cli.domain("check", "Consistency gates — the checks the push hook runs.") @@ -66,6 +67,75 @@ def client_version() -> None: console.verdict(f"check-client-version: OK — {result.yaml_version}") +_STAMP_REMEDY = { + StampState.UNSTAMPED: "make regen-db — systems.db carries no stamp to verify", + StampState.BAD_VERSION: "make regen-db — the recorded schema_version predates semver", + StampState.CONFLICT: "make regen-db — the DB was partially regenerated, so nothing " + "in it is internally consistent", + StampState.UNKNOWN: "register the generator in tooling/generator_sources.py, then " + "make regen-db", + StampState.BROKEN: "a registered generator source has moved or been deleted — fix " + "the path in tooling/generator_sources.py", + StampState.STALE: "make regen-db, then stage server/data/systems.db", +} + + +@app.command("systems-db-stamp") +@command +def systems_db_stamp() -> None: + """Fail if systems.db is older than the generator sources that produced it.""" + result = service.systems_db_stamp() + + if result.state is StampState.ABSENT: + console.verdict("check-systems-db-stamp: no systems.db — nothing to verify") + return + + if result.state is StampState.OK: + console.verdict( + f"check-systems-db-stamp: OK — {result.generators} generator(s) up to date" + ) + return + + detail = "".join(f"\n {line}" for line in result.details) + raise ReachError( + f"check-systems-db-stamp: {result.state.value.upper()}{detail}", + fix=_STAMP_REMEDY[result.state], + # UNSTAMPED is 2, everything else 1 — a distinction the pre-push hook + # has relied on since T-857 and which parity must preserve. + exit_code=result.exit_code, + ) + + +@app.command("dataflow-graph") +@command +def dataflow_graph() -> None: + """Fail if a path named in a hand-authored diagram no longer resolves.""" + result = service.dataflow_graph() + + if result.missing or result.unresolved: + detail = "\n".join(f" {line}" for line in result.missing + result.unresolved) + raise ReachError( + f"check-dataflow-graph: FAILED\n{detail}", + fix=( + "either the path moved, in which case update the diagram, or the " + "diagram was always wrong — check the source before editing either" + ), + ) + + if result.checked == 0: + # A checker that found nothing to check reports success, which is the + # quietest way for this gate to stop working: the label format changes + # and every run stays green while asserting nothing. + raise ReachError( + "check-dataflow-graph: no path-like tokens found — the checker is " + "not actually checking anything", + fix="the d2 label format likely changed; update the token pattern in " + "domains/check/service.py", + ) + + console.verdict(f"check-dataflow-graph: OK — {result.diagrams} diagram(s)") + + @app.command("fact-ids") @command def fact_ids() -> None: diff --git a/tooling/domains/check/schemas.py b/tooling/domains/check/schemas.py index 4abd3ee6d..72321e3d0 100644 --- a/tooling/domains/check/schemas.py +++ b/tooling/domains/check/schemas.py @@ -12,6 +12,8 @@ reported. from __future__ import annotations +from enum import Enum + from pydantic import BaseModel, ConfigDict @@ -45,6 +47,65 @@ class FactIdCheck(BaseModel): return not self.unknown +class DiagramCheck(BaseModel): + """The outcome of checking that paths named in diagrams still resolve. + + `checked` matters as much as `unresolved`. A diagram whose label format + changed yields zero tokens, and a checker that finds nothing to check + reports success — so a count of zero is itself a failure, not a clean run. + """ + + model_config = ConfigDict(frozen=True) + + diagrams: int + checked: int + unresolved: list[str] = [] + missing: list[str] = [] + + @property + def ok(self) -> bool: + return not self.unresolved and not self.missing and self.checked > 0 + + +class StampState(str, Enum): + """Why the systems.db stamp check reached its verdict. + + Distinguished rather than collapsed into pass/fail because they carry + different remedies and, in one case, a different exit code: UNSTAMPED exits + 2 while every other failure exits 1, and the pre-push hook has treated that + 2 as meaningful since T-857. + """ + + OK = "ok" + ABSENT = "absent" # no DB at all — not a failure, nothing to verify + UNSTAMPED = "unstamped" # no meta table or empty: exit 2 + BAD_VERSION = "bad_version" # schema_version is not semver + CONFLICT = "conflict" # generators disagree on schema_version + UNKNOWN = "unknown" # a generator not in the registry + BROKEN = "broken" # a registered source file is gone + STALE = "stale" # sources changed since the stamp was written + + +class StampCheck(BaseModel): + """The outcome of comparing systems.db's meta stamp against its sources.""" + + model_config = ConfigDict(frozen=True) + + state: StampState + generators: int = 0 + details: list[str] = [] + + @property + def ok(self) -> bool: + return self.state in (StampState.OK, StampState.ABSENT) + + @property + def exit_code(self) -> int: + if self.ok: + return 0 + return 2 if self.state is StampState.UNSTAMPED else 1 + + class VersionCheck(BaseModel): """The outcome of comparing project.yaml against client/project.godot. diff --git a/tooling/domains/check/service.py b/tooling/domains/check/service.py index 8530435e4..f4766f7cf 100644 --- a/tooling/domains/check/service.py +++ b/tooling/domains/check/service.py @@ -9,10 +9,18 @@ a rewrite. from __future__ import annotations import re +import sqlite3 from pathlib import Path from tooling.core import config -from tooling.domains.check.schemas import FactIdCheck, UnknownFact, VersionCheck +from tooling.domains.check.schemas import ( + DiagramCheck, + FactIdCheck, + StampCheck, + StampState, + UnknownFact, + VersionCheck, +) # Anchored to line start so the commentary above `version:` (which mentions # earlier versions by number) can never be mistaken for the field itself. @@ -52,6 +60,166 @@ def client_version() -> VersionCheck: ) +# --- systems-db stamp ----------------------------------------------------- +# +# server/data/systems.db is a committed build artefact. Its meta table records +# the SHA-1 of each generator's sources at generation time; if the sources have +# moved on and the DB has not, a push would ship a snapshot nobody can reproduce +# (T-855, T-857). + +_SEMVER = re.compile(r"^\d+\.\d+\.\d+$") + + +def systems_db_stamp() -> StampCheck: + """Compare systems.db's recorded generator SHAs against the sources on disk.""" + # Imported directly rather than through a sys.path insert — the old script + # needed the hack because tooling/ was not a package. That is the E402 debt + # T-1274 expects to evaporate, doing so. + from tooling import generator_sources + + db_path = config.path("server", "data", "systems.db") + if not db_path.exists(): + return StampCheck(state=StampState.ABSENT) + + try: + connection = sqlite3.connect(str(db_path)) + rows = connection.execute( + "SELECT generator_name, schema_version, generator_sha FROM meta" + ).fetchall() + connection.close() + except sqlite3.OperationalError: + return StampCheck(state=StampState.UNSTAMPED) + + if not rows: + return StampCheck(state=StampState.UNSTAMPED) + + bad_version: list[str] = [] + unknown: list[str] = [] + stale: list[str] = [] + versions: dict[str, str] = {} + + for name, schema_version, stored_sha in rows: + versions[name] = schema_version + # An old DB may still carry a 40-char SHA here rather than a semver. + # Flagged rather than silently accepted, so the answer is "regen" and + # not a pass that hides a schema from a different era (T-888). + if not _SEMVER.match(schema_version or ""): + bad_version.append( + f"{name}: schema_version={schema_version!r} " + "(expected semver like '1.0.0' — run make regen-db)" + ) + + sources = generator_sources.GENERATOR_SOURCES.get(name) + if sources is None: + # Fail CLOSED. A branch that adds a generator without registering it + # would otherwise pass this gate while checking nothing about it. + unknown.append(name) + continue + try: + current = generator_sources.file_sha1(*sources) + except FileNotFoundError as exc: + return StampCheck(state=StampState.BROKEN, details=[f"{name}: {exc}"]) + if current != stored_sha: + stale.append(name) + + if bad_version: + return StampCheck(state=StampState.BAD_VERSION, details=bad_version) + + # Generators disagreeing on schema_version means the DB was partially + # regenerated against different source trees — worse than stale, because + # nothing about it is internally consistent. + if len(set(versions.values())) > 1: + return StampCheck( + state=StampState.CONFLICT, + details=[f"{gen}: {ver}" for gen, ver in sorted(versions.items())], + ) + + if unknown: + return StampCheck(state=StampState.UNKNOWN, details=sorted(unknown)) + if stale: + return StampCheck(state=StampState.STALE, details=sorted(stale)) + + return StampCheck(state=StampState.OK, generators=len(rows)) + + +# --- dataflow-graph ------------------------------------------------------- +# +# A diagram that names files goes stale SILENTLY: nothing fails when a path +# moves, so the map keeps asserting a layout that is no longer true. This closes +# the cheap half of that gap (D-262). It CANNOT check whether an edge still +# means what it says — if wiki_sync.py stopped writing body pages tomorrow every +# path here would still exist. Edge semantics are a human job. + +# Hand-authored diagrams whose labels name real repo paths. A diagram absent +# from this list is not checked; add one when it starts naming files. +CHECKED_DIAGRAMS = ["data-flow/wiki-generator-flow.d2"] + +# Generated sources are skipped even if listed: their correctness is the +# generator's problem (the source-canonical rule), and they name star-system ids +# rather than repo paths. +GENERATED_PREFIXES = ("design/star-map-",) + +_LABEL = re.compile(r'"((?:[^"\\]|\\.)*)"') +_PATH_TOKEN = re.compile(r"[A-Za-z0-9_.\-*{}/]*/[A-Za-z0-9_.\-*{}/]+") + + +def dataflow_graph() -> DiagramCheck: + """Assert every repo path named in a hand-authored diagram still resolves.""" + root = config.repo_root() + top_level = {entry.name for entry in root.iterdir()} + diagram_root = root / "docs" / "diagrams" + + unresolved: list[str] = [] + missing: list[str] = [] + checked = 0 + considered = [d for d in CHECKED_DIAGRAMS if not d.startswith(GENERATED_PREFIXES)] + + for diagram in considered: + path = diagram_root / diagram + if not path.exists(): + missing.append(f"{diagram}: diagram not found") + continue + for label in _LABEL.findall(path.read_text(encoding="utf-8")): + for token in _path_tokens(label, top_level): + checked += 1 + if not _resolves(root, token): + unresolved.append(f"{diagram}: path does not resolve: {token}") + + return DiagramCheck( + diagrams=len(considered), + checked=checked, + unresolved=unresolved, + missing=missing, + ) + + +def _path_tokens(label: str, top_level: set[str]) -> list[str]: + # Labels use \n for line breaks and · to separate sibling files. + flat = label.replace("\\n", " ").replace("·", " ") + tokens = [] + for raw in _PATH_TOKEN.findall(flat): + token = raw.strip(".,;:") + # A token only counts if its first segment is a real top-level entry. + # Without this, legend prose like "dashed one-time or bootstrap" yields + # the token one-time/bootstrap and fails the check on nothing. + if token and token.split("/", 1)[0] in top_level: + tokens.append(token) + return tokens + + +def _resolves(root: Path, token: str) -> bool: + if (root / token).exists(): + return True + # bodies/{id}/index.md -> bodies/*/index.md, then glob it. + pattern = re.sub(r"\{[^}]*\}", "*", token) + if "*" in pattern: + try: + return any(root.glob(pattern)) + except (ValueError, OSError): + return False + return False + + # Ported from the bash `check-fact-ids` (D-263: the shell scripts are rewritten, # not wrapped). Deliberately still LINE-MATCHED rather than YAML-parsed: the # original was grep-based, and parsing YAML properly would change which lines