qa-agent-kit 0.6.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.
qakit/tools/mutate.py ADDED
@@ -0,0 +1,684 @@
1
+ #!/usr/bin/env python3
2
+ """Mutation testing for any repo: break the code on purpose, one small change at a time, and see whether the tests notice.
3
+
4
+ A mutant the tests catch is *killed*. One they miss *survived*: no test pins that behaviour. Every survivor gets a test, or a
5
+ `mutation-ok: <reason>` comment on its line when the change cannot alter behaviour (an equivalent mutant).
6
+
7
+ python3 mutate.py --config apps/pawday/release/testing/mutation.json
8
+ python3 mutate.py --config <json> --changed-since 'latest-tag:pawday/*' # release gate: lines changed since the last release
9
+ python3 mutate.py --root agent --source 'core/**/*.py' --test '{orig}/.venv/bin/python -m pytest -x -q'
10
+ python3 mutate.py --config <json> --list # print the mutants, run nothing
11
+
12
+ Config (JSON, paths relative to the git top level that holds the config): root, sources, exclude, setup, build, test,
13
+ env, copy_exclude, jobs, timeout_factor, operators, sweep_sample (seeded sample size for a full sweep only), coverage
14
+ (writes lcov to {lcov}; mutants on lines it never ran are reported uncovered without a run), test_timeout (seconds,
15
+ when the per-mutant test command differs from the baseline one, e.g. it picks tests by {stem}). `build` failing means the mutant does not compile (invalid, not counted);
16
+ `test` failing or timing out means killed. Commands run with a shell inside a throwaway copy of root, never in the checkout;
17
+ {orig} is the real root, {file} and {stem} the mutated file. Languages: Python (tokenize and ast) and C-family syntax
18
+ (Swift, JS/TS, Kotlin, Go, Java, C#), where a binary operator must have spaces on both sides; that keeps generics
19
+ (`Array<Int>`), arrows and prefix/postfix operators out. Exit 0 no survivors, 1 survivors, 2 setup or baseline failure.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import ast
25
+ import concurrent.futures as futures
26
+ import datetime as dt
27
+ import fnmatch
28
+ import io
29
+ import json
30
+ import os
31
+ import queue
32
+ import random
33
+ import re
34
+ import shutil
35
+ import subprocess
36
+ import sys
37
+ import tempfile
38
+ import threading
39
+ import time
40
+ import tokenize
41
+ from dataclasses import asdict, dataclass
42
+ from pathlib import Path
43
+
44
+ C_FAMILY = {".swift", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".kt", ".go", ".java", ".cs"}
45
+ OPERATORS = ("boundary", "equality", "logical", "arithmetic", "boolean", "negation", "drop-call")
46
+ COPY_EXCLUDE = {".git", ".build", "DerivedData", ".dd", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache",
47
+ ".venv", "venv", "xcuserdata", ".swiftpm"}
48
+ SKIP_MARK = "mutation-ok"
49
+ # Calls whose removal says nothing about behaviour a test should pin: logging, debug output, assertions that crash.
50
+ QUIET_CALLS = re.compile(r"(^|\.)(print|debugPrint|dump|NSLog|os_log|log|logger|Logger|console|assert\w*|precondition\w*|"
51
+ r"fatalError|super|warn|warning|info|debug|error|exception|critical)\b")
52
+ SWAP = {"<": "<=", "<=": "<", ">": ">=", ">=": ">", "==": "!=", "!=": "==", "===": "!==", "!==": "===",
53
+ "&&": "||", "||": "&&", "+": "-", "-": "+", "+=": "-=", "-=": "+=", "*": "/", "/": "*",
54
+ "true": "false", "false": "true", "and": "or", "or": "and", "True": "False", "False": "True"}
55
+ FAMILY = {"<": "boundary", "<=": "boundary", ">": "boundary", ">=": "boundary", "==": "equality", "!=": "equality",
56
+ "===": "equality", "!==": "equality", "&&": "logical", "||": "logical", "and": "logical", "or": "logical",
57
+ "+": "arithmetic", "-": "arithmetic", "+=": "arithmetic", "-=": "arithmetic", "*": "arithmetic",
58
+ "/": "arithmetic", "true": "boolean", "false": "boolean", "True": "boolean", "False": "boolean"}
59
+ C_BINARY = re.compile(r"(?<=\s)(===|!==|==|!=|<=|>=|&&|\|\||\+=|-=|<|>|\+|-|\*|/)(?=\s)")
60
+ C_BOOL = re.compile(r"\b(true|false)\b")
61
+ C_NOT = re.compile(r"(?:(?<=[\s(\[,{=&|:?])|^)!(?=[A-Za-z_($.])", re.MULTILINE)
62
+ C_DIRECTIVE = re.compile(r"^\s*(#(if|else|elseif|endif|warning|error|sourceLocation)\b|import\b|@testable\b)")
63
+ C_CONTINUATION = ("=", "(", ",", ".", "?", ":", "&&", "||", "+", "-", "*", "/", "return", "->", "{") # the previous line is unfinished
64
+ C_CALL_LINE = re.compile(r"^(\s*)((?:try[!?]?\s+|await\s+)*([A-Za-z_$][\w$]*(?:\??\.[A-Za-z_$][\w$]*)*)\s*\(.*\)(?:\s*;)?)\s*$")
65
+ C_KEYWORDS = {"return", "if", "guard", "while", "for", "switch", "case", "let", "var", "throw", "defer", "func", "init",
66
+ "else", "repeat", "catch", "yield", "await", "try", "new", "typeof", "delete", "const"}
67
+ PY_VALUE_END = {tokenize.NAME, tokenize.NUMBER, tokenize.STRING}
68
+ PY_KEYWORDS = {"and", "or", "not", "in", "is", "if", "else", "elif", "return", "yield", "lambda", "await", "assert", "del",
69
+ "import", "from", "as", "with", "for", "while", "def", "class", "raise", "pass", "global", "nonlocal"}
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Mutant:
74
+ file: str # relative to root
75
+ line: int # 1-based
76
+ start: int # character offsets into the file text
77
+ end: int
78
+ before: str
79
+ after: str
80
+ op: str
81
+
82
+ def apply(self, text: str) -> str:
83
+ return text[:self.start] + self.after + text[self.end:]
84
+
85
+ def label(self) -> str:
86
+ return f"{self.file}:{self.line} {self.op} `{self.before.strip()}` -> `{self.after.strip() or '(removed)'}`"
87
+
88
+
89
+ def line_starts(text: str) -> list[int]:
90
+ starts = [0]
91
+ for index, char in enumerate(text):
92
+ if char == "\n":
93
+ starts.append(index + 1)
94
+ return starts
95
+
96
+
97
+ def line_of(starts: list[int], offset: int) -> int:
98
+ low, high = 0, len(starts) - 1
99
+ while low < high:
100
+ mid = (low + high + 1) // 2
101
+ if starts[mid] <= offset:
102
+ low = mid
103
+ else:
104
+ high = mid - 1
105
+ return low + 1
106
+
107
+
108
+ def mask_c_family(text: str) -> str:
109
+ """Same length as text; comments and string/char literals become spaces (newlines kept), so offsets map 1:1."""
110
+ out = list(text)
111
+ i, n = 0, len(text)
112
+
113
+ def blank(a: int, b: int) -> None:
114
+ for k in range(a, min(b, n)):
115
+ if out[k] != "\n":
116
+ out[k] = " "
117
+
118
+ while i < n:
119
+ if text.startswith("//", i):
120
+ j = text.find("\n", i)
121
+ j = n if j < 0 else j
122
+ blank(i, j)
123
+ i = j
124
+ elif text.startswith("/*", i):
125
+ depth, j = 1, i + 2
126
+ while j < n and depth:
127
+ if text.startswith("/*", j):
128
+ depth, j = depth + 1, j + 2
129
+ elif text.startswith("*/", j):
130
+ depth, j = depth - 1, j + 2
131
+ else:
132
+ j += 1
133
+ blank(i, j)
134
+ i = j
135
+ elif text[i] == "#" and re.match(r"#+\"", text[i:]): # Swift raw string #"..."#
136
+ hashes = len(re.match(r"#+", text[i:]).group(0))
137
+ multi = text.startswith('"""', i + hashes)
138
+ close = ('"""' if multi else '"') + "#" * hashes
139
+ j = text.find(close, i + hashes + (3 if multi else 1))
140
+ j = n if j < 0 else j + len(close)
141
+ blank(i, j)
142
+ i = j
143
+ elif text.startswith('"""', i):
144
+ j = i + 3
145
+ while j < n and not text.startswith('"""', j):
146
+ j += 2 if text[j] == "\\" else 1
147
+ j = min(n, j + 3)
148
+ blank(i, j)
149
+ i = j
150
+ elif text[i] in "\"'`":
151
+ quote, j = text[i], i + 1
152
+ while j < n and text[j] != quote and not (quote != "`" and text[j] == "\n"):
153
+ j += 2 if text[j] == "\\" else 1
154
+ j = min(n, j + 1)
155
+ blank(i, j)
156
+ i = j
157
+ else:
158
+ i += 1
159
+ return "".join(out)
160
+
161
+
162
+ def c_family_mutants(rel: str, text: str) -> list[Mutant]:
163
+ masked = mask_c_family(text)
164
+ starts = line_starts(text)
165
+ lines = text.split("\n")
166
+ masked_lines = masked.split("\n")
167
+ skip = {number for number, line in enumerate(lines, 1) if SKIP_MARK in line or C_DIRECTIVE.match(masked_lines[number - 1])}
168
+ found: list[Mutant] = []
169
+
170
+ def add(start: int, end: int, after: str, op: str) -> None:
171
+ number = line_of(starts, start)
172
+ if number not in skip:
173
+ found.append(Mutant(rel, number, start, end, text[start:end], after, op))
174
+
175
+ for match in C_BINARY.finditer(masked):
176
+ token = match.group(1)
177
+ add(match.start(1), match.end(1), SWAP[token], FAMILY[token])
178
+ for match in C_BOOL.finditer(masked):
179
+ add(match.start(), match.end(), SWAP[match.group(0)], "boolean")
180
+ for match in C_NOT.finditer(masked):
181
+ add(match.start(), match.end(), "", "negation")
182
+ for number, line in enumerate(masked_lines, 1):
183
+ call = C_CALL_LINE.match(line)
184
+ if not call or number in skip:
185
+ continue
186
+ # A call that continues the previous line (`let x =` / an open `(` / a trailing `,` or `.`) is an expression, not a
187
+ # statement: dropping it leaves a dangling `=` (every invalid Swift mutant in docs/case-studies.md was this).
188
+ previous = next((masked_lines[k].rstrip() for k in range(number - 2, -1, -1) if masked_lines[k].strip()), "")
189
+ if previous.endswith(C_CONTINUATION):
190
+ continue
191
+ callee = call.group(3)
192
+ head = callee.split(".")[0].rstrip("?")
193
+ if head in C_KEYWORDS or QUIET_CALLS.search(callee) or line.count("(") != line.count(")"):
194
+ continue
195
+ if "." not in callee and callee[0].isupper(): # `Text("a")`, `Spacer()`: a result-builder element, not a side effect
196
+ continue
197
+ offset = starts[number - 1] + len(call.group(1))
198
+ add(offset, offset + len(call.group(2)), "", "drop-call")
199
+ return found
200
+
201
+
202
+ def typing_only_lines(tree: ast.AST) -> set[int]:
203
+ """Lines whose mutants can never change behaviour at runtime: `TYPE_CHECKING = False`, the body of `if TYPE_CHECKING:`,
204
+ `@overload` stubs, and the test of `if sys.version_info ...` (the interpreter running the tests takes one branch only).
205
+ The case studies (docs/case-studies.md) found 11% of the Python survivors here, every one equivalent."""
206
+ skip: set[int] = set()
207
+
208
+ def dotted(node: ast.AST) -> str:
209
+ return ast.unparse(node) if isinstance(node, (ast.Name, ast.Attribute)) else ""
210
+
211
+ for node in ast.walk(tree):
212
+ if isinstance(node, ast.If):
213
+ test = node.test
214
+ if dotted(test).endswith("TYPE_CHECKING"):
215
+ skip.update(range(node.lineno, node.end_lineno + 1))
216
+ elif isinstance(test, ast.Compare) and "version_info" in dotted(test.left):
217
+ skip.update(range(test.lineno, test.end_lineno + 1))
218
+ elif isinstance(node, ast.Assign) and any(dotted(t) == "TYPE_CHECKING" for t in node.targets):
219
+ skip.update(range(node.lineno, node.end_lineno + 1))
220
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and any(dotted(d).endswith("overload") for d in node.decorator_list):
221
+ skip.update(range(node.lineno, node.end_lineno + 1))
222
+ return skip
223
+
224
+
225
+ def python_mutants(rel: str, text: str) -> list[Mutant]:
226
+ starts = line_starts(text)
227
+ lines = text.split("\n")
228
+ skip = {number for number, line in enumerate(lines, 1) if SKIP_MARK in line or "__name__" in line}
229
+ try:
230
+ skip |= typing_only_lines(ast.parse(text))
231
+ except SyntaxError:
232
+ pass
233
+ found: list[Mutant] = []
234
+
235
+ def offset(position: tuple[int, int]) -> int:
236
+ return starts[position[0] - 1] + position[1]
237
+
238
+ def add(start: int, end: int, after: str, op: str) -> None:
239
+ number = line_of(starts, start)
240
+ if number not in skip:
241
+ found.append(Mutant(rel, number, start, end, text[start:end], after, op))
242
+
243
+ try:
244
+ tokens = list(tokenize.generate_tokens(io.StringIO(text).readline))
245
+ except (tokenize.TokenError, SyntaxError):
246
+ return []
247
+ significant = [t for t in tokens if t.type not in (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT,
248
+ tokenize.DEDENT, tokenize.ENCODING)]
249
+ for index, token in enumerate(significant):
250
+ previous = significant[index - 1] if index else None
251
+ value_before = previous is not None and (
252
+ (previous.type in PY_VALUE_END and previous.string not in PY_KEYWORDS) or previous.string in (")", "]", "}"))
253
+ start, end = offset(token.start), offset(token.end)
254
+ if token.type == tokenize.OP and token.string in ("<", "<=", ">", ">=", "==", "!="):
255
+ add(start, end, SWAP[token.string], FAMILY[token.string])
256
+ elif token.type == tokenize.OP and token.string in ("+", "-", "*", "/") and value_before:
257
+ add(start, end, SWAP[token.string], "arithmetic")
258
+ elif token.type == tokenize.OP and token.string in ("+=", "-="):
259
+ add(start, end, SWAP[token.string], "arithmetic")
260
+ elif token.type == tokenize.NAME and token.string in ("and", "or", "True", "False"):
261
+ add(start, end, SWAP[token.string], FAMILY[token.string])
262
+ elif token.type == tokenize.NAME and token.string == "not":
263
+ following = significant[index + 1] if index + 1 < len(significant) else None
264
+ if following is not None:
265
+ add(start, offset(following.start), "", "negation")
266
+ try:
267
+ tree = ast.parse(text)
268
+ except SyntaxError:
269
+ return found
270
+ for node in ast.walk(tree):
271
+ call = node.value.value if isinstance(node, ast.Expr) and isinstance(node.value, ast.Await) else getattr(node, "value", None)
272
+ if not isinstance(node, ast.Expr) or not isinstance(call, ast.Call) or node.lineno != node.end_lineno:
273
+ continue
274
+ if QUIET_CALLS.search(ast.unparse(call.func)):
275
+ continue
276
+ start = starts[node.lineno - 1] + utf8_col(lines[node.lineno - 1], node.col_offset)
277
+ end = starts[node.lineno - 1] + utf8_col(lines[node.lineno - 1], node.end_col_offset)
278
+ add(start, end, "pass", "drop-call")
279
+ return found
280
+
281
+
282
+ def utf8_col(line: str, byte_col: int) -> int:
283
+ """ast columns count UTF-8 bytes; text offsets count characters."""
284
+ return len(line.encode("utf-8")[:byte_col].decode("utf-8", errors="ignore"))
285
+
286
+
287
+ def mutants_for(rel: str, text: str) -> list[Mutant]:
288
+ suffix = Path(rel).suffix
289
+ if suffix == ".py":
290
+ return python_mutants(rel, text)
291
+ if suffix in C_FAMILY:
292
+ return c_family_mutants(rel, text)
293
+ return []
294
+
295
+
296
+ def changed_lines(repo: Path, ref: str, paths: list[Path]) -> dict[Path, set[int] | None]:
297
+ """Lines added or changed since ref (working tree included). None means the whole file (untracked, so all new)."""
298
+ untracked = set(run_git(repo, ["ls-files", "--others", "--exclude-standard", "--full-name"]).split("\n"))
299
+ result: dict[Path, set[int] | None] = {}
300
+ for path in paths:
301
+ rel = path.resolve().relative_to(repo).as_posix()
302
+ if rel in untracked:
303
+ result[path] = None
304
+ continue
305
+ lines: set[int] = set()
306
+ for match in re.finditer(r"^@@ -\S+ \+(\d+)(?:,(\d+))? @@", run_git(repo, ["diff", "-U0", ref, "--", rel]), re.MULTILINE):
307
+ first, count = int(match.group(1)), int(match.group(2) or "1")
308
+ lines.update(range(first, first + count))
309
+ result[path] = lines
310
+ return result
311
+
312
+
313
+ def run_git(repo: Path, args: list[str]) -> str:
314
+ done = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, check=False)
315
+ if done.returncode:
316
+ raise SystemExit(f"git {' '.join(args)}: {done.stderr.strip()}")
317
+ return done.stdout
318
+
319
+
320
+ def resolve_ref(repo: Path, ref: str) -> str | None:
321
+ """`latest-tag:<glob>` is the newest tag matching glob (the last release); anything else is a git ref as given."""
322
+ if not ref.startswith("latest-tag:"):
323
+ return ref
324
+ tags = run_git(repo, ["tag", "-l", ref.split(":", 1)[1], "--sort=-creatordate"]).split()
325
+ return tags[0] if tags else None # never released: every line is new
326
+
327
+
328
+ def git_top(path: Path) -> Path | None:
329
+ done = subprocess.run(["git", "-C", str(path), "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False)
330
+ return Path(done.stdout.strip()).resolve() if done.returncode == 0 else None
331
+
332
+
333
+ def matches(rel: str, patterns: list[str]) -> bool:
334
+ return any(fnmatch.fnmatchcase(rel, pattern) or fnmatch.fnmatchcase(rel, pattern.replace("**/", "")) for pattern in patterns)
335
+
336
+
337
+ def source_files(root: Path, sources: list[str], exclude: list[str]) -> list[Path]:
338
+ files: set[Path] = set()
339
+ for pattern in sources:
340
+ files.update(p for p in root.glob(pattern) if p.is_file())
341
+ return sorted(p for p in files if not matches(p.relative_to(root).as_posix(), exclude)
342
+ and not any(part in COPY_EXCLUDE for part in p.relative_to(root).parts))
343
+
344
+
345
+ def load_config(args: argparse.Namespace) -> dict:
346
+ config: dict = {}
347
+ base = Path.cwd()
348
+ if args.config:
349
+ path = Path(args.config).resolve()
350
+ config = json.loads(path.read_text())
351
+ base = git_top(path.parent) or path.parent
352
+ for key in ("root", "build", "test"):
353
+ if getattr(args, key):
354
+ config[key] = getattr(args, key)
355
+ if args.source:
356
+ config["sources"] = args.source
357
+ if not config.get("root") or not config.get("sources") or not config.get("test"):
358
+ raise SystemExit("need root, sources and test (from --config or flags)")
359
+ config["root"] = str((base / config["root"]).resolve())
360
+ config["name"] = config.get("name") or Path(config["root"]).name
361
+ return config
362
+
363
+
364
+ def shell(command: str, cwd: Path, env: dict[str, str], timeout: float) -> tuple[str, float, str]:
365
+ """('ok'|'fail'|'timeout', seconds, output tail). The whole process group dies on timeout."""
366
+ began = time.monotonic()
367
+ # nice: a sweep is background work and must not starve the machine's interactive sessions and services
368
+ process = subprocess.Popen(["nice", "-n", "10", "/bin/sh", "-c", command], cwd=cwd, env=env, stdout=subprocess.PIPE,
369
+ stderr=subprocess.STDOUT, text=True, errors="replace", start_new_session=True)
370
+ try:
371
+ output, _ = process.communicate(timeout=timeout)
372
+ except subprocess.TimeoutExpired:
373
+ kill_tree(process.pid, cwd)
374
+ output, _ = process.communicate()
375
+ return "timeout", time.monotonic() - began, digest(output or "")
376
+ return ("ok" if process.returncode == 0 else "fail"), time.monotonic() - began, digest(output or "")
377
+
378
+
379
+ def kill_tree(pid: int, copy: Path) -> None:
380
+ """Every process the command started, not just its process group: `swift test` runs xctest in a group of its own, and
381
+ an infinite-loop mutant then spun at 90% CPU for 4 h 40 min after its timeout (2026-10-28). Descendants first, then
382
+ anything still running from inside this copy (it may already have been reparented to launchd)."""
383
+ table = subprocess.run(["ps", "-A", "-o", "pid=,ppid="], capture_output=True, text=True).stdout.split()
384
+ children: dict[int, list[int]] = {}
385
+ for child, parent in zip(table[::2], table[1::2]):
386
+ children.setdefault(int(parent), []).append(int(child))
387
+ victims, stack = [], [pid]
388
+ while stack:
389
+ current = stack.pop()
390
+ victims.append(current)
391
+ stack.extend(children.get(current, []))
392
+ for victim in [pid] + victims:
393
+ try:
394
+ os.killpg(victim, 9) if victim == pid else os.kill(victim, 9)
395
+ except (ProcessLookupError, PermissionError):
396
+ pass
397
+ subprocess.run(["pkill", "-9", "-f", f"{Path(copy).resolve()}/"], capture_output=True)
398
+ subprocess.run(["pkill", "-9", "-f", f"{Path(copy)}/"], capture_output=True)
399
+
400
+
401
+ FAILURE_LINE = re.compile(r"error:|' failed|failed \(|FAIL|Traceback|AssertionError|✘|not ok ")
402
+
403
+
404
+ def digest(output: str) -> str:
405
+ """The lines that name what failed (anywhere in the output), then the tail. A plain tail cut the failing test's name."""
406
+ named = [line for line in output.splitlines() if FAILURE_LINE.search(line)][:20]
407
+ return ("\n".join(named) + "\n...\n" if named else "") + output[-1500:]
408
+
409
+
410
+ def touch(path: Path) -> None:
411
+ """Move mtime forward so mtime-based build caches always see the edit, even two edits within one tick."""
412
+ stamp = max(time.time_ns(), path.stat().st_mtime_ns + 1_000_000_000)
413
+ os.utime(path, ns=(stamp, stamp))
414
+
415
+
416
+ def uncovered_lines(lcov: str, copy: Path) -> dict[str, set[int]]:
417
+ """Lines an lcov report saw as executable and never ran, keyed by path relative to the copy. Relative SF paths are
418
+ relative to the copy (node writes them so); a file can appear in several records (one per test process), and a line
419
+ any record ran counts as run."""
420
+ base = copy.resolve()
421
+ zero: dict[str, set[int]] = {}
422
+ ran: dict[str, set[int]] = {}
423
+ rel: str | None = None
424
+ for line in lcov.splitlines():
425
+ if line.startswith("SF:"):
426
+ source = Path(line[3:])
427
+ try:
428
+ rel = (source if source.is_absolute() else base / source).resolve().relative_to(base).as_posix()
429
+ except ValueError:
430
+ rel = None
431
+ elif line.startswith("DA:") and rel is not None:
432
+ number, count = line[3:].split(",")[:2]
433
+ (zero if int(float(count)) == 0 else ran).setdefault(rel, set()).add(int(number))
434
+ return {path: lines - ran.get(path, set()) for path, lines in zero.items()}
435
+
436
+
437
+ def fill(command: str, orig: Path, rel: str = "", lcov: str = "") -> str:
438
+ return (command.replace("{orig}", str(orig)).replace("{file}", rel).replace("{stem}", Path(rel).stem if rel else "")
439
+ .replace("{lcov}", lcov))
440
+
441
+
442
+ def copy_tree(source: Path, target: Path, excluded: set[str]) -> None:
443
+ """APFS clone when the OS has one (a 1 GB repo copies in seconds and costs no disk), plain copy otherwise."""
444
+ if sys.platform == "darwin" and subprocess.run(["cp", "-cR", str(source), str(target)], capture_output=True).returncode == 0:
445
+ for folder, dirs, _files in os.walk(target):
446
+ for name in [d for d in dirs if d in excluded]:
447
+ dirs.remove(name)
448
+ victim = Path(folder) / name
449
+ victim.unlink() if victim.is_symlink() else shutil.rmtree(victim, ignore_errors=True)
450
+ return
451
+ shutil.rmtree(target, ignore_errors=True)
452
+ shutil.copytree(source, target, symlinks=True, ignore=lambda _d, names: [n for n in names if n in excluded])
453
+
454
+
455
+ class Worker:
456
+ def __init__(self, index: int, config: dict, scratch: Path, env: dict[str, str]):
457
+ self.orig = Path(config["root"])
458
+ self.copy = scratch / f"w{index}"
459
+ self.config, self.env = config, env
460
+ copy_tree(self.orig, self.copy, COPY_EXCLUDE | set(config.get("copy_exclude", [])))
461
+ self.build_seconds = self.test_seconds = 0.0
462
+
463
+ def baseline(self) -> str | None:
464
+ if self.config.get("setup"): # once per copy: `pod install`, `npm ci`, a dedicated simulator
465
+ status, _, tail = shell(fill(self.config["setup"], self.orig), self.copy, self.env, 3600)
466
+ if status != "ok":
467
+ return f"setup {status}:\n{tail}"
468
+ if self.config.get("build"):
469
+ status, self.build_seconds, tail = shell(fill(self.config["build"], self.orig), self.copy, self.env, 3600)
470
+ if status != "ok":
471
+ return f"baseline build {status}:\n{tail}"
472
+ status, self.test_seconds, tail = shell(fill(self.config["test"], self.orig), self.copy, self.env, 3600)
473
+ return None if status == "ok" else f"baseline tests {status} (they must pass before mutants mean anything):\n{tail}"
474
+
475
+ def coverage(self) -> dict[str, set[int]] | None:
476
+ """Optional `coverage` command writes lcov to {lcov}; mutants on never-run lines need no test run to judge."""
477
+ if not self.config.get("coverage"):
478
+ return None
479
+ lcov = self.copy.parent / f"{self.copy.name}.lcov"
480
+ status, _, tail = shell(fill(self.config["coverage"], self.orig, lcov=str(lcov)), self.copy, self.env, 3600)
481
+ if status != "ok" or not lcov.exists():
482
+ print(f"coverage {status}; every mutant runs:\n{tail[-600:]}", file=sys.stderr)
483
+ return None
484
+ return uncovered_lines(lcov.read_text(errors="replace"), self.copy)
485
+
486
+ def run(self, mutant: Mutant, build_limit: float, test_limit: float) -> tuple[str, float, str]:
487
+ target = self.copy / mutant.file
488
+ original = target.read_text()
489
+ began = time.monotonic()
490
+ try:
491
+ target.write_text(mutant.apply(original))
492
+ touch(target)
493
+ if self.config.get("build"):
494
+ status, _, tail = shell(fill(self.config["build"], self.orig, mutant.file), self.copy, self.env, build_limit)
495
+ if status != "ok":
496
+ return ("invalid" if status == "fail" else "timeout"), time.monotonic() - began, tail
497
+ status, _, tail = shell(fill(self.config["test"], self.orig, mutant.file), self.copy, self.env, test_limit)
498
+ verdict = {"ok": "survived", "fail": "killed", "timeout": "timeout"}[status]
499
+ return verdict, time.monotonic() - began, tail
500
+ finally:
501
+ target.write_text(original)
502
+ touch(target)
503
+
504
+
505
+ def collect(config: dict, changed_since: str | None, operators: set[str]) -> tuple[list[Mutant], str | None]:
506
+ """The mutants in scope, and the ref the scope was cut at (None: whole source set)."""
507
+ root = Path(config["root"])
508
+ files = source_files(root, config["sources"], config.get("exclude", []))
509
+ scope: dict[Path, set[int] | None] = {}
510
+ if changed_since:
511
+ repo = git_top(root)
512
+ if repo is None:
513
+ raise SystemExit("--changed-since needs a git checkout")
514
+ ref = resolve_ref(repo, changed_since)
515
+ if ref is None:
516
+ print(f"{changed_since}: no such tag yet, so this is a full sweep (sampled if the config says so); "
517
+ "tag the last release to gate on changed lines", flush=True)
518
+ changed_since = None
519
+ else:
520
+ scope = changed_lines(repo, ref, files)
521
+ changed_since = ref
522
+ found: list[Mutant] = []
523
+ for path in files:
524
+ lines = scope.get(path, None) if changed_since else None
525
+ if changed_since and lines is not None and not lines:
526
+ continue
527
+ for mutant in mutants_for(path.relative_to(root).as_posix(), path.read_text(errors="replace")):
528
+ if mutant.op in operators and (lines is None or mutant.line in lines):
529
+ found.append(mutant)
530
+ return found, changed_since
531
+
532
+
533
+ def report(config: dict, results: list[dict], out: Path, args: argparse.Namespace, seconds: float,
534
+ coverage: bool = False) -> dict:
535
+ counts = {k: sum(1 for r in results if r["verdict"] == k) for k in ("killed", "timeout", "survived", "uncovered", "invalid")}
536
+ caught = counts["killed"] + counts["timeout"]
537
+ measured = caught + counts["survived"] + counts["uncovered"]
538
+ summary = {"name": config["name"], "root": config["root"], "changed_since": args.changed_since, "mutants": len(results),
539
+ **counts, "score": round(caught / measured, 4) if measured else None, "seconds": round(seconds),
540
+ "finished": dt.datetime.now().isoformat(timespec="seconds")}
541
+ survivors = sorted((r for r in results if r["verdict"] in ("survived", "uncovered")), key=lambda r: (r["file"], r["line"]))
542
+ out.mkdir(parents=True, exist_ok=True)
543
+ (out / "mutation.json").write_text(json.dumps({"summary": summary, "results": results}, ensure_ascii=False, indent=1))
544
+ lived = "tests run it, nothing checks it" if coverage else "no test failed (coverage not measured)"
545
+ rows = [f"| `{r['file']}:{r['line']}` | {r['op']} | `{r['before'].strip()}` → `{r['after'].strip() or '(removed)'}` | "
546
+ f"{'no test runs this line' if r['verdict'] == 'uncovered' else lived} |" for r in survivors]
547
+ score = f"{summary['score']:.0%}" if summary["score"] is not None else "n/a"
548
+ lines = [f"# Mutation report: {config['name']}", "",
549
+ f"Scope: {'lines changed since ' + args.changed_since if args.changed_since else 'all sources'}. "
550
+ f"Score {score} ({caught} caught of {measured}); {counts['invalid']} did not compile; {summary['seconds']} s.", "",
551
+ "Each survivor needs a test that fails on it, or `mutation-ok: <reason>` on its line if it cannot change behaviour.", ""]
552
+ if rows:
553
+ lines += ["| Where | Operator | Change | Why it lived |", "|---|---|---|---|", *rows]
554
+ (out / "mutation.md").write_text("\n".join(lines) + "\n")
555
+ return summary
556
+
557
+
558
+ def main(argv: list[str] | None = None) -> int:
559
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
560
+ parser.add_argument("--config")
561
+ parser.add_argument("--root")
562
+ parser.add_argument("--source", action="append", help="glob relative to root; repeatable")
563
+ parser.add_argument("--build")
564
+ parser.add_argument("--test")
565
+ parser.add_argument("--changed-since", help="git ref; mutate only lines added or changed since it")
566
+ parser.add_argument("--file", action="append", help="limit to these files, or file:line (relative to root); repeatable")
567
+ parser.add_argument("--operator", action="append", choices=OPERATORS)
568
+ parser.add_argument("--max-mutants", type=int, help="seeded sample when there are more")
569
+ parser.add_argument("--seed", type=int, default=1)
570
+ parser.add_argument("--jobs", type=int)
571
+ parser.add_argument("--max-survivors", type=int, default=0, help="exit 0 up to this many survivors")
572
+ parser.add_argument("--out", help="report directory (default: $TMPDIR/mutation-reports/<name>-<time>)")
573
+ parser.add_argument("--list", action="store_true", help="print the mutants and stop")
574
+ parser.add_argument("--keep", action="store_true", help="keep the scratch copies")
575
+ parser.add_argument("--resume", action="store_true", help="reuse verdicts already in --out/results.jsonl (a long run cut short)")
576
+ args = parser.parse_args(argv)
577
+
578
+ config = load_config(args)
579
+ operators = set(args.operator or config.get("operators") or OPERATORS)
580
+ mutants, scoped_at = collect(config, args.changed_since, operators)
581
+ if args.file: # `path` or `path:line`
582
+ wanted = [f.rsplit(":", 1) if re.fullmatch(r".+:\d+", f) else [f, None] for f in args.file]
583
+ mutants = [m for m in mutants if any(m.file == f and (line is None or m.line == int(line)) for f, line in wanted)]
584
+ cap = args.max_mutants or (None if scoped_at else config.get("sweep_sample"))
585
+ args.changed_since = scoped_at
586
+ if cap and len(mutants) > cap:
587
+ mutants = sorted(random.Random(args.seed).sample(mutants, cap), key=lambda m: (m.file, m.start))
588
+ if args.list:
589
+ for mutant in mutants:
590
+ print(mutant.label())
591
+ print(f"{len(mutants)} mutants")
592
+ return 0
593
+ stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
594
+ out = Path(args.out) if args.out else Path(tempfile.gettempdir()) / "mutation-reports" / f"{config['name']}-{stamp}"
595
+ if not mutants:
596
+ report(config, [], out, args, 0)
597
+ print(f"{config['name']}: no mutants in scope; report {out / 'mutation.md'}")
598
+ return 0
599
+
600
+ env = os.environ.copy()
601
+ env["PYTHONDONTWRITEBYTECODE"] = "1" # a .pyc keyed on mtime seconds and size hides a same-size mutant written in the same second
602
+ if Path("/Applications/Xcode.app/Contents/Developer").is_dir():
603
+ env.setdefault("DEVELOPER_DIR", "/Applications/Xcode.app/Contents/Developer") # xcode-select may point at CommandLineTools
604
+ env.update({k: str(v) for k, v in config.get("env", {}).items()})
605
+ jobs = max(1, min(args.jobs or config.get("jobs") or max(1, (os.cpu_count() or 4) // 6), len(mutants)))
606
+ scratch = Path(tempfile.mkdtemp(prefix=f"mutate-{config['name']}-"))
607
+ began = time.monotonic()
608
+ measured_coverage = False
609
+ print(f"{config['name']}: {len(mutants)} mutants, {jobs} workers, scratch {scratch}", flush=True)
610
+ try:
611
+ workers = [Worker(i, config, scratch, env) for i in range(jobs)]
612
+ with futures.ThreadPoolExecutor(jobs) as pool:
613
+ failures = [f for f in pool.map(Worker.baseline, workers) if f]
614
+ if failures:
615
+ print(failures[0], file=sys.stderr)
616
+ return 2
617
+ factor = float(config.get("timeout_factor", 3))
618
+ build_limit = max(120.0, max(w.build_seconds for w in workers) * factor)
619
+ test_limit = float(config.get("test_timeout") or max(30.0, max(w.test_seconds for w in workers) * factor))
620
+ print(f"baseline ok: build {workers[0].build_seconds:.0f} s, tests {workers[0].test_seconds:.0f} s", flush=True)
621
+ results: list[dict] = []
622
+ lock = threading.Lock()
623
+ out.mkdir(parents=True, exist_ok=True)
624
+ journal = out / "results.jsonl"
625
+ done = {}
626
+ if args.resume and journal.exists():
627
+ for raw in journal.read_text().splitlines():
628
+ row = json.loads(raw)
629
+ done[(row["file"], row["start"], row["op"], row["after"])] = row
630
+ elif journal.exists():
631
+ journal.unlink()
632
+ coverage_map = workers[0].coverage()
633
+ measured_coverage = coverage_map is not None
634
+ never_run = coverage_map or {}
635
+ pending: queue.Queue[Mutant] = queue.Queue()
636
+ for mutant in mutants:
637
+ previous = done.get((mutant.file, mutant.start, mutant.op, mutant.after))
638
+ if previous:
639
+ results.append(previous)
640
+ elif mutant.line in never_run.get(mutant.file, ()):
641
+ results.append({**asdict(mutant), "verdict": "uncovered", "seconds": 0})
642
+ else:
643
+ pending.put(mutant)
644
+ if done:
645
+ print(f"resume: {len(results)} verdicts reused from {journal}", flush=True)
646
+ if never_run:
647
+ print(f"coverage: {len(results)} mutants sit on lines no test runs (uncovered, not run)", flush=True)
648
+
649
+ def drain(worker: Worker) -> None:
650
+ while True:
651
+ try:
652
+ mutant = pending.get_nowait()
653
+ except queue.Empty:
654
+ return
655
+ verdict, seconds, tail = worker.run(mutant, build_limit, test_limit)
656
+ row = {**asdict(mutant), "verdict": verdict, "seconds": round(seconds, 1)}
657
+ if verdict == "survived":
658
+ row["tail"] = tail[-400:]
659
+ with lock:
660
+ results.append(row)
661
+ with journal.open("a") as handle:
662
+ handle.write(json.dumps(row, ensure_ascii=False) + "\n")
663
+ print(f"[{len(results)}/{len(mutants)}] {verdict:8} {mutant.label()}", flush=True)
664
+
665
+ with futures.ThreadPoolExecutor(jobs) as pool:
666
+ list(pool.map(drain, workers))
667
+ finally:
668
+ for workdir in (scratch, scratch.resolve()): # nothing may outlive the run from inside its copies
669
+ subprocess.run(["pkill", "-9", "-f", f"{workdir}/"], capture_output=True)
670
+ if not args.keep:
671
+ shutil.rmtree(scratch, ignore_errors=True)
672
+ for litter in scratch.parent.glob(f"*{scratch.name}*"): # SwiftPM drops <mangled path>.build.lock files in TMPDIR
673
+ if litter.is_file():
674
+ litter.unlink(missing_ok=True)
675
+ summary = report(config, results, out, args, time.monotonic() - began, coverage=measured_coverage)
676
+ score = f"{summary['score']:.0%}" if summary["score"] is not None else "n/a"
677
+ print(f"{config['name']}: score {score}, killed {summary['killed']}, timeout {summary['timeout']}, "
678
+ f"survived {summary['survived']}, uncovered {summary['uncovered']}, invalid {summary['invalid']}; "
679
+ f"report {out / 'mutation.md'}")
680
+ return 1 if summary["survived"] + summary["uncovered"] > args.max_survivors else 0
681
+
682
+
683
+ if __name__ == "__main__":
684
+ sys.exit(main())