test(785): mutation proofs for the three unproven ranked guards (#810)
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 26s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m48s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m14s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m17s
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

Closes the remaining three entries on #785's ranked list with clause-level mutation proofs, each
witnessed red against the real subject in place:

* the `pretooluse-worktree-guard.sh` + `posttooluse-worktree-marker.sh` PAIR — four clauses,
  including the cross-file seam (a clause in the marker hook, asserted against the guard's
  decision) that could not exist while the halves were tested apart;
* `.husky/pre-push:11`'s `unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE` — git exports `GIT_DIR` to
  `pre-push` only from a worktree, which `process.shared-tree-readonly` makes the mandated way to
  work here, so the guarded case is the normal one;
* `scripts/build_decisions_catalog.py --check` — including the `__main__` wiring, which can print
  "is stale" on stderr and still exit 0.

Nine ways the catalog guard can stop gating are detected, judged by executing the step's whole
`run` script rather than by matching lines out of it. Two channels are undecidable outside the
runner and are stated as uncovered rather than guessed at.

Inventory regraded to 12 MUTATION / 6 BEHAVIOUR-ONLY / 16 NONE, with a stated reason for every
remaining NONE row, verified member-for-member against the derived set.

Five cold review rounds; findings closed include production-hook-fire-log corruption, a
tautological assertion, a guard asserting on its helper rather than on the effect, and two false
greens in the workflow extractor. Follow-up: #809.

fixes #785

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
This commit was merged in pull request #810.
This commit is contained in:
2026-08-21 20:10:01 +00:00
committed by timothy
co-authored by Claude Opus 5
parent 649acd12ee
commit 706674272c
4 changed files with 1488 additions and 30 deletions
+122 -30
View File
@@ -75,7 +75,7 @@ all.
| --- | --- | --- | --- | --- |
| `.claude/hooks/decisions-guard.sh` | a commit | GUARD | NONE | — |
| `.claude/hooks/design-sync-reminder.sh` | the first Stop after a UI change (one-shot, then allows) | GUARD | NONE | — |
| `.claude/hooks/posttooluse-worktree-marker.sh` | nothing (writes the marker the worktree guard reads) | GUARD | NONE | |
| `.claude/hooks/posttooluse-worktree-marker.sh` | nothing (writes the marker the worktree guard reads) | GUARD | MUTATION | `test_worktree_ownership_guard.py::test_MUTATION_a_marker_hook_that_stops_WRITING_makes_the_guard_go_quiet` |
| `.claude/hooks/prepush-clean-worktree-check.sh` | a push with uncommitted changes in the pushed set | GUARD | NONE | — |
| `.claude/hooks/prepush-donewhen.sh` | a direct push to `main` with unticked Done-when boxes | GUARD | NONE | — |
| `.claude/hooks/prepush-rebase-check.sh` | a push from a branch behind `origin/main` | GUARD | BEHAVIOUR-ONLY | `test_prepush_rebase_check_tag_exemption.py::test_zero_ref_lines_does_not_exempt` |
@@ -85,11 +85,11 @@ all.
| `.claude/hooks/pretooluse-bom-guard.sh` | a commit/push carrying a BOM in a touched `.cs` | GUARD | MUTATION | `test_bom_guard_detection.py::test_DISARMING_the_BOM_comparison_stops_detection` |
| `.claude/hooks/pretooluse-merge-consent.sh` | a PR merge without derived consent | GUARD | BEHAVIOUR-ONLY | `test_merge_consent_exemption.py::test_protected_path_on_a_LATER_page_is_still_seen` |
| `.claude/hooks/pretooluse-nav-guard.sh` | a browser navigate to a streaming URL | GUARD | NONE | — |
| `.claude/hooks/pretooluse-worktree-guard.sh` | a commit/merge in a foreign worktree | GUARD | NONE | |
| `.claude/hooks/pretooluse-worktree-guard.sh` | a commit/merge in a foreign worktree | GUARD | MUTATION | `test_worktree_ownership_guard.py::test_MUTATION_disarming_the_guards_MARKER_READ_stops_the_deny` |
| `.husky/commit-msg` | a commit with no `Co-Authored-By` trailer | GUARD | NONE | — |
| `.husky/pre-commit` | a commit failing lint-staged, decisions, root-PNG or format | GUARD | NONE | — |
| `.husky/pre-push` | a push failing any pre-push hook or the SPA gate | GUARD | NONE | |
| `scripts/build_decisions_catalog.py` | the `decisions-guard` job, on a stale catalog | GUARD | NONE | |
| `.husky/pre-push` | a push failing any pre-push hook or the SPA gate | GUARD | MUTATION | `test_prepush_unsets_git_env.py::test_MUTATION_DELETING_the_unset_lets_drift_through_silently` |
| `scripts/build_decisions_catalog.py` | the `decisions-guard` job, on a stale catalog | GUARD | MUTATION | `test_build_catalog_check_path.py::test_MUTATION_disarming_the_stale_comparison_stops_detection` |
| `scripts/check-kickoff-guard.sh` | the `decisions-guard` job, on a revived #237 reference | GUARD | NONE | — |
| `scripts/check-review-verdict.sh` | the merge-consent hook's verdict classification | GUARD | BEHAVIOUR-ONLY | `test_check_review_verdict.py::test_falseopen_token_must_be_a_whole_word` |
| `scripts/ci-detect-already-validated.sh` | nothing directly (feeds the skip gate) | GUARD | NONE | — |
@@ -109,6 +109,7 @@ all.
| `scripts/update-openapi.sh` | nothing (regenerates the spec) | TOOLING | NONE | — |
| `scripts/tests/test_bom_guard_detection.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_build_catalog.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_build_catalog_check_path.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_check_review_verdict.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_ci_dropped_step_guard.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_ci_image_pin_population.py` | the `script-tests` job, when a container job loses its pin | GUARD | MUTATION | `test_ci_image_pin_population.py::test_a_single_job_losing_its_pin_is_DETECTED` |
@@ -123,15 +124,17 @@ all.
| `scripts/tests/test_merge_consent_required_check.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_migration_equivalence.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_post_review_verdict.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_prepush_unsets_git_env.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_pr_changed_files.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_prepush_rebase_check_tag_exemption.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_prove_fix.py` | the `script-tests` job | PROOF | NONE | — |
| `scripts/tests/test_remote_state_inventory.py` | the `script-tests` job, on an executable that talks to a remote service with no row in `docs/remote-state-inventory.md` | GUARD | MUTATION | `test_remote_state_inventory.py::test_MUTATION_PROOF_a_dropped_row_and_a_phantom_row_are_both_detected` |
| `scripts/tests/test_worktree_ownership_guard.py` | the `script-tests` job | PROOF | NONE | — |
## What the numbers say
34 guards, 5 tooling scripts, 15 proof files. **8 guards carry a mutation proof; 6 are
behaviour-only; 20 have none.** These figures are asserted against the table by
34 guards, 5 tooling scripts, 18 proof files. **12 guards carry a mutation proof; 6 are
behaviour-only; 16 have none.** These figures are asserted against the table by
`test_the_summary_counts_match_the_table` — they were wrong in the first draft (28/4/6/3/19 against
a table holding 27/5/6/3/18), because a hand-maintained summary of a table is a second copy of it,
which is the duplication family this change argues against. Both cold reviewers found the error
@@ -186,41 +189,130 @@ lines flagged wrongly — and cold review then constructed more of both (`[[ "$x
a backslash continuation, a `>` inside a quoted string). Deleted rather than patched a fifth time,
on the same reasoning as the vocabulary-parity withdrawal above.
**`test_hook_fire_log.py` asserts TWO clauses
**`test_hook_fire_log.py` proves THREE clauses, and its row claims two of them.** Coverage (every
hook reports that it fired) via `test_a_hook_that_LOSES_its_instrumentation_is_DETECTED`;
transparency (the wrapper changes nothing the harness can see) via
`test_instrumentation_changes_NOTHING_the_harness_can_see` with its mutation proof
`test_DELETING_the_replay_makes_the_differential_go_RED`; and placement (`etv_hook_fire_begin` must
precede the stdin read) via `test_begin_placed_AFTER_the_stdin_read_is_DETECTED`. The row's `Blocks`
column covers the first two — "stops reporting that it fired, **or whose reporting changes what the
harness sees**" — and the `Proof ref` column holds one ref because the column holds one, not because
the second is unproven. Placement is proved and unclaimed.
**Its `test_the_suite_does_not_write_to_the_PRODUCTION_log` is narrower than its docstring**, which
says `conftest.py` "must isolate every test, not just this file's". What it checks is that the
fixture set `ETV_HOOK_FIRE_LOG_DIR` *for the test currently running* and that a hook it drives in
its own sandbox does not touch the real log. It cannot see another suite that snapshots
`os.environ` at IMPORT time — before the autouse fixture runs — and hands that stale mapping to its
subprocesses. That suite's hooks then write to `$HOME/.cache/ersatztv/hook-fire/` while every
assertion stays green, because the fire-log library is fail-open. #785 shipped exactly that defect,
inside the file added to prove those very hooks.
The reproduction is the part worth keeping: restore a module-level `{**os.environ}` snapshot — or
leave the helper correct and point a single `env=` argument at one — then run that file and count
records for its synthetic session ids. **58 per run**, measured identically on macOS/git 2.55 and
Linux/git 2.47.3. (The accumulated total observed before the fix ran to four figures across many
runs; that is an observation rather than a reproducible measurement, so check the per-run figure.)
The pin is `test_worktree_ownership_guard.py::test_driving_a_hook_LANDS_its_records_in_the_ISOLATED_dir`,
which asserts the EFFECT — records land in the fixture's dir — rather than the shape of the fix. Its
predecessor asserted the helper's return value, and cold review showed that leaves the call site
unguarded: `_env()` correct, one `env=` reverted, all tests green, records still leaking. Pinning to
a hand-written revert rather than to the property is `verify-against-the-REAL-predecessor`.
It is still narrower than the property: it guards the launch path it drives. A second launcher in
the same file that passed a stale environment would leave it green — measured, 18 records — because
the hooks it drives would still log correctly. Every hook in that file goes through one helper
today, which is what makes it sufficient there. Generalising it is #809, and the reason that is hard
is that the obvious version races against a real session's hooks firing during the run.
**That unproven set carries no number here on purpose.** It restated the count, drifted the moment
the BOM guard was regraded, and `test_the_summary_counts_match_the_table` cannot see it — the parser
checks the formatted summary sentence and nothing else. A second hand-maintained copy of a number is
the duplication family this file argues against, so the copy is removed rather than corrected: the
set is every `PreToolUse` hook except merge-consent and the BOM guard, and the count is in the
summary above.
set is the eight `.claude/hooks/` guards enumerated in the standing list below, and the count is in
the summary above. Naming them by event is what made the earlier wording wrong twice — they do not
share one event, and `design-sync-reminder.sh` is registered on **both** `PreToolUse` and `Stop` in
`.claude/settings.json`, so any "every X hook" phrasing double-counts it.
**They are now observable but still unproven, and the two words carry different weight.** Observable:
every hook records its own execution through `scripts/hook-fire-log.sh`, so "did this hook fire, and
what did it decide" is a measurement — run `scripts/hook-fire-log.sh report` (#776). Unproven:
nobody has demonstrated any of them is load-bearing, which is what #785 tracks. Observability tells
you a guard ran; only a mutation tells you it would have caught anything. The BOM guard is the case
that shows why the distinction matters — it was firing on every commit the whole time it was
fail-open.
nobody has demonstrated any of them is load-bearing — the first group in the standing list below.
Observability tells you a guard ran; only a mutation tells you it would have caught anything. The
BOM guard is the case that shows why the distinction matters — it was firing on every commit the
whole time it was fail-open.
The gaps are not uniform in cost, and the ranking that matters is *what a silent failure would let
through*, not test count:
through*, not test count. **All four ranked entries now carry clause-level mutation proofs (#785);
they are kept here with what each mutation established, because the ranking is the reusable part and
because two of them turned out to be worse than the ranking predicted.**
1. ~~`pretooluse-bom-guard.sh`~~**now proven, and it was fail-open the whole time.** Ranked
first here because the defect it guards has recurred three times (#311, #402, #405); that
ranking turned out to be right for a worse reason than intended. It detected a BOM with
`xxd -p`, and `xxd` ships with vim and is **absent on the Linux CI runner**, so the comparison
never matched and every BOM was allowed in silence. `od` now, with a clause-level mutation proof.
The lesson for the rows below: an unproven guard is not merely untested, it is a guard whose
*current* behaviour nobody has established.
2. `pretooluse-worktree-guard.sh` + `posttooluse-worktree-marker.sh` — a two-file mechanism guarding
#289, where a regression in either half is invisible and the two halves have never been tested
together.
3. `.husky/pre-push:11`'s `unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE` — a one-line fix for a real
bug (a nested `git diff --exit-code` silently reporting no diff) that nothing pins. Reordering it
after the nested git calls reintroduces the bug silently.
4. `scripts/build_decisions_catalog.py` — its `--check` path is what CI runs and no test calls
`main()` at all; the tests exercise `render_catalog()` directly.
1. ~~`pretooluse-bom-guard.sh`~~**proven, and it was fail-open the whole time.** Ranked first
because the defect it guards has recurred three times (#311, #402, #405); that ranking turned out
to be right for a worse reason than intended. It detected a BOM with `xxd -p`, and `xxd` ships
with vim and is **absent on the Linux CI runner**, so the comparison never matched and every BOM
was allowed in silence. `od` now. The lesson for the rows below: an unproven guard is not merely
untested, it is a guard whose *current* behaviour nobody has established.
2. ~~`pretooluse-worktree-guard.sh` + `posttooluse-worktree-marker.sh`~~**proven as a pair.**
Four clauses were disarmed and witnessed red: the guard's marker read; the guard's ownership
comparison (inverted, because disarming it the other way only makes the guard deny more and every
deny assertion stays green); the `commit|merge` alternation, whose `merge` half every other case
in the file left untested while guarding the plumbing-merge path; and — the one that could not
exist while the halves were tested apart — the *marker hook's write*, asserted against the
*guard's* decision. Both hooks are
deliberately fail-open, so an absent mechanism and a working one produce the identical "commit
allowed".
3. ~~`.husky/pre-push:11`'s `unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE`~~ — **proven, and the case
is the normal one rather than an edge.** Git exports `GIT_DIR` to `pre-push` **when the push
comes from a worktree** and not from the main tree; `process.shared-tree-readonly` makes the
worktree the mandated way to work here, so every push takes the exposed path. With `GIT_DIR` set
and `GIT_WORK_TREE` unset git stops discovering the repo and treats the *current directory* as
the work tree, so `cd web && npm run check:api`'s `git diff --exit-code` compares against index
paths that do not exist and reports no diff. Both the deletion and the relocation are proved.
4. ~~`scripts/build_decisions_catalog.py`~~**proven, including the wiring.** The `--check`
comparison is mutated directly, and a separate subprocess case runs the command *derived from
`pr-checks.yml`* against a copied corpus. That second case is not redundant: replacing
`raise SystemExit(main())` with a bare `main()` leaves the script printing
`docs/decisions/README.md is stale` on stderr while exiting **0**, and the workflow step reads
nothing but the exit code. Only the subprocess case reddens — the #751/#719 shape.
Filling these is tracked rather than done in one pass, deliberately: a mutation proof written to
**What a file-level `MUTATION` grade does and does not claim, because three of these four rows are
multi-purpose files.** The grade covers *the clause the cited case actually mutates*, per
`testing.guard-ships-with-mutation-proof`; it is not a statement about every line in the file.
`.husky/pre-push` runs four other things, and its row asserts only that line 11 is load-bearing —
the three hooks it invokes carry their own rows and their own grades. Reading the row as "pre-push
is tested" is the same relabelling error the withdrawn-guard table above exists to prevent.
Clause-level grading is tracked in #790.
**The remaining unproven guards, and why each is still `NONE` rather than merely unattended** — the
third `## Done-when` box of #785, answered here rather than left implicit. They fall into three
groups, and the split is the point: the first two are backlogs, the third is not.
- **The eight remaining hook guards** — genuinely unproven, and a real backlog. Four are
`PreToolUse` (`pretooluse-agent-model.sh`, `pretooluse-agent-ram.sh`, `pretooluse-bash-guard.sh`,
`pretooluse-nav-guard.sh`), two are pre-push (`prepush-clean-worktree-check.sh`,
`prepush-donewhen.sh`), one is pre-commit (`decisions-guard.sh`) and one is registered on both
`PreToolUse` and `Stop` (`design-sync-reminder.sh`); they are grouped by their status, not by
their event, because the events do not partition them. They are now
*observable* (`scripts/hook-fire-log.sh report`, #776), which is a weaker claim than proven and is
stated as such above.
- **`.husky/commit-msg` and `.husky/pre-commit`** — unproven, and each carries its own clause, so
they are a backlog too rather than "covered by what they call". `pre-commit` dispatches to
`lint-staged` and `decisions-guard.sh`, but the root-level-`*.png` refusal and the
`dotnet format whitespace --verify-no-changes` block are its own. `commit-msg` is not a dispatcher
at all: it is one `grep -q '^Co-Authored-By:'` plus a `MERGE_HEAD` exemption, and nothing tests
that the exemption fires only for merges. Neither is covered by what it calls — the trap is to read
"it dispatches to guards" as "its own clauses are guarded".
- **`ci-detect-already-validated.sh`, `ci-detect-docs-only.sh`, `ci-prove-ban-detects.sh`,
`e2e-functional.sh`, `check-kickoff-guard.sh`, `test_ci_release_path_scan_job.py`** — not
unattended either, but each needs its own harness rather than a proof written to clear a row.
`ci-prove-ban-detects.sh` is the exception noted in `testing.guard-ships-with-mutation-proof`: it
runs its own mutation at CI time, because what it proves is disarmable from inside pytest.
That list is prose and nothing checks it, which is the honest limit — a guard moving out of a group
will not redden anything. It is here so the next session inherits *why* a row is `NONE`, which is
the distinction #785 asked for; the machine-checked half remains the table.
Filling the rest is tracked rather than done in one pass, deliberately: a mutation proof written to
close a row is the kind of test that passes for the wrong reason.
@@ -0,0 +1,546 @@
"""`scripts/build_decisions_catalog.py`'s `--check` path — the one CI actually runs.
`decisions-guard` (`.gitea/workflows/pr-checks.yml`, the "Active catalog in sync" step) runs
`python3 scripts/build_decisions_catalog.py --check`. `scripts/tests/test_build_catalog.py` covers
`render_catalog()` directly and never calls `main()` at all, so nothing there proves:
* that `main()`'s stale-detection comparison (`want.strip() != have.strip()`) is load-bearing —
a version that always agreed would pass every existing test;
* that the `if __name__ == "__main__": raise SystemExit(main())` wiring actually turns a stale
catalog into a non-zero process exit code, which is the only thing CI's `run:` step reads.
`testing.guard-ships-with-mutation-proof` (#775) is explicit that a guard is not tested because a
test *involving* it passes: it ships with a mutation proof — disarm the guard's own clause, alone,
and a named test must go red. This file is that proof for the catalog-guard, plus the subprocess
proof that the `__main__` wiring is connected (the #751/#719 shape the decision record names: a
green `main()` behind dead wiring).
"""
from __future__ import annotations
import importlib.util
import re
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
import scripts.build_decisions_catalog as bc
import scripts.decisions_lib as dl
REPO_ROOT = Path(__file__).resolve().parents[2]
CATALOG_SCRIPT = REPO_ROOT / "scripts" / "build_decisions_catalog.py"
DECISIONS_LIB = REPO_ROOT / "scripts" / "decisions_lib.py"
SCRIPTS_INIT = REPO_ROOT / "scripts" / "__init__.py"
def _current_catalog_text() -> str:
return bc.render_catalog(dl.all_active_records())
# ------------------------------------------------------------------------------------------------
# ANTI-VACUITY — if the real corpus is empty, every assertion below passes for nothing.
# ------------------------------------------------------------------------------------------------
def test_the_real_corpus_is_non_empty_and_renders_a_real_catalog():
records = dl.all_active_records()
active = [r for r in records if r.status == "active" and r.key]
assert active, "no active decision records were parsed — every test below would be vacuous"
text = _current_catalog_text()
assert bc.BANNER in text, "render_catalog produced no banner — not a real catalog document"
assert f"`{active[0].key}`" in text, (
"render_catalog produced no row for a known active record — not a real catalog document"
)
# ------------------------------------------------------------------------------------------------
# `main(["--check"])` — the comparison CI reads
# ------------------------------------------------------------------------------------------------
def test_check_returns_0_when_OUTPUT_matches_render_catalog(tmp_path, monkeypatch):
fresh = _current_catalog_text()
output = tmp_path / "README.md"
output.write_text(fresh.rstrip("\n") + "\n", encoding="utf-8")
monkeypatch.setattr(bc, "OUTPUT", output)
assert bc.main(["--check"]) == 0
def test_check_returns_1_when_OUTPUT_is_stale(tmp_path, monkeypatch):
fresh = _current_catalog_text()
output = tmp_path / "README.md"
# Append a line: the on-disk file no longer matches what render_catalog would produce.
output.write_text(fresh.rstrip("\n") + "\nEXTRA STALE LINE\n", encoding="utf-8")
monkeypatch.setattr(bc, "OUTPUT", output)
assert bc.main(["--check"]) == 1
def test_generate_then_check_round_trips(tmp_path, monkeypatch):
"""The no-argument path WRITES the catalog, and a subsequent --check must then pass.
This pins the property CI depends on: generate and check agree. If they ever diverged, `main([])`
would produce a file that `main(["--check"])` immediately rejects — a self-contradiction that
would make the generator useless for fixing the exact problem `--check` reports.
"""
output = tmp_path / "README.md"
assert not output.exists()
monkeypatch.setattr(bc, "OUTPUT", output)
assert bc.main([]) == 0
assert output.exists(), "main([]) with no --check must write OUTPUT"
written = output.read_text(encoding="utf-8")
assert written.strip() == _current_catalog_text().strip()
assert bc.main(["--check"]) == 0, "the file main([]) just wrote must satisfy main(['--check'])"
# ------------------------------------------------------------------------------------------------
# THE `__main__` WIRING — proof CI's subprocess invocation actually surfaces staleness
# ------------------------------------------------------------------------------------------------
def _seed_decisions_copy(root: Path) -> None:
"""Copy only what build_decisions_catalog.py + decisions_lib.py need to resolve a real corpus."""
(root / "scripts").mkdir(parents=True)
(root / "scripts" / "__init__.py").write_bytes(SCRIPTS_INIT.read_bytes())
(root / "scripts" / "build_decisions_catalog.py").write_bytes(CATALOG_SCRIPT.read_bytes())
(root / "scripts" / "decisions_lib.py").write_bytes(DECISIONS_LIB.read_bytes())
docs = root / "docs"
docs.mkdir()
(docs / "decisions.md").write_bytes((REPO_ROOT / "docs" / "decisions.md").read_bytes())
dst_decisions = docs / "decisions"
src_decisions = REPO_ROOT / "docs" / "decisions"
dst_decisions.mkdir()
for item in src_decisions.iterdir():
if item.is_dir():
_copy_tree(item, dst_decisions / item.name)
else:
(dst_decisions / item.name).write_bytes(item.read_bytes())
def _copy_tree(src: Path, dst: Path) -> None:
dst.mkdir(parents=True, exist_ok=True)
for item in src.rglob("*"):
rel = item.relative_to(src)
target = dst / rel
if item.is_dir():
target.mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(item.read_bytes())
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "pr-checks.yml"
def _active_runs(workflow: Path | None = None) -> list[str]:
"""The `run` script of every step the `decisions-guard` job would ACTUALLY execute.
Parsed with `yaml.safe_load`, and returned WHOLE — not split into lines. Both choices are
scar tissue.
Text-scanning for `run:` was round one, and cold review broke it three ways: a `run: |` block
scalar was invisible; a job or step switched off still read as wired; and `run:` inside
block-scalar *text* was extracted and executed. Round two parsed the YAML and matched a LINE
beginning with `PYTHONPATH=.` — and review broke that too, with a heredoc:
run: |
cat <<'EOF' > /dev/null
PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
EOF
The matched line is heredoc DATA. The extractor reported the guard as running, and the proof
executed a command CI does not. Deciding which lines of a shell script are executed requires
parsing shell, and `fixing-a-parser-bug-introduces-the-next-one` is explicit that this repo has
lost that argument repeatedly — a regex over shell is not a parser, and round four would find
round five.
So the line-level heuristic is WITHDRAWN. The whole `run` script is handed to `bash`, exactly as
the runner does. The heredoc above then runs, writes to `/dev/null`, checks nothing, and exits
0 — so the stale-catalog case fails to redden and the proof reports the defect instead of
stepping around it. No shell parsing, and the ambiguous cases resolve by execution.
"""
doc = yaml.safe_load((workflow or WORKFLOW).read_text())
# The workflow's TRIGGERS, before its jobs. A `decisions-guard` job that is perfectly healthy
# gates nothing if the workflow stopped running on pull requests, and starting at `jobs:` cannot
# see that. Note `on` parses to the boolean True in YAML 1.1 (the Norway problem's cousin), so
# the key is looked up both ways rather than assumed.
triggers = (doc or {}).get("on", (doc or {}).get(True)) or {}
names = set(triggers) if isinstance(triggers, dict) else {triggers} if isinstance(triggers, str) else set(triggers)
assert "pull_request" in names, (
"pr-checks.yml no longer runs on `pull_request`, so NO gate in it — including the catalog "
f"guard — fires on a PR. Triggers found: {sorted(str(n) for n in names)}"
)
jobs = (doc or {}).get("jobs") or {}
job = jobs.get("decisions-guard")
assert job is not None, (
"no `decisions-guard` job in pr-checks.yml. Either it was renamed or it was removed — the "
f"second is the far more serious finding. Jobs present: {sorted(jobs)}"
)
assert not _disabled(job), (
"the `decisions-guard` job is disabled at the job level "
f"(if: {job.get('if')!r}, continue-on-error: {job.get('continue-on-error')!r}), so nothing "
"in it runs — including the catalog guard"
)
return [str(step["run"]) for step in (job.get("steps") or []) if step.get("run") and not _disabled(step)]
def _unwrap(value: str) -> str:
"""Strip an `${{ ... }}` expression wrapper, if present, and lowercase.
Written as a regex over the WHOLE value rather than `.strip("${{ }}")`, which strips a character
SET — it would turn `"false}"` into `"false"` and reads as though it removed a wrapper it never
checked for.
"""
inner = value.strip()
m = re.fullmatch(r"\$\{\{(.*)\}\}", inner, flags=re.DOTALL)
if m:
inner = m.group(1)
return inner.strip().lower()
def _falsey(value) -> bool:
"""A literal false, however this workflow dialect spells it.
`if: false`, `if: "false"` and `if: ${{ false }}` all mean never. The middle and last are the
ones a text comparison misses; the last was a live false green — `${{ false }}` is the ordinary
spelling in Actions-flavoured YAML, and it read as wired.
An expression that is merely falsy AT RUN TIME (`if: ${{ github.event_name == 'x' }}`) is not
decidable here and is deliberately not guessed at.
"""
if value is False:
return True
if not isinstance(value, str):
return False
return _unwrap(value) == "false"
def _truthy_literal(value) -> bool:
if value is True:
return True
if not isinstance(value, str):
return False
return _unwrap(value) == "true"
def _disabled(node: dict) -> bool:
"""A job or step that cannot fail the run: switched off, or allowed to fail.
`continue-on-error: true` is the subtle one — the step still runs and still reports, but its
failure does not fail the job, so it is not a gate.
"""
return _falsey(node.get("if", True)) or _truthy_literal(node.get("continue-on-error", False))
def _ci_check_command(workflow: Path | None = None) -> list[str]:
"""The catalog step's script, DERIVED from the workflow and executed whole.
`testing.guard-derives-population-from-source`: a hand-copied command is a second copy of the
workflow that drifts silently, and this test's whole value is that it runs what CI runs.
"""
runs = _active_runs(workflow)
matches = [r for r in runs if "build_decisions_catalog.py" in r and "--check" in r]
assert matches, (
"the `decisions-guard` job has no ACTIVE step mentioning "
"`build_decisions_catalog.py --check`. The catalog guard has stopped running in CI — that "
f"is the finding, not this test's failure. Active step scripts in that job: {runs}"
)
assert len(matches) == 1, f"expected exactly one such step, found {matches}"
script = matches[0]
assert "${{" not in script, (
"the catalog step's script interpolates an Actions expression, which cannot be expanded "
f"outside the runner — this proof would be executing something else: {script!r}"
)
# Substitute the interpreter only where `python3` is a bare command word. A plain
# `str.replace` rewrites EVERY occurrence, including inside a path — `/usr/bin/python3` would
# become `/usr/bin/<venv>/bin/python3` and fail with ENOENT, a red blaming the catalog guard for
# something this line did.
return ["bash", "-c", re.sub(r"(?<![\w/])python3\b", sys.executable, script)]
def _proof_holds(workflow: Path, repo: Path) -> bool:
"""Does the whole guarantee hold — fresh corpus passes AND stale corpus fails?
The disablement cases below assert on THIS rather than on whether extraction raises, because
the ways a guard can stop gating do not all surface at the same place. Deletion and disablement
surface as a failed extraction; a heredoc or an `echo` surfaces only when the script is run and
reports success over a stale catalog. One predicate covers both.
"""
try:
cmd = _ci_check_command(workflow)
except AssertionError:
return False
readme = repo / "docs" / "decisions" / "README.md"
original = readme.read_text(encoding="utf-8")
try:
readme.write_text(original, encoding="utf-8")
fresh = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, timeout=60)
if fresh.returncode != 0:
return False
readme.write_text(original + "\nSTALE INJECTED LINE\n", encoding="utf-8")
stale = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, timeout=60)
return stale.returncode != 0
finally:
readme.write_text(original, encoding="utf-8")
_STEP = (
" - name: Active catalog in sync\n"
" run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n"
)
@pytest.mark.parametrize(
"label,mutate",
[
(
"the step is COMMENTED OUT",
lambda s: s.replace(
_STEP,
" # - name: Active catalog in sync\n"
" # run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n",
1,
),
),
("the step is DELETED", lambda s: s.replace(_STEP, "", 1)),
(
"the step is switched off with `if: false`",
lambda s: s.replace(
_STEP,
" - name: Active catalog in sync\n if: false\n"
" run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n",
1,
),
),
(
"the step is switched off with `if: ${{ false }}`",
lambda s: s.replace(
_STEP,
" - name: Active catalog in sync\n if: ${{ false }}\n"
" run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n",
1,
),
),
(
"the step is allowed to fail with `continue-on-error: true`",
lambda s: s.replace(
_STEP,
" - name: Active catalog in sync\n continue-on-error: true\n"
" run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n",
1,
),
),
(
"the whole JOB is switched off with `if: false`",
lambda s: s.replace(
" decisions-guard:\n name: decisions lifecycle\n runs-on: small\n"
" if: github.event_name == 'pull_request'\n",
" decisions-guard:\n name: decisions lifecycle\n runs-on: small\n if: false\n",
1,
),
),
(
"the command survives only as TEXT in another step's `echo`",
lambda s: s.replace(
_STEP,
" - name: Note\n run: |\n"
" echo we no longer run: PYTHONPATH=. python3 "
"scripts/build_decisions_catalog.py --check\n",
1,
),
),
(
"the WORKFLOW no longer runs on pull requests",
lambda s: s.replace("on:\n pull_request:", "on:\n workflow_dispatch:", 1),
),
(
"the command survives only as HEREDOC DATA",
lambda s: s.replace(
_STEP,
" - name: Note\n run: |\n"
" cat <<'EOF' > /dev/null\n"
" PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n"
" EOF\n",
1,
),
),
],
)
def test_a_guard_that_stopped_RUNNING_is_DETECTED(tmp_path, label, mutate):
"""Eight ways the catalog guard can stop gating, each of which must be caught.
Commenting out is one of them and was the only one the first version detected. The last two
leave the command in the file, parseable and even matchable — as `echo` argument and as heredoc
data — which is why the proof executes the step's whole script instead of a line lifted out of it.
"""
raw = WORKFLOW.read_text()
mutated = mutate(raw)
assert mutated != raw, f"the mutation for {label!r} matched nothing; RETARGET it"
alt = tmp_path / "pr-checks.yml"
alt.write_text(mutated)
repo = tmp_path / "repo-copy"
repo.mkdir()
_seed_decisions_copy(repo)
assert not _proof_holds(alt, repo), (
f"the catalog guard still reported as gating when {label}. CI would run nothing and this "
"file would report full coverage."
)
@pytest.mark.parametrize(
"label,replacement",
[
(
"block scalar",
" - name: Active catalog in sync\n run: |\n"
" PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n",
),
(
"backslash continuation",
" - name: Active catalog in sync\n run: |\n"
" PYTHONPATH=. python3 \\\n"
" scripts/build_decisions_catalog.py --check\n",
),
(
"a leading `set -euo pipefail`",
" - name: Active catalog in sync\n run: |\n"
" set -euo pipefail\n"
" PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n",
),
(
"wrapped in a shell block",
" - name: Active catalog in sync\n run: |\n"
" if true; then\n"
" PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n"
" fi\n",
),
],
)
def test_the_step_may_be_REFORMATTED_without_being_flagged(tmp_path, label, replacement):
"""The negative controls for the eight above: a legitimate rewrite must NOT be flagged.
`run: |` is idiomatic in this very job (the sibling "Validate decision lifecycle" step uses it),
and the text-scanning version reported that form as "the guard has stopped running in CI" — a
false red whose message asserts a regression that has not happened. A detector that cannot tell
a reformat from a removal trains its readers to ignore it, which is the whole subject of #806.
A line-matching version had to be taught each of these shapes one at a time, and the
continuation case defeated two rounds of it. Executing the script gets all four for free: the
question "is the guard still gating" is answered by running it, not by recognising how it was
written.
"""
raw = WORKFLOW.read_text()
mutated = raw.replace(_STEP, replacement, 1)
assert mutated != raw, "retarget this reformatting; the step's text has changed"
alt = tmp_path / "pr-checks.yml"
alt.write_text(mutated)
repo = tmp_path / "repo-copy"
repo.mkdir()
_seed_decisions_copy(repo)
assert _proof_holds(alt, repo), f"a legitimate reformat ({label}) was reported as a removal"
def test_CLI_subprocess_exits_nonzero_on_a_stale_catalog(tmp_path):
"""The real CI invocation, as a subprocess, against a real corpus.
This is the `__main__` → `SystemExit(main())` wiring proof: `main()` returning 1 is worthless if
the process still exits 0, and the workflow step reads nothing but the exit code. It is the
#751/#719 shape the decision record names — a green result behind wiring that is not connected.
The FRESH half is not optional decoration. Without it this test passes whenever the subprocess
dies for any reason at all — an import error, a missing file in the copy, a syntax error — none
of which is the guard detecting anything. `arbitrary-sample-gives-false-negatives` in reverse:
a non-zero exit is only evidence when the same harness is shown to exit zero on a clean corpus.
"""
repo = tmp_path / "repo-copy"
repo.mkdir()
_seed_decisions_copy(repo)
cmd = _ci_check_command()
fresh = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, timeout=60)
assert fresh.returncode == 0, (
"the copied corpus does not even pass --check when untouched, so a non-zero exit below "
f"would be the harness failing rather than the guard firing: {fresh.stdout!r} {fresh.stderr!r}"
)
readme = repo / "docs" / "decisions" / "README.md"
readme.write_text(readme.read_text(encoding="utf-8") + "\nSTALE INJECTED LINE\n", encoding="utf-8")
stale = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, timeout=60)
assert stale.returncode != 0, (
"the CLI wiring did not surface a stale catalog as a non-zero exit — CI would report "
f"green over a stale README.md: stdout={stale.stdout!r} stderr={stale.stderr!r}"
)
# ------------------------------------------------------------------------------------------------
# THE MUTATION PROOF — disarm `main()`'s stale-detection clause alone, --check must stop detecting
# ------------------------------------------------------------------------------------------------
def _load_mutated_module(tmp_path: Path, mutated_source: str):
mutated_path = tmp_path / "mutated_build_decisions_catalog.py"
mutated_path.write_text(mutated_source, encoding="utf-8")
spec = importlib.util.spec_from_file_location("mutated_build_decisions_catalog", mutated_path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_MUTATION_disarming_the_stale_comparison_stops_detection(tmp_path, monkeypatch):
"""`testing.guard-ships-with-mutation-proof` (#775).
`main()`'s ONLY stale-detection logic is the clause `want.strip() != have.strip()`. Disarm that
clause alone — replace it with the constant `False` in an isolated copy of the module, changing
nothing else — and `--check` must stop reporting staleness. If it still returns 1, the deny is
coming from somewhere other than the clause the guard is supposed to hang on, and every test
above proves nothing about it.
"""
text = CATALOG_SCRIPT.read_text(encoding="utf-8")
clause = "want.strip() != have.strip()"
assert clause in text, (
"the stale-detection clause has moved or been reworded; RETARGET this mutation at its new "
"location rather than loosening the string match — a mutation that silently stops mutating "
"is the exact failure this file exists to catch"
)
mutated_source = text.replace(clause, "False", 1)
assert mutated_source != text and clause not in mutated_source, (
"the replacement did not change the source, so the mutant is the subject"
)
fresh = _current_catalog_text()
output = tmp_path / "README.md"
output.write_text(fresh.rstrip("\n") + "\nEXTRA STALE LINE\n", encoding="utf-8")
# POSITIVE CONTROL FIRST. Without this, the mutation assertion below would pass just as well if
# `main(["--check"])` never detected anything at all on this input — "the mutant is silent"
# proves nothing unless the real guard is first shown to be loud on the exact same input.
monkeypatch.setattr(bc, "OUTPUT", output)
assert bc.main(["--check"]) == 1, (
"the UNMUTATED guard did not detect the staleness on this input, so a silent mutant below "
"would prove nothing about the clause"
)
mutant = _load_mutated_module(tmp_path, mutated_source)
mutant.OUTPUT = output
assert mutant.main(["--check"]) == 0, (
"disarming `want.strip() != have.strip()` alone did not stop --check from reporting "
"staleness, so that clause is not what the guard's exit code hangs on"
)
@@ -0,0 +1,330 @@
"""`.husky/pre-push` line 11 — `unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE` — is load-bearing.
That one line is the fix for a real, silent defect, and ersatztv#785 ranks it third because nothing
pinned it: reordering it after the nested git calls, or dropping it in a tidy-up, reintroduces the
bug with no symptom at all. The failure is a check reporting SUCCESS, which is the family
`testing.guard-ships-with-mutation-proof` exists for.
**The mechanism, measured rather than asserted.** Measured on both platforms this suite runs on,
because the guard it replaces was fail-open for months on exactly the platform nobody measured:
macOS/git 2.55 (development) and Linux/git 2.47.3 (the `script-tests` runner host). Identical on
both — `GIT_DIR` exported, `show-toplevel` answering `<wt>/web`, the nested diff reporting exit 0.
The whole file was run green there, and the deletion mutation was witnessed red there:
* Git exports `GIT_DIR` to `pre-push` **when the push comes from a worktree** — e.g.
`GIT_DIR=/repo/.git/worktrees/wt`. From the main tree it exports nothing, which is why this
never bites in a plain checkout and why it bites here constantly: `process.shared-tree-readonly`
makes working in a worktree the mandated path, so the exported-`GIT_DIR` case is the NORMAL one.
* With `GIT_DIR` set and `GIT_WORK_TREE` unset, git stops discovering the repo and takes the
**current directory** as the work tree. `git rev-parse --show-toplevel` from `web/` answers
`/repo/wt/web`.
* So `pre-push`'s last line — `cd web && npm run check:api`, whose `check:api` ends in
`git diff --exit-code` — compares against index paths that do not exist under that root. It
reports **no diff and exits 0**. Generated-API drift ships, and the gate that exists to catch it
prints success.
The test drives the REAL `.husky/pre-push` file, unedited, in a real worktree, with the environment
git really exports. What is substituted is only what surrounds it: the three `.claude/hooks` calls
are stubs (they are separately guarded and are not the subject), and `npm` is a stub on PATH whose
`run check:api` performs the nested `git diff --exit-code` that the real one ends in. The subject —
the ordering of the `unset` against the nested git call — is untouched.
"""
from __future__ import annotations
import os
import shutil
import stat
import subprocess
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
PRE_PUSH = REPO_ROOT / ".husky" / "pre-push"
UNSET_CLAUSE = "unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE"
# The hooks `pre-push` calls before the CI-parity block. Stubbed because each is guarded on its own
# terms and none of them is what this file is about — but DERIVED from the real file rather than
# hand-listed, so a hook added to `pre-push` tomorrow cannot leave this harness silently running a
# `pre-push` that dies at a missing script and calling that a red.
def _hook_calls(text: str) -> list[str]:
"""Every `.claude/hooks/` script `pre-push` actually invokes.
Two refinements over a plain substring scan, each closing a way the harness would misreport:
* **comment lines are skipped.** A comment naming a hook that no longer exists would otherwise
redden `test_the_stub_hooks_are_derived_from_the_real_file` for a file that is perfectly
correct.
* **the prefix is not assumed to be `./`.** A hook invoked as `bash .claude/hooks/x.sh` would
be missed, left unstubbed, and exit 127 — a NON-ZERO status that two tests here read as
"drift was caught". That is a false green in the direction that matters, so the match is on
the path segment rather than on `./`.
"""
names = []
for line in text.splitlines():
# INLINE comments too, not just whole-line ones. Broadening the marker from `./.claude/hooks/`
# to the path segment made a trailing `# ... .claude/hooks/removed-helper.sh` match, which
# would redden this file for a `pre-push` that is perfectly correct — a false red the
# narrower marker did not have. The fix for one over-match must not introduce another.
stripped = line.split("#", 1)[0].strip()
marker = ".claude/hooks/"
if marker in stripped:
rest = stripped.split(marker, 1)[1]
names.append(rest.split()[0].rstrip("|&;\"'"))
return names
def _git(cwd: Path, *args: str) -> str:
return subprocess.run(
["git", *args],
cwd=str(cwd),
check=True,
capture_output=True,
text=True,
env={
**os.environ,
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@e",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@e",
},
).stdout.strip()
def _exe(path: Path, body: str) -> None:
path.write_text(body)
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
class Bench:
"""A real repo, a real worktree, the real `pre-push`, and the env git really exports."""
def __init__(self, tmp_path: Path, pre_push_text: str, *, drift: bool):
tmp_path.mkdir(parents=True, exist_ok=True)
self.root = tmp_path / "main-tree"
self.root.mkdir()
_git(self.root, "init", "-q", "-b", "main", ".")
(self.root / "seed").write_text("seed\n")
_git(self.root, "add", "seed")
_git(self.root, "commit", "-qm", "init")
self.wt = tmp_path / "wt"
_git(self.root, "worktree", "add", "-q", str(self.wt), "-b", "feature")
web = self.wt / "web"
web.mkdir()
(web / "gen.txt").write_text("generated\n")
_git(self.wt, "add", "web/gen.txt")
_git(self.wt, "commit", "-qm", "add generated file")
if drift:
# The condition `check:api` exists to catch: the committed generated artifact no longer
# matches what regeneration produces.
(web / "gen.txt").write_text("generated\nDRIFT\n")
(self.wt / ".husky").mkdir()
self.pre_push = self.wt / ".husky" / "pre-push"
_exe(self.pre_push, pre_push_text)
hooks = self.wt / ".claude" / "hooks"
hooks.mkdir(parents=True)
for name in _hook_calls(pre_push_text):
_exe(hooks / name, "#!/usr/bin/env bash\ncat >/dev/null\nexit 0\n")
# The npm stand-in. `run check:api` performs the nested `git diff --exit-code` the real
# script ends in; everything else is a no-op. It records the nested diff's OWN exit code,
# not merely that it ran. That distinction is load-bearing: one of the mutations below moves
# the `unset` to the end of the file, which also makes the script's terminal status 0 (a
# bare `unset` succeeds) — so asserting on pre-push's exit code alone would be satisfied for
# a reason that has nothing to do with the nested git call. The recorded diff verdict IS the
# defect; the script's exit code is downstream of it.
self.witness = tmp_path / "check-api-ran"
bindir = tmp_path / "bin"
bindir.mkdir()
_exe(
bindir / "npm",
"#!/usr/bin/env bash\n"
'if [ "$1" = "run" ] && [ "$2" = "check:api" ]; then\n'
" git diff --exit-code -- gen.txt >/dev/null\n"
" _rc=$?\n"
f' echo "$PWD $_rc" >> "{self.witness}"\n'
" exit $_rc\n"
"fi\n"
"exit 0\n",
)
self.bindir = bindir
# `GIT_DIR` exactly as git exports it for a push from this worktree, verified against a real
# push in the investigation that produced this file.
self.git_dir = _git(self.wt, "rev-parse", "--absolute-git-dir")
def run(self, *, export_git_dir: bool) -> subprocess.CompletedProcess:
env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
env["PATH"] = f"{self.bindir}:{env['PATH']}"
if export_git_dir:
env["GIT_DIR"] = self.git_dir
return subprocess.run(
["bash", str(self.pre_push), "origin", "file:///dev/null"],
input=b"refs/heads/feature abc refs/heads/feature def\n",
capture_output=True,
cwd=str(self.wt),
env=env,
timeout=120,
)
def check_api_ran(self) -> bool:
return self.witness.exists()
def nested_diff_rc(self) -> int:
"""The exit code the nested `git diff --exit-code` actually reported.
0 means it saw NO diff. With a drifted tree that answer is the bug.
"""
assert self.witness.exists(), "check:api never ran, so there is no nested diff verdict"
return int(self.witness.read_text().split()[-1])
# ------------------------------------------------------------------------------------------------
# ANTI-VACUITY — the clause exists, and the harness reaches the check that depends on it
# ------------------------------------------------------------------------------------------------
def test_the_unset_clause_is_still_in_pre_push():
text = PRE_PUSH.read_text()
assert UNSET_CLAUSE in text, (
f"`{UNSET_CLAUSE}` is gone from .husky/pre-push. If it was removed deliberately, this file "
"must be removed with it and docs/guard-inventory.md updated — do not delete this assertion "
"on its own, it is the only thing pinning the ordering"
)
lines = text.splitlines()
unset_at = next(i for i, ln in enumerate(lines) if UNSET_CLAUSE in ln)
nested_at = next(i for i, ln in enumerate(lines) if "npm run check:api" in ln)
assert unset_at < nested_at, (
"the unset now comes AFTER the nested git call it exists to protect — that ordering is the "
"regression, and it is silent"
)
def test_the_harness_actually_reaches_check_api(tmp_path):
bench = Bench(tmp_path, PRE_PUSH.read_text(), drift=True)
bench.run(export_git_dir=False)
assert bench.check_api_ran(), (
"the CI-parity block never ran, so every exit code below would be reporting on the hooks "
"before it rather than on the nested git call this file is about"
)
# ------------------------------------------------------------------------------------------------
# THE GUARD DECIDES
# ------------------------------------------------------------------------------------------------
def test_drift_is_CAUGHT_with_no_git_env_exported(tmp_path):
"""Positive control: the harness detects real drift when nothing is in the way."""
bench = Bench(tmp_path, PRE_PUSH.read_text(), drift=True)
p = bench.run(export_git_dir=False)
assert bench.nested_diff_rc() != 0, "the nested diff saw no drift even with a clean env"
assert p.returncode != 0, f"the harness did not detect drift even with a clean env: {p.stderr!r}"
def test_drift_is_CAUGHT_when_git_exports_GIT_DIR_from_a_worktree(tmp_path):
"""THE REAL CASE. Every push from a worktree — the mandated way to work here — runs this."""
bench = Bench(tmp_path, PRE_PUSH.read_text(), drift=True)
p = bench.run(export_git_dir=True)
assert bench.nested_diff_rc() != 0, (
"the nested `git diff --exit-code` reported NO DIFF on a drifted file. That is the silent "
"fail-open the `unset` exists to prevent, and it is invisible from the exit code alone"
)
assert p.returncode != 0, (
"pre-push reported success on a drifted generated file while git had exported GIT_DIR: "
f"{p.stdout!r} {p.stderr!r}"
)
def test_a_CLEAN_tree_is_allowed_through(tmp_path):
"""Negative control. A harness that always failed would pass both assertions above."""
bench = Bench(tmp_path, PRE_PUSH.read_text(), drift=False)
p = bench.run(export_git_dir=True)
assert p.returncode == 0, f"a clean tree was blocked: {p.stdout!r} {p.stderr!r}"
assert bench.nested_diff_rc() == 0, "the nested diff invented a diff on a clean tree"
# ------------------------------------------------------------------------------------------------
# MUTATION PROOFS — the two ways the clause stops protecting anything
# ------------------------------------------------------------------------------------------------
def _positive_control(tmp_path: Path, label: str) -> None:
bench = Bench(tmp_path / f"pc-{label}", PRE_PUSH.read_text(), drift=True)
p = bench.run(export_git_dir=True)
assert bench.nested_diff_rc() != 0 and p.returncode != 0, (
"the UNMUTATED pre-push did not catch the drift, so 'the mutant lets it through' proves "
f"nothing about the clause: {p.stdout!r} {p.stderr!r}"
)
def test_MUTATION_DELETING_the_unset_lets_drift_through_silently(tmp_path):
_positive_control(tmp_path, "delete")
text = PRE_PUSH.read_text()
assert UNSET_CLAUSE in text, "retarget this mutation; the clause has moved"
mutated = text.replace(UNSET_CLAUSE, "# clause removed by the mutation proof", 1)
bench = Bench(tmp_path / "mut", mutated, drift=True)
p = bench.run(export_git_dir=True)
assert bench.check_api_ran(), "the mutant died before check:api, so its exit code says nothing"
assert bench.nested_diff_rc() == 0, (
"removing the unset did NOT blind the nested diff, so the clause is not what protects it "
"and this whole file is pinning the wrong thing"
)
assert p.returncode == 0, f"the drift was still caught somehow: {p.stdout!r}"
def test_MUTATION_REORDERING_the_unset_after_the_nested_git_call_lets_drift_through(tmp_path):
"""The regression ersatztv#785 names by hand: not deletion, relocation.
Deleting a line is a conspicuous diff. Moving it — during a tidy-up, or when a new check is
appended above it — reads as a no-op and is not.
This asserts on the NESTED DIFF's verdict, not on pre-push's exit code. Relocating the `unset`
to the end of the file also makes it the script's last statement, and a bare `unset` succeeds —
so `returncode == 0` would hold here even if the nested git call had worked perfectly. That is a
test passing for the wrong reason, and it was written that way in this file's first draft.
"""
_positive_control(tmp_path, "reorder")
lines = PRE_PUSH.read_text().splitlines()
kept = [ln for ln in lines if UNSET_CLAUSE not in ln]
assert len(kept) == len(lines) - 1, "expected exactly one unset line to relocate"
mutated = "\n".join(kept + [UNSET_CLAUSE, ""])
bench = Bench(tmp_path / "mut", mutated, drift=True)
bench.run(export_git_dir=True)
assert bench.check_api_ran(), "the mutant died before check:api, so it reports on nothing"
assert bench.nested_diff_rc() == 0, (
"moving the unset below the nested git call did NOT blind it, so the ORDERING is not "
"load-bearing and the ordering assertion in the anti-vacuity test is decoration"
)
def test_the_stub_hooks_are_derived_from_the_real_file():
"""If `pre-push` gains a hook call, the harness must stub it rather than die at a missing file.
A `pre-push` that exits 127 at a missing script produces a non-zero exit — indistinguishable
from 'drift was caught' in two of the tests above.
"""
calls = _hook_calls(PRE_PUSH.read_text())
assert calls, "no ./.claude/hooks/ call found in pre-push; the extractor has stopped matching"
for name in calls:
assert (REPO_ROOT / ".claude" / "hooks" / name).is_file(), (
f"pre-push calls {name}, which does not exist in .claude/hooks/"
)
def test_shutil_which_npm_is_not_what_the_harness_used(tmp_path):
"""Anti-vacuity for the stand-in: a real `npm` on PATH would run the real scripts and pass."""
bench = Bench(tmp_path, PRE_PUSH.read_text(), drift=True)
resolved = shutil.which("npm", path=f"{bench.bindir}:{os.environ['PATH']}")
assert resolved == str(bench.bindir / "npm"), (
f"the harness would have used {resolved}, not its stand-in — the check it performs would "
"then be whatever the real package.json says, not the nested git call under test"
)
@@ -0,0 +1,490 @@
"""The worktree-ownership mechanism is TWO files, and this drives both halves as one thing.
`pretooluse-worktree-guard.sh` denies a `git commit`/`git merge` inside a worktree another session
created. It can only do that because `posttooluse-worktree-marker.sh` wrote the
`.claude-worktree-owner` marker at `git worktree add` time. Neither file had a test, and — the part
that makes this rank second in ersatztv#785 — **the halves had never been exercised together**, so a
regression in either one is invisible: the marker hook silently writing nothing and the guard hook
silently reading nothing produce the identical outcome, which is *the commit is allowed*, which is
also what a correct fail-open looks like.
Both hooks are deliberately fail-open (`docs/decisions` — the main tree is never marked, and
pre-convention worktrees have no marker), and that is exactly why an absent mechanism is
indistinguishable from a working one from the outside. It is the shape
`testing.guard-ships-with-mutation-proof` was written for: "in every case a human had read the guard
and believed it worked. The guard was not subtly wrong, it was *absent*."
So this file:
* drives the REAL pair end to end over a REAL `git worktree add`, in the real payload shape —
marker hook first as the harness would fire it, then the guard hook;
* carries a negative control (a non-mutating git command) and a fail-open control (an unmarked
worktree), because a guard that denied everything would satisfy the deny assertions;
* and performs FOUR clause-level mutations: the guard's marker read, the guard's ownership
comparison, the command-detection alternation (`commit|merge`), and the *other file's* marker
write. The last is the one that could not exist while the halves were tested apart.
These four are the clauses whose disarm this file detects. They are not every line in either hook —
the `git -C` / `cd` redirection extraction and the marker hook's argument parsing are exercised
behaviourally but not mutated, and the grade in `docs/guard-inventory.md` covers the clause its
cited case mutates, not the whole file.
"""
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
GUARD = REPO_ROOT / ".claude" / "hooks" / "pretooluse-worktree-guard.sh"
MARKER_HOOK = REPO_ROOT / ".claude" / "hooks" / "posttooluse-worktree-marker.sh"
MARKER_NAME = ".claude-worktree-owner"
SESSION_A = "session-aaaa-1111"
SESSION_B = "session-bbbb-2222"
def _env() -> dict:
"""The subprocess environment, built PER CALL — never snapshotted at import.
Two things it must get right.
`CLAUDE_PROJECT_DIR` is pinned because the hooks resolve `scripts/hook-fire-log.sh` from it,
falling back to a path relative to their own location; a MUTATED copy lives in tmp_path, where
that fallback finds nothing. Without the pin the mutant differs from the subject in a second way
and the comparison stops being about the mutated clause.
And it is a FUNCTION rather than a module-level dict because `conftest.py`'s autouse
`isolate_hook_fire_log` fixture monkeypatches `ETV_HOOK_FIRE_LOG_DIR` into `os.environ` at test
setup — which happens AFTER this module is imported. A `{**os.environ}` snapshot taken at import
time captures the environment as it was before the fixture ran, so every hook subprocess writes
to the REAL `$HOME/.cache/ersatztv/hook-fire/` log instead of the fixture's tmp dir. That is not
untidiness: it is #776's defect reintroduced in the file that is meant to prove #776's hooks,
and it corrupts the `hook-fire-log.sh report` surface this repo cites as the observability claim
for every guard still graded NONE. The reproduction, rather than a figure whose evidence has
since been deleted: reintroduce the snapshot and run this file, then count records for the two
synthetic session ids below — 58 per run, on macOS and Linux alike.
"""
return {**os.environ, "CLAUDE_PROJECT_DIR": str(REPO_ROOT)}
def _git(cwd: Path, *args: str) -> str:
p = subprocess.run(
["git", *args],
cwd=str(cwd),
check=True,
capture_output=True,
text=True,
env={
**os.environ,
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@e",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@e",
},
)
return p.stdout
def _scratch_repo(tmp_path: Path) -> Path:
repo = tmp_path / "main-tree"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main", ".")
(repo / "f.txt").write_text("one\n")
_git(repo, "add", "f.txt")
_git(repo, "commit", "-qm", "init")
return repo
def _run(hook: Path, payload: dict, cwd: Path) -> tuple[int, bytes]:
p = subprocess.run(
["bash", str(hook)],
input=json.dumps(payload).encode(),
capture_output=True,
cwd=str(cwd),
env=_env(),
timeout=60,
)
return p.returncode, p.stdout
def _add_worktree(repo: Path, name: str, marker_hook: Path | None, session: str) -> Path:
"""`git worktree add` exactly as a session does it, then fire the PostToolUse marker hook.
The marker hook is driven with the payload the harness would hand it AFTER the command
succeeded, which is when PostToolUse fires — not a hand-planted marker file. A hand-planted
marker would make every deny below a test of the guard alone, and the untested seam is the
handoff between the two files.
"""
wt = repo.parent / name
_git(repo, "worktree", "add", "-q", str(wt))
if marker_hook is not None:
rc, _ = _run(
marker_hook,
{
"session_id": session,
"hook_event_name": "PostToolUse",
"tool_name": "Bash",
"cwd": str(repo),
"tool_input": {"command": f"git worktree add {wt}"},
},
repo,
)
assert rc == 0, "the marker hook must always exit 0"
return wt
def _commit_payload(session: str, cwd: Path, command: str = "git commit -m x") -> dict:
return {
"session_id": session,
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"cwd": str(cwd),
"tool_input": {"command": command},
}
def _denied(out: bytes) -> bool:
return b'"permissionDecision": "deny"' in out or b'"permissionDecision":"deny"' in out
# ------------------------------------------------------------------------------------------------
# ANTI-VACUITY — if the fixture never produces a marked worktree, every deny below is meaningless
# ------------------------------------------------------------------------------------------------
def test_the_subprocess_env_CARRIES_the_isolated_hook_fire_log_dir():
"""The hooks these tests drive must log to the fixture's dir, never the production one.
`conftest.py`'s autouse `isolate_hook_fire_log` monkeypatches `ETV_HOOK_FIRE_LOG_DIR` into
`os.environ` at test setup. Anything that snapshots `os.environ` at IMPORT time captures the
value from before the fixture ran and silently defeats it — the hooks then append to
`$HOME/.cache/ersatztv/hook-fire/`, which is #776's defect reintroduced inside the file that
proves #776's hooks, corrupting the one surface this repo cites as the observability claim for
every guard still graded NONE.
It is invisible from the outside: the tests pass either way, because the fire-log library is
fail-open by design. So it needs its own assertion.
"""
env = _env()
isolated = os.environ.get("ETV_HOOK_FIRE_LOG_DIR")
assert isolated, "the autouse isolation fixture did not run; conftest.py is not being loaded"
assert env.get("ETV_HOOK_FIRE_LOG_DIR") == isolated, (
"the subprocess environment does not carry the isolated log dir, so every hook driven by "
"this file is writing into the production hook-fire log. Build the env per call; do not "
f"snapshot os.environ at import time. env has {env.get('ETV_HOOK_FIRE_LOG_DIR')!r}"
)
# The path `hook-fire-log.sh` falls back to when ETV_HOOK_FIRE_LOG_DIR is unset, derived the
# same way it derives it rather than restated as a literal.
production = Path(os.environ.get("HOME", "/tmp")) / ".cache" / "ersatztv" / "hook-fire" # noqa: S108 — mirrors hook-fire-log.sh's own ${HOME:-/tmp}
assert Path(isolated).resolve() != production.resolve(), (
f"the 'isolated' log dir IS the production one ({production}), so the fixture is isolating "
"nothing and this test would pass while the leak continued"
)
def test_driving_a_hook_LANDS_its_records_in_the_ISOLATED_dir(tmp_path):
"""The invariant, asserted at the EFFECT rather than at the helper that is supposed to produce it.
`test_the_subprocess_env_CARRIES_...` above checks `_env()`'s return value, and that is not the
same claim: `_env()` can be perfectly correct while a call site passes something else. Cold
review demonstrated exactly that — restore the module-level snapshot and change one `env=_env()`
back to `env=_ENV`, and all thirteen tests pass while 54 records leak into the real log. The
guard was pinned to the shape of the fix instead of to the property, which is
`verify-against-the-REAL-predecessor`: a hand-written revert is not the code a future tidy-up
produces.
So this drives a real hook through the real `_run()` and asserts the records landed where the
fixture put them.
ITS SCOPE, stated because the first version of this docstring claimed more than it delivers: it
guards THE LAUNCH PATH IT DRIVES, not the file. Cold review demonstrated the gap — add a second
launcher alongside `_run()` that passes a stale snapshot and point the mutation tests at it, and
this test stays green while 18 records leak, because the hooks IT drives still log correctly.
Every hook in this file goes through `_run()` today, which is what makes the guard sufficient
HERE and not a general property. The general form is a suite-level check, tracked in #809; the
reason it is hard is that the obvious version — diff the production log around each test — races
against a real session's hooks firing concurrently.
"""
isolated = Path(os.environ["ETV_HOOK_FIRE_LOG_DIR"])
before = {p.name for p in isolated.glob("*.jsonl")} if isolated.exists() else set()
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
rc, out = _run(GUARD, _commit_payload(SESSION_B, wt), wt)
# Anti-vacuity: if the hook decided nothing, it may simply have had nothing to log.
assert rc == 0 and _denied(out), f"the hook reached no decision, so 'records landed' would prove nothing: {out!r}"
after = {p.name for p in isolated.glob("*.jsonl")} if isolated.exists() else set()
assert after > before, (
f"driving two hooks added no record to the isolated log dir {isolated}. Either the "
"instrumentation stopped firing, or these hooks are logging somewhere else — and the only "
"somewhere else is the production log this file must never touch"
)
def test_the_marker_hook_really_marks_the_worktree_it_was_told_about(tmp_path):
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
marker = wt / MARKER_NAME
assert marker.is_file(), (
f"no {MARKER_NAME} in {wt}. Without it the guard has nothing to read and every 'denied' "
"assertion in this file would be testing a mechanism that is not there"
)
assert marker.read_text().strip() == SESSION_A, (
f"the marker names {marker.read_text().strip()!r}, not the session that created the "
"worktree — ownership would be attributed to the wrong session"
)
def test_the_marker_hook_ignores_a_command_that_is_not_a_worktree_add(tmp_path):
"""The write side's own negative control: a hook that marked on any command would pass above."""
repo = _scratch_repo(tmp_path)
wt = repo.parent / "wt-unrelated"
_git(repo, "worktree", "add", "-q", str(wt))
rc, _ = _run(
MARKER_HOOK,
{
"session_id": SESSION_A,
"hook_event_name": "PostToolUse",
"tool_name": "Bash",
"cwd": str(repo),
"tool_input": {"command": f"ls {wt}"},
},
repo,
)
assert rc == 0
assert not (wt / MARKER_NAME).exists(), "the marker hook stamped a worktree it never created"
# ------------------------------------------------------------------------------------------------
# THE PAIR DECIDES — both halves, in sequence, as the harness fires them
# ------------------------------------------------------------------------------------------------
def test_a_commit_in_ANOTHER_sessions_worktree_is_DENIED(tmp_path):
"""The #289 case the mechanism exists for, end to end across both files."""
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
rc, out = _run(GUARD, _commit_payload(SESSION_B, wt), wt)
assert rc == 0, "the hook communicates by printing, and must always exit 0"
assert _denied(out), f"a commit into session A's worktree was not denied from session B: {out!r}"
assert SESSION_A.encode() in out, (
"the deny reason must name the owning session — without it the operator cannot tell whether "
f"the marker is stale or the worktree is genuinely foreign: {out!r}"
)
def test_a_commit_in_MY_OWN_worktree_is_ALLOWED(tmp_path):
"""Negative control. A guard that denied every marked worktree would pass the test above."""
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
rc, out = _run(GUARD, _commit_payload(SESSION_A, wt), wt)
assert rc == 0
assert out == b"", f"the owning session was blocked from committing in its own worktree: {out!r}"
def test_an_UNMARKED_worktree_is_ALLOWED(tmp_path):
"""The deliberate fail-open: pre-convention worktrees and the main tree carry no marker."""
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-none", None, SESSION_A)
rc, out = _run(GUARD, _commit_payload(SESSION_B, wt), wt)
assert rc == 0
assert out == b"", f"an unmarked worktree was blocked, which breaks the main tree too: {out!r}"
def test_a_NON_MUTATING_git_command_in_a_foreign_worktree_is_ALLOWED(tmp_path):
"""Only `commit`/`merge` are guarded; `git status` in a sibling worktree is normal work."""
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
rc, out = _run(GUARD, _commit_payload(SESSION_B, wt, "git status"), wt)
assert rc == 0
assert out == b"", f"a read-only git command was denied: {out!r}"
def test_a_MERGE_in_a_foreign_worktree_is_DENIED(tmp_path):
"""The other half of the guarded alternation.
Every other deny case here uses `git commit`, so `merge` could be dropped from the detection
regex and this file would stay green — the mechanism guards the plumbing-merge path
(`process.foreign-worktree-plumbing-merge`) specifically, which makes that the more damaging
half to lose.
"""
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
rc, out = _run(GUARD, _commit_payload(SESSION_B, wt, "git merge --no-ff topic"), wt)
assert rc == 0
assert _denied(out), f"a merge into session A's worktree was not denied: {out!r}"
def test_a_git_C_into_a_foreign_worktree_is_DENIED_from_the_main_tree(tmp_path):
"""The redirection that makes the guard non-trivial.
The session's cwd is its OWN tree — where committing is fine — and only the `-C` argument moves
the operation into the foreign worktree. A guard that looked at `cwd` alone would allow this,
and `git -C` is how the sibling-worktree commit actually gets typed.
"""
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
rc, out = _run(GUARD, _commit_payload(SESSION_B, repo, f"git -C {wt} commit -m x"), repo)
assert rc == 0
assert _denied(out), f"`git -C <foreign worktree> commit` was not denied: {out!r}"
# ------------------------------------------------------------------------------------------------
# MUTATION PROOFS — four clauses, one per thing the mechanism hangs on
#
# Each mutant is an isolated copy with ONE clause disarmed, and each test asserts the UNMUTATED pair
# reaches the opposite decision on the same fixture FIRST. Without that positive control a mutation
# proof passes when the mechanism detects nothing at all, which is how the BOM guard sat fail-open
# for months while reading as covered.
# ------------------------------------------------------------------------------------------------
def _mutate(src: Path, tmp_path: Path, old: str, new: str, why: str) -> Path:
assert old in src.read_text(), (
f"the clause {old!r} has moved in {src.name}; RETARGET this mutation rather than loosening "
f"it — a mutation that silently stops mutating is the failure this file is about ({why})"
)
dst = tmp_path / f"mutated-{src.name}"
dst.write_text(src.read_text().replace(old, new, 1))
return dst
def test_MUTATION_disarming_the_guards_MARKER_READ_stops_the_deny(tmp_path):
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
payload = _commit_payload(SESSION_B, wt)
rc_live, out_live = _run(GUARD, payload, wt)
assert rc_live == 0 and _denied(out_live), (
"the UNMUTATED guard did not deny, so 'the mutant is silent' would prove nothing about the "
f"marker read: {out_live!r}"
)
mutant = _mutate(
GUARD,
tmp_path,
'marker="$root/.claude-worktree-owner"',
'marker="$root/.claude-worktree-owner-NOTHING-WRITES-THIS"',
"the guard's marker read",
)
rc, out = _run(mutant, payload, wt)
assert rc == 0
assert out == b"", (
"the guard still denied with its marker read pointed at a file nothing writes, so the deny "
f"is not coming from ownership at all: {out!r}"
)
def test_MUTATION_inverting_the_OWNERSHIP_COMPARISON_blocks_the_owner(tmp_path):
"""The allow direction, which the deny mutation above cannot reach.
Disarming the comparison the other way would only make the guard deny more, and every deny
assertion in this file would stay green. Inverting it is what shows the comparison — rather than
the mere presence of a marker — is what decides.
"""
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
own_payload = _commit_payload(SESSION_A, wt)
rc_live, out_live = _run(GUARD, own_payload, wt)
assert rc_live == 0 and out_live == b"", (
f"the UNMUTATED guard already blocked the owner, so the inversion below proves nothing: {out_live!r}"
)
mutant = _mutate(
GUARD,
tmp_path,
'[ "$owner" = "$me" ] && exit 0',
'[ "$owner" != "$me" ] && exit 0',
"the guard's ownership comparison",
)
rc, out = _run(mutant, own_payload, wt)
assert rc == 0
assert _denied(out), (
"inverting the ownership comparison did not change the decision for the OWNING session, so "
f"the comparison is not what allows it through: {out!r}"
)
def test_MUTATION_a_marker_hook_that_stops_WRITING_makes_the_guard_go_quiet(tmp_path):
"""THE CROSS-FILE PROOF — the one that could not exist while the halves were tested apart.
The clause disarmed here is in `posttooluse-worktree-marker.sh`; the assertion is about
`pretooluse-worktree-guard.sh`. A regression in the write half is otherwise completely silent:
the marker hook exits 0 either way, and the guard's fail-open turns a missing marker into an
allowed commit that looks exactly like a correctly allowed one.
"""
repo = _scratch_repo(tmp_path)
wt_live = _add_worktree(repo, "wt-live", MARKER_HOOK, SESSION_A)
rc_live, out_live = _run(GUARD, _commit_payload(SESSION_B, wt_live), wt_live)
assert rc_live == 0 and _denied(out_live), (
f"the UNMUTATED pair did not deny, so a silent mutant proves nothing about the marker write: {out_live!r}"
)
mutant_marker = _mutate(
MARKER_HOOK,
tmp_path,
'printf \'%s\\n\' "$me" > "$abs/.claude-worktree-owner" 2>/dev/null || true',
"true",
"the marker hook's write",
)
wt_dead = _add_worktree(repo, "wt-dead", mutant_marker, SESSION_A)
assert not (wt_dead / MARKER_NAME).exists(), (
"the mutated marker hook wrote a marker anyway — the mutation did not disarm the write, so "
"the assertion below would be about nothing"
)
rc, out = _run(GUARD, _commit_payload(SESSION_B, wt_dead), wt_dead)
assert rc == 0
assert out == b"", (
"the guard denied a commit in a worktree that carries NO marker, which means the deny in "
f"the live case above is not evidence that the two halves are connected: {out!r}"
)
def test_MUTATION_dropping_MERGE_from_the_detection_clause_stops_denying_a_merge(tmp_path):
"""The alternation is two guarded operations, and losing one of them is silent.
This mutation is deliberately narrow: it must stop the guard denying a `merge` while leaving it
denying a `commit`. Asserting both is what distinguishes "the alternation is load-bearing" from
"the mutant broke the regex", which would redden everything and prove nothing about `merge`.
"""
repo = _scratch_repo(tmp_path)
wt = _add_worktree(repo, "wt-a", MARKER_HOOK, SESSION_A)
merge_payload = _commit_payload(SESSION_B, wt, "git merge --no-ff topic")
commit_payload = _commit_payload(SESSION_B, wt)
rc_live, out_live = _run(GUARD, merge_payload, wt)
assert rc_live == 0 and _denied(out_live), (
f"the UNMUTATED guard did not deny a merge, so a silent mutant proves nothing: {out_live!r}"
)
mutant = _mutate(
GUARD,
tmp_path,
"(commit|merge)\\b",
"(commit)\\b",
"the command-detection alternation",
)
rc, out = _run(mutant, merge_payload, wt)
assert rc == 0
assert out == b"", f"dropping `merge` from the alternation did not stop the merge being denied: {out!r}"
rc_c, out_c = _run(mutant, commit_payload, wt)
assert rc_c == 0 and _denied(out_c), (
"the mutant stopped denying COMMITS too, so it broke detection wholesale rather than "
f"removing the merge alternative — this proves nothing about `merge`: {out_c!r}"
)