From 313c304dfd198992fa3284b51b37274915c39965 Mon Sep 17 00:00:00 2001 From: Zach Lowden Date: Mon, 3 Aug 2026 00:36:28 -0500 Subject: [PATCH 1/2] fix(gate): run-tests.sh could not check its own most important precondition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two measured holes, plus an honest correction to a third. 1. REQUIRED_TOOLS could not express the precondition that matters most. It is a list of BINARIES checked with `command -v`. pytest is not a binary this runner calls — it is a MODULE (`python -m pytest`) — so the one guard whose entire job is "the thing that runs the tests is present" was structurally unable to check pytest. It also asserted `python3` while the runner actually invokes `python`. MEASURED on the dev host with every REQUIRED_TOOLS binary present but no pytest importable: all 17 targets printed run-tests: ERROR — could not parse pytest's summary for . and the run ended `TOTAL collected=0 … RESULT: FAIL`, exit 1. CORRECTION, stated because it changes the severity: the gate did NOT go green, and the briefed "reports per-target PASS with collected=0" does not exist on this revision — GUARD 4 (unparseable summary) and GUARD 3 (the collected floor) both fire. The defect is DIAGNOSTIC: seventeen copies of a message blaming pytest's OUTPUT FORMAT for a missing dependency, pointing at the wrong subsystem. That is the #276 shape — a real finding that reads like an environment fault. Now one named FATAL, exit 2, before any suite runs. 2. `declare -a RESULTS` / `declare -a SKIP_LINES` leave the arrays DECLARED BUT UNSET. Under `set -u` the first `${#arr[@]}` on a still-empty array aborts the command with "unbound variable" (measured, bash 5.3.15). With zero skips this printed a raw scripts/run-tests.sh: line 479: SKIP_LINES: unbound variable where GUARD 2's skip list belonged, and the unpinned-skip loop below it never executed. No `set -e`, so the script continued and the skip-TOTAL accounting still fired — the damage was confined to the DIAGNOSTIC path, at exactly the moment someone is reading why the gate is red. Fixed with `NAME=()`. 3. "printed RESULT: FAIL and exited 0" did NOT reproduce. Measured: exit 1. The structure forbids it — `RESULT: FAIL` is printed only when `fail != 0` and the next statement is `exit "$fail"`, whose only non-zero value is 1. The likely origin is reading the status through a pipeline (`| tail`), which yields the last command's status rather than the runner's. Pinned end-to-end anyway by `test_a_failing_run_never_exits_zero`, which forces a red run and asserts the two can never disagree — and pytest.fail()s if it could not force one, so it cannot pass vacuously. Known-bad states, each proven LOUD by before/after rather than by reading code: no pytest module before: 17 misleading errors, exit 1 after: one named FATAL, exit 2, no suite started empty target dir fails on the per-directory `collected 0 tests` floor typo'd target GUARD 5 aborts naming the entry and saying "does not exist" The last two were already guarded before this PR; nothing had ever proven those paths could fire, so they are labelled REACHABILITY proofs, not regression coverage. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/run-tests.sh | 56 +++- scripts/tests/test_run_tests_preconditions.py | 297 ++++++++++++++++++ 2 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 scripts/tests/test_run_tests_preconditions.py diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 000bece..b2a49ad 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -152,7 +152,11 @@ MIN_TESTS="${MIN_TESTS:-5600}" # against a temp directory (rotation, truncation, generation cap, and the .bak # scope fence). Those tests FAIL rather than skip when it is absent — a skipped # rotation test reports safety it never measured — so it belongs here. -REQUIRED_TOOLS=(bash curl node rg git awk jq grep setsid python3 nix-instantiate opencode logrotate) +# +# 🔴 `python` is listed as well as `python3` because THIS SCRIPT invokes +# `python -m pytest`, not `python3`. Asserting only `python3` checked a binary +# the runner never calls. +REQUIRED_TOOLS=(bash curl node rg git awk jq grep setsid python python3 nix-instantiate opencode logrotate) missing_tools=() for t in "${REQUIRED_TOOLS[@]}"; do command -v "$t" >/dev/null 2>&1 || missing_tools+=("$t") @@ -172,6 +176,38 @@ if [ "${#missing_tools[@]}" -gt 0 ]; then exit 2 fi +# --- GUARD 1b: pytest ITSELF must be importable -------------------------------- +# 🔴 REQUIRED_TOOLS is a list of BINARIES checked with `command -v`. pytest is +# not a binary this runner calls — it is a MODULE (`python -m pytest`) — so it +# was structurally outside the one guard whose entire job is "the thing that +# runs the tests is present". The precondition that mattered most was the one +# the precondition mechanism could not express. +# +# MEASURED 2026-08-03 on this dev host, with every REQUIRED_TOOLS binary present +# but no pytest importable from `python`: all 17 targets reported +# run-tests: ERROR — could not parse pytest's summary for . +# and the run ended `TOTAL collected=0 … RESULT: FAIL`, exit 1. +# +# So the gate did NOT go green — GUARD 4 (unparseable summary) and GUARD 3 (the +# collected floor) both fired, and the "reports per-target PASS with collected=0" +# version of this hole does not exist on this revision. What it produced instead +# was SEVENTEEN copies of a message blaming pytest's OUTPUT FORMAT for what was +# actually "pytest is not installed" — a diagnosis pointing at the wrong +# subsystem, the shape that has repeatedly cost this repo whole sessions (#276's +# "missing directory" read as an environment fault). One named error is the fix. +if [ "$CHECK_TARGETS_ONLY" -eq 0 ]; then + if ! python -m pytest --version >/dev/null 2>&1; then + echo "run-tests: FATAL — \`python -m pytest\` is not runnable." >&2 + echo " \`python\` resolves to $(command -v python 2>/dev/null || echo '(not found)') but the" >&2 + echo " pytest MODULE is not importable from it, so every suite would collect 0" >&2 + echo " tests and report an unparseable summary. This is NOT a pytest output-format" >&2 + echo " change — it is a missing dependency in the caller's environment." >&2 + echo " Fix the caller: flake.nix checks.pytests builds a python312.withPackages" >&2 + echo " env that includes pytest; the pre-push hook wraps this in a nix-shell." >&2 + exit 2 + fi +fi + # --- HERMETIC set -------------------------------------------------------------- # Verified to pass in the offline nix sandbox: every third-party call # (psycopg2 / requests / minio HTTP) is mocked, so no live DB or network is @@ -327,8 +363,22 @@ EXPECTED_SKIPS=( # instead. fail=0 -declare -a RESULTS -declare -a SKIP_LINES +# 🔴 `RESULTS=()` not `declare -a RESULTS`. Under `set -u`, `declare -a foo` +# leaves the variable DECLARED BUT UNSET, so the first `${#foo[@]}` or +# `"${foo[@]}"` on a still-empty array aborts with "foo: unbound variable" +# (MEASURED on bash 5.3.15; an explicit `=()` assignment makes it set-and-empty). +# +# That was live: with zero skips, GUARD 2's reporting block died at +# `if [ "${#SKIP_LINES[@]}" -eq 0 ]`, printing a raw +# `run-tests.sh: line 479: SKIP_LINES: unbound variable` instead of the skip +# list, and the unpinned-skip loop below it never executed. Because there is no +# `set -e` the script carried on, so the damage was confined to the DIAGNOSTIC +# path — the skip-total accounting still fired — but the runner emitted a bash +# internal error at exactly the moment someone is trying to read why the gate is +# red. That is the #276 lesson repeating: a failure that reads like an +# environment fault rather than a named finding. +RESULTS=() +SKIP_LINES=() TOT_COLLECTED=0 TOT_PASSED=0 TOT_SKIPPED=0 diff --git a/scripts/tests/test_run_tests_preconditions.py b/scripts/tests/test_run_tests_preconditions.py new file mode 100644 index 0000000..efb77bf --- /dev/null +++ b/scripts/tests/test_run_tests_preconditions.py @@ -0,0 +1,297 @@ +"""Guards for `scripts/run-tests.sh`'s PRECONDITIONS and its empty-array handling. + +WHY THIS EXISTS +--------------- +Two measured holes, both of which let the runner produce output that reads +reassuring (or merely confusing) rather than naming what is wrong. + +1. ``REQUIRED_TOOLS`` could not express its own most important precondition. + It is a list of BINARIES checked with ``command -v``. pytest is not a binary + this runner calls -- it is a MODULE (``python -m pytest``) -- so the one + guard whose whole job is "the thing that runs the tests is present" was + structurally unable to check pytest. It also asserted ``python3`` while the + runner actually invokes ``python``. + + MEASURED 2026-08-03 with every REQUIRED_TOOLS binary present but no pytest + importable: all 17 targets printed ``could not parse pytest's summary`` and + the run ended ``TOTAL collected=0 … RESULT: FAIL``, exit 1. + + Being precise, because it changes how much this matters: the gate did NOT go + green. GUARD 4 and GUARD 3 both fired. The defect is DIAGNOSTIC -- seventeen + copies of a message blaming pytest's OUTPUT FORMAT for what was actually a + missing dependency, which is the #276 shape (a real finding that reads like + an environment fault). + +2. ``declare -a RESULTS`` / ``declare -a SKIP_LINES`` leave the arrays DECLARED + BUT UNSET. Under ``set -u`` the first ``${#arr[@]}`` on a still-empty array + aborts the command with ``unbound variable`` (measured on bash 5.3.15). + With zero skips this printed a raw + ``run-tests.sh: line 479: SKIP_LINES: unbound variable`` where the skip list + belonged, and the unpinned-skip loop below it never ran. No ``set -e``, so + the script continued and the skip-TOTAL accounting still fired -- the damage + was confined to the diagnostic path, at exactly the moment someone is trying + to read why the gate is red. + +WHAT IS REGRESSION COVERAGE HERE AND WHAT IS NOT +------------------------------------------------ +Per claude/RULES.md: + + * ``test_missing_pytest_module_is_named``, ``test_python_is_a_required_tool`` + and ``test_empty_arrays_are_initialised_assigned`` are REGRESSION coverage. + Each is red at origin/main for its own reason (see the PR matrix). + + * ``test_an_empty_target_directory_is_loud`` and + ``test_a_typod_target_entry_is_loud`` are REACHABILITY proofs for guards + that already existed before this PR. They are NOT regression coverage for + this change -- they were requested as known-bad-state validation, and they + pass at origin/main too. Their value is that nothing had ever proven those + two paths could actually fire. +""" +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +RUN_TESTS = REPO_ROOT / "scripts" / "run-tests.sh" + + +def _run(args: list[str], env: dict | None = None, timeout: int = 300): + return subprocess.run( + ["bash", *args], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + timeout=timeout, + env=env, + ) + + +def _runner_with_targets(tmp_path: Path, targets: list[str]) -> Path: + """Copy run-tests.sh with HERMETIC_TARGETS replaced wholesale. + + Replacing the whole block (rather than injecting one entry) keeps these + tests FAST: the copy runs only the target under test instead of the full + 5792-test suite, which is what makes a per-target reachability proof + affordable at all. + """ + src = RUN_TESTS.read_text() + body = "\n".join(f" {t}" for t in targets) + patched, n = re.subn( + r"^HERMETIC_TARGETS=\(.*?^\)", f"HERMETIC_TARGETS=(\n{body}\n)", src, count=1, flags=re.S | re.M + ) + assert n == 1, "failed to replace HERMETIC_TARGETS in the copied runner" + assert "HERMETIC_TARGETS=(\n " in patched, "replacement produced an empty target list" + dst = tmp_path / "run-tests.sh" + dst.write_text(patched) + return dst + + +# -------------------------------------------------------------------------- +# Guard the guard. +# -------------------------------------------------------------------------- + +def test_the_runner_copy_helper_actually_works(tmp_path): + """POSITIVE CONTROL for _runner_with_targets. + + Every mutation test below rests on this rewrite landing. If the regex + stopped matching, the copies would silently be the UNMODIFIED runner and the + 'known-bad state' tests would be exercising the real target list instead -- + passing or failing for reasons that have nothing to do with the case they + claim to cover. So prove the rewrite both applies and takes effect. + """ + runner = _runner_with_targets(tmp_path, ["scripts/tests"]) + text = runner.read_text() + assert "scripts/dl-router/tests" not in text.split("HERMETIC_TARGETS=(")[1].split("\n)")[0] + proc = _run([str(runner), "--check-targets", str(REPO_ROOT)]) + assert proc.returncode == 0, f"{proc.stdout}\n{proc.stderr}" + assert "all 1 hermetic target(s) resolve" in proc.stdout, ( + f"the copied runner did not use the rewritten 1-entry list.\n{proc.stdout}" + ) + + +# -------------------------------------------------------------------------- +# REGRESSION: hole 2 -- the pytest module precondition. +# -------------------------------------------------------------------------- + +def test_python_is_a_required_tool(): + """The runner invokes `python -m pytest`; it must assert `python`. + + origin/main listed `python3` -- a binary this script never calls. + """ + src = RUN_TESTS.read_text() + m = re.search(r"^REQUIRED_TOOLS=\((.*?)\)", src, re.M | re.S) + assert m, "could not find REQUIRED_TOOLS in run-tests.sh" + tools = m.group(1).split() + assert "python" in tools, ( + f"REQUIRED_TOOLS does not include `python`, but the runner calls " + f"`python -m pytest`. Got: {tools}" + ) + + +def test_missing_pytest_module_is_named(tmp_path): + """RED at origin/main. A pytest-less environment must fail with ONE named error. + + Drives the real runner under a PATH whose `python` cannot import pytest, and + asserts the failure names the MODULE -- not pytest's output format. The + negative assertion is the point: at origin/main this run produced 17 copies + of 'could not parse pytest's summary', a diagnosis pointing at the wrong + subsystem entirely. + """ + # A `python` shim that exists and runs but has no pytest. Written without a + # `#!/usr/bin/env` shebang: the nix build sandbox has no /usr/bin/env, and a + # stub that is dead in one tier is how #306's portability defect shipped. + bindir = tmp_path / "bin" + bindir.mkdir() + real_bash = shutil.which("bash") + shim = bindir / "python" + shim.write_text( + f"#!{real_bash}\n" + 'if [ "$1" = "-m" ] && [ "$2" = "pytest" ]; then\n' + ' echo "/usr/bin/python: No module named pytest" >&2; exit 1\n' + "fi\n" + "exit 0\n" + ) + shim.chmod(0o755) + + env = dict(os.environ) + env["PATH"] = f"{bindir}{os.pathsep}{env.get('PATH', '')}" + + proc = _run([str(RUN_TESTS), str(REPO_ROOT)], env=env) + out = proc.stdout + proc.stderr + + assert proc.returncode == 2, ( + f"expected the precondition to abort with exit 2, got {proc.returncode}.\n{out}" + ) + assert "python -m pytest` is not runnable" in out, ( + f"the failure did not name the pytest MODULE precondition.\n{out}" + ) + # THIS guard's own reason, not a neighbour's: the old, misleading message + # must be gone, and no suite may have been attempted. + assert "could not parse pytest's summary" not in out, ( + "the runner still blames pytest's OUTPUT FORMAT for a missing " + f"dependency.\n{out}" + ) + assert "=== pytest " not in proc.stdout, ( + f"the runner started a suite despite the precondition failing.\n{proc.stdout}" + ) + + +# -------------------------------------------------------------------------- +# REGRESSION: hole 3 -- set -u vs. an empty array. +# -------------------------------------------------------------------------- + +def test_empty_arrays_are_initialised_assigned(): + """RED at origin/main. `declare -a foo` is unset under `set -u`. + + Pinned as source structure rather than behaviour because reproducing it + needs a full zero-skip run of the real suite. The mechanism is exact and + measured (bash 5.3.15): `declare -a A; echo ${#A[@]}` under `set -u` aborts + with 'A: unbound variable', while `A=()` does not. + """ + src = RUN_TESTS.read_text() + offenders = re.findall(r"^\s*declare -a (\w+)\s*$", src, re.M) + assert not offenders, ( + f"{offenders} are declared with bare `declare -a`, which leaves them " + "UNSET under `set -u`; the first ${#arr[@]} on a still-empty array " + "aborts with 'unbound variable'. Use `NAME=()` instead." + ) + for name in ("RESULTS", "SKIP_LINES"): + assert re.search(rf"^{name}=\(\)\s*$", src, re.M), ( + f"{name} is not initialised with an explicit `{name}=()`" + ) + + +def test_set_u_empty_array_mechanism_holds(): + """POSITIVE CONTROL for the test above. + + The source-structure pin is only meaningful if the bash behaviour it + describes is real on the bash actually running the gate. Assert BOTH halves + -- `declare -a` fails, `=()` succeeds -- so this cannot pass on a bash where + neither form errors and the pin above would be guarding nothing. + """ + bad = subprocess.run( + ["bash", "-c", 'set -uo pipefail; declare -a A; echo "${#A[@]}"'], + capture_output=True, text=True, + ) + good = subprocess.run( + ["bash", "-c", 'set -uo pipefail; A=(); echo "${#A[@]}"'], + capture_output=True, text=True, + ) + assert bad.returncode != 0 and "unbound variable" in bad.stderr, ( + "`declare -a A` + `${#A[@]}` did NOT error under set -u on this bash; " + f"the pin above may be guarding a non-issue here.\n{bad.stderr}" + ) + assert good.returncode == 0 and good.stdout.strip() == "0", ( + f"`A=()` did not behave as the fix requires.\n{good.stderr}" + ) + + +# -------------------------------------------------------------------------- +# REACHABILITY for pre-existing guards (NOT regression coverage for this PR). +# -------------------------------------------------------------------------- + +def test_an_empty_target_directory_is_loud(tmp_path): + """A target that EXISTS but holds no tests must fail, not pass. + + GUARD 5 accepts it (the directory is real), so the only thing standing + between an emptied suite and a green gate is the per-directory + ``collected < 1`` floor. Nothing had ever proven that path could fire. + """ + empty = tmp_path / "empty_tests" + empty.mkdir() + runner = _runner_with_targets(tmp_path, [str(empty)]) + proc = _run([str(runner), str(REPO_ROOT)]) + out = proc.stdout + proc.stderr + assert proc.returncode != 0, ( + f"an EMPTY target directory produced a passing run.\n{out}" + ) + assert "collected 0 tests" in out, ( + f"the run failed but not for the empty-directory reason.\n{out}" + ) + assert "RESULT: FAIL" in proc.stdout, out + + +def test_a_typod_target_entry_is_loud(tmp_path): + """A misspelled target must be named, not silently dropped.""" + typo = "scripts/dl-rooter/tests" + runner = _runner_with_targets(tmp_path, [typo]) + proc = _run([str(runner), "--check-targets", str(REPO_ROOT)]) + out = proc.stdout + proc.stderr + assert proc.returncode == 2, f"a typo'd target did not abort GUARD 5.\n{out}" + assert typo in out, f"GUARD 5 failed but never named {typo!r}.\n{out}" + assert "does not exist" in out, f"GUARD 5 named {typo!r} but not why.\n{out}" + + +def test_a_failing_run_never_exits_zero(tmp_path): + """`RESULT: FAIL` and a zero exit must be impossible. + + The report that motivated this PR said a bare worktree run printed + ``RESULT: FAIL`` and exited 0. That did NOT reproduce (measured: exit 1), + and the structure forbids it -- ``RESULT: FAIL`` is printed only when + ``fail != 0`` and the very next statement is ``exit "$fail"``. The most + likely origin is reading the status through a pipeline (``| tail``), which + yields the LAST command's status, not the runner's. + + This pins the invariant end-to-end anyway, against a run forced red by the + empty-directory case, so the claim can never quietly become true. + """ + empty = tmp_path / "empty_tests" + empty.mkdir() + runner = _runner_with_targets(tmp_path, [str(empty)]) + proc = _run([str(runner), str(REPO_ROOT)]) + if "RESULT: FAIL" in proc.stdout: + assert proc.returncode != 0, ( + "the runner printed RESULT: FAIL and exited 0 — a reassuring exit " + f"code over a failed run.\nexit={proc.returncode}\n{proc.stdout}" + ) + else: + pytest.fail( + "could not force a RESULT: FAIL, so this invariant was never " + f"exercised — the test would pass vacuously.\n{proc.stdout}" + ) From ec63d11a75a797787a4f53513ee8aabf54654447 Mon Sep 17 00:00:00 2001 From: Zach Lowden Date: Mon, 3 Aug 2026 00:43:18 -0500 Subject: [PATCH 2/2] fix(test): write the python shim via testlib.mockbin, not a hand-rolled shebang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo-wide runtime-shebang scanner (#306, scripts/tests/ test_runtime_shebangs.py) failed this file IN THE SANDBOX ONLY. The dev-host run was green because I ran this ONE file, and the scanner lives in another — a per-file run structurally cannot see a repo-wide guard. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/tests/test_run_tests_preconditions.py | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/scripts/tests/test_run_tests_preconditions.py b/scripts/tests/test_run_tests_preconditions.py index efb77bf..437fdeb 100644 --- a/scripts/tests/test_run_tests_preconditions.py +++ b/scripts/tests/test_run_tests_preconditions.py @@ -51,13 +51,15 @@ import os import re -import shutil import subprocess +import sys from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) +from testlib.mockbin import write_exec # noqa: E402 RUN_TESTS = REPO_ROOT / "scripts" / "run-tests.sh" @@ -143,21 +145,23 @@ def test_missing_pytest_module_is_named(tmp_path): of 'could not parse pytest's summary', a diagnosis pointing at the wrong subsystem entirely. """ - # A `python` shim that exists and runs but has no pytest. Written without a - # `#!/usr/bin/env` shebang: the nix build sandbox has no /usr/bin/env, and a - # stub that is dead in one tier is how #306's portability defect shipped. + # A `python` shim that exists and runs but has no pytest. + # + # 🔴 Written via testlib.mockbin.write_exec, NOT by hand. The first draft + # wrote its own `#!{shutil.which("bash")}` shebang and the repo-wide runtime + # -shebang scanner (scripts/tests/test_runtime_shebangs.py, added by #306) + # failed it IN THE SANDBOX ONLY — the dev-host run of this file alone was + # green, because the scanner lives in a different file. Exactly the two-tier + # lesson: a per-file dev-host run cannot see a repo-wide guard. bindir = tmp_path / "bin" bindir.mkdir() - real_bash = shutil.which("bash") - shim = bindir / "python" - shim.write_text( - f"#!{real_bash}\n" + write_exec( + bindir / "python", 'if [ "$1" = "-m" ] && [ "$2" = "pytest" ]; then\n' - ' echo "/usr/bin/python: No module named pytest" >&2; exit 1\n' + ' echo "python: No module named pytest" >&2; exit 1\n' "fi\n" - "exit 0\n" + "exit 0\n", ) - shim.chmod(0o755) env = dict(os.environ) env["PATH"] = f"{bindir}{os.pathsep}{env.get('PATH', '')}"