Files
settled-reach/tooling/test_canvas_version_check.py
T
jpmschweitzerandClaude Opus 5 48fee8a0b6 feat(config): make the canvas-generation/version pairing a gate, not a habit (T-1242)
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 over
4e503c356 -- 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>
2026-08-15 00:06:32 +02:00

172 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""Units for the canvas-generation/version pairing gate (T-1242).
The gate's whole value is that it fires on the cases that shipped broken and
stays quiet otherwise. Two properties carry that, and neither is observable from
"the check passed on this branch":
1. Editing project.yaml WITHOUT moving `version:` is not a bump. The file
carries a comment block that quotes old version numbers (0.4.2 … 0.4.6), so
a naive "did project.yaml change" or "does the diff mention a version"
test would count commentary edits as a bump and wave through exactly the
regressions this exists to catch.
2. The registry is non-empty and covers the files the five known regressions
actually touched. An empty or narrowed registry passes every push silently.
Run: python3 tooling/test_canvas_version_check.py
"""
import importlib.util
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
TOOLING = REPO_ROOT / "tooling"
sys.path.insert(0, str(TOOLING))
from canvas_sources import relative_paths # noqa: E402
def _load_check_module():
"""Import the extensionless check script as a module."""
path = TOOLING / "check-canvas-version"
spec = importlib.util.spec_from_loader(
"check_canvas_version",
importlib.machinery.SourceFileLoader("check_canvas_version", str(path)),
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
check = _load_check_module()
FAILURES: list[str] = []
def expect(condition: bool, label: str) -> None:
if condition:
print(f" ok {label}")
else:
print(f" FAIL {label}")
FAILURES.append(label)
def test_real_bump_counts() -> None:
diff = (
"--- a/project.yaml\n"
"+++ b/project.yaml\n"
"@@ -22 +22 @@\n"
"-version: 0.4.5\n"
"+version: 0.4.6\n"
)
expect(check.diff_has_version_bump(diff), "a real -version/+version pair is a bump")
def test_comment_edit_is_not_a_bump() -> None:
# The exact shape that must NOT count: adding a line to the comment block
# that documents what earlier versions carried. It mentions version numbers
# and sits directly above the field.
diff = (
"--- a/project.yaml\n"
"+++ b/project.yaml\n"
"@@ -20,0 +21,2 @@\n"
"+# 0.4.5-tagged canvases carry pre-T-1237 river courses: one course per\n"
"+# D8 hop rather than one per river. 0.4.6 forces them to miss.\n"
)
expect(
not check.diff_has_version_bump(diff),
"a comment-block edit quoting version numbers is NOT a bump",
)
def test_other_field_edit_is_not_a_bump() -> None:
diff = (
"--- a/project.yaml\n"
"+++ b/project.yaml\n"
"@@ -23 +23 @@\n"
"-repository: settled-reach\n"
"+repository: settled-reach-renamed\n"
)
expect(
not check.diff_has_version_bump(diff),
"editing another field is NOT a bump",
)
def test_diff_header_is_not_a_bump() -> None:
# `+++ b/project.yaml` starts with '+' and must not be mistaken for content.
diff = "--- a/project.yaml\n+++ b/project.yaml\n@@ -1 +1 @@\n-name: x\n+name: y\n"
expect(
not check.diff_has_version_bump(diff),
"the +++ diff header is NOT a bump",
)
def test_deletion_alone_is_not_a_bump() -> None:
diff = "--- a/project.yaml\n+++ b/project.yaml\n@@ -22 +21,0 @@\n-version: 0.4.6\n"
expect(
not check.diff_has_version_bump(diff),
"removing the version field is NOT a bump",
)
def test_empty_diff_is_not_a_bump() -> None:
expect(not check.diff_has_version_bump(""), "an empty diff is NOT a bump")
def test_registry_is_populated() -> None:
paths = relative_paths()
expect(len(paths) > 10, f"registry is populated ({len(paths)} paths)")
def test_registry_covers_the_known_regressions() -> None:
"""The files the five documented stale-cache regressions actually touched.
If a future edit narrows the registry past any of these, the gate stops
catching the exact class of bug it was built for — silently. This is the
test that makes such a narrowing loud.
"""
paths = set(relative_paths())
required = [
# T-1237 / T-1239: one course per river (0.4.6)
"server/src/atlas/step_canvas.rs",
"server/src/atlas/river_course.rs",
"client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd",
# D-255 extent inversion (0.4.4) + Global sentinel (0.4.5)
"client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd",
# coast_warp_px at orbital sampling (0.4.3)
"server/src/atlas/coast_invention.rs",
# lake_margin_q semantics (0.4.2)
"server/src/atlas/hydrology_equilibrium.rs",
# the layer every rung samples
"server/src/atlas/layer1.rs",
"server/src/atlas/district_profile.rs",
# the cache that the version tag actually governs
"client/ui/implant/apps/atlas/step_canvas/step_canvas_disk_cache.gd",
]
for path in required:
expect(path in paths, f"registry covers {path}")
def main() -> int:
print("test_canvas_version_check:")
test_real_bump_counts()
test_comment_edit_is_not_a_bump()
test_other_field_edit_is_not_a_bump()
test_diff_header_is_not_a_bump()
test_deletion_alone_is_not_a_bump()
test_empty_diff_is_not_a_bump()
test_registry_is_populated()
test_registry_covers_the_known_regressions()
if FAILURES:
print(f"\nFAILED ({len(FAILURES)}): " + "; ".join(FAILURES), file=sys.stderr)
return 1
print("test_canvas_version_check: PASS")
return 0
if __name__ == "__main__":
sys.exit(main())