Repository navigation
Conversation
scripts/mutate_changed.py diffs the PR against its base, finds the tests covering each changed line through per-test coverage contexts, and runs up to 40 AST mutants (flip a comparison, swap and/or, negate a condition, return None, flip a bool, bump an int, swap +/-, drop not) against only those tests, in a copy of the tree. Survivors go to the step summary and as warnings. New ci.yml job mutation-diff (PR only, after static, 30 min cap); make ci-local runs it from the workflow. AGENTS.md documents it next to the by-hand step. Signed-off-by: Petrov, Nikolay A <nikolay.a.petrov@intel.com>
…y in its own step Signed-off-by: Petrov, Nikolay A <nikolay.a.petrov@intel.com>
There was a problem hiding this comment.
🟡 Changes recommended
Shared temporary-tree bytecode caches can cause mutants to execute stale code and produce incorrect results, while incomplete timeout cleanup can leave child processes running during subsequent runs.
2 open issues
What changed in this PR
2 new findings.
This PR adds warn-only mutation testing for changed package lines, complementing changed-line coverage and the existing weekly whole-module mutation job.
Changes:
- Adds a budgeted mutation runner that selects covering tests and reports survivors in Markdown, JSON, and annotations.
- Adds a pull-request-only CI job with bounded runtime and a separate summary step.
- Documents local usage, adds focused tests, and ignores generated reports.
| File | Description |
|---|---|
dokimi/tests/test_mutate_changed.py |
Tests mutation generation, source edits, selection, diff parsing, and reporting. |
dokimi/tests/test_ci_label_triggers.py |
Excludes the PR-only mutation job from expected push jobs. |
dokimi/scripts/mutate_changed.py |
Implements changed-line mutation testing and reports. |
AGENTS.md |
Documents the warn-only job and local command. |
.gitignore |
Ignores generated mutation reports. |
.github/workflows/ci.yml |
Adds the mutation job and summary publication step. |
Review effort: Balanced
|
OpenCodeReview: Commit 0c8e87d was not reviewed. This PR has reached its review limit (6 reviews). CI at `0c8e87d`: 12 still running, 4 passing, 1 skipped
2 more not listed. To review this commit: re-request review from exai-bot in the Reviewers list. <details><summary>Last review (19619fa): no findings</summary> Open threads from the bot: 0 </details> |
The script has a shebang, so it is committed executable like its siblings (EXE001). deptry skips it the way it skips the other CI-only scripts. _edits splits into two helpers to get under the complexity cap of 10. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
Mutants are written into one tree. A same-size rewrite within the same second matched the cached bytecode of the previous text, so a mutant could be judged on the original code. Drop the module's .pyc on every write; the test forces the same mtime and expects kills. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
--max-tests ran the first 30 covering node ids by name, so a mutant that only a later test catches was reported as survived (on intel#641's merge, two stats.py:167 mutants its own new test kills). pytest reads the full list from a file; -x still stops at the first failure. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
No test ran run(): an inverted survived/killed mapping, a test that fails unmutated left in the covering set, or a lost --fail-on-survivor exit code all kept the suite green. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
subprocess.run's timeout killed only pytest: a test's child kept running the mutant, and on Windows the call waited on the pipes that child held. dokimi.process.run kills the group (job object on Windows) when the run ends or times out. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
|
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #739 +/- ##
=======================================
Coverage 95.71% 95.72%
=======================================
Files 415 415
Lines 55346 55362 +16
Branches 9514 9516 +2
=======================================
+ Hits 52977 52993 +16
Misses 1466 1466
Partials 903 903 🚀 New features to boost your workflow:
|
The conda test environments have no coverage, and ci/test.py's PYTHONSAFEPATH made pytest in the copy import the installed dokimi. The run() tests now give the covering-test map in place of the coverage pass; covering_tests' exclusion of failing tests gets its own test. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
|
Pushed f26652d ( |
antonwolfy
left a comment
There was a problem hiding this comment.
Review at f26652d. The process-tree kill and the stale-bytecode fixes hold up. Two defects make the report wrong: a mutant is counted as killed when no test ran, and import-time lines are never mutated. The inline comments also cover candidate discovery, a local-run gap and test gaps that let exit-code and annotation changes through.
What I ran, on a Linux review host at f26652d:
make ci-local: 30 passed, 4 failed, 14 skipped; 19,764 tests passed. The failures are not the PR's.Install gitleakshit an HTTP 504 andScan commitsfailed with it.pre-commitfails oncheck-added-large-filesfordokimi/docs/reference/cli-interface.json(501 KB), which this PR does not touch.Run testsshows 2 pytest-timeout errors intest_audit_test_quality.py, which passes alone (172 passed).mutation-diffruns underci-localand reports "no mutable changed lines", since the PR changes no package file.mypy,deptry,ruff,zizmorandschema_compatpass.- Reproductions for the code findings, driving
run(),covering_testsandcandidate_testson a scratch git repo with real pytest and coverage runs, no model calls. - A mutation run of
test_mutate_changed.pyagainst 96 one-line mutants of the script: 49 pass the file. Four of them decide an exit code, an annotation level or a measurement (inline). - All 8 commits carry
Signed-off-by.
GitHub checks red at this head: Static checks (...) and Test Linux Python 3.11. I could not reproduce either here. schema_compat passes in my run, and the 3.11 failure is a wall-clock assertion in code the PR does not touch. Keep them in view until a re-run is green.
Open for the author: @xaleryb's suggestion that a survivor whose file still has unrun candidate tests be reported as inconclusive, not survived, waits for the author. It still holds at this head: candidate_tests_not_run is listed, but such a survivor is reported as survived.
Bot points I checked that no longer apply at this head (6)
- Copilot's point on stale bytecode from equal-size edits in the same second does not apply:
_write_sourcedeletes the module's.pycon every write, andtest_a_same_size_mutant_in_the_same_second_runs_its_own_sourceforces the same-mtime case. - Copilot's point on descendants left running after a timeout does not apply:
_pytestgoes throughdokimi.process.run, which kills the whole process tree, and a test checks that no grandchild survives a mutant timeout. - exai-bot's point on the shebang and file mode (Ruff EXE001) does not apply: the script is mode 100755 at this head and
ruff checkpasses. - exai-bot's point on the Windows timeout and pipe drain does not apply at this head:
_pytestno longer usessubprocess.run, it usesdokimi.process.run, which kills the process tree on timeout; I checked this on Linux only. - exai-bot's point on deptry reporting
coverageas a transitive dependency does not apply:pyproject.tomlnow excludes the script, anddeptrypasses. - exai-bot's point on cached bytecode running a previous mutant does not apply: the
.pycis removed on each write and restore, as above.
…utation Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…, mutate import-time lines A from-import wrapped over several lines or with a backslash now makes its test a candidate. Exit codes other than 0 and 1 (nothing collected, usage, internal error) are reported as errors, not kills. A line run at collection (a constant or a default) is attributed to the file's passing tests. changed_lines reads the working tree, so uncommitted edits count. The tests pin the exit code, the annotation level, the restore of the mutated file and the clean exit with no mutable line; the help text says a timeout is neither killed nor survived. Thread: PRRT_kwDOVBg7g863b_Iw Thread: PRRT_kwDOVBg7g863eoff Thread: PRRT_kwDOVBg7g863c2VO Thread: PRRT_kwDOVBg7g863eofh Thread: PRRT_kwDOVBg7g863b_Iz Thread: PRRT_kwDOVBg7g863eofj Thread: PRRT_kwDOVBg7g863eofo Thread: PRRT_kwDOVBg7g863eofq Thread: PRRT_kwDOVBg7g863eoft Thread: PRR_kwDOVBg7g88AAAABX_OVNg Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
The commits named here were pushed before this round; this commit changes no file. Thread: PRRT_kwDOVBg7g863VktL: already fixed in 39c554c Thread: PRRT_kwDOVBg7g863VveQ: already fixed in 39c554c Thread: PRRT_kwDOVBg7g863Vkta: already fixed in c36d0a4 Thread: PRRT_kwDOVBg7g863VveE: already fixed in c36d0a4 Thread: PRRT_kwDOVBg7g863Vvdu: already fixed in 312bcc4 Thread: PRRT_kwDOVBg7g863V30l: already fixed in 312bcc4 Thread: PRRT_kwDOVBg7g863VveK: already fixed in 312bcc4 Thread: PRRT_kwDOVBg7g863V30r: already fixed in 312bcc4 Thread: PRRT_kwDOVBg7g863V30q: already fixed in 312bcc4 Thread: PRRT_kwDOVBg7g863ccVt: already fixed in f26652d Thread: PRRT_kwDOVBg7g863ccVy: already fixed in f26652d Thread: IC_kwDOVBg7g88AAAABmdXp1Q: already fixed in f26652d Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
… whose tests all skip attributes no import-time line A collection error caused by the mutant (pytest exits 2 or 4 by version) is a kill, not an error. An import-time line is attributed to the file's tests only when one ran. The merge-base failure names the command that failed. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
|
|
|
…me line in a mixed file to its passing tests Thread: PRRT_kwDOVBg7g863iVpb: fixed in this commit: a failing id with ' - ' in a parameter is matched whole. Thread: PRRT_kwDOVBg7g863iVpe: fixed in this commit: import-time lines of a file with a failure go to its passing tests. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
| return any(line == i or line.startswith(i + " - ") for i in ids) | ||
|
|
||
| lines = re.findall(r"^(?:FAILED|ERROR) (.+)$", log, re.MULTILINE) | ||
| named = {i for i in ids if any(line == i or line.startswith(i + " - ") for line in lines)} |
There was a problem hiding this comment.
Must-fix — Impact: a passing test can be reported as baseline-failing and removed from both per-test and import-time coverage, leaving its changed lines unmutated. Trigger: parameter IDs a and a] - b produce nodes tests/test_zz.py::test_f[a] and tests/test_zz.py::test_f[a] - b]; only the latter fails. Evidence: FAILED tests/test_zz.py::test_f[a] - b] - AssertionError satisfies startswith(i + " - ") for both nodes, so named contains both; ids - bad and passed - bad then discard the passing node, and run() skips mutants without remaining coverage. Match each summary line to its longest matching known node rather than independently accepting every prefix, and add a two-ID regression. Workaround: avoid colliding parameter IDs. Severity: medium because this materially corrupts the bounded advisory mutation measurement, without affecting package runtime.
There was a problem hiding this comment.
Fixed in 0c8e87d: _failed attributes each summary line to the longest known id it starts with. FAILED ...test_f[a] - b] - AssertionError now returns only test_f[a] - b]. tests/test_mutate_changed.py::test_a_passing_id_that_prefixes_a_failing_id_is_not_failed pins it and fails without the change.
antonwolfy
left a comment
There was a problem hiding this comment.
Review at 337f794. The changes since my last review hold up: wrapped imports are read through ast, only exit 1 is a kill, and import-time lines are mutated. One bug is left in the new ' - ' handling, and three test gaps let a change to a verdict or to the choice of mutated lines through. Two nits inline. The merge also waits for the author on @xaleryb's question.
What I ran, on a Linux review host at 337f794:
make ci-local: 35 passed, 0 failed, 14 skipped; 20,463 tests passed. The changed-line coverage gate passed;mutation-diffruns underci-localand reports no mutable changed lines, since the PR changes no package file.- Reproductions for the code finding, calling
_faileddirectly and with real pytest output, with no model calls. - A mutation run of
test_mutate_changed.pyagainst 45 one-line mutants of the script: 12 pass the file. Three decide a verdict or which lines get mutated (inline). - All 13 non-merge commits carry
Signed-off-by.
Still open from the author: @xaleryb's suggestion that a survivor whose file still has unrun candidate tests be reported as inconclusive, not survived, waits for the author. The author handed the choice back ("waiting on a decision") and the code is unchanged.
Checked, no issues
- Workflow wiring:
needs: static,if: pull_request, the 30 and 22 minute timeouts and the flags passed to the script match the argparse flags;fetch-depth: 0makesgit merge-base origin/<base>work..gitignorecovers both output files. - Exit codes: 0 is
survived, 1 and a collection error (2 or 4 withERROR collecting) arekilled, -1 istimeout, and 3, 5 and a bare 2 or 4 areerror. I ran each. - Mixed files: an import-time line in a file with a failing test is credited to the tests that passed, and a file where nothing ran credits nothing.
- Docs:
AGENTS.mdnames the job, the script, the cap of 40 and the local command correctly. No changelog fragment is required; no generated file is committed.
| def names(line: str) -> bool: | ||
| return any(line == i or line.startswith(i + " - ") for i in ids) | ||
|
|
||
| lines = re.findall(r"^(?:FAILED|ERROR) (.+)$", log, re.MULTILINE) | ||
| named = {i for i in ids if any(line == i or line.startswith(i + " - ") for line in lines)} | ||
| return named, [line.split(" - ", 1)[0] for line in lines if not names(line)] |
There was a problem hiding this comment.
_failed marks a passing id as failed when it is a prefix of a failing id. I agree with exai-bot here; what I add is the run with pytest's own output. @pytest.mark.parametrize("p", ["a", "a] - b"]) gives test_f[a] (passes) and test_f[a] - b] (fails). With -rfEp pytest prints FAILED tests/test_zz.py::test_f[a] - b] - AssertionError, and _failed returns both ids:
_failed("FAILED tests/test_zz.py::test_f[a] - b] - AssertionError\n",
{"tests/test_zz.py::test_f[a]", "tests/test_zz.py::test_f[a] - b]"})
# ({'tests/test_zz.py::test_f[a]', 'tests/test_zz.py::test_f[a] - b]'}, [])line.startswith(i + " - ") is true for both ids (names at :271 and named at :274). covering_tests then removes the passing test from every line, so a changed line only that test covers is reported as unexecuted and never mutated, and the test is listed as failing without a mutant.
Suggested fix: attribute each summary line to the longest id that matches it, max((i for i in ids if line == i or line.startswith(i + " - ")), key=len, default=None), and add a two-id regression with the log above.
There was a problem hiding this comment.
Fixed in 0c8e87d: _failed attributes each summary line to the longest known id it starts with. FAILED ...test_f[a] - b] - AssertionError now returns only test_f[a] - b]. tests/test_mutate_changed.py::test_a_passing_id_that_prefixes_a_failing_id_is_not_failed pins it and fails without the change.
| if mb.returncode: | ||
| raise SystemExit(f"git merge-base {base} HEAD failed: {mb.stderr.strip()}") | ||
| out = subprocess.run( | ||
| ["git", "diff", "-U0", "--no-color", "--diff-filter=AMR", mb.stdout.strip(), "--", f"{PKG}*.py"], |
There was a problem hiding this comment.
Nothing pins the merge base: diffing against base itself passes all 30 tests. Mutation check: replacing mb.stdout.strip() with base in this git diff call survives. test_changed_lines_* only uses HEAD~1, where the merge base and the base tip are the same commit. In CI, --base origin/main is the tip of main as it is when the job runs. If main advanced after the PR branched, diffing against the tip makes main's later edits to the same files look like the PR's changed lines, and they get mutated and reported.
Suggestion: add a test with a base branch that gains a commit editing another line of the same file after the PR branch was cut, and assert changed_lines(<base branch>) returns only the branch's lines.
There was a problem hiding this comment.
Fixed in 0c8e87d: Added a case where the base branch gains a commit after the branch was cut; changed_lines('main') returns only the branch's line. Diffing against base fails it. tests/test_mutate_changed.py::test_changed_lines_diffs_against_the_merge_base_not_the_base_tip pins it and fails without the change.
| return "survived" | ||
| if rc == -1: | ||
| return "timeout" | ||
| if rc == 1 or (rc in (2, 4) and _COLLECTION_ERROR in log): |
There was a problem hiding this comment.
Only exit 2 is tested as a collection-error kill. Mutation check: rc in (4,) and rc in (2, 3, 4) both pass the full file. The docstring says the exit code varies by pytest version, so dropping 2 (an older pytest) or adding 3 would turn broken-import kills into error, or an internal error into a kill, with no test red. That is the killed/error verdict --fail-on-survivor and the summary follow from.
Suggestion: a table test of _mutant_status: (4, "ERROR collecting x") is killed, (3, "ERROR collecting x") is error, (2, "") and (4, "") are error, (5, "") is error.
There was a problem hiding this comment.
Fixed in 0c8e87d: Added a table test of _mutant_status over exit codes 0, -1, 1, 2, 3, 4, 5 with and without ERROR collecting. rc in (4,) fails it. tests/test_mutate_changed.py::test_mutant_status_by_exit_code pins it and fails without the change.
| return by_file, failing, tests[i:] | ||
| data_file.unlink(missing_ok=True) | ||
| rc, log = _pytest( | ||
| work / "dokimi", ["-rfEp", "--cov=dokimi", "--cov-context=test", "--cov-report=", test], left, |
There was a problem hiding this comment.
No test checks that the coverage pass asks for PASSED ids. Mutation check: changing -rfEp to -rfE passes all 30 tests. test_covering_tests_* stubs _pytest and ignores its arguments, and the one real-pytest test has no failing test. Without p, passed is empty on a real run, so in a file with one failing test the import-time constants and defaults are credited to no test and silently dropped from mutation. This is the case the latest commit fixed.
Suggestion: run covering_tests for real on a file with one failing and one passing test and an import-time FLAG = True, and assert line 1 is credited to the passing test.
There was a problem hiding this comment.
Fixed in 0c8e87d: Added a real covering_tests run on a file with one passing and one failing test and an import-time FLAG; line 1 is credited to the passing test. -rfE fails it. tests/test_mutate_changed.py::test_the_coverage_pass_asks_for_passed_ids pins it and fails without the change.
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
antonwolfy
left a comment
There was a problem hiding this comment.
I pushed a commit per nit, up to 19619fa, which fix the two nits from my review:
19619fa:dokimi/tests/test_mutate_changed.py:121: the summary test now has two killed mutants and oneerrormutant, and asserts2 killedand1 errors; printing the killed count or a hardcoded 0 as errors now fails it.56e1109:dokimi/scripts/mutate_changed.py:11: step 1 of the docstring now says the working tree is diffed against the merge base of BASE and HEAD.
make ci-local at 19619fa: 35 passed, 0 failed, 14 skipped.
Not approving yet. Still open: the bug and the three risks from my review; @xaleryb on report a survivor whose file still has unrun candidate tests as inconclusive, not survived.
…CII paths Fixes: - _failed credited a passing id that prefixes a failing id holding ' - ' (test_f[a] vs test_f[a] - b]) with the failure; the longest match wins. - changed_lines dropped a package file with a non-ASCII path (quoted by git). - The coverage pass kept only a capped log, so failures past the cap were not seen; the pytest output is now captured whole. Tests pin the merge-base diff, the exit-code table of _mutant_status and the -rp passed ids of a real coverage run. Fixes: - _failed credited a passing id that prefixes a failing id holding ' - ' (test_f[a] vs test_f[a] - b]) with the failure; the longest match wins. - A failure message can complete a longer passing id (test_f[a] - Failed: b]); ids with a PASSED line are tried last. - changed_lines dropped a package file with a non-ASCII path (quoted by git). - The coverage pass kept only a capped log, so failures past the cap were not seen; the pytest output is now captured whole. Tests pin the merge-base diff, the exit-code table of _mutant_status and the -rp passed ids of a real coverage run. Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com>
|
Pushed 1 commit to
Threads: 5 fixed, 0 answered as not a defect. |


