project.yaml's version is the Atlas disk cache's only invalidation signal, and nothing enforced that changing canvas GENERATION also moved it. It broke five times -- 0.4.2 lake_margin_q, 0.4.3 coast_warp_px, 0.4.4 the extent inversion, 0.4.5 the Global sentinel, 0.4.6 one-course-per-river -- each bumped only after someone noticed a wrong map. The failure is invisible to its author: it needs a warm cache to reproduce, so a cold checkout looks fine. T-1239 is the last one, and it took eight days. tooling/canvas_sources.py is the path registry; tooling/check-canvas-version rejects a push that touches those paths without moving project.yaml's version line. Wired into the pre-push hook, `make check-canvas-version`, and, for the parsing units, `make test-tooling`. Verified against real history rather than a synthetic branch: run over4e503c356-- the commit that actually caused T-1239 -- the gate rejects and names the three files. Run over the commits that DID bump (bdea71953,39f0fd8c5, and T-1239's own fix), it passes. The registry is globbed, not hand-listed. step_canvas.rs imports ten sibling modules and those import more, so a traced closure would be stale within a month, and stale here is silent. It over-includes on purpose: a false positive costs one bump and one round of cache misses, a false negative costs another week of a wrong map -- the ticket's own ruling. Two deliberate calls worth naming. The registry includes ITSELF, which closes the narrowing hole: remove a path and change that same path in one push, and the gate still fires because the registry file is in the set. And there is no override flag -- it would be reached for exactly when someone is certain their change is harmless, which is the reasoning behind all five regressions. Version bumped 0.4.6 -> 0.4.7 with NO canvas-generation change: self-inclusion means adding the registry trips its own rule. Spent rather than special-cased, because the first exception is how a rule like this dies. The units cover the property no branch run can show -- that editing project.yaml's comment block, which quotes old version NUMBERS directly above the field, is not a bump -- plus a registry-coverage test naming the files each of the five known regressions touched, so a future narrowing past them fails loudly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
159 lines
6.6 KiB
Python
159 lines
6.6 KiB
Python
#!/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"
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
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()
|