dsh-vibe-math 2.7.3 → 2.8.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 (101) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +682 -682
  3. package/README.md +683 -683
  4. package/docs/AUDIT-CHECKLIST.md +755 -744
  5. package/docs/COMPAT-AUDIT-ROUND2.md +351 -351
  6. package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
  7. package/docs/audit-methodology.md +93 -0
  8. package/docs/final-paper.md +172 -172
  9. package/docs/formal-verification.md +613 -588
  10. package/docs/generate_framework_diagram_v2.py +114 -114
  11. package/docs/generate_framework_diagram_v3.py +127 -127
  12. package/docs/math-computation.md +195 -194
  13. package/docs/release-notes/README.en.md +1 -0
  14. package/docs/release-notes/README.md +1 -0
  15. package/docs/release-notes/RELEASE-NOTES-2.0.22.md +112 -112
  16. package/docs/release-notes/RELEASE-NOTES-2.1.0.md +143 -143
  17. package/docs/release-notes/RELEASE-NOTES-2.2.0.md +266 -266
  18. package/docs/release-notes/RELEASE-NOTES-2.2.1.md +43 -43
  19. package/docs/release-notes/RELEASE-NOTES-2.2.2.md +88 -88
  20. package/docs/release-notes/RELEASE-NOTES-2.3.0.md +207 -207
  21. package/docs/release-notes/RELEASE-NOTES-2.3.1.md +134 -134
  22. package/docs/release-notes/RELEASE-NOTES-2.3.10.md +105 -105
  23. package/docs/release-notes/RELEASE-NOTES-2.3.11.md +57 -57
  24. package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -80
  25. package/docs/release-notes/RELEASE-NOTES-2.3.2.md +145 -145
  26. package/docs/release-notes/RELEASE-NOTES-2.3.3.md +115 -115
  27. package/docs/release-notes/RELEASE-NOTES-2.3.4.md +69 -69
  28. package/docs/release-notes/RELEASE-NOTES-2.3.5.md +63 -63
  29. package/docs/release-notes/RELEASE-NOTES-2.3.6.md +66 -66
  30. package/docs/release-notes/RELEASE-NOTES-2.3.7.md +59 -59
  31. package/docs/release-notes/RELEASE-NOTES-2.3.8.md +45 -45
  32. package/docs/release-notes/RELEASE-NOTES-2.3.9.md +70 -70
  33. package/docs/release-notes/RELEASE-NOTES-2.8.0.en.md +65 -0
  34. package/docs/release-notes/RELEASE-NOTES-2.8.0.md +65 -0
  35. package/docs/release-playbook.md +103 -0
  36. package/docs/slv-playbook.md +117 -0
  37. package/docs/test-timing.md +158 -157
  38. package/docs//346/236/266/346/236/204/345/233/276.md +77 -77
  39. package/installer.js +792 -792
  40. package/package.json +13 -3
  41. package/prompt-corpus-v2/formal-verify-v2.json +2 -2
  42. package/prompt-corpus-v2/formal-verify-v2.md +4 -0
  43. package/prompt-corpus-v3/formal-verify-v3.json +7 -7
  44. package/prompt-corpus-v3/formal-verify-v3.md +14 -0
  45. package/prompt-corpus-v5/prompt-corpus-v5.json +61 -61
  46. package/prompt-corpus-v5/prompt-corpus-v5.md +122 -0
  47. package/scripts/clean-temp.mjs +234 -0
  48. package/tests/audit-engine-faces.mjs +298 -0
  49. package/tests/audit-formal-sensitivity.mjs +345 -342
  50. package/tests/audit-installer-compat.test.mjs +256 -256
  51. package/tests/audit-math-computation-sensitivity.mjs +1 -1
  52. package/tests/audit-persona-sensitivity.mjs +323 -323
  53. package/tests/audit-persona-surface.test.mjs +431 -431
  54. package/tests/audit-prompt-duplication.mutants.mjs +3 -2
  55. package/tests/audit-prompt-invariants.mjs +589 -546
  56. package/tests/audit-registration.mjs +78 -78
  57. package/tests/audit-spec-traceability.mjs +193 -193
  58. package/tests/audit-status-report-fields.mjs +172 -172
  59. package/tests/audit-v5-integrity.mjs +795 -795
  60. package/tests/audit-v5-sensitivity.mjs +433 -432
  61. package/tests/e2e-v5-round2.test.mjs +2453 -2426
  62. package/tests/formal-verify-v2.test.mjs +1806 -1800
  63. package/tests/formal-verify-v3.test.mjs +1757 -1750
  64. package/tests/formal-verify-v4.test.mjs +2114 -2106
  65. package/tests/formal-verify-v5.test.mjs +774 -767
  66. package/tests/math-computation-discovery.mutants.mjs +3 -3
  67. package/tests/math-computation-shared.test.mjs +122 -5
  68. package/tests/math-computation-v2.test.mjs +14 -2
  69. package/tests/math-computation-v3.test.mjs +14 -2
  70. package/tests/math-computation-v4.test.mjs +739 -739
  71. package/tests/prompt-v5-integrity.test.mjs +1291 -1291
  72. package/tests/run-tests.mjs +393 -332
  73. package/tests/selfdrive-v5.mjs +560 -560
  74. package/tests/temp-hygiene.mutants.mjs +80 -0
  75. package/tests/v2-fix-probes.mutants.mjs +4 -0
  76. package/tests/v2-fix-probes.test.mjs +1034 -1033
  77. package/tests/v2v3-interrupt.mutants.mjs +111 -0
  78. package/tests/v3-fix-probes.mutants.mjs +99 -95
  79. package/tests/v3-fix-probes.test.mjs +891 -890
  80. package/tests/v4-final-paper.mutants.mjs +108 -0
  81. package/tests/v5-institute-fixes.mutants.mjs +218 -190
  82. package/vibe-math-v2/agent.cordis.yml +376 -376
  83. package/vibe-math-v2/math-computation.js +17 -15
  84. package/vibe-math-v2/preset.yml +2 -2
  85. package/vibe-math-v2/v2/347/232/204/351/242/235/345/244/226/350/246/201/346/261/202.txt +21 -21
  86. package/vibe-math-v2/vibe-math-v2.js +5078 -5011
  87. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +486 -486
  88. package/vibe-math-v3/agent.cordis.yml +420 -420
  89. package/vibe-math-v3/math-computation.js +17 -15
  90. package/vibe-math-v3/preset.yml +2 -2
  91. package/vibe-math-v3/vibe-math-v3.js +6215 -6149
  92. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +649 -649
  93. package/vibe-math-v4/agent.cordis.yml +438 -438
  94. package/vibe-math-v4/math-computation.js +17 -15
  95. package/vibe-math-v4/preset.yml +2 -2
  96. package/vibe-math-v4/vibe-math-v4.js +4490 -4422
  97. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1015 -1015
  98. package/vibe-math-v5/math-computation.js +17 -15
  99. package/vibe-math-v5/vibe-math-v5.js +93 -11
  100. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1728
  101. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
