Files
ersatztv/scripts/tests/mutation_manifest.py
T
timothyandClaude Opus 5 b0f42f14a0
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
PR Gates / Docs update reminder (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 18s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 11s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 25s
review-verdict/h10 Awaiting review verdict for b0f42f1
Review verdict / Set review-verdict status (pull_request_target) Successful in 10s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 5m38s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m35s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
fix(806): guard populations over FILES derive from the git index, not a filesystem walk
`testing.guard-derives-population-from-source` (#774) says a completeness guard
derives its population from an authoritative source, and its worked examples are an
enum and the generated OpenAPI document. It was silent on the commonest population
in our own guards — files in a directory — and every one of them answered with a
filesystem walk. A walk is not an authoritative source: it reports build output,
generated shims and editor droppings, and it differs per machine.

#778 measured the cost by getting the same population wrong three times in one PR
while implementing the milestone that exists to prevent it. The worst shape was
`Path.rglob` enumerating `.husky/_/` — 17 shims `npm ci` writes, gitignored and
untracked — which made that guard RED on every developer checkout and GREEN in CI,
whose `script-tests` job never runs `npm ci`. Only that one guard was fixed then.
This audits the rest.

CONVERTED (a completeness claim over tracked files):
  * `test_guard_inventory.py` — its `.husky/` walk excluded `.husky/_/` only because
    `_` is a directory, so the obvious "make it recursive" edit would have
    reintroduced #778's defect in the repo's own model guard. Both halves are gated
    on the index: the callers, and the `scripts/…` paths they name — the second was
    left on `Path.exists()` in the first cut and found by cold review.
  * `test_hook_fire_log.py` — an untracked scratch `.sh` in `.claude/hooks/` was
    demanded to carry instrumentation.
  * `test_ci_image_pin_population.py` — and `*.yaml` added: Gitea accepts both
    spellings, so a `.yaml` workflow adopting the toolchain image was structurally
    invisible while the test read as covering every workflow.
  * `test_remote_state_inventory.py` — folded onto the shared derivation, so the
    rule has one implementation rather than two.

ASSESSED AND RECORDED, not silently skipped:
  * `test_ci_release_path_scan_job.py::_repo_copy` — not a completeness claim, but
    it takes its file LIST from the index anyway for hermeticity, since `copytree`
    copied untracked files and `__pycache__` into a tree whose behaviour the probes
    measure. Content still comes from the working tree, and the copy is NOT a git
    repo, so neither file that step runs may use the helper — written down because
    `MARKED_JOBS` is left open as a residual gap, which invites editing exactly
    that file.
  * `test_ci_dropped_step_guard.py` — no filesystem population at all; its members
    come from the parsed workflow.
  * The decisions corpus keeps its walks and is recorded as unexamined rather than
    cleared. This is not "replace every glob".

`scripts/tests/tracked_files.py` is the single derivation.
`test_guard_populations_derive_from_git.py` proves it in two directions, which are
complements rather than a second opinion — measured both ways:
  * REMOVAL, exhaustively: every member of every registered derivation is dropped
    from the index in turn and must disappear while still on disk. One victim was
    not enough — `derived_guard_files` unions four contributors, so a mutant putting
    only one back on a walk passed. This catches a hardcoded `.exists()` admit and
    memoisation, which the other direction cannot.
  * The CALL LOG: a derivation may READ a file but must not LIST a directory. This
    catches an append-only source that yields nothing on this machine — #778's
    shape — which removal cannot see, because it has nothing to remove. Its limits
    are stated in one place and are true in both directions.

Both the population and the proofs are registered against a hand-written scope
mirror that carries its own equality check, and the import matcher's 16 forms are
pinned as a table so the next edit cannot silently re-open one.

The mutation this guard declares in `mutation_manifest.py` (#790) is the real
defect cold review found in its own first round.

Docs: `testing.guard-derives-population-from-source` gains the file-population case
and why the disk is not authoritative; `docs/guard-inventory.md` carries the
per-guard audit table, including the two no-change verdicts and the decisions
corpus recorded as unexamined.

Seven rounds of independent cold review (Codex GPT-5.6 and Opus, alternating) —
the per-round findings and their measurements are in the PR.

fixes #806

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:48:46 +02:00

317 lines
21 KiB
Python

"""The DECLARED clause mutations, one per `MUTATION`-graded row of `docs/guard-inventory.md`.
Data only. The machinery that applies these is `mutation_harness_lib.py`; the checks that keep this
file honest are `test_mutation_harness.py`.
Every entry is declared by hand and none is inferred, which is the whole design constraint from
ersatztv#790: "a harness that guesses which clause of a 90-line hook is *the* guard would manufacture
exactly the confident-but-empty coverage this is meant to prevent". Where a proof test already names
its own clause in source — the BOM guard's `= "efbbbf" ]; then`, `UNSET_CLAUSE`, `prove-fix.sh`'s
`if [ "$RC" -eq 0 ]; then` — the entry reuses THAT string rather than inventing a second one, so a
retarget in either place is caught by the other.
WHY AN ENTRY'S `target` MAY DIFFER FROM ITS `guard`. Some guards here ARE tests
(`scripts/tests/test_*.py`). Disarming such a guard makes it ABSENT rather than red, so
`testing.guard-ships-with-mutation-proof`'s checker-guard exception applies: the mutation goes into
the guarded ARTIFACT — a deleted row, a planted phantom row — and the check must report it. Mutating
a checker's own POPULATION instead is a trap that looks identical and is not: a shrunken population
makes every real row report as PHANTOM, so the proof reddens on a false positive while saying
nothing about the missing-row detection the row claims. `why` states per entry which shape applies
and why; no count is kept here, because a count of the entries below is a second copy of them.
"""
from __future__ import annotations
from scripts.tests.mutation_harness_lib import Mutation
CLAUSE = Mutation.CLAUSE
DETECTOR = Mutation.DETECTOR
MUTATIONS: tuple[Mutation, ...] = (
Mutation(
guard=".claude/hooks/posttooluse-worktree-marker.sh",
target=".claude/hooks/posttooluse-worktree-marker.sh",
clause='printf \'%s\\n\' "$me" > "$abs/.claude-worktree-owner" 2>/dev/null || true',
replacement="true",
proof="test_worktree_ownership_guard.py::test_MUTATION_a_marker_hook_that_stops_WRITING_makes_the_guard_go_quiet",
granularity=CLAUSE,
expect="the UNMUTATED pair did not deny",
why="The marker write is the hook's entire job; without it the guard has nothing to read and "
"fails open. The clause string is the one the proof test itself passes to its `_mutate` helper.",
),
Mutation(
guard=".claude/hooks/pretooluse-bom-guard.sh",
target=".claude/hooks/pretooluse-bom-guard.sh",
clause='= "efbbbf" ]; then',
replacement='= "deadbeef" ]; then',
proof="test_bom_guard_detection.py::test_DISARMING_the_BOM_comparison_stops_detection",
granularity=CLAUSE,
expect="the BOM comparison has moved",
why="The BOM comparison is the guard's only detection logic. Same clause the proof test names.",
),
Mutation(
guard=".claude/hooks/pretooluse-worktree-guard.sh",
target=".claude/hooks/pretooluse-worktree-guard.sh",
clause='marker="$root/.claude-worktree-owner"',
replacement='marker="$root/.claude-worktree-owner-NOTHING-WRITES-THIS"',
proof="test_worktree_ownership_guard.py::test_MUTATION_disarming_the_guards_MARKER_READ_stops_the_deny",
granularity=CLAUSE,
expect="the UNMUTATED guard did not deny",
why="The marker read is what the ownership decision hangs on. Same clause the proof test names.",
),
Mutation(
guard=".husky/pre-push",
target=".husky/pre-push",
clause="unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE",
replacement=": # clause removed by the mutation harness",
proof="test_prepush_unsets_git_env.py::test_MUTATION_DELETING_the_unset_lets_drift_through_silently",
granularity=CLAUSE,
expect="the UNMUTATED pre-push did not catch the drift",
why="Without the unset, every git call the pre-push chain makes is aimed at the repository git "
"exported the environment for, not the one being pushed. `UNSET_CLAUSE` in the proof test.",
),
Mutation(
guard="scripts/build_decisions_catalog.py",
target="scripts/build_decisions_catalog.py",
clause="want.strip() != have.strip()",
replacement="False",
proof="test_build_catalog_check_path.py::test_MUTATION_disarming_the_stale_comparison_stops_detection",
granularity=CLAUSE,
expect="the stale-detection clause has moved or been reworded",
why="`main()`'s only stale-detection logic, per the proof test's own docstring, which uses this "
"exact clause and this exact replacement.",
),
Mutation(
guard="scripts/ci-step-ran.sh",
target="scripts/ci-step-ran.sh",
clause='if ! grep -qxF "$key" "$marker" 2>/dev/null; then',
replacement="if false; then",
proof="test_ci_dropped_step_guard.py::test_dropping_ANY_single_step_FAILS_the_guard",
granularity=CLAUSE,
expect="never having executed",
why="The per-key membership test is what turns a dropped step into a red job; disarmed, every "
"expected key reads as present and the guard passes a run in which nothing executed.",
),
Mutation(
guard="scripts/decisions_validate.py",
target="scripts/decisions_validate.py",
clause="wing_faults=record_wing_faults() + yaml_faults,",
replacement="wing_faults=yaml_faults,",
proof="test_decisions_validate.py::test_main_actually_CALLS_the_wing_scan",
granularity=CLAUSE,
expect="a wing fault must fail the validator",
why="The wiring the proof test exists for: deleting this call left the whole suite green while "
"a real block-scalar record vanished under `decisions-validate: OK` (#609).",
),
Mutation(
guard="scripts/prove-fix.sh",
target="scripts/prove-fix.sh",
clause='if [ "$RC" -eq 0 ]; then',
replacement="if false; then",
proof="test_prove_fix.py::test_MUTATION_disarming_the_UNPROVEN_clause_reddens_the_refusal_test",
granularity=CLAUSE,
expect="the clause under mutation is gone",
why="The UNPROVEN branch: a named test that passes WITHOUT the fix must be refused. Same clause "
"the proof test names.",
),
Mutation(
guard="scripts/tests/test_ci_image_pin_population.py",
target="scripts/tests/test_ci_image_pin_population.py",
clause="for name in sorted(TOOLCHAIN_JOBS - set(pinned)):",
replacement="for name in sorted(set()):",
proof="test_ci_image_pin_population.py::test_a_single_job_losing_its_pin_is_DETECTED",
granularity=CLAUSE,
expect="the population check accepted a workflow in which a container job no longer runs",
why="The against-the-registry direction, and the one the other two clauses cannot cover: a job "
"that loses its `container:` block leaves `declared` and `pinned` equal, so only this "
"comparison notices it has moved to the bare runner. This is the clause #790 asked for "
"instead of neutering `pin_population_faults` wholesale.",
),
Mutation(
guard="scripts/tests/test_guard_populations_derive_from_git.py",
target="scripts/tests/test_guard_inventory.py",
clause="if ref in tracked:",
replacement="if (REPO_ROOT / ref).exists():",
proof="test_guard_populations_derive_from_git.py::test_no_derivation_admits_an_untracked_file",
granularity=CLAUSE,
expect="after git stopped tracking them",
why="THE GUARD IS A TEST, so the mutation goes into the guarded ARTIFACT — one of the "
"derivations it watches — rather than into the checker, per the checker-guard exception. "
"The clause is the exact defect this guard was written after: `derived_guard_files` read "
"its CALLERS from the index and then admitted the paths they name on `Path.exists()`, so a "
"tracked workflow naming a script that exists on one machine only entered the population "
"there, red on that checkout and green in CI (#778's third shape, found by cold review "
"inside #806 itself). Note what this mutation does NOT do: on a clean tree the mutated set "
"is identical, so `test_guard_inventory.py`'s own assertions stay green — only narrowing "
"the index, which is what the proof does, separates them. That is why the proof has to "
"remove EVERY member rather than sample one.",
),
Mutation(
guard="scripts/tests/test_guard_inventory.py",
target="docs/guard-inventory.md",
clause="| `.claude/hooks/decisions-guard.sh` | a commit | GUARD | NONE | — |\n",
replacement="",
proof="test_guard_inventory.py::test_the_inventory_covers_exactly_the_guards_that_exist",
granularity=CLAUSE,
expect="these guard files exist but have no row in guard-inventory.md",
why="THE GUARD IS A TEST, so the mutation goes into the guarded ARTIFACT rather than into the "
"checker — disarming a checker makes it absent, not red, and mutating its population instead "
"would only demonstrate a false POSITIVE (a shrunken population reports every real row as "
"phantom) while proving nothing about the missing-row detection the row claims. A deleted "
"row is the defect this guard exists to catch, and it is one of the mutations #774 witnessed "
"by hand.",
),
Mutation(
guard="scripts/tests/test_hook_fire_log.py",
target="scripts/tests/test_hook_fire_log.py",
clause=" return faults\n\n\ndef strip_instrumentation",
replacement=" return []\n\n\ndef strip_instrumentation",
proof="test_hook_fire_log.py::test_a_hook_that_LOSES_its_instrumentation_is_DETECTED",
granularity=DETECTOR,
expect="left the check GREEN. The check is not load-bearing",
why="NO CLAUSE-LEVEL MUTATION REDDENS THIS ONE, and that is a finding rather than a shortcut. "
"`instrumentation_faults` accumulates from four independent arms and a stripped hook trips "
"three of them at once (no sink source, no ETV_HOOK_FIRE_LIB assignment, no begin call), so "
"disarming any single arm leaves the other two answering and the proof test stays green. The "
"whole detector is therefore the smallest mutation this proof can witness — and the surviving "
"single-arm mutation below is re-run every time so that claim is checked, not recited.",
survived_clause=" if not _SOURCES_SINK.search(text):",
survived_replacement=" if False:",
),
Mutation(
guard="scripts/tests/test_remote_state_inventory.py",
target="docs/remote-state-inventory.md",
clause="| `scripts/post-review-verdict.sh` — commit-status write |",
replacement="| `scripts/DELETED-BY-THE-MUTATION-HARNESS.sh` — not a real path |",
proof="test_remote_state_inventory.py::test_every_in_scope_file_has_a_row_and_every_row_names_a_real_file",
granularity=CLAUSE,
expect="in scope but absent from docs/remote-state-inventory.md",
why="THE GUARD IS A TEST, so the mutation goes into the guarded ARTIFACT: a real executable's "
"row is renamed away, which is the MISSING-row defect the row's Blocks column claims — an "
"in-scope file with no classification. Two shapes were tried and rejected. Emptying the "
"guard's `git ls-files` derivation reddens the proof with an IndexError over an empty "
"population: a crash, not a detection. Planting a PHANTOM row reddens "
"`test_MUTATION_PROOF_a_dropped_row_and_a_phantom_row_are_both_detected` by contaminating "
"the fixture that test builds for itself, and proves the opposite direction from the one the "
"row claims. Renaming the row exercises both directions of the production set comparison at "
"once and is matched on the missing half.",
),
Mutation(
guard="scripts/tests/test_mutation_harness.py",
target="scripts/tests/mutation_harness_lib.py",
clause=" if mutation.expect not in diagnostic:",
replacement=" if False:",
proof="test_mutation_harness.py::test_MUTATION_disarming_the_DIAGNOSTIC_gate_accepts_a_red_for_the_wrong_reason",
granularity=CLAUSE,
expect="the UNMUTATED verdict already accepted it, so the mutant proves nothing",
why="The target is not the guard for a structural reason: the harness keeps its machinery in "
"`mutation_harness_lib.py` so a clause of it can be disarmed in an isolated copy at all. The "
"clause is the DIAGNOSTIC gate — the check that a failing proof failed with the diagnostic "
"its row declares. Disarmed, a red for any unrelated reason is certified as a guard doing "
"its job, which is the shape that made two rows in this very file measure nothing. The other "
"gate, the one requiring pytest exit code 1, carries its own proof in "
"`test_MUTATION_disarming_the_EXIT_STATUS_gate_accepts_a_run_that_NEVER_RAN_A_TEST`; the "
"inventory holds one ref per row, so this entry names the stronger of the two.",
),
)
# ------------------------------------------------------------------------------------------------
# THE OTHER GUARDS — stated per guard, and compared for SET EQUALITY against the inventory
# ------------------------------------------------------------------------------------------------
#
# #790's third Done-when box asks that guards whose mutation cannot be declared be STATED. A reason
# keyed on the row's GRADE would be cheaper and is tautological: a new guard graded NONE inherits one
# automatically and nobody ever looks at that particular guard. A count of them is no better — it
# moves only on net change, so adding one undeclared guard while promoting another leaves it at 22.
#
# So this is keyed on the guard, and `test_every_GUARD_row_is_either_DECLARED_or_STATED_here` asserts
# set equality against the inventory's GUARD rows in both directions. That makes it the same kind of
# hand-maintained-but-machine-checked table as `docs/guard-inventory.md` itself: a new guard cannot
# arrive without someone writing a line here about why it carries no mutation, and a line cannot
# outlive the row it is about.
#
# WHAT `NONE` ACTUALLY MEANS, because the wording matters here: the row nominates no proof ref. It
# does NOT mean the guard is untested. `scripts/ci-prove-ban-detects.sh` is graded NONE and is driven
# end to end by `test_ci_release_path_scan_job.py`. Nominating a proof is a judgement about which
# test is THE proof, which is #775's scope; this file can only verify one afterwards.
UNDECLARED: dict[str, str] = {
# NO GROUPING. An earlier version sorted these into "driven through their deciding path" and
# "not driven at all", and the sort was wrong twice in successive review rounds — in both
# directions, over entries whose own text said the opposite. A category above a list is a second
# classification of the same facts, and it drifts the moment one entry's situation changes. Each
# entry states its own case instead.
#
# THE TWO THINGS THAT GO MISSING ARE DIFFERENT, and which one it is decides where the work goes.
# A guard may be DRIVEN — `test_hook_fire_log.py` executes most hooks through their real deciding
# branch, its matrix asserting instrumentation TRANSPARENCY (the wrapped and unwrapped runs
# agree), never that the decision is right or that a particular clause produced it — and still
# have no NOMINATED proof and no witnessed clause disarm. Nominating one is a judgement about
# which test is THE proof, which is #775's scope; this file can only verify one afterwards. A
# guard nothing executes at all needs the test first.
#
# `NONE` in the inventory means the row nominates no proof ref. It does NOT mean untested.
".claude/hooks/decisions-guard.sh": "Driven to a block and to a pass by test_hook_fire_log.py's "
"constructed cases, which assert transparency rather than the decision. No nominated proof, and "
"no clause disarmed.",
".claude/hooks/prepush-clean-worktree-check.sh": "Driven with a file both modified in the tree "
"and present in the pushed set, by test_hook_fire_log.py, for transparency. No nominated proof, "
"and no clause disarmed.",
".claude/hooks/prepush-donewhen.sh": "Driven against a stub Gitea by test_hook_fire_log.py, so "
"its real blocking path is reached — for transparency. No nominated proof, and no clause "
"disarmed.",
".claude/hooks/prepush-rebase-check.sh": "BEHAVIOUR-ONLY. A named test drives it and "
"test_hook_fire_log.py reaches its behind-origin block, but which clause carries that decision "
"has not been established by disarming one.",
".claude/hooks/pretooluse-agent-ram.sh": "Driven at 5% and 15% free memory through a stubbed "
"`memory_pressure`, by test_hook_fire_log.py, for transparency. No nominated proof, and no "
"clause disarmed.",
".claude/hooks/pretooluse-agent-model.sh": "Driven with and without a `model` in the payload by "
"test_hook_fire_log.py's matrix, for transparency. No nominated proof, and no clause disarmed.",
".claude/hooks/pretooluse-bash-guard.sh": "Driven with an ETV_UPDATE_GOLDENS command and a "
"harmless one by test_hook_fire_log.py's matrix, for transparency. No nominated proof, and no "
"clause disarmed.",
".claude/hooks/pretooluse-nav-guard.sh": "Driven with an `/iptv/` URL by test_hook_fire_log.py's "
"matrix, for transparency. No nominated proof, and no clause disarmed.",
".claude/hooks/design-sync-reminder.sh": "Driven by test_hook_fire_log.py, which gives it its "
"start/finish arguments and works around its self-throttle — for transparency, and never to the "
"one-shot branch that fires on the first Stop after a UI change and then allows. A proof has to "
"model that state transition rather than a single invocation.",
".claude/hooks/pretooluse-merge-consent.sh": "BEHAVIOUR-ONLY. Several suites execute it, but consent "
"is derived from several independent conditions, so which one a given red belongs to has to be "
"established before a clause can be named.",
".husky/commit-msg": "Outside the hook-fire population (that globs `.claude/hooks/*.sh`) and "
"executed by no test: the repositories the suite builds are fresh `git init`s that never install "
"husky, so the hook is absent rather than bypassed. A proof has to install or invoke it.",
".husky/pre-commit": "Runs lint-staged, the decisions guard, the root-PNG check and the format "
"gate. Only the decisions guard has an inventory row of its own — root-PNG and format are INLINE "
"here, so this one row is the whole classification of both, and neither has a proof. Executed by "
"no test, and nothing observes the dispatch itself.",
"scripts/check-kickoff-guard.sh": "Nothing drives it. A proof needs a tree carrying a revived "
"#237 reference, which is cheap and simply not written.",
"scripts/check-review-verdict.sh": "BEHAVIOUR-ONLY. Its named test feeds the real script an "
"input only one clause rejects, which proves it reacts, not that the clause is load-bearing.",
"scripts/ci-detect-already-validated.sh": "Blocks nothing directly — it feeds the skip gate. The "
"consequence a mutation would have to be observed through is a job that skips, which is visible "
"only in a workflow run.",
"scripts/ci-detect-docs-only.sh": "Same shape: it feeds the skip gate rather than blocking, so "
"its effect is visible only in a workflow run, not in this suite.",
"scripts/ci-prove-ban-detects.sh": "Driven end to end by test_ci_release_path_scan_job.py, and "
"it runs a mutation of its own at CI time. Grading it here needs a decision about what a second "
"mutation would add; the row nominates no ref today.",
"scripts/e2e-functional.sh": "Needs a running instance. Its clauses are HTTP contract "
"assertions, so a proof means booting the app — `scripts/e2e-local.sh`'s job, not this "
"harness's.",
"scripts/jq-preflight.sh": "BEHAVIOUR-ONLY. Its named test drives the real script below the "
"version floor; no clause has been disarmed to show the floor comparison is what refuses.",
"scripts/post-review-verdict.sh": "BEHAVIOUR-ONLY, and the most valuable upgrade on this list: "
"it writes the required `review-verdict/h10` status. Its re-read-and-compare has several arms "
"and naming one as THE clause needs the judgement #790 declines to make blind.",
"scripts/pr-changed-files.sh": "BEHAVIOUR-ONLY. Its named test feeds a short page to the real "
"enumeration; the pagination clause has not been disarmed.",
"scripts/tests/test_ci_release_path_scan_job.py": "A GUARD that is a test, so the mutation would "
"have to go into the guarded artifact — the release-path scan job in the workflow. Which "
"weakening of that job is THE defect it exists to catch has not been settled.",
}