@jesdi/skills 0.0.49 → 0.0.51
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.
- package/package.json +2 -2
- package/skills/implement-spec/driver_agents.py +20 -2
- package/skills/implement-spec/driver_run.py +2 -0
- package/skills/implement-spec/tests/test_agents.py +23 -0
- package/skills/implement-spec/tests/test_driver.py +1 -1
- package/skills/test-prune/SKILL.md +156 -0
- package/skills/test-prune/prune.py +202 -0
- package/skills/test-prune/prune_repo.py +319 -0
- package/skills/test-prune/prune_review.py +363 -0
- package/skills/test-prune/prune_runs.py +240 -0
- package/skills/test-prune/prune_source.py +237 -0
- package/skills/test-prune/prune_strengthen.py +301 -0
- package/skills/test-prune/tests/conftest.py +15 -0
- package/skills/test-prune/tests/prune_harness.py +205 -0
- package/skills/test-prune/tests/test_prune_guards.py +871 -0
- package/skills/test-prune/tests/test_prune_inputs.py +97 -0
- package/skills/test-prune/tests/test_prune_review.py +113 -0
- package/skills/test-prune/tests/test_prune_source.py +84 -0
- package/skills/test-prune/tests/test_prune_strengthen.py +183 -0
- package/skills/test-prune/tests/test_prune_unattended.py +225 -0
- package/skills/test-prune/tests/testdata/gh +16 -0
- package/skills/test-prune/tests/testdata/repo/Makefile +25 -0
- package/skills/test-prune/tests/testdata/repo/backend/mutation_baseline.json +16 -0
- package/skills/test-prune/tests/testdata/repo/backend/services/concentration.py +2 -0
- package/skills/test-prune/tests/testdata/repo/backend/services/rebalancer.py +11 -0
- package/skills/test-prune/tests/testdata/repo/backend/tests/conftest.py +6 -0
- package/skills/test-prune/tests/testdata/repo/backend/tests/test_concentration.py +73 -0
- package/skills/test-prune/tests/testdata/repo/backend/tests/test_rebalancer.py +77 -0
- package/skills/test-prune/tests/testdata/repo/fake_engine.py +289 -0
- package/skills/test-prune/tests/testdata/repo/kill_map.json +131 -0
- package/skills-manifest.json +9 -3
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Test files on the AST: find a test by id, read its assertions, and compute the delete-only edit.
|
|
2
|
+
|
|
3
|
+
The edit mirrors what `make prune-gate` point 1 accepts: whole test functions (decorators, the
|
|
4
|
+
comment lines directly above and the blank lines before them), a test class left with nothing but
|
|
5
|
+
its docstring, and a top-level import whose every name only deleted code reads. Nothing else.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
|
|
11
|
+
FUNCS = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
12
|
+
ASSERTING_CALLS = {"raises", "approx", "warns"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def split_id(test_id: str) -> tuple[str, tuple[str, ...], str]:
|
|
16
|
+
"""`tests/x.py::C::f[p]` → (`tests/x.py`, (`C`,), `f`)."""
|
|
17
|
+
path, *names = test_id.split("[", 1)[0].split("::")
|
|
18
|
+
return path, tuple(names[:-1]), names[-1]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def targets(ids) -> dict:
|
|
22
|
+
"""{test file: {(classes, function)}} for the test ids."""
|
|
23
|
+
out: dict = {}
|
|
24
|
+
for test_id in ids:
|
|
25
|
+
path, classes, name = split_id(test_id)
|
|
26
|
+
out.setdefault(path, set()).add((classes, name))
|
|
27
|
+
return out
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def families(ids) -> list:
|
|
31
|
+
"""Test ids grouped by test function, in order: a parametrize family is deleted, capped, bisected and
|
|
32
|
+
restored as one unit, because deleting one case deletes the function."""
|
|
33
|
+
groups: dict = {}
|
|
34
|
+
for test_id in ids:
|
|
35
|
+
groups.setdefault(test_id.split("[")[0], []).append(test_id)
|
|
36
|
+
return list(groups.values())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _span(node: ast.stmt) -> tuple[int, int]:
|
|
40
|
+
return min([node.lineno, *(d.lineno for d in getattr(node, "decorator_list", []))]), node.end_lineno
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def find(tree: ast.Module, classes: tuple[str, ...], name: str) -> ast.stmt | None:
|
|
44
|
+
owner = find_class(tree, classes) if classes else tree
|
|
45
|
+
return next((n for n in owner.body if isinstance(n, FUNCS) and n.name == name), None) if owner else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _callee(func: ast.expr) -> str | None:
|
|
49
|
+
return func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _asserts_by_call(node: ast.AST) -> bool:
|
|
53
|
+
return any(isinstance(n, ast.Call) and _callee(n.func) in ASSERTING_CALLS for n in ast.walk(node))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_assertion(node: ast.AST) -> bool:
|
|
57
|
+
if isinstance(node, ast.Assert):
|
|
58
|
+
return True
|
|
59
|
+
if isinstance(node, (ast.With, ast.AsyncWith)):
|
|
60
|
+
return any(_asserts_by_call(item.context_expr) for item in node.items)
|
|
61
|
+
return isinstance(node, (ast.Expr, ast.Assign, ast.AnnAssign, ast.AugAssign)) and _asserts_by_call(node)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def describe(source: str, test_id: str) -> dict | None:
|
|
65
|
+
"""{source, assertions} of the test function behind test_id, or None when the file lacks it."""
|
|
66
|
+
_, classes, name = split_id(test_id)
|
|
67
|
+
node = find(ast.parse(source), classes, name)
|
|
68
|
+
if node is None:
|
|
69
|
+
return None
|
|
70
|
+
start, end = _span(node)
|
|
71
|
+
asserts = sorted((n for n in ast.walk(node) if _is_assertion(n)), key=lambda n: (n.lineno, n.col_offset))
|
|
72
|
+
return {"source": "".join(source.splitlines(keepends=True)[start - 1:end]),
|
|
73
|
+
"assertions": [ast.get_source_segment(source, n) for n in asserts]}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _uses(tree: ast.Module) -> tuple[dict[str, set[int]], set[str]]:
|
|
77
|
+
"""name → lines that read, take or spell it; and the names a parameter or a string carries."""
|
|
78
|
+
uses: dict[str, set[int]] = {}
|
|
79
|
+
pinned: set[str] = set()
|
|
80
|
+
for node in ast.walk(tree):
|
|
81
|
+
if isinstance(node, ast.Name):
|
|
82
|
+
name, pin = node.id, False
|
|
83
|
+
elif isinstance(node, ast.arg):
|
|
84
|
+
name, pin = node.arg, True
|
|
85
|
+
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
86
|
+
name, pin = node.value, True
|
|
87
|
+
else:
|
|
88
|
+
continue
|
|
89
|
+
uses.setdefault(name, set()).add(node.lineno)
|
|
90
|
+
if pin:
|
|
91
|
+
pinned.add(name)
|
|
92
|
+
return uses, pinned
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def undeletable(source: str, test_id: str) -> str | None:
|
|
96
|
+
"""Why the prune gate would refuse deleting this test whole, or None."""
|
|
97
|
+
_, classes, name = split_id(test_id)
|
|
98
|
+
tree = ast.parse(source)
|
|
99
|
+
node = find(tree, classes, name)
|
|
100
|
+
if node is None:
|
|
101
|
+
return "not a test function defined in its file (inherited or generated), cannot be deleted whole"
|
|
102
|
+
uses = _uses(tree)[0]
|
|
103
|
+
if _named_outside(uses, node):
|
|
104
|
+
return f"the file reads or spells the name {name} outside the test"
|
|
105
|
+
named = next((cls for cls in _enclosing(tree, classes) if _named_outside(uses, cls)), None)
|
|
106
|
+
if named is not None:
|
|
107
|
+
return (f"class {named.name} is subclassed or named elsewhere in the file: deleting its test changes "
|
|
108
|
+
"what that code inherits or finds")
|
|
109
|
+
owner = find_class(tree, classes)
|
|
110
|
+
if owner is not None and owner.body[0] is node and node.decorator_list:
|
|
111
|
+
return (f"decorated first member of class {owner.name}: the prune gate reads its decorators as the "
|
|
112
|
+
"class header")
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _named_outside(uses: dict, node: ast.stmt) -> bool:
|
|
117
|
+
start, end = _span(node)
|
|
118
|
+
return bool(uses.get(node.name, set()) - set(range(start, end + 1)))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _enclosing(tree: ast.Module, classes: tuple[str, ...]) -> list:
|
|
122
|
+
"""The class nodes along classes, outermost first."""
|
|
123
|
+
return [c for c in (find_class(tree, classes[:depth]) for depth in range(1, len(classes) + 1)) if c is not None]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def find_class(tree: ast.Module, classes: tuple[str, ...]) -> ast.ClassDef | None:
|
|
127
|
+
node, body = None, tree.body
|
|
128
|
+
for cls in classes:
|
|
129
|
+
node = next((n for n in body if isinstance(n, ast.ClassDef) and n.name == cls), None)
|
|
130
|
+
if node is None:
|
|
131
|
+
return None
|
|
132
|
+
body = node.body
|
|
133
|
+
return node
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _whole_class(node: ast.ClassDef, scope: tuple[str, ...], targets: set) -> bool:
|
|
137
|
+
body = node.body[1:] if ast.get_docstring(node) is not None else node.body
|
|
138
|
+
return bool(body) and all(isinstance(m, FUNCS) and (scope, m.name) in targets for m in body)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _marked(body: list[ast.stmt], scope: tuple[str, ...], floor: int, targets: set):
|
|
142
|
+
"""(node, floor) for every statement to delete; floor = last line of the enclosing class header."""
|
|
143
|
+
for node in body:
|
|
144
|
+
if isinstance(node, FUNCS) and (scope, node.name) in targets:
|
|
145
|
+
yield node, floor
|
|
146
|
+
elif isinstance(node, ast.ClassDef) and _whole_class(node, scope + (node.name,), targets):
|
|
147
|
+
yield node, floor
|
|
148
|
+
elif isinstance(node, ast.ClassDef):
|
|
149
|
+
yield from _marked(node.body, scope + (node.name,), node.body[0].lineno - 1, targets)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _removed_classes(body: list[ast.stmt], scope: tuple[str, ...], parent, targets: set):
|
|
153
|
+
"""(class, its scope, enclosing class or None) for every class the deletion removes whole."""
|
|
154
|
+
for node in body:
|
|
155
|
+
if isinstance(node, ast.ClassDef) and _whole_class(node, scope + (node.name,), targets):
|
|
156
|
+
yield node, scope, parent
|
|
157
|
+
elif isinstance(node, ast.ClassDef):
|
|
158
|
+
yield from _removed_classes(node.body, scope + (node.name,), node, targets)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _leaves_empty(parent: ast.ClassDef, scope: tuple[str, ...], targets: set) -> bool:
|
|
162
|
+
"""Whether deleting targets leaves the class with no statement at all."""
|
|
163
|
+
def removed(m: ast.stmt) -> bool:
|
|
164
|
+
if isinstance(m, FUNCS):
|
|
165
|
+
return (scope, m.name) in targets
|
|
166
|
+
return isinstance(m, ast.ClassDef) and _whole_class(m, scope + (m.name,), targets)
|
|
167
|
+
return all(removed(m) for m in parent.body)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def class_refusals(source: str, test_ids: list) -> dict:
|
|
171
|
+
"""{test id: reason} for tests whose deletion together removes a nested class and leaves its
|
|
172
|
+
enclosing class empty (a class named outside itself is refused per test, by undeletable)."""
|
|
173
|
+
tree = ast.parse(source)
|
|
174
|
+
targets = {split_id(t)[1:] for t in test_ids}
|
|
175
|
+
out: dict = {}
|
|
176
|
+
for node, scope, parent in _removed_classes(tree.body, (), None, targets):
|
|
177
|
+
if parent is not None and _leaves_empty(parent, scope, targets):
|
|
178
|
+
path = scope + (node.name,)
|
|
179
|
+
reason = f"deleting it removes class {node.name} and leaves class {parent.name} empty"
|
|
180
|
+
out.update({t: reason for t in test_ids if split_id(t)[1][:len(path)] == path})
|
|
181
|
+
return out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _with_surroundings(lines: list[str], start: int, end: int, floor: int) -> set[int]:
|
|
185
|
+
"""The statement's lines, the comment lines directly above, the blank lines before those; when
|
|
186
|
+
that reaches the class header or the file start, the blank lines after instead."""
|
|
187
|
+
first = start
|
|
188
|
+
while first - 1 > floor and lines[first - 2].strip().startswith("#"):
|
|
189
|
+
first -= 1
|
|
190
|
+
while first - 1 > floor and not lines[first - 2].strip():
|
|
191
|
+
first -= 1
|
|
192
|
+
last = end
|
|
193
|
+
if first - 1 == floor:
|
|
194
|
+
while last < len(lines) and not lines[last].strip():
|
|
195
|
+
last += 1
|
|
196
|
+
return set(range(first, last + 1))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _dead_imports(tree: ast.Module, removed: set) -> set:
|
|
200
|
+
"""Lines of top-level imports whose every name is read only on removed lines (never a name a
|
|
201
|
+
parameter or a string carries: a fixture, a usefixtures marker)."""
|
|
202
|
+
uses, pinned = _uses(tree)
|
|
203
|
+
dead: set = set()
|
|
204
|
+
for node in tree.body:
|
|
205
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
206
|
+
names = {(a.asname or a.name).split(".")[0] for a in node.names}
|
|
207
|
+
if not names & pinned and all(uses.get(n) and uses[n] <= removed for n in names):
|
|
208
|
+
dead |= set(range(node.lineno, node.end_lineno + 1))
|
|
209
|
+
return dead
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def delete_tests(source: str, targets: set[tuple[tuple[str, ...], str]]) -> str:
|
|
213
|
+
"""source without the test functions in targets ({(classes, name)}), shaped for the prune gate."""
|
|
214
|
+
tree = ast.parse(source)
|
|
215
|
+
lines = source.splitlines(keepends=True)
|
|
216
|
+
removed: set[int] = set()
|
|
217
|
+
for node, floor in list(_marked(tree.body, (), 0, targets)):
|
|
218
|
+
removed |= _with_surroundings(lines, *_span(node), floor)
|
|
219
|
+
removed |= _dead_imports(tree, removed)
|
|
220
|
+
return "".join(line for number, line in enumerate(lines, 1) if number not in removed)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_sources(source: str) -> dict[str, str]:
|
|
224
|
+
"""`[Class::]name` → source of every test function in the file."""
|
|
225
|
+
out: dict[str, str] = {}
|
|
226
|
+
lines = source.splitlines()
|
|
227
|
+
|
|
228
|
+
def walk(body: list[ast.stmt], prefix: str) -> None:
|
|
229
|
+
for node in body:
|
|
230
|
+
if isinstance(node, FUNCS) and node.name.startswith("test"):
|
|
231
|
+
start, end = _span(node)
|
|
232
|
+
out[prefix + node.name] = "\n".join(lines[start - 1:end])
|
|
233
|
+
elif isinstance(node, ast.ClassDef):
|
|
234
|
+
walk(node.body, f"{prefix}{node.name}::")
|
|
235
|
+
|
|
236
|
+
walk(ast.parse(source).body, "")
|
|
237
|
+
return out
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""strengthen-start, strengthen-verify and strengthen-finish: prove each strengthen item kills the
|
|
2
|
+
mutant ids it names (surviving on the base, killed on the head), keep what the reviewer accepts,
|
|
3
|
+
lower the baseline, and (unattended) open the strengthen PR from the base commit."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from prune_repo import KILLED, SURVIVING, BadInput, Fail, Repo, ratchet_lines
|
|
9
|
+
from prune_review import check_reviews, equivalents_section, suggestions_section
|
|
10
|
+
from prune_source import test_sources
|
|
11
|
+
|
|
12
|
+
TRAILER = "Test-Prune-Item"
|
|
13
|
+
BASELINE = "backend/mutation_baseline.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _branch(ctx) -> str:
|
|
17
|
+
return f"chore/test-strengthen-{ctx.date}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _work_item(item: dict) -> dict:
|
|
21
|
+
candidate = item.get("candidate") or {}
|
|
22
|
+
return {"id": item["id"], "kind": item["kind"], "mutant_ids": item.get("mutant_ids", []),
|
|
23
|
+
"module": item.get("module"), "function": item.get("function"),
|
|
24
|
+
"test": item.get("test") or item.get("test_id"), "assertion": item.get("assertion"),
|
|
25
|
+
"source": candidate.get("source"), "reason": item.get("fixer_reason"), "review": item["reason"]}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def strengthen_start(ctx) -> int:
|
|
29
|
+
repo, base = ctx.repo, ctx.base()
|
|
30
|
+
out = ctx.state.load("batch.json")
|
|
31
|
+
items = [i for i in out["strengthen"] if i["kind"] == "weak_spot"]
|
|
32
|
+
if not items:
|
|
33
|
+
ctx.state.emit("\n".join(["nothing to strengthen"] + suggestions_section(out)
|
|
34
|
+
+ equivalents_section(out["equivalents"], shown=5)))
|
|
35
|
+
ctx.finished = True
|
|
36
|
+
return 0
|
|
37
|
+
if base["mode"] == "unattended":
|
|
38
|
+
repo.detach_at(base, _branch(ctx), "strengthen-start")
|
|
39
|
+
else:
|
|
40
|
+
repo.require_clean()
|
|
41
|
+
start = repo.head()
|
|
42
|
+
work = [_work_item(i) for i in items]
|
|
43
|
+
ctx.state.save("strengthen.json", {"start": start, "verified": False, "items": work,
|
|
44
|
+
"status": {i["id"]: "open" for i in work}, "closed": {}})
|
|
45
|
+
path = ctx.state.save("strengthen-input.json", {"start": start, "items": work})
|
|
46
|
+
ctx.state.emit(f"{len(work)} strengthen items → {path}\nCommit each item test-first on top of {start[:12]}: one or more "
|
|
47
|
+
f"commits per item, each touching only backend/tests/ and ending with the trailer `{TRAILER}: <item id>`.")
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _history(repo, start: str) -> list:
|
|
52
|
+
"""[(sha, trailer values, paths)] for every commit since start, oldest first."""
|
|
53
|
+
out = []
|
|
54
|
+
for sha in repo.git("rev-list", "--reverse", f"{start}..HEAD").split():
|
|
55
|
+
values = repo.git("log", "-1", f"--format=%(trailers:key={TRAILER},valueonly)", sha).split("\n")
|
|
56
|
+
paths = repo.git("diff-tree", "--no-commit-id", "--name-only", "-r", sha).split()
|
|
57
|
+
out.append((sha, [v.strip() for v in values if v.strip()], paths))
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def commit_problems(sha: str, items: list, paths: list, known: set) -> list:
|
|
62
|
+
problems = [f"{sha[:12]}: touches {p}, outside backend/tests/" for p in paths if not p.startswith("backend/tests/")]
|
|
63
|
+
if len(items) != 1 or items[0] not in known:
|
|
64
|
+
problems.insert(0, f"{sha[:12]}: needs exactly one `{TRAILER}` trailer naming a strengthen item, has {items}")
|
|
65
|
+
return problems
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def reopened(history: list, closed: dict) -> list:
|
|
69
|
+
"""Commits naming an item after test-prune closed it (reverted or rejected at the recorded commit)."""
|
|
70
|
+
position = {sha: i for i, (sha, _, _) in enumerate(history)}
|
|
71
|
+
return [f"{sha[:12]}: {items[0]} was closed at {closed[items[0]][:12]}; new commits for it are refused"
|
|
72
|
+
for i, (sha, items, _) in enumerate(history)
|
|
73
|
+
if len(items) == 1 and items[0] in closed and i > position.get(closed[items[0]], -1)]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _commits(repo, st: dict) -> list:
|
|
77
|
+
"""[(sha, item id)] since start; every commit must name a known, open item and touch only backend/tests/."""
|
|
78
|
+
start = st["start"]
|
|
79
|
+
if repo.run_git("merge-base", "--is-ancestor", start, "HEAD").returncode:
|
|
80
|
+
raise BadInput(f"HEAD does not descend from the strengthen start {start}")
|
|
81
|
+
errors = ["merge commits since the strengthen start"] if repo.git(
|
|
82
|
+
"rev-list", "--min-parents=2", f"{start}..HEAD").strip() else []
|
|
83
|
+
history = _history(repo, start)
|
|
84
|
+
known = {i["id"] for i in st["items"]}
|
|
85
|
+
for sha, items, paths in history:
|
|
86
|
+
errors += commit_problems(sha, items, paths, known)
|
|
87
|
+
errors += reopened(history, st["closed"])
|
|
88
|
+
if errors:
|
|
89
|
+
raise BadInput("strengthen commits rejected:\n" + "\n".join(errors))
|
|
90
|
+
return [(sha, items[0]) for sha, items, _ in history]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _revert(repo, item_id: str, commits: list, why: str) -> None:
|
|
94
|
+
shas = [sha for sha, item in reversed(commits) if item == item_id]
|
|
95
|
+
if not shas:
|
|
96
|
+
return
|
|
97
|
+
if repo.run_git("revert", "--no-commit", *shas).returncode:
|
|
98
|
+
repo.run_git("revert", "--abort")
|
|
99
|
+
raise Fail(f"reverting strengthen item {item_id} conflicts with a later item's commits")
|
|
100
|
+
repo.git("commit", "-q", "--allow-empty", "-m", f"Revert test-prune item {item_id}", "-m", why,
|
|
101
|
+
"-m", f"{TRAILER}: {item_id}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _close(repo, st: dict, item_id: str, commits: list, status: str, why: str) -> None:
|
|
105
|
+
"""Revert the item's commits and record where it closed: later commits for it are refused."""
|
|
106
|
+
_revert(repo, item_id, commits, why)
|
|
107
|
+
st["status"][item_id] = status
|
|
108
|
+
st["closed"][item_id] = repo.head()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def item_proof(base_codes: dict, codes: dict, item: dict, has_commits: bool) -> dict:
|
|
112
|
+
"""Per mutant id the base and head exit codes; proven when the item names at least one id, every id
|
|
113
|
+
survived on the base and is killed on the head, and the item has a commit."""
|
|
114
|
+
module = item.get("module")
|
|
115
|
+
mutants = {m: {"base": base_codes.get(module, {}).get(m), "head": codes.get(module, {}).get(m)}
|
|
116
|
+
for m in item["mutant_ids"]}
|
|
117
|
+
proven = has_commits and bool(mutants) and all(e["base"] in SURVIVING and e["head"] in KILLED for e in mutants.values())
|
|
118
|
+
return {"status": "proven" if proven else "unproven", "module": module, "mutants": mutants}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _judge(ctx, base: dict, st: dict, proof: dict, commits: list) -> list:
|
|
122
|
+
"""One cold run; records each open item's proof and returns the unproven ones."""
|
|
123
|
+
ctx.repo.cold_mutation()
|
|
124
|
+
codes = ctx.repo.mutant_codes()
|
|
125
|
+
items = {i["id"]: i for i in st["items"]}
|
|
126
|
+
failing = []
|
|
127
|
+
for item_id in [i for i, s in st["status"].items() if s in ("open", "proven")]:
|
|
128
|
+
proof[item_id] = item_proof(base["mutants"], codes, items[item_id], any(c[1] == item_id for c in commits))
|
|
129
|
+
st["status"][item_id] = proof[item_id]["status"]
|
|
130
|
+
if st["status"][item_id] == "unproven":
|
|
131
|
+
failing.append(item_id)
|
|
132
|
+
return failing
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _prove(ctx, base: dict, st: dict) -> dict:
|
|
136
|
+
"""Cold runs until every remaining item is proven; unproven items are reverted between runs."""
|
|
137
|
+
path = ctx.state.path / "strengthen-proof.json"
|
|
138
|
+
proof = json.loads(path.read_text())["items"] if path.exists() else {}
|
|
139
|
+
while True:
|
|
140
|
+
commits = _commits(ctx.repo, st)
|
|
141
|
+
failing = _judge(ctx, base, st, proof, commits)
|
|
142
|
+
for item_id in failing:
|
|
143
|
+
_close(ctx.repo, st, item_id, commits, "reverted",
|
|
144
|
+
"Its mutant ids are not all killed on the head while surviving on the base.")
|
|
145
|
+
proof[item_id]["status"] = "reverted"
|
|
146
|
+
ctx.state.save("strengthen.json", st)
|
|
147
|
+
ctx.state.save("strengthen-proof.json", {"start": st["start"], "head": ctx.repo.head(), "items": proof})
|
|
148
|
+
if not failing:
|
|
149
|
+
return proof
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _proof_lines(proof: dict) -> list:
|
|
153
|
+
lines = []
|
|
154
|
+
for item_id, entry in proof.items():
|
|
155
|
+
lines.append(f"- {item_id}: {entry['status']}")
|
|
156
|
+
lines += [f" {m}: base exit code {e['base']}, head exit code {e['head']}" for m, e in entry["mutants"].items()]
|
|
157
|
+
return lines
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def strengthen_verify(ctx) -> int:
|
|
161
|
+
base = ctx.base()
|
|
162
|
+
st = ctx.state.load("strengthen.json")
|
|
163
|
+
ctx.repo.require_clean()
|
|
164
|
+
proof = _prove(ctx, base, st)
|
|
165
|
+
st["verified"] = True
|
|
166
|
+
ctx.state.save("strengthen.json", st)
|
|
167
|
+
ctx.state.emit("\n".join(["Strengthen proof (survives on the base, killed on the head, per mutant id):"] + _proof_lines(proof)))
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def changed_tests(before: str, after: str, path: str) -> list:
|
|
172
|
+
"""Test ids in the file at path that are new or changed from before to after."""
|
|
173
|
+
old, rel = test_sources(before), path[len(Repo.test_path("")):]
|
|
174
|
+
return [f"{rel}::{name}" for name, text in test_sources(after).items() if old.get(name) != text]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _commit_tests(repo, sha: str) -> list:
|
|
178
|
+
paths = repo.git("diff-tree", "--no-commit-id", "--name-only", "-r", sha).split()
|
|
179
|
+
return [t for path in paths if path.endswith(".py")
|
|
180
|
+
for t in changed_tests(repo.show(f"{sha}^", path) or "", repo.show(sha, path) or "", path)]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _item_tests(repo, commits: list, item_id: str) -> list:
|
|
184
|
+
changed: list = []
|
|
185
|
+
for sha in (sha for sha, item in commits if item == item_id):
|
|
186
|
+
changed += [t for t in _commit_tests(repo, sha) if t not in changed]
|
|
187
|
+
return changed
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _guard_equivalents(repo, base: dict, new: dict) -> None:
|
|
191
|
+
gained = {m: sorted(set(e.get("equivalent", {})) - set(base["baseline"].get(m, {}).get("equivalent", {})))
|
|
192
|
+
for m, e in new.items()}
|
|
193
|
+
gained = {m: ids for m, ids in gained.items() if ids}
|
|
194
|
+
if gained:
|
|
195
|
+
repo.git("checkout", "-q", "HEAD", "--", BASELINE)
|
|
196
|
+
raise Fail("guard: the baseline update gained equivalent ids test-prune never declares: "
|
|
197
|
+
+ "; ".join(f"{m}: {', '.join(ids)}" for m, ids in gained.items()))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _test_lines(items: dict, accepted: list, tests: dict) -> list:
|
|
201
|
+
lines = []
|
|
202
|
+
for item_id in accepted:
|
|
203
|
+
ids = items[item_id]["mutant_ids"]
|
|
204
|
+
kills = f"now kills {', '.join(f'`{m}`' for m in ids)}" if ids else f"adds `{items[item_id]['assertion']}`"
|
|
205
|
+
lines += [f"- `{t}` ({item_id}) {kills}" for t in tests[item_id]] or [f"- {item_id}: {kills}"]
|
|
206
|
+
return lines
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _body(base: dict, st: dict, proof: dict, accepted: list, tests: dict, new: dict, ratchet: str, out: dict) -> str:
|
|
210
|
+
lines = [f"Strengthened tests from `{base['branch']}` at {base['commit'][:12]}, independent of the prune PR. "
|
|
211
|
+
"Every mutant id listed survived the cold mutation run on the base and is killed by the cold run on "
|
|
212
|
+
"this branch.", "", "## Changed tests", ""]
|
|
213
|
+
lines += _test_lines({i["id"]: i for i in st["items"]}, accepted, tests)
|
|
214
|
+
lines += ["", "## Proof per mutant id", ""]
|
|
215
|
+
lines += [f"- `{m}` ({proof[i]['module']}): survived the base run (exit code {e['base']}), killed on this "
|
|
216
|
+
f"branch (exit code {e['head']})" for i in accepted for m, e in proof[i]["mutants"].items()]
|
|
217
|
+
lines += ["", "## Baseline survivors", ""]
|
|
218
|
+
lines += [f"- {m}: {base['baseline'].get(m, {}).get('survivors', 'new')} → {e['survivors']}" for m, e in new.items()]
|
|
219
|
+
lines += ["", "## Mutation ratchet", "", "```", ratchet.strip(), "```"]
|
|
220
|
+
return "\n".join(lines + suggestions_section(out) + equivalents_section(out["equivalents"])) + "\n"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _finish_reviews(ctx, st: dict) -> dict:
|
|
224
|
+
"""Preconditions: verified, clean, HEAD still the proven head, a new branch name, valid reviews."""
|
|
225
|
+
if not st["verified"]:
|
|
226
|
+
raise BadInput("run strengthen-verify first")
|
|
227
|
+
ctx.repo.require_clean()
|
|
228
|
+
proven_head = ctx.state.load("strengthen-proof.json")["head"]
|
|
229
|
+
if ctx.repo.head() != proven_head:
|
|
230
|
+
raise BadInput(f"HEAD moved since strengthen-verify proved {proven_head[:12]}: run strengthen-verify again")
|
|
231
|
+
if ctx.base()["mode"] == "unattended":
|
|
232
|
+
ctx.repo.require_new_branch(_branch(ctx))
|
|
233
|
+
return check_reviews(ctx.reviews(), [i for i, s in st["status"].items() if s == "proven"])
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def strengthen_finish(ctx) -> int:
|
|
237
|
+
repo, base = ctx.repo, ctx.base()
|
|
238
|
+
st = ctx.state.load("strengthen.json")
|
|
239
|
+
unattended = base["mode"] == "unattended"
|
|
240
|
+
leave = unattended
|
|
241
|
+
try:
|
|
242
|
+
code = _finish(ctx, base, st, _finish_reviews(ctx, st), unattended)
|
|
243
|
+
except BadInput:
|
|
244
|
+
leave = False # HEAD stays at the proven head: fix the input and rerun
|
|
245
|
+
raise
|
|
246
|
+
finally:
|
|
247
|
+
if leave:
|
|
248
|
+
repo.leave_on(base["branch"], base["commit"])
|
|
249
|
+
ctx.finished = code == 0
|
|
250
|
+
return code
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _keep_accepted(ctx, base: dict, st: dict, reviews: dict) -> tuple[dict, list]:
|
|
254
|
+
rejected = [i for i, review in reviews.items() if review["verdict"] == "reject"]
|
|
255
|
+
if rejected: # a revert changes the head: prove the rest again, cold
|
|
256
|
+
commits = _commits(ctx.repo, st)
|
|
257
|
+
for item_id in rejected:
|
|
258
|
+
_close(ctx.repo, st, item_id, commits, "rejected", f"Rejected by the reviewer: {reviews[item_id]['reason']}")
|
|
259
|
+
proof = _prove(ctx, base, st)
|
|
260
|
+
else:
|
|
261
|
+
proof = ctx.state.load("strengthen-proof.json")["items"]
|
|
262
|
+
return proof, [i for i, s in st["status"].items() if s == "proven"]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _lower_baseline(repo, base: dict) -> tuple[str, dict]:
|
|
266
|
+
"""The ratchet, test and crap targets, then the baseline update behind the equivalent guard."""
|
|
267
|
+
ratchet = repo.must_make("mutation-check")
|
|
268
|
+
repo.must_make("test")
|
|
269
|
+
repo.must_make("crap")
|
|
270
|
+
repo.must_make("mutation-baseline")
|
|
271
|
+
repo.own(BASELINE) # only once the target passed: after a failure its bytes are not provably the run's
|
|
272
|
+
new = json.loads((repo.root / BASELINE).read_text())
|
|
273
|
+
_guard_equivalents(repo, base, new)
|
|
274
|
+
if repo.git("status", "--porcelain", "--", BASELINE).strip():
|
|
275
|
+
repo.git("add", "--", BASELINE)
|
|
276
|
+
repo.git("commit", "-q", "-m", "test: lower the mutation baseline",
|
|
277
|
+
"-m", "The strengthened tests kill mutants that survived on the base.")
|
|
278
|
+
return "\n".join(ratchet_lines({"output": ratchet})), new
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _finish(ctx, base: dict, st: dict, reviews: dict, unattended: bool) -> int:
|
|
282
|
+
repo = ctx.repo
|
|
283
|
+
proof, accepted = _keep_accepted(ctx, base, st, reviews)
|
|
284
|
+
if not accepted:
|
|
285
|
+
ctx.state.emit("test-prune: nothing proven and accepted to strengthen; no branch, no PR")
|
|
286
|
+
return 0
|
|
287
|
+
ratchet, new = _lower_baseline(repo, base)
|
|
288
|
+
commits = [(sha, items[0]) for sha, items, _ in _history(repo, st["start"]) if items]
|
|
289
|
+
tests = {i: _item_tests(repo, commits, i) for i in accepted}
|
|
290
|
+
body = _body(base, st, proof, accepted, tests, new, ratchet, ctx.state.load("batch.json"))
|
|
291
|
+
if not unattended:
|
|
292
|
+
ctx.state.emit(f"Strengthen commits kept on {repo.branch()}:\n\n{body}")
|
|
293
|
+
return 0
|
|
294
|
+
branch = _branch(ctx)
|
|
295
|
+
repo.git("branch", branch)
|
|
296
|
+
repo.push(branch)
|
|
297
|
+
title = f"test: strengthen {len(accepted)} weak spot{'' if len(accepted) == 1 else 's'}"
|
|
298
|
+
path, body = ctx.state.save_body("strengthen-pr.md", body)
|
|
299
|
+
url = repo.open_pr(base["branch"], branch, title, path)
|
|
300
|
+
ctx.state.emit(f"Opened {url}\n\n{body}")
|
|
301
|
+
return 0
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # the prune modules are one folder up
|
|
7
|
+
|
|
8
|
+
from prune_harness import Target # noqa: E402
|
|
9
|
+
|
|
10
|
+
collect_ignore = ["testdata"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def target(tmp_path):
|
|
15
|
+
return Target(tmp_path)
|