@jesdi/skills 0.0.50 → 0.0.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/skills/test-prune/SKILL.md +156 -0
- package/skills/test-prune/prune.py +202 -0
- package/skills/test-prune/prune_repo.py +319 -0
- package/skills/test-prune/prune_review.py +363 -0
- package/skills/test-prune/prune_runs.py +240 -0
- package/skills/test-prune/prune_source.py +237 -0
- package/skills/test-prune/prune_strengthen.py +301 -0
- package/skills/test-prune/tests/conftest.py +15 -0
- package/skills/test-prune/tests/prune_harness.py +205 -0
- package/skills/test-prune/tests/test_prune_guards.py +871 -0
- package/skills/test-prune/tests/test_prune_inputs.py +97 -0
- package/skills/test-prune/tests/test_prune_review.py +113 -0
- package/skills/test-prune/tests/test_prune_source.py +84 -0
- package/skills/test-prune/tests/test_prune_strengthen.py +183 -0
- package/skills/test-prune/tests/test_prune_unattended.py +225 -0
- package/skills/test-prune/tests/testdata/gh +16 -0
- package/skills/test-prune/tests/testdata/repo/Makefile +25 -0
- package/skills/test-prune/tests/testdata/repo/backend/mutation_baseline.json +16 -0
- package/skills/test-prune/tests/testdata/repo/backend/services/concentration.py +2 -0
- package/skills/test-prune/tests/testdata/repo/backend/services/rebalancer.py +11 -0
- package/skills/test-prune/tests/testdata/repo/backend/tests/conftest.py +6 -0
- package/skills/test-prune/tests/testdata/repo/backend/tests/test_concentration.py +73 -0
- package/skills/test-prune/tests/testdata/repo/backend/tests/test_rebalancer.py +77 -0
- package/skills/test-prune/tests/testdata/repo/fake_engine.py +289 -0
- package/skills/test-prune/tests/testdata/repo/kill_map.json +131 -0
- package/skills-manifest.json +7 -1
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""The target repo: git, make targets, mutmut results, run state and the run summary."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# mutmut's status_by_exit_code, as check_mutation.py reads it.
|
|
12
|
+
SURVIVING = {0, 5, 33}
|
|
13
|
+
KILLED = {1, 3}
|
|
14
|
+
PUSHABLE = re.compile(r"^chore/test-(prune|strengthen)-\d{4}-\d{2}-\d{2}$")
|
|
15
|
+
DROPPED_ENV = {"KEEP_CACHE", "MAKEFLAGS", "MFLAGS", "MAKELEVEL", "PYTEST_ADDOPTS"}
|
|
16
|
+
_FAILED = re.compile(r"✗ (\S+): (\d+) survivors > baseline (\d+)")
|
|
17
|
+
_PASSED = re.compile(r"✓ (\S+): (\d+) survivors, baseline (\d+)")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Fail(Exception):
|
|
21
|
+
"""A make target, a gate or a guard failed: exit 1."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BadInput(Exception):
|
|
25
|
+
"""Invalid input or an unmet precondition: exit 2."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
LIMIT = 60_000 # characters per PR body or summary block; GitHub refuses a PR body over 65,536
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def capped(text: str, full: Path, limit: int = LIMIT) -> str:
|
|
32
|
+
"""text, or when it exceeds limit, its start with a visible truncation line (the full text saved to full)."""
|
|
33
|
+
if len(text) <= limit:
|
|
34
|
+
return text
|
|
35
|
+
full.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
full.write_text(text)
|
|
37
|
+
note = f"\n\n… truncated, full text in {full}\n"
|
|
38
|
+
cut = text[:limit - len(note) - 4]
|
|
39
|
+
return cut + ("\n```" if cut.count("```") % 2 else "") + note
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def tail(text: str, lines: int = 30) -> str:
|
|
43
|
+
return "\n".join(text.splitlines()[-lines:])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fenced(text: str) -> str:
|
|
47
|
+
return f"```\n{text.strip()}\n```"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_ratchet(output: str, returncode: int) -> dict:
|
|
51
|
+
"""`make mutation-check` → {ok, output, modules: {module: [survivors, baseline]}, failed: [modules]}."""
|
|
52
|
+
modules, failed = {}, []
|
|
53
|
+
for line in output.splitlines():
|
|
54
|
+
match = _FAILED.search(line) or _PASSED.search(line)
|
|
55
|
+
if match:
|
|
56
|
+
modules[match.group(1)] = [int(match.group(2)), int(match.group(3))]
|
|
57
|
+
if match.re is _FAILED:
|
|
58
|
+
failed.append(match.group(1))
|
|
59
|
+
if returncode and not failed:
|
|
60
|
+
raise Fail(f"make mutation-check failed without a survivor count (exit {returncode})\n\n{fenced(tail(output))}")
|
|
61
|
+
return {"ok": not returncode, "output": output, "modules": modules, "failed": failed}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def ratchet_lines(ratchet: dict) -> list[str]:
|
|
65
|
+
return [line.strip() for line in ratchet["output"].splitlines() if _FAILED.search(line) or _PASSED.search(line)]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Repo:
|
|
69
|
+
def __init__(self, cwd: Path):
|
|
70
|
+
proc = subprocess.run(["git", "rev-parse", "--show-toplevel"], cwd=cwd, capture_output=True, text=True)
|
|
71
|
+
if proc.returncode:
|
|
72
|
+
raise BadInput(f"not inside a git repository: {cwd}")
|
|
73
|
+
self.root = Path(proc.stdout.strip())
|
|
74
|
+
self.backend = self.root / "backend"
|
|
75
|
+
self.written: dict = {} # path → the bytes this run last wrote there (None: the run deleted it)
|
|
76
|
+
self.stranded = "" # why leave_on stayed off the base branch, when it did
|
|
77
|
+
|
|
78
|
+
# --- git -------------------------------------------------------------------------
|
|
79
|
+
def run_git(self, *args: str) -> subprocess.CompletedProcess:
|
|
80
|
+
return subprocess.run(["git", "-c", "core.quotePath=false", *args], cwd=self.root,
|
|
81
|
+
capture_output=True, text=True)
|
|
82
|
+
|
|
83
|
+
def git(self, *args: str) -> str:
|
|
84
|
+
proc = self.run_git(*args)
|
|
85
|
+
if proc.returncode:
|
|
86
|
+
raise Fail(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
|
|
87
|
+
return proc.stdout
|
|
88
|
+
|
|
89
|
+
def git_dir(self) -> Path:
|
|
90
|
+
return Path(self.git("rev-parse", "--absolute-git-dir").strip())
|
|
91
|
+
|
|
92
|
+
def head(self) -> str:
|
|
93
|
+
return self.git("rev-parse", "HEAD").strip()
|
|
94
|
+
|
|
95
|
+
def ref(self, branch: str) -> str:
|
|
96
|
+
return self.git("rev-parse", f"refs/heads/{branch}").strip()
|
|
97
|
+
|
|
98
|
+
def branch(self) -> str:
|
|
99
|
+
proc = self.run_git("symbolic-ref", "--short", "-q", "HEAD")
|
|
100
|
+
if proc.returncode:
|
|
101
|
+
raise BadInput("HEAD is detached: check out the base branch first")
|
|
102
|
+
return proc.stdout.strip()
|
|
103
|
+
|
|
104
|
+
def dirty(self) -> list[str]:
|
|
105
|
+
"""Paths with tracked changes, from `git status --porcelain -z` (unquoted; a rename gives its new path)."""
|
|
106
|
+
fields, paths = self.git("status", "--porcelain", "-z", "--untracked-files=no").split("\0"), []
|
|
107
|
+
while fields and fields[0]:
|
|
108
|
+
entry = fields.pop(0)
|
|
109
|
+
paths.append(entry[3:])
|
|
110
|
+
if entry[0] in "RC":
|
|
111
|
+
fields.pop(0)
|
|
112
|
+
return paths
|
|
113
|
+
|
|
114
|
+
def require_clean(self) -> None:
|
|
115
|
+
dirty = self.dirty()
|
|
116
|
+
if dirty:
|
|
117
|
+
raise BadInput("tracked changes in the working tree, commit or discard them first:\n"
|
|
118
|
+
+ "\n".join(dirty))
|
|
119
|
+
|
|
120
|
+
def require_at(self, base: dict, command: str) -> None:
|
|
121
|
+
"""A clean tree on the collected branch at the collected commit."""
|
|
122
|
+
self.require_clean()
|
|
123
|
+
if self.branch() != base["branch"] or self.head() != base["commit"]:
|
|
124
|
+
raise BadInput(f"{command} runs on {base['branch']} at the collected commit {base['commit']}; "
|
|
125
|
+
"check it out, or collect again after moving it")
|
|
126
|
+
|
|
127
|
+
def detach_at(self, base: dict, new_branch: str, command: str) -> None:
|
|
128
|
+
"""require_at, an unmoved base ref and a branch name still free, then HEAD detached at the base
|
|
129
|
+
commit: the base branch is never written."""
|
|
130
|
+
self.require_at(base, command)
|
|
131
|
+
self.require_new_branch(new_branch)
|
|
132
|
+
self.git("checkout", "-q", "--detach", base["commit"])
|
|
133
|
+
|
|
134
|
+
def require_new_branch(self, name: str) -> None:
|
|
135
|
+
local = self.run_git("rev-parse", "--verify", "-q", f"refs/heads/{name}").returncode == 0
|
|
136
|
+
remote = bool(self.git("ls-remote", "--heads", "origin", name).strip())
|
|
137
|
+
if local or remote:
|
|
138
|
+
raise BadInput(f"branch {name} already exists {'locally' if local else 'on origin'}")
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def test_path(rel: str) -> str:
|
|
142
|
+
"""The repo path of a backend-relative test path (`tests/x.py` → `backend/tests/x.py`)."""
|
|
143
|
+
return f"backend/{rel}"
|
|
144
|
+
|
|
145
|
+
def show_test(self, commit: str, rel: str) -> str | None:
|
|
146
|
+
return self.show(commit, self.test_path(rel))
|
|
147
|
+
|
|
148
|
+
def show(self, commit: str, path: str) -> str | None:
|
|
149
|
+
proc = self.run_git("show", f"{commit}:{path}")
|
|
150
|
+
return proc.stdout if proc.returncode == 0 else None
|
|
151
|
+
|
|
152
|
+
def push(self, branch: str) -> None:
|
|
153
|
+
if not PUSHABLE.match(branch):
|
|
154
|
+
raise Fail(f"refusing to push {branch}: only chore/test-prune-<date> and chore/test-strengthen-<date>")
|
|
155
|
+
self.git("push", "-q", "origin", f"refs/heads/{branch}:refs/heads/{branch}")
|
|
156
|
+
|
|
157
|
+
def open_pr(self, base: str, branch: str, title: str, body_file: Path) -> str:
|
|
158
|
+
proc = subprocess.run(["gh", "pr", "create", "--base", base, "--head", branch, "--title", title,
|
|
159
|
+
"--body-file", str(body_file)], cwd=self.root, capture_output=True, text=True)
|
|
160
|
+
if proc.returncode:
|
|
161
|
+
raise Fail(f"gh pr create failed: {proc.stderr.strip()}")
|
|
162
|
+
return proc.stdout.strip()
|
|
163
|
+
|
|
164
|
+
def read(self, path: str):
|
|
165
|
+
"""The bytes at path (relative to the repo root), None when there is no file."""
|
|
166
|
+
try:
|
|
167
|
+
return (self.root / path).read_bytes()
|
|
168
|
+
except FileNotFoundError:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
def unchanged(self, path: str, commit: str) -> bool:
|
|
172
|
+
"""Nobody else changed path: it holds what the run last wrote there, or its content at commit."""
|
|
173
|
+
return (path in self.written and self.read(path) == self.written[path]
|
|
174
|
+
or self.run_git("diff", "--quiet", commit, "--", path).returncode == 0)
|
|
175
|
+
|
|
176
|
+
def write(self, paths: dict, commit: str) -> None:
|
|
177
|
+
"""Write {path: text} for the run, but only over files nobody else changed since (see unchanged):
|
|
178
|
+
otherwise Fail naming them, writing nothing. A run needs a checkout of its own."""
|
|
179
|
+
changed = [path for path in paths if not self.unchanged(path, commit)]
|
|
180
|
+
if changed:
|
|
181
|
+
raise Fail(f"{', '.join(changed)} changed during the run, not by test-prune; left as it is "
|
|
182
|
+
"(a run needs a checkout of its own)")
|
|
183
|
+
for path, text in paths.items():
|
|
184
|
+
(self.root / path).write_text(text)
|
|
185
|
+
self.own(path)
|
|
186
|
+
|
|
187
|
+
def own(self, path: str) -> None:
|
|
188
|
+
"""Remember the current content of path (None when missing) as the run's own: leave_on may restore it."""
|
|
189
|
+
self.written[path] = self.read(path)
|
|
190
|
+
|
|
191
|
+
def leave_on(self, branch: str, commit: str) -> None:
|
|
192
|
+
"""Back on the base branch, undoing only the run's own edits (files still holding what the run
|
|
193
|
+
wrote). Any other tracked change is never discarded: HEAD then stays where it is and `stranded`
|
|
194
|
+
says why (the command then fails). The base ref must not move."""
|
|
195
|
+
dirty = self.dirty()
|
|
196
|
+
foreign = [path for path in dirty if path not in self.written or not self.unchanged(path, commit)]
|
|
197
|
+
if foreign:
|
|
198
|
+
self.stranded = (f"test-prune: staying at {self.head()[:12]}, not on {branch}: tracked changes "
|
|
199
|
+
f"test-prune did not make: {', '.join(foreign)}; keep or discard them, then "
|
|
200
|
+
f"`git checkout {branch}`")
|
|
201
|
+
else:
|
|
202
|
+
if dirty:
|
|
203
|
+
self.git("checkout", "-q", "HEAD", "--", *dirty)
|
|
204
|
+
self.git("checkout", "-q", branch)
|
|
205
|
+
if self.ref(branch) != commit:
|
|
206
|
+
raise Fail(f"guard: {branch} moved from {commit} during the run")
|
|
207
|
+
|
|
208
|
+
# --- make ------------------------------------------------------------------------
|
|
209
|
+
def make(self, target: str, *assigns: str) -> subprocess.CompletedProcess:
|
|
210
|
+
env = {k: v for k, v in os.environ.items() if k not in DROPPED_ENV}
|
|
211
|
+
print(f"make {target} {' '.join(assigns)}".rstrip(), flush=True)
|
|
212
|
+
return subprocess.run(["make", "-s", "--no-print-directory", "-C", str(self.root), target, *assigns],
|
|
213
|
+
env=env, capture_output=True, text=True)
|
|
214
|
+
|
|
215
|
+
def must_make(self, target: str, *assigns: str) -> str:
|
|
216
|
+
proc = self.make(target, *assigns)
|
|
217
|
+
if proc.returncode:
|
|
218
|
+
raise Fail(f"make {target} failed (exit {proc.returncode})\n\n{fenced(tail(proc.stdout + proc.stderr))}")
|
|
219
|
+
return proc.stdout
|
|
220
|
+
|
|
221
|
+
def cold_mutation(self) -> None:
|
|
222
|
+
"""rm -rf backend/mutants, then make mutation: no verdict survives from an earlier run."""
|
|
223
|
+
shutil.rmtree(self.backend / "mutants", ignore_errors=True)
|
|
224
|
+
self.must_make("mutation")
|
|
225
|
+
|
|
226
|
+
def cold_ratchet(self) -> dict:
|
|
227
|
+
"""A cold mutation run, then make mutation-check."""
|
|
228
|
+
self.cold_mutation()
|
|
229
|
+
proc = self.make("mutation-check")
|
|
230
|
+
return parse_ratchet(proc.stdout, proc.returncode)
|
|
231
|
+
|
|
232
|
+
def mutant_codes(self) -> dict[str, dict[str, int]]:
|
|
233
|
+
"""{module: {mutant id: exit code}} from backend/mutants/**/*.meta."""
|
|
234
|
+
mutants = self.backend / "mutants"
|
|
235
|
+
codes = {}
|
|
236
|
+
for meta in sorted(mutants.rglob("*.meta")):
|
|
237
|
+
keys = json.loads(meta.read_text()).get("exit_code_by_key", {})
|
|
238
|
+
codes[meta.relative_to(mutants).as_posix()[:-len(".meta")]] = {
|
|
239
|
+
key.rpartition(".")[2]: code for key, code in keys.items()}
|
|
240
|
+
return codes
|
|
241
|
+
|
|
242
|
+
def collected_tests(self) -> list[str]:
|
|
243
|
+
text = (self.backend / ".mutation" / "tests.txt").read_text()
|
|
244
|
+
return [line.strip() for line in text.splitlines() if "::" in line]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class State:
|
|
248
|
+
"""The run state directory, and the summaries a command emits."""
|
|
249
|
+
|
|
250
|
+
FILES = ("base.json", "status.json", "fixer-input.json", "review-input.json", "batch.json", "strengthen.json",
|
|
251
|
+
"strengthen-input.json", "strengthen-proof.json", "prune-pr.md", "prune-pr-full.md", "strengthen-pr.md",
|
|
252
|
+
"strengthen-pr-full.md", "prune-gate.log", "prune-gate-report.md")
|
|
253
|
+
MARKER = ".test-prune-state" # written by reset: the only proof a directory is a run's
|
|
254
|
+
|
|
255
|
+
def __init__(self, path: Path):
|
|
256
|
+
self.path = path
|
|
257
|
+
self.summaries: list = []
|
|
258
|
+
|
|
259
|
+
def _files(self) -> list:
|
|
260
|
+
return [self.path / name for name in self.FILES] + sorted(self.path.glob("summary-*.md"))
|
|
261
|
+
|
|
262
|
+
def owned(self) -> bool:
|
|
263
|
+
"""The directory is a run's to write: empty, or holding the marker reset writes."""
|
|
264
|
+
return self.path.is_dir() and (not any(self.path.iterdir()) or (self.path / self.MARKER).is_file())
|
|
265
|
+
|
|
266
|
+
def reset(self) -> None:
|
|
267
|
+
"""Start a new run: delete the files a run writes, never anything else, and refuse a non-empty
|
|
268
|
+
directory that is not a run's."""
|
|
269
|
+
if self.path.exists() and not self.owned():
|
|
270
|
+
raise BadInput(f"refusing to use {self.path} as the run state: it is not empty and was not made by "
|
|
271
|
+
f"test-prune (no {self.MARKER}); pass an empty or new --state")
|
|
272
|
+
for path in self._files():
|
|
273
|
+
if path.exists():
|
|
274
|
+
path.unlink()
|
|
275
|
+
self.path.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
(self.path / self.MARKER).write_text("test-prune run state; collect deletes only the files a run writes\n")
|
|
277
|
+
|
|
278
|
+
def emit(self, text: str) -> None:
|
|
279
|
+
"""Print a run summary; also append it to the GitHub job summary when there is one. Every summary is
|
|
280
|
+
kept in summaries, in order; one over LIMIT is cut, its full text kept in the state dir."""
|
|
281
|
+
text = capped(text, self.path / f"summary-{len(self.summaries) + 1}.md")
|
|
282
|
+
self.summaries.append(text)
|
|
283
|
+
print(text, flush=True)
|
|
284
|
+
summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
285
|
+
if summary:
|
|
286
|
+
with open(summary, "a") as f:
|
|
287
|
+
f.write(text.rstrip("\n") + "\n\n")
|
|
288
|
+
|
|
289
|
+
def load(self, name: str) -> dict:
|
|
290
|
+
path = self.path / name
|
|
291
|
+
if not path.exists():
|
|
292
|
+
raise BadInput(f"{path} is missing: run the earlier test-prune commands first")
|
|
293
|
+
return json.loads(path.read_text())
|
|
294
|
+
|
|
295
|
+
def save(self, name: str, data: dict) -> Path:
|
|
296
|
+
return self.save_text(name, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
297
|
+
|
|
298
|
+
def save_body(self, name: str, text: str) -> tuple[Path, str]:
|
|
299
|
+
"""A PR body, capped to fit GitHub with room for the summary around it; the full text, when cut,
|
|
300
|
+
goes to <name>-full.md."""
|
|
301
|
+
body = capped(text, self.path / name.replace(".md", "-full.md"), LIMIT - 1000)
|
|
302
|
+
return self.save_text(name, body), body
|
|
303
|
+
|
|
304
|
+
def save_text(self, name: str, text: str) -> Path:
|
|
305
|
+
path = self.path / name
|
|
306
|
+
path.write_text(text)
|
|
307
|
+
return path
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def read_agent_json(path: str, key: str) -> list:
|
|
311
|
+
try:
|
|
312
|
+
data = json.loads(Path(path).read_text())
|
|
313
|
+
except (OSError, ValueError) as exc:
|
|
314
|
+
raise BadInput(f"cannot read {path}: {exc}")
|
|
315
|
+
if not isinstance(data, dict) or not isinstance(data.get(key), list):
|
|
316
|
+
raise BadInput(f"{path}: expected an object with a list under {key!r}")
|
|
317
|
+
if not all(isinstance(entry, dict) for entry in data[key]):
|
|
318
|
+
raise BadInput(f"{path}: every entry of {key!r} must be an object")
|
|
319
|
+
return data[key]
|