@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.
Files changed (27) hide show
  1. package/package.json +2 -2
  2. package/skills/test-prune/SKILL.md +156 -0
  3. package/skills/test-prune/prune.py +202 -0
  4. package/skills/test-prune/prune_repo.py +319 -0
  5. package/skills/test-prune/prune_review.py +363 -0
  6. package/skills/test-prune/prune_runs.py +240 -0
  7. package/skills/test-prune/prune_source.py +237 -0
  8. package/skills/test-prune/prune_strengthen.py +301 -0
  9. package/skills/test-prune/tests/conftest.py +15 -0
  10. package/skills/test-prune/tests/prune_harness.py +205 -0
  11. package/skills/test-prune/tests/test_prune_guards.py +871 -0
  12. package/skills/test-prune/tests/test_prune_inputs.py +97 -0
  13. package/skills/test-prune/tests/test_prune_review.py +113 -0
  14. package/skills/test-prune/tests/test_prune_source.py +84 -0
  15. package/skills/test-prune/tests/test_prune_strengthen.py +183 -0
  16. package/skills/test-prune/tests/test_prune_unattended.py +225 -0
  17. package/skills/test-prune/tests/testdata/gh +16 -0
  18. package/skills/test-prune/tests/testdata/repo/Makefile +25 -0
  19. package/skills/test-prune/tests/testdata/repo/backend/mutation_baseline.json +16 -0
  20. package/skills/test-prune/tests/testdata/repo/backend/services/concentration.py +2 -0
  21. package/skills/test-prune/tests/testdata/repo/backend/services/rebalancer.py +11 -0
  22. package/skills/test-prune/tests/testdata/repo/backend/tests/conftest.py +6 -0
  23. package/skills/test-prune/tests/testdata/repo/backend/tests/test_concentration.py +73 -0
  24. package/skills/test-prune/tests/testdata/repo/backend/tests/test_rebalancer.py +77 -0
  25. package/skills/test-prune/tests/testdata/repo/fake_engine.py +289 -0
  26. package/skills/test-prune/tests/testdata/repo/kill_map.json +131 -0
  27. package/skills-manifest.json +7 -1
