Files
ersatztv/scripts/tests/mutation_harness_lib.py
T
3b386649bf
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 18s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m2s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m30s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m9s
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 4m10s
feat(790): execute every MUTATION grade instead of asserting it (#815)
Every `MUTATION` row of `docs/guard-inventory.md` now carries a DECLARED clause mutation that is applied to an isolated copy of the repository on every suite run, with the row's own named test required to go red carrying a declared diagnostic. Manifest and MUTATION rows are compared for set equality both directions; the other 22 guards each carry a stated reason, compared the same way.

Measured rather than assumed: 12 of 13 guards admit a single-clause mutation; `instrumentation_faults` does not, and that entry carries the surviving finer mutation, re-run every suite.

Nine cold cross-family review rounds. Rounds 1, 2, 7 and 8 each found real mechanism defects — two mutations that measured nothing, an incomplete git-environment sanitisation, a reset that restored its own mutant, and a proof of that fix which was not itself isolated. All fixed and witnessed red.

fixes #790

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-22 13:12:15 +00:00

327 lines
16 KiB
Python

"""Machinery for the clause-level mutation harness (ersatztv#790).
`docs/guard-inventory.md` grades each guard's proof `MUTATION`, `BEHAVIOUR-ONLY` or `NONE`, and
`MUTATION` means "a clause-level mutation was executed and this named test was witnessed red". A
witnessing performed once, by hand, decays the moment anyone edits the guard, and a grade nothing
re-checks can simply be wrong.
This module turns each such row from an assertion into a check: apply the guard's **declared**
clause mutation to an isolated copy of the repo and require the row's own named test to go RED.
WHAT IS DELIBERATELY NOT DONE. The mutation is declared per guard in `mutation_manifest.py`, never
inferred. A harness that guessed which clause of a 90-line hook is *the* guard would manufacture
exactly the confident-but-empty coverage this exists to prevent — the reason
`testing.guard-ships-with-mutation-proof` rejects a generic runner. Guessing is also unnecessary:
most of the proof tests already name their clause in source (the BOM test's `= "efbbbf" ]; then`,
`UNSET_CLAUSE`, `prove-fix.sh`'s `if [ "$RC" -eq 0 ]; then`), and the manifest reuses that same
string rather than inventing a second one.
THE SANDBOX IS A REAL GIT REPOSITORY, not a directory of copied files. Several guards derive their
population from `git ls-files` and one drives `git worktree add`, so a plain copy would send them
down their degraded paths and every mutation would "redden" for a reason having nothing to do with
the clause. Its contents are the TRACKED files with WORKING-TREE content — `git ls-files -s`, not a
filesystem walk (`testing.guard-derives-population-from-source`, and the reason #778's guard was red
on every developer checkout and green in CI: `.husky/_/` is generated by `npm ci` and untracked).
Two index entries are not regular files and are handled explicitly rather than by an exception:
the `.claude/skills/jellyfin` symlink is recreated as a symlink (it dangles outside `~/ersatztv`,
which is inherent to the cross-repo symlink pattern and not this harness's problem), and the
`ErsatzTV-macOS` gitlink is SKIPPED — no guard reads the submodule, and materialising one would
cost a fetch per run.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
# Long enough that a slow shared runner is not mistaken for a hang, short enough that a genuinely
# stuck inner pytest fails the job rather than burning the whole CI budget. The full set of proof
# tests runs in ~7s locally.
PYTEST_TIMEOUT = 300
# Every git call here is local and confined to the sandbox; anything slower is stuck, not slow.
GIT_TIMEOUT = 120
# The pristine commit of each sandbox, held OUT OF THE REPOSITORY the proof tests drive. A ref inside
# it would be one more thing a proof can move: `git branch -f` fails on a checked-out branch, a
# global `init.defaultBranch` can collide with the name, and any `git update-ref`/`git checkout -B` a
# proof runs could retarget it. An object id kept here cannot be reached from inside the sandbox at
# all, and `git reset --hard <oid>` needs no ref to exist.
_BASELINES: dict[str, str] = {}
@dataclass(frozen=True)
class Mutation:
"""One declared clause mutation and the test that must notice it.
`guard` is the `Guard` column of `docs/guard-inventory.md` — the thing being graded.
`target` is the file actually edited. They are usually the same; where they differ, `why` says
why, and `test_a_cross_file_mutation_states_why` requires it.
`clause` must occur EXACTLY ONCE in `target`: a mutation that lands on an unintended second site
proves something about a clause nobody declared.
`expect` is a substring the FAILING run's output must contain, and it is what stops exit code 1
from being the whole verdict. Pytest reports an ordinary exception the same way it reports a
failed assertion, so a mutation that merely CRASHES the proof test — an emptied population
reaching an `IndexError`, a syntax error, an unrelated parametrisation — would otherwise be
accepted as "the guard noticed". Naming the diagnostic the mutation is supposed to produce makes
each row's evidence specific: a red for a different reason fails here and has to be re-declared.
`granularity` is `CLAUSE` or `DETECTOR`, and it is the honest half of this harness. #790 opened
on the observation that neutering `pin_population_faults` wholesale is "coarser than disarming
one clause at a time — coarse enough that a single surviving clause would not be noticed". That
is true, and it is also not always avoidable: a detector that accumulates faults from several
independent arms answers on ANY of them, so disarming one arm leaves its proof test green and
the only mutation that reddens is the whole detector. Recording which grade each guard actually
admits turns that from an unstated weakness into a measured property.
A `DETECTOR` entry does not merely SAY a finer mutation was tried; it carries that mutation in
`survived_clause`/`survived_replacement`, and `test_every_SURVIVING_clause_mutation_still_does`
re-runs it and requires the proof test to stay GREEN. The justification for the coarse grade is
therefore executed on every run, exactly like the grade it justifies — a prose claim would decay
the same way the hand-run witnessing this whole harness replaces did.
"""
CLAUSE = "CLAUSE"
DETECTOR = "DETECTOR"
guard: str
target: str
clause: str
replacement: str
proof: str
granularity: str
expect: str
why: str
survived_clause: str = ""
survived_replacement: str = ""
@property
def node_id(self) -> str:
"""The inventory records proof refs as `file.py::test`; pytest wants a path."""
return f"scripts/tests/{self.proof}"
@dataclass(frozen=True)
class Verdict:
ok: bool
reason: str
def _clean_env(**extra: str) -> dict[str, str]:
"""The environment every subprocess here runs in, with git's ambient state REMOVED.
Exported `GIT_*` variables override `-C` and `cwd`. `GIT_DIR`, `GIT_WORK_TREE`,
`GIT_INDEX_FILE`, `GIT_COMMON_DIR` and `GIT_OBJECT_DIRECTORY` each redirect part of a repository;
`GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` inject arbitrary settings, `core.worktree`
among them. Any of those reaching this module's `git init`/`add`/`commit`/`reset --hard` points
them at the REAL repository, and the "sandbox" would then write through the tree it exists to
stay out of. A git hook exports several of them, and this suite runs from one.
So this is a DENY-BY-DEFAULT boundary rather than a list of the variables anyone has thought of:
every `GIT_*` is dropped and only the identity this module sets itself is put back. Enumerating
the dangerous ones is how the first version of this function shipped covering three of them.
"""
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
env.update(extra)
return env
def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
# `-c` rather than the ambient configuration, because the sandbox must not inherit the
# developer's machine: a global `core.hooksPath` would fire this repo's husky hooks against a
# throwaway tree, and `commit.gpgsign` would block the commit on a signing key CI does not have —
# indefinitely, at a pinentry prompt, which no pytest timeout is watching.
return subprocess.run(
# `core.worktree` is pinned along with the rest: a proof that plants one in the sandbox's own
# config would otherwise redirect `reset --hard` and `clean -qffdx` at a tree outside it.
[
"git",
"-c",
"core.hooksPath=/dev/null",
"-c",
"commit.gpgsign=false",
"-c",
f"core.worktree={cwd}",
*args,
],
cwd=str(cwd),
check=True,
capture_output=True,
timeout=GIT_TIMEOUT,
env=_clean_env(
GIT_AUTHOR_NAME="mutation-harness",
GIT_AUTHOR_EMAIL="harness@example.invalid",
GIT_COMMITTER_NAME="mutation-harness",
GIT_COMMITTER_EMAIL="harness@example.invalid",
),
)
def build_sandbox(dest: Path, root: Path = REPO_ROOT) -> Path:
"""Materialise `root`'s tracked files at `dest` and make it a git repository."""
entries = subprocess.run(
["git", "-C", str(root), "ls-files", "-s", "-z"],
capture_output=True,
check=True,
timeout=GIT_TIMEOUT,
env=_clean_env(),
).stdout.decode()
copied = 0
for entry in entries.split("\0"):
if not entry:
continue
meta, path = entry.split("\t", 1)
mode = meta.split()[0]
if mode == "160000": # gitlink — see the module docstring
continue
src = root / path
dst = dest / path
dst.parent.mkdir(parents=True, exist_ok=True)
if src.is_symlink():
os.symlink(os.readlink(src), dst)
else:
shutil.copy2(src, dst)
copied += 1
if copied == 0:
raise RuntimeError(
"the sandbox population is EMPTY — `git ls-files` returned nothing, so every mutation "
"below would run against an empty tree and report success. Anti-vacuity, not paranoia."
)
_git(dest, "init", "-q", ".")
# `-f` because some tracked files are also gitignored; without it they would be dropped from the
# sandbox's index and a guard deriving its population from `git ls-files` would see less than the
# real repo does.
_git(dest, "add", "-A", "-f", ".")
_git(dest, "commit", "-qm", "mutation-harness sandbox")
_BASELINES[str(dest.resolve())] = _git(dest, "rev-parse", "HEAD").stdout.decode().strip()
return dest
def reset_sandbox(sandbox: Path) -> None:
"""Return the sandbox to its committed state between mutations.
The proof tests write into `tmp_path`, but a guard driven through its real entry point can leave
artifacts in the tree it is pointed at, and one mutation's residue reaching the next would make
the second result a function of the first's.
"""
# RESET TO THE RECORDED BASELINE COMMIT, never to bare HEAD. `git reset --hard` with no argument
# resets to whatever HEAD currently is — so a proof test that COMMITS inside the sandbox moves
# HEAD onto a commit containing the mutant, and every later "reset" would then faithfully restore
# it. The `finally` in `verify_mutation` puts the file back, but nothing would put HEAD back, and
# the contamination would surface as an unrelated red several mutations later.
#
# `-ff` rather than `-f` because a single `-f` refuses to delete a nested git repository, which is
# precisely what a proof driving `git init` or `git worktree add` into the sandbox leaves behind.
baseline = _BASELINES.get(str(sandbox.resolve()))
if baseline is None:
raise RuntimeError(
f"no recorded baseline for {sandbox} — it was not built by build_sandbox, so there is "
"nothing to reset TO and a reset here would pin whatever state the tree is in now"
)
_git(sandbox, "reset", "-q", "--hard", baseline)
_git(sandbox, "clean", "-qffdx")
def run_pytest(sandbox: Path, node_ids: list[str]) -> subprocess.CompletedProcess:
return subprocess.run(
[
sys.executable,
"-m",
"pytest",
"-q",
"--no-header",
"--tb=short", # the assertion MESSAGE, which `expect` is matched against
"-p",
"no:cacheprovider", # keeps `git status` in the sandbox clean between mutations
*node_ids,
],
cwd=str(sandbox),
capture_output=True,
text=True,
env=_clean_env(PYTHONPATH="."),
timeout=PYTEST_TIMEOUT,
)
# Only exit code 1 means "a test ran and failed", and it is the only status accepted here. Everything
# else is rejected, which matters most for the two ways a proof ref goes stale — measured, because
# they are easy to get the wrong way round: with an explicit `file.py::function` node id, a missing
# FILE and a missing FUNCTION both exit 4 ("ERROR: not found"), while 5 needs a successful collection
# that selected nothing — a deselection. Reading either as a guard going red is how a harness reports
# coverage it does not have.
_PYTEST_RED_MEANINGS = {
0: "the named test still PASSED with the clause mutated, so the clause is not load-bearing for it",
2: "the inner pytest was interrupted",
3: "the inner pytest hit an internal error",
4: "the inner pytest could not resolve the node id — the proof ref names a file or a test that does not exist",
5: "the inner pytest collected successfully but selected NOTHING — the proof ref was deselected",
}
def verify_mutation(sandbox: Path, mutation: Mutation) -> Verdict:
"""Apply one declared mutation in `sandbox` and require its named test to go red.
The sandbox is left as it was found; callers still `reset_sandbox` between mutations because a
driven guard can dirty the tree in ways this function does not know about.
"""
target = sandbox / mutation.target
if not target.is_file():
return Verdict(False, f"the mutation target {mutation.target} does not exist in the sandbox")
original = target.read_text(encoding="utf-8")
occurrences = original.count(mutation.clause)
if occurrences != 1:
return Verdict(
False,
f"the declared clause occurs {occurrences} times in {mutation.target}, not once. "
"RETARGET it rather than loosening the match — a clause that has moved, or that now "
"matches a second site, means the recorded proof no longer points at what it claims to.",
)
mutated = original.replace(mutation.clause, mutation.replacement, 1)
if mutated == original:
return Verdict(False, "the replacement is identical to the clause, so nothing was mutated")
target.write_text(mutated, encoding="utf-8")
try:
result = run_pytest(sandbox, [mutation.node_id])
finally:
target.write_text(original, encoding="utf-8")
output = result.stdout + result.stderr
if result.returncode != 1:
meaning = _PYTEST_RED_MEANINGS.get(result.returncode, f"unexpected pytest exit code {result.returncode}")
return Verdict(False, f"{meaning}\n--- inner pytest output ---\n{output[-3000:]}")
# MATCHED AGAINST THE EXCEPTION OUTPUT ALONE, not the whole run. `--tb=short` echoes the failing
# SOURCE as well as the message, and every one of these assertions carries its message as a
# string literal a line or two above — so matching the full output would let a red at assertion A
# be certified by assertion B's text merely being on screen. Pytest prefixes exception lines with
# `E `, and that is the only part that reports what actually failed.
diagnostic = "\n".join(line[2:] for line in output.splitlines() if line.startswith("E "))
# This couples the harness to pytest's traceback FORMAT, and pytest is deliberately unpinned in
# `script-tests`. The coupling is fail-CLOSED: a release that stopped prefixing exception lines
# with `E ` would empty `diagnostic` and every row would fail here naming its own expectation,
# which is loud and instantly diagnosable. The alternative — matching the whole run — fails
# silently in the direction that certifies rows on the wrong red. Note the join: a multi-line
# assertion message arrives as several `E ` lines, so an expectation must not span a newline.
if mutation.expect not in diagnostic:
return Verdict(
False,
f"the named test went red, but NOT with the declared diagnostic {mutation.expect!r}. A red "
"for a reason other than the one this row records is not evidence about the clause — a "
"crash, a syntax error or an unrelated parametrisation all look like this. Re-declare "
f"`expect` once you know what the mutation now produces.\n--- inner pytest output ---\n"
f"{output[-3000:]}",
)
return Verdict(True, "the named test went red under the declared mutation, with the declared diagnostic")