@jesdi/skills 0.0.50 → 0.0.52

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 (34) hide show
  1. package/package.json +2 -2
  2. package/skills/implement-spec/ARCHITECTURE.md +3 -0
  3. package/skills/implement-spec/README.md +2 -2
  4. package/skills/implement-spec/driver_fixloop.py +27 -6
  5. package/skills/implement-spec/driver_report.py +3 -2
  6. package/skills/implement-spec/prompts/correction.md +1 -0
  7. package/skills/implement-spec/prompts/test-writer.md +3 -2
  8. package/skills/implement-spec/tests/test_fixloop.py +50 -0
  9. package/skills/test-prune/SKILL.md +156 -0
  10. package/skills/test-prune/prune.py +202 -0
  11. package/skills/test-prune/prune_repo.py +319 -0
  12. package/skills/test-prune/prune_review.py +363 -0
  13. package/skills/test-prune/prune_runs.py +240 -0
  14. package/skills/test-prune/prune_source.py +237 -0
  15. package/skills/test-prune/prune_strengthen.py +301 -0
  16. package/skills/test-prune/tests/conftest.py +15 -0
  17. package/skills/test-prune/tests/prune_harness.py +205 -0
  18. package/skills/test-prune/tests/test_prune_guards.py +871 -0
  19. package/skills/test-prune/tests/test_prune_inputs.py +97 -0
  20. package/skills/test-prune/tests/test_prune_review.py +113 -0
  21. package/skills/test-prune/tests/test_prune_source.py +84 -0
  22. package/skills/test-prune/tests/test_prune_strengthen.py +183 -0
  23. package/skills/test-prune/tests/test_prune_unattended.py +225 -0
  24. package/skills/test-prune/tests/testdata/gh +16 -0
  25. package/skills/test-prune/tests/testdata/repo/Makefile +25 -0
  26. package/skills/test-prune/tests/testdata/repo/backend/mutation_baseline.json +16 -0
  27. package/skills/test-prune/tests/testdata/repo/backend/services/concentration.py +2 -0
  28. package/skills/test-prune/tests/testdata/repo/backend/services/rebalancer.py +11 -0
  29. package/skills/test-prune/tests/testdata/repo/backend/tests/conftest.py +6 -0
  30. package/skills/test-prune/tests/testdata/repo/backend/tests/test_concentration.py +73 -0
  31. package/skills/test-prune/tests/testdata/repo/backend/tests/test_rebalancer.py +77 -0
  32. package/skills/test-prune/tests/testdata/repo/fake_engine.py +289 -0
  33. package/skills/test-prune/tests/testdata/repo/kill_map.json +131 -0
  34. package/skills-manifest.json +9 -3
