@matt82198/aesop 0.1.0 → 0.2.0
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 +89 -2
- package/README.md +63 -12
- package/bin/cli.js +164 -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/INSTALL.md +70 -1
- package/docs/QUICKSTART.md +80 -0
- package/docs/README.md +33 -3
- package/docs/TEAM-STATE.md +540 -0
- package/driver/CLAUDE.md +148 -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 +626 -0
- package/driver/openai_compatible_driver.py +249 -0
- package/driver/openai_transport.py +146 -0
- package/driver/verification_policy.py +75 -0
- package/driver/wave_bridge.py +246 -0
- package/driver/wave_loop.py +1041 -0
- package/hooks/pre-push-policy.sh +109 -28
- package/mcp/server.mjs +320 -4
- package/package.json +23 -15
- 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 +2 -1
- package/state_store/api.py +19 -4
- package/state_store/coordination.py +209 -0
- package/state_store/identity.py +51 -0
- package/state_store/store.py +185 -73
- 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 +63 -31
- package/tools/cost_econ.py +480 -0
- package/tools/defect_escape.py +252 -0
- package/tools/doctor.js +1 -1
- package/tools/eod_sweep.py +58 -0
- 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 +401 -0
- package/tools/portability_check.py +206 -0
- package/tools/reconcile.py +7 -4
- package/tools/secret_scan.py +137 -50
- package/tools/self_stats.py +20 -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_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 +512 -0
- package/tools/wave_resume.py +215 -0
- package/tools/wave_templates.py +215 -0
- package/ui/agents.py +68 -14
- package/ui/collectors.py +0 -55
- package/ui/config.py +7 -2
- package/ui/cost.py +231 -12
- package/ui/handler.py +183 -0
- package/ui/quality_scorecard.py +232 -0
- 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 +377 -0
- package/ui/web/dist/assets/index-BGbgw2Nh.js +9 -0
- package/ui/web/dist/assets/index-DqZLgwNg.css +1 -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,401 @@
|
|
|
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
|
+
if _pytest_available():
|
|
212
|
+
cmd = [sys.executable, "-m", "pytest", "-xvs", test_module_path]
|
|
213
|
+
else:
|
|
214
|
+
# unittest needs a module name (importable via cwd/PYTHONPATH), not a path.
|
|
215
|
+
cmd = [sys.executable, "-m", "unittest", "-v", Path(test_module_path).stem]
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
result = subprocess.run(
|
|
219
|
+
cmd,
|
|
220
|
+
cwd=work_dir,
|
|
221
|
+
capture_output=True,
|
|
222
|
+
text=True,
|
|
223
|
+
timeout=timeout,
|
|
224
|
+
env=env,
|
|
225
|
+
)
|
|
226
|
+
return result.returncode, result.stdout + result.stderr
|
|
227
|
+
except subprocess.TimeoutExpired:
|
|
228
|
+
return 124, "Test timeout (>{}s)".format(timeout)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def run(target_module_path: str, test_module_path: str) -> Dict[str, Any]:
|
|
232
|
+
"""Run mutation testing on target_module_path using test_module_path.
|
|
233
|
+
|
|
234
|
+
Returns {
|
|
235
|
+
"killed": int,
|
|
236
|
+
"survived": int,
|
|
237
|
+
"mutations": [
|
|
238
|
+
{"file": str, "line": int, "original": str, "mutated": str},
|
|
239
|
+
...
|
|
240
|
+
]
|
|
241
|
+
}
|
|
242
|
+
"""
|
|
243
|
+
target_path = Path(target_module_path).resolve()
|
|
244
|
+
test_path = Path(test_module_path).resolve()
|
|
245
|
+
|
|
246
|
+
if not target_path.exists():
|
|
247
|
+
return {
|
|
248
|
+
"killed": 0,
|
|
249
|
+
"survived": 0,
|
|
250
|
+
"mutations": [],
|
|
251
|
+
"error": f"target module not found: {target_module_path}",
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if not test_path.exists():
|
|
255
|
+
return {
|
|
256
|
+
"killed": 0,
|
|
257
|
+
"survived": 0,
|
|
258
|
+
"mutations": [],
|
|
259
|
+
"error": f"test module not found: {test_module_path}",
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
# Read target source
|
|
263
|
+
try:
|
|
264
|
+
source = target_path.read_text(encoding="utf-8")
|
|
265
|
+
except Exception as e:
|
|
266
|
+
return {
|
|
267
|
+
"killed": 0,
|
|
268
|
+
"survived": 0,
|
|
269
|
+
"mutations": [],
|
|
270
|
+
"error": f"failed to read target: {e}",
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
# Extract mutations
|
|
274
|
+
mutations = extract_mutations(source)
|
|
275
|
+
if not mutations:
|
|
276
|
+
return {
|
|
277
|
+
"killed": 0,
|
|
278
|
+
"survived": 0,
|
|
279
|
+
"mutations": [],
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
# Create temporary work directory
|
|
283
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
284
|
+
tmpdir = Path(tmpdir)
|
|
285
|
+
|
|
286
|
+
# Copy target to temp dir
|
|
287
|
+
temp_target = tmpdir / target_path.name
|
|
288
|
+
temp_target.write_text(source, encoding="utf-8")
|
|
289
|
+
|
|
290
|
+
# Copy test to temp dir
|
|
291
|
+
temp_test = tmpdir / test_path.name
|
|
292
|
+
try:
|
|
293
|
+
test_source = test_path.read_text(encoding="utf-8")
|
|
294
|
+
temp_test.write_text(test_source, encoding="utf-8")
|
|
295
|
+
except Exception as e:
|
|
296
|
+
return {
|
|
297
|
+
"killed": 0,
|
|
298
|
+
"survived": 0,
|
|
299
|
+
"mutations": [],
|
|
300
|
+
"error": f"failed to copy test: {e}",
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
# Copy any other Python files from test directory (dependencies)
|
|
304
|
+
test_dir = test_path.parent
|
|
305
|
+
for py_file in test_dir.glob("*.py"):
|
|
306
|
+
if py_file.name not in (test_path.name, target_path.name):
|
|
307
|
+
try:
|
|
308
|
+
dest = tmpdir / py_file.name
|
|
309
|
+
dest.write_text(py_file.read_text(encoding="utf-8"), encoding="utf-8")
|
|
310
|
+
except Exception:
|
|
311
|
+
pass # Best effort
|
|
312
|
+
|
|
313
|
+
# Run tests against unmutated code (baseline)
|
|
314
|
+
baseline_exit, _ = run_tests(str(temp_test), str(tmpdir))
|
|
315
|
+
baseline_passes = (baseline_exit == 0)
|
|
316
|
+
|
|
317
|
+
# Guard: if baseline tests fail, the results are invalid.
|
|
318
|
+
# Every mutation would also fail, giving a false-perfect kill rate.
|
|
319
|
+
if not baseline_passes:
|
|
320
|
+
return {
|
|
321
|
+
"killed": 0,
|
|
322
|
+
"survived": 0,
|
|
323
|
+
"mutations": [],
|
|
324
|
+
"error": "baseline tests fail in sandbox — results invalid",
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
killed = 0
|
|
328
|
+
survived = 0
|
|
329
|
+
survived_mutations = []
|
|
330
|
+
|
|
331
|
+
# Test each mutation
|
|
332
|
+
for mut_idx, (line, orig, mutated) in enumerate(mutations):
|
|
333
|
+
mutated_source = apply_mutation(source, mut_idx)
|
|
334
|
+
if mutated_source is None:
|
|
335
|
+
continue
|
|
336
|
+
|
|
337
|
+
# Write mutated source to temp file
|
|
338
|
+
temp_target.write_text(mutated_source, encoding="utf-8")
|
|
339
|
+
|
|
340
|
+
# Run tests
|
|
341
|
+
exit_code, _ = run_tests(str(temp_test), str(tmpdir))
|
|
342
|
+
tests_pass = (exit_code == 0)
|
|
343
|
+
|
|
344
|
+
if tests_pass:
|
|
345
|
+
# Mutation survived (tests didn't catch the bug)
|
|
346
|
+
survived += 1
|
|
347
|
+
survived_mutations.append({
|
|
348
|
+
"file": target_path.name,
|
|
349
|
+
"line": line,
|
|
350
|
+
"original": orig,
|
|
351
|
+
"mutated": mutated,
|
|
352
|
+
})
|
|
353
|
+
else:
|
|
354
|
+
# Mutation killed (tests caught the bug)
|
|
355
|
+
killed += 1
|
|
356
|
+
|
|
357
|
+
# Restore original
|
|
358
|
+
temp_target.write_text(source, encoding="utf-8")
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
"killed": killed,
|
|
362
|
+
"survived": survived,
|
|
363
|
+
"mutations": survived_mutations,
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
368
|
+
"""CLI entry point."""
|
|
369
|
+
parser = argparse.ArgumentParser(
|
|
370
|
+
description="Mutation testing tool - measure test quality by applying mutations."
|
|
371
|
+
)
|
|
372
|
+
parser.add_argument("--target", required=True, help="Target module to mutate (path to .py file)")
|
|
373
|
+
parser.add_argument("--test", required=True, help="Test module to run (path to .py file)")
|
|
374
|
+
parser.add_argument("--json", action="store_true", help="Output results as JSON")
|
|
375
|
+
|
|
376
|
+
args = parser.parse_args(argv)
|
|
377
|
+
|
|
378
|
+
result = run(args.target, args.test)
|
|
379
|
+
|
|
380
|
+
if args.json:
|
|
381
|
+
print(json.dumps(result, indent=2))
|
|
382
|
+
else:
|
|
383
|
+
print("Mutation test results:")
|
|
384
|
+
print(f" Killed: {result['killed']}")
|
|
385
|
+
print(f" Survived: {result['survived']}")
|
|
386
|
+
if result["survived"] > 0:
|
|
387
|
+
print("\nSurvived mutations (test gaps):")
|
|
388
|
+
for mut in result["mutations"]:
|
|
389
|
+
print(f" {mut['file']}:{mut['line']} - {mut['original']} -> {mut['mutated']}")
|
|
390
|
+
if "error" in result:
|
|
391
|
+
print(f"\nError: {result['error']}", file=sys.stderr)
|
|
392
|
+
|
|
393
|
+
# Exit nonzero if there's a baseline error (tool/setup failure).
|
|
394
|
+
# Normal results (mutations/survivors) exit 0 (advisory).
|
|
395
|
+
if "error" in result:
|
|
396
|
+
return 1
|
|
397
|
+
return 0 # Exit 0 always (advisory) for normal results
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
if __name__ == "__main__":
|
|
401
|
+
sys.exit(main())
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Portability gate: scan shipped surface for hardcoded personal/environment paths.
|
|
4
|
+
|
|
5
|
+
Detects absolute Windows user paths (C:\\Users\\<name> / C:/Users/<name>),
|
|
6
|
+
POSIX home paths (/home/<name>, /Users/<name>), and private-machine tokens
|
|
7
|
+
('conductor3', 'matt8'). Allows clearly-marked examples/defaults (lines containing
|
|
8
|
+
'example', 'default', or 'e.g.').
|
|
9
|
+
|
|
10
|
+
Exit 0 clean, 1 with numbered file:line findings.
|
|
11
|
+
Supports --json output and --root for base directory.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
import os
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import glob
|
|
19
|
+
import argparse
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_package_json(root):
|
|
24
|
+
"""Read package.json and extract 'files' array."""
|
|
25
|
+
pkg_path = os.path.join(root, 'package.json')
|
|
26
|
+
try:
|
|
27
|
+
with open(pkg_path, 'r') as f:
|
|
28
|
+
content = json.load(f)
|
|
29
|
+
return content.get('files', [])
|
|
30
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def expand_globs(root, patterns):
|
|
35
|
+
"""Expand glob patterns from package.json 'files' array."""
|
|
36
|
+
files = set()
|
|
37
|
+
for pattern in patterns:
|
|
38
|
+
# Normalize pattern to use forward slashes for glob
|
|
39
|
+
pattern = pattern.replace('\\', '/')
|
|
40
|
+
full_pattern = os.path.join(root, pattern).replace('\\', '/')
|
|
41
|
+
|
|
42
|
+
matches = glob.glob(full_pattern, recursive=True)
|
|
43
|
+
for match in matches:
|
|
44
|
+
# Use Path to normalize, convert back to string
|
|
45
|
+
normalized = str(Path(match))
|
|
46
|
+
files.add(normalized)
|
|
47
|
+
|
|
48
|
+
return sorted(files)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_text_file(filepath):
|
|
52
|
+
"""Check if file is likely text (not binary)."""
|
|
53
|
+
binary_extensions = {
|
|
54
|
+
'.bin', '.exe', '.dll', '.so', '.dylib',
|
|
55
|
+
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp',
|
|
56
|
+
'.pdf', '.zip', '.tar', '.gz', '.rar',
|
|
57
|
+
'.woff', '.woff2', '.ttf', '.eot',
|
|
58
|
+
'.mp3', '.mp4', '.wav', '.mov'
|
|
59
|
+
}
|
|
60
|
+
_, ext = os.path.splitext(filepath.lower())
|
|
61
|
+
return ext not in binary_extensions
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def read_file_lines(filepath):
|
|
65
|
+
"""Read file lines, handling encoding issues gracefully."""
|
|
66
|
+
try:
|
|
67
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
68
|
+
return f.readlines()
|
|
69
|
+
except UnicodeDecodeError:
|
|
70
|
+
try:
|
|
71
|
+
with open(filepath, 'r', encoding='latin-1') as f:
|
|
72
|
+
return f.readlines()
|
|
73
|
+
except Exception:
|
|
74
|
+
return []
|
|
75
|
+
except Exception:
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_exception_line(line):
|
|
80
|
+
"""Check if line is marked as example/default."""
|
|
81
|
+
line_lower = line.lower()
|
|
82
|
+
return any(marker in line_lower for marker in ['example', 'default', 'e.g.'])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def scan_line_for_paths(line):
|
|
86
|
+
"""Scan a line for problematic paths and tokens."""
|
|
87
|
+
findings = []
|
|
88
|
+
|
|
89
|
+
# Windows absolute paths: C:\Users\<name> or C:/Users/<name>
|
|
90
|
+
windows_user_patterns = [
|
|
91
|
+
r'C:\\Users\\[a-zA-Z0-9_\-\.]+',
|
|
92
|
+
r'C:/Users/[a-zA-Z0-9_\-\.]+'
|
|
93
|
+
]
|
|
94
|
+
for pattern in windows_user_patterns:
|
|
95
|
+
matches = re.finditer(pattern, line)
|
|
96
|
+
for match in matches:
|
|
97
|
+
findings.append({
|
|
98
|
+
'type': 'windows_user_path',
|
|
99
|
+
'path': match.group(0)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
# POSIX home paths: /home/<name> or /Users/<name>
|
|
103
|
+
posix_patterns = [
|
|
104
|
+
r'/home/[a-zA-Z0-9_\-\.]+',
|
|
105
|
+
r'/Users/[a-zA-Z0-9_\-\.]+'
|
|
106
|
+
]
|
|
107
|
+
for pattern in posix_patterns:
|
|
108
|
+
matches = re.finditer(pattern, line)
|
|
109
|
+
for match in matches:
|
|
110
|
+
findings.append({
|
|
111
|
+
'type': 'posix_home_path',
|
|
112
|
+
'path': match.group(0)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
# Private machine tokens: 'conductor3' and 'matt8'
|
|
116
|
+
# These are simple word boundary matches (whole word)
|
|
117
|
+
for token in ['conductor3', 'matt8']:
|
|
118
|
+
# Use word boundaries to avoid false positives in longer identifiers
|
|
119
|
+
pattern = r'\b' + re.escape(token) + r'\b'
|
|
120
|
+
matches = re.finditer(pattern, line)
|
|
121
|
+
for match in matches:
|
|
122
|
+
findings.append({
|
|
123
|
+
'type': 'private_token',
|
|
124
|
+
'token': token
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
return findings
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def scan_shipped_surface(root, json_output=False):
|
|
131
|
+
"""Scan shipped surface for portability issues."""
|
|
132
|
+
patterns = read_package_json(root)
|
|
133
|
+
if not patterns:
|
|
134
|
+
print("Warning: Could not read package.json 'files' array", file=sys.stderr)
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
files = expand_globs(root, patterns)
|
|
138
|
+
all_findings = []
|
|
139
|
+
|
|
140
|
+
for filepath in files:
|
|
141
|
+
if not os.path.isfile(filepath) or not is_text_file(filepath):
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
lines = read_file_lines(filepath)
|
|
145
|
+
for line_num, line in enumerate(lines, 1):
|
|
146
|
+
# Skip exception lines (marked as example/default)
|
|
147
|
+
if is_exception_line(line):
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
# Scan for issues
|
|
151
|
+
issues = scan_line_for_paths(line)
|
|
152
|
+
for issue in issues:
|
|
153
|
+
relative_path = os.path.relpath(filepath, root)
|
|
154
|
+
finding = {
|
|
155
|
+
'file': relative_path,
|
|
156
|
+
'line': line_num,
|
|
157
|
+
'content': line.rstrip()[:100], # First 100 chars
|
|
158
|
+
**issue
|
|
159
|
+
}
|
|
160
|
+
all_findings.append(finding)
|
|
161
|
+
|
|
162
|
+
return all_findings
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main():
|
|
166
|
+
parser = argparse.ArgumentParser(
|
|
167
|
+
description='Portability gate: scan for hardcoded personal paths'
|
|
168
|
+
)
|
|
169
|
+
parser.add_argument(
|
|
170
|
+
'--root',
|
|
171
|
+
default='.',
|
|
172
|
+
help='Root directory to scan (default: current directory)'
|
|
173
|
+
)
|
|
174
|
+
parser.add_argument(
|
|
175
|
+
'--json',
|
|
176
|
+
action='store_true',
|
|
177
|
+
help='Output findings as JSON'
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
args = parser.parse_args()
|
|
181
|
+
root = os.path.abspath(args.root)
|
|
182
|
+
|
|
183
|
+
findings = scan_shipped_surface(root, json_output=args.json)
|
|
184
|
+
|
|
185
|
+
if args.json:
|
|
186
|
+
print(json.dumps(findings, indent=2))
|
|
187
|
+
else:
|
|
188
|
+
if findings:
|
|
189
|
+
print(f"Found {len(findings)} portability issue(s):", file=sys.stderr)
|
|
190
|
+
for i, finding in enumerate(findings, 1):
|
|
191
|
+
print(
|
|
192
|
+
f"{i}. {finding['file']}:{finding['line']}: "
|
|
193
|
+
f"{finding.get('type', 'unknown')}",
|
|
194
|
+
file=sys.stderr
|
|
195
|
+
)
|
|
196
|
+
if finding.get('path'):
|
|
197
|
+
print(f" Path: {finding['path']}", file=sys.stderr)
|
|
198
|
+
if finding.get('token'):
|
|
199
|
+
print(f" Token: {finding['token']}", file=sys.stderr)
|
|
200
|
+
print(f" {finding['content']}", file=sys.stderr)
|
|
201
|
+
|
|
202
|
+
return 1 if findings else 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == '__main__':
|
|
206
|
+
sys.exit(main())
|
package/tools/reconcile.py
CHANGED
|
@@ -60,9 +60,11 @@ manufacturing a false "in sync".
|
|
|
60
60
|
|
|
61
61
|
## Usage
|
|
62
62
|
|
|
63
|
-
python tools/reconcile.py --state-md STATE.md
|
|
64
|
-
python tools/reconcile.py --state-md STATE.md --
|
|
65
|
-
python tools/reconcile.py --state-md STATE.md --
|
|
63
|
+
python tools/reconcile.py --state-md STATE.md
|
|
64
|
+
python tools/reconcile.py --state-md STATE.md --resolve
|
|
65
|
+
python tools/reconcile.py --state-md STATE.md --json
|
|
66
|
+
|
|
67
|
+
(DB path defaults to state/tracker_events.db; override with --db if needed.)
|
|
66
68
|
|
|
67
69
|
Exit codes: 0 = no drift (or drift cleanly resolved), 1 = drift detected and
|
|
68
70
|
NOT resolved (report mode), 2 = usage/file error.
|
|
@@ -81,6 +83,7 @@ if str(ROOT) not in sys.path:
|
|
|
81
83
|
sys.path.insert(0, str(ROOT))
|
|
82
84
|
|
|
83
85
|
from state_store.store import EventStore # noqa: E402
|
|
86
|
+
from tools.common import get_state_db_path # noqa: E402
|
|
84
87
|
|
|
85
88
|
PHASE_RE = re.compile(r"^##\s*Phase:\s*`([^`]+)`", re.MULTILINE)
|
|
86
89
|
|
|
@@ -251,7 +254,7 @@ def _format_human(report: dict) -> str:
|
|
|
251
254
|
def main(argv=None) -> int:
|
|
252
255
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
253
256
|
parser.add_argument("--state-md", default="STATE.md", help="Path to STATE.md (default: ./STATE.md)")
|
|
254
|
-
parser.add_argument("--db", default=
|
|
257
|
+
parser.add_argument("--db", default=str(get_state_db_path()), help="Path to state_store SQLite db (default: state/tracker_events.db)")
|
|
255
258
|
parser.add_argument("--resolve", action="store_true", help="Fix drift by writing the authoritative value to the non-authoritative side (opt-in; default is detect+report only)")
|
|
256
259
|
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON instead of human text")
|
|
257
260
|
args = parser.parse_args(argv)
|