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/__init__.py +3 -0
- deployproof/cli.py +288 -0
- deployproof/dependencies.py +874 -0
- deployproof/diff.py +234 -0
- deployproof/mutator.py +475 -0
- deployproof/reporter.py +281 -0
- deployproof/secrets.py +221 -0
- deployproof/symlinks.py +156 -0
- deployproof/wsl.py +147 -0
- deployproof-0.1.0.dist-info/METADATA +111 -0
- deployproof-0.1.0.dist-info/RECORD +15 -0
- deployproof-0.1.0.dist-info/WHEEL +5 -0
- deployproof-0.1.0.dist-info/entry_points.txt +2 -0
- deployproof-0.1.0.dist-info/licenses/LICENSE +21 -0
- deployproof-0.1.0.dist-info/top_level.txt +1 -0
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)
|