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.
- qa_agent_kit-0.6.0.dist-info/METADATA +157 -0
- qa_agent_kit-0.6.0.dist-info/RECORD +32 -0
- qa_agent_kit-0.6.0.dist-info/WHEEL +4 -0
- qa_agent_kit-0.6.0.dist-info/entry_points.txt +6 -0
- qa_agent_kit-0.6.0.dist-info/licenses/LICENSE +21 -0
- qakit/AGENTS.md +48 -0
- qakit/__init__.py +21 -0
- qakit/_cli.py +46 -0
- qakit/docs/README.md +14 -0
- qakit/docs/agents.md +40 -0
- qakit/docs/case-studies.md +146 -0
- qakit/docs/device-qa-protocol.md +426 -0
- qakit/docs/getting-started.md +100 -0
- qakit/docs/ko/device-qa-protocol.md +433 -0
- qakit/docs/manifest.md +96 -0
- qakit/docs/mutation-testing.md +69 -0
- qakit/docs/prose-qa.md +49 -0
- qakit/docs/pypi.md +40 -0
- qakit/docs/report.md +64 -0
- qakit/skills/README.md +51 -0
- qakit/skills/device-qa/SKILL.md +87 -0
- qakit/skills/prose-qa/SKILL.md +49 -0
- qakit/skills/qa-rigor/SKILL.md +105 -0
- qakit/tools/device_lock.py +151 -0
- qakit/tools/device_qa.py +838 -0
- qakit/tools/install_skills.py +120 -0
- qakit/tools/mutate.py +684 -0
- qakit/tools/prose_check.py +291 -0
- qakit/tools/recording_frames.swift +78 -0
- qakit/tools/release_checks.py +287 -0
- qakit/tools/screenshot_contrast.swift +42 -0
- qakit/tools/shots_gif.py +81 -0
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())
|