@@ -1,332 +1,393 @@
1
- #!/usr/bin/env node
2
- /**
3
- * PARALLEL TEST RUNNER — run every shipped suite AND every probe (or a filtered subset)
4
- * concurrently and report per-suite timings, so the strategy for the next run is chosen
5
- * from DATA instead of guesswork.
6
- *
7
- * Why this exists: the suites are wildly uneven (one suite is ~160 s, most are under 2 s), so a
8
- * sequential sweep spends almost all of its wall time waiting for the slowest one. Running them
9
- * with a worker pool makes the sweep bounded by the slowest SUITE rather than by their SUM.
10
- * On this machine (4 cores) the sweep went from ~5.5 min to ~2 min; see ../docs/test-timing.md.
11
- *
12
- * WHAT IS COLLECTED (Round B): `tests/` holds two families of `.mjs`:
13
- * · `*.test.mjs` — the suites proper;
14
- * · every other `.mjs` beside this runner — the PROBES that prove those suites are not
15
- * vacuous (sensitivity mutations, the registration surface, the self-driving guards).
16
- * Collecting only the first family was a false green: two probe suites sat at exit 1 while
17
- * every report said "30/30 green". Both families are therefore collected, and a script can
18
- * only stay out through the explicit, documented `NEEDS_ARGS` list below — a stale entry in
19
- * that list is a hard error in a development checkout (`.git` present), so no script can be
20
- * dropped silently there. Scripts whose bare run under-covers get argument `VARIANTS` (e.g.
21
- * `audit-registration.mjs` defaults to v3 alone). A PUBLISHED tarball ships only a subset of
22
- * `tests/`; there the missing entries are reported instead of aborting the run.
23
- *
24
- * Every suite already isolates itself (each creates its own mkdtemp workspace), so parallelism is
25
- * safe. Suites that WRITE a corpus take a per-run corpus dir from an env var; this runner points
26
- * them at a scratch dir when it runs a suite more than once, which it never does — but the probe
27
- * runner (audit-formal-sensitivity.mjs) does, and it passes its own dirs.
28
- *
29
- * Usage:
30
- * node tests/run-tests.mjs # every suite + probe, concurrency = min(4, cpus)
31
- * node tests/run-tests.mjs --concurrency=6
32
- * node tests/run-tests.mjs --only formal # substring match on the file name (repeatable, OR)
33
- * node tests/run-tests.mjs --exclude e2e-v4 # substring to skip (repeatable)
34
- * node tests/run-tests.mjs --json # machine-readable summary on stdout
35
- */
36
- import { spawn } from 'node:child_process'
37
- import { existsSync, readdirSync } from 'node:fs'
38
- import { cpus } from 'node:os'
39
- import { join } from 'node:path'
40
- import { fileURLToPath } from 'node:url'
41
-
42
- const HERE = fileURLToPath(new URL('./', import.meta.url))
43
- // Suites live beside this runner, but they resolve the presets and the corpora from the
44
- // repository root, so they must keep running with that as their working directory.
45
- const REPO = fileURLToPath(new URL('../', import.meta.url))
46
- const argv = process.argv.slice(2)
47
- // Accept BOTH `--only=x` and `--only x` (the help text used the space form, which a value-taking
48
- // flag() did not understand — the filter silently did nothing and every suite still ran).
49
- const flag = (name) => {
50
- const out = []
51
- for (let i = 0; i < argv.length; i++) {
52
- const a = argv[i]
53
- if (a === '--' + name && i + 1 < argv.length && !argv[i + 1].startsWith('--')) out.push(argv[++i])
54
- else if (a.startsWith('--' + name + '=')) out.push(a.split('=').slice(1).join('='))
55
- }
56
- return out
57
- }
58
- const has = (name) => argv.includes('--' + name)
59
- const only = flag('only')
60
- const exclude = flag('exclude')
61
- const asJson = has('json')
62
- const concurrency = Math.max(1, Number(flag('concurrency')[0] || Math.min(4, cpus().length)))
63
-
64
- const SELF = 'run-tests.mjs'
65
- // Per-suite HARD timeout (GATE_SUITE_TIMEOUT_MS overrides, ms). It bounds an intermittent hang so the
66
- // gate always terminates and a hang is reported - never mistaken for "still running". 180 s comes from
67
- // measurements: whole-gate wall ~200 s, slowest honest suite 135-144 s (audit-math-computation-sensitivity),
68
- // so 180 s bounds a hang without killing an honest slow suite. Do NOT raise it silently to "fix" reds.
69
- const SUITE_TIMEOUT_MS = Math.max(1000, Number(process.env.GATE_SUITE_TIMEOUT_MS || 180000))
70
-
71
- // NAMED per-suite overrides for suites that are HONEST but slow (measured, not guessed). Raising a
72
- // limit is a documented act: update the numbers here AND say so in the commit message - never silently.
73
- // v2-fix-probes.mutants.mjs: ~43.1 s per family x 10 families ~= 430 s measured (inner child cap 120 s)
74
- // v3-fix-probes.mutants.mjs: ~23.5 s per family x 11 families ~= 260 s measured (inner child cap 120 s)
75
- // Both report hangs=[] with their own 120 s child cap, i.e. they are slow, not hung - so the 180 s
76
- // default would misreport them. Everything else keeps the 180 s default.
77
- const TIMEOUT_OVERRIDES = {
78
- 'v2-fix-probes.mutants.mjs': 900000,
79
- 'v3-fix-probes.mutants.mjs': 900000,
80
- // v5-institute-fixes.mutants.mjs: MEASURED 201.9 s wall (hangs=[], skipped=[], ALL MUTANTS RED
81
- // AS REQUIRED) - i.e. OVER the 180 s default, so it gets the same 900 s as the v2/v3 families.
82
- // History worth keeping: a writer CLAIMED this family fitted its override when it did not, and the
83
- // gate caught it as "FAILED: v5-institute-fixes.mutants.mjs [probe] (TIMEOUT after 180s)" - the
84
- // named-timeout mechanism doing its job. For contrast, formal-verify-v4.mutants.mjs MEASURED
85
- // 70.9 s wall, i.e. ample headroom, so it needs no override.
86
- 'v5-institute-fixes.mutants.mjs': 900000,
87
- }
88
- /** One place decides a job limit: explicit job value, then the named override, then the default. */
89
- function jobLimit(job) { return job.timeoutMs || TIMEOUT_OVERRIDES[job.file] || SUITE_TIMEOUT_MS }
90
- /**
91
- * DIAGNOSABILITY (protocol: every red must name an assertion): a failing suite's assertion NAMES are
92
- * what a reader needs, and they must appear under the FAILED line - not only in the suite's own last
93
- * stdout line. Extract the failing-assertion lines (this repo prints ` - <name>` or ` FAIL - <name>`,
94
- * and suites that abort print `FAILURES:`/`Error:` markers), and fall back to the raw tail when a suite
95
- * names nothing.
96
- */
97
- /**
98
- * A reporting line's OWN prefix decides whether it names a failure. Anchored at the start (after the
99
- * reporter's indent) so a token appearing INSIDE a passing line (e.g. ` ok - FAIL - ★★★ message`) can
100
- * never be extracted as a failure — that false positive made a green run look red.
101
- * own prefix: `FAIL`, `FAIL -`/`FAIL:`, `FAILURES:`; assertion bullets `- <name>` / `* <name>`; `✗`/`✘`.
102
- * line-anchored abort markers: `SyntaxError:`, `TypeError:`, `ReferenceError:`, `Error:`.
103
- * `ok`-prefixed lines are excluded explicitly, so a passing bullet is never counted.
104
- */
105
- function namedFailureLines(text) {
106
- const out = []
107
- for (const raw of String(text || '').split(/\r?\n/)) {
108
- const l = raw.replace(/\s+$/, '')
109
- if (!l.trim()) continue
110
- if (/^\s*ok\b/.test(l)) continue // a PASSING line, whatever it contains
111
- if (/^\s*(?:FAIL\b|FAILURES:|✗|✘)/.test(l)) { out.push(l); continue }
112
- if (/^\s*(?:-|\*)\s+\S/.test(l)) { out.push(l); continue }
113
- if (/^\s*(?:SyntaxError|TypeError|ReferenceError|Error):/.test(l)) { out.push(l); continue }
114
- }
115
- return out
116
- }
117
-
118
- function failureDetail(r) {
119
- const lines = (String(r.out || '') + '\n' + String(r.err || '')).split('\n').map((l) => l.replace(/\s+$/, '')).filter(Boolean)
120
- const named = namedFailureLines(String(r.out || '') + '\n' + String(r.err || ''))
121
- const chosen = (named.length ? named : lines).slice(-40)
122
- return chosen.length ? chosen : ['(no captured output)']
123
- }
124
- // `--self-check`: prove the diagnostics above actually surface a NAME. Runs one synthetic failing child
125
- // through the same extractor and asserts the extracted detail contains its assertion name; the mutant
126
- // that strips `failureDetail` makes this red.
127
- /** ONE place builds the failure line, so the reporter and the self-check cannot diverge. */
128
- function failedLine(b) {
129
- const kind = '[' + b.job.kind + ']'
130
- const why = b.timedOut ? 'TIMEOUT after ' + Math.round(jobLimit(b.job) / 1000) + 's'
131
- : 'exit ' + b.code + (b.job.expectExit ? ', required exit ' + b.job.expectExit : '')
132
- return ' FAILED: ' + label(b.job) + ' ' + kind + ' (' + why + ')'
133
- }
134
-
135
- if (process.argv.includes('--self-check')) {
136
- const { spawnSync } = await import('node:child_process')
137
- const synth = spawnSync(process.execPath, ['-e', "console.error(' - synthetic assertion name XYZ'); process.exit(1)"], { encoding: 'utf8' })
138
- const detail = failureDetail({ out: synth.stdout, err: synth.stderr }).join('\n')
139
- const okSelf = detail.indexOf('synthetic assertion name XYZ') !== -1
140
- console.log((okSelf ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': a failing child yields a NAMED assertion line (' + detail.trim().slice(0, 80) + ')')
141
- // FALSE-POSITIVE direction: a PASSING line whose message literally contains `FAIL - ` must extract
142
- // NOTHING (the v2/v3 owner saw a green run look red this way).
143
- const leaked = namedFailureLines(' ok - FAIL - ★★★ [F2cap] the assertion message mentions FAIL - inside it\n')
144
- const okPassing = leaked.length === 0
145
- console.log((okPassing ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': a passing line containing "FAIL - " is NOT extracted as a failure (' + JSON.stringify(leaked) + ')')
146
- // FALSE-NEGATIVE direction stays closed: a genuine FAIL line and a line-anchored abort marker extract.
147
- const okReal = namedFailureLines(' FAIL - ★★★ real failure\n').length === 1
148
- const okAbort = namedFailureLines('TypeError: boom\n').length === 1
149
- console.log((okReal && okAbort ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': a genuine FAIL line and an abort marker are still extracted')
150
- // TIMEOUT case on the REAL path: a synthetic job that sleeps past a 1 s per-job limit, run through
151
- // the SAME runSuite/reporting path the gate uses. A string-only check could pass with a half-armed
152
- // timer; this one cannot, because it reads the run own timedOut/exit and the line the reporter builds.
153
- const slow = await runSuite({ file: '(synthetic-sleeper)', args: [], expectExit: 0, kind: 'probe', eval: 'setTimeout(() => {}, 5000)', timeoutMs: 1000 })
154
- const killedByRunner = slow.timedOut === true && slow.code !== 0
155
- const line = failedLine(slow)
156
- const namedTimeout = /FAILED: .*\[probe\] \(TIMEOUT after 1s\)/.test(line)
157
- console.log((killedByRunner ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': the RUNNER kills a sleeping job at its per-job limit (timedOut=true, exit=' + slow.code + ')')
158
- console.log((namedTimeout ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': the failure line names the timeout: ' + line.trim())
159
- // OVERRIDE case (real path): the named override must actually EXTEND the limit. A job that sleeps
160
- // 1.5 s would be killed by 1 s, so it must survive under the override and report its own seconds.
161
- const NAMED_OVERRIDES = ['v2-fix-probes.mutants.mjs', 'v3-fix-probes.mutants.mjs', 'v5-institute-fixes.mutants.mjs']
162
- const overrideOk = NAMED_OVERRIDES.every((f) => jobLimit({ file: f }) === 900000)
163
- && jobLimit({ file: 'anything-else.mjs' }) === SUITE_TIMEOUT_MS
164
- && jobLimit({ file: 'formal-verify-v4.mutants.mjs' }) === SUITE_TIMEOUT_MS // measured 70.9 s: no override needed
165
- const survived = await runSuite({ file: '(synthetic-ok)', args: [], expectExit: 0, kind: 'probe', eval: 'setTimeout(() => {}, 1500)', timeoutMs: 900000 })
166
- const extended = survived.timedOut === false && survived.code === 0
167
- const overrideLine = failedLine({ job: { file: 'v2-fix-probes.mutants.mjs', args: [], kind: 'probe', expectExit: 0 }, code: null, timedOut: true })
168
- const namesOverride = /TIMEOUT after 900s/.test(overrideLine)
169
- console.log((overrideOk ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': a named override is resolved for its suite and nothing else')
170
- console.log((extended ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': a job that would die at 1 s SURVIVES under the override (real runSuite)')
171
- console.log((namesOverride ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': a timed-out override reports its own limit (' + overrideLine.trim() + ')')
172
- process.exit(okSelf && okPassing && okReal && okAbort && killedByRunner && namedTimeout && overrideOk && extended && namesOverride ? 0 : 1)
173
- }
174
- // The scripts that genuinely cannot run without arguments. They are named here (with the exact
175
- // command a human must run) instead of being omitted quietly: an entry that no longer exists
176
- // aborts the run, so a rename cannot turn into a silent gap.
177
- const NEEDS_ARGS = {
178
- 'audit-tool-exec.mjs': 'node tests/audit-tool-exec.mjs <preset-js> <tool-name> (calls one real tool through the host path)',
179
- }
180
- // Argument variants, run IN ADDITION to the bare run unless `replaceBare` says the bare run is a
181
- // strict subset of them. `expectExit` documents a probe whose SUCCESS is a non-zero exit (it
182
- // mutates the source and requires the guarded check to fail): anything else is a FAIL here.
183
- const VARIANTS = [
184
- // A bare `audit-registration.mjs` audits ONLY v3 (its `process.argv[2] || v3` default), so the
185
- // four presets are run explicitly and the redundant bare run is replaced.
186
- { file: 'audit-registration.mjs', args: ['vibe-math-v2/vibe-math-v2.js'], replaceBare: true },
187
- { file: 'audit-registration.mjs', args: ['vibe-math-v3/vibe-math-v3.js'] },
188
- { file: 'audit-registration.mjs', args: ['vibe-math-v4/vibe-math-v4.js'] },
189
- { file: 'audit-registration.mjs', args: ['vibe-math-v5/vibe-math-v5.js'] },
190
- // The invariant scanner's own falsifiability check: it exits 0 only when every injected
191
- // mutation really made a run go RED (and the unmutated control stayed green).
192
- { file: 'audit-prompt-invariants.mjs', args: ['--self-probe'] },
193
- // The participant-set proof, shipped: the guard's own predicates against broken strings.
194
- { file: 'audit-participant-set-parity.mjs', args: ['--self-probe'] },
195
- // §7/§8 predicates, shipped: same functions fed broken strings.
196
- { file: 'audit-math-computation-parity.mjs', args: ['--self-probe'] },
197
- // §30 verify-provenance predicates (case 3), shipped.
198
- { file: 'math-computation-shared.test.mjs', args: ['--self-probe'] },
199
- // The two-registration-paths probe, inverted: it applies a real description mutation and
200
- // REQUIRES the parity check to exit 1 (exit 2 means the mutation no longer applies = drift).
201
- {
202
- file: 'audit-v3-registration-parity.mjs',
203
- args: ['--self-probe', '["TOOL_DESC.vibe_math_start","TOOL_DESC.vibe_math_startX"]'],
204
- expectExit: 1,
205
- },
206
- ]
207
-
208
- const present = readdirSync(HERE).filter((f) => f.endsWith('.mjs')).sort()
209
- const presentSet = new Set(present)
210
- // A published tarball ships only a SUBSET of `tests/` (see docs/test-timing.md §1), so the
211
- // scripts that stay behind are simply ABSENT there. The skip/variant lists are therefore
212
- // enforced strictly in a development checkout (a `.git` entry marks one) and merely REPORTED —
213
- // never silently dropped, they still appear in every run's output — in a partial tree.
214
- const DEV_CHECKOUT = existsSync(join(REPO, '.git'))
215
- const stale = Object.keys(NEEDS_ARGS).filter((f) => !presentSet.has(f))
216
- if (stale.length && DEV_CHECKOUT) {
217
- console.error('NEEDS_ARGS names a script that no longer exists: ' + stale.join(', ') + ' — fix the skip list, do not delete the entry blindly')
218
- process.exit(2)
219
- }
220
- function label(j) { return j.file + (j.args.length ? ' ' + j.args.join(' ') : '') }
221
- const replacedBare = new Set(VARIANTS.filter((v) => v.replaceBare).map((v) => v.file))
222
- let suites = []
223
- for (const file of present) {
224
- if (file === SELF || NEEDS_ARGS[file] || replacedBare.has(file)) continue
225
- suites.push({ file, args: [], expectExit: 0, kind: file.endsWith('.test.mjs') ? 'suite' : 'probe' })
226
- }
227
- for (const v of VARIANTS) {
228
- if (!presentSet.has(v.file)) {
229
- if (DEV_CHECKOUT) { console.error('a VARIANTS entry names a missing script: ' + v.file); process.exit(2) }
230
- continue
231
- }
232
- // A VARIANT is an EXTRA instrumented run of the SAME script (argument variants), never an additional
233
- // suite: the counting unit for `suites` is "the suites proper", one per script. Classifying by file name
234
- // here would inflate `suites` whenever a suite gains a `--self-probe` job (R18: it did, 44 -> 45).
235
- suites.push({ file: v.file, args: v.args || [], expectExit: v.expectExit || 0, kind: 'probe' })
236
- }
237
- suites.sort((a, b) => (label(a) < label(b) ? -1 : 1))
238
- if (only.length) suites = suites.filter((j) => only.some((o) => label(j).includes(o)))
239
- if (exclude.length) suites = suites.filter((j) => !exclude.some((o) => label(j).includes(o)))
240
- if (!suites.length) { console.error('no suites matched'); process.exit(2) }
241
-
242
- // --counts: print the DERIVED suite/probe totals (the same job list the gate runs) as JSON, then
243
- // exit. Docs quote these numbers, so they must be derived and checked rather than typed by hand.
244
- if (process.argv.includes('--counts')) {
245
- const suiteN = suites.filter((j) => j.kind === 'suite').length
246
- console.log(JSON.stringify({ total: suites.length, suites: suiteN, probes: suites.length - suiteN }))
247
- process.exit(0)
248
- }
249
-
250
- function runSuite(job) {
251
- return new Promise((resolve) => {
252
- const t0 = Date.now()
253
- const argv = job.eval ? ['-e', job.eval] : [join(HERE, job.file), ...job.args]
254
- const limitMs = jobLimit(job)
255
- const child = spawn(process.execPath, argv, { cwd: REPO, stdio: ['ignore', 'pipe', 'pipe'] })
256
- let timedOut = false
257
- const killer = setTimeout(() => { timedOut = true; try { child.kill('SIGKILL') } catch (e) { /* already gone */ } }, limitMs)
258
- const clearKiller = () => clearTimeout(killer)
259
- let out = '', err = ''
260
- child.stdout.on('data', (d) => { out += d.toString() })
261
- child.stderr.on('data', (d) => { err += d.toString() })
262
- child.on('error', (e) => { clearKiller(); resolve({ job, code: -1, ms: Date.now() - t0, out, err: err + '\n' + String(e), timedOut }) })
263
- child.on('close', (code) => { clearKiller(); resolve({ job, code, ms: Date.now() - t0, out, err, timedOut }) })
264
- })
265
- }
266
-
267
- const results = []
268
- let cursor = 0
269
- const started = Date.now()
270
- async function worker(id) {
271
- for (;;) {
272
- const i = cursor++
273
- if (i >= suites.length) return
274
- const r = await runSuite(suites[i])
275
- const tail = String(r.out).trim().split('\n').filter(Boolean).slice(-1)[0] || ''
276
- r.ok = r.code === r.job.expectExit
277
- results[i] = r
278
- if (!asJson) {
279
- const mark = r.ok ? 'PASS' : 'FAIL'
280
- console.log(
281
- mark + ' ' + r.job.file.padEnd(34) +
282
- ' exit=' + String(r.code).padStart(3) + (r.job.expectExit ? '(want ' + r.job.expectExit + ')' : ' ') +
283
- ' ' + (r.ms / 1000).toFixed(1).padStart(6) + 's' +
284
- (r.job.args.length ? ' [' + r.job.args.join(' ').slice(0, 40) + ']' : '') +
285
- (tail ? ' ' + tail.slice(0, 60) : '')
286
- )
287
- if (!r.ok) {
288
- for (const l of failureDetail(r)) console.log(' ' + l)
289
- }
290
- } else if (!r.ok) {
291
- // In --json mode keep stdout machine-readable: the failure detail travels in the JSON.
292
- r.tailDetail = failureDetail(r).join('\n')
293
- }
294
- }
295
- }
296
- await Promise.all(Array.from({ length: Math.min(concurrency, suites.length) }, (_, i) => worker(i)))
297
-
298
- const wall = (Date.now() - started) / 1000
299
- const sum = results.reduce((a, r) => a + r.ms, 0) / 1000
300
- const bad = results.filter((r) => !r.ok)
301
- const slowest = results.slice().sort((a, b) => b.ms - a.ms).slice(0, 5)
302
- const suiteCount = results.filter((r) => r.job.kind === 'suite').length
303
-
304
- if (asJson) {
305
- console.log(JSON.stringify({
306
- concurrency, wallSeconds: Number(wall.toFixed(1)), sumSeconds: Number(sum.toFixed(1)),
307
- pass: results.length - bad.length, fail: bad.length,
308
- suites: suiteCount, probes: results.length - suiteCount,
309
- devCheckout: DEV_CHECKOUT,
310
- skippedNeedsArgs: NEEDS_ARGS,
311
- runs: results.map((r) => ({
312
- file: r.job.file, args: r.job.args, expectExit: r.job.expectExit, exit: r.code,
313
- seconds: Number((r.ms / 1000).toFixed(1)),
314
- ...(r.tailDetail ? { detail: r.tailDetail } : {}),
315
- })),
316
- }, null, 2))
317
- } else {
318
- console.log('')
319
- console.log('concurrency ' + concurrency + ' · wall ' + wall.toFixed(1) + 's · sum of suite times ' + sum.toFixed(1) + 's'
320
- + ' · speed-up x' + (sum / Math.max(wall, 0.001)).toFixed(2))
321
- console.log('slowest: ' + slowest.map((r) => r.job.file.replace('.test.mjs', '') + ' ' + (r.ms / 1000).toFixed(1) + 's').join(' · '))
322
- console.log('TOTAL ' + results.length + ' PASS ' + (results.length - bad.length) + ' FAIL ' + bad.length
323
- + ' (suites ' + suiteCount + ' · probes ' + (results.length - suiteCount) + ')')
324
- for (const b of bad) {
325
- console.log(failedLine(b))
326
- for (const l of failureDetail(b)) console.log(' ' + l)
327
- }
328
- for (const [f, why] of Object.entries(NEEDS_ARGS)) {
329
- console.log(' SKIPPED (needs CLI args): ' + f + ' — ' + why + (presentSet.has(f) ? '' : ' [not present in this checkout]'))
330
- }
331
- }
332
- process.exit(bad.length === 0 ? 0 : 1)
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PARALLEL TEST RUNNER — run every shipped suite AND every probe (or a filtered subset)
4
+ * concurrently and report per-suite timings, so the strategy for the next run is chosen
5
+ * from DATA instead of guesswork.
6
+ *
7
+ * Why this exists: the suites are wildly uneven (one suite is ~160 s, most are under 2 s), so a
8
+ * sequential sweep spends almost all of its wall time waiting for the slowest one. Running them
9
+ * with a worker pool makes the sweep bounded by the slowest SUITE rather than by their SUM.
10
+ * On this machine (4 cores) the sweep went from ~5.5 min to ~2 min; see ../docs/test-timing.md.
11
+ *
12
+ * WHAT IS COLLECTED (Round B): `tests/` holds two families of `.mjs`:
13
+ * · `*.test.mjs` — the suites proper;
14
+ * · every other `.mjs` beside this runner — the PROBES that prove those suites are not
15
+ * vacuous (sensitivity mutations, the registration surface, the self-driving guards).
16
+ * Collecting only the first family was a false green: two probe suites sat at exit 1 while
17
+ * every report said "30/30 green". Both families are therefore collected, and a script can
18
+ * only stay out through the explicit, documented `NEEDS_ARGS` list below — a stale entry in
19
+ * that list is a hard error in a development checkout (`.git` present), so no script can be
20
+ * dropped silently there. Scripts whose bare run under-covers get argument `VARIANTS` (e.g.
21
+ * `audit-registration.mjs` defaults to v3 alone). A PUBLISHED tarball ships only a subset of
22
+ * `tests/`; there the missing entries are reported instead of aborting the run.
23
+ *
24
+ * Every suite already isolates itself (each creates its own mkdtemp workspace), so parallelism is
25
+ * safe. Suites that WRITE a corpus take a per-run corpus dir from an env var; this runner points
26
+ * them at a scratch dir when it runs a suite more than once, which it never does — but the probe
27
+ * runner (audit-formal-sensitivity.mjs) does, and it passes its own dirs.
28
+ *
29
+ * Usage:
30
+ * node tests/run-tests.mjs # every suite + probe, concurrency = min(2, cpus) (measured: 4 oversubscribes)
31
+ * node tests/run-tests.mjs --concurrency=6
32
+ * node tests/run-tests.mjs --only formal # substring match on the file name (repeatable, OR)
33
+ * node tests/run-tests.mjs --exclude e2e-v4 # substring to skip (repeatable)
34
+ * node tests/run-tests.mjs --json # machine-readable summary on stdout
35
+ * node tests/run-tests.mjs --temp-age-hours=12 # temp hygiene: stale-scratch threshold (default 6 h)
36
+ * node tests/run-tests.mjs --temp-dry-run # temp hygiene: print the plan, delete nothing
37
+ * node tests/run-tests.mjs --no-temp-hygiene # skip the sweep and the roomier-drive temp root
38
+ */
39
+ import { spawn } from 'node:child_process'
40
+ import { existsSync, readdirSync } from 'node:fs'
41
+ import { cpus } from 'node:os'
42
+ import { join } from 'node:path'
43
+ import { fileURLToPath } from 'node:url'
44
+
45
+ const HERE = fileURLToPath(new URL('./', import.meta.url))
46
+ // Suites live beside this runner, but they resolve the presets and the corpora from the
47
+ // repository root, so they must keep running with that as their working directory.
48
+ const REPO = fileURLToPath(new URL('../', import.meta.url))
49
+ const argv = process.argv.slice(2)
50
+ // Accept BOTH `--only=x` and `--only x` (the help text used the space form, which a value-taking
51
+ // flag() did not understand — the filter silently did nothing and every suite still ran).
52
+ const flag = (name) => {
53
+ const out = []
54
+ for (let i = 0; i < argv.length; i++) {
55
+ const a = argv[i]
56
+ if (a === '--' + name && i + 1 < argv.length && !argv[i + 1].startsWith('--')) out.push(argv[++i])
57
+ else if (a.startsWith('--' + name + '=')) out.push(a.split('=').slice(1).join('='))
58
+ }
59
+ return out
60
+ }
61
+ const has = (name) => argv.includes('--' + name)
62
+ const only = flag('only')
63
+ const exclude = flag('exclude')
64
+ // TEMP HYGIENE (task-12): a suite killed by the per-suite timeout cannot clean up, and some suites
65
+ // create many workspace dirs per run — measured here: %TEMP% held 160,923 top-level entries, 144,404 of
66
+ // them stale suite dirs (oldest ~2 weeks). Sweep the stale ones (top level, EXTRACTED suite prefixes,
67
+ // older than --temp-age-hours) and prefer the roomier drive's temp root; `os.tmpdir()` follows TEMP/TMP,
68
+ // so the suites need NO change. Hygiene must never break the sweep itself, and it reports on **stderr**
69
+ // because stdout is a MACHINE-READABLE channel here (`--counts`/`--json` are JSON.parsed by callers).
70
+ // Machine modes (`--self-check`, `--counts`) must not DELETE anything: the first is a guard the mutant
71
+ // family runs repeatedly, the second is called by scripts/update-doc-counts.mjs on every check - a sweep
72
+ // there is a surprising side effect (and a slow one). Only a genuine test sweep cleans up.
73
+ if (!has('no-temp-hygiene') && !has('self-check') && !has('counts')) {
74
+ try {
75
+ const { suitePrefixes, sweep, preferredTempRoot, useTempRoot, listProcesses, suspectProcesses } = await import('../scripts/clean-temp.mjs')
76
+ if (useTempRoot(preferredTempRoot())) console.error('run-tests: temp root -> ' + process.env.TEMP + ' (roomier drive preferred; D:\\_tmp when present)')
77
+ sweep({
78
+ prefixes: suitePrefixes(),
79
+ ageHours: Number(flag('temp-age-hours')[0] || 6),
80
+ dryRun: has('temp-dry-run'),
81
+ log: (m) => console.error(m),
82
+ })
83
+ // task-15 (MEASURED): a LEFTOVER process can steal a whole core and make honest suites look flaky - a
84
+ // leftover DSH host with 197,410 s CPU turned a 2822 s gate into 7875 s and amplified single suites
85
+ // 1.3x-4.5x. REPORT ONLY: the gate never kills anything it did not start (that decision belongs to a
86
+ // human, via `node scripts/clean-temp.mjs --ps [--kill]`).
87
+ const susp = suspectProcesses(listProcesses(), { selfPids: [process.pid, process.ppid] })
88
+ if (susp.length) {
89
+ console.error('run-tests: WARNING - ' + susp.length + ' suspected LEFTOVER process(es) (orphaned, project/team):')
90
+ for (const s of susp) console.error(' pid=' + s.pid + ' ' + String(s.cmd || '').slice(0, 110))
91
+ console.error('run-tests: leftover CPU load makes honest suites flaky; inspect with `node scripts/clean-temp.mjs --ps` (add --kill to stop them).')
92
+ }
93
+ } catch (e) { console.error('run-tests: temp hygiene skipped (' + ((e && e.message) || e) + ')') }
94
+ }
95
+ const asJson = has('json')
96
+ // task-13 (MEASURED): the default used to be min(4, cpus) = 4 on this 4-core box, and the heavy jobs then
97
+ // ran 1.3x-4.5x SLOWER than standalone - the gate's own "slowest" lines recorded v2-fix-probes.mutants
98
+ // 430 s standalone -> 568-651 s under the gate, v5-institute-fixes.mutants 202 s -> 419-900 s (the last
99
+ // one TIMING OUT at its own 900 s override). That oversubscription is what made timing-sensitive suites
100
+ // report failures that never reproduce standalone (e2e-v4-fixes T1/T19/T22/T23/T26/T31, e2e-v5-round2
101
+ // paper phase, host-failure-paths stall watchdog). Two concurrent jobs keep the machine honest; pass
102
+ // --concurrency=N explicitly when a faster (noisier) sweep is wanted.
103
+ const concurrency = Math.max(1, Number(flag('concurrency')[0] || Math.min(2, cpus().length)))
104
+
105
+ const SELF = 'run-tests.mjs'
106
+ // Per-suite HARD timeout (GATE_SUITE_TIMEOUT_MS overrides, ms). It bounds an intermittent hang so the
107
+ // gate always terminates and a hang is reported - never mistaken for "still running". 180 s comes from
108
+ // measurements: whole-gate wall ~200 s, slowest honest suite 135-144 s (audit-math-computation-sensitivity),
109
+ // so 180 s bounds a hang without killing an honest slow suite. Do NOT raise it silently to "fix" reds.
110
+ const SUITE_TIMEOUT_MS = Math.max(1000, Number(process.env.GATE_SUITE_TIMEOUT_MS || 180000))
111
+
112
+ // NAMED per-suite overrides for suites that are HONEST but slow (measured, not guessed). Raising a
113
+ // limit is a documented act: update the numbers here AND say so in the commit message - never silently.
114
+ // v2-fix-probes.mutants.mjs: ~43.1 s per family x 10 families ~= 430 s measured (inner child cap 120 s)
115
+ // v3-fix-probes.mutants.mjs: ~23.5 s per family x 11 families ~= 260 s measured (inner child cap 120 s)
116
+ // Both report hangs=[] with their own 120 s child cap, i.e. they are slow, not hung - so the 180 s
117
+ // default would misreport them. Everything else keeps the 180 s default.
118
+ const TIMEOUT_OVERRIDES = {
119
+ 'v2-fix-probes.mutants.mjs': 900000,
120
+ 'v3-fix-probes.mutants.mjs': 900000,
121
+ // v5-institute-fixes.mutants.mjs: MEASURED 201.9 s wall (hangs=[], skipped=[], ALL MUTANTS RED
122
+ // AS REQUIRED) - i.e. OVER the 180 s default, so it gets the same 900 s as the v2/v3 families.
123
+ // History worth keeping: a writer CLAIMED this family fitted its override when it did not, and the
124
+ // gate caught it as "FAILED: v5-institute-fixes.mutants.mjs [probe] (TIMEOUT after 180s)" - the
125
+ // named-timeout mechanism doing its job. (The older note here claimed formal-verify-v4.mutants.mjs
126
+ // MEASURED 70.9 s with ample headroom - that became STALE as the family grew; see its own entry below.)
127
+ 'v5-institute-fixes.mutants.mjs': 900000,
128
+ // e2e-v4-fixes.test.mjs: MEASURED, not guessed (task-13). Its cases script an institute and pump
129
+ // member followups; the pump loops used FIXED iteration caps (i<300 etc.) which, under a loaded gate,
130
+ // ran out BEFORE the plugin's next scheduling tick produced the verification/debate wakes.
131
+ // Instrumented proof under load: `T1 DEBUG: {"fi":300,"fu":302,"running":true,"phase":"active",
132
+ // "autoDone":false}` - the run was ALIVE (not concluded, so NOT a product race), the loop had simply
133
+ // exhausted its cap. The loops now also stop on a wall-clock deadline (LOOP_CAP_MS, default 45 s per
134
+ // loop, reached only in that pathological case, so healthy runs are unchanged), and the suite honestly
135
+ // needs more than the 180 s default: it MEASURED 160.2 s wall under the gate before this change.
136
+ // 420 s = that measurement + headroom for the bounded deadlines. Never raise this silently.
137
+ 'e2e-v4-fixes.test.mjs': 420000,
138
+ // formal-verify-v4.mutants.mjs: the family grew after the 70.9 s note above was written. Re-measured
139
+ // for task-13: 146.2 s standalone and 212.7 s with three heavy peers in parallel (ALL MUTANTS RED AS
140
+ // REQUIRED in both) - i.e. it legitimately exceeds the 180 s default under gate load, where a killed
141
+ // family then reports its children's F-5/N15/N18 assertions as failures (which reads like a regression
142
+ // but is only the timeout). 900 s matches the sibling families' convention.
143
+ 'formal-verify-v4.mutants.mjs': 900000,
144
+ }
145
+ /** One place decides a job limit: explicit job value, then the named override, then the default. */
146
+ function jobLimit(job) { return job.timeoutMs || TIMEOUT_OVERRIDES[job.file] || SUITE_TIMEOUT_MS }
147
+ /**
148
+ * DIAGNOSABILITY (protocol: every red must name an assertion): a failing suite's assertion NAMES are
149
+ * what a reader needs, and they must appear under the FAILED line - not only in the suite's own last
150
+ * stdout line. Extract the failing-assertion lines (this repo prints ` - <name>` or ` FAIL - <name>`,
151
+ * and suites that abort print `FAILURES:`/`Error:` markers), and fall back to the raw tail when a suite
152
+ * names nothing.
153
+ */
154
+ /**
155
+ * A reporting line's OWN prefix decides whether it names a failure. Anchored at the start (after the
156
+ * reporter's indent) so a token appearing INSIDE a passing line (e.g. ` ok - FAIL - ★★★ message`) can
157
+ * never be extracted as a failure — that false positive made a green run look red.
158
+ * own prefix: `FAIL`, `FAIL -`/`FAIL:`, `FAILURES:`; assertion bullets `- <name>` / `* <name>`; `✗`/`✘`.
159
+ * line-anchored abort markers: `SyntaxError:`, `TypeError:`, `ReferenceError:`, `Error:`.
160
+ * `ok`-prefixed lines are excluded explicitly, so a passing bullet is never counted.
161
+ */
162
+ function namedFailureLines(text) {
163
+ const out = []
164
+ for (const raw of String(text || '').split(/\r?\n/)) {
165
+ const l = raw.replace(/\s+$/, '')
166
+ if (!l.trim()) continue
167
+ if (/^\s*ok\b/.test(l)) continue // a PASSING line, whatever it contains
168
+ if (/^\s*(?:FAIL\b|FAILURES:|✗|✘)/.test(l)) { out.push(l); continue }
169
+ if (/^\s*(?:-|\*)\s+\S/.test(l)) { out.push(l); continue }
170
+ if (/^\s*(?:SyntaxError|TypeError|ReferenceError|Error):/.test(l)) { out.push(l); continue }
171
+ }
172
+ return out
173
+ }
174
+
175
+ function failureDetail(r) {
176
+ const lines = (String(r.out || '') + '\n' + String(r.err || '')).split('\n').map((l) => l.replace(/\s+$/, '')).filter(Boolean)
177
+ const named = namedFailureLines(String(r.out || '') + '\n' + String(r.err || ''))
178
+ const chosen = (named.length ? named : lines).slice(-40)
179
+ return chosen.length ? chosen : ['(no captured output)']
180
+ }
181
+ // `--self-check`: prove the diagnostics above actually surface a NAME. Runs one synthetic failing child
182
+ // through the same extractor and asserts the extracted detail contains its assertion name; the mutant
183
+ // that strips `failureDetail` makes this red.
184
+ /** ONE place builds the failure line, so the reporter and the self-check cannot diverge. */
185
+ function failedLine(b) {
186
+ const kind = '[' + b.job.kind + ']'
187
+ const why = b.timedOut ? 'TIMEOUT after ' + Math.round(jobLimit(b.job) / 1000) + 's'
188
+ : 'exit ' + b.code + (b.job.expectExit ? ', required exit ' + b.job.expectExit : '')
189
+ return ' FAILED: ' + label(b.job) + ' ' + kind + ' (' + why + ')'
190
+ }
191
+
192
+ if (process.argv.includes('--self-check')) {
193
+ const { spawnSync } = await import('node:child_process')
194
+ const synth = spawnSync(process.execPath, ['-e', "console.error(' - synthetic assertion name XYZ'); process.exit(1)"], { encoding: 'utf8' })
195
+ const detail = failureDetail({ out: synth.stdout, err: synth.stderr }).join('\n')
196
+ const okSelf = detail.indexOf('synthetic assertion name XYZ') !== -1
197
+ console.log((okSelf ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': a failing child yields a NAMED assertion line (' + detail.trim().slice(0, 80) + ')')
198
+ // FALSE-POSITIVE direction: a PASSING line whose message literally contains `FAIL - ` must extract
199
+ // NOTHING (the v2/v3 owner saw a green run look red this way).
200
+ const leaked = namedFailureLines(' ok - FAIL - ★★★ [F2cap] the assertion message mentions FAIL - inside it\n')
201
+ const okPassing = leaked.length === 0
202
+ console.log((okPassing ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': a passing line containing "FAIL - " is NOT extracted as a failure (' + JSON.stringify(leaked) + ')')
203
+ // FALSE-NEGATIVE direction stays closed: a genuine FAIL line and a line-anchored abort marker extract.
204
+ const okReal = namedFailureLines(' FAIL - ★★★ real failure\n').length === 1
205
+ const okAbort = namedFailureLines('TypeError: boom\n').length === 1
206
+ console.log((okReal && okAbort ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': a genuine FAIL line and an abort marker are still extracted')
207
+ // TIMEOUT case on the REAL path: a synthetic job that sleeps past a 1 s per-job limit, run through
208
+ // the SAME runSuite/reporting path the gate uses. A string-only check could pass with a half-armed
209
+ // timer; this one cannot, because it reads the run own timedOut/exit and the line the reporter builds.
210
+ const slow = await runSuite({ file: '(synthetic-sleeper)', args: [], expectExit: 0, kind: 'probe', eval: 'setTimeout(() => {}, 5000)', timeoutMs: 1000 })
211
+ const killedByRunner = slow.timedOut === true && slow.code !== 0
212
+ const line = failedLine(slow)
213
+ const namedTimeout = /FAILED: .*\[probe\] \(TIMEOUT after 1s\)/.test(line)
214
+ console.log((killedByRunner ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': the RUNNER kills a sleeping job at its per-job limit (timedOut=true, exit=' + slow.code + ')')
215
+ console.log((namedTimeout ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': the failure line names the timeout: ' + line.trim())
216
+ // OVERRIDE case (real path): the named override must actually EXTEND the limit. A job that sleeps
217
+ // 1.5 s would be killed by 1 s, so it must survive under the override and report its own seconds.
218
+ // task-13: the list is DERIVED from the table. A hard-coded example went stale the moment
219
+ // `formal-verify-v4.mutants.mjs` earned an override - and this self-check caught exactly that, by name
220
+ // (which is why it is worth deriving: the invariant is "every entry resolves to its own limit, and an
221
+ // unlisted file gets the default", not "these three files are the special ones").
222
+ const NAMED_OVERRIDES = Object.keys(TIMEOUT_OVERRIDES)
223
+ const overrideOk = NAMED_OVERRIDES.length > 0
224
+ && NAMED_OVERRIDES.every((f) => jobLimit({ file: f }) === TIMEOUT_OVERRIDES[f] && jobLimit({ file: f }) > SUITE_TIMEOUT_MS)
225
+ && jobLimit({ file: 'anything-else.mjs' }) === SUITE_TIMEOUT_MS
226
+ const survived = await runSuite({ file: '(synthetic-ok)', args: [], expectExit: 0, kind: 'probe', eval: 'setTimeout(() => {}, 1500)', timeoutMs: 900000 })
227
+ const extended = survived.timedOut === false && survived.code === 0
228
+ const overrideLine = failedLine({ job: { file: 'v2-fix-probes.mutants.mjs', args: [], kind: 'probe', expectExit: 0 }, code: null, timedOut: true })
229
+ const namesOverride = /TIMEOUT after 900s/.test(overrideLine)
230
+ console.log((overrideOk ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': a named override is resolved for its suite and nothing else')
231
+ console.log((extended ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': a job that would die at 1 s SURVIVES under the override (real runSuite)')
232
+ console.log((namesOverride ? 'SELF-CHECK PASS' : 'SELF-CHECK FAIL') + ': a timed-out override reports its own limit (' + overrideLine.trim() + ')')
233
+ process.exit(okSelf && okPassing && okReal && okAbort && killedByRunner && namedTimeout && overrideOk && extended && namesOverride ? 0 : 1)
234
+ }
235
+ // The scripts that genuinely cannot run without arguments. They are named here (with the exact
236
+ // command a human must run) instead of being omitted quietly: an entry that no longer exists
237
+ // aborts the run, so a rename cannot turn into a silent gap.
238
+ const NEEDS_ARGS = {
239
+ 'audit-tool-exec.mjs': 'node tests/audit-tool-exec.mjs <preset-js> <tool-name> (calls one real tool through the host path)',
240
+ }
241
+ // Argument variants, run IN ADDITION to the bare run unless `replaceBare` says the bare run is a
242
+ // strict subset of them. `expectExit` documents a probe whose SUCCESS is a non-zero exit (it
243
+ // mutates the source and requires the guarded check to fail): anything else is a FAIL here.
244
+ const VARIANTS = [
245
+ // A bare `audit-registration.mjs` audits ONLY v3 (its `process.argv[2] || v3` default), so the
246
+ // four presets are run explicitly and the redundant bare run is replaced.
247
+ { file: 'audit-registration.mjs', args: ['vibe-math-v2/vibe-math-v2.js'], replaceBare: true },
248
+ { file: 'audit-registration.mjs', args: ['vibe-math-v3/vibe-math-v3.js'] },
249
+ { file: 'audit-registration.mjs', args: ['vibe-math-v4/vibe-math-v4.js'] },
250
+ { file: 'audit-registration.mjs', args: ['vibe-math-v5/vibe-math-v5.js'] },
251
+ // The invariant scanner's own falsifiability check: it exits 0 only when every injected
252
+ // mutation really made a run go RED (and the unmutated control stayed green).
253
+ { file: 'audit-prompt-invariants.mjs', args: ['--self-probe'] },
254
+ // The participant-set proof, shipped: the guard's own predicates against broken strings.
255
+ { file: 'audit-participant-set-parity.mjs', args: ['--self-probe'] },
256
+ // §7/§8 predicates, shipped: same functions fed broken strings.
257
+ { file: 'audit-math-computation-parity.mjs', args: ['--self-probe'] },
258
+ // §30 verify-provenance predicates (case 3), shipped.
259
+ { file: 'math-computation-shared.test.mjs', args: ['--self-probe'] },
260
+ // The two-registration-paths probe, inverted: it applies a real description mutation and
261
+ // REQUIRES the parity check to exit 1 (exit 2 means the mutation no longer applies = drift).
262
+ {
263
+ file: 'audit-v3-registration-parity.mjs',
264
+ args: ['--self-probe', '["TOOL_DESC.vibe_math_start","TOOL_DESC.vibe_math_startX"]'],
265
+ expectExit: 1,
266
+ },
267
+ ]
268
+
269
+ const present = readdirSync(HERE).filter((f) => f.endsWith('.mjs')).sort()
270
+ const presentSet = new Set(present)
271
+ // A published tarball ships only a SUBSET of `tests/` (see docs/test-timing.md §1), so the
272
+ // scripts that stay behind are simply ABSENT there. The skip/variant lists are therefore
273
+ // enforced strictly in a development checkout (a `.git` entry marks one) and merely REPORTED —
274
+ // never silently dropped, they still appear in every run's output — in a partial tree.
275
+ const DEV_CHECKOUT = existsSync(join(REPO, '.git'))
276
+ const stale = Object.keys(NEEDS_ARGS).filter((f) => !presentSet.has(f))
277
+ if (stale.length && DEV_CHECKOUT) {
278
+ console.error('NEEDS_ARGS names a script that no longer exists: ' + stale.join(', ') + ' — fix the skip list, do not delete the entry blindly')
279
+ process.exit(2)
280
+ }
281
+ function label(j) { return j.file + (j.args.length ? ' ' + j.args.join(' ') : '') }
282
+ const replacedBare = new Set(VARIANTS.filter((v) => v.replaceBare).map((v) => v.file))
283
+ let suites = []
284
+ for (const file of present) {
285
+ if (file === SELF || NEEDS_ARGS[file] || replacedBare.has(file)) continue
286
+ suites.push({ file, args: [], expectExit: 0, kind: file.endsWith('.test.mjs') ? 'suite' : 'probe' })
287
+ }
288
+ for (const v of VARIANTS) {
289
+ if (!presentSet.has(v.file)) {
290
+ if (DEV_CHECKOUT) { console.error('a VARIANTS entry names a missing script: ' + v.file); process.exit(2) }
291
+ continue
292
+ }
293
+ // A VARIANT is an EXTRA instrumented run of the SAME script (argument variants), never an additional
294
+ // suite: the counting unit for `suites` is "the suites proper", one per script. Classifying by file name
295
+ // here would inflate `suites` whenever a suite gains a `--self-probe` job (R18: it did, 44 -> 45).
296
+ suites.push({ file: v.file, args: v.args || [], expectExit: v.expectExit || 0, kind: 'probe' })
297
+ }
298
+ suites.sort((a, b) => (label(a) < label(b) ? -1 : 1))
299
+ if (only.length) suites = suites.filter((j) => only.some((o) => label(j).includes(o)))
300
+ if (exclude.length) suites = suites.filter((j) => !exclude.some((o) => label(j).includes(o)))
301
+ if (!suites.length) { console.error('no suites matched'); process.exit(2) }
302
+
303
+ // --counts: print the DERIVED suite/probe totals (the same job list the gate runs) as JSON, then
304
+ // exit. Docs quote these numbers, so they must be derived and checked rather than typed by hand.
305
+ if (process.argv.includes('--counts')) {
306
+ const suiteN = suites.filter((j) => j.kind === 'suite').length
307
+ console.log(JSON.stringify({ total: suites.length, suites: suiteN, probes: suites.length - suiteN }))
308
+ process.exit(0)
309
+ }
310
+
311
+ function runSuite(job) {
312
+ return new Promise((resolve) => {
313
+ const t0 = Date.now()
314
+ const argv = job.eval ? ['-e', job.eval] : [join(HERE, job.file), ...job.args]
315
+ const limitMs = jobLimit(job)
316
+ const child = spawn(process.execPath, argv, { cwd: REPO, stdio: ['ignore', 'pipe', 'pipe'] })
317
+ let timedOut = false
318
+ const killer = setTimeout(() => { timedOut = true; try { child.kill('SIGKILL') } catch (e) { /* already gone */ } }, limitMs)
319
+ const clearKiller = () => clearTimeout(killer)
320
+ let out = '', err = ''
321
+ child.stdout.on('data', (d) => { out += d.toString() })
322
+ child.stderr.on('data', (d) => { err += d.toString() })
323
+ child.on('error', (e) => { clearKiller(); resolve({ job, code: -1, ms: Date.now() - t0, out, err: err + '\n' + String(e), timedOut }) })
324
+ child.on('close', (code) => { clearKiller(); resolve({ job, code, ms: Date.now() - t0, out, err, timedOut }) })
325
+ })
326
+ }
327
+
328
+ const results = []
329
+ let cursor = 0
330
+ const started = Date.now()
331
+ async function worker(id) {
332
+ for (;;) {
333
+ const i = cursor++
334
+ if (i >= suites.length) return
335
+ const r = await runSuite(suites[i])
336
+ const tail = String(r.out).trim().split('\n').filter(Boolean).slice(-1)[0] || ''
337
+ r.ok = r.code === r.job.expectExit
338
+ results[i] = r
339
+ if (!asJson) {
340
+ const mark = r.ok ? 'PASS' : 'FAIL'
341
+ console.log(
342
+ mark + ' ' + r.job.file.padEnd(34) +
343
+ ' exit=' + String(r.code).padStart(3) + (r.job.expectExit ? '(want ' + r.job.expectExit + ')' : ' ') +
344
+ ' ' + (r.ms / 1000).toFixed(1).padStart(6) + 's' +
345
+ (r.job.args.length ? ' [' + r.job.args.join(' ').slice(0, 40) + ']' : '') +
346
+ (tail ? ' ' + tail.slice(0, 60) : '')
347
+ )
348
+ if (!r.ok) {
349
+ for (const l of failureDetail(r)) console.log(' ' + l)
350
+ }
351
+ } else if (!r.ok) {
352
+ // In --json mode keep stdout machine-readable: the failure detail travels in the JSON.
353
+ r.tailDetail = failureDetail(r).join('\n')
354
+ }
355
+ }
356
+ }
357
+ await Promise.all(Array.from({ length: Math.min(concurrency, suites.length) }, (_, i) => worker(i)))
358
+
359
+ const wall = (Date.now() - started) / 1000
360
+ const sum = results.reduce((a, r) => a + r.ms, 0) / 1000
361
+ const bad = results.filter((r) => !r.ok)
362
+ const slowest = results.slice().sort((a, b) => b.ms - a.ms).slice(0, 5)
363
+ const suiteCount = results.filter((r) => r.job.kind === 'suite').length
364
+
365
+ if (asJson) {
366
+ console.log(JSON.stringify({
367
+ concurrency, wallSeconds: Number(wall.toFixed(1)), sumSeconds: Number(sum.toFixed(1)),
368
+ pass: results.length - bad.length, fail: bad.length,
369
+ suites: suiteCount, probes: results.length - suiteCount,
370
+ devCheckout: DEV_CHECKOUT,
371
+ skippedNeedsArgs: NEEDS_ARGS,
372
+ runs: results.map((r) => ({
373
+ file: r.job.file, args: r.job.args, expectExit: r.job.expectExit, exit: r.code,
374
+ seconds: Number((r.ms / 1000).toFixed(1)),
375
+ ...(r.tailDetail ? { detail: r.tailDetail } : {}),
376
+ })),
377
+ }, null, 2))
378
+ } else {
379
+ console.log('')
380
+ console.log('concurrency ' + concurrency + ' · wall ' + wall.toFixed(1) + 's · sum of suite times ' + sum.toFixed(1) + 's'
381
+ + ' · speed-up x' + (sum / Math.max(wall, 0.001)).toFixed(2))
382
+ console.log('slowest: ' + slowest.map((r) => r.job.file.replace('.test.mjs', '') + ' ' + (r.ms / 1000).toFixed(1) + 's').join(' · '))
383
+ console.log('TOTAL ' + results.length + ' PASS ' + (results.length - bad.length) + ' FAIL ' + bad.length
384
+ + ' (suites ' + suiteCount + ' · probes ' + (results.length - suiteCount) + ')')
385
+ for (const b of bad) {
386
+ console.log(failedLine(b))
387
+ for (const l of failureDetail(b)) console.log(' ' + l)
388
+ }
389
+ for (const [f, why] of Object.entries(NEEDS_ARGS)) {
390
+ console.log(' SKIPPED (needs CLI args): ' + f + ' — ' + why + (presentSet.has(f) ? '' : ' [not present in this checkout]'))
391
+ }
392
+ }
393
+ process.exit(bad.length === 0 ? 0 : 1)