@@ -0,0 +1,289 @@
1
+ """Fake mutmut/pytest/gate engine behind the fixture repo's make targets.
2
+
3
+ Model: kill_map.json maps {module: {mutant id: [test ids that kill it]}}. A mutant is
4
+ killed iff one of its killers exists in the working tree (file::[Class::]func, parametrize
5
+ brackets ignored). Output formats copy the target repo's scripts (check_mutation.py,
6
+ test_candidates.py, check_prune_pr.py). Logs go to --logs, outside the repo.
7
+
8
+ Env knobs: FAKE_SNEAK_EQUIVALENT=<module>:<mutant id> makes mutation-baseline add an
9
+ equivalent id; FAKE_GATE_FAIL=1 makes prune-gate fail at point 1; FAKE_TEST_FAIL=1 makes
10
+ `make test` fail.
11
+ """
12
+ import argparse
13
+ import ast
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ SURVIVING = {0, 5, 33}
22
+ KILLED = {1, 3}
23
+
24
+
25
+ def log(logs, name, entry):
26
+ with open(Path(logs) / f"{name}.log", "a") as f:
27
+ f.write(json.dumps(entry) + "\n")
28
+
29
+
30
+ def _param_ids(func):
31
+ for dec in func.decorator_list:
32
+ if not (isinstance(dec, ast.Call) and getattr(dec.func, "attr", "") == "parametrize"):
33
+ continue
34
+ for kw in dec.keywords:
35
+ if kw.arg == "ids":
36
+ return list(ast.literal_eval(kw.value))
37
+ return [str(v) for v in ast.literal_eval(dec.args[1])]
38
+ return None
39
+
40
+
41
+ def collect_source(rel_path, source):
42
+ """Test ids pytest would collect from one file, in file order."""
43
+ ids = []
44
+
45
+ def add(prefix, func):
46
+ if not func.name.startswith("test"):
47
+ return
48
+ params = _param_ids(func)
49
+ base = f"{prefix}::{func.name}"
50
+ ids.extend([f"{base}[{p}]" for p in params] if params else [base])
51
+
52
+ for node in ast.parse(source).body:
53
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
54
+ add(rel_path, node)
55
+ elif isinstance(node, ast.ClassDef) and node.name.startswith("Test"):
56
+ for inner in node.body:
57
+ if isinstance(inner, (ast.FunctionDef, ast.AsyncFunctionDef)):
58
+ add(f"{rel_path}::{node.name}", inner)
59
+ return ids
60
+
61
+
62
+ def collect_tree(backend):
63
+ ids = []
64
+ for path in sorted((backend / "tests").rglob("test_*.py")):
65
+ ids += collect_source(path.relative_to(backend).as_posix(), path.read_text())
66
+ return ids
67
+
68
+
69
+ def collect_ref(root, ref):
70
+ files = subprocess.run(["git", "ls-tree", "-r", "--name-only", ref, "backend/tests"], cwd=root,
71
+ check=True, capture_output=True, text=True).stdout.split()
72
+ ids = []
73
+ for name in sorted(files):
74
+ if Path(name).name.startswith("test_") and name.endswith(".py"):
75
+ source = subprocess.run(["git", "show", f"{ref}:{name}"], cwd=root, check=True,
76
+ capture_output=True, text=True).stdout
77
+ ids += collect_source(name[len("backend/"):], source)
78
+ return ids
79
+
80
+
81
+ def fn_hash(module, function):
82
+ return hashlib.md5(f"{module}:{function}".encode()).hexdigest()[:12]
83
+
84
+
85
+ def dotted(module):
86
+ return module[:-3].replace("/", ".")
87
+
88
+
89
+ def mutation(root, logs):
90
+ backend = root / "backend"
91
+ mutants = backend / "mutants"
92
+ state = "absent" if not mutants.exists() else ("empty" if not any(mutants.iterdir()) else "populated")
93
+ head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True).stdout.strip()
94
+ log(logs, "mutation", {"mutants": state, "head": head, "KEEP_CACHE": os.environ.get("KEEP_CACHE")})
95
+ present = {i.split("[")[0] for i in collect_tree(backend)}
96
+ kill_map = json.loads((root / "kill_map.json").read_text())
97
+ for module, by_mutant in kill_map.items():
98
+ codes, hashes = {}, {}
99
+ for mutant_id, killers in by_mutant.items():
100
+ killed = any(k.split("[")[0] in present for k in killers)
101
+ codes[f"{dotted(module)}.{mutant_id}"] = 1 if killed else 0
102
+ function = mutant_id.partition("__mutmut_")[0]
103
+ hashes[function] = fn_hash(module, function)
104
+ meta = mutants / f"{module}.meta"
105
+ meta.parent.mkdir(parents=True, exist_ok=True)
106
+ meta.write_text(json.dumps({"exit_code_by_key": codes, "hash_by_function_name": hashes}, indent=2))
107
+ print("done")
108
+ return 0
109
+
110
+
111
+ def read_results(backend, module, declared):
112
+ meta_path = backend / "mutants" / f"{module}.meta"
113
+ if not meta_path.exists():
114
+ return None
115
+ meta = json.loads(meta_path.read_text())
116
+ hashes = meta["hash_by_function_name"]
117
+ codes = {k.rpartition(".")[2]: c for k, c in meta["exit_code_by_key"].items()}
118
+ equivalent = {}
119
+ for mutant_id, digest in declared.items():
120
+ if mutant_id not in codes:
121
+ print(f" ! {module}: equivalent {mutant_id} no longer exists, dropped")
122
+ elif hashes.get(mutant_id.partition("__mutmut_")[0]) != digest:
123
+ print(f" ! {module}: equivalent {mutant_id} is stale (function changed), counts as a survivor")
124
+ else:
125
+ equivalent[mutant_id] = digest
126
+ killed = sum(1 for c in codes.values() if c in KILLED)
127
+ survivors = sorted(m for m, c in codes.items() if c in SURVIVING and m not in equivalent)
128
+ tested = len(codes) - len(equivalent)
129
+ score = round(100 * killed / tested, 2) if tested else 0.0
130
+ return {"killed": killed, "total": len(codes), "survivors": survivors, "equivalent": equivalent, "score": score}
131
+
132
+
133
+ def modules(root):
134
+ return list(json.loads((root / "kill_map.json").read_text()))
135
+
136
+
137
+ def mutation_check(root):
138
+ backend = root / "backend"
139
+ baseline = json.loads((backend / "mutation_baseline.json").read_text())
140
+ ok = True
141
+ for module in modules(root):
142
+ entry = baseline.get(module)
143
+ result = read_results(backend, module, (entry or {}).get("equivalent", {}))
144
+ if result is None:
145
+ print(f" ✗ {module}: no mutmut results, run 'make mutation'")
146
+ ok = False
147
+ continue
148
+ count = len(result["survivors"])
149
+ if entry is None:
150
+ print(f" • {module}: no baseline, run the update command ({count} survivors, score {result['score']:.2f})")
151
+ continue
152
+ allowed = entry["survivors"]
153
+ if count > allowed:
154
+ print(f" ✗ {module}: {count} survivors > baseline {allowed} (score {result['score']:.2f})")
155
+ for mutant_id in result["survivors"]:
156
+ print(f" {mutant_id}")
157
+ ok = False
158
+ continue
159
+ print(f" ✓ {module}: {count} survivors, baseline {allowed}, score {result['score']:.2f}")
160
+ if count < allowed:
161
+ print(" ↑ fewer survivors than the baseline, run 'make mutation-baseline' to lock it in")
162
+ print("Mutation ratchet PASSED." if ok else "Mutation ratchet FAILED.")
163
+ return 0 if ok else 1
164
+
165
+
166
+ def mutation_baseline(root, logs):
167
+ backend = root / "backend"
168
+ path = backend / "mutation_baseline.json"
169
+ baseline = json.loads(path.read_text())
170
+ sneak = os.environ.get("FAKE_SNEAK_EQUIVALENT")
171
+ log(logs, "mutation-baseline", {"sneak": sneak})
172
+ new = {}
173
+ for module in modules(root):
174
+ entry = baseline.get(module)
175
+ declared = dict((entry or {}).get("equivalent", {}))
176
+ if sneak and sneak.split(":")[0] == module:
177
+ mutant_id = sneak.split(":")[1]
178
+ declared[mutant_id] = fn_hash(module, mutant_id.partition("__mutmut_")[0])
179
+ result = read_results(backend, module, declared)
180
+ if result is None:
181
+ print(f" ! {module}: no mutmut results, baseline kept")
182
+ if entry is not None:
183
+ new[module] = entry
184
+ continue
185
+ new[module] = {"equivalent": dict(sorted(result["equivalent"].items())), "killed": result["killed"],
186
+ "score": result["score"], "survivors": len(result["survivors"]), "total": result["total"]}
187
+ print(f" {module}: {entry['survivors'] if entry else 'new'} → {len(result['survivors'])} survivors")
188
+ path.write_text(json.dumps(new, indent=2, sort_keys=True) + "\n")
189
+ return 0
190
+
191
+
192
+ def _function_name(mutant_id):
193
+ mangled = mutant_id.partition("__mutmut_")[0]
194
+ return mangled[2:] if mangled.startswith("x_") else ".".join(mangled.split("ǁ")[1:])
195
+
196
+
197
+ def test_candidates(root, logs, args):
198
+ backend = root / "backend"
199
+ log(logs, "test-candidates", {"args": args})
200
+ out = backend / ".mutation"
201
+ out.mkdir(exist_ok=True)
202
+ ids = collect_tree(backend)
203
+ out.joinpath("tests.txt").write_text("\n".join(ids) + f"\n\n{len(ids)} tests collected in 0.01s\n")
204
+ provided = Path(logs) / "candidates.json"
205
+ candidates = json.loads(provided.read_text()) if provided.exists() else []
206
+ if "--protected-only" in args:
207
+ candidates = [c for c in candidates if not c.get("unprotected")]
208
+ candidates = [{k: v for k, v in c.items() if k != "unprotected"} for c in candidates]
209
+ baseline = json.loads((backend / "mutation_baseline.json").read_text())
210
+ groups = {}
211
+ for module in modules(root):
212
+ result = read_results(backend, module, baseline.get(module, {}).get("equivalent", {}))
213
+ for mutant_id in result["survivors"] if result else []:
214
+ groups.setdefault((module, _function_name(mutant_id)), []).append(mutant_id)
215
+ ordered = sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[0]))
216
+ report = {"candidates": candidates, "no_measured_coverage": [],
217
+ "weak_spots": [{"module": m, "function": f, "mutant_ids": i} for (m, f), i in ordered]}
218
+ out.joinpath("test-candidates.json").write_text(json.dumps(report, indent=2) + "\n")
219
+ print(f"{len(candidates)} candidates, 0 without measured coverage, {len(ordered)} weak-spot functions"
220
+ f" → {out / 'test-candidates.json'}")
221
+ return 0
222
+
223
+
224
+ def prune_gate(root, logs, base, head):
225
+ log(logs, "prune-gate", {"base": base, "head": head})
226
+ if os.environ.get("FAKE_GATE_FAIL"):
227
+ print("Prune gate FAILED at point 1:")
228
+ print(" ✗ backend/tests/test_rebalancer.py: adds line 3: 'assert True'")
229
+ return 1
230
+ numstat = subprocess.run(["git", "diff", "--numstat", base, head], cwd=root, check=True,
231
+ capture_output=True, text=True).stdout
232
+ problems = []
233
+ for line in numstat.splitlines():
234
+ added, _, path = line.split("\t")
235
+ if not path.startswith("backend/tests/") or Path(path).name == "conftest.py":
236
+ problems.append(f"{path}: outside the deletable test files")
237
+ elif added != "0":
238
+ problems.append(f"{path}: adds {added} lines")
239
+ base_ids, head_ids = set(collect_ref(root, base)), set(collect_ref(root, head))
240
+ problems += [f"{t}: added test" for t in sorted(head_ids - base_ids)]
241
+ if problems:
242
+ print("Prune gate FAILED at point 1:")
243
+ for p in problems:
244
+ print(f" ✗ {p}")
245
+ return 1
246
+ deleted = sorted(base_ids - head_ids)
247
+ print(f"Prune gate PASSED: {len(deleted)} deleted tests, all protected by the ratchet")
248
+ for t in deleted:
249
+ print(f" ✓ {t}")
250
+ return 0
251
+
252
+
253
+ def run_tests(root, logs):
254
+ log(logs, "test", {})
255
+ if os.environ.get("FAKE_TEST_FAIL"):
256
+ print("1 failed")
257
+ return 1
258
+ for path in (root / "backend" / "tests").rglob("*.py"):
259
+ ast.parse(path.read_text())
260
+ print("passed")
261
+ return 0
262
+
263
+
264
+ def main():
265
+ parser = argparse.ArgumentParser()
266
+ parser.add_argument("--logs", required=True)
267
+ parser.add_argument("--root", type=Path, required=True)
268
+ parser.add_argument("target")
269
+ parser.add_argument("rest", nargs=argparse.REMAINDER)
270
+ a = parser.parse_args()
271
+ root = a.root
272
+ if a.target == "mutation":
273
+ return mutation(root, a.logs)
274
+ if a.target == "mutation-check":
275
+ return mutation_check(root)
276
+ if a.target == "mutation-baseline":
277
+ return mutation_baseline(root, a.logs)
278
+ if a.target == "test-candidates":
279
+ return test_candidates(root, a.logs, a.rest)
280
+ if a.target == "prune-gate":
281
+ return prune_gate(root, a.logs, *a.rest)
282
+ if a.target == "test":
283
+ return run_tests(root, a.logs)
284
+ log(a.logs, a.target, {})
285
+ return 0
286
+
287
+
288
+ if __name__ == "__main__":
289
+ sys.exit(main())
@@ -0,0 +1,131 @@
1
+ {
2
+ "services/concentration.py": {
3
+ "x_herfindahl__mutmut_1": [
4
+ "tests/test_concentration.py::test_hhi_reference",
5
+ "tests/test_concentration.py::test_hhi_case_01"
6
+ ],
7
+ "x_herfindahl__mutmut_2": [
8
+ "tests/test_concentration.py::test_hhi_reference",
9
+ "tests/test_concentration.py::test_hhi_case_01"
10
+ ],
11
+ "x_herfindahl__mutmut_3": [
12
+ "tests/test_concentration.py::test_hhi_reference",
13
+ "tests/test_concentration.py::test_hhi_case_01"
14
+ ],
15
+ "x_herfindahl__mutmut_4": [
16
+ "tests/test_concentration.py::test_hhi_reference",
17
+ "tests/test_concentration.py::test_hhi_case_01"
18
+ ],
19
+ "x_herfindahl__mutmut_5": [
20
+ "tests/test_concentration.py::test_hhi_reference",
21
+ "tests/test_concentration.py::test_hhi_case_01"
22
+ ],
23
+ "x_herfindahl__mutmut_6": [
24
+ "tests/test_concentration.py::test_hhi_reference",
25
+ "tests/test_concentration.py::test_hhi_case_01"
26
+ ],
27
+ "x_herfindahl__mutmut_7": [
28
+ "tests/test_concentration.py::test_hhi_reference",
29
+ "tests/test_concentration.py::test_hhi_case_01"
30
+ ],
31
+ "x_herfindahl__mutmut_8": [
32
+ "tests/test_concentration.py::test_hhi_reference",
33
+ "tests/test_concentration.py::test_hhi_case_01"
34
+ ],
35
+ "x_herfindahl__mutmut_9": [],
36
+ "x_herfindahl__mutmut_10": []
37
+ },
38
+ "services/rebalancer.py": {
39
+ "x_compute_trades__mutmut_1": [
40
+ "tests/test_rebalancer.py::test_zero_weight",
41
+ "tests/test_rebalancer.py::test_small_weight",
42
+ "tests/test_rebalancer.py::test_compute_trades_sums_to_zero"
43
+ ],
44
+ "x_compute_trades__mutmut_2": [
45
+ "tests/test_rebalancer.py::test_zero_weight",
46
+ "tests/test_rebalancer.py::test_small_weight",
47
+ "tests/test_rebalancer.py::test_compute_trades_sums_to_zero"
48
+ ],
49
+ "x_compute_trades__mutmut_3": [
50
+ "tests/test_rebalancer.py::test_zero_weight",
51
+ "tests/test_rebalancer.py::test_small_weight",
52
+ "tests/test_rebalancer.py::test_compute_trades_sums_to_zero"
53
+ ],
54
+ "x_compute_trades__mutmut_4": [
55
+ "tests/test_rebalancer.py::test_zero_weight",
56
+ "tests/test_rebalancer.py::test_small_weight",
57
+ "tests/test_rebalancer.py::test_compute_trades_sums_to_zero"
58
+ ],
59
+ "x_compute_trades__mutmut_5": [
60
+ "tests/test_rebalancer.py::test_zero_weight",
61
+ "tests/test_rebalancer.py::test_small_weight",
62
+ "tests/test_rebalancer.py::test_compute_trades_sums_to_zero"
63
+ ],
64
+ "x_compute_trades__mutmut_6": [
65
+ "tests/test_rebalancer.py::test_zero_weight",
66
+ "tests/test_rebalancer.py::test_small_weight",
67
+ "tests/test_rebalancer.py::test_compute_trades_sums_to_zero"
68
+ ],
69
+ "x_compute_trades__mutmut_7": [
70
+ "tests/test_rebalancer.py::test_compute_trades_rounding"
71
+ ],
72
+ "x_compute_trades__mutmut_8": [
73
+ "tests/test_rebalancer.py::test_small_weight"
74
+ ],
75
+ "x_compute_trades__mutmut_9": [
76
+ "tests/test_rebalancer.py::test_compute_trades_rounding"
77
+ ],
78
+ "x_apply_drift__mutmut_1": [
79
+ "tests/test_rebalancer.py::test_apply_drift_bounds",
80
+ "tests/test_rebalancer.py::TestDriftHelpers::test_drift_scales"
81
+ ],
82
+ "x_apply_drift__mutmut_2": [
83
+ "tests/test_rebalancer.py::test_apply_drift_new"
84
+ ],
85
+ "x_apply_drift__mutmut_3": [
86
+ "tests/test_rebalancer.py::test_apply_drift_bounds",
87
+ "tests/test_rebalancer.py::TestDriftHelpers::test_drift_scales"
88
+ ],
89
+ "x_apply_drift__mutmut_4": [],
90
+ "x_rebalance__mutmut_1": [],
91
+ "x_rebalance__mutmut_2": [],
92
+ "x_rebalance__mutmut_3": [],
93
+ "x_rebalance__mutmut_4": [],
94
+ "x_rebalance__mutmut_5": [],
95
+ "x_rebalance__mutmut_6": [],
96
+ "x_rebalance__mutmut_7": [],
97
+ "x_rebalance__mutmut_8": [],
98
+ "x_rebalance__mutmut_9": [],
99
+ "x_rebalance__mutmut_10": [],
100
+ "x_rebalance__mutmut_11": [],
101
+ "x_rebalance__mutmut_12": [],
102
+ "x_rebalance__mutmut_13": [],
103
+ "x_rebalance__mutmut_14": [],
104
+ "x_rebalance__mutmut_15": [],
105
+ "x_rebalance__mutmut_16": [],
106
+ "x_rebalance__mutmut_17": [
107
+ "tests/test_rebalancer.py::test_hold_when_on_target",
108
+ "tests/test_rebalancer.py::test_hold_when_within_band",
109
+ "tests/test_rebalancer.py::test_sell_only_rebalance"
110
+ ],
111
+ "x_rebalance__mutmut_18": [
112
+ "tests/test_rebalancer.py::test_hold_when_on_target",
113
+ "tests/test_rebalancer.py::test_hold_when_within_band",
114
+ "tests/test_rebalancer.py::test_sell_only_rebalance"
115
+ ],
116
+ "x_rebalance__mutmut_19": [
117
+ "tests/test_rebalancer.py::test_hold_when_on_target",
118
+ "tests/test_rebalancer.py::test_hold_when_within_band",
119
+ "tests/test_rebalancer.py::test_sell_only_rebalance"
120
+ ],
121
+ "x_rebalance__mutmut_20": [
122
+ "tests/test_rebalancer.py::test_sell_only_rebalance"
123
+ ],
124
+ "x_rebalance__mutmut_21": [
125
+ "tests/test_rebalancer.py::test_sell_only_rebalance"
126
+ ],
127
+ "x_rebalance__mutmut_22": [
128
+ "tests/test_rebalancer.py::test_sell_only_rebalance"
129
+ ]
130
+ }
131
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-19T09:42:49.911Z",
3
+ "generatedAt": "2026-10-19T10:48:11.958Z",
4
4
  "skills": [
5
5
  {
6
6
  "name": "backlog",
@@ -80,6 +80,12 @@
80
80
  "hash": "sha256-e8204e4c6f05e2721a20d9ac3f72b8ff84a2854647688bb9bf057312b53a59f4",
81
81
  "description": "One interactive HTML page for a plan review or a questionnaire, built from a fixed template. Use when a pipeline session must deliver a plan review or a questionnaire as an interactive page the console shows."
82
82
  },
83
+ {
84
+ "name": "test-prune",
85
+ "version": "0.1.0",
86
+ "hash": "sha256-84743bfc41613291debeacd1ef47a1d3982df7635efa39fc727f5525659761cd",
87
+ "description": "Mutation-guided test pruning: a fixer proposes deleting redundant tests and strengthening weak ones, an independent reviewer accepts or rejects each proposal, and prune.py applies the accepted batch behind a cold mutation run and the ratchet. Use when the user says \"prune tests\", \"test-prune\", \"mutation test pruning\" or \"strengthen weak tests\", or when a scheduled job runs it with `--unattended`."
88
+ },
83
89
  {
84
90
  "name": "to-openspec",
85
91
  "version": "0.1.3",