@matt82198/aesop 0.1.0 → 0.3.1

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 (130) hide show
  1. package/CHANGELOG.md +117 -2
  2. package/README.md +59 -218
  3. package/bin/cli.js +168 -41
  4. package/daemons/run-watchdog.sh +16 -4
  5. package/daemons/selfheal.sh +231 -0
  6. package/docs/ANY-REPO.md +427 -0
  7. package/docs/CONTRIBUTING.md +72 -0
  8. package/docs/DEMO.md +334 -0
  9. package/docs/HOOK-INSTALL.md +15 -56
  10. package/docs/INSTALL.md +74 -4
  11. package/docs/PORTING.md +166 -0
  12. package/docs/README.md +33 -3
  13. package/docs/TEAM-STATE.md +372 -0
  14. package/docs/reproduce.md +33 -3
  15. package/driver/CLAUDE.md +150 -0
  16. package/driver/README.md +383 -0
  17. package/driver/aesop.config.example.json +80 -0
  18. package/driver/agent_driver.py +355 -0
  19. package/driver/backend_config.py +253 -0
  20. package/driver/claude_code_driver.py +198 -0
  21. package/driver/codex_driver.py +673 -0
  22. package/driver/openai_compatible_driver.py +249 -0
  23. package/driver/openai_transport.py +179 -0
  24. package/driver/verification_policy.py +75 -0
  25. package/driver/wave_bridge.py +254 -0
  26. package/driver/wave_loop.py +1408 -0
  27. package/driver/wave_scheduler.py +890 -0
  28. package/hooks/pre-push-policy.sh +131 -33
  29. package/mcp/server.mjs +320 -4
  30. package/monitor/collect-signals.mjs +69 -0
  31. package/package.json +22 -14
  32. package/skills/CLAUDE.md +132 -2
  33. package/skills/buildsystem/SKILL.md +330 -0
  34. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  35. package/skills/fleet/SKILL.md +113 -0
  36. package/skills/power/SKILL.md +246 -131
  37. package/state_store/__init__.py +4 -2
  38. package/state_store/api.py +19 -4
  39. package/state_store/coordination.py +209 -0
  40. package/state_store/identity.py +51 -0
  41. package/state_store/projections.py +63 -0
  42. package/state_store/read_api.py +156 -0
  43. package/state_store/store.py +185 -73
  44. package/state_store/write_api.py +462 -0
  45. package/templates/wave-presets/data.json +65 -0
  46. package/templates/wave-presets/library.json +65 -0
  47. package/templates/wave-presets/saas.json +64 -0
  48. package/tools/audit_report.py +388 -0
  49. package/tools/bench_runner.py +100 -3
  50. package/tools/ci_merge_wait.py +256 -35
  51. package/tools/ci_workflow_lint.py +430 -0
  52. package/tools/claudemd_drift.py +394 -0
  53. package/tools/claudemd_lint.py +359 -0
  54. package/tools/common.py +39 -3
  55. package/tools/cost_ceiling.py +166 -43
  56. package/tools/cost_econ.py +480 -0
  57. package/tools/cost_projection.py +559 -0
  58. package/tools/crossos_drift.py +394 -0
  59. package/tools/defect_escape.py +252 -0
  60. package/tools/doctor.js +1 -1
  61. package/tools/eod_sweep.py +188 -26
  62. package/tools/fleet.js +260 -0
  63. package/tools/fleet_ledger.py +209 -7
  64. package/tools/git_identity_check.py +315 -0
  65. package/tools/health-score.js +40 -0
  66. package/tools/health_score.py +361 -0
  67. package/tools/metrics_gate.py +13 -4
  68. package/tools/mutation_test.py +523 -0
  69. package/tools/portability_check.py +206 -0
  70. package/tools/proposals.mjs +47 -2
  71. package/tools/reconcile.py +7 -4
  72. package/tools/reproduce.js +405 -0
  73. package/tools/secret_scan.py +207 -65
  74. package/tools/self_stats.py +20 -0
  75. package/tools/stall_check.py +247 -16
  76. package/tools/stateapi_lint.py +325 -0
  77. package/tools/test_battery.py +173 -0
  78. package/tools/transcript_digest.py +380 -0
  79. package/tools/verify_activity_filter.py +437 -0
  80. package/tools/verify_agent_inspector.py +2 -0
  81. package/tools/verify_cost_panel.py +345 -0
  82. package/tools/verify_dash.py +2 -0
  83. package/tools/verify_dispatch_panel.py +301 -0
  84. package/tools/verify_failure_drilldown.py +188 -0
  85. package/tools/verify_prboard.py +2 -0
  86. package/tools/verify_scorecards.py +281 -0
  87. package/tools/verify_submit_encoding.py +2 -0
  88. package/tools/verify_ui_trio.py +409 -0
  89. package/tools/verify_wave_telemetry.py +268 -0
  90. package/tools/wave_backlog_analyzer.py +490 -0
  91. package/tools/wave_ledger_hook.py +150 -0
  92. package/tools/wave_preflight.py +779 -0
  93. package/tools/wave_resume.py +215 -0
  94. package/tools/wave_templates.py +340 -0
  95. package/ui/agents.py +68 -14
  96. package/ui/collectors.py +81 -55
  97. package/ui/config.py +7 -2
  98. package/ui/cost.py +231 -12
  99. package/ui/handler.py +383 -55
  100. package/ui/quality_scorecard.py +232 -0
  101. package/ui/sse.py +3 -3
  102. package/ui/wave_audit_tail.py +213 -0
  103. package/ui/wave_dispatch.py +280 -0
  104. package/ui/wave_failure.py +288 -0
  105. package/ui/wave_gantt.py +152 -0
  106. package/ui/wave_reasoning_tail.py +176 -0
  107. package/ui/wave_telemetry.py +383 -0
  108. package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
  109. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  110. package/ui/web/dist/index.html +2 -2
  111. package/bin/CLAUDE.md +0 -76
  112. package/daemons/CLAUDE.md +0 -36
  113. package/dash/CLAUDE.md +0 -32
  114. package/docs/archive/README.md +0 -3
  115. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  116. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  117. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  118. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  119. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  120. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  121. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  122. package/hooks/CLAUDE.md +0 -89
  123. package/mcp/CLAUDE.md +0 -213
  124. package/monitor/CLAUDE.md +0 -40
  125. package/scan/CLAUDE.md +0 -30
  126. package/state_store/CLAUDE.md +0 -39
  127. package/tools/CLAUDE.md +0 -79
  128. package/ui/CLAUDE.md +0 -127
  129. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  130. package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
