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
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>
327 lines
16 KiB
Python
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")
|