feat(794): witness a fix's test failing BEFORE the fix, and check the claim in CI
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 26s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 21s
review-verdict/h10 Awaiting review verdict for 587edbe
Review verdict / Set review-verdict status (pull_request_target) Successful in 15s
PR Gates / Script tests (pytest) (pull_request) Successful in 3m57s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Canceled after 6m3s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Canceled after 0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s

Closes the mechanised half of the defect that took #776 and #793 six review rounds
each: a fix's test written to confirm the fix rather than to discriminate against its
absence. This is testing.guard-ships-with-mutation-proof generalised from guards to
fixes — refs #794, and #796 is the same finding measured from the other side.

scripts/prove-fix.sh checks a RED-GREEN PAIR, not a red. It runs the selector at the
commit (control, must be green), then reverts only the commit's non-test files and runs
it again (must be non-zero). The control is what makes the second run mean anything: a
test already failing proves nothing by failing again. That hole was real — the first
version had no control, and this file's own tests found it.

Two exit-code traps, both found by the tests rather than reasoned about:
- pytest exits 4 for a path that does not exist and 5 when it collects nothing. The
  first version handled only 5, so a mistyped selector fell through to "non-zero implies
  red" and would have PROVEN every fix.
- exit 2 (collection error) after a green control IS accepted: when the fix adds the
  module the test imports, reverting makes it unloadable, which is the strongest
  discrimination available. Refusing it would leave every add-a-file fix unprovable.