@@ -0,0 +1,871 @@
1
+ """Bisect order, parametrize families, branch and ref guards, failure paths and the run status."""
2
+ import json
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+
7
+ import pytest
8
+
9
+ from prune_harness import PRUNE, PRUNE_BRANCH, arg, REFERENCE, STRENGTHEN_BRANCH, R, cand, case, lines_with
10
+ from prune_repo import Fail, Repo
11
+ from prune_review import cap
12
+ from test_prune_strengthen import (COMPUTE, COMPUTE_TEST, DRIFT, DRIFT_TEST, EQUIVALENT_ID, HERFINDAHL, IDS_7_9,
13
+ strengthen, strengthen_group, strengthen_pr)
14
+
15
+ CULPRIT = R + "test_sell_only_rebalance" # sole killer of x_rebalance__mutmut_20..22
16
+ HOLD = R + "test_hold_when_on_target"
17
+ DRIFT_FAMILY = [R + f"test_apply_drift_bounds[{p}]" for p in ("low", "mid", "high")]
18
+
19
+
20
+ def assert_on_base(target):
21
+ assert target.git("symbolic-ref", "--short", "HEAD").strip() == "main"
22
+ assert target.git("rev-parse", "main").strip() == target.base == target.remote_sha("main")
23
+ assert target.git("status", "--porcelain") == ""
24
+
25
+
26
+ # --- bisect -------------------------------------------------------------------------
27
+
28
+ def test_bisect_restores_a_culprit_in_the_middle_of_the_batch(target):
29
+ deletions = {case(1): REFERENCE, CULPRIT: HOLD, R + "test_zero_weight": R + "test_small_weight"}
30
+ target.prepare([cand(t, into) for t, into in deletions.items()], accept=list(deletions), deletions=deletions)
31
+ assert [d["test_id"] for d in target.read_state("batch.json")["deletions"]][1] == CULPRIT
32
+
33
+ out = target.ok("prune").stdout
34
+
35
+ head_ids = target.ids_at(target.remote_sha(PRUNE_BRANCH))
36
+ assert CULPRIT in head_ids
37
+ assert case(1) not in head_ids and R + "test_zero_weight" not in head_ids
38
+ assert "23" in lines_with(out, CULPRIT)
39
+
40
+
41
+ def test_bisect_restores_two_culprits_including_an_interaction(target):
42
+ # case_01 and the reference are the only killers of the herfindahl mutants: either may go, not both.
43
+ deletions = {case(1): case(2), REFERENCE: case(3), CULPRIT: HOLD, case(4): case(5)}
44
+ target.prepare([cand(t, into) for t, into in deletions.items()], accept=list(deletions), deletions=deletions)
45
+
46
+ out = target.ok("prune").stdout
47
+
48
+ head_ids = target.ids_at(target.remote_sha(PRUNE_BRANCH))
49
+ assert REFERENCE in head_ids and CULPRIT in head_ids
50
+ assert case(1) not in head_ids and case(4) not in head_ids
51
+ assert "services/concentration.py had 10 survivors" in lines_with(out, REFERENCE)
52
+
53
+
54
+ # --- parametrize families -------------------------------------------------------------
55
+
56
+ def test_the_cap_takes_whole_parametrize_families(target):
57
+ singles = [case(i) for i in range(2, 11)]
58
+ deletions = {**{t: REFERENCE for t in singles}, **{t: R + "TestDriftHelpers::test_drift_scales" for t in DRIFT_FAMILY}}
59
+ target.prepare([cand(t, into) for t, into in deletions.items()], accept=list(deletions), deletions=deletions)
60
+
61
+ target.ok("prune")
62
+
63
+ head = target.remote_sha(PRUNE_BRANCH)
64
+ assert target.ids_at(target.base) - target.ids_at(head) == set(singles)
65
+ body = target.pr_creates()[0]["body"]
66
+ for test_id in DRIFT_FAMILY:
67
+ assert test_id in body.split("## Left for next week")[1]
68
+
69
+
70
+ def test_bisect_restores_a_parametrize_family_whole(target):
71
+ scales = R + "TestDriftHelpers::test_drift_scales" # with the family, the only killers of apply_drift 1 and 3
72
+ deletions = {scales: HOLD, **{t: HOLD for t in DRIFT_FAMILY}}
73
+ target.prepare([cand(t, into) for t, into in deletions.items()], accept=list(deletions), deletions=deletions)
74
+
75
+ out = target.ok("prune").stdout
76
+
77
+ head_ids = target.ids_at(target.remote_sha(PRUNE_BRANCH))
78
+ assert set(DRIFT_FAMILY) <= head_ids and scales not in head_ids
79
+ assert "test_apply_drift_bounds` (3 parametrized cases): services/rebalancer.py had 22" in out
80
+
81
+
82
+ # --- ref, branch and date guards -------------------------------------------------------
83
+
84
+ def test_push_refuses_any_ref_but_a_chore_branch(target):
85
+ with pytest.raises(Fail, match="refusing to push main"):
86
+ Repo(target.root).push("main")
87
+ assert target.remote_sha("main") == target.base
88
+
89
+
90
+ def test_a_malformed_date_exits_2(target):
91
+ proc = subprocess.run([sys.executable, str(PRUNE), "collect", "--state", str(target.state), "--date", "2026-1-1"],
92
+ cwd=target.root, env=target.env(), capture_output=True, text=True)
93
+ assert proc.returncode == 2 and "2026-1-1" in proc.stderr
94
+ assert target.log("mutation") == []
95
+
96
+
97
+ def test_leaving_the_run_trips_when_the_base_branch_moved(target):
98
+ target.git("checkout", "-q", "--detach")
99
+ target.git("commit", "-q", "--allow-empty", "-m", "elsewhere")
100
+ target.git("update-ref", "refs/heads/main", target.git("rev-parse", "HEAD").strip())
101
+
102
+ with pytest.raises(Fail, match="main moved"):
103
+ Repo(target.root).leave_on("main", target.base)
104
+
105
+
106
+ def test_prune_exits_2_when_the_branch_already_exists_on_origin(target):
107
+ target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
108
+ target.git("push", "-q", "origin", f"main:refs/heads/{PRUNE_BRANCH}")
109
+ runs = len(target.log("mutation"))
110
+
111
+ proc = target.run("prune")
112
+
113
+ assert proc.returncode == 2 and PRUNE_BRANCH in proc.stderr
114
+ assert len(target.log("mutation")) == runs and target.log("gh") == []
115
+ assert_on_base(target)
116
+
117
+
118
+ def test_prune_exits_2_off_the_collected_branch(target):
119
+ target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
120
+ target.git("checkout", "-q", "-b", "elsewhere")
121
+
122
+ proc = target.run("prune")
123
+
124
+ assert proc.returncode == 2 and "prune runs on main" in proc.stderr
125
+ assert target.remote_branches() == {"main"}
126
+
127
+
128
+ # --- failure paths --------------------------------------------------------------------
129
+
130
+ def test_unattended_collect_stops_when_make_test_fails_on_the_base(target):
131
+ target.set_candidates([cand(case(1))])
132
+
133
+ proc = target.run("collect", "--unattended", env={"FAKE_TEST_FAIL": "1"})
134
+
135
+ assert proc.returncode == 1 and "make test failed" in proc.stdout
136
+ assert target.log("mutation") == []
137
+
138
+
139
+ def test_collect_stops_when_the_base_fails_the_ratchet(target):
140
+ path = target.root / "backend" / "mutation_baseline.json"
141
+ baseline = json.loads(path.read_text())
142
+ baseline["services/rebalancer.py"]["survivors"] = 19
143
+ path.write_text(json.dumps(baseline, indent=2) + "\n")
144
+ target.git("commit", "-q", "-am", "tighten the baseline")
145
+ target.set_candidates([cand(case(1))])
146
+
147
+ proc = target.run("collect")
148
+
149
+ assert proc.returncode == 1 and "services/rebalancer.py: 20 survivors > baseline 19" in proc.stdout
150
+ assert not (target.state / "fixer-input.json").exists()
151
+
152
+
153
+ def test_prune_stops_before_committing_when_make_test_fails(target):
154
+ target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
155
+
156
+ proc = target.run("prune", env={"FAKE_TEST_FAIL": "1"})
157
+
158
+ assert proc.returncode == 1 and "make test failed" in proc.stdout
159
+ assert target.git("branch", "--list", "chore/*").strip() == ""
160
+ assert target.remote_branches() == {"main"}
161
+ assert_on_base(target)
162
+
163
+
164
+ def test_apply_puts_the_test_files_back_when_make_test_fails(target):
165
+ target.prepare([cand(case(1))], unattended=False, accept=[case(1)], deletions={case(1): REFERENCE})
166
+
167
+ proc = target.run("apply", env={"FAKE_TEST_FAIL": "1"})
168
+
169
+ assert proc.returncode == 1
170
+ assert_on_base(target)
171
+
172
+
173
+ # --- strengthen -----------------------------------------------------------------------
174
+
175
+ def test_candidate_strengthen_verdicts_are_listed_for_a_human_not_applied(target):
176
+ assertion = "assert herfindahl([1.0]) == 1.0"
177
+ target.prepare([cand(case(1)), cand(case(2))], accept=[case(1), case(2)], deletions={case(2): REFERENCE},
178
+ strengthen={case(1): assertion})
179
+
180
+ target.ok("prune")
181
+ out = target.ok("strengthen-start").stdout
182
+
183
+ assert "nothing to strengthen" in out and assertion in out
184
+ assert_on_base(target)
185
+ body = target.pr_creates()[0]["body"]
186
+ assert assertion in lines_with(body, case(1))
187
+
188
+
189
+ def test_a_rejected_proven_item_is_reverted_and_left_out_of_the_pr(target):
190
+ drift_id = "x_apply_drift__mutmut_2"
191
+ target.prepare([cand(case(1))], accept=[COMPUTE, DRIFT],
192
+ groups={COMPUTE: strengthen_group(IDS_7_9), DRIFT: strengthen_group([drift_id])})
193
+ target.ok("strengthen-start")
194
+ target.commit_test(COMPUTE, COMPUTE_TEST)
195
+ target.commit_test(DRIFT, DRIFT_TEST)
196
+ target.ok("strengthen-verify")
197
+ reviews = target.tmp / "reviews-reject.json"
198
+ reviews.write_text(json.dumps({"reviews": [{"id": COMPUTE, "verdict": "accept", "reason": "pins the rounding"},
199
+ {"id": DRIFT, "verdict": "reject", "reason": "restates the clamp"}]}))
200
+
201
+ target.ok("strengthen-finish", "--reviews", str(reviews))
202
+
203
+ source = target.show(target.remote_sha(STRENGTHEN_BRANCH), "backend/tests/test_rebalancer.py")
204
+ assert "test_compute_trades_rounding" in source and "test_apply_drift_new" not in source
205
+ body = strengthen_pr(target)
206
+ assert drift_id not in body and "test_apply_drift_new" not in body
207
+ assert "✓ services/rebalancer.py: 18 survivors, baseline 20" in body # make mutation-check ran
208
+ assert target.log("crap"), "strengthen-finish runs make crap"
209
+
210
+
211
+ def test_finish_refuses_a_head_moved_since_the_proof_and_stays_for_the_rerun(target):
212
+ target.prepare([cand(case(1))], accept=[COMPUTE], groups={COMPUTE: strengthen_group(IDS_7_9)})
213
+ target.ok("strengthen-start")
214
+ target.commit_test(COMPUTE, COMPUTE_TEST)
215
+ target.ok("strengthen-verify")
216
+ target.commit_test(COMPUTE, COMPUTE_TEST.replace("test_compute_trades_rounding", "test_unproven"))
217
+
218
+ proc = target.run("strengthen-finish", "--reviews", str(target.reviews([COMPUTE], ids=[COMPUTE])))
219
+
220
+ assert proc.returncode == 2 and "strengthen-verify" in proc.stderr
221
+ assert STRENGTHEN_BRANCH not in target.remote_branches() and target.pr_creates() == []
222
+ assert "test_unproven" in (target.root / "backend/tests/test_rebalancer.py").read_text() # HEAD stayed
223
+ assert target.git("rev-parse", "main").strip() == target.base == target.remote_sha("main")
224
+
225
+
226
+ def test_new_commits_for_a_reverted_item_are_refused(target):
227
+ drift_ids = ["x_apply_drift__mutmut_2", "x_apply_drift__mutmut_4"]
228
+ target.prepare([cand(case(1))], accept=[COMPUTE, DRIFT],
229
+ groups={COMPUTE: strengthen_group(IDS_7_9), DRIFT: strengthen_group(drift_ids)})
230
+ target.ok("strengthen-start")
231
+ target.commit_test(COMPUTE, COMPUTE_TEST)
232
+ target.commit_test(DRIFT, DRIFT_TEST)
233
+ target.ok("strengthen-verify")
234
+ target.commit_test(DRIFT, DRIFT_TEST.replace("test_apply_drift_new", "test_apply_drift_again"))
235
+
236
+ proc = target.run("strengthen-verify")
237
+
238
+ assert proc.returncode == 2 and f"{DRIFT} was closed" in proc.stderr
239
+
240
+
241
+ # --- run status -----------------------------------------------------------------------
242
+
243
+ def test_every_command_records_its_status_for_the_headless_job(target):
244
+ target.prepare([cand(case(1), unprotected=True)])
245
+ target.ok("prune")
246
+ status = target.read_state("status.json")
247
+ assert status["exit"] == 1 and status["reason"] == "test-prune: run incomplete: the strengthen loop has not finished"
248
+ target.ok("strengthen-start")
249
+ status = target.read_state("status.json")
250
+ assert status["exit"] == 0 and status["reason"].startswith("nothing to strengthen")
251
+ assert [c["command"] for c in status["commands"]] == ["collect", "run", "review-input", "batch", "prune",
252
+ "strengthen-start", "run"]
253
+
254
+ target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
255
+ proc = target.run("prune", env={"FAKE_GATE_FAIL": "1"})
256
+ status = target.read_state("status.json")
257
+ assert proc.returncode == 1 and status["exit"] == 1 and "prune-gate failed" in status["reason"]
258
+
259
+
260
+ def test_status_names_the_bad_input(target):
261
+ target.prepare([cand(case(1))], unattended=False, accept=[case(1)], deletions={case(1): REFERENCE})
262
+
263
+ target.run("prune")
264
+
265
+ status = target.read_state("status.json")
266
+ assert status["exit"] == 2 and "runs only in unattended mode" in status["reason"]
267
+
268
+
269
+ # --- split weak-spot groups -------------------------------------------------------------
270
+
271
+ def split_proposals(target, strengthen_ids, equivalent_ids, second="equivalent"):
272
+ proposals = target.proposals(groups={COMPUTE: strengthen_group(strengthen_ids)})
273
+ proposals["weak_spots"].append({"group": COMPUTE, "verdict": second, "mutant_ids": equivalent_ids,
274
+ "test": "tests/test_rebalancer.py", "reason": "the rounding mutant is unobservable"})
275
+ return proposals
276
+
277
+
278
+ def test_a_group_splits_into_a_strengthen_and_an_equivalent_proposal(target):
279
+ target.set_candidates([cand(case(1))])
280
+ target.ok("collect", "--unattended")
281
+ assert target.review_input(split_proposals(target, IDS_7_9[:1], IDS_7_9[1:])).returncode == 0
282
+ ids = [p["id"] for p in target.read_state("review-input.json")["proposals"]]
283
+ assert f"{COMPUTE}:strengthen" in ids and f"{COMPUTE}:equivalent" in ids
284
+
285
+ target.ok("batch", "--reviews", str(target.reviews(ids)))
286
+
287
+ batch = target.read_state("batch.json")
288
+ assert [(e["group"], e["mutant_ids"]) for e in batch["equivalents"]] == [(COMPUTE, IDS_7_9[1:])]
289
+ (item,) = [s for s in batch["strengthen"] if s["id"] == f"{COMPUTE}:strengthen"]
290
+ assert item["mutant_ids"] == IDS_7_9[:1] and item["kind"] == "weak_spot"
291
+
292
+
293
+ @pytest.mark.parametrize("strengthen_ids, equivalent_ids, second, problem", [
294
+ (IDS_7_9, IDS_7_9[1:], "equivalent", "in both entries"),
295
+ (None, IDS_7_9[1:], "equivalent", "lists mutant_ids in both entries"),
296
+ (IDS_7_9[:1], IDS_7_9[1:], "strengthen", "at most one strengthen and one equivalent entry"),
297
+ ])
298
+ def test_a_bad_split_exits_2_naming_the_group(target, strengthen_ids, equivalent_ids, second, problem):
299
+ target.set_candidates([cand(case(1))])
300
+ target.ok("collect")
301
+ proposals = split_proposals(target, strengthen_ids or [], equivalent_ids, second)
302
+ if strengthen_ids is None:
303
+ del proposals["weak_spots"][[w["group"] for w in proposals["weak_spots"]].index(COMPUTE)]["mutant_ids"]
304
+
305
+ proc = target.review_input(proposals)
306
+
307
+ assert proc.returncode == 2 and f"weak spot {COMPUTE}: " in proc.stderr and problem in proc.stderr
308
+
309
+
310
+ # --- round 2 --------------------------------------------------------------------------
311
+
312
+ def test_leaving_never_discards_a_change_the_run_did_not_make(target):
313
+ mine, theirs = target.root / "backend/tests/test_rebalancer.py", target.root / "backend/services/rebalancer.py"
314
+ target.git("checkout", "-q", "--detach")
315
+ repo = Repo(target.root)
316
+ repo.write({"backend/tests/test_rebalancer.py": mine.read_text() + "# the run's own edit\n"}, target.base)
317
+
318
+ repo.leave_on("main", target.base)
319
+ assert target.git("symbolic-ref", "--short", "HEAD").strip() == "main" and target.git("status", "--porcelain") == ""
320
+
321
+ target.git("checkout", "-q", "--detach")
322
+ theirs.write_text(theirs.read_text() + "# someone else's edit\n")
323
+ repo.leave_on("main", target.base)
324
+ assert "someone else's edit" in theirs.read_text() and "someone else" not in repo.stranded
325
+ assert "backend/services/rebalancer.py" in repo.stranded
326
+ assert target.git("rev-parse", "--abbrev-ref", "HEAD").strip() == "HEAD"
327
+
328
+
329
+ def test_a_failed_finish_precondition_keeps_uncommitted_edits(target):
330
+ target.prepare([cand(case(1))], accept=[COMPUTE], groups={COMPUTE: strengthen_group(IDS_7_9)})
331
+ target.ok("strengthen-start")
332
+ target.commit_test(COMPUTE, COMPUTE_TEST)
333
+ target.ok("strengthen-verify")
334
+ path = target.root / "backend/tests/test_concentration.py"
335
+ path.write_text(path.read_text() + "# work in progress\n")
336
+
337
+ proc = target.run("strengthen-finish", "--reviews", str(target.reviews([COMPUTE], ids=[COMPUTE])))
338
+
339
+ assert proc.returncode == 2 and "test_concentration.py" in proc.stderr
340
+ assert "# work in progress" in path.read_text()
341
+
342
+
343
+ def test_finish_reruns_after_bad_reviews(target):
344
+ target.prepare([cand(case(1))], accept=[COMPUTE], groups={COMPUTE: strengthen_group(IDS_7_9)})
345
+ target.ok("strengthen-start")
346
+ target.commit_test(COMPUTE, COMPUTE_TEST)
347
+ target.ok("strengthen-verify")
348
+ proven = target.read_state("strengthen-proof.json")["head"]
349
+ bad = target.tmp / "bad-reviews.json"
350
+ bad.write_text(json.dumps({"reviews": [{"id": COMPUTE, "verdict": "maybe", "reason": "unsure"}]}))
351
+
352
+ assert target.run("strengthen-finish", "--reviews", str(bad)).returncode == 2
353
+ assert target.git("rev-parse", "HEAD").strip() == proven
354
+
355
+ target.ok("strengthen-finish", "--reviews", str(target.reviews([COMPUTE], ids=[COMPUTE])))
356
+ assert STRENGTHEN_BRANCH in target.remote_branches()
357
+ assert_on_base(target)
358
+
359
+
360
+ def test_the_status_keeps_the_worst_exit_while_strengthen_still_runs(target):
361
+ target.prepare([cand(case(1))], accept=[case(1), COMPUTE], deletions={case(1): REFERENCE},
362
+ groups={COMPUTE: strengthen_group(IDS_7_9)})
363
+ assert target.run("prune", env={"FAKE_GATE_FAIL": "1"}).returncode == 1
364
+
365
+ target.ok("strengthen-start")
366
+
367
+ status = target.read_state("status.json")
368
+ assert status["exit"] == 1 and "prune-gate failed" in status["reason"]
369
+ assert [(c["command"], c["exit"]) for c in status["commands"][-2:]] == [("prune", 1), ("strengthen-start", 0)]
370
+
371
+
372
+ def test_a_malformed_date_is_recorded_in_the_status(target):
373
+ target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
374
+
375
+ proc = subprocess.run([sys.executable, str(PRUNE), "prune", "--state", str(target.state), "--date", "nope"],
376
+ cwd=target.root, env=target.env(), capture_output=True, text=True)
377
+
378
+ status = target.read_state("status.json")
379
+ assert proc.returncode == 2 and status["exit"] == 2 and "--date must be YYYY-MM-DD" in status["reason"]
380
+
381
+
382
+ def test_the_cap_skips_a_family_that_does_not_fit_and_takes_later_ones():
383
+ singles = [{"test_id": f"tests/t.py::test_{i}", "into": "x"} for i in range(9)]
384
+ family = [{"test_id": f"tests/t.py::test_f[{i}]", "into": "x"} for i in range(3)]
385
+ last = [{"test_id": "tests/t.py::test_last", "into": "x"}]
386
+
387
+ big = [{"test_id": f"tests/t.py::test_big[{i}]", "into": "x"} for i in range(11)]
388
+
389
+ take, left, over = cap(big + singles + family + last)
390
+
391
+ assert take == singles + last and left == family
392
+ assert [o["test_id"] for o in over] == [d["test_id"] for d in big]
393
+
394
+
395
+ @pytest.mark.parametrize("addition, deleted", [
396
+ ("class TestRoundingMore(TestRounding):\n pass\n", ["TestRounding::test_round_down"]),
397
+ ('def test_names_the_class():\n assert "TestRounding"\n',
398
+ ["TestRounding::test_round_down", "TestRounding::test_round_half_up"]),
399
+ ])
400
+ def test_a_test_in_a_class_named_elsewhere_is_skipped(target, addition, deleted):
401
+ path = target.root / "backend/tests/test_rebalancer.py"
402
+ path.write_text(path.read_text() + "\n\n" + addition)
403
+ target.git("commit", "-q", "-am", "name TestRounding elsewhere")
404
+ deletions = {R + d: R + "test_trade_size_is_decimal" for d in deleted}
405
+
406
+ target.prepare([cand(t, into) for t, into in deletions.items()], accept=list(deletions), deletions=deletions)
407
+
408
+ batch = target.read_state("batch.json")
409
+ assert batch["deletions"] == []
410
+ assert all("class TestRounding is subclassed or named" in s["reason"] for s in batch["skipped"])
411
+ assert sorted(s["test_id"] for s in batch["skipped"]) == sorted(deletions)
412
+
413
+
414
+ def test_make_runs_without_pytest_addopts(tmp_path, monkeypatch):
415
+ subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
416
+ (tmp_path / "Makefile").write_text("show:\n\t@echo \"[$$PYTEST_ADDOPTS][$$KEEP_CACHE]\"\n")
417
+ monkeypatch.setenv("PYTEST_ADDOPTS", "-k nothing")
418
+ monkeypatch.setenv("KEEP_CACHE", "1")
419
+
420
+ assert Repo(tmp_path).make("show").stdout.strip() == "[][]"
421
+
422
+
423
+ def test_batch_prints_long_mutant_lists_shortened():
424
+ from prune_review import mutant_summary
425
+ ids = [f"x_f__mutmut_{i}" for i in range(1, 66)]
426
+
427
+ assert mutant_summary(ids) == "(65 mutants: " + ", ".join(ids[:5]) + " +60 more)"
428
+ assert mutant_summary(ids[:1]) == "(1 mutant: x_f__mutmut_1)"
429
+
430
+
431
+ def test_a_nested_class_that_would_empty_its_outer_class_is_skipped(target):
432
+ path = target.root / "backend/tests/test_rebalancer.py"
433
+ path.write_text(path.read_text() + "\n\nclass TestOuter:\n class TestInner:\n def test_inner(self):\n"
434
+ " assert apply_drift(0.5) == 0.5\n")
435
+ target.git("commit", "-q", "-am", "nest a test class")
436
+ inner = R + "TestOuter::TestInner::test_inner"
437
+
438
+ target.prepare([cand(inner, HOLD)], accept=[inner], deletions={inner: HOLD})
439
+
440
+ (skipped,) = target.read_state("batch.json")["skipped"]
441
+ assert skipped["test_id"] == inner and "leaves class TestOuter empty" in skipped["reason"]
442
+
443
+
444
+ def test_an_unattended_batch_skips_a_family_larger_than_the_cap(target):
445
+ path = target.root / "backend/tests/test_rebalancer.py"
446
+ path.write_text(path.read_text() + "\n\n@pytest.mark.parametrize(\"d\", " + repr(list(range(11))) + ")\n"
447
+ "def test_drift_grid(d):\n assert apply_drift(d / 20) == d / 20\n")
448
+ target.git("commit", "-q", "-am", "an eleven-case family")
449
+ grid = [f"{R}test_drift_grid[{i}]" for i in range(11)]
450
+ deletions = {**{t: HOLD for t in grid}, case(1): REFERENCE}
451
+
452
+ target.prepare([cand(t, into) for t, into in deletions.items()], accept=list(deletions), deletions=deletions)
453
+
454
+ batch = target.read_state("batch.json")
455
+ assert [d["test_id"] for d in batch["deletions"]] == [case(1)]
456
+ assert sorted(s["test_id"] for s in batch["skipped"]) == sorted(grid)
457
+ assert {s["reason"] for s in batch["skipped"]} == {"11 cases exceed the cap of 10"}
458
+
459
+
460
+ @pytest.mark.parametrize("old", ['{"command": "collect", "exit": 0, "reason": "1 candidates"}', "{not json", "[]"])
461
+ def test_an_old_or_malformed_status_never_crashes_a_command(target, old):
462
+ target.prepare([cand(case(1), unprotected=True)])
463
+ (target.state / "status.json").write_text(old)
464
+
465
+ proc = target.run("prune")
466
+
467
+ assert proc.returncode == 0 and "Traceback" not in proc.stderr
468
+ status = target.read_state("status.json")
469
+ assert status["exit"] == 0 and [c["command"] for c in status["commands"]] == ["prune"] # treated as missing
470
+
471
+
472
+ # --- round 3 --------------------------------------------------------------------------
473
+
474
+ def fake_make(target, script, goal="test"):
475
+ """A `make` first on PATH that runs `script` (sh, $MAKE is the real make) for `make <goal>`."""
476
+ path = target.bin / "make"
477
+ path.write_text(f"#!/bin/sh\nMAKE={shutil.which('make')}\ncase \" $* \" in *\" {goal} \"*)\n{script}\n;; esac\n"
478
+ "exec $MAKE \"$@\"\n")
479
+ path.chmod(0o755)
480
+
481
+
482
+ def test_an_exit_2_clears_once_the_same_command_passes(target):
483
+ target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
484
+ bad = target.tmp / "bad.json"
485
+ bad.write_text("{}")
486
+ assert target.run("batch", "--reviews", str(bad)).returncode == 2
487
+ target.ok("batch", "--reviews", str(target.reviews([case(1)])))
488
+
489
+ target.ok("prune")
490
+ target.ok("strengthen-start")
491
+
492
+ status = target.read_state("status.json")
493
+ assert status["exit"] == 0
494
+ assert [(c["command"], c["exit"]) for c in status["commands"][-5:]] == [
495
+ ("batch", 2), ("batch", 0), ("prune", 0), ("strengthen-start", 0), ("run", 0)]
496
+
497
+
498
+ def test_a_failed_make_test_reports_one_line_and_the_end_of_its_output(target, tmp_path):
499
+ target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
500
+ fake_make(target, "for i in $(seq 1 100); do echo \"DeprecationWarning noise $i\"; done\n"
501
+ "echo 'FAILED app-chained-targets.test.ts: timed out'; exit 2")
502
+ summary = tmp_path / "summary.md"
503
+
504
+ proc = target.run("prune", env={"GITHUB_STEP_SUMMARY": str(summary)})
505
+
506
+ status = target.read_state("status.json")
507
+ assert proc.returncode == 1 and status["reason"] == "test-prune: make test failed (exit 2)"
508
+ for text in (proc.stdout, summary.read_text()):
509
+ assert "FAILED app-chained-targets.test.ts: timed out" in text and "noise 72" in text
510
+ assert "noise 71\n" not in text
511
+ assert "## Mutation ratchet (cold run on this branch)" in text and "services/concentration.py" in text
512
+ assert_on_base(target)
513
+
514
+
515
+ def test_leaving_keeps_a_user_edit_to_a_file_the_run_wrote(target, tmp_path):
516
+ target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
517
+ edited = target.root / "backend/tests/test_concentration.py"
518
+ fake_make(target, f"echo '# my edit' >> '{edited}'; exit 2")
519
+ summary = tmp_path / "summary.md"
520
+
521
+ proc = target.run("prune", env={"GITHUB_STEP_SUMMARY": str(summary)})
522
+
523
+ assert proc.returncode == 1 and "# my edit" in edited.read_text()
524
+ assert target.git("rev-parse", "--abbrev-ref", "HEAD").strip() == "HEAD"
525
+ assert target.git("rev-parse", "main").strip() == target.base
526
+ for text in (proc.stdout, summary.read_text()):
527
+ assert "staying at" in text and "backend/tests/test_concentration.py" in text
528
+
529
+
530
+ def test_staying_off_the_base_branch_fails_a_passing_command(target, tmp_path):
531
+ target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
532
+ foreign = target.root / "backend/services/concentration.py"
533
+ gh = target.bin / "gh"
534
+ gh.rename(target.bin / "real-gh")
535
+ gh.write_text(f"#!/bin/sh\necho '# foreign' >> '{foreign}'\nexec '{target.bin / 'real-gh'}' \"$@\"\n")
536
+ gh.chmod(0o755)
537
+ summary = tmp_path / "summary.md"
538
+
539
+ proc = target.run("prune", env={"GITHUB_STEP_SUMMARY": str(summary)})
540
+
541
+ status = target.read_state("status.json")
542
+ assert proc.returncode == 1 and len(target.pr_creates()) == 1 and "# foreign" in foreign.read_text()
543
+ assert status["exit"] == 1 and status["reason"].startswith("test-prune: staying at ")
544
+ assert "backend/services/concentration.py" in status["reason"]
545
+ assert "staying at" in proc.stdout and "staying at" in summary.read_text()
546
+
547
+
548
+ def test_leaving_restores_a_file_whose_name_git_quotes(target):
549
+ name = 'backend/tests/test_a "quoted" name.py'
550
+ (target.root / name).write_text("def test_x():\n assert 1\n")
551
+ target.git("add", "--", name)
552
+ target.git("commit", "-q", "-m", "a quoted name")
553
+ target.git("checkout", "-q", "--detach")
554
+ repo, head = Repo(target.root), target.git("rev-parse", "main").strip()
555
+ repo.write({name: ""}, head)
556
+
557
+ repo.leave_on("main", head)
558
+
559
+ assert repo.stranded == "" and target.git("symbolic-ref", "--short", "HEAD").strip() == "main"
560
+ assert "def test_x" in (target.root / name).read_text()
561
+
562
+
563
+ def proven_strengthen(target):
564
+ target.prepare([cand(case(1))], accept=[COMPUTE], groups={COMPUTE: strengthen_group(IDS_7_9)})
565
+ target.ok("prune") # nothing to prune: the strengthen loop follows, as in a real run
566
+ target.ok("strengthen-start")
567
+ target.commit_test(COMPUTE, COMPUTE_TEST)
568
+ target.ok("strengthen-verify")
569
+ return target.root / "backend/mutation_baseline.json"
570
+
571
+
572
+ def test_a_change_to_the_baseline_during_a_failed_update_is_kept(target):
573
+ baseline = proven_strengthen(target)
574
+ fake_make(target, f"echo ' ' >> '{baseline}'; exit 2", goal="mutation-baseline")
575
+ edited = baseline.read_text() + " \n"
576
+
577
+ proc = target.run("strengthen-finish", "--reviews", str(target.reviews([COMPUTE], ids=[COMPUTE])))
578
+
579
+ assert proc.returncode == 1 and "make mutation-baseline failed (exit 2)" in proc.stdout
580
+ assert baseline.read_text() == edited and "staying at" in proc.stdout
581
+ assert target.git("rev-parse", "--abbrev-ref", "HEAD").strip() == "HEAD"
582
+ assert target.remote_branches() == {"main"}
583
+
584
+
585
+ def test_a_baseline_deleted_by_a_failed_update_fails_the_status_without_a_traceback(target):
586
+ baseline = proven_strengthen(target)
587
+ fake_make(target, f"rm -f '{baseline}'; exit 2", goal="mutation-baseline")
588
+
589
+ proc = target.run("strengthen-finish", "--reviews", str(target.reviews([COMPUTE], ids=[COMPUTE])))
590
+
591
+ status = target.read_state("status.json")
592
+ assert proc.returncode == 1 and "Traceback" not in proc.stderr
593
+ assert status["exit"] == 1 and status["commands"][-1]["command"] == "strengthen-finish"
594
+ assert status["commands"][-1]["exit"] == 1
595
+ assert not baseline.exists() and "staying at" in proc.stdout # the run did not delete it: not restored
596
+
597
+
598
+ def test_an_unexpected_error_is_reported_and_recorded(target):
599
+ target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
600
+ (target.state / "batch.json").write_text("{")
601
+
602
+ proc = target.run("prune")
603
+
604
+ status = target.read_state("status.json")
605
+ assert proc.returncode == 1 and "Traceback" not in proc.stderr
606
+ assert "test-prune: unexpected JSONDecodeError" in proc.stdout
607
+ assert status["exit"] == 1 and status["reason"].startswith("test-prune: unexpected JSONDecodeError")
608
+
609
+
610
+ def test_a_user_edit_during_a_passing_mutation_run_is_never_overwritten(target):
611
+ target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
612
+ edited = target.root / "backend/tests/test_concentration.py"
613
+ fake_make(target, f"$MAKE \"$@\"; rc=$?; echo '# my edit' >> '{edited}'; exit $rc", goal="mutation")
614
+
615
+ proc = target.run("prune")
616
+
617
+ assert proc.returncode == 1 and "# my edit" in edited.read_text()
618
+ assert "backend/tests/test_concentration.py changed during the run, not by test-prune" in proc.stdout
619
+ assert target.log("gh") == [] and target.remote_branches() == {"main"}
620
+ assert target.read_state("status.json")["exit"] == 1
621
+
622
+
623
+ def test_a_failure_report_says_where_the_deletions_are(target):
624
+ target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
625
+ proc = target.run("prune", env={"FAKE_TEST_FAIL": "1"})
626
+ assert "## Deletions that passed the safety check, not committed (1)" in proc.stdout
627
+ assert "## Deleted tests" not in proc.stdout
628
+
629
+ target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
630
+ proc = target.run("prune", env={"FAKE_GATE_FAIL": "1"})
631
+ assert f"## Deletions on the unpushed branch `{PRUNE_BRANCH}` (1)" in proc.stdout
632
+ assert "## Deleted tests" not in proc.stdout
633
+
634
+
635
+ def test_an_exit_1_stays_open_after_the_command_passes():
636
+ from prune import run_status
637
+ status = None
638
+ for command, code in [("collect", 0), ("prune", 1), ("prune", 0), ("batch", 2), ("strengthen-start", 0)]:
639
+ status = run_status(status, command, code, f"{command} said {code}")
640
+
641
+ assert status["exit"] == 2 and status["reason"] == "batch said 2"
642
+ status = run_status(status, "batch", 0, "batch said 0")
643
+ assert status["exit"] == 1 and status["reason"] == "prune said 1"
644
+ status = run_status(status, "strengthen-finish", 1, "strengthen-finish said 1")
645
+ assert status["exit"] == 1 and status["reason"] == "strengthen-finish said 1" # the latest on a tie
646
+
647
+
648
+ # --- PR body size ----------------------------------------------------------------------
649
+
650
+ def prune_pr(target):
651
+ (create,) = [c for c in target.pr_creates() if arg(c["argv"], "--head") == PRUNE_BRANCH]
652
+ return create["body"]
653
+
654
+
655
+ def test_the_pr_carries_the_gate_report_not_its_whole_mutmut_log(target, tmp_path):
656
+ target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
657
+ fake_make(target, "for i in $(seq 1 20000); do echo \" x_herfindahl__mutmut_$i: killed\"; done\n"
658
+ "echo 'Mutation results from commit abc123'\n"
659
+ "echo ' ✓ services/concentration.py: 2 survivors, baseline 2, score 80.00'\n"
660
+ "echo 'Mutation ratchet PASSED.'\n"
661
+ 'exec $MAKE "$@"', goal="prune-gate")
662
+ summary = tmp_path / "summary.md"
663
+
664
+ proc = target.run("prune", env={"GITHUB_STEP_SUMMARY": str(summary)})
665
+
666
+ assert proc.returncode == 0, proc.stdout + proc.stderr
667
+ body, log = prune_pr(target), target.state / "prune-gate.log"
668
+ gate = body.split("## Prune gate", 1)[1]
669
+ assert "Mutation results from commit abc123" in gate and "Mutation ratchet PASSED." in gate
670
+ assert " ✓ services/concentration.py: 2 survivors" in gate and "Prune gate PASSED" in gate
671
+ assert "mutmut_" not in gate and str(log) in gate and "truncated" not in body
672
+ assert "x_herfindahl__mutmut_20000: killed" in log.read_text()
673
+ assert len(summary.read_text()) < 60_000
674
+
675
+
676
+ def test_pr_bodies_and_summaries_over_the_limit_are_cut_visibly(target, tmp_path):
677
+ huge = "the fixer's argument, " * 3500 # 77,000 characters
678
+ summary = tmp_path / "summary.md"
679
+ env = {"GITHUB_STEP_SUMMARY": str(summary)}
680
+ target.prepare([cand(case(1))], accept=[case(1), COMPUTE, HERFINDAHL], deletions={case(1): REFERENCE}, env=env,
681
+ reasons={case(1): huge}, groups={COMPUTE: strengthen_group(IDS_7_9),
682
+ HERFINDAHL: {"verdict": "equivalent", "mutant_ids": [EQUIVALENT_ID],
683
+ "test": "tests/test_concentration.py", "reason": huge}})
684
+
685
+ target.ok("prune", env=env)
686
+ assert strengthen(target, [(COMPUTE, COMPUTE_TEST)], [COMPUTE], env=env).returncode == 0
687
+
688
+ for body, name in ((prune_pr(target), "prune-pr-full.md"), (strengthen_pr(target), "strengthen-pr-full.md")):
689
+ full = target.state / name
690
+ assert len(body) < 60_000 and f"… truncated, full text in {full}" in body
691
+ assert huge in full.read_text() and body == (target.state / name.replace("-full", "")).read_text()
692
+ batch_full = target.state / "summary-1.md" # batch prints both arguments
693
+ assert f"… truncated, full text in {batch_full}" in summary.read_text() and huge in batch_full.read_text()
694
+
695
+
696
+ def test_the_gate_report_keeps_the_ratchet_block_and_the_verdict():
697
+ from prune_runs import gate_report
698
+ noise = [f" x_hhi__mutmut_{i}: killed" for i in range(500)]
699
+ check = ["Mutation results from commit abc", " ✗ services/a.py: 3 survivors > baseline 2 (score 50.00)",
700
+ " x_a__mutmut_1", " ✓ services/b.py: 1 survivors, baseline 1, score 90.00",
701
+ " ↑ fewer survivors than the baseline, run 'make mutation-baseline' to lock it in",
702
+ "Mutation ratchet FAILED."]
703
+ verdict = ["Prune gate FAILED at point 4:", " ✗ the mutation ratchet check failed (output above)"]
704
+
705
+ assert gate_report("\n".join(noise + check + noise[:3] + verdict)) == "\n".join(verdict + check)
706
+ assert gate_report("\n".join(noise + verdict)) == "\n".join(verdict)
707
+ assert gate_report("\n".join(noise)) == "\n".join(noise[-30:])
708
+
709
+
710
+ def test_a_cut_inside_a_fence_closes_it(tmp_path):
711
+ from prune_repo import capped
712
+ text = "## Prune gate\n\n```\n" + "x\n" * 40_000 + "```\n"
713
+
714
+ cut = capped(text, tmp_path / "full.md", 1000)
715
+
716
+ assert len(cut) <= 1000 and cut.count("```") == 2 and cut.endswith(f"… truncated, full text in {tmp_path / 'full.md'}\n")
717
+ assert (tmp_path / "full.md").read_text() == text and capped("short", tmp_path / "other.md") == "short"
718
+
719
+
720
+ def test_a_pr_that_failed_after_the_push_says_the_branch_is_pushed(target):
721
+ target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
722
+ (target.bin / "gh").write_text("#!/bin/sh\necho 'Body is too long' >&2\nexit 1\n")
723
+
724
+ proc = target.run("prune")
725
+
726
+ assert proc.returncode == 1 and "gh pr create failed: Body is too long" in proc.stdout
727
+ assert f"## Deletions on `{PRUNE_BRANCH}`, pushed without a PR (1)" in proc.stdout
728
+ assert PRUNE_BRANCH in target.remote_branches()
729
+
730
+
731
+ def test_apply_keeps_a_user_edit_when_it_puts_the_test_files_back(target):
732
+ target.prepare([cand(case(1))], unattended=False, accept=[case(1)], deletions={case(1): REFERENCE})
733
+ edited = target.root / "backend/tests/test_concentration.py"
734
+ fake_make(target, f"echo '# my edit' >> '{edited}'; exit 2")
735
+
736
+ proc = target.run("apply")
737
+
738
+ assert proc.returncode == 1 and "# my edit" in edited.read_text()
739
+ assert "backend/tests/test_concentration.py changed during the run, not by test-prune" in proc.stdout
740
+ assert "make test failed (exit 2)" in target.read_state("status.json")["reason"]
741
+
742
+
743
+ # --- final review -----------------------------------------------------------------------
744
+
745
+ def test_collect_refuses_a_state_dir_that_is_not_a_run_s(target):
746
+ notes = target.tmp / "notes"
747
+ notes.mkdir()
748
+ (notes / "mine.txt").write_text("keep me\n")
749
+ target.set_candidates([cand(case(1))])
750
+
751
+ proc = subprocess.run([sys.executable, str(PRUNE), "collect", "--state", str(notes), "--date", "2026-10-17"],
752
+ cwd=target.root, env=target.env(), capture_output=True, text=True)
753
+
754
+ assert proc.returncode == 2 and "refusing to use" in proc.stderr
755
+ assert sorted(p.name for p in notes.iterdir()) == ["mine.txt"] and target.log("mutation") == []
756
+
757
+
758
+ def test_collect_resets_only_the_run_s_own_files(target):
759
+ target.prepare([cand(case(1), unprotected=True)])
760
+ (target.state / "notes.txt").write_text("mine\n")
761
+
762
+ target.ok("collect", "--unattended")
763
+
764
+ assert (target.state / "notes.txt").read_text() == "mine\n"
765
+ assert not (target.state / "batch.json").exists()
766
+
767
+
768
+ def test_a_run_that_stops_after_collect_stays_red(target):
769
+ target.set_candidates([cand(case(1))])
770
+
771
+ target.ok("collect", "--unattended")
772
+
773
+ status = target.read_state("status.json")
774
+ assert status["exit"] == 1 and "run incomplete" in status["reason"]
775
+
776
+
777
+ def test_an_argument_error_is_recorded_as_exit_2(target):
778
+ target.set_candidates([cand(case(1))])
779
+ target.ok("collect", "--unattended")
780
+
781
+ proc = target.run("batch")
782
+
783
+ status = target.read_state("status.json")
784
+ assert proc.returncode == 2 and "--reviews" in proc.stderr
785
+ assert status["exit"] == 2 and status["commands"][-1]["command"] == "batch" and "--reviews" in status["reason"]
786
+
787
+
788
+ def test_a_finish_without_rejections_runs_no_extra_cold_proof(target):
789
+ proven_strengthen(target)
790
+ runs = len(target.log("mutation"))
791
+
792
+ target.ok("strengthen-finish", "--reviews", str(target.reviews([COMPUTE], ids=[COMPUTE])))
793
+
794
+ assert len(target.log("mutation")) == runs
795
+ assert target.read_state("status.json")["exit"] == 0
796
+
797
+
798
+ def test_a_failed_gate_lands_in_the_job_summary(target, tmp_path):
799
+ target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
800
+ summary = tmp_path / "summary.md"
801
+
802
+ assert target.run("prune", env={"FAKE_GATE_FAIL": "1", "GITHUB_STEP_SUMMARY": str(summary)}).returncode == 1
803
+
804
+ assert "Prune gate FAILED at point 1" in summary.read_text()
805
+
806
+
807
+
808
+ def test_a_bare_date_is_recorded_as_exit_2(target):
809
+ target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
810
+
811
+ proc = subprocess.run([sys.executable, str(PRUNE), "batch", "--state", str(target.state), "--date"],
812
+ cwd=target.root, env=target.env(), capture_output=True, text=True)
813
+
814
+ status = target.read_state("status.json")
815
+ assert proc.returncode == 2 and proc.stderr.count("--date") == 1 and "Traceback" not in proc.stderr
816
+ assert status["exit"] == 2 and status["commands"][-1]["command"] == "batch"
817
+
818
+
819
+ def test_the_run_finishes_only_after_prune_ran(target):
820
+ target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
821
+
822
+ assert "nothing to strengthen" in target.ok("strengthen-start").stdout
823
+ assert target.read_state("status.json")["exit"] == 1
824
+
825
+ target.ok("prune")
826
+ target.ok("strengthen-start")
827
+ assert target.read_state("status.json")["exit"] == 0
828
+
829
+
830
+ def test_a_status_json_alone_does_not_make_a_dir_a_run_s(target):
831
+ notes = target.tmp / "notes"
832
+ notes.mkdir()
833
+ (notes / "status.json").write_text('{"mine": true}\n')
834
+ target.set_candidates([cand(case(1))])
835
+
836
+ proc = subprocess.run([sys.executable, str(PRUNE), "collect", "--state", str(notes), "--date", "2026-10-17"],
837
+ cwd=target.root, env=target.env(), capture_output=True, text=True)
838
+
839
+ assert proc.returncode == 2 and "refusing to use" in proc.stderr
840
+ assert (notes / "status.json").read_text() == '{"mine": true}\n' and sorted(notes.iterdir()) == [notes / "status.json"]
841
+
842
+
843
+ def test_gate_warnings_on_stderr_never_crowd_out_the_later_sections(target):
844
+ target.prepare([cand(case(1))], accept=[case(1), HERFINDAHL], deletions={case(1): REFERENCE},
845
+ groups={HERFINDAHL: {"verdict": "equivalent", "mutant_ids": [EQUIVALENT_ID],
846
+ "test": "tests/test_concentration.py", "reason": "never empty"}})
847
+ fake_make(target, "echo 'Mutation results from commit abc123'\n"
848
+ "echo ' ✗ services/concentration.py: 9 survivors > baseline 2 (score 10.00)'\n"
849
+ "for i in $(seq 1 3000); do echo \" x_herfindahl__mutmut_$i\"; done\n"
850
+ "echo 'Mutation ratchet FAILED.'\n"
851
+ '$MAKE "$@"; rc=$?\n'
852
+ "for i in $(seq 1 3000); do echo \"db.py:93: DeprecationWarning: utcnow $i\" >&2; done\n"
853
+ "exit $rc", goal="prune-gate")
854
+
855
+ proc = target.run("prune")
856
+
857
+ assert proc.returncode == 0, proc.stdout + proc.stderr
858
+ body = prune_pr(target)
859
+ gate, after = body.split("## Prune gate", 1)[1].split("## Equivalent proposals", 1)
860
+ assert "Prune gate PASSED: 1 deleted tests" in gate
861
+ assert "DeprecationWarning" not in body and f"… truncated, full text in {target.state / 'prune-gate-report.md'}" in gate
862
+ assert EQUIVALENT_ID in after and "truncated" not in after and len(body) < 60_000
863
+ assert "DeprecationWarning: utcnow 3000" in (target.state / "prune-gate.log").read_text()
864
+
865
+
866
+ def test_the_gate_verdict_block_ends_at_its_last_indented_line():
867
+ from prune_runs import gate_report
868
+ stdout = "noise\nPrune gate PASSED: 1 deleted tests, all protected by the ratchet\n ✓ tests/t.py::test_a\nlate noise\n"
869
+
870
+ assert gate_report(stdout, "warning\n") == "Prune gate PASSED: 1 deleted tests, all protected by the ratchet\n ✓ tests/t.py::test_a"
871
+ assert gate_report("no verdict\n", "Prune gate ERROR: collection failed\n").endswith("Prune gate ERROR: collection failed")