@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.
- package/package.json +2 -2
- package/skills/implement-spec/ARCHITECTURE.md +3 -0
- package/skills/implement-spec/README.md +2 -2
- package/skills/implement-spec/driver_fixloop.py +27 -6
- package/skills/implement-spec/driver_report.py +3 -2
- package/skills/implement-spec/prompts/correction.md +1 -0
- package/skills/implement-spec/prompts/test-writer.md +3 -2
- package/skills/implement-spec/tests/test_fixloop.py +50 -0
- package/skills/test-prune/SKILL.md +156 -0
- package/skills/test-prune/prune.py +202 -0
- package/skills/test-prune/prune_repo.py +319 -0
- package/skills/test-prune/prune_review.py +363 -0
- package/skills/test-prune/prune_runs.py +240 -0
- package/skills/test-prune/prune_source.py +237 -0
- package/skills/test-prune/prune_strengthen.py +301 -0
- package/skills/test-prune/tests/conftest.py +15 -0
- package/skills/test-prune/tests/prune_harness.py +205 -0
- package/skills/test-prune/tests/test_prune_guards.py +871 -0
- package/skills/test-prune/tests/test_prune_inputs.py +97 -0
- package/skills/test-prune/tests/test_prune_review.py +113 -0
- package/skills/test-prune/tests/test_prune_source.py +84 -0
- package/skills/test-prune/tests/test_prune_strengthen.py +183 -0
- package/skills/test-prune/tests/test_prune_unattended.py +225 -0
- package/skills/test-prune/tests/testdata/gh +16 -0
- package/skills/test-prune/tests/testdata/repo/Makefile +25 -0
- package/skills/test-prune/tests/testdata/repo/backend/mutation_baseline.json +16 -0
- package/skills/test-prune/tests/testdata/repo/backend/services/concentration.py +2 -0
- package/skills/test-prune/tests/testdata/repo/backend/services/rebalancer.py +11 -0
- package/skills/test-prune/tests/testdata/repo/backend/tests/conftest.py +6 -0
- package/skills/test-prune/tests/testdata/repo/backend/tests/test_concentration.py +73 -0
- package/skills/test-prune/tests/testdata/repo/backend/tests/test_rebalancer.py +77 -0
- package/skills/test-prune/tests/testdata/repo/fake_engine.py +289 -0
- package/skills/test-prune/tests/testdata/repo/kill_map.json +131 -0
- package/skills-manifest.json +9 -3
|
@@ -0,0 +1,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")
|