Opt-in by trailer, enforced when present. Requiring Proves: on every commit would block
docs/CI/refactor commits with no code side, and a gate that blocks ordinary work gets
switched off — that is how a check ends up running nowhere (#631). The trailer is the
author's claim; CI checks claims. When a PR carries none the job says out loud that its
green asserts nothing.

Graded MUTATION honestly. test_unrelated_test_is_UNPROVEN only shows the script reacts
to its input, which docs/guard-inventory.md calls BEHAVIOUR-ONLY. The MUTATION row cites
a test that deletes the UNPROVEN clause and witnesses the prover wrongly reporting
PROVEN — clause, not file (#510).

Also avoided mapfile: absent from bash 3.2 (macOS /bin/bash), where a missing builtin
leaves the array EMPTY rather than erroring, and the test asserts by EXECUTING under
/bin/bash rather than grepping the source — the first version grepped for "mapfile" and
matched the comment explaining why it is avoided.

Run against real commits: 84165ab75 PROVEN against its own test, UNPROVEN against an
unrelated one, b8f60bdec refused as test-only.

Proves: scripts/tests/test_prove_fix.py
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-16 10:16:00 +02:00
co-authored by Claude Opus 5
parent 107716fa4f
commit 587edbecc0
6 changed files with 545 additions and 1 deletions
+55
View File
@@ -211,6 +211,61 @@ 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"
claimed=0; proven=0; failed=0
for sha in $(git rev-list --no-merges "$base".."$head"); do
sel="$(git show -s --format='%(trailers:key=Proves,valueonly)' "$sha" | head -1 | xargs || true)"
[ -n "$sel" ] || continue
claimed=$((claimed + 1))
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
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
+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,53 @@
---
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
non-zero). 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.
**Exit 4 and 5 are not red.** pytest exits 4 for a path that does not exist and 5 when it collects
nothing. Treating "non-zero" as red would make a mistyped selector prove every fix. Conversely exit
2 (a collection error) after a green control *is* accepted: when the fix ADDS the module the test
imports, reverting makes the test unloadable, which is the strongest discrimination available.
**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
@@ -97,6 +97,7 @@ all.
| `scripts/ci-peak-anon.sh` | nothing (samples container memory) | TOOLING | NONE | — |
| `scripts/ci-prove-ban-detects.sh` | the release path, if the delimiter ban is disarmed | GUARD | NONE | — |
| `scripts/ci-step-ran.sh` | the two required contexts, on a dropped step | GUARD | MUTATION | `test_ci_dropped_step_guard.py::test_dropping_ANY_single_step_FAILS_the_guard` |
| `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_makes_it_prove_anything` |
| `scripts/decisions_validate.py` | the `decisions-guard` job, on a lifecycle fault | GUARD | MUTATION | `test_decisions_validate.py::test_main_actually_CALLS_the_wing_scan` |
| `scripts/e2e-functional.sh` | the Functional E2E job, on a failed HTTP contract assertion | GUARD | NONE | — |
| `scripts/e2e-local.sh` | nothing (boots a local instance) | TOOLING | 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,
+182
View File
@@ -0,0 +1,182 @@
#!/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. Given a commit, check it out in a throwaway worktree, revert ONLY its non-test
# files (keeping the tests it added), and run the named tests. They must come out RED.
# Green means the test does not discriminate — it would have passed without the fix, so
# it pins nothing.
#
# 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 the selector from the touched
# test files silently does nothing for a fix that edits an existing test, and a prover
# that quietly proves nothing is worse than one that refuses.
#
# Exit codes:
# 0 PROVEN — reverting the fix turned the named tests red
# 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, or selector failure
# 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:-}"
die() { printf '%s\n' "$1" >&2; exit "$2"; }
[ -n "$COMMIT" ] || die "usage: prove-fix.sh [--repo DIR] <commit> [test-selector]" 2
[ -d "$REPO/.git" ] || 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 root commit has no parent to revert to.
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
SELECTOR="$(git -C "$REPO" show -s --format='%(trailers:key=Proves,valueonly)' "$SHA" \
| head -1 | sed 's/[[:space:]]*$//')"
fi
[ -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
# Deliberately NOT `mapfile`: it does not exist in bash 3.2 (macOS /bin/bash) and, worse,
# a missing builtin there leaves the array EMPTY rather than failing — a silent zero from
# a tool that enumerates the thing being checked is the defect this milestone is about.
CHANGED=()
while IFS= read -r line; do
[ -n "$line" ] && CHANGED+=("$line")
done < <(git -C "$REPO" diff-tree --no-commit-id --name-only -r "$SHA")
[ "${#CHANGED[@]}" -gt 0 ] || die "commit touches no files: $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=(); TEST_FILES=()
for f in "${CHANGED[@]}"; do
if is_test_path "$f"; then TEST_FILES+=("$f"); else NON_TEST+=("$f"); 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[@]}"
# ---------------------------------------------------------------- worktree
TMP="$(mktemp -d "${TMPDIR:-/tmp}/prove-fix.XXXXXX")" || die "mktemp failed" 5
WT="$TMP/wt"
cleanup() {
git -C "$REPO" worktree remove --force "$WT" >/dev/null 2>&1 || true
rm -rf "$TMP" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
git -C "$REPO" worktree add --detach "$WT" "$SHA" >/dev/null 2>&1 \
|| die "could not create a worktree at $SHA" 5
# ---------------------------------------------------------------- control run
# FIRST run the selector WITH the fix in place. A test that does not pass here proves
# nothing when it later fails: red-without-the-fix only means something if it was green
# WITH it. Without this control, a broken selector or an already-failing test would sail
# through as PROVEN — half of a discrimination claim presented as the whole.
run_pytest() { # $1 = worktree dir; echoes nothing, returns pytest's exit code
( cd "$1" && PYTHONPATH=. python3 -m pytest $SELECTOR -q >"$TMP/out.txt" 2>&1 )
}
echo "--- control: running the selector WITH the fix ---"
run_pytest "$WT"; CONTROL_RC=$?
echo "--- control pytest exit: $CONTROL_RC ---"
if [ "$CONTROL_RC" -eq 4 ] || [ "$CONTROL_RC" -eq 5 ]; then
sed -n '1,20p' "$TMP/out.txt" >&2
die "selector ran NO tests (pytest exit $CONTROL_RC): $SELECTOR
Exit 4 means the path does not exist; 5 means nothing was collected. Nothing was
witnessed, so this cannot count as red. Fix the selector." 5
fi
if [ "$CONTROL_RC" -ne 0 ]; then
sed -n '1,20p' "$TMP/out.txt" >&2
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
fi
# ---------------------------------------------------------------- revert + run
# Revert the code side only. A file the commit ADDED does not exist in the parent, so it
# is removed rather than checked out — `git checkout parent -- <new file>` would fail and
# leave the fix in place, silently turning every run into a false PROVEN.
for f in "${NON_TEST[@]}"; do
if git -C "$REPO" cat-file -e "${PARENT}:${f}" 2>/dev/null; then
git -C "$WT" checkout "$PARENT" -- "$f" 2>/dev/null \
|| die "could not revert $f to $PARENT" 5
else
rm -f "$WT/$f" || die "could not remove added file $f" 5
fi
done
echo "--- running the selector WITHOUT the fix ---"
run_pytest "$WT"; RC=$?
echo "--- pytest exit: $RC ---"
# With a green control established, ANY non-zero here is discrimination — including a
# collection error (exit 2), which is what happens when the fix ADDED the module the test
# imports. Refusing to count that would leave every add-a-file fix unprovable, and it is
# the strongest possible signal: without the fix the test cannot even load.
if [ "$RC" -eq 4 ] || [ "$RC" -eq 5 ]; then
sed -n '1,20p' "$TMP/out.txt" >&2
die "after reverting, the selector ran NO tests (pytest exit $RC).
The control collected tests and this run did not, so the revert changed what is
collectable in a way this script cannot interpret. Investigate by hand." 5
fi
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
printf '\nPROVEN %s\n' "$SHA"
printf '%s\n' "Green with the fix (control), red without it (pytest exit $RC)."
exit 0
+251
View File
@@ -0,0 +1,251 @@
"""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 subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
PROVE_FIX = 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_3_2(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")
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_makes_it_prove_anything(
fixrepo: Path, tmp_path: Path
) -> None:
"""Clause-level mutation, executed: delete the `RC -eq 0 -> UNPROVEN` branch and the
prover reports PROVEN for a test that passes without the fix.
This is the load-bearing proof for `prove-fix.sh`. `test_unrelated_test_is_UNPROVEN`
only shows the script reacts to its input, which by `docs/guard-inventory.md`'s
definition is BEHAVIOUR-ONLY. Witnessing the mutation is what shows the clause is
what produces the refusal — the distinction #775 exists for, and #685 is the
incident where two guards on one condition both passed every behavioural test while
neither was load-bearing.
Mutate the CLAUSE, not the file (#510): everything else stays intact, so a pass here
cannot be explained by the script simply breaking.
"""
src = PROVE_FIX.read_text()
marker = 'if [ "$RC" -eq 0 ]; then'
assert marker in src, "the clause under mutation is no longer present — regrade this row"
end = src.index(' exit 1\nfi\n', src.index(marker)) + len(' exit 1\nfi\n')
mutated = src[: src.index(marker)] + src[end:]
assert 'if [ "$RC" -eq 0 ]; then' not in mutated, "mutation did not remove the clause"
victim = tmp_path / "prove-fix-mutated.sh"
victim.write_text(mutated)
# Unmutated: the unrelated test is correctly refused.
before = _run(fixrepo, "HEAD", "scripts/tests/test_unrelated.py")
assert before.returncode == 1 and "UNPROVEN" in before.stdout
# Mutated: the same input is now wrongly PROVEN — the clause was doing the work.
after = subprocess.run(
["bash", str(victim), "--repo", str(fixrepo), "HEAD",
"scripts/tests/test_unrelated.py"],
capture_output=True, text=True, cwd=str(fixrepo),
)
assert after.returncode == 0, (
"disarming the UNPROVEN clause should let a non-discriminating test through; "
f"got {after.returncode}\n{after.stdout}\n{after.stderr}"
)
assert "PROVEN" in after.stdout and "UNPROVEN" not in after.stdout