Files
ersatztv/scripts/tests/mutation_harness_lib.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

330 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. WHERE SUCH AN ENTRY EXISTS, 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 harness replaces
did. No entry is graded `DETECTOR` since ersatztv#891, so that runner currently executes nothing;
`test_the_DETECTOR_survivor_set_is_empty_ON_PURPOSE` asserts the emptiness so it stays a recorded
decision rather than an unread skip.
"""
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. An enumeration
of the dangerous ones shipped here once, covering three of them — a list is what this replaces.
"""
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")