From be8a8569b7a27b45d24d040365d51fd9851cf700 Mon Sep 17 00:00:00 2001 From: Persephone Raskova <103053862+percy-raskova@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:38:32 -0500 Subject: [PATCH 1/2] Preserve cached mutation results on rerun of unchanged source create_mutants_for_file() correctly detected unchanged source files (source_mtime < mutant_mtime) and skipped regenerating mutations, but also reset all exit_code_by_key values to None. This caused every subsequent run to re-test all mutants from scratch, defeating the incremental caching introduced in bf3a2e81. Remove the reset loop so cached results survive across runs. The skip logic at line 1273 (`if result is not None: continue`) can now actually skip already-tested mutants. Also fix pre-existing ruff lint errors (unused imports, unused variable, f-strings without placeholders). --- src/mutmut/__main__.py | 17 ++------ tests/e2e/test_e2e_incremental_cache.py | 53 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 13 deletions(-) create mode 100644 tests/e2e/test_e2e_incremental_cache.py diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index 2b83ca6c..0aa56d07 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -1,4 +1,3 @@ -from typing import Iterable from mutmut.type_checking import TypeCheckingError from mutmut.type_checking import run_type_checker from typing import Any @@ -55,7 +54,6 @@ import click import libcst as cst -import libcst.matchers as m from rich.text import Text from setproctitle import setproctitle @@ -137,7 +135,7 @@ def guess_paths_to_mutate(): def record_trampoline_hit(name): - assert not name.startswith('src.'), f'Failed trampoline hit. Module name starts with `src.`, which is invalid' + assert not name.startswith('src.'), 'Failed trampoline hit. Module name starts with `src.`, which is invalid' if mutmut.config.max_stack_depth != -1: f = inspect.currentframe() c = mutmut.config.max_stack_depth @@ -293,13 +291,7 @@ def create_mutants_for_file(filename: Path, output_path: Path) -> FileMutationRe # source_mtime == mutant_mtime: only copied, otherwise the mutant file is untouched # source_mtime < mutant_mtime: the mutations have been saved after copying; source file untouched if source_mtime < mutant_mtime: - # reset the mutation stats - source_file_mutation_data = SourceFileMutationData(path=filename) - source_file_mutation_data.load() - for key in source_file_mutation_data.exit_code_by_key: - source_file_mutation_data.exit_code_by_key[key] = None - source_file_mutation_data.save() - + # Source unchanged, mutations already generated — preserve cached results return FileMutationResult(unmodified=True) except OSError: pass @@ -626,7 +618,6 @@ def pytest_deselected(self, items): collector = TestsCollector() - tests_dir = mutmut.config.tests_dir pytest_args = ['-x', '-q', '--collect-only'] + self._pytest_add_cli_args_test_selection with change_cwd('mutants'): @@ -1127,7 +1118,7 @@ def print_time_estimates(mutant_names): for time, key in sorted(times_and_keys): if not time: - print(f'', key) + print('', key) else: print(f'{int(time*1000)}ms', key) @@ -1620,7 +1611,7 @@ def on_data_table_row_highlighted(self, event): duration = source_file_mutation_data.durations_by_key.get(mutant_name, '?') type_check_error = source_file_mutation_data.type_check_error_by_key.get(mutant_name, '?') - view_tests_description = f'(press t to view tests executed for this mutant)' + view_tests_description = '(press t to view tests executed for this mutant)' match status: case 'killed': diff --git a/tests/e2e/test_e2e_incremental_cache.py b/tests/e2e/test_e2e_incremental_cache.py new file mode 100644 index 00000000..b8f52252 --- /dev/null +++ b/tests/e2e/test_e2e_incremental_cache.py @@ -0,0 +1,53 @@ +import shutil +from pathlib import Path + +from tests.e2e.e2e_utils import change_cwd, read_all_stats_for_project, write_json_file, read_json_file + +import mutmut +from mutmut.__main__ import _run + + +def test_rerun_preserves_cached_results(): + """Rerunning mutmut on unchanged source must not reset cached exit codes to None. + + Strategy: run mutmut once, then inject a sentinel exit code (99) into the + meta file. If the second run preserves the cache, the sentinel survives. + If it resets and re-tests, 99 gets replaced with a real exit code. + """ + project_path = Path("..").parent / "e2e_projects" / "my_lib" + mutants_path = project_path / "mutants" + shutil.rmtree(mutants_path, ignore_errors=True) + + # First run: generate and test all mutants + mutmut._reset_globals() + with change_cwd(project_path): + _run([], None) + + # Inject sentinel exit code (99) into every mutant result + meta_files = list(mutants_path.rglob("*.meta")) + assert meta_files, "Expected .meta files after first run" + + sentinel = 99 + for meta_file in meta_files: + meta = read_json_file(meta_file) + for key in meta["exit_code_by_key"]: + meta["exit_code_by_key"][key] = sentinel + write_json_file(meta_file, meta) + + # Second run: source unchanged, sentinel values should survive + mutmut._reset_globals() + with change_cwd(project_path): + _run([], None) + + second_run_stats = read_all_stats_for_project(project_path) + + # Every result should still be the sentinel — not None, not a real exit code + for meta_path, results in second_run_stats.items(): + for mutant_name, exit_code in results.items(): + assert exit_code == sentinel, ( + f"Cached result for {mutant_name} in {meta_path} was not preserved. " + f"Expected sentinel {sentinel}, got {exit_code}." + ) + + # Cleanup + shutil.rmtree(mutants_path, ignore_errors=True) From 113d4c7efd6b09bf5454f55ec31ee31f3e7dd873 Mon Sep 17 00:00:00 2001 From: Persephone Raskova <103053862+percy-raskova@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:45:38 -0500 Subject: [PATCH 2/2] Update tests/e2e/test_e2e_incremental_cache.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/e2e/test_e2e_incremental_cache.py | 67 +++++++++++++------------ 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/tests/e2e/test_e2e_incremental_cache.py b/tests/e2e/test_e2e_incremental_cache.py index b8f52252..148373fa 100644 --- a/tests/e2e/test_e2e_incremental_cache.py +++ b/tests/e2e/test_e2e_incremental_cache.py @@ -18,36 +18,37 @@ def test_rerun_preserves_cached_results(): mutants_path = project_path / "mutants" shutil.rmtree(mutants_path, ignore_errors=True) - # First run: generate and test all mutants - mutmut._reset_globals() - with change_cwd(project_path): - _run([], None) - - # Inject sentinel exit code (99) into every mutant result - meta_files = list(mutants_path.rglob("*.meta")) - assert meta_files, "Expected .meta files after first run" - - sentinel = 99 - for meta_file in meta_files: - meta = read_json_file(meta_file) - for key in meta["exit_code_by_key"]: - meta["exit_code_by_key"][key] = sentinel - write_json_file(meta_file, meta) - - # Second run: source unchanged, sentinel values should survive - mutmut._reset_globals() - with change_cwd(project_path): - _run([], None) - - second_run_stats = read_all_stats_for_project(project_path) - - # Every result should still be the sentinel — not None, not a real exit code - for meta_path, results in second_run_stats.items(): - for mutant_name, exit_code in results.items(): - assert exit_code == sentinel, ( - f"Cached result for {mutant_name} in {meta_path} was not preserved. " - f"Expected sentinel {sentinel}, got {exit_code}." - ) - - # Cleanup - shutil.rmtree(mutants_path, ignore_errors=True) + try: + # First run: generate and test all mutants + mutmut._reset_globals() + with change_cwd(project_path): + _run([], None) + + # Inject sentinel exit code (99) into every mutant result + meta_files = list(mutants_path.rglob("*.meta")) + assert meta_files, "Expected .meta files after first run" + + sentinel = 99 + for meta_file in meta_files: + meta = read_json_file(meta_file) + for key in meta["exit_code_by_key"]: + meta["exit_code_by_key"][key] = sentinel + write_json_file(meta_file, meta) + + # Second run: source unchanged, sentinel values should survive + mutmut._reset_globals() + with change_cwd(project_path): + _run([], None) + + second_run_stats = read_all_stats_for_project(project_path) + + # Every result should still be the sentinel — not None, not a real exit code + for meta_path, results in second_run_stats.items(): + for mutant_name, exit_code in results.items(): + assert exit_code == sentinel, ( + f"Cached result for {mutant_name} in {meta_path} was not preserved. " + f"Expected sentinel {sentinel}, got {exit_code}." + ) + finally: + # Cleanup + shutil.rmtree(mutants_path, ignore_errors=True)