Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 19s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m31s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m14s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m30s
Mechanises the defect that took #776 and #793 six review rounds each: a fix's test
written to confirm the fix, not to discriminate against its absence.
testing.guard-ships-with-mutation-proof generalised from guards to fixes.
prove-fix.sh runs the selector at the commit (control, must be GREEN) and again in a
separate fresh worktree with the non-test files reverted (must be RED = pytest exit 1
exactly; 2/3/4/5/143 are refused, and --continue-on-collection-errors keeps add-a-file
fixes provable). pytest's status comes from a marker written only after it returns,
because ( cd X && pytest ); rc=$? returns the SUBSHELL's status. Opt-in by a Proves:
trailer; CI checks every commit that carries one and says out loud when a PR has none.
THE TOOL REJECTED ITS OWN AUTHOR. Three commits on the branch claimed
Proves: scripts/tests/test_prove_fix.py; the job returned UNPROVEN for all three,
because reverting the script restored a working earlier version the suite also passed.
Two had been "verified" against hand-written mutants that did not match the code that
actually shipped. The tests were rewritten until both go RED against 587edbecc — whose
script emits "red without it (pytest exit 2)", a witnessed false PROVEN.
This branch deliberately carries no Proves: trailer: the only one that would pass does
so because reverting deletes prove-fix.sh, an add-file smoke check rather than a proof
of its logic. The logic proof is a clause-level mutation that re-runs the unchanged
refusal test against a mutant and witnesses it red (graded MUTATION).
fixes #794
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
371 lines
18 KiB
Python
371 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" — the first version of this test did the latter and matched 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". An earlier version of this
|
|
test deleted the clause and then asserted the MUTANT misbehaved, which left this test
|
|
green and proved nothing about whether the clause is load-bearing. Cold review caught
|
|
that, and it was right.
|
|
|
|
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\n"
|
|
"def 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. Cold review measured that
|
|
# pair 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.
|
|
|
|
An earlier version stubbed `python3` to exit 127, which does NOT reach the marker logic:
|
|
the marker IS written (127) and the script exits via the control-failure branch. Cold
|
|
review measured that, and it is why the round-2 commit came back UNPROVEN from this
|
|
tool's own gate — the fix was 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}"
|
|
)
|