AGENTS.md asks authors to break the code each test covers and confirm that the test fails. Until now that happened only by hand. CI gates coverage of changed lines, but not whether a test notices when one of those lines breaks.
mutation.ymlruns mutmut weekly, on three modules only.Change
dokimi/scripts/mutate_changed.pyworks through these steps:git diff -U0 BASE...HEADoverdokimi/dokimi/**.py.--cov-context=test,COVERAGE_CORE=ctrace) runs one pytest process per file and stops at half of--budget. It maps each changed line to the tests that execute it.and/or, negate anif/whilecondition, returnNone, flip a bool, bump an int, swap+/-, drop anot. Each mutant is spliced into the source text, so the rest of the file is untouched. At most 40 are made, spread round-robin over the changed lines.-x, in a temp copy of the tracked tree, so subprocess tests see the mutant too. If pytest exits 0, the mutant survived.Tests that fail without a mutant are left out and listed in the report. The output is a markdown summary, a JSON file, and a
::warningannotation per survivor.--fail-on-survivorturns those into::errorannotations and exits 1.ci.ymlhas a new job,mutation-diff: pull_request only, afterstatic, py3.13. The step stops at 22 min (no new mutant after 900 s, at most 120 s per mutant) and the job at 30. The summary is written in its own!cancelled()step, so it survives a step timeout. Warn-only for now.make ci-localruns the job, becauseci_local.pyreads the workflows (checked:ci_local.py run --jobs mutation-diff). To leave it out locally, pass a--jobslist without it.AGENTS.md documents the job next to the by-hand step.
test_ci_label_triggers: a push runs every PR job exceptmutation-diff, which has no base to diff against on a push..gitignorecovers its output files.The weekly mutmut pilot stays as is. It scores whole core modules; this job looks only at what a PR changes.
Measured on 6 recent merged PRs
Run locally (Windows, Python 3.15, serial) at each PR's merge commit against its parent. A GitHub Linux runner should be faster.
In total 31 mutants ran: 28 were killed and 3 survived. All 3 survivors are equivalent or cosmetic:
zip(..., strict=True)→False, on inputs whose lengths are already checked (stats.py:403);and→orin an overflow fallback that returns the same mean on normal input (stats.py:167);sha[:12]→[:13]in an error message (workspace.py:665).Since every survivor was noise, the job starts warn-only. Two more things showed up while measuring:
COVERAGE_CORE=ctrace.Not run / limits
cli.pycan show its lines as uncovered.Local self-review (pr-review, lenses: correctness, silent-failures, tests, comments, design): no blocking findings. A design finding, that the step could overrun the job timeout and lose the summary, is fixed in the second commit.
🤖 Generated with Claude Code