#!/usr/bin/env python3 """ canvas_sources — single source of truth for the Atlas canvas-generation path set. `project.yaml`'s `version:` is the Atlas disk cache's ONLY invalidation signal (client/scripts/build_version.gd, D-255). A change to how a canvas is GENERATED is therefore only half a change; the other half is bumping that version, or every warm cache keeps serving canvases built by code that no longer exists. Nothing enforced that pairing, and it broke five times: project.yaml's own comments record 0.4.2 (lake_margin_q semantics), 0.4.3 (coast_warp_px at orbital sampling), 0.4.4 (the D-255 extent inversion), 0.4.5 (the Global sentinel), and 0.4.6 (T-1237 one-course-per-river) — every one of them bumped *after the fact*, the last only after T-1239 spent eight days diagnosing a map drawn from a canvas whose generating code had been replaced. The failure is invisible to its author: it reproduces only where a warm cache exists, so a cold checkout looks fine. This module is the registry `tooling/check-canvas-version` intersects against, kept here rather than inline in the hook for the same reason `tooling/generator_sources.py` exists (T-1067): one list, one place, imported by everything that needs it. WHY THIS DELIBERATELY OVER-INCLUDES ----------------------------------- The set is collected by GLOB, not hand-listed, and covers the whole atlas module rather than a traced dependency closure. `step_canvas.rs` directly imports ten sibling modules and those pull in more (district_profile -> domain_warp/detail_scatter/coast_invention/..., layer1 -> drainage/hydrology_equilibrium/features). A hand-maintained closure of that would be wrong within a month, and being wrong here is silent — exactly the failure this registry exists to stop. Globbing is self-maintaining: a module added in a future split is covered the moment it exists. The cost asymmetry is the whole argument, and it is the ticket's own ruling (T-1242): a false positive costs one version bump and one round of cache misses; a false negative costs another week of a wrong map. So when the choice is "include a file that might not change canvas bytes" versus "risk missing one that does", this includes it. Fail closed on an empty glob, per generator_sources.py's precedent: an empty set would silently pass every push. Usage: python3 tooling/canvas_sources.py --list """ import argparse import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent # --------------------------------------------------------------------------- # Server: the code that PRODUCES canvas bytes # --------------------------------------------------------------------------- # The whole atlas module. Canvas bytes are produced by step_canvas.rs out of # layer1/TerrainAnalysis/district_profile/river_course/hydrology and a long tail # of invention modules; see this file's header for why the boundary is the # module rather than a traced import closure. ATLAS_DIR: Path = REPO_ROOT / "server" / "src" / "atlas" # The seed chain feeds every deterministic decision the cascade makes # (step_canvas.rs: `use crate::seed::SeedChain`), so a change to seed derivation # changes canvas bytes without touching atlas/ at all. SEED_RS: Path = REPO_ROOT / "server" / "src" / "seed.rs" # --------------------------------------------------------------------------- # Client: the code that KEYS, STORES and INTERPRETS those bytes # --------------------------------------------------------------------------- # The step_canvas cluster: the disk cache and its index format, the in-memory # cache, the request/transport layer that derives extents and spacing (and # therefore cache KEYS), and the layers that decode the payload. # # The render-only members (terrain/annotation layers) are included on purpose. # A pure draw change cannot make cached bytes wrong, so including them can force # an unnecessary re-derive — but drawing the line *inside* this directory means # hand-judging which file is "really" wire-shaped, and T-1237 changed the server # course shape and the client annotation layer in the same commit. That judgement # is precisely where a false negative would come from. CLIENT_STEP_CANVAS_DIR: Path = ( REPO_ROOT / "client" / "ui" / "implant" / "apps" / "atlas" / "step_canvas" ) # The accessor the cache reads its invalidation tag through. BUILD_VERSION_GD: Path = REPO_ROOT / "client" / "scripts" / "build_version.gd" # The wire codec, which decides which planes EXIST client-side. # # Added 2026-08-16 after T-1213 proved the omission expensive: this file's decode # dictionary was missing `relief_q`, so the plane never reached the renderer and # the deep rungs rendered flat for weeks. The gate would not have flagged the fix, # because the registry only covered ui/.../step_canvas/ and this lives under # scripts/protocol/. # # It belongs here for a second, sharper reason: the disk cache stores the DECODED # canvas, so a decode change alters what a cached entry contains. Entries written # before that fix have no relief_q key at all and keep rendering flat until the # version moves — exactly the stale-cache class this registry exists to catch. STEP_CANVAS_PROTOCOL_GD: Path = ( REPO_ROOT / "client" / "scripts" / "protocol" / "step_canvas_protocol.gd" ) def _rust_sources(directory: Path, label: str) -> tuple[Path, ...]: """Every .rs file in `directory`, collected by glob and fail-closed.""" sources = tuple(sorted(directory.rglob("*.rs"))) if not sources: raise RuntimeError( f"{label} sources not found at {directory} — the canvas-generation " "path set would be incomplete, and this check would pass every push" ) return sources def _gdscript_sources(directory: Path, label: str) -> tuple[Path, ...]: """Every .gd file in `directory`, collected by glob and fail-closed. `.uid` sidecars are Godot bookkeeping and carry no behaviour, so they are excluded — a uid churn should not demand a version bump. """ sources = tuple(sorted(directory.rglob("*.gd"))) if not sources: raise RuntimeError( f"{label} sources not found at {directory} — the canvas-generation " "path set would be incomplete, and this check would pass every push" ) return sources def canvas_sources() -> tuple[Path, ...]: """Every file whose change may alter canvas bytes or their interpretation. This registry itself is a member: loosening the set must be as visible as any other canvas-generation change (generator_sources.py makes the same call for the same reason). """ return ( Path(__file__).resolve(), *_rust_sources(ATLAS_DIR, "server atlas"), SEED_RS, *_gdscript_sources(CLIENT_STEP_CANVAS_DIR, "client step_canvas"), BUILD_VERSION_GD, STEP_CANVAS_PROTOCOL_GD, ) def relative_paths() -> tuple[str, ...]: """The registry as repo-relative POSIX paths, for matching git output.""" return tuple(p.relative_to(REPO_ROOT).as_posix() for p in canvas_sources()) def main() -> None: parser = argparse.ArgumentParser( description="Single source of truth for the Atlas canvas-generation path set" ) parser.add_argument( "--list", action="store_true", help="Print the canvas-generation paths, one repo-relative path per line", ) args = parser.parse_args() if not args.list: parser.print_help() sys.exit(2) for path in relative_paths(): print(path) if __name__ == "__main__": main()