@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.
Files changed (31) hide show
  1. package/package.json +2 -2
  2. package/skills/implement-spec/driver_agents.py +20 -2
  3. package/skills/implement-spec/driver_run.py +2 -0
  4. package/skills/implement-spec/tests/test_agents.py +23 -0
  5. package/skills/implement-spec/tests/test_driver.py +1 -1
  6. package/skills/test-prune/SKILL.md +156 -0
  7. package/skills/test-prune/prune.py +202 -0
  8. package/skills/test-prune/prune_repo.py +319 -0
  9. package/skills/test-prune/prune_review.py +363 -0
  10. package/skills/test-prune/prune_runs.py +240 -0
  11. package/skills/test-prune/prune_source.py +237 -0
  12. package/skills/test-prune/prune_strengthen.py +301 -0
  13. package/skills/test-prune/tests/conftest.py +15 -0
  14. package/skills/test-prune/tests/prune_harness.py +205 -0
  15. package/skills/test-prune/tests/test_prune_guards.py +871 -0
  16. package/skills/test-prune/tests/test_prune_inputs.py +97 -0
  17. package/skills/test-prune/tests/test_prune_review.py +113 -0
  18. package/skills/test-prune/tests/test_prune_source.py +84 -0
  19. package/skills/test-prune/tests/test_prune_strengthen.py +183 -0
  20. package/skills/test-prune/tests/test_prune_unattended.py +225 -0
  21. package/skills/test-prune/tests/testdata/gh +16 -0
  22. package/skills/test-prune/tests/testdata/repo/Makefile +25 -0
  23. package/skills/test-prune/tests/testdata/repo/backend/mutation_baseline.json +16 -0
  24. package/skills/test-prune/tests/testdata/repo/backend/services/concentration.py +2 -0
  25. package/skills/test-prune/tests/testdata/repo/backend/services/rebalancer.py +11 -0
  26. package/skills/test-prune/tests/testdata/repo/backend/tests/conftest.py +6 -0
  27. package/skills/test-prune/tests/testdata/repo/backend/tests/test_concentration.py +73 -0
  28. package/skills/test-prune/tests/testdata/repo/backend/tests/test_rebalancer.py +77 -0
  29. package/skills/test-prune/tests/testdata/repo/fake_engine.py +289 -0
  30. package/skills/test-prune/tests/testdata/repo/kill_map.json +131 -0
  31. 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)