diff --git a/Makefile b/Makefile index 800a0b5bf..a1ca7f599 100644 --- a/Makefile +++ b/Makefile @@ -304,7 +304,7 @@ test-tooling: { echo " FAIL: character decisions — log follows:"; cat .cache/test-tooling-character.log; exit 1; } @echo " [test-tooling] canvas-generation version gate units (T-1242)..." @mkdir -p .cache - @python3 tooling/test_canvas_version_check.py 2> .cache/test-tooling-canvas-version.log || \ + @$(VENV_PY) tooling/test_canvas_version_check.py 2> .cache/test-tooling-canvas-version.log || \ { echo " FAIL: canvas version gate units — log follows:"; cat .cache/test-tooling-canvas-version.log; exit 1; } @echo " [test-tooling] reach lazy domain registration (T-1260)..." @mkdir -p .cache diff --git a/tooling/test_conformance.py b/tooling/test_conformance.py index fda1e5c30..23df074ef 100644 --- a/tooling/test_conformance.py +++ b/tooling/test_conformance.py @@ -23,6 +23,12 @@ Invariants: 5. Every ReachError names a remedy — a `fix=` on every raise site. The hardest to enforce and the most valuable: an error that only says "no" is the thing D-263 exists to replace. + 6. Discovery succeeds and being wrong fails (exit codes). + 7. The Blender carve-out stays outside the package, and stays populated. + 8. reach is invoked by its bare name — never `python -m tooling` or a venv + path — in the Makefile, hooks, .claude and docs (T-1257, from T-1261). + 9. The installed reach and the .venv the gate tests run in are the same + Python minor version, and it is the one the Makefile pins (T-1257). The import-graph invariant (nothing heavy reachable from main.py) lives in test_lazy_domains.py rather than being duplicated here. @@ -359,6 +365,82 @@ def check_carve_out_stays_carved(failures: list[str]) -> None: ) +def check_invoked_through_path(failures: list[str]) -> None: + """(8) reach is invoked by NAME, never through an interpreter or a venv path. + + The PATH guarantee (T-1261) is that `reach` means the same program in a + shell, a hook and an agent. One `python -m tooling …` or `.venv/bin/reach` + in a make target or a hook silently forks that — it runs whichever + interpreter happens to be first, with whichever dependencies it has — and + nothing fails until a version-specific bug shows in one path and not the + other. T-1261 verified this by hand; this makes it permanent (T-1257). + """ + patterns = ("python -m tooling", "python3 -m tooling", ".venv/bin/reach") + roots = [REPO_ROOT / "Makefile", REPO_ROOT / ".config" / "hooks", REPO_ROOT / ".claude", REPO_ROOT / "docs"] + for root in roots: + files = [root] if root.is_file() else [p for p in root.rglob("*") if p.is_file()] + for path in files: + try: + text = path.read_text(encoding="utf-8") + except (UnicodeDecodeError, OSError): + continue + for lineno, line in enumerate(text.splitlines(), 1): + # A line that FORBIDS the pattern is the rule, not a breach of + # it (docs/DEVOPS.md: "Never `python -m tooling`"). A check that + # flags its own documentation gets muted. + if "never" in line.lower(): + continue + for pattern in patterns: + if pattern in line: + failures.append( + f"[path] {path.relative_to(REPO_ROOT)}:{lineno} invokes reach through " + f"'{pattern}' — call the bare name `reach` (T-1261's PATH guarantee)" + ) + + +def check_one_python(failures: list[str]) -> None: + """(9) The installed reach and the .venv run the same Python minor version. + + T-1261 found uv had installed the reach tool on CPython 3.11 while .venv + and system python were 3.14 — uv picks the LOWEST interpreter satisfying + requires-python. The Makefile now pins PYTHON_VERSION for both, but nothing + noticed a divergence: the gate tests run under .venv, the hooks run reach, + and a bug in one interpreter would pass the tests that ran on the other. + """ + import re + import shutil + + reach = shutil.which("reach") + if reach is None: + return # check_exit_codes already reports a missing reach + shebang = Path(reach).read_text(encoding="utf-8", errors="replace").splitlines()[0] + if not shebang.startswith("#!"): + failures.append(f"[python] {reach} has no shebang — cannot tell which Python runs reach") + return + interpreter = shebang[2:].strip().split()[0] + probe = subprocess.run( + [interpreter, "-c", "import sys; print('%d.%d' % sys.version_info[:2])"], + capture_output=True, text=True, + ) + tool_version = probe.stdout.strip() + venv_version = "%d.%d" % sys.version_info[:2] + makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") + pinned = re.search(r"^PYTHON_VERSION \?= (\S+)", makefile, re.M) + pinned_version = pinned.group(1) if pinned else None + + if tool_version != venv_version: + failures.append( + f"[python] reach runs on Python {tool_version} ({interpreter}) but the gate tests run on " + f"{venv_version} — tests prove nothing about the interpreter the hooks use. " + "Fix: make install-reach && make setup-venv (both pin PYTHON_VERSION)" + ) + if pinned_version and tool_version != pinned_version: + failures.append( + f"[python] reach runs on Python {tool_version}, the Makefile pins {pinned_version}. " + "Fix: make install-reach" + ) + + def main() -> int: if shutil.which("reach") is None: print("test_conformance: `reach` is not on PATH.\n Fix: make install-reach", file=sys.stderr) @@ -373,6 +455,8 @@ def main() -> int: check_errors_name_a_remedy(failures) check_exit_codes(failures) check_carve_out_stays_carved(failures) + check_invoked_through_path(failures) + check_one_python(failures) if failures: print("test_conformance: FAIL", file=sys.stderr) @@ -381,7 +465,8 @@ def main() -> int: return 1 print("test_conformance: OK — transport isolated, one output path, " - "every command decorated and helped, every error names a remedy") + "every command decorated and helped, every error names a remedy, " + "reach called by name, one Python") return 0