feat(ci): scope mutation testing to the pull request diff - #37664
Open
ryan-crabbe-berri wants to merge 7 commits into
Open
feat(ci): scope mutation testing to the pull request diff#37664ryan-crabbe-berri wants to merge 7 commits into
ryan-crabbe-berri wants to merge 7 commits into
Conversation
The existing mutation workflow is manual and mutates a whole folder, which takes hours, so nothing about a pull request's own test quality gets checked before it merges. This adds the diff-scoped half, following Google's approach in "State of Mutation Testing at Google": mutate what the change touched, not the codebase. scripts/mutation_diff_scope.py reads the diff against the merge base, rewrites [tool.mutmut] so paths_to_mutate is the changed production files and tests_dir is the tests mirroring them, then emits mutant-name globs for the functions holding the changed lines. mutmut trampolines per function and per method, so that is the smallest unit it can be asked to run. The new workflow is advisory: results land in the job summary and an artifact, and nothing blocks a merge. A --max-functions cap bounds the worst case and prints what it dropped rather than truncating quietly. The report script now counts only the mutants a run actually executed, since a diff-scoped run leaves everything else at "not checked" and the old summary folded those into the score.
Contributor
Greptile SummaryThe PR adds pull-request mutation testing scoped to changed production functions and their mirrored tests.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| .github/workflows/mutation-test-pr.yml | Adds an advisory PR workflow that scopes mutation execution and propagates incomplete-run report failures. |
| scripts/mutation_diff_scope.py | Builds mutation scope from the merge-base diff and now handles the previously reported deletion-boundary cases. |
| scripts/mutation_report.py | Produces class-aware survivor reports and rejects empty or explicitly incomplete mutation runs. |
| tests/test_litellm/test_mutation_diff_scope.py | Covers function and method scoping, mirrored test selection, and deletion-only boundary behavior. |
| tests/test_litellm/test_mutation_report.py | Covers mutant-name parsing, class disambiguation, result summaries, and incomplete-run detection. |
Reviews (5): Last reviewed commit: "fix(ci): drop deletions with no survivin..." | Re-trigger Greptile
ryan-crabbe-berri
force-pushed
the
litellm_mutation_test_pr_gate
branch
from
August 20, 2026 19:00
1cc9746 to
65df36c
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…at checked nothing
Contributor
Author
|
@greptile re review |
Contributor
Author
|
@greptileai re review |
Contributor
Author
|
@greptile re review |
…blaming the next function
Contributor
Author
|
Verified: deleting a function's last statement keeps it in scope. Deleted functions have no code to mutate. Added both tests. |
Contributor
Author
|
@greptileai re review |
…itellm_mutation_test_pr_gate
Timing the gate against three real open pull requests turned up two ways the test selection went wide. A private module never matched its test file, since litellm/_redis.py globs test__redis*.py while the file is test_redis.py, and the miss fell through to the mirror directory. For a module at the top of litellm/ that directory is tests/test_litellm itself, so every mutant would have run the entire unit suite. The same fallback pulled all of tests/test_litellm/caching into a caching diff and picked up an unrelated order-dependent s3 test, which fails on an unmutated tree and aborts the run before a single mutant executes. Private modules now also try the underscore-stripped spelling, and the directory fallback stops at the mirror root instead of returning it. Overload stubs are deduplicated too: they repeat the implementation's name, so a touched signature asked for the same function three times and burned three slots of --max-functions.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TLDR
Problem this solves:
How it solves it:
User Flow
Before: a contributor changes one line of production code and merges with no idea whether their tests would notice if that line were wrong
litellm/proxy/management_endpoints/common_utils.pyand push the branchAfter: the same pull request gets a per-diff mutation result in under a minute of test time
Relevant issues
Linear ticket
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Screenshots / Proof of Fix
Shared setup, applied on top of each hash below, standing in for a contributor's one-line change:
Before (282bcda)
Nothing is triggered by
pull_request, so pushing the change above produces no mutation result at allTrigger the manual job by hand instead, and it mutates a whole folder on a 350 minute budget rather than the one changed function:
After (65df36c)
Case A: as written, the mirrored tests kill every mutation
Case B: same change, after deleting the existing
test_is_user_team_admin_user_not_in_teamCase C: a class method, and a run that checks nothing
CoroutineChecker.is_async_callable:Case D: a run that stops partway through
mutmut catches an interrupt and still exits 0, so the job cannot read completeness off its exit code. What the run was asked for is the contract, so the report compares the requested globs against what came back. Executing 3 of the 27 requested mutants leaves exactly the state a killed run leaves:
Case E: deletions at a function boundary
Deleted lines are charged to the line above them, so a deletion anywhere inside a function, including its last statement, keeps that function in scope. Deleting a whole function leaves nothing to mutate, and the neighbour that moves up into its place is not charged for it:
Case F: three real open pull requests
Every number so far comes from a one-function diff, so the gate was also pointed at the head of three unrelated open pull requests, base
origin/litellm_internal_staging, same machine, one at a time:Nine of those seconds are fixed, measured by asking for a single mutant: building the
mutants/sandbox and running the selected tests once against clean code. The rest divides into 0.02s per mutant where the selection is one self-contained test file and about a second per mutant where it reaches into the proxy import graph, so wall clock follows the tests a diff selects much more closely than it follows the diff's size. This machine runs the gate about three times faster than the CI runner did on the same ten-mutant scope, 34s against 102sThe exercise paid for itself twice. Both #37740 and #37742 first came back selecting an entire test directory, because
litellm/_redis.pylooks fortest__redis*.pywhile the file istest_redis.py, and a miss fell through to the mirror directory, which for a module at the top oflitellm/is the whole unit suite. On #37742 that dragged in an unrelated order-dependent s3 test that fails on unmutated code, and mutmut refuses to trust a red baseline, so the run aborted before a single mutant ran. Touching an overloaded signature also asked for the same function once per@overloadstub. Both are fixed, and the table above is the run after the fixIn CI (both paths, on this pull request's own checks)
1cc9746d18) purely to trigger the job, then dropped from the branch. Run 32405681200 is green, and itsmutmut-scope.logartifact shows CI picked the same single function the local run did:mutation-report.mdartifact from that run reproduces Case A, so the summary a reviewer sees in the job is the one produced locally:Skip path: on the branch tip, where the diff touches only tooling and tests, run 32405523726 is green in 66 seconds, skips every heavy step and posts "nothing in scope" instead. A pull request that changes no production code pays checkout and nothing else
Cost of the hot run, every step, cold caches:
Mutation itself is 102 of those 382 seconds, and everything around it is fixed cost the diff cannot change. The job clears 5 minutes only once the uv and Prisma caches are warm; cold it runs about 6m20s. Scale Case F by the 3x this runner costs against the machine those numbers came from and a real pull request adds roughly 1 to 9 minutes of mutation on top, so the heaviest of the three would sit near the 10 minute step timeout.
continue-on-errorkeeps that off the critical path: a timeout reads as an unfinished run, which is the one thing this gate refuses to call cleanType
🚄 Infrastructure
✅ Test
Caveats (if any)
--max-functionscaps a big diff at 40 functionsFinal Attestation