Skip to content

ci: fail a PR whose merge silently deletes a test, unless it names it (#1976) - #2036

Merged
macanderson merged 2 commits into
mainfrom
deleted-test-guard
Aug 7, 2026
Merged

ci: fail a PR whose merge silently deletes a test, unless it names it (#1976)#2036
macanderson merged 2 commits into
mainfrom
deleted-test-guard

Conversation

@macanderson

@macanderson macanderson commented Aug 7, 2026

Copy link
Copy Markdown
Owner

What & why

Three times a PR has landed on main that silently deleted code another PR
added to the same file hours earlier, and CI could not see it.

The most recent (fixed in #1975): #1951 rewrote
crates/stella-pipeline/src/pipeline/tests/verification_hardening.rs from a
pre-#1945 base, deleting the PassingShell double, shell_call_result, and the
witness a_revision_halts_at_the_step_where_the_tracked_test_flips that #1945
had added hours earlier.

It was caught only by luck: #1945 had also added a mod flip_halt_arming;
line that survived the rewrite and referenced two of the deleted symbols, so
main went red. Had #1945 added only the test and its doubles — no new module —
the deletion would have compiled clean and silently removed a witness from the
tree. A witness that no longer exists cannot fail, so nothing downstream would
ever have reported it. Same shape on record for #1860 reverting #1836's
forwarding in four crates.

Both PRs are green against the main they branched from, and the merge is
textually clean: git has no conflict to report, because one side simply does not
contain the other's lines.

The design decision that matters

scripts/check-deleted-tests.sh compares the base branch tip against the
merge result — never the PR's branch point.

That pair is the whole guard. Comparing against the branch point would miss
exactly this defect: a test added to main after the PR branched is absent
from the branch point too, so its disappearance would look like nothing at all.
Comparing main's tip against the merged tree asks the question that matters —
"did everything main had survive this merge?" — and is quiet on a merely stale
branch, because git merges main's own additions in unless the PR's side actively
removed them.

On a pull_request event the checkout is refs/pull/N/merge, so HEAD^1 is
the base branch tip. The guard needs no PR metadata, and because it compares two
trees rather than two histories, fetch-depth: 2 is sufficient — no
full-depth clone.

It asks for an acknowledgement, it does not forbid deletion

A removed test is not automatically wrong — renames, folding into a table-driven
case, and deliberate removal with the feature covered are all ordinary. So a
removal fails only while unnamed: writing the test's name in the PR
description (or a commit message) passes it.

That mechanism is deliberately weak. The goal is not to adjudicate whether a
deletion was correct — a script cannot — but to convert an invisible deletion
into a sentence a reviewer reads.

Deliberately NOT a GATE_STEPS entry

It is the one question here about two trees, and a local make gate has no
second tree to compare, so there is nothing for it to do there. This also means
the five-edit gate-parity dance does not apply; check-gate-parity still reports
25 steps, unchanged.

Closes #1976

The witness

  • This PR includes a witness test (fails on main, passes here), or
  • No witness needed

The witness is reproduced from real history, not a synthetic fixture —
eddf9700 is #1945 (added the witness), 2a142b26 is #1951 (deleted it):

$ PR_BODY="" ./scripts/check-deleted-tests.sh eddf9700 2a142b26
check-deleted-tests: FAILED

These tests exist in eddf9700 but not in the merged tree, and nothing
in the PR description or the branch's commit messages names them:

  a_revision_halts_at_the_step_where_the_tracked_test_flips

It names exactly the test that was really lost. The other three required
behaviours, all verified:

$ PR_BODY="Folded a_revision_halts_at_the_step_where_the_tracked_test_flips into …" \
    ./scripts/check-deleted-tests.sh eddf9700 2a142b26
check-deleted-tests: OK — 1 removed test(s), each named in the PR description or a commit.

$ ./scripts/check-deleted-tests.sh 1feb0292 eddf9700      # a range that only ADDS tests
check-deleted-tests: OK — 6790 test(s) in 1feb0292, none lost by the merge.

$ ./scripts/check-deleted-tests.sh origin/main HEAD       # this very branch
check-deleted-tests: OK — 6865 test(s) in origin/main, none lost by the merge.

A genuine rename is reported too (the old name is gone), which is intended —
naming it in the PR is the whole cost.

The gate

  • shellcheck — clean
  • make guards-fast — all green, including check-gate-parity (25 steps,
    unchanged) and check-action-pins
  • Docs updated: AGENTS.md § witness tests gains the paragraph, and the CI
    description now lists the guard among what ci.yml adds beyond the gate
  • Closes #1976 appears both here and as a commit trailer

No Rust changed, so fmt/clippy/test are untouched by this PR.

Nothing left behind

  • Filed: see below

Two limitations are measured and documented in the script header rather than
left implicit:

  1. The key is the bare test name, unqualified by file or module. That makes
    a test moved between modules silently fine (the common legitimate case),
    at the cost that a duplicated name masks a deletion. Measured: 51 of 6867
    distinct test names (0.74%) are duplicated today, nearly all the per-adapter
    provider suites where one contract is asserted against each vendor under one
    name. The house style of long sentence-shaped test names is what makes the
    unqualified key work — it is not a general assumption.
  2. #[test] inside a multi-line string fixture is counted. Two lines in the
    tree (witness/density.rs, candidate_ws/witness_tools.rs — code that
    analyses test code) put #[test] at the start of a continuation line inside
    a string literal. It is symmetric noise, so a difference detector cancels it;
    it could only false-positive if such a fixture were edited, which lands in
    the acknowledge path by design.

Ground-rule check

  • No I/O added to stella-core; no new deps
  • No new outbound network calls

Anything reviewers should know?

fetch-depth: 2 on the check job's checkout is the one change that touches
every CI run. It is one extra commit, not a full clone — deliberately the
smallest thing that makes the merge commit's first parent readable.

The guard runs on pull_request only: on a squash-merged push to main there is
no merge commit to inspect, and the report would arrive too late to act on
anyway. The script self-skips on a non-merge HEAD with no explicit base, so
running it by hand needs a base ref: ./scripts/check-deleted-tests.sh origin/main.

Summary by Sourcery

Add a CI guard that detects tests removed by a PR’s merge unless their deletion is explicitly acknowledged, and document this behaviour in agent guidance.

CI:

  • Update the main CI workflow to fetch two commits for pull_request checkouts and run a deleted-test guard that compares the base branch tip to the merge result, passing only when removed tests are named in the PR body.

Documentation:

  • Extend AGENTS.md to describe the deleted-test guard, its scope, and its requirement to mention removed tests in PR descriptions.

Chores:

  • Add the scripts/check-deleted-tests.sh utility to scan test trees across two revisions and enforce acknowledgement of removed tests.

Three times a PR has landed on `main` that deleted code another PR added to
the same file hours earlier, invisibly. The most recent: #1951 rewrote
crates/stella-pipeline/src/pipeline/tests/verification_hardening.rs from a
pre-#1945 base, dropping the witness
`a_revision_halts_at_the_step_where_the_tracked_test_flips` that #1945 had
added the same day. Both PRs were green against the `main` they branched
from, and the merge was textually clean — git had no conflict to report,
because one side simply did not contain the other's lines.

It was caught only by luck: #1945 had also added a `mod flip_halt_arming;`
line that survived the rewrite and referenced two deleted symbols, so `main`
went red. Had #1945 added only the test and its doubles, the deletion would
have compiled clean. A witness that no longer exists cannot fail, so nothing
downstream would ever have reported it.

`scripts/check-deleted-tests.sh` compares the base branch tip against the
merge result and names any `#[test]`/`#[tokio::test]` that did not survive.
That pair is the design: comparing against the PR's branch point would miss
exactly this defect, since a test added to `main` after the branch point is
absent there too. Comparing against the merged tree asks "did everything main
had survive?", and is quiet on a merely stale branch because git merges
main's own additions in unless the PR's side removed them.

It asks for an acknowledgement rather than forbidding deletion: renames and
deliberate removals are ordinary, so a removed test passes once the PR
description names it. The goal is not to adjudicate whether a deletion was
correct — a script cannot — but to turn an invisible deletion into a sentence
a reviewer reads.

Deliberately NOT a GATE_STEPS entry: it is the one question here about two
trees, and a local `make gate` has no second tree to compare. It runs on
`pull_request` only, needs `fetch-depth: 2` (two trees, not two histories),
and keys on the bare test name so a test moved between modules stays quiet.
The cost of the unqualified key is measured in the script header rather than
assumed: 51 of 6867 distinct test names (0.74%) are duplicated today, nearly
all per-adapter provider suites.

Witness, reproduced from the real history rather than a fixture:

    $ ./scripts/check-deleted-tests.sh eddf970 2a142b2   # 1945 -> 1951
    check-deleted-tests: FAILED
      a_revision_halts_at_the_step_where_the_tracked_test_flips

and with the name in PR_BODY it passes, as does a range that only adds tests.

Closes #1976
A `#[test]` at the start of a Rust line-continuation inside a string literal
is counted as a test. Two lines in the tree do it — witness/density.rs and
candidate_ws/witness_tools.rs, both code that analyses test code. The guard
is a difference detector, so the noise is symmetric and cancels; it can only
speak up if such a fixture is edited, which lands in the acknowledge path.

Documented rather than parsed around, for the reason check-left-behind.sh
gives about the same shape: a shell script cannot reliably tell whether a
token is inside a string, and one that guesses wrong cries wolf.
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
stella-cli-docs Ignored Ignored Aug 7, 2026 4:37am

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @macanderson, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Adds a CI guard that compares the base branch tip to the PR merge result to detect tests deleted by the merge unless explicitly acknowledged, wires it into the GitHub Actions workflow, documents its behavior and limitations in AGENTS.md, and implements the guard as a shell script that scans Rust test functions between two git trees.

Sequence diagram for the new deleted-test CI guard on pull_request

sequenceDiagram
    participant github_actions
    participant checkout
    participant check_deleted_tests_sh

    github_actions->>checkout: actions/checkout
    note right of checkout: fetch-depth: 2
    github_actions->>check_deleted_tests_sh: run ./scripts/check-deleted-tests.sh
    activate check_deleted_tests_sh
    check_deleted_tests_sh-->>check_deleted_tests_sh: read PR_BODY env
    check_deleted_tests_sh-->>check_deleted_tests_sh: compare base_branch_tip tree vs merge_result tree
    alt [no tests removed]
        check_deleted_tests_sh-->>github_actions: exit 0 (OK — none lost by the merge)
    else [tests removed and all names mentioned in PR_BODY]
        check_deleted_tests_sh-->>github_actions: exit 0 (OK — removed test(s) acknowledged)
    else [tests removed and at least one name not mentioned]
        check_deleted_tests_sh-->>github_actions: exit 1 (FAILED — unnamed removed test(s))
    end
    deactivate check_deleted_tests_sh
Loading

File-Level Changes

Change Details Files
Introduce a shell-based CI guard that detects tests removed by a merge unless they are named in the PR description or commits.
  • Add scripts/check-deleted-tests.sh implementing the guard logic around two git trees (base vs merge result).
  • Scan Rust sources at each revision for #[test]/#[tokio::test] functions and compare names to find removed tests.
  • Require removed tests to be mentioned in PR_BODY or commit messages; fail with guidance if unacknowledged.
  • Handle merge/non-merge HEADs, shallow clones, and local/manual invocation with explicit base refs.
scripts/check-deleted-tests.sh
Wire the deleted-test guard into the CI workflow so it runs on pull_request merges with sufficient git history.
  • Update actions/checkout step in ci.yml to use fetch-depth: 2 so the merge commit’s first parent is available.
  • Add a CI step that runs scripts/check-deleted-tests.sh only on pull_request events, passing github.event.pull_request.body as PR_BODY.
  • Document in comments that this guard operates on two trees, is not part of make gate, and only runs where a merge commit exists.
.github/workflows/ci.yml
Document the new deleted-test guard and its role in witness-test guarantees.
  • Mention scripts/check-deleted-tests.sh in the CI overview as an additional guard beyond the local gate.
  • Add a paragraph to AGENTS.md explaining the silent-test-deletion problem, how check-deleted-tests compares base vs merge trees, and that deletions are allowed but must be named in the PR description.
  • Clarify that this guard is CI-only and intentionally not part of make gate because local runs lack a second tree.
AGENTS.md

Assessment against linked issues

Issue Objective Addressed Explanation
#1976 Implement a deleted-test guard script that compares the merge base against the merge result, detects #[test]/#[tokio::test] functions present in the base but absent in the merged tree, and fails unless each removed test is acknowledged in the PR body or commit messages.
#1976 Integrate the deleted-test guard into CI so that on pull_request merges, a test deleted by the merge cannot reach main without being explicitly named, while remaining quiet on PRs that do not delete tests.
#1976 Document the deleted-test guard in the CI/gate documentation (e.g., AGENTS.md) and clarify that it runs in CI rather than as a local make gate step.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@macanderson
macanderson merged commit 9a4d631 into main Aug 7, 2026
15 checks passed
@macanderson
macanderson deleted the deleted-test-guard branch August 7, 2026 04:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A merge that deletes another PR's test is invisible to CI — add a deleted-test guard

1 participant