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
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:84165ab75PROVEN against its own test, UNPROVEN against an unrelated one,b8f60bdecrefused 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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
Executable
+182
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user