feat(794): witness a fix's test failing BEFORE the fix, and check the claim in CI (#801)
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 19s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m31s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m14s
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 4m30s

Mechanises the defect that took #776 and #793 six review rounds each: a fix's test
written to confirm the fix, not to discriminate against its absence.
testing.guard-ships-with-mutation-proof generalised from guards to fixes.

prove-fix.sh runs the selector at the commit (control, must be GREEN) and again in a
separate fresh worktree with the non-test files reverted (must be RED = pytest exit 1
exactly; 2/3/4/5/143 are refused, and --continue-on-collection-errors keeps add-a-file
fixes provable). pytest's status comes from a marker written only after it returns,
because ( cd X && pytest ); rc=$? returns the SUBSHELL's status. Opt-in by a Proves:
trailer; CI checks every commit that carries one and says out loud when a PR has none.

THE TOOL REJECTED ITS OWN AUTHOR. Three commits on the branch claimed
Proves: scripts/tests/test_prove_fix.py; the job returned UNPROVEN for all three,
because reverting the script restored a working earlier version the suite also passed.
Two had been "verified" against hand-written mutants that did not match the code that
actually shipped. The tests were rewritten until both go RED against 587edbecc — whose
script emits "red without it (pytest exit 2)", a witnessed false PROVEN.

This branch deliberately carries no Proves: trailer: the only one that would pass does
so because reverting deletes prove-fix.sh, an add-file smoke check rather than a proof
of its logic. The logic proof is a clause-level mutation that re-runs the unchanged
refusal test against a mutant and witnesses it red (graded MUTATION).

