Files
ersatztv/scripts/tests/test_prove_fix.py
T
timothyandClaude Fable 5.1 a7d91bf15a
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 35s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 57s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 1m0s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ a7d91bf (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
fix(876): sweep session narrative out of hooks, workflows, scripts, tests and code comments; grow the detector to the process corpus
`docs.no-session-narrative` reaches every durable artifact, but its detector scanned only
`docs/**/*.md` and root markdown, and nothing had ever swept the rest. The issue named four sites
from one grep and called them a floor. Deriving the population instead — a whitespace-joined sweep
over every tracked file outside the detector, for the detector's own phrasings plus the attribution
and review-round class #812 found — gave 453 sites in 108 files at `fb5592971`, and a second pass
for phrasings the first list missed (hyphenated `round-N`, "an earlier version", "the reviewer
proved") added residuals in the same files. Every site was classified with #812's three
dispositions (CUT / SEVER / KEEP with its sub-kind) under the who-benefits test; the per-site
manifests are on the PR. The rejected designs, tested-and-rejected fixtures, measurements and
traps stay; the attribution of who found them and the round in which they were found go.

The detector's population grows to `.claude/`, `.gitea/`, `.husky/` and `scripts/` regardless
of extension, minus the detector and its own test (whose fixtures ARE the phrasings) and minus
`scripts/tests/fixtures/` (test data, including decision-record copies — the same reasoning as
the records' own exemption, and what keeps the record's depth measurement true), and `--all`
lists tracked REGULAR files only — a symlink's content is its target and a gitlink has none. The #812
argument for leaving `docs/superpowers/**` in the population runs the other way here: `--diff`
sees only ADDED lines, and 287 of the 453 sites were under 30 days old — this corpus is where
narrative is being added, so the advisory nudge has reach. Density agrees: 56 line-mode hits over
the 113 regular files the predicate admits, against 9 over 66 docs files before #812. `web/` and C# stay out on the same
measurement (3 of 74 PATTERNS-matching sites, ~4,600 files). The predicate did not grow: PATTERNS
matched 74 of 453 sites, and widening the word list to the attribution class is the treadmill
the withdrawn parity test ran on. The population oracle is restated over segments with the new
arms, the synthetic cross product gains the process heads and non-markdown extensions, a fixture
witnesses that a tracked symlink is neither scanned nor counted, a `.py.bak` axis separates a
by-name exemption from a `startswith` over the same tuple, and eight mutants (drop the process
arm, drop the by-name exemption, exempt by `startswith`, drop or add a prefix, drop the fixtures
exemption, list only markdown, drop the symlink filter, test the mode per row instead of per
path) each
redden it. A pre-existing silent drop in `--diff` goes with it: git tab-terminates a `+++`
filename that contains a space, and the kept tab made `is_scanned_path` refuse the file with no
notice — fixed, with a positive control and its own mutant.

Code is unchanged by construction, measured per file type against `origin/main`: Python modules
are AST-equal with docstrings stripped, except `#` lines inside the embedded fixture programs
(string literals) of three test modules; workflows differ only in `#` lines inside `run:` block
scalars; shell, C#, TypeScript and jq are equal with comment lines stripped. The stated
exceptions: the detector and its test, 26 vitest titles that carried review-round or severity
labels or a reviewer attribution (call sites whose title changed — every changed title line
walked back to its `it(` / `it.each(...)(` anchor, so a `' + '` concatenation counts once), two
registry note strings and the mutation manifest's prose fields. scripts/tests: 1565 passed.
Web: lint, typecheck, 1319 tests green. Closes #876.

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEcBoFw7ctrf3Nb7R7x7wk
2026-09-03 20:51:39 +02:00

387 lines
18 KiB
Python

"""scripts/prove-fix.sh witnesses a fix's test failing before the fix (ersatztv#794).
THE NEGATIVE CONTROL IS THE POINT OF THIS FILE. A prover that reports PROVEN for
everything is worse than no prover: it manufactures exactly the confidence #794 exists
to withhold. So `test_unrelated_test_is_UNPROVEN` is the load-bearing case here, and the
positive case only tells us the script can distinguish the two.
Each test builds a throwaway git repo rather than pinning real commits from this
repository's history — a test anchored to a real sha rots the moment that sha is rebased
or the file moves, and then it passes for the wrong reason (or is deleted for being
flaky, which is worse).
"""
from __future__ import annotations
import os
import subprocess
import time
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
# Overridable so a MUTANT copy can be driven through these very tests — that is what makes
# the mutation proof below a witnessed red rather than an argument. Gated behind a sentinel
# so a stray CI value cannot silently point the whole suite at another script.
if os.environ.get("PROVE_FIX_PATH") and os.environ.get("PROVE_FIX_MUTATION_RUN") != "1":
raise RuntimeError(
"PROVE_FIX_PATH is set without PROVE_FIX_MUTATION_RUN=1. That would silently test a "
"different script than the one this suite vouches for."
)
PROVE_FIX = Path(os.environ.get("PROVE_FIX_PATH") or (REPO_ROOT / "scripts" / "prove-fix.sh"))
def _git(repo: Path, *args: str) -> str:
return subprocess.run(
["git", "-C", str(repo), *args],
check=True,
capture_output=True,
text=True,
).stdout.strip()
def _run(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["bash", str(PROVE_FIX), "--repo", str(repo), *args],
capture_output=True,
text=True,
cwd=str(repo),
)
@pytest.fixture
def fixrepo(tmp_path: Path) -> Path:
"""A repo whose HEAD is a fix: code change + a test that discriminates.
It also carries an UNRELATED test, present from the first commit, which passes with
or without the fix. That test is the negative control's subject.
"""
repo = tmp_path / "r"
(repo / "scripts" / "tests").mkdir(parents=True)
subprocess.run(["git", "init", "-q", str(repo)], check=True)
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "T")
# --- commit 1: the bug, plus a test that cannot see it
(repo / "calc.py").write_text("def add(a, b):\n return a - b # bug\n")
(repo / "scripts" / "tests" / "test_unrelated.py").write_text("def test_unrelated():\n assert 1 + 1 == 2\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "initial: buggy add, unrelated test")
# --- commit 2: the fix + a test that discriminates against its absence
(repo / "calc.py").write_text("def add(a, b):\n return a + b\n")
(repo / "scripts" / "tests" / "test_add.py").write_text(
"from calc import add\n\n\ndef test_add():\n assert add(2, 3) == 5\n"
)
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "fix: add() returned a difference\n\nProves: scripts/tests/test_add.py")
return repo
def test_reverting_the_fix_reddens_its_test_PROVEN(fixrepo: Path) -> None:
r = _run(fixrepo, "HEAD", "scripts/tests/test_add.py")
assert r.returncode == 0, f"expected PROVEN (0), got {r.returncode}\n{r.stdout}\n{r.stderr}"
assert "PROVEN" in r.stdout
assert "UNPROVEN" not in r.stdout
def test_unrelated_test_is_UNPROVEN(fixrepo: Path) -> None:
"""THE NEGATIVE CONTROL. A test that passes without the fix must be refused.
Without this, every other assertion in this file is compatible with a script that
prints PROVEN unconditionally.
"""
r = _run(fixrepo, "HEAD", "scripts/tests/test_unrelated.py")
assert r.returncode == 1, f"expected UNPROVEN (1), got {r.returncode}\n{r.stdout}\n{r.stderr}"
assert "UNPROVEN" in r.stdout
assert "do not discriminate" in r.stdout
def test_selector_comes_from_the_Proves_trailer(fixrepo: Path) -> None:
"""No selector argument: it must read `Proves:` rather than guess."""
r = _run(fixrepo, "HEAD")
assert r.returncode == 0, f"expected PROVEN via trailer, got {r.returncode}\n{r.stdout}\n{r.stderr}"
assert "scripts/tests/test_add.py" in r.stdout
def test_no_selector_and_no_trailer_REFUSES(fixrepo: Path) -> None:
"""Refusing beats guessing: a heuristic silently proves nothing when a fix edits an
existing test, which is the failure mode being mechanised against."""
(fixrepo / "calc.py").write_text("def add(a, b):\n return a + b # touched\n")
_git(fixrepo, "add", "-A")
_git(fixrepo, "commit", "-q", "-m", "fix: no trailer here")
r = _run(fixrepo, "HEAD")
assert r.returncode == 3, f"expected 3 (no selector), got {r.returncode}\n{r.stderr}"
assert "Proves:" in r.stderr
def test_test_only_commit_REFUSES(fixrepo: Path) -> None:
"""A commit with no code side cannot be proven this way — it must say so, not pass."""
(fixrepo / "scripts" / "tests" / "test_extra.py").write_text("def test_x():\n assert True\n")
_git(fixrepo, "add", "-A")
_git(fixrepo, "commit", "-q", "-m", "test: add a test only\n\nProves: scripts/tests/test_extra.py")
r = _run(fixrepo, "HEAD")
assert r.returncode == 4, f"expected 4 (nothing to revert), got {r.returncode}\n{r.stderr}"
assert "nothing to revert" in r.stderr
def test_selector_matching_no_tests_REFUSES(fixrepo: Path) -> None:
"""`pytest` exits 5 when it collects nothing. Treating that as red would prove every
fix — a check that examined nothing reporting success."""
r = _run(fixrepo, "HEAD", "scripts/tests/test_does_not_exist.py")
assert r.returncode == 5, f"expected 5 (no tests collected), got {r.returncode}\n{r.stderr}"
assert "NO tests" in r.stderr
def test_added_code_file_is_removed_not_checked_out(tmp_path: Path) -> None:
"""A file the fix ADDED does not exist in the parent. `git checkout parent -- <new>`
fails there, and if that failure were swallowed the fix would stay in place and every
run would report a false PROVEN."""
repo = tmp_path / "r2"
(repo / "scripts" / "tests").mkdir(parents=True)
subprocess.run(["git", "init", "-q", str(repo)], check=True)
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "T")
(repo / "scripts" / "tests" / "keep.py").write_text("# placeholder\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "initial")
# the fix ADDS helper.py (it has no parent version) and a test that needs it
(repo / "helper.py").write_text("def shout(s):\n return s.upper()\n")
(repo / "scripts" / "tests" / "test_helper.py").write_text(
"from helper import shout\n\n\ndef test_shout():\n assert shout('a') == 'A'\n"
)
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "feat: add shout\n\nProves: scripts/tests/test_helper.py")
r = _run(repo, "HEAD")
assert r.returncode == 0, f"expected PROVEN, got {r.returncode}\n{r.stdout}\n{r.stderr}"
assert "PROVEN" in r.stdout
def test_root_commit_REFUSES(tmp_path: Path) -> None:
repo = tmp_path / "r3"
repo.mkdir()
subprocess.run(["git", "init", "-q", str(repo)], check=True)
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "T")
(repo / "a.py").write_text("x = 1\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "root")
r = _run(repo, "HEAD", "scripts/tests")
assert r.returncode == 5
assert "root commit" in r.stderr
def test_runs_under_the_system_bash(fixrepo: Path) -> None:
"""macOS ships /bin/bash 3.2, where `mapfile` is absent AND yields an empty array
instead of erroring. Assert by EXECUTING under that bash, not by grepping the source
for "mapfile" — a grep matches the comment explaining why mapfile is avoided, which is
a string predicate failing exactly as docs/defect-shapes-773.md §3.7 says they do."""
system_bash = Path("/bin/bash")
if not system_bash.exists():
pytest.skip("/bin/bash not present")
# Named for the system bash, NOT for 3.2: on the Linux runner /bin/bash is 5.x, so a
# name promising bash-3.2 coverage would read as coverage that exists only on a
# developer Mac. The 3.2 hazard (mapfile yielding an empty array) is what motivated it.
ver = subprocess.run([str(system_bash), "--version"], capture_output=True, text=True).stdout
r = subprocess.run(
[str(system_bash), str(PROVE_FIX), "--repo", str(fixrepo), "HEAD", "scripts/tests/test_add.py"],
capture_output=True,
text=True,
cwd=str(fixrepo),
)
assert r.returncode == 0, (
f"prove-fix.sh must work under the system bash ({ver.splitlines()[0] if ver else '?'}); "
f"got {r.returncode}\n{r.stdout}\n{r.stderr}"
)
assert "PROVEN" in r.stdout
def test_control_failure_REFUSES(fixrepo: Path) -> None:
"""A test that is ALREADY red with the fix in place proves nothing by being red after
a revert. Without this control, half a discrimination claim reads as the whole one."""
(fixrepo / "scripts" / "tests" / "test_broken.py").write_text(
"from calc import add\n\n\ndef test_broken():\n assert add(2, 3) == 99\n"
)
(fixrepo / "calc.py").write_text("def add(a, b):\n return a + b # unchanged\n")
_git(fixrepo, "add", "-A")
_git(fixrepo, "commit", "-q", "-m", "fix: with an already-failing test\n\nProves: scripts/tests/test_broken.py")
r = _run(fixrepo, "HEAD")
assert r.returncode == 6, f"expected 6 (control failed), got {r.returncode}\n{r.stdout}\n{r.stderr}"
assert "control FAILED" in r.stderr
def test_control_green_then_red_is_reported_as_both(fixrepo: Path) -> None:
"""The PROVEN line must state both halves — green with, red without — because that
pair is the claim. Reporting only the red half is the overclaim being mechanised out."""
r = _run(fixrepo, "HEAD", "scripts/tests/test_add.py")
assert r.returncode == 0
assert "control" in r.stdout.lower()
assert "Green with the fix (control), red without it" in r.stdout
def test_MUTATION_disarming_the_UNPROVEN_clause_reddens_the_refusal_test(
tmp_path: Path,
) -> None:
"""Clause-level mutation, EXECUTED, with the unchanged test WITNESSED RED against it.
`docs/guard-inventory.md` is explicit that MUTATION means a mutation was executed and
the named test was *witnessed red* — feeding the real script a rejecting input is
BEHAVIOUR-ONLY, and the column "is not a grading curve". Deleting the clause and then
asserting the MUTANT misbehaved leaves this test green and proves nothing about
whether the clause is load-bearing.
So: delete the `RC -eq 0 -> UNPROVEN` clause (the CLAUSE, not the file — #510), then
re-run the UNCHANGED `test_unrelated_test_is_UNPROVEN` against the mutant in a nested
pytest run. That test must go RED. Its red is the proof.
"""
real = REPO_ROOT / "scripts" / "prove-fix.sh"
src = real.read_text()
marker = 'if [ "$RC" -eq 0 ]; then'
assert marker in src, "the clause under mutation is gone — regrade the inventory row"
end = src.index(" exit 1\nfi\n", src.index(marker)) + len(" exit 1\nfi\n")
mutant = tmp_path / "prove-fix-mutant.sh"
mutant.write_text(src[: src.index(marker)] + src[end:])
assert marker not in mutant.read_text(), "mutation did not remove the clause"
env = {**os.environ, "PROVE_FIX_PATH": str(mutant), "PROVE_FIX_MUTATION_RUN": "1"}
nested = subprocess.run(
[
"python3",
"-m",
"pytest",
f"{Path(__file__).name}::test_unrelated_test_is_UNPROVEN",
"-q",
"-p",
"no:cacheprovider",
],
cwd=str(Path(__file__).parent),
env=env,
capture_output=True,
text=True,
)
out = nested.stdout + nested.stderr
assert nested.returncode == 1, (
"the unchanged refusal test must go RED against the mutant (pytest exit 1). "
f"got {nested.returncode} — exit 2/3/4/5 would mean the nested run broke rather "
f"than the test failing, which proves nothing.\n{out[-2000:]}"
)
# Require a real reported FAILURE of that specific test. `returncode != 0` alone would
# be satisfied by a collection error — the vacuous shape this whole file is against.
assert "FAILED" in out and "test_unrelated_test_is_UNPROVEN" in out, (
"expected a reported failure of test_unrelated_test_is_UNPROVEN; the nested run "
f"failed for some other reason.\n{out[-2000:]}"
)
assert "1 failed" in out, f"expected exactly one failing test.\n{out[-1200:]}"
def test_SIGTERM_mid_run_never_reports_PROVEN(tmp_path: Path) -> None:
"""A killed run must not look like evidence.
An early DRAFT printed PROVEN and exited 0 after a SIGTERM: the trap cleaned up but did
not exit, so the previous status stood. On the first COMMITTED version (587edbecc) the
run reaches rc 5 by a different route entirely, so this test earns its keep only via the
assertions below — see the comment there.
"""
repo = tmp_path / "slow"
(repo / "scripts" / "tests").mkdir(parents=True)
subprocess.run(["git", "init", "-q", str(repo)], check=True)
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "T")
(repo / "mod.py").write_text("VALUE = 1\n")
(repo / "scripts" / "tests" / "test_slow.py").write_text(
"import time\nfrom mod import VALUE\n\n\ndef test_slow():\n time.sleep(20)\n assert VALUE == 2\n"
)
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "initial")
(repo / "mod.py").write_text("VALUE = 2\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "fix: bump\n\nProves: scripts/tests/test_slow.py")
proc = subprocess.Popen(
["bash", str(PROVE_FIX), "--repo", str(repo), "HEAD"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=str(repo),
)
time.sleep(4) # inside the control run, which sleeps 20s
assert proc.poll() is None, "the run finished before it could be signalled; test is void"
proc.terminate()
out, err = proc.communicate(timeout=60)
# Assert the observable THIS fix introduced, not merely "non-zero and no PROVEN":
# the pre-fix script also satisfied those two, by accident — its `trap cleanup EXIT INT
# TERM` fired, deleted $TMP, execution continued, and a later step died 5. Two different
# bugs landing on the same observable is not a witnessed fix. That pair was measured
# failing to separate old from new; rc==5 AND the handler's own message do separate
# them.
assert proc.returncode == 5, f"a signalled run must exit 5 from on_signal, got {proc.returncode}\n{out}\n{err}"
assert "interrupted by signal" in err, (
f"expected the signal handler's own message, so this test cannot be satisfied by an "
f"unrelated later failure:\n{err}"
)
assert "PROVEN" not in out, f"a signalled run must not print a verdict:\n{out}"
def test_a_harness_failure_is_NOT_reported_as_PROVEN(tmp_path: Path) -> None:
"""The marker-absence branch, reached the way the old false green was reached.
Stubbing `python3` to exit 127 does NOT reach the marker logic: the marker IS written
(127) and the script exits via the control-failure branch. Measured: a commit taking
that route came back UNPROVEN from this tool's own gate, its fix executed by no test.
The real shape is `( cd X && pytest ); rc=$?` returning 1 because `cd` FAILED and pytest
never ran; pre-fix that was accepted as red and produced PROVEN. Reproduced by shimming
`git` so the SECOND `worktree add` (the reverted phase) exits 0 without creating the
directory. The fixture's fix ADDS its code file, so the revert step is `rm -f` — which
succeeds on a missing directory and lets execution reach the phase's `cd`.
"""
repo = tmp_path / "addrepo"
(repo / "scripts" / "tests").mkdir(parents=True)
subprocess.run(["git", "init", "-q", str(repo)], check=True)
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "T")
(repo / "scripts" / "tests" / "keep.py").write_text("# placeholder\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "initial")
(repo / "added.py").write_text("def val():\n return 7\n")
(repo / "scripts" / "tests" / "test_added.py").write_text(
"from added import val\n\n\ndef test_val():\n assert val() == 7\n"
)
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "feat: add val\n\nProves: scripts/tests/test_added.py")
shim = tmp_path / "bin"
shim.mkdir()
counter = tmp_path / "count"
(shim / "git").write_text(
"#!/bin/sh\n"
'if [ "$3" = "worktree" ] && [ "$4" = "add" ]; then\n'
f' n=$(cat "{counter}" 2>/dev/null || echo 0); n=$((n+1)); echo "$n" > "{counter}"\n'
' if [ "$n" -ge 2 ]; then exit 0; fi\n'
"fi\n"
'exec /usr/bin/git "$@"\n'
)
(shim / "git").chmod(0o755)
env = {**os.environ, "PATH": f"{shim}:{os.environ['PATH']}"}
r = subprocess.run(
["bash", str(PROVE_FIX), "--repo", str(repo), "HEAD"],
capture_output=True,
text=True,
cwd=str(repo),
env=env,
)
assert "PROVEN" not in r.stdout, (
"a phase whose worktree does not exist cannot witness anything; pre-fix this "
f"produced PROVEN from the subshell's status:\n{r.stdout}\n{r.stderr}"
)
assert r.returncode == 5, f"expected refusal (5), got {r.returncode}\n{r.stdout}\n{r.stderr}"
assert "did not complete" in r.stderr, (
f"expected the marker-absence diagnostic, not some other refusal:\n{r.stderr}"
)