deployproof 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
deployproof/mutator.py ADDED
@@ -0,0 +1,475 @@
1
+ """Deterministic AST mutation testing engine for DeployProof."""
2
+ import ast
3
+ import copy
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import time
11
+ from concurrent.futures import ThreadPoolExecutor, as_completed
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Set, Tuple
15
+
16
+ @dataclass
17
+ class Mutant:
18
+ """Represents an individual code mutant."""
19
+ mutant_id: str
20
+ file_path: Path
21
+ line_number: int
22
+ description: str
23
+ original_line: str
24
+ mutated_line: str
25
+ mutated_source: str
26
+ status: str = 'PENDING'
27
+
28
+ @dataclass
29
+ class SkippedConstruct:
30
+ """Represents an unsupported construct in source code that Tier 1 cannot mutate."""
31
+ file_path: Path
32
+ line_number: int
33
+ construct_name: str
34
+ description: str
35
+ snippet: str = ''
36
+
37
+ @dataclass
38
+ class MutationResult:
39
+ """Aggregated mutation testing results."""
40
+ total_mutants: int
41
+ killed_mutants: int
42
+ survived_mutants: List[Mutant] = field(default_factory=list)
43
+ untested_files: List[Path] = field(default_factory=list)
44
+ runner_errors: List[Tuple[Mutant, str]] = field(default_factory=list)
45
+ skipped_constructs: List[SkippedConstruct] = field(default_factory=list)
46
+ mutation_score: float = 100.0
47
+ duration_seconds: float = 0.0
48
+ files_tested: List[Path] = field(default_factory=list)
49
+ COMPARE_MAP = {ast.Eq: (ast.NotEq, '==', '!='), ast.NotEq: (ast.Eq, '!=', '=='), ast.Lt: (ast.GtE, '<', '>='), ast.LtE: (ast.Gt, '<=', '>'), ast.Gt: (ast.LtE, '>', '<='), ast.GtE: (ast.Lt, '>=', '<'), ast.In: (ast.NotIn, 'in', 'not in'), ast.NotIn: (ast.In, 'not in', 'in'), ast.Is: (ast.IsNot, 'is', 'is not'), ast.IsNot: (ast.Is, 'is not', 'is')}
50
+ BINOP_MAP = {ast.Add: (ast.Sub, '+', '-'), ast.Sub: (ast.Add, '-', '+'), ast.Mult: (ast.Div, '*', '/'), ast.Div: (ast.Mult, '/', '*'), ast.FloorDiv: (ast.Div, '//', '/'), ast.Mod: (ast.Mult, '%', '*'), ast.Pow: (ast.Mult, '**', '*'), ast.BitAnd: (ast.BitOr, '&', '|'), ast.BitOr: (ast.BitAnd, '|', '&'), ast.BitXor: (ast.BitAnd, '^', '&')}
51
+ BOOLOP_MAP = {ast.And: (ast.Or, 'and', 'or'), ast.Or: (ast.And, 'or', 'and')}
52
+ AUGASSIGN_MAP = {ast.Add: (ast.Sub, '+=', '-='), ast.Sub: (ast.Add, '-=', '+='), ast.Mult: (ast.Div, '*=', '/='), ast.Div: (ast.Mult, '/=', '*=')}
53
+
54
+ class SkippedConstructCollector(ast.NodeVisitor):
55
+ """Identifies and records unsupported Python constructs during AST traversal."""
56
+
57
+ def __init__(self, file_path: Path, source_lines: List[str]) -> None:
58
+ self.file_path = file_path
59
+ self.source_lines = source_lines
60
+ self.skipped: List[SkippedConstruct] = []
61
+ self._seen: Set[Tuple[int, str]] = set()
62
+
63
+ def _record_skip(self, node: ast.AST, construct_name: str, description: str) -> None:
64
+ lineno = getattr(node, 'lineno', 1)
65
+ key = (lineno, construct_name)
66
+ if key not in self._seen:
67
+ self._seen.add(key)
68
+ snippet = self.source_lines[lineno - 1].strip() if 0 <= lineno - 1 < len(self.source_lines) else ''
69
+ self.skipped.append(SkippedConstruct(file_path=self.file_path, line_number=lineno, construct_name=construct_name, description=description, snippet=snippet))
70
+
71
+ def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
72
+ self._record_skip(node, 'Walrus Operator (:=)', 'Walrus assignment expression target binding not mutated by Tier 1')
73
+ self.generic_visit(node)
74
+
75
+ def visit_Match(self, node: ast.Match) -> None:
76
+ self._record_skip(node, 'Match Statement Pattern', 'Structural pattern matching shapes/rules not mutated by Tier 1')
77
+ self.generic_visit(node)
78
+
79
+ def visit_Await(self, node: ast.Await) -> None:
80
+ self._record_skip(node, 'Await Expression', 'Async await call semantics and coroutine resolution not mutated by Tier 1')
81
+ self.generic_visit(node)
82
+
83
+ def visit_Yield(self, node: ast.Yield) -> None:
84
+ self._record_skip(node, 'Yield Statement', 'Generator yield statement semantics not mutated by Tier 1')
85
+ self.generic_visit(node)
86
+
87
+ def visit_YieldFrom(self, node: ast.YieldFrom) -> None:
88
+ self._record_skip(node, 'Yield From Statement', 'Generator yield from statement semantics not mutated by Tier 1')
89
+ self.generic_visit(node)
90
+
91
+ class MutationCounter(ast.NodeVisitor):
92
+ """Count number of mutable locations in an AST, excluding type annotations."""
93
+
94
+ def __init__(self) -> None:
95
+ self.count = 0
96
+
97
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
98
+ for d in node.decorator_list:
99
+ self.visit(d)
100
+ for default in node.args.defaults:
101
+ if default:
102
+ self.visit(default)
103
+ for kw_default in node.args.kw_defaults:
104
+ if kw_default:
105
+ self.visit(kw_default)
106
+ for stmt in node.body:
107
+ self.visit(stmt)
108
+
109
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
110
+ for d in node.decorator_list:
111
+ self.visit(d)
112
+ for default in node.args.defaults:
113
+ if default:
114
+ self.visit(default)
115
+ for kw_default in node.args.kw_defaults:
116
+ if kw_default:
117
+ self.visit(kw_default)
118
+ for stmt in node.body:
119
+ self.visit(stmt)
120
+
121
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
122
+ self.visit(node.target)
123
+ if node.value:
124
+ self.visit(node.value)
125
+
126
+ def visit_arg(self, node: ast.arg) -> None:
127
+ pass
128
+
129
+ def visit_Compare(self, node: ast.Compare) -> None:
130
+ for op in node.ops:
131
+ if type(op) in COMPARE_MAP:
132
+ self.count += 1
133
+ self.generic_visit(node)
134
+
135
+ def visit_BinOp(self, node: ast.BinOp) -> None:
136
+ if type(node.op) in BINOP_MAP:
137
+ self.count += 1
138
+ self.generic_visit(node)
139
+
140
+ def visit_BoolOp(self, node: ast.BoolOp) -> None:
141
+ if type(node.op) in BOOLOP_MAP:
142
+ self.count += 1
143
+ self.generic_visit(node)
144
+
145
+ def visit_AugAssign(self, node: ast.AugAssign) -> None:
146
+ if type(node.op) in AUGASSIGN_MAP:
147
+ self.count += 1
148
+ self.generic_visit(node)
149
+
150
+ def visit_Constant(self, node: ast.Constant) -> None:
151
+ if isinstance(node.value, bool):
152
+ self.count += 1
153
+ elif isinstance(node.value, (int, float)) and (not isinstance(node.value, bool)):
154
+ self.count += 1
155
+ self.generic_visit(node)
156
+
157
+ class MutationTransformer(ast.NodeTransformer):
158
+ """Applies a single mutation at the specified index, excluding type annotations."""
159
+
160
+ def __init__(self, target_index: int) -> None:
161
+ self.target_index = target_index
162
+ self.current_index = 0
163
+ self.applied_info: Optional[Tuple[int, str, str, str]] = None
164
+
165
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.AST:
166
+ node.decorator_list = [self.visit(d) for d in node.decorator_list]
167
+ node.args.defaults = [self.visit(d) if d else None for d in node.args.defaults]
168
+ node.args.kw_defaults = [self.visit(d) if d else None for d in node.args.kw_defaults]
169
+ node.body = [self.visit(stmt) for stmt in node.body]
170
+ return node
171
+
172
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> ast.AST:
173
+ node.decorator_list = [self.visit(d) for d in node.decorator_list]
174
+ node.args.defaults = [self.visit(d) if d else None for d in node.args.defaults]
175
+ node.args.kw_defaults = [self.visit(d) if d else None for d in node.args.kw_defaults]
176
+ node.body = [self.visit(stmt) for stmt in node.body]
177
+ return node
178
+
179
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> ast.AST:
180
+ node.target = self.visit(node.target)
181
+ if node.value:
182
+ node.value = self.visit(node.value)
183
+ return node
184
+
185
+ def visit_arg(self, node: ast.arg) -> ast.AST:
186
+ return node
187
+
188
+ def visit_Compare(self, node: ast.Compare) -> ast.AST:
189
+ self.generic_visit(node)
190
+ new_ops = list(node.ops)
191
+ for i, op in enumerate(node.ops):
192
+ op_type = type(op)
193
+ if op_type in COMPARE_MAP:
194
+ if self.current_index == self.target_index:
195
+ new_cls, old_s, new_s = COMPARE_MAP[op_type]
196
+ new_ops[i] = new_cls()
197
+ self.applied_info = (getattr(node, 'lineno', 1), f"Replace comparison '{old_s}' with '{new_s}'", old_s, new_s)
198
+ self.current_index += 1
199
+ node.ops = new_ops
200
+ return node
201
+
202
+ def visit_BinOp(self, node: ast.BinOp) -> ast.AST:
203
+ self.generic_visit(node)
204
+ op_type = type(node.op)
205
+ if op_type in BINOP_MAP:
206
+ if self.current_index == self.target_index:
207
+ new_cls, old_s, new_s = BINOP_MAP[op_type]
208
+ node.op = new_cls()
209
+ self.applied_info = (getattr(node, 'lineno', 1), f"Replace binary operator '{old_s}' with '{new_s}'", old_s, new_s)
210
+ self.current_index += 1
211
+ return node
212
+
213
+ def visit_BoolOp(self, node: ast.BoolOp) -> ast.AST:
214
+ self.generic_visit(node)
215
+ op_type = type(node.op)
216
+ if op_type in BOOLOP_MAP:
217
+ if self.current_index == self.target_index:
218
+ new_cls, old_s, new_s = BOOLOP_MAP[op_type]
219
+ node.op = new_cls()
220
+ self.applied_info = (getattr(node, 'lineno', 1), f"Replace logical operator '{old_s}' with '{new_s}'", old_s, new_s)
221
+ self.current_index += 1
222
+ return node
223
+
224
+ def visit_AugAssign(self, node: ast.AugAssign) -> ast.AST:
225
+ self.generic_visit(node)
226
+ op_type = type(node.op)
227
+ if op_type in AUGASSIGN_MAP:
228
+ if self.current_index == self.target_index:
229
+ new_cls, old_s, new_s = AUGASSIGN_MAP[op_type]
230
+ node.op = new_cls()
231
+ self.applied_info = (getattr(node, 'lineno', 1), f"Replace augmented assignment '{old_s}' with '{new_s}'", old_s, new_s)
232
+ self.current_index += 1
233
+ return node
234
+
235
+ def visit_Constant(self, node: ast.Constant) -> ast.AST:
236
+ self.generic_visit(node)
237
+ if isinstance(node.value, bool):
238
+ if self.current_index == self.target_index:
239
+ old_val = node.value
240
+ node.value = not node.value
241
+ self.applied_info = (getattr(node, 'lineno', 1), f"Replace boolean literal '{old_val}' with '{node.value}'", str(old_val), str(node.value))
242
+ self.current_index += 1
243
+ elif isinstance(node.value, (int, float)) and (not isinstance(node.value, bool)):
244
+ if self.current_index == self.target_index:
245
+ old_num = node.value
246
+ new_num = old_num + 1 if old_num != 0 else 1
247
+ node.value = new_num
248
+ self.applied_info = (getattr(node, 'lineno', 1), f"Replace numeric constant '{old_num}' with '{new_num}'", str(old_num), str(new_num))
249
+ self.current_index += 1
250
+ return node
251
+
252
+ def parse_pytest_summary(output: str) -> Dict[str, Any]:
253
+ """
254
+ Parse pytest stdout/stderr to inspect whether any tests actually executed.
255
+
256
+ Returns a dict with test counts and boolean no_tests_ran.
257
+ """
258
+ lowered = output.lower()
259
+ if 'no tests ran' in lowered or 'no test was collected' in lowered or '0 selected' in lowered:
260
+ return {'passed': 0, 'failed': 0, 'errors': 0, 'total': 0, 'no_tests_ran': True}
261
+ passed_m = re.search('(\\d+)\\s+passed', output)
262
+ failed_m = re.search('(\\d+)\\s+failed', output)
263
+ errors_m = re.search('(\\d+)\\s+error', output)
264
+ passed = int(passed_m.group(1)) if passed_m else 0
265
+ failed = int(failed_m.group(1)) if failed_m else 0
266
+ errors = int(errors_m.group(1)) if errors_m else 0
267
+ total = passed + failed
268
+ no_tests = total == 0 and errors == 0
269
+ return {'passed': passed, 'failed': failed, 'errors': errors, 'total': total, 'no_tests_ran': no_tests}
270
+
271
+ def collect_skipped_constructs_for_file(file_path: Path) -> List[SkippedConstruct]:
272
+ """Identify unsupported constructs in a file that Tier 1 skips."""
273
+ try:
274
+ source = file_path.read_text(encoding='utf-8', errors='replace')
275
+ except Exception:
276
+ return []
277
+ try:
278
+ tree = ast.parse(source)
279
+ except SyntaxError:
280
+ return []
281
+ collector = SkippedConstructCollector(file_path, source.splitlines())
282
+ collector.visit(tree)
283
+ return collector.skipped
284
+
285
+ def generate_mutants_for_file(file_path: Path) -> List[Mutant]:
286
+ """Generate all deterministic mutants for a single Python file."""
287
+ try:
288
+ source = file_path.read_text(encoding='utf-8', errors='replace')
289
+ except Exception:
290
+ return []
291
+ try:
292
+ tree = ast.parse(source)
293
+ except SyntaxError:
294
+ return []
295
+ lines = source.splitlines()
296
+ counter = MutationCounter()
297
+ counter.visit(tree)
298
+ total_locations = counter.count
299
+ mutants: List[Mutant] = []
300
+ for idx in range(total_locations):
301
+ fresh_tree = ast.parse(source)
302
+ transformer = MutationTransformer(idx)
303
+ mutated_tree = transformer.visit(fresh_tree)
304
+ ast.fix_missing_locations(mutated_tree)
305
+ try:
306
+ mutated_source = ast.unparse(mutated_tree)
307
+ except Exception:
308
+ continue
309
+ lineno = transformer.applied_info[0] if transformer.applied_info else 1
310
+ desc = transformer.applied_info[1] if transformer.applied_info else f'Mutation #{idx + 1}'
311
+ old_val = transformer.applied_info[2] if transformer.applied_info and len(transformer.applied_info) > 2 else ''
312
+ new_val = transformer.applied_info[3] if transformer.applied_info and len(transformer.applied_info) > 3 else ''
313
+ orig_line = lines[lineno - 1].strip() if 0 <= lineno - 1 < len(lines) else ''
314
+ if old_val and new_val and (old_val in orig_line):
315
+ if old_val.isalpha():
316
+ mut_line = re.sub('\\b' + re.escape(old_val) + '\\b', new_val, orig_line, count=1)
317
+ else:
318
+ mut_line = orig_line.replace(old_val, new_val, 1)
319
+ else:
320
+ mut_lines = mutated_source.splitlines()
321
+ mut_line = mut_lines[lineno - 1].strip() if 0 <= lineno - 1 < len(mut_lines) else orig_line
322
+ mutant_id = f'{file_path.name}:{lineno}:mutant_{idx + 1}'
323
+ mutants.append(Mutant(mutant_id=mutant_id, file_path=file_path, line_number=lineno, description=desc, original_line=orig_line, mutated_line=mut_line, mutated_source=mutated_source))
324
+ return mutants
325
+
326
+ def discover_target_tests(target_files: List[Path], root: Path) -> List[str]:
327
+ """Discover candidate pytest test targets relevant to the changed files."""
328
+ matched_test_files: List[str] = []
329
+ tests_dirs = [root, root / 'tests', root / 'test']
330
+ existing_tests_dirs = [d for d in tests_dirs if d.is_dir()]
331
+ if not existing_tests_dirs:
332
+ return []
333
+ for f in target_files:
334
+ stem = f.stem
335
+ direct_matched: List[str] = []
336
+ for t_dir in existing_tests_dirs:
337
+ candidates = [t_dir / f'test_{stem}.py', t_dir / f'{stem}_test.py', t_dir / f'test_{stem}s.py']
338
+ for c in candidates:
339
+ if c.is_file():
340
+ try:
341
+ rel = str(c.relative_to(root))
342
+ except ValueError:
343
+ rel = str(c)
344
+ if rel not in direct_matched:
345
+ direct_matched.append(rel)
346
+ if direct_matched:
347
+ for m in direct_matched:
348
+ if m not in matched_test_files:
349
+ matched_test_files.append(m)
350
+ else:
351
+ for t_dir in existing_tests_dirs:
352
+ parent_candidate = t_dir / f'test_{f.parent.name}.py'
353
+ if parent_candidate.is_file():
354
+ try:
355
+ rel = str(parent_candidate.relative_to(root))
356
+ except ValueError:
357
+ rel = str(parent_candidate)
358
+ if rel not in matched_test_files:
359
+ matched_test_files.append(rel)
360
+ return matched_test_files
361
+
362
+ def run_mutation_tests(target_files: List[Path], repo_root: Optional[Path]=None, test_runner_timeout: float=10.0, extra_pytest_args: Optional[List[str]]=None) -> MutationResult:
363
+ """
364
+ Execute mutation testing across the specified target files.
365
+ """
366
+ root = (repo_root or Path.cwd()).resolve()
367
+ start_time = time.time()
368
+ all_mutants: List[Mutant] = []
369
+ all_skipped: List[SkippedConstruct] = []
370
+ for f in target_files:
371
+ if f.is_file() and f.suffix == '.py':
372
+ all_mutants.extend(generate_mutants_for_file(f))
373
+ all_skipped.extend(collect_skipped_constructs_for_file(f))
374
+ if not all_mutants:
375
+ return MutationResult(total_mutants=0, killed_mutants=0, survived_mutants=[], untested_files=[], runner_errors=[], skipped_constructs=all_skipped, mutation_score=100.0, duration_seconds=round(time.time() - start_time, 2), files_tested=target_files)
376
+ killed_count = 0
377
+ survived: List[Mutant] = []
378
+ runner_errors: List[Tuple[Mutant, str]] = []
379
+ untested_files_set: Set[Path] = set()
380
+ env = os.environ.copy()
381
+ env['PYTHONDONTWRITEBYTECODE'] = '1'
382
+ current_pythonpath = env.get('PYTHONPATH', '')
383
+ paths_to_add = [str(root)]
384
+ if (root / 'src').is_dir():
385
+ paths_to_add.append(str(root / 'src'))
386
+ new_pythonpath = os.pathsep.join(paths_to_add)
387
+ if current_pythonpath:
388
+ new_pythonpath = f'{new_pythonpath}{os.pathsep}{current_pythonpath}'
389
+ env['PYTHONPATH'] = new_pythonpath
390
+ if extra_pytest_args:
391
+ pytest_args = list(extra_pytest_args)
392
+ else:
393
+ targeted_tests = discover_target_tests(target_files, root)
394
+ pytest_args = targeted_tests if targeted_tests else []
395
+ pytest_cmd = [sys.executable, '-B', '-m', 'pytest', '-q', '--tb=no', '-p', 'no:cacheprovider'] + pytest_args
396
+ baseline_has_no_tests = False
397
+ baseline_duration = 1.0
398
+ baseline_summary: Dict[str, Any] = {}
399
+ try:
400
+ t0 = time.time()
401
+ baseline_res = subprocess.run(pytest_cmd, cwd=root, env=env, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=max(test_runner_timeout * 2, 21.0))
402
+ baseline_duration = max(time.time() - t0, 0.5)
403
+ combined_output = baseline_res.stdout + '\n' + baseline_res.stderr
404
+ baseline_summary = parse_pytest_summary(combined_output)
405
+ if baseline_res.returncode == 5 or baseline_summary['no_tests_ran']:
406
+ if pytest_args:
407
+ fallback_res = subprocess.run([sys.executable, '-B', '-m', 'pytest', '-q', '--tb=no', '-p', 'no:cacheprovider'], cwd=root, env=env, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=max(test_runner_timeout * 2, 20.0))
408
+ fb_summary = parse_pytest_summary(fallback_res.stdout + '\n' + fallback_res.stderr)
409
+ if fallback_res.returncode == 5 or fb_summary['no_tests_ran']:
410
+ baseline_has_no_tests = True
411
+ untested_files_set.update(target_files)
412
+ else:
413
+ pytest_args = []
414
+ baseline_summary = fb_summary
415
+ else:
416
+ baseline_has_no_tests = True
417
+ untested_files_set.update(target_files)
418
+ elif baseline_res.returncode in (2, 3, 4) or baseline_summary['errors'] > 0:
419
+ err_output = combined_output.strip()
420
+ if 'ModuleNotFoundError' in err_output or 'ImportError' in err_output:
421
+ print("\n[!] Test suite failed to run before mutations (ModuleNotFoundError / ImportError).\n If this is a newly cloned repo, run 'pip install -e .' first to install dependencies and entry points.\n", file=sys.stderr)
422
+ except Exception:
423
+ pass
424
+ effective_timeout = max(test_runner_timeout, baseline_duration * 3.0 + 5.0)
425
+ if baseline_has_no_tests:
426
+ for mutant in all_mutants:
427
+ mutant.status = 'SURVIVED'
428
+ survived.append(mutant)
429
+ return MutationResult(total_mutants=len(all_mutants), killed_mutants=0, survived_mutants=survived, untested_files=sorted(untested_files_set), runner_errors=[], skipped_constructs=all_skipped, mutation_score=0.0, duration_seconds=round(time.time() - start_time, 2), files_tested=target_files)
430
+ baseline_has_errors = baseline_summary.get('errors', 0) > 0
431
+ mutant_pytest_cmd = [sys.executable, '-B', '-m', 'pytest', '-q', '--tb=no', '-p', 'no:cacheprovider']
432
+ if not baseline_has_errors:
433
+ mutant_pytest_cmd.append('-x')
434
+ mutant_pytest_cmd.extend(pytest_args)
435
+ for mutant in all_mutants:
436
+ original_code = mutant.file_path.read_text(encoding='utf-8', errors='replace')
437
+ try:
438
+ mutant.file_path.write_text(mutant.mutated_source, encoding='utf-8')
439
+ res = subprocess.run(mutant_pytest_cmd, cwd=root, env=env, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=effective_timeout)
440
+ combined_out = res.stdout + '\n' + res.stderr
441
+ mut_summary = parse_pytest_summary(combined_out)
442
+ baseline_errors = baseline_summary.get('errors', 0)
443
+ mut_failed = mut_summary.get('failed', 0)
444
+ mut_passed = mut_summary.get('passed', 0)
445
+ mut_errors = mut_summary.get('errors', 0)
446
+ if res.returncode == 5 or mut_summary['no_tests_ran']:
447
+ mutant.status = 'SURVIVED'
448
+ survived.append(mutant)
449
+ elif mut_failed > 0:
450
+ mutant.status = 'KILLED'
451
+ killed_count += 1
452
+ elif mut_passed > 0 and mut_failed == 0:
453
+ mutant.status = 'SURVIVED'
454
+ survived.append(mutant)
455
+ elif mut_errors > baseline_errors or res.returncode in (2, 3, 4):
456
+ mutant.status = 'RUNNER_ERROR'
457
+ err_msg = f'Pytest exit code {res.returncode}: {res.stderr.strip() or res.stdout.strip()}'
458
+ runner_errors.append((mutant, err_msg))
459
+ else:
460
+ mutant.status = 'SURVIVED'
461
+ survived.append(mutant)
462
+ except subprocess.TimeoutExpired:
463
+ mutant.status = 'KILLED'
464
+ killed_count += 1
465
+ except Exception as e:
466
+ mutant.status = 'RUNNER_ERROR'
467
+ runner_errors.append((mutant, f'Execution exception: {type(e).__name__}: {e}'))
468
+ finally:
469
+ mutant.file_path.write_text(original_code, encoding='utf-8')
470
+ valid_mutants_count = killed_count + len(survived)
471
+ if valid_mutants_count > 0:
472
+ score = killed_count / valid_mutants_count * 100.0
473
+ else:
474
+ score = 0.0 if untested_files_set else 100.0
475
+ return MutationResult(total_mutants=len(all_mutants), killed_mutants=killed_count, survived_mutants=survived, untested_files=sorted(untested_files_set), runner_errors=runner_errors, skipped_constructs=all_skipped, mutation_score=round(score, 1), duration_seconds=round(time.time() - start_time, 2), files_tested=target_files)