@matt82198/aesop 0.2.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 +28 -0
- package/README.md +50 -260
- package/bin/cli.js +7 -3
- package/docs/HOOK-INSTALL.md +15 -56
- package/docs/INSTALL.md +4 -3
- package/docs/PORTING.md +166 -0
- package/docs/TEAM-STATE.md +16 -184
- package/docs/reproduce.md +33 -3
- package/driver/CLAUDE.md +50 -48
- package/driver/codex_driver.py +59 -12
- package/driver/openai_transport.py +33 -0
- package/driver/wave_bridge.py +9 -1
- package/driver/wave_loop.py +437 -70
- package/driver/wave_scheduler.py +890 -0
- package/hooks/pre-push-policy.sh +22 -5
- package/monitor/collect-signals.mjs +69 -0
- package/package.json +4 -4
- package/state_store/__init__.py +2 -1
- package/state_store/projections.py +63 -0
- package/state_store/read_api.py +156 -0
- package/state_store/write_api.py +462 -0
- package/tools/cost_ceiling.py +106 -15
- package/tools/cost_projection.py +559 -0
- package/tools/crossos_drift.py +394 -0
- package/tools/eod_sweep.py +137 -33
- package/tools/mutation_test.py +130 -8
- package/tools/proposals.mjs +47 -2
- package/tools/reproduce.js +405 -0
- package/tools/secret_scan.py +73 -18
- package/tools/stall_check.py +247 -16
- package/tools/stateapi_lint.py +325 -0
- package/tools/test_battery.py +173 -0
- package/tools/verify_cost_panel.py +345 -0
- package/tools/wave_preflight.py +296 -29
- package/tools/wave_templates.py +170 -45
- package/ui/collectors.py +81 -0
- package/ui/handler.py +230 -85
- package/ui/sse.py +3 -3
- package/ui/wave_telemetry.py +24 -18
- package/ui/web/dist/assets/{index-DqZLgwNg.css → index-CNQxaiOW.css} +1 -1
- package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
- package/ui/web/dist/index.html +2 -2
- package/docs/QUICKSTART.md +0 -80
- package/ui/web/dist/assets/index-BGbgw2Nh.js +0 -9
package/tools/mutation_test.py
CHANGED
|
@@ -208,11 +208,29 @@ def run_tests(test_module_path: str, work_dir: str, timeout: int = 30) -> Tuple[
|
|
|
208
208
|
existing = env.get("PYTHONPATH", "")
|
|
209
209
|
env["PYTHONPATH"] = work_dir + (os.pathsep + existing if existing else "")
|
|
210
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
|
+
|
|
211
221
|
if _pytest_available():
|
|
212
|
-
|
|
222
|
+
# pytest can take a relative path to the test file
|
|
223
|
+
cmd = [sys.executable, "-m", "pytest", "-xvs", str(rel_test_path)]
|
|
213
224
|
else:
|
|
214
|
-
# unittest needs a module name
|
|
215
|
-
|
|
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]
|
|
216
234
|
|
|
217
235
|
try:
|
|
218
236
|
result = subprocess.run(
|
|
@@ -283,12 +301,66 @@ def run(target_module_path: str, test_module_path: str) -> Dict[str, Any]:
|
|
|
283
301
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
284
302
|
tmpdir = Path(tmpdir)
|
|
285
303
|
|
|
286
|
-
#
|
|
287
|
-
|
|
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
|
+
|
|
288
353
|
temp_target.write_text(source, encoding="utf-8")
|
|
289
354
|
|
|
290
|
-
# Copy test to temp dir
|
|
291
|
-
|
|
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
|
+
|
|
292
364
|
try:
|
|
293
365
|
test_source = test_path.read_text(encoding="utf-8")
|
|
294
366
|
temp_test.write_text(test_source, encoding="utf-8")
|
|
@@ -300,7 +372,30 @@ def run(target_module_path: str, test_module_path: str) -> Dict[str, Any]:
|
|
|
300
372
|
"error": f"failed to copy test: {e}",
|
|
301
373
|
}
|
|
302
374
|
|
|
303
|
-
# Copy
|
|
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)
|
|
304
399
|
test_dir = test_path.parent
|
|
305
400
|
for py_file in test_dir.glob("*.py"):
|
|
306
401
|
if py_file.name not in (test_path.name, target_path.name):
|
|
@@ -310,6 +405,33 @@ def run(target_module_path: str, test_module_path: str) -> Dict[str, Any]:
|
|
|
310
405
|
except Exception:
|
|
311
406
|
pass # Best effort
|
|
312
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
|
+
|
|
313
435
|
# Run tests against unmutated code (baseline)
|
|
314
436
|
baseline_exit, _ = run_tests(str(temp_test), str(tmpdir))
|
|
315
437
|
baseline_passes = (baseline_exit == 0)
|
package/tools/proposals.mjs
CHANGED
|
@@ -19,6 +19,47 @@ import fs from 'node:fs';
|
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { acquireLock, releaseLock } from './lock.mjs';
|
|
21
21
|
|
|
22
|
+
// === Windows-portable atomic rename with EPERM/EBUSY retry ===
|
|
23
|
+
// On Windows, renaming over a file that another process has open throws EPERM.
|
|
24
|
+
// This wrapper provides bounded retry with exponential backoff and cleanup.
|
|
25
|
+
function atomicRename(tmpPath, targetPath) {
|
|
26
|
+
const maxRetries = 20;
|
|
27
|
+
const baseDelayMs = 50;
|
|
28
|
+
|
|
29
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
30
|
+
try {
|
|
31
|
+
fs.renameSync(tmpPath, targetPath);
|
|
32
|
+
return true; // Success
|
|
33
|
+
} catch (e) {
|
|
34
|
+
if ((e.code === 'EPERM' || e.code === 'EACCES' || e.code === 'EBUSY') && i < maxRetries - 1) {
|
|
35
|
+
// Retry on Windows EPERM/EACCES/EBUSY (file held by reader)
|
|
36
|
+
// Exponential backoff: 50ms, 100ms, 150ms, ..., 950ms (max ~9.5s total)
|
|
37
|
+
const delayMs = baseDelayMs * (i + 1);
|
|
38
|
+
const start = Date.now();
|
|
39
|
+
while (Date.now() - start < delayMs) {
|
|
40
|
+
// Busy-wait to avoid scheduling overhead
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
// Final failure or non-retryable error; clean up .tmp file and return false
|
|
44
|
+
try {
|
|
45
|
+
fs.unlinkSync(tmpPath);
|
|
46
|
+
} catch {
|
|
47
|
+
// Cleanup failed; best effort
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Final failure after all retries; clean up and return false
|
|
55
|
+
try {
|
|
56
|
+
fs.unlinkSync(tmpPath);
|
|
57
|
+
} catch {
|
|
58
|
+
// Cleanup failed; best effort
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
22
63
|
// === Arg parsing ===
|
|
23
64
|
const args = process.argv.slice(2);
|
|
24
65
|
let command = '';
|
|
@@ -193,11 +234,15 @@ function moveProposal(status) {
|
|
|
193
234
|
return b.trim();
|
|
194
235
|
}).filter(b => b).join('\n\n---\n\n');
|
|
195
236
|
|
|
196
|
-
// ATOMIC WRITE: write to temp file, then rename
|
|
237
|
+
// ATOMIC WRITE: write to temp file, then rename with retry (Windows-safe)
|
|
197
238
|
const tmpFile = proposalsFile + '.tmp';
|
|
198
239
|
try {
|
|
199
240
|
fs.writeFileSync(tmpFile, updatedContent.trim() ? updatedContent + '\n' : '', 'utf8');
|
|
200
|
-
|
|
241
|
+
// Use atomicRename for Windows-safe atomic replace (handles EPERM/EBUSY retries)
|
|
242
|
+
if (!atomicRename(tmpFile, proposalsFile)) {
|
|
243
|
+
console.error(`Error: Could not write ${proposalsFile} after retry`);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
201
246
|
} catch (e) {
|
|
202
247
|
// Clean up temp file if it exists
|
|
203
248
|
try { fs.unlinkSync(tmpFile); } catch { }
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Aesop reproduce — offline verification suite
|
|
5
|
+
*
|
|
6
|
+
* Mirrors .github/workflows/reproduce.yml for local execution.
|
|
7
|
+
*
|
|
8
|
+
* Modes:
|
|
9
|
+
* REPO: Full test suites (Node.js, Python, Shell, React, benchmarks)
|
|
10
|
+
* INSTALLED: Shipped self-checks (doctor, health-score, secret-scan selftest, packaging)
|
|
11
|
+
*
|
|
12
|
+
* Exit: 0 = all checks pass, 1 = any check failed
|
|
13
|
+
* Output: ASCII table with per-step timing
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const { execSync, spawnSync } = require('child_process');
|
|
19
|
+
|
|
20
|
+
const CURRENT_DIR = process.cwd();
|
|
21
|
+
const PACKAGE_ROOT = path.join(__dirname, '..');
|
|
22
|
+
|
|
23
|
+
// ANSI colors
|
|
24
|
+
const COLORS = {
|
|
25
|
+
GREEN: '\x1b[32m',
|
|
26
|
+
RED: '\x1b[31m',
|
|
27
|
+
YELLOW: '\x1b[33m',
|
|
28
|
+
RESET: '\x1b[0m',
|
|
29
|
+
BOLD: '\x1b[1m',
|
|
30
|
+
DIM: '\x1b[2m'
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function colorPass() {
|
|
34
|
+
return `${COLORS.GREEN}PASS${COLORS.RESET}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function colorFail() {
|
|
38
|
+
return `${COLORS.RED}FAIL${COLORS.RESET}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function colorSkip() {
|
|
42
|
+
return `${COLORS.YELLOW}SKIP${COLORS.RESET}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Detect context: repo checkout vs installed package
|
|
46
|
+
function detectContext() {
|
|
47
|
+
// Repo checkout has .git/config and package.json with npm scripts
|
|
48
|
+
const hasGitConfig = fs.existsSync(path.join(PACKAGE_ROOT, '.git', 'config'));
|
|
49
|
+
const hasPackageJson = fs.existsSync(path.join(PACKAGE_ROOT, 'package.json'));
|
|
50
|
+
const hasTestScripts = hasPackageJson && (() => {
|
|
51
|
+
try {
|
|
52
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8'));
|
|
53
|
+
return pkg.scripts && pkg.scripts['test:node'] && pkg.scripts['test:py'];
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
})();
|
|
58
|
+
|
|
59
|
+
// Installed package is in node_modules with limited structure
|
|
60
|
+
const isInstalled = !hasGitConfig && !hasTestScripts;
|
|
61
|
+
|
|
62
|
+
return isInstalled ? 'installed' : 'repo';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Format timing for display
|
|
66
|
+
function formatTiming(ms) {
|
|
67
|
+
if (ms < 1000) {
|
|
68
|
+
return `${ms}ms`;
|
|
69
|
+
}
|
|
70
|
+
const sec = (ms / 1000).toFixed(1);
|
|
71
|
+
return `${sec}s`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Format a result row
|
|
75
|
+
function formatRow(label, status, timing, hint) {
|
|
76
|
+
const statusStr = status === 'PASS' ? colorPass() : (status === 'FAIL' ? colorFail() : colorSkip());
|
|
77
|
+
const timingStr = timing ? ` [${formatTiming(timing)}]` : '';
|
|
78
|
+
const hintStr = hint ? ` — ${hint}` : '';
|
|
79
|
+
const paddedLabel = label.padEnd(50);
|
|
80
|
+
return ` ${paddedLabel} ${statusStr}${timingStr}${hintStr}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Run a subprocess and return pass/fail
|
|
84
|
+
function runSubprocess(label, args, options = {}) {
|
|
85
|
+
const { cwd = PACKAGE_ROOT, stdio = 'inherit', skipIfMissing = null } = options;
|
|
86
|
+
|
|
87
|
+
if (skipIfMissing && !fs.existsSync(skipIfMissing)) {
|
|
88
|
+
return {
|
|
89
|
+
label,
|
|
90
|
+
status: 'SKIP',
|
|
91
|
+
timing: 0,
|
|
92
|
+
passed: true,
|
|
93
|
+
hint: `${path.basename(skipIfMissing)} not found`
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const startTime = Date.now();
|
|
98
|
+
try {
|
|
99
|
+
const result = spawnSync(args[0], args.slice(1), {
|
|
100
|
+
stdio,
|
|
101
|
+
cwd,
|
|
102
|
+
encoding: 'utf8'
|
|
103
|
+
});
|
|
104
|
+
const timing = Date.now() - startTime;
|
|
105
|
+
|
|
106
|
+
if (result.status === 0 || result.status === null) {
|
|
107
|
+
return { label, status: 'PASS', timing, passed: true };
|
|
108
|
+
} else {
|
|
109
|
+
let hint;
|
|
110
|
+
if (stdio === 'pipe' && result.stderr) {
|
|
111
|
+
hint = result.stderr.split('\n')[0].slice(0, 80);
|
|
112
|
+
} else if (stdio === 'pipe' && result.stdout) {
|
|
113
|
+
hint = result.stdout.split('\n')[0].slice(0, 80);
|
|
114
|
+
}
|
|
115
|
+
return { label, status: 'FAIL', timing, passed: false, hint };
|
|
116
|
+
}
|
|
117
|
+
} catch (e) {
|
|
118
|
+
const timing = Date.now() - startTime;
|
|
119
|
+
return { label, status: 'FAIL', timing, passed: false, hint: e.message.slice(0, 80) };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Repo mode: full test suite
|
|
124
|
+
function runRepoMode() {
|
|
125
|
+
console.log(`\n${COLORS.BOLD}Running Full Test Suite (Repo Mode)${COLORS.RESET}\n`);
|
|
126
|
+
|
|
127
|
+
const results = [];
|
|
128
|
+
|
|
129
|
+
// Step 1: Node syntax check
|
|
130
|
+
results.push(runSubprocess(
|
|
131
|
+
'Node syntax check (*.mjs excluding .template.mjs)',
|
|
132
|
+
['bash', '-c', 'git ls-files "*.mjs" | grep -v ".template.mjs$" | while read f; do [ -n "$f" ] && node --check "$f" || true; done'],
|
|
133
|
+
{ stdio: 'pipe' }
|
|
134
|
+
));
|
|
135
|
+
|
|
136
|
+
// Step 2: Shell syntax check
|
|
137
|
+
results.push(runSubprocess(
|
|
138
|
+
'Shell syntax check (*.sh)',
|
|
139
|
+
['bash', '-c', 'git ls-files "*.sh" | while read f; do [ -n "$f" ] && bash -n "$f" || true; done'],
|
|
140
|
+
{ stdio: 'pipe' }
|
|
141
|
+
));
|
|
142
|
+
|
|
143
|
+
// Step 3: Node.js tests
|
|
144
|
+
results.push(runSubprocess(
|
|
145
|
+
'Node.js tests (npm run test:node)',
|
|
146
|
+
['npm', 'run', 'test:node'],
|
|
147
|
+
{ stdio: 'inherit' }
|
|
148
|
+
));
|
|
149
|
+
|
|
150
|
+
// Step 4: Shell test suites
|
|
151
|
+
results.push(runSubprocess(
|
|
152
|
+
'Shell test suites (npm run test:sh)',
|
|
153
|
+
['npm', 'run', 'test:sh'],
|
|
154
|
+
{ stdio: 'inherit' }
|
|
155
|
+
));
|
|
156
|
+
|
|
157
|
+
// Step 5: React component tests (vitest)
|
|
158
|
+
const uiWebDir = path.join(PACKAGE_ROOT, 'ui', 'web');
|
|
159
|
+
if (fs.existsSync(uiWebDir)) {
|
|
160
|
+
results.push(runSubprocess(
|
|
161
|
+
'React component tests (vitest)',
|
|
162
|
+
['bash', '-c', `cd ${JSON.stringify(uiWebDir)} && npm ci && npx vitest run`],
|
|
163
|
+
{ stdio: 'inherit' }
|
|
164
|
+
));
|
|
165
|
+
} else {
|
|
166
|
+
results.push({
|
|
167
|
+
label: 'React component tests (vitest)',
|
|
168
|
+
status: 'SKIP',
|
|
169
|
+
timing: 0,
|
|
170
|
+
passed: true,
|
|
171
|
+
hint: 'ui/web not found'
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Step 6: Python tool compile check
|
|
176
|
+
results.push(runSubprocess(
|
|
177
|
+
'Python tool import/compile smoke gate',
|
|
178
|
+
['bash', '-c', 'python -m compileall -q tools/ && python -m unittest tests.test_tools_importable -v'],
|
|
179
|
+
{ stdio: 'pipe' }
|
|
180
|
+
));
|
|
181
|
+
|
|
182
|
+
// Step 7: Python tests
|
|
183
|
+
results.push(runSubprocess(
|
|
184
|
+
'Python tests (unittest discover)',
|
|
185
|
+
['bash', '-c', 'python -m unittest discover -s tests -p "test_*.py" -v'],
|
|
186
|
+
{ stdio: 'inherit' }
|
|
187
|
+
));
|
|
188
|
+
|
|
189
|
+
// Step 8: Benchmark scorer tests
|
|
190
|
+
results.push(runSubprocess(
|
|
191
|
+
'Benchmark scorer tests (test_bench_runner)',
|
|
192
|
+
['python', '-m', 'unittest', 'tests.test_bench_runner', '-v'],
|
|
193
|
+
{ stdio: 'pipe' }
|
|
194
|
+
));
|
|
195
|
+
|
|
196
|
+
// Step 9: Benchmark reproduction
|
|
197
|
+
results.push(runSubprocess(
|
|
198
|
+
'Offline benchmark reproduction',
|
|
199
|
+
['python', 'tools/bench_runner.py', '--runner', 'mock'],
|
|
200
|
+
{ stdio: 'pipe' }
|
|
201
|
+
));
|
|
202
|
+
|
|
203
|
+
return results;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Classify doctor failures: expected pre-init findings vs real failures
|
|
207
|
+
// Expected on fresh systems: missing config, missing hooks, missing dirs (if tarball unpack incomplete)
|
|
208
|
+
// Real failures: Node version < 18, Python missing, not in git repo
|
|
209
|
+
function classifyDoctorFailure(output) {
|
|
210
|
+
const lines = output.split('\n');
|
|
211
|
+
const failures = [];
|
|
212
|
+
// Expected pre-init directory names from doctor.js checkDirectories()
|
|
213
|
+
const expectedDirs = ['daemons', 'dash', 'monitor', 'tools', 'ui'];
|
|
214
|
+
|
|
215
|
+
for (const line of lines) {
|
|
216
|
+
if (line.includes('✗') || line.includes('FAIL')) {
|
|
217
|
+
failures.push(line);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Check if all failures match expected pre-init patterns
|
|
222
|
+
const allExpected = failures.every(failure => {
|
|
223
|
+
// Pattern 1: Missing config file
|
|
224
|
+
if (failure.includes('aesop.config.json not found')) {
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
// Pattern 2: Pre-push hook not installed
|
|
228
|
+
if (failure.includes('Pre-push hook not installed')) {
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
// Pattern 3: Missing directories — verify line contains only expected directory names
|
|
232
|
+
if (failure.includes('Missing:')) {
|
|
233
|
+
// Extract the part after "Missing:"
|
|
234
|
+
const afterMissing = failure.substring(failure.indexOf('Missing:') + 8);
|
|
235
|
+
// Check if all mentioned items are valid directory names
|
|
236
|
+
const items = afterMissing.split(',').map(item => item.trim());
|
|
237
|
+
return items.every(item => expectedDirs.includes(item));
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
return { allExpected, failures };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Installed mode: shipped self-checks
|
|
246
|
+
function runInstalledMode() {
|
|
247
|
+
console.log(`\n${COLORS.BOLD}Running Shipped Self-Checks (Installed Mode)${COLORS.RESET}\n`);
|
|
248
|
+
|
|
249
|
+
const results = [];
|
|
250
|
+
|
|
251
|
+
// Step 1: Doctor check — distinguish expected pre-init findings from real failures
|
|
252
|
+
const doctorJs = path.join(PACKAGE_ROOT, 'tools', 'doctor.js');
|
|
253
|
+
const doctorResult = (() => {
|
|
254
|
+
if (!fs.existsSync(doctorJs)) {
|
|
255
|
+
return {
|
|
256
|
+
label: 'Preflight checks (aesop doctor)',
|
|
257
|
+
status: 'SKIP',
|
|
258
|
+
timing: 0,
|
|
259
|
+
passed: true,
|
|
260
|
+
hint: 'doctor.js not found'
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const startTime = Date.now();
|
|
265
|
+
try {
|
|
266
|
+
const result = spawnSync('node', [doctorJs], {
|
|
267
|
+
stdio: 'pipe',
|
|
268
|
+
encoding: 'utf8'
|
|
269
|
+
});
|
|
270
|
+
const timing = Date.now() - startTime;
|
|
271
|
+
|
|
272
|
+
if (result.status === 0) {
|
|
273
|
+
return { label: 'Preflight checks (aesop doctor)', status: 'PASS', timing, passed: true };
|
|
274
|
+
} else {
|
|
275
|
+
// Doctor failed; classify whether it's expected pre-init findings or real failure
|
|
276
|
+
const output = result.stdout || result.stderr || '';
|
|
277
|
+
const { allExpected } = classifyDoctorFailure(output);
|
|
278
|
+
|
|
279
|
+
if (allExpected) {
|
|
280
|
+
// Expected pre-init findings (missing config, hooks, etc.) — pass but note it
|
|
281
|
+
return {
|
|
282
|
+
label: 'Preflight checks (aesop doctor)',
|
|
283
|
+
status: 'PASS',
|
|
284
|
+
timing,
|
|
285
|
+
passed: true,
|
|
286
|
+
hint: 'Expected pre-init findings (run `aesop doctor` to initialize)'
|
|
287
|
+
};
|
|
288
|
+
} else {
|
|
289
|
+
// Real failure (e.g., Node/Python missing, not in git repo)
|
|
290
|
+
const hint = result.stderr ? result.stderr.split('\n')[0].slice(0, 80) : 'doctor check failed';
|
|
291
|
+
return { label: 'Preflight checks (aesop doctor)', status: 'FAIL', timing, passed: false, hint };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} catch (e) {
|
|
295
|
+
const timing = Date.now() - startTime;
|
|
296
|
+
return {
|
|
297
|
+
label: 'Preflight checks (aesop doctor)',
|
|
298
|
+
status: 'FAIL',
|
|
299
|
+
timing,
|
|
300
|
+
passed: false,
|
|
301
|
+
hint: e.message.slice(0, 80)
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
})();
|
|
305
|
+
results.push(doctorResult);
|
|
306
|
+
|
|
307
|
+
// Step 2: Health score check
|
|
308
|
+
const healthScoreJs = path.join(PACKAGE_ROOT, 'tools', 'health-score.js');
|
|
309
|
+
results.push(runSubprocess(
|
|
310
|
+
'Health score check (aesop health-score)',
|
|
311
|
+
['node', healthScoreJs],
|
|
312
|
+
{ stdio: 'pipe', skipIfMissing: healthScoreJs }
|
|
313
|
+
));
|
|
314
|
+
|
|
315
|
+
// Step 3: Secret-scan selftest
|
|
316
|
+
const scannerSelftest = path.join(PACKAGE_ROOT, 'tools', 'scanner_selftest.py');
|
|
317
|
+
results.push(runSubprocess(
|
|
318
|
+
'Secret-scan selftest',
|
|
319
|
+
['python', scannerSelftest],
|
|
320
|
+
{ stdio: 'pipe', skipIfMissing: scannerSelftest }
|
|
321
|
+
));
|
|
322
|
+
|
|
323
|
+
// Step 4: Packaging assertions (check required files are shipped)
|
|
324
|
+
const startTime = Date.now();
|
|
325
|
+
let packagingPassed = true;
|
|
326
|
+
let packagingHint;
|
|
327
|
+
try {
|
|
328
|
+
const requiredDirs = ['tools', 'daemons', 'dash', 'monitor', 'ui', 'docs'];
|
|
329
|
+
const requiredFiles = ['aesop.config.example.json', 'README.md', 'LICENSE'];
|
|
330
|
+
|
|
331
|
+
const missing = [];
|
|
332
|
+
for (const dir of requiredDirs) {
|
|
333
|
+
const dirPath = path.join(PACKAGE_ROOT, dir);
|
|
334
|
+
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
|
335
|
+
missing.push(dir);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
for (const file of requiredFiles) {
|
|
340
|
+
const filePath = path.join(PACKAGE_ROOT, file);
|
|
341
|
+
if (!fs.existsSync(filePath)) {
|
|
342
|
+
missing.push(file);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (missing.length > 0) {
|
|
347
|
+
packagingPassed = false;
|
|
348
|
+
packagingHint = `Missing: ${missing.join(', ')}`;
|
|
349
|
+
}
|
|
350
|
+
} catch (e) {
|
|
351
|
+
packagingPassed = false;
|
|
352
|
+
packagingHint = e.message.slice(0, 80);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const packagingTiming = Date.now() - startTime;
|
|
356
|
+
results.push({
|
|
357
|
+
label: 'Packaging assertions',
|
|
358
|
+
status: packagingPassed ? 'PASS' : 'FAIL',
|
|
359
|
+
timing: packagingTiming,
|
|
360
|
+
passed: packagingPassed,
|
|
361
|
+
hint: packagingHint
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
return results;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Main execution
|
|
368
|
+
(async function main() {
|
|
369
|
+
try {
|
|
370
|
+
const context = detectContext();
|
|
371
|
+
console.log(`${COLORS.DIM}Context: ${context}${COLORS.RESET}`);
|
|
372
|
+
|
|
373
|
+
let results;
|
|
374
|
+
if (context === 'repo') {
|
|
375
|
+
results = runRepoMode();
|
|
376
|
+
} else {
|
|
377
|
+
results = runInstalledMode();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Print results table
|
|
381
|
+
console.log(`\n${COLORS.BOLD}Results${COLORS.RESET}\n`);
|
|
382
|
+
for (const result of results) {
|
|
383
|
+
console.log(formatRow(result.label, result.status, result.timing, result.hint));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Summary
|
|
387
|
+
const passCount = results.filter(r => r.passed).length;
|
|
388
|
+
const failCount = results.filter(r => !r.passed).length;
|
|
389
|
+
const totalTime = results.reduce((sum, r) => sum + (r.timing || 0), 0);
|
|
390
|
+
|
|
391
|
+
console.log(`\n${COLORS.BOLD}Summary: ${passCount}/${results.length} checks passed${COLORS.RESET}`);
|
|
392
|
+
console.log(`${COLORS.DIM}Total time: ${formatTiming(totalTime)}${COLORS.RESET}\n`);
|
|
393
|
+
|
|
394
|
+
if (failCount === 0) {
|
|
395
|
+
console.log(`${COLORS.GREEN}✓ All checks passed${COLORS.RESET}\n`);
|
|
396
|
+
process.exitCode = 0;
|
|
397
|
+
} else {
|
|
398
|
+
console.log(`${COLORS.RED}✗ ${failCount} check(s) failed${COLORS.RESET}\n`);
|
|
399
|
+
process.exitCode = 1;
|
|
400
|
+
}
|
|
401
|
+
} catch (err) {
|
|
402
|
+
console.error(`${COLORS.RED}Error: ${err.message}${COLORS.RESET}`);
|
|
403
|
+
process.exit(1);
|
|
404
|
+
}
|
|
405
|
+
})();
|