@jesdi/skills 0.0.50 → 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/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 +7 -1
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""collect, review-input and batch: from the candidate report to the accepted batch; edits nothing."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import PurePosixPath
|
|
6
|
+
|
|
7
|
+
from prune_repo import BadInput, Fail, ratchet_lines
|
|
8
|
+
from prune_source import class_refusals, describe, families, undeletable
|
|
9
|
+
|
|
10
|
+
CAP = 10 # deleted test ids per unattended run; a parametrized test counts each case
|
|
11
|
+
|
|
12
|
+
CANDIDATE_VERDICTS = {"keep", "delete", "strengthen"}
|
|
13
|
+
GROUP_VERDICTS = {"strengthen", "equivalent"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Sources:
|
|
17
|
+
"""Test files as of one commit, keyed by their backend-relative path."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, repo, commit: str):
|
|
20
|
+
self.repo, self.commit, self.files = repo, commit, {}
|
|
21
|
+
|
|
22
|
+
def text(self, path: str) -> str | None:
|
|
23
|
+
if path not in self.files:
|
|
24
|
+
self.files[path] = self.repo.show_test(self.commit, path)
|
|
25
|
+
return self.files[path]
|
|
26
|
+
|
|
27
|
+
def describe(self, test_id: str) -> dict:
|
|
28
|
+
text = self.text(test_id.split("::")[0])
|
|
29
|
+
found = describe(text, test_id) if text is not None else None
|
|
30
|
+
return found or {"source": None, "assertions": []}
|
|
31
|
+
|
|
32
|
+
def test(self, test_id: str) -> dict:
|
|
33
|
+
return dict(test_id=test_id, **self.describe(test_id))
|
|
34
|
+
|
|
35
|
+
def class_refusals(self, test_ids: list) -> dict:
|
|
36
|
+
by_file: dict = {}
|
|
37
|
+
for test_id in test_ids:
|
|
38
|
+
by_file.setdefault(test_id.split("::")[0], []).append(test_id)
|
|
39
|
+
refused: dict = {}
|
|
40
|
+
for path, ids in by_file.items():
|
|
41
|
+
refused.update(class_refusals(self.text(path), ids))
|
|
42
|
+
return refused
|
|
43
|
+
|
|
44
|
+
def undeletable(self, test_id: str) -> str | None:
|
|
45
|
+
path = PurePosixPath(test_id.split("::")[0])
|
|
46
|
+
if path.parts[0] != "tests" or path.name == "conftest.py":
|
|
47
|
+
return f"{path} is not a deletable test module"
|
|
48
|
+
text = self.text(str(path))
|
|
49
|
+
return f"{path} not found at the base commit" if text is None else undeletable(text, test_id)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _fixer_input(repo, commit: str, report: dict) -> dict:
|
|
53
|
+
sources = Sources(repo, commit)
|
|
54
|
+
return {
|
|
55
|
+
"candidates": [dict(c, **sources.describe(c["test_id"]),
|
|
56
|
+
containers=[sources.test(t) for t in c.get("contained_by", [])])
|
|
57
|
+
for c in report["candidates"]],
|
|
58
|
+
"weak_spots": [{"group": f"{PurePosixPath(w['module']).stem}.{w['function']}", **w}
|
|
59
|
+
for w in report["weak_spots"]],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def collect(ctx) -> int:
|
|
64
|
+
repo, args = ctx.repo, ctx.args
|
|
65
|
+
repo.require_clean()
|
|
66
|
+
branch = repo.branch()
|
|
67
|
+
protected = args.unattended or args.protected_only
|
|
68
|
+
ctx.state.reset()
|
|
69
|
+
if args.unattended:
|
|
70
|
+
repo.must_make("test")
|
|
71
|
+
ratchet = repo.cold_ratchet()
|
|
72
|
+
if not ratchet["ok"]:
|
|
73
|
+
raise Fail("the base fails the mutation ratchet, fix it before pruning:\n" + "\n".join(ratchet_lines(ratchet)))
|
|
74
|
+
mutants = repo.mutant_codes()
|
|
75
|
+
repo.must_make("test-candidates", *(["ARGS=--protected-only"] if protected else []))
|
|
76
|
+
report = json.loads((repo.backend / ".mutation" / "test-candidates.json").read_text())
|
|
77
|
+
commit = repo.head()
|
|
78
|
+
ctx.state.save("base.json", {
|
|
79
|
+
"mode": "unattended" if args.unattended else "interactive", "protected": protected, "commit": commit,
|
|
80
|
+
"branch": branch, "baseline": json.loads((repo.backend / "mutation_baseline.json").read_text()),
|
|
81
|
+
"mutants": mutants, "tests": repo.collected_tests(), "ratchet": ratchet,
|
|
82
|
+
})
|
|
83
|
+
fixer = _fixer_input(repo, commit, report)
|
|
84
|
+
path = ctx.state.save("fixer-input.json", fixer)
|
|
85
|
+
kind = "protected candidates" if protected else "candidates"
|
|
86
|
+
count = f"{len(fixer['candidates'])} {kind}" if fixer["candidates"] else f"no {kind}"
|
|
87
|
+
ctx.state.emit(f"{count}, {len(fixer['weak_spots'])} weak-spot groups → {path}")
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _index(entries: list, key: str, expected: list, label: str, errors: list) -> dict:
|
|
92
|
+
"""{key value: entry}; records missing, unknown and duplicate entries in errors."""
|
|
93
|
+
found: dict = {}
|
|
94
|
+
for entry in entries:
|
|
95
|
+
value = entry.get(key)
|
|
96
|
+
if value in found:
|
|
97
|
+
errors.append(f"{label} {value}: more than one entry")
|
|
98
|
+
elif value not in expected:
|
|
99
|
+
errors.append(f"{label} {value}: unknown, not in the input")
|
|
100
|
+
found[value] = entry
|
|
101
|
+
errors += [f"{label} {value}: no entry" for value in expected if value not in found]
|
|
102
|
+
return found
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _blank(value) -> bool:
|
|
106
|
+
return not str(value or "").strip()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def candidate_problems(tid: str, entry: dict, tests: set) -> list:
|
|
110
|
+
verdict, into = entry.get("verdict"), entry.get("into")
|
|
111
|
+
checks = [
|
|
112
|
+
(verdict not in CANDIDATE_VERDICTS, f"verdict {verdict!r} is not one of {sorted(CANDIDATE_VERDICTS)}"),
|
|
113
|
+
(verdict == "delete" and (into == tid or into not in tests), f"delete into {into} is not another collected test id"),
|
|
114
|
+
(verdict == "strengthen" and _blank(entry.get("assertion")), "strengthen names no assertion"),
|
|
115
|
+
(verdict != "keep" and _blank(entry.get("reason")), "no reason"),
|
|
116
|
+
]
|
|
117
|
+
return [f"candidate {tid}: {message}" for failed, message in checks if failed]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def group_problems(group: dict, entry: dict) -> list:
|
|
121
|
+
verdict, ids = entry.get("verdict"), entry.get("mutant_ids", group["mutant_ids"])
|
|
122
|
+
listed = isinstance(ids, list) and bool(ids)
|
|
123
|
+
checks = [
|
|
124
|
+
(verdict not in GROUP_VERDICTS, f"verdict {verdict!r} is not one of {sorted(GROUP_VERDICTS)}"),
|
|
125
|
+
(not listed, "mutant_ids must be a non-empty list"),
|
|
126
|
+
(_blank(entry.get("reason")), "no reason"),
|
|
127
|
+
] + [(True, f"{m} is not one of its surviving mutants") for m in (ids if listed else []) if m not in group["mutant_ids"]]
|
|
128
|
+
return [f"weak spot {group['group']}: {message}" for failed, message in checks if failed]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def split_problems(name: str, entries: list) -> list:
|
|
132
|
+
"""A group takes one entry, or one strengthen and one equivalent entry with disjoint, listed ids."""
|
|
133
|
+
if len(entries) == 1:
|
|
134
|
+
return []
|
|
135
|
+
verdicts = sorted(str(e.get("verdict")) for e in entries)
|
|
136
|
+
if verdicts != ["equivalent", "strengthen"]:
|
|
137
|
+
return [f"weak spot {name}: at most one strengthen and one equivalent entry, got {verdicts}"]
|
|
138
|
+
if not all(isinstance(e.get("mutant_ids"), list) for e in entries):
|
|
139
|
+
return [f"weak spot {name}: a split group lists mutant_ids in both entries"]
|
|
140
|
+
shared = set(entries[0]["mutant_ids"]) & set(entries[1]["mutant_ids"])
|
|
141
|
+
return [f"weak spot {name}: {', '.join(sorted(shared))} in both entries"] if shared else []
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _group_entries(entries: list, groups: dict, errors: list) -> dict:
|
|
145
|
+
"""{group: [entries]}; records unknown and missing groups and bad splits in errors."""
|
|
146
|
+
found: dict = {}
|
|
147
|
+
for entry in entries:
|
|
148
|
+
found.setdefault(entry.get("group"), []).append(entry)
|
|
149
|
+
errors += [f"weak spot {name}: unknown, not in the input" for name in found if name not in groups]
|
|
150
|
+
errors += [f"weak spot {name}: no entry" for name in groups if name not in found]
|
|
151
|
+
for name in [n for n in found if n in groups]:
|
|
152
|
+
errors += _entries_problems(groups[name], found[name])
|
|
153
|
+
return found
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _entries_problems(group: dict, entries: list) -> list:
|
|
157
|
+
return split_problems(group["group"], entries) + [p for e in entries for p in group_problems(group, e)]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _validated(fixer: dict, out: dict, tests: set) -> tuple[dict, dict]:
|
|
161
|
+
errors: list = []
|
|
162
|
+
cands = _index(out["candidates"], "test_id", [c["test_id"] for c in fixer["candidates"]], "candidate", errors)
|
|
163
|
+
for tid, entry in cands.items():
|
|
164
|
+
errors += candidate_problems(tid, entry, tests)
|
|
165
|
+
spots = _group_entries(out["weak_spots"], {w["group"]: w for w in fixer["weak_spots"]}, errors)
|
|
166
|
+
if errors:
|
|
167
|
+
raise BadInput("invalid fixer proposals:\n" + "\n".join(errors))
|
|
168
|
+
return cands, spots
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _group_proposals(w: dict, entries: list) -> list:
|
|
172
|
+
"""One proposal per entry; id = the group, or `<group>:<verdict>` when the group is split."""
|
|
173
|
+
return [dict(e, id=w["group"] if len(entries) == 1 else f"{w['group']}:{e['verdict']}", kind=e["verdict"],
|
|
174
|
+
module=w["module"], function=w["function"], mutant_ids=e.get("mutant_ids", w["mutant_ids"]))
|
|
175
|
+
for e in entries]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _candidate_proposal(c: dict, entry: dict, sources: Sources) -> dict:
|
|
179
|
+
proposal = dict(entry, id=c["test_id"], kind=entry["verdict"],
|
|
180
|
+
candidate={k: c[k] for k in ("test_id", "source", "assertions")})
|
|
181
|
+
if entry["verdict"] == "delete":
|
|
182
|
+
proposal["into"] = sources.test(entry["into"])
|
|
183
|
+
return proposal
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def review_input(ctx) -> int:
|
|
187
|
+
base = ctx.base()
|
|
188
|
+
fixer = ctx.state.load("fixer-input.json")
|
|
189
|
+
cands, spots = _validated(fixer, ctx.proposals(), set(base["tests"]))
|
|
190
|
+
sources = Sources(ctx.repo, base["commit"])
|
|
191
|
+
proposals = [_candidate_proposal(c, cands[c["test_id"]], sources)
|
|
192
|
+
for c in fixer["candidates"] if cands[c["test_id"]]["verdict"] != "keep"]
|
|
193
|
+
proposals += [p for w in fixer["weak_spots"] for p in _group_proposals(w, spots[w["group"]])]
|
|
194
|
+
path = ctx.state.save("review-input.json", {"proposals": proposals})
|
|
195
|
+
ctx.state.emit(f"{len(proposals)} proposals for the reviewer → {path}")
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _skip_reason(tid: str, into: str, kept: list, sources: Sources) -> str | None:
|
|
200
|
+
for d in kept:
|
|
201
|
+
if d["test_id"] == into:
|
|
202
|
+
return f"its containing test {into} is deleted earlier in this batch"
|
|
203
|
+
return sources.undeletable(tid)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _whole_parametrize(kept: list, tests: list) -> tuple[list, list]:
|
|
207
|
+
"""A parametrized case goes only with all its siblings: the gate refuses a parametrize-list edit."""
|
|
208
|
+
chosen = {d["test_id"] for d in kept}
|
|
209
|
+
deletions, skipped = [], []
|
|
210
|
+
for d in kept:
|
|
211
|
+
stem = d["test_id"].split("[")[0]
|
|
212
|
+
missing = [t for t in tests if t.split("[")[0] == stem and t not in chosen]
|
|
213
|
+
if "[" in d["test_id"] and missing:
|
|
214
|
+
skipped.append({"test_id": d["test_id"], "reason": "parametrized case: deleting it would edit the "
|
|
215
|
+
f"parametrize list, and its siblings {', '.join(missing)} are not all accepted"})
|
|
216
|
+
else:
|
|
217
|
+
deletions.append(d)
|
|
218
|
+
return deletions, skipped
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _settle(accepted: list, tests: list, sources: Sources) -> tuple[list, list]:
|
|
222
|
+
"""Accepted deletions → (deletions, skipped): a test whose container goes, or the gate would refuse
|
|
223
|
+
(alone or with the rest of the batch), or a parametrized case whose siblings stay, is skipped."""
|
|
224
|
+
kept, skipped = [], []
|
|
225
|
+
for proposal, review in accepted:
|
|
226
|
+
tid, into = proposal["test_id"], proposal["into"]["test_id"]
|
|
227
|
+
reason = _skip_reason(tid, into, kept, sources)
|
|
228
|
+
if reason:
|
|
229
|
+
skipped.append({"test_id": tid, "reason": reason})
|
|
230
|
+
else:
|
|
231
|
+
kept.append({"test_id": tid, "into": into, "reason": review["reason"]})
|
|
232
|
+
deletions, partial = _whole_parametrize(kept, tests)
|
|
233
|
+
refused = sources.class_refusals([d["test_id"] for d in deletions])
|
|
234
|
+
return ([d for d in deletions if d["test_id"] not in refused],
|
|
235
|
+
skipped + partial + [{"test_id": t, "reason": r} for t, r in refused.items()])
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def cap(deletions: list) -> tuple[list, list, list]:
|
|
239
|
+
"""(take, left, over): every whole family that still fits within CAP ids, in batch order; the families
|
|
240
|
+
that do not fit this run, left for next week; and a family of more than CAP cases, which never fits,
|
|
241
|
+
as skipped entries."""
|
|
242
|
+
take, left, over = _fit(families(d["test_id"] for d in deletions))
|
|
243
|
+
over_entries = [{"test_id": t, "reason": f"{len(f)} cases exceed the cap of {CAP}"} for f in over for t in f]
|
|
244
|
+
return _only(deletions, take), _only(deletions, left), over_entries
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _fit(groups: list) -> tuple[set, set, list]:
|
|
248
|
+
"""(ids taken, ids left, families too big ever to fit) for families in batch order."""
|
|
249
|
+
take, left, over, count = set(), set(), [], 0
|
|
250
|
+
for family in groups:
|
|
251
|
+
if len(family) > CAP:
|
|
252
|
+
over.append(family)
|
|
253
|
+
elif count + len(family) > CAP:
|
|
254
|
+
left.update(family)
|
|
255
|
+
else:
|
|
256
|
+
take.update(family)
|
|
257
|
+
count += len(family)
|
|
258
|
+
return take, left, over
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _only(deletions: list, ids: set) -> list:
|
|
262
|
+
return [d for d in deletions if d["test_id"] in ids]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _strengthen_item(proposal: dict, review: dict) -> dict:
|
|
266
|
+
item = {k: v for k, v in proposal.items() if k not in ("into", "verdict")}
|
|
267
|
+
return dict(item, kind="weak_spot" if "group" in proposal else "candidate",
|
|
268
|
+
fixer_reason=proposal.get("reason"), reason=review["reason"])
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def mutant_summary(ids: list, shown: int = 5) -> str:
|
|
272
|
+
"""`(<n> mutants: <first ids> +N more)`: batch.json keeps every id, the printout stays readable."""
|
|
273
|
+
more = f" +{len(ids) - shown} more" if len(ids) > shown else ""
|
|
274
|
+
return f"({len(ids)} mutant{'' if len(ids) == 1 else 's'}: {', '.join(ids[:shown])}{more})"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def suggestions_section(out: dict) -> list:
|
|
278
|
+
"""Accepted "strengthen with this assertion" verdicts on candidates: listed for a human, never applied."""
|
|
279
|
+
items = [i for i in out["strengthen"] if i["kind"] == "candidate"]
|
|
280
|
+
if not items:
|
|
281
|
+
return []
|
|
282
|
+
return ["", "## Strengthen suggestions", "",
|
|
283
|
+
"The reviewer accepted these \"strengthen with this assertion\" verdicts. test-prune does not apply "
|
|
284
|
+
"them; a human adds each assertion.", ""] + [
|
|
285
|
+
f"- `{i['id']}`: add `{i['assertion']}`. {i['reason']}" for i in items]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def equivalents_section(equivalents: list, shown: int | None = None) -> list:
|
|
289
|
+
"""Accepted equivalent proposals, for a human to check and declare; shown: ids listed per group
|
|
290
|
+
before the rest is counted (None: every id, as the PR bodies need)."""
|
|
291
|
+
if not equivalents:
|
|
292
|
+
return []
|
|
293
|
+
return ["", "## Equivalent proposals", "",
|
|
294
|
+
"The reviewer accepted these as equivalent mutants. test-prune never declares an equivalent; "
|
|
295
|
+
"a human adds them to `backend/mutation_baseline.json` after checking each one.", ""] + [
|
|
296
|
+
f"- `{e['group']}`: {_ids(e['mutant_ids'], shown)}. {e['reason']}" for e in equivalents]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _ids(ids: list, shown: int | None) -> str:
|
|
300
|
+
listed = ", ".join(f"`{m}`" for m in ids[:shown])
|
|
301
|
+
return listed + (f" +{len(ids) - shown} more" if shown is not None and len(ids) > shown else "")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _left_lines(left: list) -> list:
|
|
305
|
+
if not left:
|
|
306
|
+
return []
|
|
307
|
+
return [f"Left for next week ({len(left)}, over the cap of {CAP} per run):"] + [
|
|
308
|
+
f" - {d['test_id']} → contained by {d['into']}" for d in left]
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _render_batch(out: dict, mode: str) -> str:
|
|
312
|
+
lines = [f"Accepted batch ({mode})" + (": nothing is edited until a human approves and runs `apply`"
|
|
313
|
+
if mode == "interactive" else "")]
|
|
314
|
+
lines.append(f"Deletions ({len(out['deletions'])}):")
|
|
315
|
+
lines += [f" - {d['test_id']} → contained by {d['into']}: {d['reason']}" for d in out["deletions"]]
|
|
316
|
+
spots = [i for i in out["strengthen"] if i["kind"] == "weak_spot"]
|
|
317
|
+
lines.append(f"Strengthen, weak spots for the strengthen agent ({len(spots)}):")
|
|
318
|
+
lines += [f" - {s['id']} {mutant_summary(s['mutant_ids'])}: {s['reason']}" for s in spots]
|
|
319
|
+
lines += _left_lines(out["left"])
|
|
320
|
+
lines.append(f"Skipped ({len(out['skipped'])}):")
|
|
321
|
+
lines += [f" - {s['test_id']}: {s['reason']}" for s in out["skipped"]]
|
|
322
|
+
lines.append(f"Rejected ({len(out['rejected'])}):")
|
|
323
|
+
lines += [f" - {r['id']} [{r['kind']}]: {r['reason']}" for r in out["rejected"]]
|
|
324
|
+
return "\n".join(lines + suggestions_section(out) + equivalents_section(out["equivalents"], shown=5))
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def check_reviews(reviews: list, ids: list) -> dict:
|
|
328
|
+
errors: list = []
|
|
329
|
+
found = _index(reviews, "id", ids, "proposal", errors)
|
|
330
|
+
for pid, review in found.items():
|
|
331
|
+
if review.get("verdict") not in ("accept", "reject"):
|
|
332
|
+
errors.append(f"proposal {pid}: verdict {review.get('verdict')!r} is not accept or reject")
|
|
333
|
+
if not str(review.get("reason") or "").strip():
|
|
334
|
+
errors.append(f"proposal {pid}: no reason")
|
|
335
|
+
if errors:
|
|
336
|
+
raise BadInput("invalid reviews:\n" + "\n".join(errors))
|
|
337
|
+
return found
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def batch(ctx) -> int:
|
|
341
|
+
base = ctx.base()
|
|
342
|
+
proposals = ctx.state.load("review-input.json")["proposals"]
|
|
343
|
+
reviews = check_reviews(ctx.reviews(), [p["id"] for p in proposals])
|
|
344
|
+
out: dict = {"deletions": [], "left": [], "strengthen": [], "equivalents": [], "rejected": [], "skipped": []}
|
|
345
|
+
accepted = []
|
|
346
|
+
for p in proposals:
|
|
347
|
+
review = reviews[p["id"]]
|
|
348
|
+
if review["verdict"] == "reject":
|
|
349
|
+
out["rejected"].append({"id": p["id"], "kind": p["kind"], "reason": review["reason"]})
|
|
350
|
+
elif p["kind"] == "delete":
|
|
351
|
+
accepted.append((p, review))
|
|
352
|
+
elif p["kind"] == "equivalent":
|
|
353
|
+
out["equivalents"].append({"group": p["group"], "mutant_ids": p["mutant_ids"], "reason": p["reason"],
|
|
354
|
+
"review": review["reason"]})
|
|
355
|
+
else:
|
|
356
|
+
out["strengthen"].append(_strengthen_item(p, review))
|
|
357
|
+
out["deletions"], out["skipped"] = _settle(accepted, base["tests"], Sources(ctx.repo, base["commit"]))
|
|
358
|
+
if base["mode"] == "unattended":
|
|
359
|
+
out["deletions"], out["left"], over = cap(out["deletions"])
|
|
360
|
+
out["skipped"] += over
|
|
361
|
+
path = ctx.state.save("batch.json", out)
|
|
362
|
+
ctx.state.emit(f"{_render_batch(out, base['mode'])}\n→ {path}")
|
|
363
|
+
return 0
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""apply (interactive) and prune (unattended): apply the accepted deletions, run the safety check,
|
|
2
|
+
restore every culprit, then commit; unattended also branches, gates, pushes and opens the PR."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from prune_repo import Fail, capped, fenced, ratchet_lines
|
|
8
|
+
from prune_review import CAP, equivalents_section, suggestions_section
|
|
9
|
+
from prune_source import delete_tests, families, targets
|
|
10
|
+
|
|
11
|
+
_CHECK_LINE = re.compile(r"^( [✓✗•!] | \S| ↑)")
|
|
12
|
+
_VERDICT = re.compile(r"^Prune gate (PASSED|FAILED|ERROR)")
|
|
13
|
+
_VERDICT_LINE = re.compile(r"^ [✓✗] ")
|
|
14
|
+
GATE_LIMIT = 20_000 # the gate section's share of a PR body: a cut stays inside it
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _check_block(lines: list) -> list:
|
|
18
|
+
"""The last `make mutation-check` block in lines: back from `Mutation ratchet PASSED/FAILED.` over its
|
|
19
|
+
module lines to `Mutation results from…` when printed."""
|
|
20
|
+
ends = [i for i, line in enumerate(lines) if re.match(r"^Mutation ratchet (PASSED|FAILED)", line)]
|
|
21
|
+
if not ends:
|
|
22
|
+
return []
|
|
23
|
+
start = ends[-1]
|
|
24
|
+
while start and _CHECK_LINE.match(lines[start - 1]):
|
|
25
|
+
start -= 1
|
|
26
|
+
if start and lines[start - 1].startswith("Mutation results from"):
|
|
27
|
+
start -= 1
|
|
28
|
+
return lines[start:ends[-1] + 1]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def gate_report(stdout: str, stderr: str = "") -> str:
|
|
32
|
+
"""The prune gate's own report out of its stdout (the real gate also prints its cold mutmut run, and
|
|
33
|
+
mutmut's children warn on stderr): its verdict line with the indented ✓/✗ lines right after it, then
|
|
34
|
+
the `make mutation-check` block it printed (verdict first: a cut for size keeps it). No verdict on
|
|
35
|
+
stdout: the last 30 lines of both streams."""
|
|
36
|
+
lines = stdout.splitlines()
|
|
37
|
+
verdicts = [i for i, line in enumerate(lines) if _VERDICT.match(line)]
|
|
38
|
+
if not verdicts:
|
|
39
|
+
return "\n".join((stdout + stderr).splitlines()[-30:])
|
|
40
|
+
end = verdicts[-1] + 1
|
|
41
|
+
while end < len(lines) and _VERDICT_LINE.match(lines[end]):
|
|
42
|
+
end += 1
|
|
43
|
+
return "\n".join(lines[verdicts[-1]:end] + _check_block(lines[:verdicts[-1]]))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DeletionSet:
|
|
47
|
+
"""Writes a deletion set over the base commit's content of every file the whole batch touches."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, repo, commit: str, ids):
|
|
50
|
+
self.repo, self.commit, self.files = repo, commit, sorted(targets(ids))
|
|
51
|
+
|
|
52
|
+
def write(self, ids) -> None:
|
|
53
|
+
chosen, texts = targets(ids), {}
|
|
54
|
+
for path in self.files:
|
|
55
|
+
source = self.repo.show_test(self.commit, path)
|
|
56
|
+
texts[self.repo.test_path(path)] = delete_tests(source, chosen[path]) if path in chosen else source
|
|
57
|
+
self.repo.write(texts, self.commit)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _culprit(family: list, failing: dict, passing: dict) -> list:
|
|
61
|
+
name = family[0] if len(family) == 1 else family[0].split("[")[0]
|
|
62
|
+
return [{"test_id": name, "test_ids": family, "module": m, "survivors_with": failing["modules"][m][0],
|
|
63
|
+
"baseline": failing["modules"][m][1], "survivors_after": passing["modules"].get(m, [None])[0]}
|
|
64
|
+
for m in failing["failed"]]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _flat(units) -> list:
|
|
68
|
+
return [test_id for unit in units for test_id in unit]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def safety_check(repo, base: dict, ids: list) -> tuple[list, list, dict]:
|
|
72
|
+
"""Restore every deletion whose absence lets a module exceed its baseline; returns (kept ids, restored,
|
|
73
|
+
ratchet of the kept set) with the kept set written to the tree. Survivors only grow as tests go, so
|
|
74
|
+
the smallest failing prefix of families ends with a culprit; repeat until the whole kept set passes."""
|
|
75
|
+
deletion_set = DeletionSet(repo, base["commit"], ids)
|
|
76
|
+
results = {(): base["ratchet"]}
|
|
77
|
+
|
|
78
|
+
def check(units: list) -> dict:
|
|
79
|
+
key = tuple(_flat(units))
|
|
80
|
+
if key not in results:
|
|
81
|
+
deletion_set.write(key)
|
|
82
|
+
results[key] = repo.cold_ratchet()
|
|
83
|
+
return results[key]
|
|
84
|
+
|
|
85
|
+
kept, restored, lo = families(ids), [], 0
|
|
86
|
+
while not check(kept)["ok"]:
|
|
87
|
+
hi = len(kept)
|
|
88
|
+
while hi - lo > 1:
|
|
89
|
+
mid = (lo + hi) // 2
|
|
90
|
+
if check(kept[:mid])["ok"]:
|
|
91
|
+
lo = mid
|
|
92
|
+
else:
|
|
93
|
+
hi = mid
|
|
94
|
+
restored += _culprit(kept[hi - 1], check(kept[:hi]), check(kept[:hi - 1]))
|
|
95
|
+
del kept[hi - 1]
|
|
96
|
+
lo = hi - 1
|
|
97
|
+
deletion_set.write(_flat(kept))
|
|
98
|
+
return _flat(kept), restored, check(kept)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _commit(repo, kept: list, by_id: dict) -> str:
|
|
102
|
+
subject = f"test: delete {len(kept)} redundant test{'' if len(kept) == 1 else 's'}"
|
|
103
|
+
body = "Each one is contained by another test and the mutation ratchet passes without it:\n" + "\n".join(
|
|
104
|
+
f"- {t} (contained by {by_id[t]['into']})" for t in kept)
|
|
105
|
+
repo.git("add", "--", *(repo.test_path(path) for path in sorted(targets(kept))))
|
|
106
|
+
repo.git("commit", "-q", "-m", subject, "-m", body)
|
|
107
|
+
return subject
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def restored_lines(restored: list) -> list:
|
|
111
|
+
return [f"- `{r['test_id']}`{_cases(r)}: {r['module']} had {r['survivors_with']} survivors with it deleted, baseline "
|
|
112
|
+
f"{r['baseline']}; {r['survivors_after']} after restoring it" for r in restored]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _cases(restored: dict) -> str:
|
|
116
|
+
return f" ({len(restored['test_ids'])} parametrized cases)" if len(restored["test_ids"]) > 1 else ""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _deletions_report(heading: str, kept: list, by_id: dict, restored: list, left: list, ratchet: dict) -> list:
|
|
120
|
+
lines = [f"## {heading} ({len(kept)})", ""]
|
|
121
|
+
lines += [f"- `{t}`, contained by `{by_id[t]['into']}`: {by_id[t]['reason']}" for t in kept]
|
|
122
|
+
if restored:
|
|
123
|
+
lines += ["", f"## Restored by the safety check ({len({t for r in restored for t in r['test_ids']})})", ""]
|
|
124
|
+
lines += restored_lines(restored)
|
|
125
|
+
if left:
|
|
126
|
+
lines += ["", f"## Left for next week ({len(left)})", "",
|
|
127
|
+
f"Accepted, over the cap of {CAP} deletions per run (a parametrized test counts each case):", ""]
|
|
128
|
+
lines += [f"- `{d['test_id']}`, contained by `{d['into']}`" for d in left]
|
|
129
|
+
lines += ["", "## Mutation ratchet (cold run on this branch)", ""]
|
|
130
|
+
lines += [f"- {line}" for line in ratchet_lines(ratchet)]
|
|
131
|
+
return lines
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _apply_and_commit(ctx, base: dict, deletions: list) -> tuple[list, list, dict]:
|
|
135
|
+
"""Safety check, make test and the commit; any failure puts every touched test file back."""
|
|
136
|
+
repo = ctx.repo
|
|
137
|
+
by_id = {d["test_id"]: d for d in deletions}
|
|
138
|
+
ids = [d["test_id"] for d in deletions]
|
|
139
|
+
try:
|
|
140
|
+
kept, restored, ratchet = safety_check(repo, base, ids)
|
|
141
|
+
if kept:
|
|
142
|
+
repo.must_make("test")
|
|
143
|
+
_commit(repo, kept, by_id)
|
|
144
|
+
except BaseException:
|
|
145
|
+
try:
|
|
146
|
+
DeletionSet(repo, base["commit"], ids).write([])
|
|
147
|
+
except Fail as kept_edit: # a file someone else changed stays as it is
|
|
148
|
+
ctx.state.emit(f"test-prune: {kept_edit}")
|
|
149
|
+
raise
|
|
150
|
+
return kept, restored, ratchet
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def apply(ctx) -> int:
|
|
154
|
+
repo, base = ctx.repo, ctx.base("interactive")
|
|
155
|
+
repo.require_at(base, "apply")
|
|
156
|
+
deletions = ctx.state.load("batch.json")["deletions"]
|
|
157
|
+
if not deletions:
|
|
158
|
+
ctx.state.emit("no accepted deletions to apply")
|
|
159
|
+
return 0
|
|
160
|
+
kept, restored, ratchet = _apply_and_commit(ctx, base, deletions)
|
|
161
|
+
head = (f"Committed on {base['branch']}: {repo.git('log', '-1', '--format=%s').strip()}" if kept
|
|
162
|
+
else "Every deletion was restored: nothing committed")
|
|
163
|
+
by_id = {d["test_id"]: d for d in deletions}
|
|
164
|
+
ctx.state.emit("\n".join([head, ""] + _deletions_report("Deletions committed", kept, by_id, restored, [], ratchet)))
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _nothing_to_prune(ctx, base: dict, out: dict) -> str:
|
|
169
|
+
if not ctx.state.load("fixer-input.json")["candidates"]:
|
|
170
|
+
reason = "no protected candidates" if base["protected"] else "no candidates"
|
|
171
|
+
else:
|
|
172
|
+
reason = f"no accepted deletions ({len(out['rejected'])} rejected, {len(out['skipped'])} skipped)"
|
|
173
|
+
return "\n".join([f"test-prune: {reason}; no branch, no PR"] + suggestions_section(out)
|
|
174
|
+
+ equivalents_section(out["equivalents"]))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def prune(ctx) -> int:
|
|
178
|
+
repo, base = ctx.repo, ctx.base("unattended")
|
|
179
|
+
out = ctx.state.load("batch.json")
|
|
180
|
+
if not out["deletions"]:
|
|
181
|
+
ctx.state.emit(_nothing_to_prune(ctx, base, out))
|
|
182
|
+
return 0
|
|
183
|
+
branch = f"chore/test-prune-{ctx.date}"
|
|
184
|
+
repo.detach_at(base, branch, "prune")
|
|
185
|
+
try:
|
|
186
|
+
return _prune_detached(ctx, base, out, branch)
|
|
187
|
+
finally:
|
|
188
|
+
repo.leave_on(base["branch"], base["commit"])
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _prune_detached(ctx, base: dict, out: dict, branch: str) -> int:
|
|
192
|
+
repo = ctx.repo
|
|
193
|
+
by_id = {d["test_id"]: d for d in out["deletions"]}
|
|
194
|
+
left = out["left"]
|
|
195
|
+
kept, restored, ratchet = safety_check(repo, base, [d["test_id"] for d in out["deletions"]])
|
|
196
|
+
if not kept:
|
|
197
|
+
ctx.state.emit("\n".join(["test-prune: the safety check restored every accepted deletion; no branch, no PR", ""]
|
|
198
|
+
+ restored_lines(restored)))
|
|
199
|
+
return 0
|
|
200
|
+
def report(heading: str) -> list:
|
|
201
|
+
return _deletions_report(heading, kept, by_id, restored, left, ratchet)
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
return _publish(ctx, base, out, branch, kept, report)
|
|
205
|
+
except Fail as exc: # the job summary keeps what the safety check found
|
|
206
|
+
raise Fail("\n".join([f"{exc}", ""] + report(_where(repo, branch)))) from None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _where(repo, branch: str) -> str:
|
|
210
|
+
"""Where the deletions are after a failure past the safety check."""
|
|
211
|
+
if repo.run_git("rev-parse", "--verify", "-q", f"refs/heads/{branch}").returncode:
|
|
212
|
+
return "Deletions that passed the safety check, not committed"
|
|
213
|
+
if repo.run_git("ls-remote", "--heads", "origin", branch).stdout.strip():
|
|
214
|
+
return f"Deletions on `{branch}`, pushed without a PR"
|
|
215
|
+
return f"Deletions on the unpushed branch `{branch}`"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _publish(ctx, base: dict, out: dict, branch: str, kept: list, report) -> int:
|
|
219
|
+
"""make test, the commit, the branch, make prune-gate, then the push and the PR."""
|
|
220
|
+
repo = ctx.repo
|
|
221
|
+
repo.must_make("test")
|
|
222
|
+
subject = _commit(repo, kept, {d["test_id"]: d for d in out["deletions"]})
|
|
223
|
+
repo.git("branch", branch)
|
|
224
|
+
gate = repo.make("prune-gate", f"BASE={base['commit']}", f"HEAD={branch}")
|
|
225
|
+
log = ctx.state.save_text("prune-gate.log", gate.stdout + gate.stderr)
|
|
226
|
+
report_text = capped(gate_report(gate.stdout, gate.stderr), ctx.state.path / "prune-gate-report.md", GATE_LIMIT)
|
|
227
|
+
gate_output = f"{fenced(report_text)}\n\nThe gate's report; its full log is in `{log}`."
|
|
228
|
+
if gate.returncode:
|
|
229
|
+
ctx.state.emit("\n".join([f"test-prune: make prune-gate failed on {branch}; nothing pushed, no PR", "", gate_output, ""]
|
|
230
|
+
+ report(f"Deletions on the unpushed branch `{branch}`")))
|
|
231
|
+
return 1
|
|
232
|
+
body = "\n".join([f"Weekly test prune from `{base['branch']}` at {base['commit'][:12]}. "
|
|
233
|
+
"Delete-only; merge it to accept the batch.", ""] + report("Deleted tests")
|
|
234
|
+
+ ["", "## Prune gate", "", gate_output]
|
|
235
|
+
+ suggestions_section(out) + equivalents_section(out["equivalents"])) + "\n"
|
|
236
|
+
repo.push(branch)
|
|
237
|
+
path, body = ctx.state.save_body("prune-pr.md", body)
|
|
238
|
+
url = repo.open_pr(base["branch"], branch, subject, path)
|
|
239
|
+
ctx.state.emit(f"Opened {url}\n\n{body}")
|
|
240
|
+
return 0
|