fixes #794

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 #801.
This commit is contained in:
2026-08-16 10:24:59 +00:00
committed by timothy
co-authored by Claude Opus 5
parent 107716fa4f
commit 15d2439915
7 changed files with 897 additions and 3 deletions
+117
View File
@@ -211,6 +211,123 @@ jobs:
# `.claude/hooks/pretooluse-merge-consent.sh`, so its true input set spans at least two top-level
# directories. A `scripts/**` filter would silently miss a `.claude/hooks/**` edit — and at ~10s a
# filter buys nothing but drift.
prove-fix:
name: "Fix proofs (Proves trailers)"
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
# Full history: prove-fix.sh reverts each commit against its PARENT, so a shallow
# clone would leave it unable to resolve `<sha>^` and it would refuse every commit.
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install test dependencies
run: python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml
# OPT-IN BY TRAILER, deliberately. Requiring `Proves:` on every commit would block
# docs, CI and refactor commits that have no code side to revert, and a gate that
# blocks ordinary work gets disabled — which is how a check ends up running nowhere
# (#631). So the trailer is the AUTHOR'S CLAIM, and this job checks claims: write
# one and it must hold. Coverage is therefore honest rather than assumed, and
# `docs/decisions/records/testing/fix-ships-a-witnessed-red-test.md` says so.
- name: Prove every commit that claims a proof
run: |
set -uo pipefail
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
echo "range: $base..$head"
# Capture and VALIDATE the enumeration before looping. `for sha in $(git ...)`
# swallows a git failure: the command substitution yields nothing, the loop body
# never runs, and the job reports "0 claims" green. Fail-open enumeration in the
# thing that decides what gets checked is the defect this job exists to catch.
if ! shas="$(git rev-list "$base".."$head")"; then
echo "::error::git rev-list failed for $base..$head — cannot enumerate commits," \
"so this job cannot assert anything. Refusing to pass."
exit 1
fi
claimed=0; proven=0; failed=0
while IFS= read -r sha; do
[ -n "$sha" ] || continue
# Trim whitespace only — NOT `xargs`, which applies quote parsing and turns a
# legitimate parametrised node id like test_x[can't] into an empty selector,
# silently dropping a real claim.
# Extract with a CHECKED status. `sel="$(git show ... )"` under `set -uo
# pipefail` but no `-e` yields an empty selector when git fails, the commit is
# skipped, and the job exits 0 having been unable to inspect a possible claim —
# fail-open in the step that decides what gets checked.
if ! raw="$(git show -s --format='%(trailers:key=Proves,valueonly)' "$sha")"; then
echo "::error::git show failed for $sha — cannot read its trailers, so this" \
"job cannot assert anything about it. Refusing to pass."
exit 1
fi
# Refuse MORE THAN ONE `Proves:` here too. prove-fix.sh has this guard, but it
# only fires when it reads the trailer itself — and this job passes the selector
# explicitly, so the guard was bypassed on the one path that actually enforces.
# Measured: a commit with two trailers reported PROVEN while the second was never
# run. Fixing the script and not its twin is how a guard reads as coverage.
# Count trailer PRESENCE, not non-empty values: `%(...valueonly)` renders a bare
# `Proves:` as an empty line, so counting non-empty lines misses a commit whose
# FIRST trailer is empty — `sel` then comes out empty and the commit is skipped
# in silence, with a real second selector never checked. Fail-open in CI while
# the script is fail-closed is the same asymmetry this guard exists to remove.
present="$(git show -s --format='%(trailers:key=Proves)' "$sha")"
if [ "$(printf '%s\n' "$present" | grep -c .)" -gt 1 ]; then
claimed=$((claimed + 1)); failed=$((failed + 1))
echo "::error::commit $sha carries more than one 'Proves:' trailer; only the" \
"first would be checked, so the rest would read as proven without ever" \
"running. Use a single selector."
continue
fi
sel="$(printf '%s\n' "$raw" | head -1 | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
# A trailer that is PRESENT but empty is a claim with no selector. Refuse it
# loudly; skipping it silently would let the job report "no claims" for a PR that
# made one.
if [ -n "$present" ] && [ -z "$sel" ]; then
claimed=$((claimed + 1)); failed=$((failed + 1))
echo "::error::commit $sha carries a 'Proves:' trailer with no selector."
continue
fi
[ -n "$sel" ] || continue
claimed=$((claimed + 1))
# A merge commit has several parents, so "before this change" is ambiguous.
# prove-fix.sh refuses them; catch it here with a clearer message rather than
# letting the trailer be silently skipped (which --no-merges used to do).
if [ "$(git rev-list --parents -n 1 "$sha" | wc -w)" -gt 2 ]; then
failed=$((failed + 1))
echo "::error::commit $sha is a MERGE carrying 'Proves: $sel'. Put the trailer" \
"on the commit that carries the fix — a merge has no single 'before'."
continue
fi
echo "::group::prove $sha -> $sel"
if bash ./scripts/prove-fix.sh "$sha" "$sel"; then
proven=$((proven + 1)); echo "PROVEN $sha"
else
rc=$?
failed=$((failed + 1))
echo "::error::commit $sha claims 'Proves: $sel' but prove-fix.sh exited $rc." \
"A claimed proof that does not hold is worse than none — it reads as" \
"coverage. Strengthen the test until reverting the fix reddens it, or" \
"drop the trailer."
fi
echo "::endgroup::"
done <<< "$shas"
echo "commits claiming a proof: $claimed (proven $proven, failed $failed)"
if [ "$claimed" -eq 0 ]; then
echo "::notice::No commit in this PR carries a 'Proves:' trailer, so nothing was" \
"verified here. That is allowed — the trailer is opt-in — but it means this" \
"job asserts NOTHING about this PR. Do not read its green as fix coverage."
fi
[ "$failed" -eq 0 ]
script-tests:
name: Script tests (pytest)
runs-on: small
+35 -2
View File
@@ -200,7 +200,10 @@ rather than in `docker-build.yml` — see that section (ersatztv#535).
**`small` is git-only, and that is load-bearing (server-management#639).** Everything in
the lane is a checkout plus a `git diff`: `decisions-guard`, `ci-image-pin`,
`docs-reminder` — plus `script-tests`, which is a checkout plus a `pytest` run needing only
`docs-reminder` — plus `prove-fix` (ersatztv#794), the heaviest member: per commit carrying a
`Proves:` trailer it makes two `git worktree add`s and runs an arbitrary pytest selection twice,
so a PR claiming many proofs costs proportionally more than the rest of the lane combined — plus
`script-tests`, which is a checkout plus a `pytest` run needing only
`pytest` and `pyyaml` (ersatztv#631; it is NOT stdlib-only — that assumption is what turned the
job red on its first CI run, see below) — plus **`scan`** (ersatztv#767), the same
lightweight-Python shape. `scan` is the lane member to think hardest about before changing anything
@@ -871,6 +874,36 @@ compiler/docker build), so it doesn't violate the "small is git-only" lane rule.
`docs-reminder`, otherwise a seconds-long `git diff` + parse with no dotnet/node setup
(`runs-on: small`).
### `prove-fix` job (`Fix proofs (Proves trailers)`, PR-only — in `pr-checks.yml`)
Runs `scripts/prove-fix.sh` for **every commit in the PR that carries a `Proves: <pytest selector>`
trailer**, and fails the PR if a claimed proof does not hold. The rule and its rationale are
`testing.fix-ships-a-witnessed-red-test`; this section is the CI-side contract.
**Opt-in by trailer, enforced when present.** Requiring `Proves:` on every commit would block docs,
CI and refactor commits that have no code side to revert, and a gate that blocks ordinary work gets
switched off — which is how a check ends up running nowhere (ersatztv#631). So the trailer is the
author's *claim* and this job checks claims. **When a PR carries none, the job emits a `::notice::`
saying it asserted nothing** — its green must not be read as fix coverage.
**It needs full history** (`fetch-depth: 0`): the script reverts each commit against its PARENT, and
a shallow clone cannot resolve `<sha>^`.
Three refusals worth knowing before you write a trailer:
- a **merge** commit is rejected — several parents means "the code before this change" is ambiguous;
put the trailer on the commit carrying the fix;
- **more than one** `Proves:` trailer is rejected — only the first would be checked, so the rest
would read as proven without ever running (the job checks this itself, because passing the
selector explicitly bypasses the script's own guard);
- a **test-only** commit is rejected — there is no code side to revert.
**Only pytest exit 1 counts as red.** 2 is an interrupted collection, 3 internal, 4 usage, 5 nothing
collected, 143 a signal; `--continue-on-collection-errors` converts a genuine collection failure to
1 so add-a-file fixes stay provable. The job inherits that. Note the direction: a wobble DOES redden
this job (`prove-fix.sh` exits 5 on a harness/git failure or a signal, and the job turns that into an
error), so what the exit-code discipline buys is the other way round — a **green** here means a claim
was witnessed, never that a run was cancelled or broke.
### `script-tests` job (`Script tests (pytest)`, PR-only — in `pr-checks.yml`)
> Reddens the run on failure, but like the other `pr-checks.yml` gates it is **not** one of the
@@ -964,7 +997,7 @@ assumed.
**File:** `.gitea/workflows/pr-checks.yml` — `on: pull_request` only.
The four git-only PR gates — `ci-image-pin`, `docs-reminder`, `decisions-guard`, `script-tests`
The five PR gates — `ci-image-pin`, `docs-reminder`, `decisions-guard`, `script-tests`, and `prove-fix` (the one member that is NOT merely checkout + `git diff`: it installs pytest and creates worktrees)
(all described above) — live here, **not** in `docker-build.yml`, and that separation is the fix
for **ersatztv#535**.
+1
View File
@@ -194,6 +194,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `testing.e2e-cleanup-scope-by-pid` | An E2E harness or agent may only kill processes whose PIDs it captured at launch — capture the PID; whoever owns the lifecycle releases it from a `trap ... EXIT INT TERM`. Never `pkill -f "dotnet ErsatzTV.dll"` (or any pattern that can match a process this run did not start). A foreign listener is reported, not reaped. | 2026-07-25 | [link](records/testing/e2e-cleanup-scope-by-pid.md) |
| `testing.e2e-local-fresh-config-dir` | Always point `scripts/e2e-local.sh` at a fresh config dir — leftover channels/schedules/DB rows bleed state between runs and corrupt assertions. (The *readiness-probe hang* this record was originally written about was fixed in #533; the fresh-dir rule stands on state-bleed grounds alone.) | 2026-07-21 | [link](records/testing/e2e-local-fresh-config-dir.md) |
| `testing.enumerating-guard-identity-not-position` | A guard that cross-checks a hand-reviewed registry against call sites discovered across the whole repo must key each entry on properties INTRINSIC to the site — file, kind, and the value source text — and never on its absolute line or column. A registry keyed on position is a function of every other file in the repo, so a branch that never touches the guard can invalidate it; and because each PR is green against its own base, that failure is structurally invisible pre-merge and lands on `main` after review and after the merge gate. Dropping the position keeps every mutation the guard exists for — a NEW site, a REMOVED site and a CHANGED value each still fail, since each changes the identity multiset — and costs exactly ONE case, which must be stated rather than implied: a SAME-IDENTITY SUBSTITUTION within one file (delete a registered site, add a different unreviewed one with the same kind and value token, net-zero count) now passes. A REPORTED failure still prints the discovered line:column, because identity and diagnostics need not share a format. Comparison stays a MULTISET count rather than set membership, so two sites in one file sharing an identity must be discovered exactly that many times and a third occurrence still fails. A SCANNER test that asserts real AST positions against FIXED inline fixtures is the opposite case and keeps its line/column identity — it has no churn, because its input does not move. | 2026-07-27 | [link](records/testing/enumerating-guard-identity-not-position.md) |
| `testing.fix-ships-a-witnessed-red-test` | A commit claiming to fix something may carry a `Proves: <pytest selector>` trailer; when it does, `scripts/prove-fix.sh` must show that selector GREEN with the fix and RED with the code side reverted, and CI enforces it per-PR. The trailer is opt-in — an unproven commit is allowed — but a claimed proof that does not hold fails the build. | 2026-08-16 | [link](records/testing/fix-ships-a-witnessed-red-test.md) |
| `testing.guard-derives-population-from-source` | A guard that asserts a COMPLETENESS property enumerates its population from a machine-readable authoritative source — the enum, the generated OpenAPI document, the parsed workflow YAML, the provider list — and asserts SET EQUALITY in BOTH directions against it. It may not narrow that population with a filter, a `Where`, a `grep` or an early `continue` before the assertion, because a filter cannot see the member that is MISSING: the member whose absence is the defect is precisely the one the predicate excludes. A hand-written literal list of members is the same defect in slower motion — a filter frozen at authoring time, correct on the day it was written and unable to report the day it stopped being. Two boundaries bound the rule rather than weaken it. FIRST, filtering to select the SUBJECT of a PER-MEMBER property is legitimate and is not this defect: the excluded members satisfy the property vacuously, so the filtered walk and the whole walk assert the same thing (`ToolCatalogTests.Every_Query_Parameter_Should_Be_A_Declared_Property` filters to tools that declare query parameters, and a tool declaring none has nothing to check). The defect is filtering the population before a COMPLETENESS claim, which is what makes an absent member unrepresentable (#757 filtered on `QueryParameters is {Count: > 0}` and so could not see a tool that should have declared one and did not). SECOND, a population of VALUES always has an external authoritative source and this rule applies directly; a population of SITES IN CODE has no such list, needs find-all-references tooling, and is tracked separately in #777 — do not stretch a set-equality assertion over it. Distinguish the guard SCOPE (which subsystems it covers — a reviewed policy choice, legitimately hand-written) from the guard POPULATION (the members inside that scope — always derived). When the scope itself MIRRORS an authoritative source, the mirror needs its own equality check or a dated staleness marker, or the guard is complete within a scope that has silently gone stale. The canonical worked example in this repo is `ToolCatalogTests.Every_Tool_Should_Declare_Exactly_Its_OpenApi_Query_Parameters`; the canonical residual gap is `MARKED_JOBS` in `scripts/tests/test_ci_dropped_step_guard.py`. | 2026-08-13 | [link](records/testing/guard-derives-population-from-source.md) |
| `testing.guard-ships-with-mutation-proof` | A guard is not considered tested because a test involving it passes. It ships with a MUTATION PROOF: remove or disarm THAT GUARD'S CLAUSE ALONE, and a NAMED test must go red. ONE NAMED EXCEPTION, with its limits, because the rule degenerates without it: where the guard IS a test (a checker enforcing a repo invariant, with no separate script behind it), disarming it makes it ABSENT rather than red, so the proof is the contrapositive — INTRODUCE THE DEFECT THE GUARD EXISTS TO CATCH into an isolated copy of the guarded artifact, and the named test must go red. That is a mutation of the guarded SYSTEM rather than of the assertion, and it is admissible ONLY for checker-guards and ONLY when the mutation was executed and witnessed. It is NOT a licence to grade an ordinary script-guard MUTATION for having a bad-input test: feeding a script an input its clause rejects is BEHAVIOUR-ONLY, which is what three rows were regraded for. A file-level grade under this exception covers the clause its cited case actually mutates, not every assertion that later lands in the same file; clause-level grading is tracked in #790. Three things this excludes, each of which has already shipped here as a green suite over a dead check. FIRST, a behavioural test — one that feeds the guard a good input and a bad input and checks it passes and fails — proves the guard REACTS, never that it is LOAD-BEARING; #685 had two guards on one condition where deleting either left the whole suite green while every behavioural test passed. SECOND, mutating the WHOLE FILE does not count (#510): a whole-file revert cannot show that a test reaches a particular clause, so the mutation must target the clause. THIRD, the guard being WIRED is not the guard RUNNING — #631's suite was invoked by no CI job, #751's step was dropped by the runner and the job reported success in 6s against a normal 14-17s, and #719's new logic was never connected to stdin. Every guard also carries an ANTI-VACUITY assertion, because the characteristic failure of a completeness check is reporting that it proved everything while its population was empty. Mechanical enforcement is possible for the BOOKKEEPING and not for the JUDGEMENT, and the split is the decision: `docs/guard-inventory.md` lists every guard file with its Kind, its Proof class (`MUTATION`/`BEHAVIOUR-ONLY`/`NONE`) and a `file::function` ref, and `scripts/tests/test_guard_inventory.py` derives the guard population from the filesystem and the call sites, asserts SET EQUALITY against the rows, and resolves every claimed ref to a real `def`. So a new guard cannot ship unclassified and a renamed test cannot leave a row silently claiming coverage. What stays with review, and is stated rather than papered over: nothing checks that a row claiming `MUTATION` is telling the truth. | 2026-08-13 | [link](records/testing/guard-ships-with-mutation-proof.md) |
| `testing.hook-reports-its-own-execution` | Every script in `.claude/hooks/` sources `scripts/hook-fire-log.sh` and calls `etv_hook_fire_begin <its-own-name> <label> <capture\|stream>` as its FIRST act, before anything reads stdin. Two records are appended per invocation — a `fire` record on entry and an `exit` record carrying the exit status and the decision — to a session-scoped JSONL log. THE DECISION IS READ FROM WHAT THE HOOK ACTUALLY EMITTED, never declared by the hook author: Claude Code hooks (`capture` mode) always exit 0 and communicate by PRINTING JSON, so their stdout is diverted and replayed, and the recorded decision is parsed from those bytes; git hooks (`stream` mode) decide by EXIT CODE and their stdout is live progress text a human is watching, so it is not diverted and the decision is the status. That split is not a tuning knob — capturing a slow pre-push hook's output would hold it back until the end and read as a hang, and inferring a git hook's decision from absent JSON would put the report back into the guessing business this record exists to end. The population is DERIVED from `.claude/hooks/*.sh` by `scripts/tests/test_hook_fire_log.py`, so a new hook is uninstrumented-and-red rather than silently unobserved, and the report lists every hook that EXISTS rather than every hook that appears in the log — a report built from the log alone can only show hooks that fired, which makes the never-fired hook, the one finding worth having, invisible. THE INSTRUMENTATION MUST BE INVISIBLE TO THE HARNESS, and this is the load-bearing half: it sits in the stdin and stdout path of the most authoritative guards in the repo, so a differential test drives EVERY hook with and without it over a payload matrix and demands byte-equal stdout and equal exit status. It fails OPEN in exactly one direction — if the log cannot be written the hook behaves exactly as before — because observability that breaks a guard is worse than the blindness it replaces. Two mechanical traps are pinned by tests rather than left to care: stdout must be replayed from the FILE, since `out=$(cat f)` strips trailing newlines and delivers a guard's JSON one byte short with no parser anywhere to complain; and stdin must never be slurped when it is a TTY, because an interactive `git commit` hands its hooks a terminal and `cat` would block forever, hanging the commit the instrumentation was added to observe. | 2026-08-14 | [link](records/testing/hook-reports-its-own-execution.md) |
@@ -0,0 +1,68 @@
---
key: testing.fix-ships-a-witnessed-red-test
title: '2026-08-16 — A fix''s test is witnessed RED before the fix, or it pins nothing (#794)'
status: active
since: '2026-08-16'
supersedes: none
superseded-by: none
rule: 'A commit claiming to fix something may carry a `Proves: <pytest selector>` trailer; when it does, `scripts/prove-fix.sh` must show that selector GREEN with the fix and RED with the code side reverted, and CI enforces it per-PR. The trailer is opt-in — an unproven commit is allowed — but a claimed proof that does not hold fails the build.'
signals: 'witnessed red, test does not discriminate, prove the fix, Proves trailer, revert the code keep the test, mutation proof for fixes, red-green · paths: `scripts/prove-fix.sh`, `scripts/tests/test_prove_fix.py`, `.gitea/workflows/pr-checks.yml` · issues: #794, #776, #793, #796, #775'
mechanics: '`scripts/prove-fix.sh [--repo DIR] <commit> [selector]`; the `prove-fix` job in `.gitea/workflows/pr-checks.yml`'
---
**`testing.guard-ships-with-mutation-proof` generalised from guards to fixes.** That record says a
guard is not tested because a test involving it passes. The same argument applies to every bug fix,
and nothing enforced it. Two six-round issues in one week had the same recurring mechanism, and it
was not any individual bug: **the fix's test was written to confirm the fix, not to discriminate
against its absence.** #776 shipped a test asserting stdout after a signal that never read
`returncode` — the one thing its fix changed. #793 produced five false greens in its own verification
code, every one a check that could not go red (#796).
**The check is a red-green pair, not a red.** `prove-fix.sh` runs the selector twice: once at the
commit (control, must be GREEN) and once with the commit's non-test files reverted (must be RED,
and RED means pytest exit 1 exactly — see below). The control is what makes the second run mean anything — a test that is already failing
proves nothing by failing again, and without the control a broken selector sails through as PROVEN.
That hole existed in the first version of the script and its own test found it.
**Opt-in by trailer, enforced when present.** Requiring `Proves:` on every commit would block docs,
CI and refactor commits that have no code side to revert, and a gate that blocks ordinary work gets
switched off — which is how a check ends up running nowhere (#631). So the trailer is the author's
*claim*, and CI checks claims. The job says so out loud when a PR carries none: its green asserts
nothing about that PR, and must not be read as fix coverage.
**Refusing beats guessing.** With no trailer and no argument the script exits rather than inferring
the selector from the touched test files. That heuristic silently does nothing when a fix edits an
existing test — the exact case worth catching — and a prover that quietly proves nothing is worse
than one that refuses.
**Only pytest exit 1 counts as red, and that is the whole safety argument.** "Non-zero" is not "the
test failed": 2 is an interrupted collection, 3 an internal error, 4 a usage error, 5 nothing
collected, and a killed run gives 143. Cold review measured a SIGTERM being read as red in an
early DRAFT — cancellation masquerading as evidence, produced by the tool meant to prevent it. The
first *committed* version (`587edbecc`) exits 5 on a signal by a different route, so do not expect
to reproduce PROVEN there; the reproducible false PROVEN on that sha is the marker case —
`( cd X && pytest ); rc=$?` returning 1 because `cd` failed, witnessed by
`test_a_harness_failure_is_NOT_reported_as_PROVEN`. `--continue-on-collection-errors` is passed so a
genuine collection failure (what happens when the fix ADDED the module the test imports) is reported
as a test error and exits 1, rather than being lost in the ambiguous 2/3 band. Measured: pass 0,
fail 1, collection-error 2 → 1 with the flag, SIGTERM 143.
**Each phase gets a fresh worktree and its own TMPDIR.** Sharing one lets state written during the
control run decide the second run — a test that creates a marker and fails when it exists would "go
red" with the fix still in place, a false proof manufactured entirely by the harness.
**A merge commit is refused, not silently resolved.** It has several parents, so "the code before
this change" is ambiguous; evaluating one of them quietly would be the overclaim this record is
about. Put the trailer on the commit carrying the fix.
**What this does NOT establish, stated because a prover that overclaims is the defect it exists to
catch.** It shows a test *can* go red, not that it asserts the *right* property. #776's
`test_output_survives_a_SIGTERM` would have passed this check while still missing `returncode`. That
judgement stays with review. It is Python-only: C# and `web/` fixes need their own runners and no
coverage is claimed for them.
**The sibling rule is deliberately NOT mechanised.** #776's worst artifacts were two wrong
measurements written into a decision record, both from quoting a review summary without re-running
it. The rule — *a number in a durable artifact carries the command that produced it, or is not
written* — stays a convention. Detecting "this number lacks provenance" is a string predicate over
prose, which `docs/defect-shapes-773.md` §4 argues against and this repo has withdrawn twice.
+3 -1
View File
@@ -105,6 +105,7 @@ all.
| `scripts/jq-preflight.sh` | the `script-tests` job, on a jq version change | GUARD | BEHAVIOUR-ONLY | `test_jq_preflight.py::test_below_the_floor_is_LOUD` |
| `scripts/post-review-verdict.sh` | nothing (writes the verdict status) | GUARD | BEHAVIOUR-ONLY | `test_post_review_verdict.py::test_never_retargets_the_verdict_at_the_new_head` |
| `scripts/pr-changed-files.sh` | the verdict exemption, on an incomplete enumeration | GUARD | BEHAVIOUR-ONLY | `test_pr_changed_files.py::test_a_SHORT_page_does_not_end_the_enumeration` |
| `scripts/prove-fix.sh` | the `prove-fix` job, on a commit whose `Proves:` trailer names a test that passes without the fix | GUARD | MUTATION | `test_prove_fix.py::test_MUTATION_disarming_the_UNPROVEN_clause_reddens_the_refusal_test` |
| `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 | — |
@@ -123,10 +124,11 @@ all.
| `scripts/tests/test_post_review_verdict.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 | — |
## What the numbers say
32 guards, 5 tooling scripts, 13 proof files. **6 guards carry a mutation proof; 6 are
33 guards, 5 tooling scripts, 14 proof files. **7 guards carry a mutation proof; 6 are
behaviour-only; 20 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,
+303
View File
@@ -0,0 +1,303 @@
#!/usr/bin/env bash
# prove-fix.sh — witness a fix's test failing BEFORE the fix (ersatztv#794).
#
# THE RULE THIS ENFORCES. `testing.guard-ships-with-mutation-proof` says a guard is not
# tested because a test involving it passes; it ships with a proof it can fail. The same
# argument applies to every bug fix, and nothing enforced it. #776 and #793 each took six
# review rounds, and in both the recurring mechanism was one thing: a fix's test was
# written to confirm the fix, not to discriminate against its absence.
#
# HOW. Run the named tests at the commit (CONTROL — must be GREEN), then again in a
# SEPARATE fresh worktree with the commit's non-test files reverted (must be RED). The
# control is what makes the second run mean anything: a test that is already failing
# proves nothing by failing again.
#
# ONLY pytest exit 1 COUNTS AS RED, and that is the whole safety argument. "Non-zero" is
# not "the test failed": 2 is an interrupted collection, 3 an internal error, 4 a usage
# error, 5 nothing collected, and a killed run gives 143. Cold review measured a SIGTERM
# being read as red in an early DRAFT — cancellation masquerading as evidence. On the first
# COMMITTED version the reproducible false PROVEN is the marker case (a failed `cd` giving
# the subshell status 1), not the signal one; see the decision record.
# `--continue-on-collection-errors` is passed so a genuine collection failure
# (what happens when the fix ADDED the module the test imports) is reported as a test
# error and exits 1 instead of vanishing into the ambiguous 2/3 band. Measured: pass 0,
# fail 1, collection-error 2 (1 with the flag), SIGTERM 143.
#
# WHAT THIS DOES NOT DO, stated because a prover that overclaims is the defect it exists
# to catch: it checks a test CAN go red, not that it asserts the RIGHT property. #776's
# `test_output_survives_a_SIGTERM` would have passed this check while still never reading
# `returncode` — the one thing its fix changed. That judgement stays with review.
#
# Usage:
# prove-fix.sh [--repo DIR] <commit> [test-selector]
#
# The selector may be omitted when the commit carries a `Proves: <selector>` trailer.
# There is deliberately no heuristic fallback: guessing from the touched test files
# silently does nothing when a fix edits an existing test, and a prover that quietly
# proves nothing is worse than one that refuses.
#
# Exit codes:
# 0 PROVEN — green with the fix, red (pytest exit 1) without it
# 1 UNPROVEN — the tests passed without the fix; they do not discriminate
# 2 usage / bad arguments
# 3 no selector (no argument and no `Proves:` trailer)
# 4 nothing to revert (no non-test files) — a docs/CI-only commit; opt out explicitly
# 5 environment/git/selector failure, or a run whose exit code is not interpretable
# 6 control failed — the selector does not even pass WITH the fix
set -uo pipefail
REPO="$PWD"
if [ "${1:-}" = "--repo" ]; then
REPO="${2:-}"; shift 2
fi
COMMIT="${1:-}"
SELECTOR="${2:-}"
TMP=""
TMP_ENUM=""
# ONE cleanup, ONE EXIT trap. An earlier version installed a SECOND `trap ... EXIT` later in
# the script to remove TMP_ENUM; `on_signal` then ran `trap - EXIT`, disarming it, so a
# signalled run leaked that directory. Folding both removals in here removes the ordering
# subtlety rather than adding a third trap to compensate for it.
cleanup() {
if [ -n "$TMP" ]; then
for d in "$TMP/wt-control" "$TMP/wt-reverted"; do
[ -d "$d" ] && git -C "$REPO" worktree remove --force "$d" >/dev/null 2>&1
done
rm -rf "$TMP" >/dev/null 2>&1
fi
[ -n "$TMP_ENUM" ] && rm -rf "$TMP_ENUM" >/dev/null 2>&1
return 0
}
# A signal must not look like a verdict: clean up, then exit non-zero EXPLICITLY. Without
# that explicit exit the handler falls through and the previous status stands, which is
# how a cancelled run printed PROVEN in the draft.
on_signal() { cleanup; trap - EXIT; printf 'prove-fix: interrupted by signal\n' >&2; exit 5; }
trap cleanup EXIT
trap on_signal INT TERM
die() { printf '%s\n' "$1" >&2; exit "$2"; }
[ -n "$COMMIT" ] || die "usage: prove-fix.sh [--repo DIR] <commit> [test-selector]" 2
git -C "$REPO" rev-parse --git-dir >/dev/null 2>&1 || die "not a git repository: $REPO" 5
command -v python3 >/dev/null 2>&1 || die "python3 is required to run the tests" 5
git -C "$REPO" rev-parse --verify --quiet "$COMMIT^{commit}" >/dev/null \
|| die "no such commit: $COMMIT" 5
SHA="$(git -C "$REPO" rev-parse "$COMMIT")"
# A merge commit has several parents, so "the code before this change" is ambiguous.
# Refuse rather than silently taking the first parent: a `Proves:` trailer on a merge is a
# claim this script cannot evaluate, and quietly evaluating a different one would be the
# overclaim it exists to prevent.
PARENTS="$(git -C "$REPO" rev-list --parents -n 1 "$SHA")" || die "rev-list failed for $SHA" 5
NPARENT=$(( $(printf '%s' "$PARENTS" | wc -w) - 1 ))
[ "$NPARENT" -le 1 ] || die \
"cannot prove a MERGE commit ($NPARENT parents): $SHA
'The code before this change' is ambiguous across parents. Put the Proves: trailer on the
commit that carries the fix." 5
PARENT="$(git -C "$REPO" rev-parse --verify --quiet "${SHA}^" || true)"
[ -n "$PARENT" ] || die "cannot prove a root commit (no parent to revert to): $SHA" 5
# ---------------------------------------------------------------- selector
if [ -z "$SELECTOR" ]; then
RAW_TRAILERS="$(git -C "$REPO" show -s --format='%(trailers:key=Proves,valueonly)' "$SHA")"
# More than one `Proves:` means the commit claims two proofs and only the first would be
# checked — the rest would read as covered while never running.
if [ "$(printf '%s\n' "$RAW_TRAILERS" | grep -c .)" -gt 1 ]; then
die "commit carries more than one 'Proves:' trailer; only one is checked, so the others
would read as proven without ever running. Use a single selector." 3
fi
SELECTOR="$(printf '%s\n' "$RAW_TRAILERS" | head -1)"
# Trim surrounding whitespace ONLY. Not `xargs`: it applies shell-ish quote parsing, so
# a legitimate parametrised node id like `test_x[can't]` makes it report an unterminated
# quote and yield an empty selector — silently dropping a real claim.
SELECTOR="${SELECTOR#"${SELECTOR%%[![:space:]]*}"}"
SELECTOR="${SELECTOR%"${SELECTOR##*[![:space:]]}"}"
fi
# Split ONCE, deliberately, into an array — then always expand as "${SEL[@]}". Leaving the
# scalar unquoted at the call site would also apply PATHNAME EXPANSION, so a node id
# containing a glob character could select different tests than the trailer names.
read -r -a SEL <<< "$SELECTOR"
[ -n "$SELECTOR" ] || die \
"no test selector: pass one, or give the commit a 'Proves: <selector>' trailer.
Refusing to guess from the touched test files — that silently proves nothing when a fix
edits an existing test, which is the failure mode this script exists to prevent." 3
# ---------------------------------------------------------------- classify
TMP_ENUM="$(mktemp -d "${TMPDIR:-/tmp}/prove-fix-enum.XXXXXX")" || die "mktemp failed" 5
# NUL-delimited --name-status: git tells us whether each path was Added, Modified,
# Deleted or Renamed. Deriving added-ness from git's own status letter beats probing the
# parent blob and interpreting the error text — `cat-file -e parent:new.py` fails with a
# non-empty message for a legitimately-added file, so "non-empty stderr means git broke"
# both mis-classifies that case and would leave real git failures indistinguishable.
# -z also protects paths containing newlines/quotes, which git otherwise quotes and which
# would then silently stay un-reverted while we claim to have reverted them.
# Capture to a file with a CHECKED status first. Inside `done < <(git ...)` the producer's
# exit status is unavailable, so a git failure that had already emitted one complete record
# would pass the non-empty check and revert only PART of the commit — overstating "without
# the fix" and manufacturing a proof from an incomplete revert.
NS="$TMP_ENUM/name-status"
git -C "$REPO" diff-tree --no-commit-id -r -z --name-status "$SHA" >"$NS" \
|| die "git diff-tree failed for $SHA — cannot enumerate what to revert" 5
CHANGED=(); STATUS=()
while IFS= read -r -d '' st; do
IFS= read -r -d '' path || die "truncated --name-status stream after status '$st'" 5
case "$st" in
R*|C*) IFS= read -r -d '' newpath \
|| die "truncated rename/copy record after '$st' '$path'" 5
CHANGED+=("$newpath"); STATUS+=("A") # the new path is absent in the parent
CHANGED+=("$path"); STATUS+=("D") ;; # the old path is present in it
*) CHANGED+=("$path"); STATUS+=("${st:0:1}") ;;
esac
done <"$NS"
[ "${#CHANGED[@]}" -gt 0 ] || die "commit touches no files, or enumeration failed: $SHA" 5
is_test_path() {
case "$1" in
scripts/tests/*|*/scripts/tests/*) return 0 ;;
*[Tt]ests/*) return 0 ;;
*.test.ts|*.test.tsx|*.test.js) return 0 ;;
*_test.py|test_*.py) return 0 ;;
*) return 1 ;;
esac
}
NON_TEST=(); NON_TEST_ST=(); TEST_FILES=()
for i in "${!CHANGED[@]}"; do
f="${CHANGED[$i]}"
if is_test_path "$f"; then
TEST_FILES+=("$f")
else
NON_TEST+=("$f"); NON_TEST_ST+=("${STATUS[$i]}")
fi
done
if [ "${#NON_TEST[@]}" -eq 0 ]; then
die "nothing to revert: $SHA changes only test files.
A commit with no code side cannot be proven this way. If it is a docs/CI-only fix, opt out
explicitly with a reason rather than letting this pass silently." 4
fi
printf 'commit %s\n' "$SHA"
printf 'selector %s\n' "$SELECTOR"
printf 'reverting %d non-test file(s), keeping %d test file(s)\n' \
"${#NON_TEST[@]}" "${#TEST_FILES[@]}"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/prove-fix.XXXXXX")" || die "mktemp failed" 5
mkdir -p "$TMP/tmp-control" "$TMP/tmp-reverted" || die "could not create phase temp dirs" 5
# Each phase gets its OWN worktree and TMPDIR, and pytest's cache is disabled. Sharing one
# worktree lets state written during the control run decide the second run: a test that
# creates a marker and fails when it already exists would "go red" with the fix still in
# place — a false PROVEN manufactured entirely by the harness.
#
# PYTEST_RC IS READ FROM A MARKER, NOT FROM THE SUBSHELL. `( cd X && pytest ) ; rc=$?`
# returns the SUBSHELL's status, and a failed `cd` or a failed redirection also yields 1 —
# which the "only exit 1 is red" rule would then accept as a witnessed test failure with
# pytest never having run. Cold review found exactly that. The marker file is written only
# after pytest RETURNS, so its absence means "pytest did not complete" and can never be
# mistaken for a verdict.
PYTEST_RC=""
run_phase() { # $1 = worktree, $2 = label; sets PYTEST_RC or returns non-zero
rm -f "$TMP/rc-$2"
(
cd "$1" || exit 91
exec >"$TMP/out-$2.txt" 2>&1 || exit 92
PYTHONPATH=. TMPDIR="$TMP/tmp-$2" PYTEST_ADDOPTS= \
python3 -m pytest "${SEL[@]}" -q --continue-on-collection-errors -p no:cacheprovider
printf '%s' "$?" > "$TMP/rc-$2"
)
if [ ! -s "$TMP/rc-$2" ]; then
return 1 # pytest never completed — harness failure, NOT a test result
fi
PYTEST_RC="$(cat "$TMP/rc-$2")"
return 0
}
# ---------------------------------------------------------------- control
git -C "$REPO" worktree add --detach "$TMP/wt-control" "$SHA" >/dev/null 2>&1 \
|| die "could not create the control worktree at $SHA" 5
echo "--- control: running the selector WITH the fix ---"
if ! run_phase "$TMP/wt-control" control; then
sed -n '1,25p' "$TMP/out-control.txt" 2>/dev/null >&2
die "the control run did not complete: pytest produced no exit status.
This is a harness failure (bad cd, unwritable log, missing interpreter), not a test
result, and must never be reported as a verdict." 5
fi
CONTROL_RC="$PYTEST_RC"
echo "--- control pytest exit: $CONTROL_RC ---"
if [ "$CONTROL_RC" -ne 0 ]; then
sed -n '1,25p' "$TMP/out-control.txt" >&2
case "$CONTROL_RC" in
4|5) die "selector ran NO tests (pytest exit $CONTROL_RC): $SELECTOR
Exit 4 means the path does not exist; 5 means nothing was collected. Fix the selector." 5 ;;
*) die "control FAILED: the selector does not pass WITH the fix (pytest exit $CONTROL_RC).
A test that is already red proves nothing by being red after a revert. Fix the test or the
selector first." 6 ;;
esac
fi
# ---------------------------------------------------------------- reverted
git -C "$REPO" worktree add --detach "$TMP/wt-reverted" "$SHA" >/dev/null 2>&1 \
|| die "could not create the reverted worktree at $SHA" 5
for i in "${!NON_TEST[@]}"; do
f="${NON_TEST[$i]}"; st="${NON_TEST_ST[$i]}"
case "$st" in
A) # added by this commit: it has no parent version, so remove it
rm -f "$TMP/wt-reverted/$f" || die "could not remove added file $f" 5 ;;
M|D|T)
git -C "$TMP/wt-reverted" checkout "$PARENT" -- "$f" 2>/dev/null \
|| die "could not revert $f to $PARENT (status $st)" 5 ;;
*) die "unhandled git status '$st' for $f — refusing to guess how to revert it" 5 ;;
esac
done
echo "--- running the selector WITHOUT the fix ---"
if ! run_phase "$TMP/wt-reverted" reverted; then
sed -n '1,25p' "$TMP/out-reverted.txt" 2>/dev/null >&2
die "the reverted run did not complete: pytest produced no exit status.
This is a harness failure, not a witnessed red. Accepting it would let a broken cd or an
unwritable log masquerade as proof — the defect this script exists to catch." 5
fi
RC="$PYTEST_RC"
# A non-numeric status makes BOTH `[ "$RC" -eq 0 ]` and `[ "$RC" -ne 1 ]` return 2, so
# control would fall through to PROVEN — a fail-OPEN default in the one place the whole
# safety argument rests. Unreachable today (the marker's only writer is `printf '%s' "$?"`),
# but every other branch here is deliberately fail-closed and this one should be too.
case "$RC" in
''|*[!0-9]*) die "unreadable pytest status '$RC' — refusing to guess a verdict" 5 ;;
esac
echo "--- pytest exit: $RC ---"
# ---------------------------------------------------------------- verdict
if [ "$RC" -eq 0 ]; then
printf '\nUNPROVEN %s\n' "$SHA"
printf '%s\n' "The named tests PASS without the fix, so they do not discriminate against"
printf '%s\n' "its absence. Strengthen the test until reverting the fix reddens it."
exit 1
fi
if [ "$RC" -ne 1 ]; then
sed -n '1,25p' "$TMP/out-reverted.txt" >&2
die "cannot interpret pytest exit $RC as a test failure.
Only exit 1 counts as red. 2 is an interrupted collection, 3 an internal error, 4 a usage
error, 5 nothing collected, 130/143 a signal — none is evidence that the test
discriminates, and treating them as red lets a cancelled or broken run prove a fix." 5
fi
# Print WHICH tests failed. This script's own docstring says review must judge whether the
# RIGHT property failed; hiding the report would make that judgement impossible.
printf '\n--- failures observed without the fix (first 25 lines) ---\n'
sed -n '1,25p' "$TMP/out-reverted.txt"
printf '\nPROVEN %s\n' "$SHA"
printf '%s\n' "Green with the fix (control), red without it (pytest exit 1)."
exit 0
+370
View File
@@ -0,0 +1,370 @@
"""scripts/prove-fix.sh witnesses a fix's test failing before the fix (ersatztv#794).
THE NEGATIVE CONTROL IS THE POINT OF THIS FILE. A prover that reports PROVEN for
everything is worse than no prover: it manufactures exactly the confidence #794 exists
to withhold. So `test_unrelated_test_is_UNPROVEN` is the load-bearing case here, and the
positive case only tells us the script can distinguish the two.
Each test builds a throwaway git repo rather than pinning real commits from this
repository's history — a test anchored to a real sha rots the moment that sha is rebased
or the file moves, and then it passes for the wrong reason (or is deleted for being
flaky, which is worse).
"""
from __future__ import annotations
import os
import subprocess
import time
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
# Overridable so a MUTANT copy can be driven through these very tests — that is what makes
# the mutation proof below a witnessed red rather than an argument. Gated behind a sentinel
# so a stray CI value cannot silently point the whole suite at another script.
if os.environ.get("PROVE_FIX_PATH") and os.environ.get("PROVE_FIX_MUTATION_RUN") != "1":
raise RuntimeError(
"PROVE_FIX_PATH is set without PROVE_FIX_MUTATION_RUN=1. That would silently test a "
"different script than the one this suite vouches for."
)
PROVE_FIX = Path(os.environ.get("PROVE_FIX_PATH") or (REPO_ROOT / "scripts" / "prove-fix.sh"))
def _git(repo: Path, *args: str) -> str:
return subprocess.run(
["git", "-C", str(repo), *args],
check=True, capture_output=True, text=True,
).stdout.strip()
def _run(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["bash", str(PROVE_FIX), "--repo", str(repo), *args],
capture_output=True, text=True, cwd=str(repo),
)
@pytest.fixture
def fixrepo(tmp_path: Path) -> Path:
"""A repo whose HEAD is a fix: code change + a test that discriminates.
It also carries an UNRELATED test, present from the first commit, which passes with
or without the fix. That test is the negative control's subject.
"""
repo = tmp_path / "r"
(repo / "scripts" / "tests").mkdir(parents=True)
subprocess.run(["git", "init", "-q", str(repo)], check=True)
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "T")
# --- commit 1: the bug, plus a test that cannot see it
(repo / "calc.py").write_text("def add(a, b):\n return a - b # bug\n")
(repo / "scripts" / "tests" / "test_unrelated.py").write_text(
"def test_unrelated():\n assert 1 + 1 == 2\n"
)
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "initial: buggy add, unrelated test")
# --- commit 2: the fix + a test that discriminates against its absence
(repo / "calc.py").write_text("def add(a, b):\n return a + b\n")
(repo / "scripts" / "tests" / "test_add.py").write_text(
"from calc import add\n\n\ndef test_add():\n assert add(2, 3) == 5\n"
)
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m",
"fix: add() returned a difference\n\nProves: scripts/tests/test_add.py")
return repo
def test_reverting_the_fix_reddens_its_test_PROVEN(fixrepo: Path) -> None:
r = _run(fixrepo, "HEAD", "scripts/tests/test_add.py")
assert r.returncode == 0, f"expected PROVEN (0), got {r.returncode}\n{r.stdout}\n{r.stderr}"
assert "PROVEN" in r.stdout
assert "UNPROVEN" not in r.stdout
def test_unrelated_test_is_UNPROVEN(fixrepo: Path) -> None:
"""THE NEGATIVE CONTROL. A test that passes without the fix must be refused.
Without this, every other assertion in this file is compatible with a script that
prints PROVEN unconditionally.
"""
r = _run(fixrepo, "HEAD", "scripts/tests/test_unrelated.py")
assert r.returncode == 1, f"expected UNPROVEN (1), got {r.returncode}\n{r.stdout}\n{r.stderr}"
assert "UNPROVEN" in r.stdout
assert "do not discriminate" in r.stdout
def test_selector_comes_from_the_Proves_trailer(fixrepo: Path) -> None:
"""No selector argument: it must read `Proves:` rather than guess."""
r = _run(fixrepo, "HEAD")
assert r.returncode == 0, f"expected PROVEN via trailer, got {r.returncode}\n{r.stdout}\n{r.stderr}"
assert "scripts/tests/test_add.py" in r.stdout
def test_no_selector_and_no_trailer_REFUSES(fixrepo: Path) -> None:
"""Refusing beats guessing: a heuristic silently proves nothing when a fix edits an
existing test, which is the failure mode being mechanised against."""
(fixrepo / "calc.py").write_text("def add(a, b):\n return a + b # touched\n")
_git(fixrepo, "add", "-A")
_git(fixrepo, "commit", "-q", "-m", "fix: no trailer here")
r = _run(fixrepo, "HEAD")
assert r.returncode == 3, f"expected 3 (no selector), got {r.returncode}\n{r.stderr}"
assert "Proves:" in r.stderr
def test_test_only_commit_REFUSES(fixrepo: Path) -> None:
"""A commit with no code side cannot be proven this way — it must say so, not pass."""
(fixrepo / "scripts" / "tests" / "test_extra.py").write_text("def test_x():\n assert True\n")
_git(fixrepo, "add", "-A")
_git(fixrepo, "commit", "-q", "-m", "test: add a test only\n\nProves: scripts/tests/test_extra.py")
r = _run(fixrepo, "HEAD")
assert r.returncode == 4, f"expected 4 (nothing to revert), got {r.returncode}\n{r.stderr}"
assert "nothing to revert" in r.stderr
def test_selector_matching_no_tests_REFUSES(fixrepo: Path) -> None:
"""`pytest` exits 5 when it collects nothing. Treating that as red would prove every
fix a check that examined nothing reporting success."""
r = _run(fixrepo, "HEAD", "scripts/tests/test_does_not_exist.py")
assert r.returncode == 5, f"expected 5 (no tests collected), got {r.returncode}\n{r.stderr}"
assert "NO tests" in r.stderr
def test_added_code_file_is_removed_not_checked_out(tmp_path: Path) -> None:
"""A file the fix ADDED does not exist in the parent. `git checkout parent -- <new>`
fails there, and if that failure were swallowed the fix would stay in place and every
run would report a false PROVEN."""
repo = tmp_path / "r2"
(repo / "scripts" / "tests").mkdir(parents=True)
subprocess.run(["git", "init", "-q", str(repo)], check=True)
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "T")
(repo / "scripts" / "tests" / "keep.py").write_text("# placeholder\n")
_git(repo, "add", "-A"); _git(repo, "commit", "-q", "-m", "initial")
# the fix ADDS helper.py (it has no parent version) and a test that needs it
(repo / "helper.py").write_text("def shout(s):\n return s.upper()\n")
(repo / "scripts" / "tests" / "test_helper.py").write_text(
"from helper import shout\n\n\ndef test_shout():\n assert shout('a') == 'A'\n"
)
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "feat: add shout\n\nProves: scripts/tests/test_helper.py")
r = _run(repo, "HEAD")
assert r.returncode == 0, f"expected PROVEN, got {r.returncode}\n{r.stdout}\n{r.stderr}"
assert "PROVEN" in r.stdout
def test_root_commit_REFUSES(tmp_path: Path) -> None:
repo = tmp_path / "r3"
repo.mkdir()
subprocess.run(["git", "init", "-q", str(repo)], check=True)
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "T")
(repo / "a.py").write_text("x = 1\n")
_git(repo, "add", "-A"); _git(repo, "commit", "-q", "-m", "root")
r = _run(repo, "HEAD", "scripts/tests")
assert r.returncode == 5
assert "root commit" in r.stderr
def test_runs_under_the_system_bash(fixrepo: Path) -> None:
"""macOS ships /bin/bash 3.2, where `mapfile` is absent AND yields an empty array
instead of erroring. Assert by EXECUTING under that bash, not by grepping the source
for "mapfile" the first version of this test did the latter and matched the comment
explaining why mapfile is avoided, which is a string predicate failing exactly as
docs/defect-shapes-773.md §3.7 says they do."""
system_bash = Path("/bin/bash")
if not system_bash.exists():
pytest.skip("/bin/bash not present")
# Named for the system bash, NOT for 3.2: on the Linux runner /bin/bash is 5.x, so a
# name promising bash-3.2 coverage would read as coverage that exists only on a
# developer Mac. The 3.2 hazard (mapfile yielding an empty array) is what motivated it.
ver = subprocess.run([str(system_bash), "--version"], capture_output=True, text=True).stdout
r = subprocess.run(
[str(system_bash), str(PROVE_FIX), "--repo", str(fixrepo), "HEAD",
"scripts/tests/test_add.py"],
capture_output=True, text=True, cwd=str(fixrepo),
)
assert r.returncode == 0, (
f"prove-fix.sh must work under the system bash ({ver.splitlines()[0] if ver else '?'}); "
f"got {r.returncode}\n{r.stdout}\n{r.stderr}"
)
assert "PROVEN" in r.stdout
def test_control_failure_REFUSES(fixrepo: Path) -> None:
"""A test that is ALREADY red with the fix in place proves nothing by being red after
a revert. Without this control, half a discrimination claim reads as the whole one."""
(fixrepo / "scripts" / "tests" / "test_broken.py").write_text(
"from calc import add\n\n\ndef test_broken():\n assert add(2, 3) == 99\n"
)
(fixrepo / "calc.py").write_text("def add(a, b):\n return a + b # unchanged\n")
_git(fixrepo, "add", "-A")
_git(fixrepo, "commit", "-q", "-m",
"fix: with an already-failing test\n\nProves: scripts/tests/test_broken.py")
r = _run(fixrepo, "HEAD")
assert r.returncode == 6, f"expected 6 (control failed), got {r.returncode}\n{r.stdout}\n{r.stderr}"
assert "control FAILED" in r.stderr
def test_control_green_then_red_is_reported_as_both(fixrepo: Path) -> None:
"""The PROVEN line must state both halves — green with, red without — because that
pair is the claim. Reporting only the red half is the overclaim being mechanised out."""
r = _run(fixrepo, "HEAD", "scripts/tests/test_add.py")
assert r.returncode == 0
assert "control" in r.stdout.lower()
assert "Green with the fix (control), red without it" in r.stdout
def test_MUTATION_disarming_the_UNPROVEN_clause_reddens_the_refusal_test(
tmp_path: Path,
) -> None:
"""Clause-level mutation, EXECUTED, with the unchanged test WITNESSED RED against it.
`docs/guard-inventory.md` is explicit that MUTATION means a mutation was executed and
the named test was *witnessed red* feeding the real script a rejecting input is
BEHAVIOUR-ONLY, and the column "is not a grading curve". An earlier version of this
test deleted the clause and then asserted the MUTANT misbehaved, which left this test
green and proved nothing about whether the clause is load-bearing. Cold review caught
that, and it was right.
So: delete the `RC -eq 0 -> UNPROVEN` clause (the CLAUSE, not the file #510), then
re-run the UNCHANGED `test_unrelated_test_is_UNPROVEN` against the mutant in a nested
pytest run. That test must go RED. Its red is the proof.
"""
real = REPO_ROOT / "scripts" / "prove-fix.sh"
src = real.read_text()
marker = 'if [ "$RC" -eq 0 ]; then'
assert marker in src, "the clause under mutation is gone — regrade the inventory row"
end = src.index(" exit 1\nfi\n", src.index(marker)) + len(" exit 1\nfi\n")
mutant = tmp_path / "prove-fix-mutant.sh"
mutant.write_text(src[: src.index(marker)] + src[end:])
assert marker not in mutant.read_text(), "mutation did not remove the clause"
env = {**os.environ, "PROVE_FIX_PATH": str(mutant), "PROVE_FIX_MUTATION_RUN": "1"}
nested = subprocess.run(
["python3", "-m", "pytest", f"{Path(__file__).name}::test_unrelated_test_is_UNPROVEN",
"-q", "-p", "no:cacheprovider"],
cwd=str(Path(__file__).parent), env=env, capture_output=True, text=True,
)
out = nested.stdout + nested.stderr
assert nested.returncode == 1, (
"the unchanged refusal test must go RED against the mutant (pytest exit 1). "
f"got {nested.returncode} — exit 2/3/4/5 would mean the nested run broke rather "
f"than the test failing, which proves nothing.\n{out[-2000:]}"
)
# Require a real reported FAILURE of that specific test. `returncode != 0` alone would
# be satisfied by a collection error — the vacuous shape this whole file is against.
assert "FAILED" in out and "test_unrelated_test_is_UNPROVEN" in out, (
"expected a reported failure of test_unrelated_test_is_UNPROVEN; the nested run "
f"failed for some other reason.\n{out[-2000:]}"
)
assert "1 failed" in out, f"expected exactly one failing test.\n{out[-1200:]}"
def test_SIGTERM_mid_run_never_reports_PROVEN(tmp_path: Path) -> None:
"""A killed run must not look like evidence.
An early DRAFT printed PROVEN and exited 0 after a SIGTERM: the trap cleaned up but did
not exit, so the previous status stood. On the first COMMITTED version (587edbecc) the
run reaches rc 5 by a different route entirely, so this test earns its keep only via the
assertions below see the comment there.
"""
repo = tmp_path / "slow"
(repo / "scripts" / "tests").mkdir(parents=True)
subprocess.run(["git", "init", "-q", str(repo)], check=True)
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "T")
(repo / "mod.py").write_text("VALUE = 1\n")
(repo / "scripts" / "tests" / "test_slow.py").write_text(
"import time\nfrom mod import VALUE\n\n\n"
"def test_slow():\n time.sleep(20)\n assert VALUE == 2\n"
)
_git(repo, "add", "-A"); _git(repo, "commit", "-q", "-m", "initial")
(repo / "mod.py").write_text("VALUE = 2\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "fix: bump\n\nProves: scripts/tests/test_slow.py")
proc = subprocess.Popen(
["bash", str(PROVE_FIX), "--repo", str(repo), "HEAD"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=str(repo),
)
time.sleep(4) # inside the control run, which sleeps 20s
assert proc.poll() is None, "the run finished before it could be signalled; test is void"
proc.terminate()
out, err = proc.communicate(timeout=60)
# Assert the observable THIS fix introduced, not merely "non-zero and no PROVEN":
# the pre-fix script also satisfied those two, by accident — its `trap cleanup EXIT INT
# TERM` fired, deleted $TMP, execution continued, and a later step died 5. Two different
# bugs landing on the same observable is not a witnessed fix. Cold review measured that
# pair failing to separate old from new; rc==5 AND the handler's own message do separate
# them.
assert proc.returncode == 5, (
f"a signalled run must exit 5 from on_signal, got {proc.returncode}\n{out}\n{err}"
)
assert "interrupted by signal" in err, (
f"expected the signal handler's own message, so this test cannot be satisfied by an "
f"unrelated later failure:\n{err}"
)
assert "PROVEN" not in out, f"a signalled run must not print a verdict:\n{out}"
def test_a_harness_failure_is_NOT_reported_as_PROVEN(tmp_path: Path) -> None:
"""The marker-absence branch, reached the way the old false green was reached.
An earlier version stubbed `python3` to exit 127, which does NOT reach the marker logic:
the marker IS written (127) and the script exits via the control-failure branch. Cold
review measured that, and it is why the round-2 commit came back UNPROVEN from this
tool's own gate — the fix was executed by no test.
The real shape is `( cd X && pytest ); rc=$?` returning 1 because `cd` FAILED and pytest
never ran; pre-fix that was accepted as red and produced PROVEN. Reproduced by shimming
`git` so the SECOND `worktree add` (the reverted phase) exits 0 without creating the
directory. The fixture's fix ADDS its code file, so the revert step is `rm -f` — which
succeeds on a missing directory and lets execution reach the phase's `cd`.
"""
repo = tmp_path / "addrepo"
(repo / "scripts" / "tests").mkdir(parents=True)
subprocess.run(["git", "init", "-q", str(repo)], check=True)
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "T")
(repo / "scripts" / "tests" / "keep.py").write_text("# placeholder\n")
_git(repo, "add", "-A"); _git(repo, "commit", "-q", "-m", "initial")
(repo / "added.py").write_text("def val():\n return 7\n")
(repo / "scripts" / "tests" / "test_added.py").write_text(
"from added import val\n\n\ndef test_val():\n assert val() == 7\n"
)
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "feat: add val\n\nProves: scripts/tests/test_added.py")
shim = tmp_path / "bin"; shim.mkdir()
counter = tmp_path / "count"
(shim / "git").write_text(
"#!/bin/sh\n"
'if [ "$3" = "worktree" ] && [ "$4" = "add" ]; then\n'
f' n=$(cat "{counter}" 2>/dev/null || echo 0); n=$((n+1)); echo "$n" > "{counter}"\n'
' if [ "$n" -ge 2 ]; then exit 0; fi\n'
"fi\n"
'exec /usr/bin/git "$@"\n'
)
(shim / "git").chmod(0o755)
env = {**os.environ, "PATH": f"{shim}:{os.environ['PATH']}"}
r = subprocess.run(
["bash", str(PROVE_FIX), "--repo", str(repo), "HEAD"],
capture_output=True, text=True, cwd=str(repo), env=env,
)
assert "PROVEN" not in r.stdout, (
"a phase whose worktree does not exist cannot witness anything; pre-fix this "
f"produced PROVEN from the subshell's status:\n{r.stdout}\n{r.stderr}"
)
assert r.returncode == 5, f"expected refusal (5), got {r.returncode}\n{r.stdout}\n{r.stderr}"
assert "did not complete" in r.stderr, (
f"expected the marker-absence diagnostic, not some other refusal:\n{r.stderr}"
)