@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +89 -2
  2. package/README.md +63 -12
  3. package/bin/cli.js +164 -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/INSTALL.md +70 -1
  10. package/docs/QUICKSTART.md +80 -0
  11. package/docs/README.md +33 -3
  12. package/docs/TEAM-STATE.md +540 -0
  13. package/driver/CLAUDE.md +148 -0
  14. package/driver/README.md +383 -0
  15. package/driver/aesop.config.example.json +80 -0
  16. package/driver/agent_driver.py +355 -0
  17. package/driver/backend_config.py +253 -0
  18. package/driver/claude_code_driver.py +198 -0
  19. package/driver/codex_driver.py +626 -0
  20. package/driver/openai_compatible_driver.py +249 -0
  21. package/driver/openai_transport.py +146 -0
  22. package/driver/verification_policy.py +75 -0
  23. package/driver/wave_bridge.py +246 -0
  24. package/driver/wave_loop.py +1041 -0
  25. package/hooks/pre-push-policy.sh +109 -28
  26. package/mcp/server.mjs +320 -4
  27. package/package.json +23 -15
  28. package/skills/CLAUDE.md +132 -2
  29. package/skills/buildsystem/SKILL.md +330 -0
  30. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  31. package/skills/fleet/SKILL.md +113 -0
  32. package/skills/power/SKILL.md +246 -131
  33. package/state_store/__init__.py +2 -1
  34. package/state_store/api.py +19 -4
  35. package/state_store/coordination.py +209 -0
  36. package/state_store/identity.py +51 -0
  37. package/state_store/store.py +185 -73
  38. package/templates/wave-presets/data.json +65 -0
  39. package/templates/wave-presets/library.json +65 -0
  40. package/templates/wave-presets/saas.json +64 -0
  41. package/tools/audit_report.py +388 -0
  42. package/tools/bench_runner.py +100 -3
  43. package/tools/ci_merge_wait.py +256 -35
  44. package/tools/ci_workflow_lint.py +430 -0
  45. package/tools/claudemd_drift.py +394 -0
  46. package/tools/claudemd_lint.py +359 -0
  47. package/tools/common.py +39 -3
  48. package/tools/cost_ceiling.py +63 -31
  49. package/tools/cost_econ.py +480 -0
  50. package/tools/defect_escape.py +252 -0
  51. package/tools/doctor.js +1 -1
  52. package/tools/eod_sweep.py +58 -0
  53. package/tools/fleet.js +260 -0
  54. package/tools/fleet_ledger.py +209 -7
  55. package/tools/git_identity_check.py +315 -0
  56. package/tools/health-score.js +40 -0
  57. package/tools/health_score.py +361 -0
  58. package/tools/metrics_gate.py +13 -4
  59. package/tools/mutation_test.py +401 -0
  60. package/tools/portability_check.py +206 -0
  61. package/tools/reconcile.py +7 -4
  62. package/tools/secret_scan.py +137 -50
  63. package/tools/self_stats.py +20 -0
  64. package/tools/transcript_digest.py +380 -0
  65. package/tools/verify_activity_filter.py +437 -0
  66. package/tools/verify_agent_inspector.py +2 -0
  67. package/tools/verify_dash.py +2 -0
  68. package/tools/verify_dispatch_panel.py +301 -0
  69. package/tools/verify_failure_drilldown.py +188 -0
  70. package/tools/verify_prboard.py +2 -0
  71. package/tools/verify_scorecards.py +281 -0
  72. package/tools/verify_submit_encoding.py +2 -0
  73. package/tools/verify_ui_trio.py +409 -0
  74. package/tools/verify_wave_telemetry.py +268 -0
  75. package/tools/wave_backlog_analyzer.py +490 -0
  76. package/tools/wave_ledger_hook.py +150 -0
  77. package/tools/wave_preflight.py +512 -0
  78. package/tools/wave_resume.py +215 -0
  79. package/tools/wave_templates.py +215 -0
  80. package/ui/agents.py +68 -14
  81. package/ui/collectors.py +0 -55
  82. package/ui/config.py +7 -2
  83. package/ui/cost.py +231 -12
  84. package/ui/handler.py +183 -0
  85. package/ui/quality_scorecard.py +232 -0
  86. package/ui/wave_audit_tail.py +213 -0
  87. package/ui/wave_dispatch.py +280 -0
  88. package/ui/wave_failure.py +288 -0
  89. package/ui/wave_gantt.py +152 -0
  90. package/ui/wave_reasoning_tail.py +176 -0
  91. package/ui/wave_telemetry.py +377 -0
  92. package/ui/web/dist/assets/index-BGbgw2Nh.js +9 -0
  93. package/ui/web/dist/assets/index-DqZLgwNg.css +1 -0
  94. package/ui/web/dist/index.html +2 -2
  95. package/bin/CLAUDE.md +0 -76
  96. package/daemons/CLAUDE.md +0 -36
  97. package/dash/CLAUDE.md +0 -32
  98. package/docs/archive/README.md +0 -3
  99. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  100. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  101. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  102. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  103. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  104. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  105. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  106. package/hooks/CLAUDE.md +0 -89
  107. package/mcp/CLAUDE.md +0 -213
  108. package/monitor/CLAUDE.md +0 -40
  109. package/scan/CLAUDE.md +0 -30
  110. package/state_store/CLAUDE.md +0 -39
  111. package/tools/CLAUDE.md +0 -79
  112. package/ui/CLAUDE.md +0 -127
  113. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  114. 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())
@@ -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 --db state/events.db
64
- python tools/reconcile.py --state-md STATE.md --db state/events.db --resolve
65
- python tools/reconcile.py --state-md STATE.md --db state/events.db --json
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="state/events.db", help="Path to state_store SQLite db")
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)