@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.
- package/CHANGELOG.md +117 -2
- package/README.md +59 -218
- package/bin/cli.js +168 -41
- package/daemons/run-watchdog.sh +16 -4
- package/daemons/selfheal.sh +231 -0
- package/docs/ANY-REPO.md +427 -0
- package/docs/CONTRIBUTING.md +72 -0
- package/docs/DEMO.md +334 -0
- package/docs/HOOK-INSTALL.md +15 -56
- package/docs/INSTALL.md +74 -4
- package/docs/PORTING.md +166 -0
- package/docs/README.md +33 -3
- package/docs/TEAM-STATE.md +372 -0
- package/docs/reproduce.md +33 -3
- package/driver/CLAUDE.md +150 -0
- package/driver/README.md +383 -0
- package/driver/aesop.config.example.json +80 -0
- package/driver/agent_driver.py +355 -0
- package/driver/backend_config.py +253 -0
- package/driver/claude_code_driver.py +198 -0
- package/driver/codex_driver.py +673 -0
- package/driver/openai_compatible_driver.py +249 -0
- package/driver/openai_transport.py +179 -0
- package/driver/verification_policy.py +75 -0
- package/driver/wave_bridge.py +254 -0
- package/driver/wave_loop.py +1408 -0
- package/driver/wave_scheduler.py +890 -0
- package/hooks/pre-push-policy.sh +131 -33
- package/mcp/server.mjs +320 -4
- package/monitor/collect-signals.mjs +69 -0
- package/package.json +22 -14
- package/skills/CLAUDE.md +132 -2
- package/skills/buildsystem/SKILL.md +330 -0
- package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
- package/skills/fleet/SKILL.md +113 -0
- package/skills/power/SKILL.md +246 -131
- package/state_store/__init__.py +4 -2
- package/state_store/api.py +19 -4
- package/state_store/coordination.py +209 -0
- package/state_store/identity.py +51 -0
- package/state_store/projections.py +63 -0
- package/state_store/read_api.py +156 -0
- package/state_store/store.py +185 -73
- package/state_store/write_api.py +462 -0
- package/templates/wave-presets/data.json +65 -0
- package/templates/wave-presets/library.json +65 -0
- package/templates/wave-presets/saas.json +64 -0
- package/tools/audit_report.py +388 -0
- package/tools/bench_runner.py +100 -3
- package/tools/ci_merge_wait.py +256 -35
- package/tools/ci_workflow_lint.py +430 -0
- package/tools/claudemd_drift.py +394 -0
- package/tools/claudemd_lint.py +359 -0
- package/tools/common.py +39 -3
- package/tools/cost_ceiling.py +166 -43
- package/tools/cost_econ.py +480 -0
- package/tools/cost_projection.py +559 -0
- package/tools/crossos_drift.py +394 -0
- package/tools/defect_escape.py +252 -0
- package/tools/doctor.js +1 -1
- package/tools/eod_sweep.py +188 -26
- package/tools/fleet.js +260 -0
- package/tools/fleet_ledger.py +209 -7
- package/tools/git_identity_check.py +315 -0
- package/tools/health-score.js +40 -0
- package/tools/health_score.py +361 -0
- package/tools/metrics_gate.py +13 -4
- package/tools/mutation_test.py +523 -0
- package/tools/portability_check.py +206 -0
- package/tools/proposals.mjs +47 -2
- package/tools/reconcile.py +7 -4
- package/tools/reproduce.js +405 -0
- package/tools/secret_scan.py +207 -65
- package/tools/self_stats.py +20 -0
- package/tools/stall_check.py +247 -16
- package/tools/stateapi_lint.py +325 -0
- package/tools/test_battery.py +173 -0
- package/tools/transcript_digest.py +380 -0
- package/tools/verify_activity_filter.py +437 -0
- package/tools/verify_agent_inspector.py +2 -0
- package/tools/verify_cost_panel.py +345 -0
- package/tools/verify_dash.py +2 -0
- package/tools/verify_dispatch_panel.py +301 -0
- package/tools/verify_failure_drilldown.py +188 -0
- package/tools/verify_prboard.py +2 -0
- package/tools/verify_scorecards.py +281 -0
- package/tools/verify_submit_encoding.py +2 -0
- package/tools/verify_ui_trio.py +409 -0
- package/tools/verify_wave_telemetry.py +268 -0
- package/tools/wave_backlog_analyzer.py +490 -0
- package/tools/wave_ledger_hook.py +150 -0
- package/tools/wave_preflight.py +779 -0
- package/tools/wave_resume.py +215 -0
- package/tools/wave_templates.py +340 -0
- package/ui/agents.py +68 -14
- package/ui/collectors.py +81 -55
- package/ui/config.py +7 -2
- package/ui/cost.py +231 -12
- package/ui/handler.py +383 -55
- package/ui/quality_scorecard.py +232 -0
- package/ui/sse.py +3 -3
- package/ui/wave_audit_tail.py +213 -0
- package/ui/wave_dispatch.py +280 -0
- package/ui/wave_failure.py +288 -0
- package/ui/wave_gantt.py +152 -0
- package/ui/wave_reasoning_tail.py +176 -0
- package/ui/wave_telemetry.py +383 -0
- package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
- package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
- package/ui/web/dist/index.html +2 -2
- package/bin/CLAUDE.md +0 -76
- package/daemons/CLAUDE.md +0 -36
- package/dash/CLAUDE.md +0 -32
- package/docs/archive/README.md +0 -3
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
- package/docs/archive/spikes/tiered-cognition/README.md +0 -27
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
- package/hooks/CLAUDE.md +0 -89
- package/mcp/CLAUDE.md +0 -213
- package/monitor/CLAUDE.md +0 -40
- package/scan/CLAUDE.md +0 -30
- package/state_store/CLAUDE.md +0 -39
- package/tools/CLAUDE.md +0 -79
- package/ui/CLAUDE.md +0 -127
- package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
- 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())
|