@@ -0,0 +1,523 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Mutation testing tool — measures whether a module's tests actually catch bugs.
4
+
5
+ This tool applies small source-code mutations (copy, never mutate original) to
6
+ a target module and runs its test suite against each mutation. Tests that pass
7
+ despite a mutation indicate a gap in test coverage (weak tests). Survived
8
+ mutations are reported as weak spots the tests don't cover.
9
+
10
+ Why: green test suites often just mean 'tests agree with the code', not
11
+ 'tests catch bugs'. Mutation testing reveals test quality gaps.
12
+
13
+ Mutations applied:
14
+ - Flip comparison operators (== <-> !=, < <-> >=, > <-> <=)
15
+ - Replace numeric literals with +1
16
+ - Replace boolean returns with negation
17
+ - Replace 'and' <-> 'or'
18
+ - Replace 'is None' <-> 'is not None'
19
+
20
+ Configuration (aesop.config.json):
21
+ [none — no configuration needed]
22
+
23
+ API:
24
+ run(target_module_path, test_module_path) -> dict
25
+ Returns {"killed": int, "survived": int, "mutations": [...]}
26
+ mutations is a list of {"file": str, "line": int, "original": str, "mutated": str}
27
+
28
+ CLI:
29
+ python tools/mutation_test.py --target module.py --test test_module.py [--json]
30
+ Exit 0 always (advisory, not gated). --json outputs JSON to stdout.
31
+ """
32
+
33
+ import argparse
34
+ import ast
35
+ import copy
36
+ import importlib.util
37
+ import json
38
+ import os
39
+ import subprocess
40
+ import sys
41
+ import tempfile
42
+ import shutil
43
+ from pathlib import Path
44
+ from typing import Any, Dict, List, Tuple, Optional
45
+
46
+
47
+ class MutationGenerator(ast.NodeTransformer):
48
+ """AST transformer that generates mutations of a Python module."""
49
+
50
+ def __init__(self):
51
+ self.mutations: List[Tuple[int, str, str]] = [] # (line, original, mutated)
52
+ self.current_mutation_idx = 0
53
+ self.target_mutation_idx = -1 # -1 means no mutation, just record
54
+
55
+ def record_mutation(self, line: int, original: str, mutated: str) -> None:
56
+ """Record a mutation and track mutation index."""
57
+ self.mutations.append((line, original, mutated))
58
+
59
+ def visit_Compare(self, node: ast.Compare) -> Any:
60
+ """Mutate comparison operators: == <-> !=, < <-> >=, etc."""
61
+ self.generic_visit(node)
62
+
63
+ # Map of operator mutations
64
+ op_map = {
65
+ ast.Eq: (ast.NotEq, "==", "!="),
66
+ ast.NotEq: (ast.Eq, "!=", "=="),
67
+ ast.Lt: (ast.GtE, "<", ">="),
68
+ ast.Gt: (ast.LtE, ">", "<="),
69
+ ast.LtE: (ast.Gt, "<=", ">"),
70
+ ast.GtE: (ast.Lt, ">=", "<"),
71
+ }
72
+
73
+ for i, op in enumerate(node.ops):
74
+ if type(op) in op_map:
75
+ mutated_op, orig_str, mut_str = op_map[type(op)]
76
+ self.record_mutation(node.lineno, orig_str, mut_str)
77
+
78
+ if self.current_mutation_idx == len(self.mutations) - 1 and self.target_mutation_idx == self.current_mutation_idx:
79
+ node.ops[i] = mutated_op()
80
+
81
+ self.current_mutation_idx += 1
82
+
83
+ return node
84
+
85
+ def visit_Constant(self, node: ast.Constant) -> Any:
86
+ """Mutate numeric literals: n -> n+1."""
87
+ self.generic_visit(node)
88
+
89
+ if isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
90
+ mutated_val = node.value + 1
91
+ self.record_mutation(node.lineno, str(node.value), str(mutated_val))
92
+
93
+ if self.current_mutation_idx == len(self.mutations) - 1 and self.target_mutation_idx == self.current_mutation_idx:
94
+ node.value = mutated_val
95
+
96
+ self.current_mutation_idx += 1
97
+
98
+ return node
99
+
100
+ def visit_Return(self, node: ast.Return) -> Any:
101
+ """Mutate boolean returns: True -> False, False -> True."""
102
+ self.generic_visit(node)
103
+
104
+ if node.value and isinstance(node.value, ast.Constant):
105
+ if isinstance(node.value.value, bool):
106
+ orig_val = node.value.value
107
+ mut_val = not orig_val
108
+ self.record_mutation(node.lineno, str(orig_val), str(mut_val))
109
+
110
+ if self.current_mutation_idx == len(self.mutations) - 1 and self.target_mutation_idx == self.current_mutation_idx:
111
+ node.value.value = mut_val
112
+
113
+ self.current_mutation_idx += 1
114
+
115
+ return node
116
+
117
+ def visit_BoolOp(self, node: ast.BoolOp) -> Any:
118
+ """Mutate boolean operators: and <-> or."""
119
+ self.generic_visit(node)
120
+
121
+ if isinstance(node.op, (ast.And, ast.Or)):
122
+ is_and = isinstance(node.op, ast.And)
123
+ orig_str = "and" if is_and else "or"
124
+ mut_str = "or" if is_and else "and"
125
+ self.record_mutation(node.lineno, orig_str, mut_str)
126
+
127
+ if self.current_mutation_idx == len(self.mutations) - 1 and self.target_mutation_idx == self.current_mutation_idx:
128
+ node.op = ast.Or() if is_and else ast.And()
129
+
130
+ self.current_mutation_idx += 1
131
+
132
+ return node
133
+
134
+ def visit_UnaryOp(self, node: ast.UnaryOp) -> Any:
135
+ """Mutate 'not' in returns to None checks."""
136
+ self.generic_visit(node)
137
+ return node
138
+
139
+
140
+ def extract_mutations(source: str) -> List[Tuple[int, str, str]]:
141
+ """Parse source and extract all possible mutations."""
142
+ try:
143
+ tree = ast.parse(source)
144
+ except SyntaxError:
145
+ return []
146
+
147
+ transformer = MutationGenerator()
148
+ transformer.generic_visit(tree)
149
+ return transformer.mutations
150
+
151
+
152
+ def apply_mutation(source: str, mutation_idx: int) -> Optional[str]:
153
+ """Apply a specific mutation to source code by index.
154
+
155
+ Returns mutated source, or None if mutation_idx is out of range.
156
+ """
157
+ try:
158
+ tree = ast.parse(source)
159
+ except SyntaxError:
160
+ return None
161
+
162
+ mutations = extract_mutations(source)
163
+ if mutation_idx < 0 or mutation_idx >= len(mutations):
164
+ return None
165
+
166
+ # Re-parse and apply the specific mutation
167
+ tree = ast.parse(source)
168
+ transformer = MutationGenerator()
169
+ transformer.target_mutation_idx = mutation_idx
170
+ new_tree = transformer.visit(tree)
171
+
172
+ try:
173
+ return ast.unparse(new_tree)
174
+ except Exception:
175
+ # If unparsing fails, return None
176
+ return None
177
+
178
+
179
+ def _pytest_available() -> bool:
180
+ """Return True if pytest is importable by the current interpreter.
181
+
182
+ We must decide the runner up-front rather than relying on a subprocess
183
+ exception: ``python -m pytest`` with pytest absent exits with code 1
184
+ ("No module named pytest") and does NOT raise FileNotFoundError, so an
185
+ ``except FileNotFoundError`` fallback would never fire — every test run
186
+ would look like a failure (exit 1) and every mutation would be counted
187
+ as 'killed', reporting 0 survivors regardless of test quality.
188
+ """
189
+ try:
190
+ return importlib.util.find_spec("pytest") is not None
191
+ except Exception:
192
+ return False
193
+
194
+
195
+ def run_tests(test_module_path: str, work_dir: str, timeout: int = 30) -> Tuple[int, str]:
196
+ """Run a test module in a subprocess against the code in ``work_dir``.
197
+
198
+ The (mutated) target copy lives in ``work_dir``; we force that directory
199
+ onto PYTHONPATH and run with cwd=work_dir so the test genuinely imports
200
+ the MUTATED copy on every platform, regardless of any sys.path juggling
201
+ the test module itself does. Prefers pytest when available, else falls
202
+ back to the always-present stdlib unittest.
203
+
204
+ Returns (exit_code, stdout+stderr).
205
+ """
206
+ # Deterministically make the mutated copy importable (Linux + Windows).
207
+ env = dict(os.environ)
208
+ existing = env.get("PYTHONPATH", "")
209
+ env["PYTHONPATH"] = work_dir + (os.pathsep + existing if existing else "")
210
+
211
+ # Convert absolute path to relative path from work_dir.
212
+ # The test file might be in a subdirectory (e.g., work_dir/ui/test.py)
213
+ test_path = Path(test_module_path).resolve()
214
+ work_path = Path(work_dir).resolve()
215
+ try:
216
+ rel_test_path = test_path.relative_to(work_path)
217
+ except ValueError:
218
+ # If relative_to fails, just use the filename
219
+ rel_test_path = Path(test_path.name)
220
+
221
+ if _pytest_available():
222
+ # pytest can take a relative path to the test file
223
+ cmd = [sys.executable, "-m", "pytest", "-xvs", str(rel_test_path)]
224
+ else:
225
+ # unittest needs a module name. If test is in a package dir,
226
+ # construct the module path (e.g., ui.test_fixture_sibling)
227
+ if rel_test_path.parent != Path("."):
228
+ # Test is in a subdirectory, construct module path
229
+ module_parts = list(rel_test_path.parent.parts) + [rel_test_path.stem]
230
+ module_name = ".".join(module_parts)
231
+ else:
232
+ module_name = rel_test_path.stem
233
+ cmd = [sys.executable, "-m", "unittest", "-v", module_name]
234
+
235
+ try:
236
+ result = subprocess.run(
237
+ cmd,
238
+ cwd=work_dir,
239
+ capture_output=True,
240
+ text=True,
241
+ timeout=timeout,
242
+ env=env,
243
+ )
244
+ return result.returncode, result.stdout + result.stderr
245
+ except subprocess.TimeoutExpired:
246
+ return 124, "Test timeout (>{}s)".format(timeout)
247
+
248
+
249
+ def run(target_module_path: str, test_module_path: str) -> Dict[str, Any]:
250
+ """Run mutation testing on target_module_path using test_module_path.
251
+
252
+ Returns {
253
+ "killed": int,
254
+ "survived": int,
255
+ "mutations": [
256
+ {"file": str, "line": int, "original": str, "mutated": str},
257
+ ...
258
+ ]
259
+ }
260
+ """
261
+ target_path = Path(target_module_path).resolve()
262
+ test_path = Path(test_module_path).resolve()
263
+
264
+ if not target_path.exists():
265
+ return {
266
+ "killed": 0,
267
+ "survived": 0,
268
+ "mutations": [],
269
+ "error": f"target module not found: {target_module_path}",
270
+ }
271
+
272
+ if not test_path.exists():
273
+ return {
274
+ "killed": 0,
275
+ "survived": 0,
276
+ "mutations": [],
277
+ "error": f"test module not found: {test_module_path}",
278
+ }
279
+
280
+ # Read target source
281
+ try:
282
+ source = target_path.read_text(encoding="utf-8")
283
+ except Exception as e:
284
+ return {
285
+ "killed": 0,
286
+ "survived": 0,
287
+ "mutations": [],
288
+ "error": f"failed to read target: {e}",
289
+ }
290
+
291
+ # Extract mutations
292
+ mutations = extract_mutations(source)
293
+ if not mutations:
294
+ return {
295
+ "killed": 0,
296
+ "survived": 0,
297
+ "mutations": [],
298
+ }
299
+
300
+ # Create temporary work directory
301
+ with tempfile.TemporaryDirectory() as tmpdir:
302
+ tmpdir = Path(tmpdir)
303
+
304
+ # Detect target package structure and recreate it in tmpdir.
305
+ # If target is in driver/wave_loop.py, we create driver/ in tmpdir.
306
+ # This is critical for "from driver import X" imports to work.
307
+ target_dir = target_path.parent
308
+
309
+ # Determine if target's directory is a package (has __init__.py or is a known package dir).
310
+ # Known package directories: driver, ui, tools, mcp, daemons, skills, etc.
311
+ # NOT packages: tests, fixtures, bench (these contain test/fixture files)
312
+ target_package_name = None
313
+ repo_root = None
314
+
315
+ if (target_dir / "__init__.py").exists():
316
+ # Target is in a real package directory (e.g., driver/, ui/)
317
+ target_package_name = target_dir.name
318
+ repo_root = target_dir.parent
319
+ else:
320
+ # Check if target_dir name suggests it's a package directory
321
+ package_dir_names = {"driver", "ui", "tools", "mcp", "daemons", "skills"}
322
+ non_package_dir_names = {"tests", "fixtures", "bench"}
323
+
324
+ if target_dir.name in non_package_dir_names:
325
+ # Not a package directory, treat as flat layout
326
+ repo_root = target_path.resolve()
327
+ while repo_root.parent != repo_root and not (repo_root / ".git").exists():
328
+ repo_root = repo_root.parent
329
+ if not (repo_root / ".git").exists():
330
+ repo_root = target_dir
331
+ elif target_dir.name in package_dir_names:
332
+ # Treat as package directory
333
+ target_package_name = target_dir.name
334
+ repo_root = target_dir.parent
335
+ else:
336
+ # Unknown directory, assume flat layout
337
+ repo_root = target_path.resolve()
338
+ while repo_root.parent != repo_root and not (repo_root / ".git").exists():
339
+ repo_root = repo_root.parent
340
+ if not (repo_root / ".git").exists():
341
+ repo_root = target_dir
342
+
343
+ # Create package structure in tmpdir
344
+ if target_package_name:
345
+ pkg_dir = tmpdir / target_package_name
346
+ pkg_dir.mkdir(parents=True, exist_ok=True)
347
+ # Create __init__.py to make it a proper package
348
+ (pkg_dir / "__init__.py").touch()
349
+ temp_target = pkg_dir / target_path.name
350
+ else:
351
+ temp_target = tmpdir / target_path.name
352
+
353
+ temp_target.write_text(source, encoding="utf-8")
354
+
355
+ # Copy test to temp dir.
356
+ # If target is in a package dir (e.g., ui/), put test in same dir in tmpdir
357
+ # so the test can find the target (assumes test uses sys.path.insert(0, __file__.parent))
358
+ if target_package_name:
359
+ pkg_dir = tmpdir / target_package_name
360
+ temp_test = pkg_dir / test_path.name
361
+ else:
362
+ temp_test = tmpdir / test_path.name
363
+
364
+ try:
365
+ test_source = test_path.read_text(encoding="utf-8")
366
+ temp_test.write_text(test_source, encoding="utf-8")
367
+ except Exception as e:
368
+ return {
369
+ "killed": 0,
370
+ "survived": 0,
371
+ "mutations": [],
372
+ "error": f"failed to copy test: {e}",
373
+ }
374
+
375
+ # Copy sibling modules from target's package directory.
376
+ # This is critical for modules that import siblings (e.g., ui/wave_audit_tail
377
+ # imports ui/config). Copy them into the same package dir as target.
378
+ target_dir = target_path.parent
379
+ if target_package_name:
380
+ pkg_dir = tmpdir / target_package_name
381
+ for py_file in target_dir.glob("*.py"):
382
+ if py_file.name not in (test_path.name, target_path.name):
383
+ try:
384
+ dest = pkg_dir / py_file.name
385
+ dest.write_text(py_file.read_text(encoding="utf-8"), encoding="utf-8")
386
+ except Exception:
387
+ pass # Best effort (skip files we can't read)
388
+ else:
389
+ # Flat layout (target at repo root)
390
+ for py_file in target_dir.glob("*.py"):
391
+ if py_file.name not in (test_path.name, target_path.name):
392
+ try:
393
+ dest = tmpdir / py_file.name
394
+ dest.write_text(py_file.read_text(encoding="utf-8"), encoding="utf-8")
395
+ except Exception:
396
+ pass # Best effort (skip files we can't read)
397
+
398
+ # Copy any other Python files from test directory (additional dependencies)
399
+ test_dir = test_path.parent
400
+ for py_file in test_dir.glob("*.py"):
401
+ if py_file.name not in (test_path.name, target_path.name):
402
+ try:
403
+ dest = tmpdir / py_file.name
404
+ dest.write_text(py_file.read_text(encoding="utf-8"), encoding="utf-8")
405
+ except Exception:
406
+ pass # Best effort
407
+
408
+ # Also create __init__.py files for any sibling packages that tests might import.
409
+ # This enables "from driver import X" when driver/ contains the target.
410
+ if target_package_name and repo_root:
411
+ # Known package directories in this repo
412
+ package_dir_names = {"driver", "ui", "tools", "mcp", "daemons", "skills", "bench"}
413
+
414
+ # Collect all package names from repo root
415
+ for pkg in repo_root.glob("*/"):
416
+ if pkg.is_dir():
417
+ pkg_name = pkg.name
418
+ # Only copy known package directories or those with __init__.py
419
+ if pkg_name not in package_dir_names and not (pkg / "__init__.py").exists():
420
+ continue
421
+
422
+ # Only recreate if not already created
423
+ sandbox_pkg = tmpdir / pkg_name
424
+ if not sandbox_pkg.exists():
425
+ sandbox_pkg.mkdir(parents=True, exist_ok=True)
426
+ (sandbox_pkg / "__init__.py").touch()
427
+ # Copy Python files from this package
428
+ for py_file in pkg.glob("*.py"):
429
+ try:
430
+ dest = sandbox_pkg / py_file.name
431
+ dest.write_text(py_file.read_text(encoding="utf-8"), encoding="utf-8")
432
+ except Exception:
433
+ pass # Best effort
434
+
435
+ # Run tests against unmutated code (baseline)
436
+ baseline_exit, _ = run_tests(str(temp_test), str(tmpdir))
437
+ baseline_passes = (baseline_exit == 0)
438
+
439
+ # Guard: if baseline tests fail, the results are invalid.
440
+ # Every mutation would also fail, giving a false-perfect kill rate.
441
+ if not baseline_passes:
442
+ return {
443
+ "killed": 0,
444
+ "survived": 0,
445
+ "mutations": [],
446
+ "error": "baseline tests fail in sandbox — results invalid",
447
+ }
448
+
449
+ killed = 0
450
+ survived = 0
451
+ survived_mutations = []
452
+
453
+ # Test each mutation
454
+ for mut_idx, (line, orig, mutated) in enumerate(mutations):
455
+ mutated_source = apply_mutation(source, mut_idx)
456
+ if mutated_source is None:
457
+ continue
458
+
459
+ # Write mutated source to temp file
460
+ temp_target.write_text(mutated_source, encoding="utf-8")
461
+
462
+ # Run tests
463
+ exit_code, _ = run_tests(str(temp_test), str(tmpdir))
464
+ tests_pass = (exit_code == 0)
465
+
466
+ if tests_pass:
467
+ # Mutation survived (tests didn't catch the bug)
468
+ survived += 1
469
+ survived_mutations.append({
470
+ "file": target_path.name,
471
+ "line": line,
472
+ "original": orig,
473
+ "mutated": mutated,
474
+ })
475
+ else:
476
+ # Mutation killed (tests caught the bug)
477
+ killed += 1
478
+
479
+ # Restore original
480
+ temp_target.write_text(source, encoding="utf-8")
481
+
482
+ return {
483
+ "killed": killed,
484
+ "survived": survived,
485
+ "mutations": survived_mutations,
486
+ }
487
+
488
+
489
+ def main(argv: Optional[List[str]] = None) -> int:
490
+ """CLI entry point."""
491
+ parser = argparse.ArgumentParser(
492
+ description="Mutation testing tool - measure test quality by applying mutations."
493
+ )
494
+ parser.add_argument("--target", required=True, help="Target module to mutate (path to .py file)")
495
+ parser.add_argument("--test", required=True, help="Test module to run (path to .py file)")
496
+ parser.add_argument("--json", action="store_true", help="Output results as JSON")
497
+
498
+ args = parser.parse_args(argv)
499
+
500
+ result = run(args.target, args.test)
501
+
502
+ if args.json:
503
+ print(json.dumps(result, indent=2))
504
+ else:
505
+ print("Mutation test results:")
506
+ print(f" Killed: {result['killed']}")
507
+ print(f" Survived: {result['survived']}")
508
+ if result["survived"] > 0:
509
+ print("\nSurvived mutations (test gaps):")
510
+ for mut in result["mutations"]:
511
+ print(f" {mut['file']}:{mut['line']} - {mut['original']} -> {mut['mutated']}")
512
+ if "error" in result:
513
+ print(f"\nError: {result['error']}", file=sys.stderr)
514
+
515
+ # Exit nonzero if there's a baseline error (tool/setup failure).
516
+ # Normal results (mutations/survivors) exit 0 (advisory).
517
+ if "error" in result:
518
+ return 1
519
+ return 0 # Exit 0 always (advisory) for normal results
520
+
521
+
522
+ if __name__ == "__main__":
523
+ sys.exit(main())