2222import ast
2323import fnmatch
2424import gc
25+ import hashlib
2526import inspect
2627import itertools
2728import json
6263from mutmut .code_coverage import get_covered_lines_for_file
6364from mutmut .configuration import Config
6465from mutmut .mutation .data import SourceFileMutationData
66+ from mutmut .mutation .file_mutation import FailedTypeCheckMutant
6567from mutmut .mutation .file_mutation import filter_mutants_with_type_checker
6668from mutmut .mutation .file_mutation import mutate_file_contents
6769from mutmut .mutation .trampoline_templates import CLASS_NAME_SEPARATOR
@@ -819,10 +821,130 @@ def _invalidate_stale_dependency_edges() -> set[str]:
819821 return changed_functions
820822
821823
822- def collect_or_load_stats (runner : TestRunner , invalidate_stale_callers : bool = True ) -> None :
824+ # Dependency / build files whose changes the per-function source hashes cannot see.
825+ # Globs are resolved against the project root; missing files are skipped. Users can
826+ # extend this via the ``cache_invalidation_files`` config.
827+ _DEFAULT_WATCHED_FILES = (
828+ "pyproject.toml" ,
829+ "setup.cfg" ,
830+ "setup.py" ,
831+ "requirements*.txt" ,
832+ "poetry.lock" ,
833+ "uv.lock" ,
834+ "Pipfile" ,
835+ "Pipfile.lock" ,
836+ )
837+
838+
839+ def compute_watched_file_hashes () -> dict [str , str ]:
840+ """Map watched-file path -> content hash for the default set plus user globs."""
841+ patterns = list (_DEFAULT_WATCHED_FILES ) + list (Config .get ().cache_invalidation_files )
842+ hashes : dict [str , str ] = {}
843+ for pattern in patterns :
844+ for path in sorted (Path ("." ).glob (pattern )):
845+ if path .is_file ():
846+ hashes [str (path )] = hashlib .sha256 (path .read_bytes ()).hexdigest ()[:12 ]
847+ return hashes
848+
849+
850+ def _reset_mutant_results (should_reset : Callable [[str , int ], bool ]) -> int :
851+ """Reset cached verdicts to ``None`` (forcing a re-test) where ``should_reset`` holds.
852+
853+ ``should_reset`` only sees already-decided mutants (``exit_code`` is not ``None``).
854+ """
855+ count = 0
856+ for path in walk_mutatable_files ():
857+ meta_path = Path ("mutants" ) / (str (path ) + ".meta" )
858+ if not meta_path .exists ():
859+ continue
860+ m = SourceFileMutationData (path = path )
861+ m .load ()
862+ dirty = False
863+ for key , exit_code in list (m .exit_code_by_key .items ()):
864+ if exit_code is not None and should_reset (key , exit_code ):
865+ m .exit_code_by_key [key ] = None
866+ dirty = True
867+ count += 1
868+ if dirty :
869+ m .save ()
870+ return count
871+
872+
873+ def _report_watched_file_changes () -> bool :
874+ """Surface changes to watched config/dependency files.
875+
876+ Returns True only when the configured policy is ``rerun`` and something changed,
877+ asking the caller to reset all results. Silent when no prior hashes exist.
878+ """
879+ old = state ().old_watched_file_hashes
880+ if not old :
881+ return False
882+ new = compute_watched_file_hashes ()
883+ changed = sorted (p for p in old .keys () | new .keys () if old .get (p ) != new .get (p ))
884+ if not changed :
885+ return False
886+
887+ policy = Config .get ().on_dependency_change
888+ if policy == "ignore" :
889+ return False
890+ if policy == "rerun" :
891+ print (f" { len (changed )} watched file(s) changed; rerunning all mutants: { ', ' .join (changed )} " )
892+ return True
893+ # default: warn but keep the cache
894+ print (f" Warning: { len (changed )} watched file(s) changed since the last run: { ', ' .join (changed )} " )
895+ print (" These cannot be tracked for behavioral changes, so cached results were kept." )
896+ print (' If the changes affect your tests, delete the mutants/ directory or set on_dependency_change = "rerun".' )
897+ return False
898+
899+
900+ def _apply_config_change_invalidation (mutants_caught_by_type_checker : dict [str , object ]) -> bool :
901+ """Reset only the cached verdicts a config / dependency change could have invalidated.
902+
903+ Returns True if a full stats recollection is required (a global pytest config change
904+ or an opt-in dependency rerun), in which case all results have already been reset.
905+ """
906+ old_fp = state ().old_config_fingerprint
907+ new_fp = Config .get ().config_fingerprint ()
908+ changed_groups = {g for g in new_fp if old_fp .get (g ) != new_fp [g ]} if old_fp else set ()
909+
910+ dependency_rerun = _report_watched_file_changes ()
911+
912+ # Global groups change how *every* test runs / which tests map to a function, so no
913+ # subset of results is safe to keep -> full reset and full stats recollection.
914+ if changed_groups & {"test_execution" , "test_selection" } or dependency_rerun :
915+ _reset_mutant_results (lambda key , exit_code : True )
916+ mutmut .duration_by_test .clear ()
917+ mutmut .tests_by_mangled_function_name .clear ()
918+ state ().function_dependencies .clear ()
919+ return True
920+
921+ # Timeout config only reclassifies timeouts; keep every other verdict.
922+ if "timeout" in changed_groups :
923+ _reset_mutant_results (lambda key , exit_code : status_by_exit_code [exit_code ] == "timeout" )
924+
925+ # The type-check pre-filter runs fresh every run; only verdicts whose type-check
926+ # status flips are stale -> reset the symmetric difference of old (==37) and new.
927+ if "type_check" in changed_groups :
928+ caught = set (mutants_caught_by_type_checker )
929+ _reset_mutant_results (lambda key , exit_code : (exit_code == 37 ) != (key in caught ))
930+
931+ return False
932+
933+
934+ def collect_or_load_stats (
935+ runner : TestRunner ,
936+ * ,
937+ mutants_caught_by_type_checker : dict [str , Any ] | None = None ,
938+ apply_config_invalidation : bool = False ,
939+ invalidate_stale_callers : bool = True ,
940+ ) -> None :
823941 did_load = load_stats ()
824942
825- if not did_load :
943+ force_full = False
944+ if did_load and apply_config_invalidation :
945+ force_full = _apply_config_change_invalidation (mutants_caught_by_type_checker or {})
946+
947+ if not did_load or force_full :
826948 # Run full stats
827949 run_stats_collection (runner )
828950 else :
@@ -862,6 +984,8 @@ def load_stats() -> bool:
862984 state ().old_function_hashes = data .pop ("function_hashes" , {})
863985 for k , v in data .pop ("function_dependencies" , {}).items ():
864986 state ().function_dependencies [k ] = set (v )
987+ state ().old_config_fingerprint = data .pop ("config_fingerprint" , {})
988+ state ().old_watched_file_hashes = data .pop ("watched_file_hashes" , {})
865989 assert not data , data
866990 did_load = True
867991 except (FileNotFoundError , JSONDecodeError ):
@@ -878,6 +1002,8 @@ def save_stats() -> None:
8781002 stats_time = mutmut .stats_time ,
8791003 function_hashes = state ().current_function_hashes ,
8801004 function_dependencies = {k : list (v ) for k , v in state ().function_dependencies .items ()},
1005+ config_fingerprint = Config .get ().config_fingerprint (),
1006+ watched_file_hashes = compute_watched_file_hashes (),
8811007 ),
8821008 f ,
8831009 indent = 4 ,
@@ -1101,11 +1227,10 @@ def _run(mutant_names: tuple[str, ...] | list[str], max_children: int | None) ->
11011227 f" done in { round (time .total_seconds () * 1000 )} ms ({ stats .mutated } files mutated, { stats .ignored } ignored, { stats .unmodified } unmodified)" ,
11021228 )
11031229
1230+ mutants_caught_by_type_checker : dict [str , FailedTypeCheckMutant ] = {}
11041231 if Config .get ().type_check_command :
11051232 with CatchOutput (spinner_title = "Filtering mutations with type checker" ):
11061233 mutants_caught_by_type_checker = filter_mutants_with_type_checker ()
1107- else :
1108- mutants_caught_by_type_checker = {}
11091234
11101235 # TODO: config/option for runner
11111236 # runner = HammettRunner()
@@ -1114,7 +1239,11 @@ def _run(mutant_names: tuple[str, ...] | list[str], max_children: int | None) ->
11141239
11151240 # TODO: run these steps only if we have mutants to test
11161241
1117- collect_or_load_stats (runner )
1242+ collect_or_load_stats (
1243+ runner ,
1244+ mutants_caught_by_type_checker = mutants_caught_by_type_checker ,
1245+ apply_config_invalidation = True ,
1246+ )
11181247
11191248 mutants , source_file_mutation_data_by_path = collect_source_file_mutation_data (mutant_names = mutant_names )
11201249
0 commit comments