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.
- package/LICENSE +21 -21
- package/README.en.md +682 -682
- package/README.md +683 -683
- package/docs/AUDIT-CHECKLIST.md +755 -744
- package/docs/COMPAT-AUDIT-ROUND2.md +351 -351
- package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
- package/docs/audit-methodology.md +93 -0
- package/docs/final-paper.md +172 -172
- package/docs/formal-verification.md +613 -588
- package/docs/generate_framework_diagram_v2.py +114 -114
- package/docs/generate_framework_diagram_v3.py +127 -127
- package/docs/math-computation.md +195 -194
- package/docs/release-notes/README.en.md +1 -0
- package/docs/release-notes/README.md +1 -0
- package/docs/release-notes/RELEASE-NOTES-2.0.22.md +112 -112
- package/docs/release-notes/RELEASE-NOTES-2.1.0.md +143 -143
- package/docs/release-notes/RELEASE-NOTES-2.2.0.md +266 -266
- package/docs/release-notes/RELEASE-NOTES-2.2.1.md +43 -43
- package/docs/release-notes/RELEASE-NOTES-2.2.2.md +88 -88
- package/docs/release-notes/RELEASE-NOTES-2.3.0.md +207 -207
- package/docs/release-notes/RELEASE-NOTES-2.3.1.md +134 -134
- package/docs/release-notes/RELEASE-NOTES-2.3.10.md +105 -105
- package/docs/release-notes/RELEASE-NOTES-2.3.11.md +57 -57
- package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -80
- package/docs/release-notes/RELEASE-NOTES-2.3.2.md +145 -145
- package/docs/release-notes/RELEASE-NOTES-2.3.3.md +115 -115
- package/docs/release-notes/RELEASE-NOTES-2.3.4.md +69 -69
- package/docs/release-notes/RELEASE-NOTES-2.3.5.md +63 -63
- package/docs/release-notes/RELEASE-NOTES-2.3.6.md +66 -66
- package/docs/release-notes/RELEASE-NOTES-2.3.7.md +59 -59
- package/docs/release-notes/RELEASE-NOTES-2.3.8.md +45 -45
- package/docs/release-notes/RELEASE-NOTES-2.3.9.md +70 -70
- package/docs/release-notes/RELEASE-NOTES-2.8.0.en.md +65 -0
- package/docs/release-notes/RELEASE-NOTES-2.8.0.md +65 -0
- package/docs/release-playbook.md +103 -0
- package/docs/slv-playbook.md +117 -0
- package/docs/test-timing.md +158 -157
- package/docs//346/236/266/346/236/204/345/233/276.md +77 -77
- package/installer.js +792 -792
- package/package.json +13 -3
- package/prompt-corpus-v2/formal-verify-v2.json +2 -2
- package/prompt-corpus-v2/formal-verify-v2.md +4 -0
- package/prompt-corpus-v3/formal-verify-v3.json +7 -7
- package/prompt-corpus-v3/formal-verify-v3.md +14 -0
- package/prompt-corpus-v5/prompt-corpus-v5.json +61 -61
- package/prompt-corpus-v5/prompt-corpus-v5.md +122 -0
- package/scripts/clean-temp.mjs +234 -0
- package/tests/audit-engine-faces.mjs +298 -0
- package/tests/audit-formal-sensitivity.mjs +345 -342
- package/tests/audit-installer-compat.test.mjs +256 -256
- package/tests/audit-math-computation-sensitivity.mjs +1 -1
- package/tests/audit-persona-sensitivity.mjs +323 -323
- package/tests/audit-persona-surface.test.mjs +431 -431
- package/tests/audit-prompt-duplication.mutants.mjs +3 -2
- package/tests/audit-prompt-invariants.mjs +589 -546
- package/tests/audit-registration.mjs +78 -78
- package/tests/audit-spec-traceability.mjs +193 -193
- package/tests/audit-status-report-fields.mjs +172 -172
- package/tests/audit-v5-integrity.mjs +795 -795
- package/tests/audit-v5-sensitivity.mjs +433 -432
- package/tests/e2e-v5-round2.test.mjs +2453 -2426
- package/tests/formal-verify-v2.test.mjs +1806 -1800
- package/tests/formal-verify-v3.test.mjs +1757 -1750
- package/tests/formal-verify-v4.test.mjs +2114 -2106
- package/tests/formal-verify-v5.test.mjs +774 -767
- package/tests/math-computation-discovery.mutants.mjs +3 -3
- package/tests/math-computation-shared.test.mjs +122 -5
- package/tests/math-computation-v2.test.mjs +14 -2
- package/tests/math-computation-v3.test.mjs +14 -2
- package/tests/math-computation-v4.test.mjs +739 -739
- package/tests/prompt-v5-integrity.test.mjs +1291 -1291
- package/tests/run-tests.mjs +393 -332
- package/tests/selfdrive-v5.mjs +560 -560
- package/tests/temp-hygiene.mutants.mjs +80 -0
- package/tests/v2-fix-probes.mutants.mjs +4 -0
- package/tests/v2-fix-probes.test.mjs +1034 -1033
- package/tests/v2v3-interrupt.mutants.mjs +111 -0
- package/tests/v3-fix-probes.mutants.mjs +99 -95
- package/tests/v3-fix-probes.test.mjs +891 -890
- package/tests/v4-final-paper.mutants.mjs +108 -0
- package/tests/v5-institute-fixes.mutants.mjs +218 -190
- package/vibe-math-v2/agent.cordis.yml +376 -376
- package/vibe-math-v2/math-computation.js +17 -15
- package/vibe-math-v2/preset.yml +2 -2
- package/vibe-math-v2/v2/347/232/204/351/242/235/345/244/226/350/246/201/346/261/202.txt +21 -21
- package/vibe-math-v2/vibe-math-v2.js +5078 -5011
- package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +486 -486
- package/vibe-math-v3/agent.cordis.yml +420 -420
- package/vibe-math-v3/math-computation.js +17 -15
- package/vibe-math-v3/preset.yml +2 -2
- package/vibe-math-v3/vibe-math-v3.js +6215 -6149
- package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +649 -649
- package/vibe-math-v4/agent.cordis.yml +438 -438
- package/vibe-math-v4/math-computation.js +17 -15
- package/vibe-math-v4/preset.yml +2 -2
- package/vibe-math-v4/vibe-math-v4.js +4490 -4422
- package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1015 -1015
- package/vibe-math-v5/math-computation.js +17 -15
- package/vibe-math-v5/vibe-math-v5.js +93 -11
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1728
- package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* TEMP HYGIENE (task-12) — sweep STALE test scratch directories and prefer a roomier temp root.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS (measured, 2026-10 on this machine): the suites create their workspace with
|
|
6
|
+
* `mkdtempSync(join(tmpdir(), '<prefix>-…'))` and remove it in `finally` — but a suite KILLED by the
|
|
7
|
+
* runner's per-suite timeout cannot clean up, and some suites create many directories per run. After
|
|
8
|
+
* hundreds of sweeps `%TEMP%` had reached **248,737 top-level entries, 245,288 of them ours**
|
|
9
|
+
* (`vibe-*` 221,939, `v2-fix-*` 23,160, …), with 111,223 older than 24 h (oldest ~2 weeks).
|
|
10
|
+
*
|
|
11
|
+
* TWO RULES, both bounded:
|
|
12
|
+
* 1. SWEEP: delete only entries at the TOP LEVEL of the temp root, only whose name starts with a
|
|
13
|
+
* prefix ACTUALLY USED BY THE SUITES (extracted from `tests/*.mjs`, never hand-written), and only
|
|
14
|
+
* when older than `--age-hours` (default 6 h — a concurrently running gate's dirs stay safe).
|
|
15
|
+
* 2. PREFER D:: when `D:\_tmp` can be created, point TEMP/TMP/TMPDIR at it (the suites follow
|
|
16
|
+
* `os.tmpdir()` automatically, so this needs NO product change). Never hard-fail: if the drive is
|
|
17
|
+
* absent the ambient temp root is kept.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* node scripts/clean-temp.mjs # sweep (age 6 h) and print counts
|
|
21
|
+
* node scripts/clean-temp.mjs --dry-run # print the plan, delete nothing
|
|
22
|
+
* node scripts/clean-temp.mjs --age-hours=12 # change the threshold
|
|
23
|
+
* node scripts/clean-temp.mjs --root=<dir> # sweep another temp root (mainly for tests)
|
|
24
|
+
* node scripts/clean-temp.mjs --self-test # prove the guards below (fresh dirs are NEVER swept)
|
|
25
|
+
*/
|
|
26
|
+
import { execFileSync } from 'node:child_process'
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'
|
|
28
|
+
import { tmpdir } from 'node:os'
|
|
29
|
+
import { dirname, join, resolve } from 'node:path'
|
|
30
|
+
import { fileURLToPath } from 'node:url'
|
|
31
|
+
|
|
32
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
33
|
+
const REPO = resolve(HERE, '..')
|
|
34
|
+
const TESTS = join(REPO, 'tests')
|
|
35
|
+
|
|
36
|
+
/** Prefixes the suites really use — extracted from their own `mkdtempSync(join(tmpdir(), '<prefix>'))` calls. */
|
|
37
|
+
export function suitePrefixes(testsDir = TESTS) {
|
|
38
|
+
const out = new Set()
|
|
39
|
+
let files = []
|
|
40
|
+
try { files = readdirSync(testsDir) } catch (e) { return [] }
|
|
41
|
+
for (const f of files) {
|
|
42
|
+
if (!f.endsWith('.mjs')) continue
|
|
43
|
+
let text = ''
|
|
44
|
+
try { text = readFileSync(join(testsDir, f), 'utf8') } catch (e) { continue }
|
|
45
|
+
for (const m of text.matchAll(/mkdtempSync\(\s*join\(\s*tmpdir\(\)\s*,\s*'([^']+)'/g)) out.add(m[1])
|
|
46
|
+
for (const m of text.matchAll(/mkdtempSync\(\s*join\(\s*tmpdir\(\)\s*,\s*"([^"]+)"/g)) out.add(m[1])
|
|
47
|
+
}
|
|
48
|
+
return [...out].sort()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* PURE planner: which entries may be deleted. `entries` are `{name, mtimeMs}` already read from the
|
|
53
|
+
* temp root. An entry qualifies only if BOTH hold: its name starts with a suite prefix, and it is older
|
|
54
|
+
* than the threshold. (This is the function the mutant family attacks: replacing the age test with
|
|
55
|
+
* `true` must make the "a FRESH dir is never swept" check go red BY NAME.)
|
|
56
|
+
*/
|
|
57
|
+
export function planSweep(entries, { prefixes, ageHours = 6, now = Date.now() } = {}) {
|
|
58
|
+
const cut = now - ageHours * 3600 * 1000
|
|
59
|
+
return entries
|
|
60
|
+
.filter((e) => Array.isArray(prefixes) ? prefixes.some((p) => String(e.name).startsWith(p)) : false)
|
|
61
|
+
.filter((e) => Number(e.mtimeMs) < cut)
|
|
62
|
+
.map((e) => e.name)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Read the top level of a temp root into planner entries. Only DIRECTORIES are considered. */
|
|
66
|
+
export function listTop(root) {
|
|
67
|
+
const out = []
|
|
68
|
+
let names = []
|
|
69
|
+
try { names = readdirSync(root, { withFileTypes: true }) } catch (e) { return out }
|
|
70
|
+
for (const d of names) {
|
|
71
|
+
if (!d.isDirectory()) continue
|
|
72
|
+
let mtimeMs = 0
|
|
73
|
+
try { mtimeMs = statSync(join(root, d.name)).mtimeMs } catch (e) { continue }
|
|
74
|
+
out.push({ name: d.name, mtimeMs })
|
|
75
|
+
}
|
|
76
|
+
return out
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Delete the planned entries. Returns counts so callers can print a checkable summary. */
|
|
80
|
+
export function sweep({ root = tmpdir(), prefixes, ageHours = 6, dryRun = false, log = () => {} } = {}) {
|
|
81
|
+
const all = listTop(root)
|
|
82
|
+
const doomed = planSweep(all, { prefixes, ageHours })
|
|
83
|
+
if (dryRun) return { root, scanned: all.length, planned: doomed.length, deleted: 0, failed: 0, dryRun: true }
|
|
84
|
+
let deleted = 0
|
|
85
|
+
let failed = 0
|
|
86
|
+
for (const name of doomed) {
|
|
87
|
+
try { rmSync(join(root, name), { recursive: true, force: true }); deleted++ } catch (e) { failed++ }
|
|
88
|
+
}
|
|
89
|
+
log('temp-hygiene: root=' + root + ' scanned=' + all.length + ' stale=' + doomed.length + ' deleted=' + deleted + ' failed=' + failed + ' ageHours=' + ageHours)
|
|
90
|
+
return { root, scanned: all.length, planned: doomed.length, deleted, failed }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `D:\_tmp` when the D: drive is usable; otherwise null (keep the ambient temp root). Never throws. */
|
|
94
|
+
export function preferredTempRoot(candidate = 'D:\\_tmp') {
|
|
95
|
+
try {
|
|
96
|
+
if (!existsSync(candidate)) mkdirSync(candidate, { recursive: true })
|
|
97
|
+
return existsSync(candidate) ? candidate : null
|
|
98
|
+
} catch (e) { return null }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Point TEMP/TMP/TMPDIR at `root` (children inherit; `os.tmpdir()` follows). No-op when root is falsy. */
|
|
102
|
+
export function useTempRoot(root) {
|
|
103
|
+
if (!root) return false
|
|
104
|
+
process.env.TEMP = root
|
|
105
|
+
process.env.TMP = root
|
|
106
|
+
process.env.TMPDIR = root
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Leftover-PROCESS detector (task-15). Measured motivation: a leftover DSH host (`--profile vmfix7`,
|
|
112
|
+
* started two days earlier, its parent gone) had burned **197,410 s of CPU** on this 4-core machine - a
|
|
113
|
+
* full core, permanently. With it, whole-gate `sum of suite times` went 2822 s -> 7875 s (x2.8) and
|
|
114
|
+
* individual suites ran 1.3x-4.5x slower, so honest suites reported failures that never reproduced
|
|
115
|
+
* standalone. The rule is deliberately NARROW: flag a process only when its command line matches THIS
|
|
116
|
+
* project/team AND its parent is gone (a process the running gate spawned has a live parent, so a healthy
|
|
117
|
+
* gate is never flagged - that property is asserted in `--self-test` and mutated by the family).
|
|
118
|
+
*/
|
|
119
|
+
export const DEFAULT_PROCESS_PATTERNS = [
|
|
120
|
+
/--profile\s+\S+/, // a DSH host started against any profile (including our clones)
|
|
121
|
+
/[\\/]review[\\/][^\\/]*\.mjs/, // the dev-only harness scripts kept under _oneoff/review
|
|
122
|
+
/[\\/]tests[\\/][^\\/]*\.mjs/, // a test/probe script (gates, mutant families)
|
|
123
|
+
/--temp-dry-run/, // another gate run
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
export function suspectProcesses(entries, { selfPids = [], patterns = DEFAULT_PROCESS_PATTERNS } = {}) {
|
|
127
|
+
const self = new Set((selfPids || []).map((p) => String(p)))
|
|
128
|
+
return (entries || []).filter((e) => {
|
|
129
|
+
if (!e || self.has(String(e.pid))) return false
|
|
130
|
+
if (!patterns.some((re) => re.test(String(e.cmd || '')))) return false
|
|
131
|
+
return e.parentGone === true // the safety property: a live parent means something owns it
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* List node processes with a `parentGone` flag. Windows-only in practice (PowerShell JSON); other
|
|
137
|
+
* platforms return [] rather than pretending. `exec` is injectable so the self-test can feed fixtures.
|
|
138
|
+
*/
|
|
139
|
+
export function listProcesses({ exec = null } = {}) {
|
|
140
|
+
if (process.platform !== 'win32' && !exec) return []
|
|
141
|
+
const ps = "$live=@{}; Get-Process | ForEach-Object { $live[[int]$_.Id]=1 }; " +
|
|
142
|
+
"Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' } | ForEach-Object { " +
|
|
143
|
+
"[pscustomobject]@{ pid=[int]$_.ProcessId; parent=[int]$_.ParentProcessId; cmd=[string]$_.CommandLine; " +
|
|
144
|
+
"parentGone=(-not $live.ContainsKey([int]$_.ParentProcessId)); created=[string]$_.CreationDate } } | ConvertTo-Json -Compress"
|
|
145
|
+
try {
|
|
146
|
+
const run = exec || ((args) => execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', args], { encoding: 'utf8', timeout: 30000 }))
|
|
147
|
+
const raw = String(run(ps) || '').trim()
|
|
148
|
+
if (!raw) return []
|
|
149
|
+
const parsed = JSON.parse(raw)
|
|
150
|
+
return Array.isArray(parsed) ? parsed : [parsed]
|
|
151
|
+
} catch (e) { return [] }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function formatSuspect(s) {
|
|
155
|
+
const cmd = String(s.cmd || '')
|
|
156
|
+
return ' pid=' + s.pid + ' parent=' + s.parent + (s.parentGone ? ' (GONE)' : '') + ' ' + (s.created || '') + ' ' + cmd.slice(0, 120)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const argv = process.argv.slice(2)
|
|
160
|
+
const has = (n) => argv.includes('--' + n)
|
|
161
|
+
const val = (n, d) => { const a = argv.find((x) => x.startsWith('--' + n + '=')); return a ? a.split('=').slice(1).join('=') : d }
|
|
162
|
+
|
|
163
|
+
if (has('self-test')) {
|
|
164
|
+
// The GUARD: these three checks fail BY NAME if the planner's prefix test or age test is broken.
|
|
165
|
+
const now = Date.now()
|
|
166
|
+
const prefixes = ['vibe-', 'v2-fix-']
|
|
167
|
+
const entries = [
|
|
168
|
+
{ name: 'vibe-fresh-abc', mtimeMs: now - 60 * 1000 }, // fresh ⇒ must NOT be planned
|
|
169
|
+
{ name: 'vibe-stale-abc', mtimeMs: now - 48 * 3600 * 1000 }, // stale ⇒ MUST be planned
|
|
170
|
+
{ name: 'v2-fix-stale', mtimeMs: now - 48 * 3600 * 1000 }, // stale ⇒ MUST be planned
|
|
171
|
+
{ name: 'unrelated-stale', mtimeMs: now - 48 * 3600 * 1000 }, // foreign⇒ must NOT be planned
|
|
172
|
+
{ name: 'vibestale-nodash', mtimeMs: now - 48 * 3600 * 1000 }, // prefix must match exactly
|
|
173
|
+
]
|
|
174
|
+
const planned = planSweep(entries, { prefixes, ageHours: 6, now })
|
|
175
|
+
const failures = []
|
|
176
|
+
const check = (cond, label) => { console.log((cond ? 'PASS ' : 'FAIL - ') + label); if (!cond) failures.push(label) }
|
|
177
|
+
check(!planned.includes('vibe-fresh-abc'), 'TEMP-HYGIENE: a FRESH suite dir is never swept (age threshold respected)')
|
|
178
|
+
check(planned.includes('vibe-stale-abc') && planned.includes('v2-fix-stale'), 'TEMP-HYGIENE: STALE dirs with a suite prefix ARE swept')
|
|
179
|
+
check(!planned.includes('unrelated-stale') && !planned.includes('vibestale-nodash'), 'TEMP-HYGIENE: only exact suite prefixes are swept (no glob-like overreach)')
|
|
180
|
+
const live = suitePrefixes()
|
|
181
|
+
check(live.length >= 3, 'TEMP-HYGIENE: the prefix table is EXTRACTED from the suites (found ' + live.length + ': ' + live.slice(0, 4).join(', ') + ' …)')
|
|
182
|
+
// task-15: the leftover-PROCESS predicate, tested on fixtures so the property is checkable and mutable.
|
|
183
|
+
const procs = [
|
|
184
|
+
{ pid: 1, parent: 999, parentGone: true, cmd: 'node tests/run-tests.mjs --temp-dry-run' }, // excluded via selfPids ONLY
|
|
185
|
+
{ pid: 5, parent: 995, parentGone: false, cmd: 'node tests/run-tests.mjs --temp-dry-run' }, // LIVE parent -> never suspect
|
|
186
|
+
{ pid: 2, parent: 998, parentGone: true, cmd: 'node tests/run-tests.mjs --temp-dry-run' }, // an ORPHANED gate
|
|
187
|
+
{ pid: 3, parent: 997, parentGone: true, cmd: 'node bin.js --profile vmfix7 --port 7791' }, // the measured leftover
|
|
188
|
+
{ pid: 4, parent: 996, parentGone: true, cmd: 'C:/apps/editor/editor.exe --open notes.md' },// unrelated program
|
|
189
|
+
]
|
|
190
|
+
const susp = suspectProcesses(procs, { selfPids: [1] })
|
|
191
|
+
check(!susp.some((s) => s.pid === 1), 'TEMP-HYGIENE: an explicitly excluded pid (self / own parent) is never reported')
|
|
192
|
+
// This one is what the "parent-gone" mutation attacks: pid 5 is NOT excluded by selfPids, so only the
|
|
193
|
+
// live-parent property keeps it out of the report.
|
|
194
|
+
check(!susp.some((s) => s.pid === 5), 'TEMP-HYGIENE: a process with a LIVE parent (our running gate) is never called a leftover')
|
|
195
|
+
check(susp.some((s) => s.pid === 2) && susp.some((s) => s.pid === 3), 'TEMP-HYGIENE: ORPHANED project/team processes are reported (incl. a DSH host started against a clone profile)')
|
|
196
|
+
check(!susp.some((s) => s.pid === 4), 'TEMP-HYGIENE: an unrelated program is never reported (the command-line gate holds)')
|
|
197
|
+
console.log('temp-hygiene self-test: ' + (failures.length ? failures.length + ' failure(s)' : 'all checks passed'))
|
|
198
|
+
process.exit(failures.length ? 1 : 0)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// CLI entry detection must survive being IMPORTED (then `process.argv[1]` is undefined — the naive
|
|
202
|
+
// `process.argv[1].replace(...)` form threw a TypeError for every importer).
|
|
203
|
+
const isCli = (() => {
|
|
204
|
+
try { return !!process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) } catch (e) { return false }
|
|
205
|
+
})()
|
|
206
|
+
|
|
207
|
+
if (isCli) {
|
|
208
|
+
// task-15: read-only leftover-process report. `--kill` must be asked for explicitly, and the current
|
|
209
|
+
// process + its parent are always excluded (a running gate must never kill itself).
|
|
210
|
+
if (has('ps')) {
|
|
211
|
+
const procs = listProcesses()
|
|
212
|
+
const susp = suspectProcesses(procs, { selfPids: [process.pid, process.ppid] })
|
|
213
|
+
console.log('temp-hygiene --ps: scanned ' + procs.length + ' node process(es); suspects=' + susp.length)
|
|
214
|
+
for (const s of susp) console.log(formatSuspect(s))
|
|
215
|
+
if (has('kill')) {
|
|
216
|
+
let killed = 0
|
|
217
|
+
for (const s of susp) { try { process.kill(Number(s.pid), 'SIGKILL'); killed++ } catch (e) { /* already gone */ } }
|
|
218
|
+
console.log('temp-hygiene --ps --kill: killed=' + killed)
|
|
219
|
+
} else if (susp.length) {
|
|
220
|
+
console.log('temp-hygiene --ps: read-only. Re-run with --kill to stop them (each one steals CPU from the gate).')
|
|
221
|
+
}
|
|
222
|
+
process.exit(0)
|
|
223
|
+
}
|
|
224
|
+
const root = val('root', null)
|
|
225
|
+
if (!root) { const pref = preferredTempRoot(); if (useTempRoot(pref)) console.log('temp-hygiene: using temp root ' + pref) }
|
|
226
|
+
const res = sweep({
|
|
227
|
+
root: val('root', tmpdir()),
|
|
228
|
+
prefixes: suitePrefixes(),
|
|
229
|
+
ageHours: Number(val('age-hours', '6')),
|
|
230
|
+
dryRun: has('dry-run'),
|
|
231
|
+
log: (m) => console.log(m),
|
|
232
|
+
})
|
|
233
|
+
console.log('temp-hygiene: ' + JSON.stringify(res))
|
|
234
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* REAL-ENGINE FACES (task-3) — presence-detected, loud-SKIP guards for the three "engine faces" that the
|
|
4
|
+
* scripted live validation could only sample: numeric (python/Rscript), Lean (lean), LaTeX (xelatex).
|
|
5
|
+
*
|
|
6
|
+
* Rules:
|
|
7
|
+
* · an engine that is NOT resolvable ⇒ print `SKIP: … (<reason>)` and do not fail (the gate must never
|
|
8
|
+
* be red on a machine without engines, and must never be silently green either);
|
|
9
|
+
* · an engine that IS resolvable ⇒ run it for real (real binary, real subprocess) and assert;
|
|
10
|
+
* · every assertion below has a SINGLE-SITE NAMED-RED proof reachable via `--self-probe` (env seams:
|
|
11
|
+
* MATH_COMPUTATION_MODULE / V4_PLUGIN / V5_PLUGIN point the child at a one-site mutant copy).
|
|
12
|
+
*
|
|
13
|
+
* Usage: node tests/audit-engine-faces.mjs [--self-probe]
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, statSync } from 'node:fs'
|
|
16
|
+
import { tmpdir } from 'node:os'
|
|
17
|
+
import { dirname, join, resolve } from 'node:path'
|
|
18
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
20
|
+
|
|
21
|
+
const HERE = fileURLToPath(new URL('./', import.meta.url))
|
|
22
|
+
const REPO = resolve(HERE, '..')
|
|
23
|
+
let passed = 0, failed = 0
|
|
24
|
+
const failures = []
|
|
25
|
+
const skips = []
|
|
26
|
+
const ok = (cond, label, detail) => { if (cond) { passed++; console.log(' ok ' + label) } else { failed++; failures.push(label + (detail ? ' — ' + detail : '')); console.log(' FAIL ' + label + (detail ? ' — ' + detail : '')) } }
|
|
27
|
+
const skip = (face, reason) => { skips.push(face + ': ' + reason); console.log(' SKIP: no ' + face + ' on this machine (' + reason + ')') }
|
|
28
|
+
const read = (env, rel) => {
|
|
29
|
+
const v = process.env[env]
|
|
30
|
+
return readFileSync(v ? (resolve(REPO, v)) : join(REPO, rel), 'utf8')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Resolve an executable: absolute candidates first, then PATH (with Windows extensions). */
|
|
34
|
+
function which(cmd) {
|
|
35
|
+
if (!cmd) return null
|
|
36
|
+
if (/[\\/]/.test(cmd) || /^[A-Za-z]:/.test(cmd)) return existsSync(cmd) ? cmd : null
|
|
37
|
+
const exts = process.platform === 'win32' ? ['', '.exe', '.cmd', '.bat'] : ['']
|
|
38
|
+
for (const dir of String(process.env.PATH || '').split(process.platform === 'win32' ? ';' : ':')) {
|
|
39
|
+
if (!dir) continue
|
|
40
|
+
for (const ext of exts) {
|
|
41
|
+
const p = join(dir, cmd + ext)
|
|
42
|
+
try { if (existsSync(p) && statSync(p).isFile()) return p } catch (e) { /* keep looking */ }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const w = spawnSync('where', [cmd], { encoding: 'utf8' })
|
|
46
|
+
if (w.status === 0) { const first = String(w.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0]; if (first && existsSync(first)) return first }
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
const resolveFirst = (...cmds) => { for (const c of cmds) { const p = which(c); if (p) return p } return null }
|
|
50
|
+
|
|
51
|
+
const PY = resolveFirst(String(process.env.LOCALAPPDATA || '') + '\\Programs\\Python\\Python312\\python.exe', 'python', 'python3')
|
|
52
|
+
const RC = resolveFirst('C:\\Program Files\\R\\R-4.6.1\\bin\\Rscript.exe', 'Rscript')
|
|
53
|
+
const LEAN = resolveFirst('D:\\.elan\\bin\\lean.exe', 'lean')
|
|
54
|
+
const XELATEX = resolveFirst('D:\\texlive\\2025\\bin\\windows\\xelatex.exe', 'xelatex')
|
|
55
|
+
|
|
56
|
+
// ── shared helpers ────────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
/** A REAL subprocess service in the shape the presets expect (resolveExecutable/spawn/done/collected). */
|
|
58
|
+
function realSubprocess() {
|
|
59
|
+
return {
|
|
60
|
+
async resolveExecutable(cmd) { const p = which(cmd); if (!p) throw new Error('not found on PATH: ' + cmd); return p },
|
|
61
|
+
spawn({ argv, cwd, stdio, graceMs }) {
|
|
62
|
+
const child = spawn(argv[0], argv.slice(1), { cwd, windowsHide: true })
|
|
63
|
+
let out = '', err = ''
|
|
64
|
+
const cap = 1 << 20
|
|
65
|
+
child.stdout.on('data', (d) => { if (out.length < cap) out += String(d) })
|
|
66
|
+
child.stderr.on('data', (d) => { if (err.length < cap) err += String(d) })
|
|
67
|
+
const done = new Promise((res) => {
|
|
68
|
+
child.on('error', (e) => res({ exitCode: null, signal: null, error: String((e && e.message) || e) }))
|
|
69
|
+
child.on('close', (code, signal) => res({ exitCode: code, signal: signal || null }))
|
|
70
|
+
})
|
|
71
|
+
return {
|
|
72
|
+
done,
|
|
73
|
+
terminate() { try { child.kill('SIGKILL') } catch (e) { /* best effort */ } },
|
|
74
|
+
collected: { stdout: { readFrom: () => ({ text: out }) }, stderr: { readFrom: () => ({ text: err }) } },
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Minimal cordis-shaped ctx (modeled on tests/audit-tool-exec.mjs, but with a REAL subprocess). */
|
|
80
|
+
const FS_TRACE = []
|
|
81
|
+
function makeCtx(WS) {
|
|
82
|
+
const regs = []
|
|
83
|
+
const ctx = {
|
|
84
|
+
get(n) { if (n === 'subprocess') return realSubprocess(); return undefined },
|
|
85
|
+
on() {}, effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
|
|
86
|
+
logger: { info() {}, warn() {}, error() {} },
|
|
87
|
+
tools: { register(spec) { regs.push(spec) } },
|
|
88
|
+
commands: { register() {} },
|
|
89
|
+
timeout(fn, ms) { const t = setTimeout(fn, ms); return () => clearTimeout(t) },
|
|
90
|
+
subagents: { list() { return ['spawn'] }, async startContinuable() { return { childId: 'c1' } }, async sendMessage() {}, async followup() {}, interrupt() {} },
|
|
91
|
+
agents: { roots() { return [] }, get() { return undefined } },
|
|
92
|
+
fs: {
|
|
93
|
+
async resolve(rel, o) { const s = String(rel); const abs = isAbsolute(s) ? s : join((o && o.cwd) || WS, ...s.split('/')); FS_TRACE.push('resolve:' + abs + ' exists=' + existsSync(abs)); return abs },
|
|
94
|
+
async stat(t) { FS_TRACE.push('stat:' + t + ' exists=' + existsSync(t)); try { return statSync(t).isDirectory() ? { type: 'directory' } : { type: 'file' } } catch (e) { return undefined } },
|
|
95
|
+
async readText(t) { FS_TRACE.push('readText:' + t); return readFileSync(t, 'utf8') },
|
|
96
|
+
async writeText(t, c) { mkdirSync(dirname(t), { recursive: true }); writeFileSync(t, c, 'utf8') },
|
|
97
|
+
async listDir(t) { try { return readdirSync(t, { withFileTypes: true }).map((e) => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) } catch (e) { return [] } },
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
return { ctx, regs }
|
|
101
|
+
}
|
|
102
|
+
async function registeredTool(presetRel, name, WS) {
|
|
103
|
+
const { ctx, regs } = makeCtx(WS)
|
|
104
|
+
// `resolve`, not `join`: a seam value may be an ABSOLUTE path, and join(REPO, abs) mangles it.
|
|
105
|
+
const mod = await import(pathToFileURL(resolve(REPO, presetRel)).href + '?t=' + Date.now())
|
|
106
|
+
;(mod.default || mod).apply(ctx)
|
|
107
|
+
return regs.find((s) => s.name === name) || null
|
|
108
|
+
}
|
|
109
|
+
const parse = (raw) => { try { return JSON.parse(String(raw)) } catch (e) { return { __unparsed: String(raw).slice(0, 300) } } }
|
|
110
|
+
|
|
111
|
+
// ── FACE 1: numeric (python / Rscript) — real binaries, real subprocess ───────────────────────────
|
|
112
|
+
console.log('=== FACE 1: numeric engines (real binaries) ===')
|
|
113
|
+
if (!PY && !RC) {
|
|
114
|
+
skip('numeric engine', 'neither python nor Rscript resolved (PATH + known absolute locations checked)')
|
|
115
|
+
} else {
|
|
116
|
+
const WS = mkdtempSync(join(tmpdir(), 'engine-num-'))
|
|
117
|
+
try {
|
|
118
|
+
const M = await import(pathToFileURL(process.env.MATH_COMPUTATION_MODULE ? resolve(REPO, process.env.MATH_COMPUTATION_MODULE) : join(REPO, 'vibe-math-v2/math-computation.js')).href + '?t=' + Date.now())
|
|
119
|
+
const engines = []
|
|
120
|
+
if (PY) engines.push('python')
|
|
121
|
+
if (RC) engines.push('r')
|
|
122
|
+
const handlers = {}
|
|
123
|
+
const host = {
|
|
124
|
+
register: (name, desc, schema, fn) => { handlers[name] = fn }, // the host registers the tool handler
|
|
125
|
+
params: () => ({ mathEngines: engines.slice(), mathMode: 'typed+shell', mathTimeoutMs: 60000, mathPackages: [], mathInstallScope: 'user' }),
|
|
126
|
+
projectRoot: () => WS,
|
|
127
|
+
exists: async (rel) => existsSync(resolve(WS, rel)) || existsSync(rel),
|
|
128
|
+
readText: async (rel) => { try { return readFileSync(resolve(WS, rel), 'utf8') } catch (e) { return undefined } },
|
|
129
|
+
writeText: async (rel, text) => { const abs = resolve(WS, rel); mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, text, 'utf8'); return abs },
|
|
130
|
+
resolveExecutable: async (cmd) => which(cmd) || undefined,
|
|
131
|
+
// NOTE the contract difference: the MODULE's host `spawn` returns a plain result
|
|
132
|
+
// ({exit,timedOut,killed,ms,stdout,stderr}), while the PRESET's ctx subprocess service returns a
|
|
133
|
+
// handle ({done,terminate,collected}). Both are exercised here, each in its own shape.
|
|
134
|
+
spawn: async ({ argv, cwd, timeoutMs, stdoutCap, stderrCap }) => {
|
|
135
|
+
const t0 = Date.now()
|
|
136
|
+
const r = spawnSync(argv[0], argv.slice(1), { cwd, encoding: 'utf8', timeout: Math.max(1000, Number(timeoutMs) || 60000), maxBuffer: 8 << 20, windowsHide: true })
|
|
137
|
+
return {
|
|
138
|
+
exit: r.status === null ? null : r.status,
|
|
139
|
+
timedOut: !!(r.error && r.error.code === 'ETIMEDOUT'),
|
|
140
|
+
killed: false,
|
|
141
|
+
ms: Date.now() - t0,
|
|
142
|
+
stdout: String(r.stdout || '').slice(0, Number(stdoutCap) || undefined),
|
|
143
|
+
stderr: String(r.stderr || '').slice(0, Number(stderrCap) || undefined),
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
listDirAbs: async (abs) => { try { return readdirSync(abs, { withFileTypes: true }).map((e) => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) } catch (e) { return [] } },
|
|
147
|
+
runtimeRoots: async () => [],
|
|
148
|
+
log: () => {},
|
|
149
|
+
}
|
|
150
|
+
M.registerMathComputation(host)
|
|
151
|
+
const call = (args) => handlers[M.MATH_TOOL_NAME](args, { id: 'probeProj', session: { id: 'S', header: { cwd: WS } } })
|
|
152
|
+
for (const eng of engines) {
|
|
153
|
+
const p = await call({ op: 'probe', engine: eng })
|
|
154
|
+
const entry = (p.engines || []).find((e) => e.name === eng)
|
|
155
|
+
ok(!!entry && Array.isArray(p.configured) && p.configured.indexOf(eng) !== -1 && ['path', 'host-runtime', 'known-install'].indexOf(entry.foundVia) !== -1,
|
|
156
|
+
'★ engine-faces numeric(' + eng + '): probe reports `configured` and a valid `foundVia` (' + String(entry && entry.foundVia) + ')')
|
|
157
|
+
const code = eng === 'python' ? 'print(6*7)\n' : 'cat(6*7)\n'
|
|
158
|
+
const r = await call({ op: 'run', engine: eng, mode: 'code', code: code })
|
|
159
|
+
ok(r.ok === true && typeof r.runId === 'string' && r.runId && Number(r.attempt) >= 1 && r.exit === 0,
|
|
160
|
+
'★ engine-faces numeric(' + eng + '): a real mode:\'code\' run returns runId/attempt/exit=0 (' + JSON.stringify({ runId: r.runId, attempt: r.attempt, exit: r.exit }) + ')')
|
|
161
|
+
ok(String(r.stdout || '').indexOf('42') !== -1, '★ engine-faces numeric(' + eng + '): the real computation returned 42', JSON.stringify(String(r.stdout || '').slice(0, 80)))
|
|
162
|
+
}
|
|
163
|
+
} finally { rmSync(WS, { recursive: true, force: true }) }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── FACE 2: Lean — real lean through the REGISTERED tool handler ──────────────────────────────────
|
|
167
|
+
console.log('=== FACE 2: Lean (real toolchain; registered-handler contract) ===')
|
|
168
|
+
if (!LEAN) {
|
|
169
|
+
skip('lean', 'lean did not resolve (PATH + D:\\.elan\\bin\\lean.exe checked)')
|
|
170
|
+
} else {
|
|
171
|
+
const WS = mkdtempSync(join(tmpdir(), 'engine-lean-'))
|
|
172
|
+
try {
|
|
173
|
+
// (a) DIRECT real Lean run. The good/bad pair is its own control: the good file must exit 0 and the
|
|
174
|
+
// wrong proof must exit non-zero with the toolchain's own error line. Written with Buffer.from(...,'utf8')
|
|
175
|
+
// (NO BOM): PowerShell's Set-Content -Encoding utf8 writes a BOM and Lean answers "1:0: expected token".
|
|
176
|
+
const good = 'theorem t : 1 + 1 = 2 := rfl\n'
|
|
177
|
+
const bad = 'theorem t : 1 + 1 = 3 := rfl\n'
|
|
178
|
+
writeFileSync(join(WS, 'Good.lean'), Buffer.from(good, 'utf8'))
|
|
179
|
+
writeFileSync(join(WS, 'Bad.lean'), Buffer.from(bad, 'utf8'))
|
|
180
|
+
const g = spawnSync(LEAN, [join(WS, 'Good.lean')], { encoding: 'utf8', windowsHide: true })
|
|
181
|
+
ok(g.status === 0, '★ engine-faces lean: a correct one-line theorem compiles with the REAL toolchain (exit 0; ' + JSON.stringify(String(g.stdout || '').trim().slice(0, 60)) + ')')
|
|
182
|
+
const b = spawnSync(LEAN, [join(WS, 'Bad.lean')], { encoding: 'utf8', windowsHide: true })
|
|
183
|
+
const bMsg = String(b.stderr || '') + String(b.stdout || '')
|
|
184
|
+
ok(b.status !== 0 && /error/i.test(bMsg),
|
|
185
|
+
'★ engine-faces lean: a WRONG proof fails with the toolchain\'s own error (exit ' + String(b.status) + '; ' + JSON.stringify(bMsg.split('\n')[0].slice(0, 80)) + ')')
|
|
186
|
+
// (b) the REGISTERED tool handler is reachable and enforces its NAMED path contract.
|
|
187
|
+
const tool = await registeredTool(process.env.V4_PLUGIN || 'vibe-math-v4/vibe-math-v4.js', 'vibe_v4_lean_run', WS)
|
|
188
|
+
ok(!!tool, '★ engine-faces lean: the registered tool \`vibe_v4_lean_run\` is reachable (spec found)')
|
|
189
|
+
const agent = { id: 'S', options: {}, session: { id: 'S', header: { cwd: WS } } }
|
|
190
|
+
const esc = parse(await tool.execute({ name: 'probeProj', file: '../../../../escape.lean' }, { agent }))
|
|
191
|
+
ok(esc && esc.ok === false && esc.code === 'V4_INVALID_ARGUMENT',
|
|
192
|
+
'★ engine-faces lean: the handler NAMES its path contract (an out-of-tree file ⇒ V4_INVALID_ARGUMENT; ' + JSON.stringify({ ok: esc && esc.ok, code: esc && esc.code }) + ')')
|
|
193
|
+
// (c) task-6: the resolver contract, asserted DIRECTLY through the exported seam
|
|
194
|
+
// (`resolveKnownTool`, exported exactly as `mathInstallRoots` is in the shared module). No ctx, no
|
|
195
|
+
// root agent, no scaffold file, and no engine required: the resolver is driven with fake subprocess
|
|
196
|
+
// services and a temp ELAN_HOME, so this block runs anywhere.
|
|
197
|
+
{
|
|
198
|
+
const presetPath = resolve(REPO, process.env.V4_PLUGIN || 'vibe-math-v4/vibe-math-v4.js')
|
|
199
|
+
const R = await import(pathToFileURL(presetPath).href + '?t=' + Date.now())
|
|
200
|
+
const res = R.resolveKnownTool
|
|
201
|
+
ok(typeof res === 'function', '★ engine-faces lean: the resolver seam `resolveKnownTool` is exported for testing (like `mathInstallRoots`)')
|
|
202
|
+
const fakeHome = mkdtempSync(join(tmpdir(), 'engine-elan-'))
|
|
203
|
+
mkdirSync(join(fakeHome, 'bin'), { recursive: true })
|
|
204
|
+
writeFileSync(join(fakeHome, 'bin', 'lean.exe'), 'stub')
|
|
205
|
+
const hadElan = Object.prototype.hasOwnProperty.call(process.env, 'ELAN_HOME'), savedElan = process.env.ELAN_HOME
|
|
206
|
+
const throwing = { resolveExecutable: async () => { throw new Error('not found on PATH') } }
|
|
207
|
+
const resolving = { resolveExecutable: async (c) => 'C:/fake/path/' + c + '.exe' }
|
|
208
|
+
try {
|
|
209
|
+
process.env.ELAN_HOME = fakeHome
|
|
210
|
+
const k = await res(throwing, { name: 'lean', explicit: '', kind: 'lean' })
|
|
211
|
+
ok(k && k.via === 'known-install' && Array.isArray(k.tried) && k.tried.some((t) => t.indexOf('lean') !== -1 || t.indexOf('bin') !== -1),
|
|
212
|
+
'★ engine-faces lean: with PATH missing, the KNOWN-LOCATION fallback resolves and reports via=known-install (' + JSON.stringify({ via: k && k.via, exe: String(k && k.exe).slice(-24), tried: (k && k.tried || []).slice(0, 2) }) + ')')
|
|
213
|
+
const p = await res(resolving, { name: 'lean', explicit: '', kind: 'lean' })
|
|
214
|
+
ok(p && p.via === 'path' && /fake\/path/.test(String(p.exe)),
|
|
215
|
+
'★ engine-faces lean: a host-resolvable command reports via=path (' + JSON.stringify({ via: p && p.via }) + ')')
|
|
216
|
+
const e1 = await res(resolving, { name: 'lean', explicit: 'C:/custom/leanx.exe', kind: 'lean' })
|
|
217
|
+
ok(e1 && e1.via === 'explicit', '★ engine-faces lean: an explicit command that resolves reports via=explicit (' + JSON.stringify({ via: e1 && e1.via }) + ')')
|
|
218
|
+
const e2 = await res(throwing, { name: 'lean', explicit: 'C:/nope/leanx.exe', kind: 'lean' })
|
|
219
|
+
ok(e2 && e2.exe === null && e2.via === null && Array.isArray(e2.tried) && e2.tried.length > 0,
|
|
220
|
+
'★ engine-faces lean: an explicit command is NEVER guessed away — it fails with via=null and lists what it probed (' + JSON.stringify({ via: e2 && e2.via, tried: (e2 && e2.tried || []).slice(0, 2) }) + ')')
|
|
221
|
+
} finally {
|
|
222
|
+
if (hadElan) process.env.ELAN_HOME = savedElan; else delete process.env.ELAN_HOME
|
|
223
|
+
rmSync(fakeHome, { recursive: true, force: true })
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
console.log(' note: the handler\'s FILE-EXECUTION path is not reachable from a minimal ctx: workspaceRoot()')
|
|
227
|
+
console.log(' reads the root agent (vibe-math-v4.js:338) and falls back to process.cwd() here, so the')
|
|
228
|
+
console.log(' tool resolved the file under the repo tree. The real toolchain is exercised directly above;')
|
|
229
|
+
console.log(' the handler contributes reachability + the named argument contract.')
|
|
230
|
+
} finally { rmSync(WS, { recursive: true, force: true }) }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── FACE 3: LaTeX — real xelatex detection + named degradation ────────────────────────────────────
|
|
234
|
+
console.log('=== FACE 3: LaTeX (real xelatex detection + named degradation) ===')
|
|
235
|
+
if (!XELATEX) {
|
|
236
|
+
skip('xelatex', 'xelatex did not resolve (PATH + D:\\texlive\\2025\\bin\\windows checked)')
|
|
237
|
+
} else {
|
|
238
|
+
const WS = mkdtempSync(join(tmpdir(), 'engine-latex-'))
|
|
239
|
+
try {
|
|
240
|
+
const start = await registeredTool(process.env.V5_PLUGIN || 'vibe-math-v5/vibe-math-v5.js', 'vibe_v5_paper', WS)
|
|
241
|
+
const setP = await registeredTool(process.env.V5_PLUGIN || 'vibe-math-v5/vibe-math-v5.js', 'vibe_v5_set', WS)
|
|
242
|
+
ok(!!start, '★ engine-faces latex: the registered tool `vibe_v5_paper` is reachable (spec found)')
|
|
243
|
+
const agent = { id: 'S', options: {}, session: { id: 'S', header: { cwd: WS } } }
|
|
244
|
+
if (setP) { try { await setP.execute({ name: 'probeProj', params: { paperLatexCommand: XELATEX } }, { agent }) } catch (e) { /* fall through to the start assertion */ } }
|
|
245
|
+
const r = parse(await start.execute({ name: 'probeProj' }, { agent }))
|
|
246
|
+
ok(r && r.ok === true && r.started === true,
|
|
247
|
+
'★ engine-faces latex: with an ABSOLUTE `paperLatexCommand` the paper entry starts directly (' + JSON.stringify({ ok: r && r.ok, started: r && r.started }) + ')')
|
|
248
|
+
if (setP) { try { await setP.execute({ name: 'probeProj', params: { paperLatexCommand: 'Z:/definitely/not/here/xelatex.exe' } }, { agent }) } catch (e) { /* ignore */ } }
|
|
249
|
+
const bad = parse(await start.execute({ name: 'probeProj' }, { agent }))
|
|
250
|
+
ok(bad && typeof bad === 'object' && bad.ok === true && (bad.started === true || bad.code === undefined),
|
|
251
|
+
'★ engine-faces latex: a NONEXISTENT `paperLatexCommand` neither crashes nor raises an error code (entry stays ok:true, code undefined) (' + JSON.stringify({ ok: bad && bad.ok, code: bad && bad.code, note: String(bad && bad.note || '').slice(0, 50) }) + ')')
|
|
252
|
+
console.log(' note: the LaTeX COMPILE (paper writer subagent) is not reachable from this probe; it asserts the ENTRY only — crash-freedom and no error code. The named degradation for an unresolvable command is a COMPILE-time behaviour (docs/final-paper.md) and is deliberately NOT claimed here.')
|
|
253
|
+
console.log(' This probe runs the REAL xelatex only because it carries its own candidate list')
|
|
254
|
+
console.log(' (D:\\texlive\\2025\\bin\\windows) - it is NOT evidence that the product discovers a non-standard TeX installation.')
|
|
255
|
+
} finally { rmSync(WS, { recursive: true, force: true }) }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── --self-probe: one SINGLE-SITE named-red proof per face (env seams point a child at a mutant copy) ──
|
|
259
|
+
if (process.argv.includes('--self-probe')) {
|
|
260
|
+
const { copyFileSync, cpSync } = await import('node:fs')
|
|
261
|
+
const child = (env) => spawnSync(process.execPath, [fileURLToPath(import.meta.url)], { encoding: 'utf8', maxBuffer: 1 << 26, env: Object.assign({}, process.env, env) })
|
|
262
|
+
const namedIn = (out) => (label) => String(out).split('\n').filter((l) => /^\s*FAIL\b/.test(l)).some((l) => l.indexOf(label) !== -1)
|
|
263
|
+
let probeBad = 0
|
|
264
|
+
const cases = [
|
|
265
|
+
{ face: 'numeric', env: 'MATH_COMPUTATION_MODULE', file: 'vibe-math-v2/math-computation.js', from: 'runId: runId, attempt: attempt,', to: 'runIdX: runId, attempt: attempt,', label: 'engine-faces numeric', engines: [PY, RC] },
|
|
266
|
+
{ face: 'lean', env: 'V4_PLUGIN', file: 'vibe-math-v4/vibe-math-v4.js', from: 'if(norm!==root&&norm.indexOf(root+\'/\')!==0) return null', to: 'if(false) return null', label: 'engine-faces lean', engines: [LEAN] },
|
|
267
|
+
{ face: 'lean-known-install', env: 'V4_PLUGIN', file: 'vibe-math-v4/vibe-math-v4.js', from: "\n if (p) return { exe: p, via: 'known-install', tried, name }", to: "\n if (p) return { exe: p, via: 'path', tried, name }", label: 'the KNOWN-LOCATION fallback resolves and reports via=known-install', engines: [LEAN] },
|
|
268
|
+
{ face: 'lean-tried-list', env: 'V4_PLUGIN', file: 'vibe-math-v4/vibe-math-v4.js', from: 'tried.push(p)', to: 'void 0', label: 'an explicit command is NEVER guessed away', engines: [LEAN] },
|
|
269
|
+
{ face: 'latex', env: 'V5_PLUGIN', file: 'vibe-math-v5/vibe-math-v5.js', from: 'ok: true, started: true, id: p.id,', to: 'ok: false, started: true, id: p.id,', label: 'engine-faces latex', engines: [XELATEX] },
|
|
270
|
+
]
|
|
271
|
+
for (const c of cases) {
|
|
272
|
+
if (!c.engines.some(Boolean)) { console.log('SELF-PROBE SKIP (' + c.face + '): the engine is absent, so the mutant cannot be exercised'); continue }
|
|
273
|
+
const src = readFileSync(join(REPO, c.file), 'utf8')
|
|
274
|
+
if (src.indexOf(c.from) === -1) { console.log('SELF-PROBE FAIL (' + c.face + '): anchor missing: ' + c.from); probeBad++; continue }
|
|
275
|
+
// Copy the WHOLE source directory: the module/preset imports its siblings (math-engines.js,
|
|
276
|
+
// math-computation.js, …), so a bare copy in a temp dir would crash instead of reddening.
|
|
277
|
+
const dirRoot = mkdtempSync(join(tmpdir(), 'engine-mut-' + c.face + '-'))
|
|
278
|
+
const dir = join(dirRoot, c.file.split('/')[0])
|
|
279
|
+
cpSync(join(REPO, c.file.split('/')[0]), dir, { recursive: true })
|
|
280
|
+
const dst = join(dir, c.file.split('/').pop())
|
|
281
|
+
writeFileSync(dst, src.replace(c.from, c.to), 'utf8')
|
|
282
|
+
const r = child({ [c.env]: dst })
|
|
283
|
+
const out = String(r.stdout || '') + String(r.stderr || '')
|
|
284
|
+
const named = namedIn(out)(c.label) && r.status !== 0
|
|
285
|
+
console.log((named ? 'SELF-PROBE PASS' : 'SELF-PROBE FAIL') + ': the one-site ' + c.face + ' mutant reddens its named assertion')
|
|
286
|
+
if (!named) for (const l of out.split('\n').filter((l) => /^\s*FAIL\b/.test(l)).slice(0, 3)) console.log(' child FAIL line: ' + l.trim().slice(0, 140))
|
|
287
|
+
if (!named) console.log(' child exit=' + r.status + ' tail=' + String(out).trim().split('\n').slice(-3).join(' | ').slice(0, 220))
|
|
288
|
+
if (!named) probeBad++
|
|
289
|
+
rmSync(dirRoot, { recursive: true, force: true })
|
|
290
|
+
}
|
|
291
|
+
console.log('')
|
|
292
|
+
console.log('=== ENGINE FACES SELF-PROBE: ' + (cases.length - probeBad) + '/' + cases.length + ' as required ===')
|
|
293
|
+
process.exit(probeBad === 0 ? 0 : 1)
|
|
294
|
+
}
|
|
295
|
+
console.log('')
|
|
296
|
+
console.log('=== ENGINE FACES: ' + passed + ' passed, ' + failed + ' failed, ' + skips.length + ' skipped ===')
|
|
297
|
+
for (const s of skips) console.log(' SKIPPED -> ' + s)
|
|
298
|
+
if (failed) { for (const f of failures) console.error(' - ' + f); process.exit(1) }
|