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>
172 lines
5.6 KiB
Python
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())
|