testsAndMisc/meta/scripts/mutate_session_autopsy_hooks.py
Krzysztof kuhy Rudnicki 2a788b741d Repoint MCP paths; parallelise mutation gate
The mutation hook alone took 51 s, so no commit touching the
session_autopsy test could fit the 60 s cap. Mutants now run 4 wide
(19 s). Two oldest evidence files pruned for the 60-file cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYFhDShgeHByh6yT9L
2026-11-10 14:25:05 +01:00

185 lines
6.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Prove the session_autopsy hook tests are non-vacuous, by mutation.
Both hooks in ``~/.claude/hooks`` end in an unconditional ``exit 0`` by design,
so a test that asserts exit status passes against a hook whose body has been
deleted. ``linux_configuration/tests/test_session_autopsy_hooks.py`` therefore
asserts side effects instead -- but "asserts a side effect" is a claim, and this
script is what checks it.
Each mutation below breaks ONE guard in a copy of the hooks, points the suite at
that copy through ``AUTOPSY_HOOKS_DIR`` and records which tests died. A test that
no mutation kills is asserting nothing worth asserting. The deployed hooks are
never modified.
Mutating per guard matters: gutting a whole hook body kills only the positive
tests, because a gutted hook emits neither a launch nor any output -- exactly
what the negative tests already expect. Run that way, nine of the twelve tests
look vacuous when they are not.
Used as a pre-commit hook entry point and runnable by hand. Exits 0 when the
hooks are not deployed (a CI runner has no ``~/.claude``), since there is then
nothing to mutate.
"""
from __future__ import annotations
import ast
import concurrent.futures
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
from _session_autopsy_mutations import END_HOOK, MUTATIONS, START_HOOK, Mutation
_TEST_FILE = "linux_configuration/tests/test_session_autopsy_hooks.py"
# Mutants are independent (own hook copy, own coverage file), so they run side by
# side; sequentially the ~4 s suite x 9 mutants pushed a commit past 60 s.
_WORKERS = 4
_OUTCOME = re.compile(r"::(test_\w+)\s+(PASSED|FAILED|ERROR|SKIPPED)")
def _emit(message: str) -> None:
"""Write ``message`` and a newline to stdout (``print`` is banned by ruff)."""
sys.stdout.write(f"{message}\n")
def _hooks_dir() -> Path:
"""Return the hook directory under test, honouring ``AUTOPSY_HOOKS_DIR``."""
override = os.environ.get("AUTOPSY_HOOKS_DIR")
if override:
return Path(override)
return Path.home() / ".claude" / "hooks"
def _repo_root() -> Path:
"""Return the repository root (this file lives in ``meta/scripts``)."""
return Path(__file__).resolve().parents[2]
def _run_suite(hooks_dir: Path, scratch: Path) -> set[str]:
"""Run the hook test suite against ``hooks_dir``; return failing test names.
Args:
hooks_dir: Directory holding the (possibly mutated) hooks under test.
scratch: Directory for the throwaway coverage data file.
Returns:
The names of tests that failed or errored.
Raises:
RuntimeError: If pytest produced no parseable outcomes at all.
"""
result = subprocess.run(
[
sys.executable,
"-m",
"pytest",
_TEST_FILE,
"-p",
"no:cacheprovider",
"-o",
"addopts=",
"-v",
"--no-header",
"--tb=no",
],
cwd=_repo_root(),
env={
**os.environ,
"AUTOPSY_HOOKS_DIR": str(hooks_dir),
"COVERAGE_FILE": str(scratch / ".coverage"),
},
capture_output=True,
text=True,
check=False,
)
outcomes = _OUTCOME.findall(result.stdout)
if not outcomes:
msg = f"pytest produced no test outcomes:\n{result.stdout[-2000:]}"
raise RuntimeError(msg)
return {name for name, outcome in outcomes if outcome in {"FAILED", "ERROR"}}
def _collect_test_names() -> set[str]:
"""Return every ``test_*`` function name defined in the suite."""
tree = ast.parse((_repo_root() / _TEST_FILE).read_text(encoding="utf-8"))
return {
node.name
for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name.startswith("test_")
}
def _apply(mutation: Mutation, hooks_dir: Path, target_dir: Path) -> None:
"""Copy both hooks into ``target_dir`` and inject ``mutation``.
Raises:
RuntimeError: If the mutation left the hook text unchanged, which means
the hook was edited and the mutation no longer describes it.
"""
for hook in (END_HOOK, START_HOOK):
shutil.copy2(hooks_dir / hook, target_dir / hook)
target = target_dir / mutation.hook
original = target.read_text(encoding="utf-8")
mutated = mutation.apply(original)
if mutated == original:
msg = (
f"mutation {mutation.name!r} no longer applies to {mutation.hook} -- "
"the hook text changed, so this mutation tests nothing. Update it."
)
raise RuntimeError(msg)
target.write_text(mutated, encoding="utf-8")
target.chmod(0o755)
def _run_mutant(mutation: Mutation, hooks_dir: Path) -> tuple[Mutation, set[str]]:
"""Apply one mutation in a private directory and return the tests it kills."""
with tempfile.TemporaryDirectory() as mutant_name:
mutant_dir = Path(mutant_name)
_apply(mutation, hooks_dir, mutant_dir)
return mutation, _run_suite(mutant_dir, mutant_dir)
def main() -> int:
"""Run every mutation and fail if any test survives them all."""
hooks_dir = _hooks_dir()
if not all((hooks_dir / hook).is_file() for hook in (END_HOOK, START_HOOK)):
_emit(f"session_autopsy hooks are not deployed under {hooks_dir} -- skipping")
return 0
with tempfile.TemporaryDirectory() as scratch_name:
scratch = Path(scratch_name)
baseline = _run_suite(hooks_dir, scratch)
if baseline:
_emit(f"BASELINE IS RED, cannot mutate: {sorted(baseline)}")
return 1
killed_by: dict[str, list[str]] = {}
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=_WORKERS) as pool:
results = list(pool.map(lambda m: _run_mutant(m, hooks_dir), MUTATIONS))
except RuntimeError as error:
_emit(f"ERROR: {error}")
return 1
for mutation, failures in results:
_emit(f"{mutation.name:26s} killed {len(failures):2d}: {sorted(failures)}")
for test in failures:
killed_by.setdefault(test, []).append(mutation.name)
names = _collect_test_names()
survivors = sorted(names - killed_by.keys())
if survivors:
_emit(f"\nVACUOUS -- no mutation kills these: {survivors}")
return 1
_emit(f"\nPASS: all {len(names)} tests killed by at least one mutation")
return 0
if __name__ == "__main__":
raise SystemExit(main())