dsh-vibe-math 2.6.0 → 2.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +161 -389
- package/README.md +161 -390
- package/cordis.patch.yml +62 -14
- package/docs/AUDIT-CHECKLIST.md +2 -2
- package/docs/final-paper.md +1 -0
- package/docs/formal-verification.md +184 -29
- package/docs/math-computation.md +163 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.0.en.md +151 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.0.md +115 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.1.en.md +86 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.1.md +86 -0
- package/docs/test-timing.md +24 -12
- package/installer.js +7 -4
- package/package.json +21 -3
- package/prompt-corpus-persona/persona-corpus.json +12 -12
- package/prompt-corpus-persona/persona-corpus.md +128 -32
- package/prompt-corpus-v2/formal-verify-v2.json +182 -92
- package/prompt-corpus-v2/formal-verify-v2.md +2379 -55
- package/prompt-corpus-v3/formal-verify-v3.json +215 -53
- package/prompt-corpus-v3/formal-verify-v3.md +2147 -22
- package/prompt-corpus-v4/formal-verify-v4.json +14 -14
- package/prompt-corpus-v4/formal-verify-v4.md +267 -14
- package/prompt-corpus-v5/prompt-corpus-v5.json +62 -62
- package/prompt-corpus-v5/prompt-corpus-v5.md +631 -3
- package/tests/audit-math-computation-contract.mjs +204 -0
- package/tests/audit-math-computation-parity.mjs +198 -0
- package/tests/audit-math-computation-sensitivity.mjs +231 -0
- package/tests/audit-persona-sensitivity.mjs +4 -4
- package/tests/audit-persona-surface.test.mjs +38 -10
- package/tests/audit-v5-integrity.mjs +14 -0
- package/tests/e2e-v5-round2.test.mjs +459 -2
- package/tests/formal-verify-v2.test.mjs +362 -8
- package/tests/formal-verify-v3.test.mjs +355 -6
- package/tests/formal-verify-v4.test.mjs +295 -6
- package/tests/formal-verify-v5.test.mjs +9 -1
- package/tests/helpers/math-computation-fake-seam.mjs +163 -0
- package/tests/math-computation-shared.test.mjs +806 -0
- package/tests/prompt-v5-integrity.test.mjs +5 -5
- package/vibe-math-v2/agent.cordis.yml +22 -8
- package/vibe-math-v2/math-computation.js +1279 -0
- package/vibe-math-v2/math-engines.js +197 -0
- package/vibe-math-v2/vibe-math-v2.js +853 -40
- package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
- package/vibe-math-v3/agent.cordis.yml +22 -8
- package/vibe-math-v3/math-computation.js +1279 -0
- package/vibe-math-v3/math-engines.js +197 -0
- package/vibe-math-v3/vibe-math-v3.js +844 -47
- package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
- package/vibe-math-v4/agent.cordis.yml +38 -6
- package/vibe-math-v4/math-computation.js +1279 -0
- package/vibe-math-v4/math-engines.js +197 -0
- package/vibe-math-v4/vibe-math-v4.js +816 -31
- package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
- package/vibe-math-v5/agent.cordis.yml +42 -6
- package/vibe-math-v5/math-computation.js +1279 -0
- package/vibe-math-v5/math-engines.js +197 -0
- package/vibe-math-v5/vibe-math-v5.js +1132 -37
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +83 -3
- package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +251 -0
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// V4 LEAN FORMAL VERIFICATION SUITE (docs/formal-verification.md)
|
|
3
3
|
//
|
|
4
4
|
// Asserts the whole contract of the `formalVerify` knob for the v4 preset:
|
|
5
|
-
// · 'off' is a TRUE no-op (no Lean
|
|
5
|
+
// · 'off' is a TRUE no-op for VERIFICATION (no Lean block, no gate) and for the daily line
|
|
6
|
+
// under the default initiative; `leanInitiative:'eager'` is the one exception that
|
|
7
|
+
// still injects the DAILY line (the two knobs are independent axes — see §17).
|
|
6
8
|
// · 'encourage' injects the Lean section into the VERIFICATION prompt AND the ordinary work
|
|
7
9
|
// round, and — the actual point of the feature — turns the voting prompt into a
|
|
8
10
|
// FIDELITY review once a Lean run has passed
|
|
@@ -131,7 +133,7 @@ function makeSubprocess() {
|
|
|
131
133
|
|
|
132
134
|
function makeHost() {
|
|
133
135
|
const WS = mkdtempSync(join(tmpdir(), 'vibe-v4-lean-'))
|
|
134
|
-
const listeners = {}, toolRegs = [], spawns = [], followups = []
|
|
136
|
+
const listeners = {}, toolRegs = [], spawns = [], followups = [], cmdRegs = []
|
|
135
137
|
const subprocess = makeSubprocess()
|
|
136
138
|
let ROOT
|
|
137
139
|
const ctx = {
|
|
@@ -140,7 +142,7 @@ function makeHost() {
|
|
|
140
142
|
effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
|
|
141
143
|
logger: { info() {}, warn() {}, error() {} },
|
|
142
144
|
timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
|
|
143
|
-
tools: { register(s) { toolRegs.push(s) } }, commands: { register() {} },
|
|
145
|
+
tools: { register(s) { toolRegs.push(s) } }, commands: { register(s) { cmdRegs.push(s) } },
|
|
144
146
|
// Real DSH exposes ONLY subagents.sendMessage for continuable wakes (followup is an Agent
|
|
145
147
|
// method, not a subagents service method) — deliberately no followup, so a regression fails.
|
|
146
148
|
subagents: {
|
|
@@ -163,7 +165,9 @@ function makeHost() {
|
|
|
163
165
|
const vibeRoot = join(WS, 'VibeMath')
|
|
164
166
|
let consumed = 0
|
|
165
167
|
const h = {
|
|
166
|
-
WS, ctx, toolRegs, spawns, followups, ROOT, projectRoot, vibeRoot,
|
|
168
|
+
WS, ctx, toolRegs, spawns, followups, ROOT, projectRoot, vibeRoot, cmdRegs,
|
|
169
|
+
/** Drive the registered `/v4` slash command — the OTHER parameter surface the spec §B names. */
|
|
170
|
+
async cmd(rawInput) { const c = cmdRegs.find(x => x.name === 'v4'); if (!c) throw new Error('no /v4 command'); return await c.handler({ agent: ROOT, rawInput }) },
|
|
167
171
|
async callTool(n, a, agent) {
|
|
168
172
|
const s = toolRegs.find(x => x.name === n); if (!s) throw new Error('no tool ' + n)
|
|
169
173
|
return JSON.parse(await s.execute(a || {}, { agent: agent || ROOT }))
|
|
@@ -210,7 +214,10 @@ async function establish() {
|
|
|
210
214
|
for (const sp of h.spawns.slice(before)) { h.fireEnd(sp.childId, { summary: '初始见解。', solved: false, contextPct: 10 }); await sleep(30) }
|
|
211
215
|
// Park the heartbeat far away: these tests drive wakes explicitly, so a short heartbeat would
|
|
212
216
|
// only add unrelated normal rounds and make the followup stream harder to reason about.
|
|
213
|
-
|
|
217
|
+
// `leanAsync:false` on purpose: sections 1-16 assert the HISTORICAL SYNCHRONOUS contract
|
|
218
|
+
// (lean_run returns exitCode/stdout in the same call, archive{run:true} compiles inline) verbatim.
|
|
219
|
+
// The asynchronous contract (enqueue + settle + recovery) has its own sections 17+.
|
|
220
|
+
await h.callTool('vibe_v4_set', { activityTimeoutMs: 600000, leanAsync: false })
|
|
214
221
|
for (let i = 0; i < 40; i++) { const s = await h.callTool('vibe_v4_status', {}); if (s.phase === 'active') break; await sleep(20) }
|
|
215
222
|
await sleep(30)
|
|
216
223
|
h.resetCursor()
|
|
@@ -729,7 +736,7 @@ await G.callTool('vibe_v4_set', { formalVerify: 'encourage' })
|
|
|
729
736
|
const enc = await G.prompts('verify', 'r-1', { target: 'p-text', stage: 'independent' })
|
|
730
737
|
assert(/【Lean 形式化验证(鼓励模式)】/.test(enc), 'the encourage voting prompt keeps its header')
|
|
731
738
|
assert(/实现难度/.test(enc), 'it still asks for the implementation-difficulty judgement')
|
|
732
|
-
assert(/工具:vibe_v4_lean_run
|
|
739
|
+
assert(/工具:vibe_v4_lean_run(执行\/入队)· vibe_v4_lean_archive(归档)· vibe_v4_lean_lib(查已有可复用库与 jobs)· vibe_v4_lean_read(读归档原文逐字复用)/.test(enc), 'it names all FOUR tools in FULL (the new read-only tool included)')
|
|
733
740
|
assert(/归档可复用定义\/引理前先跑通(vibe_v4_lean_archive run=true 或先 vibe_v4_lean_run);跑不通不要入库。/.test(enc), '★ a reusable definition/lemma must be RUN GREEN before it is archived')
|
|
734
741
|
assert(/宿主没有 Lean 工具链(LEAN_NOT_FOUND)或根本没有 subprocess 服务(NO_SUBPROCESS)时:把代码写下来归档,并在回执的 note 里写明"宿主无 Lean 工具链"/.test(enc), '★ both unavailable-toolchain paths are written out (missing Lean OR no subprocess service) as explicit blocker reasons')
|
|
735
742
|
assert(/一旦 Lean 通过,你唯一需要确认的就是忠实性/.test(enc), 'a green Lean run still shrinks the open question to fidelity')
|
|
@@ -1190,6 +1197,288 @@ section('16 a fresh run clears the persisted formal records (even in off mode)')
|
|
|
1190
1197
|
assert(!/"p-proof"/.test(now) && !/"passed"/.test(now), '★ a fresh run clears the persisted formal records on disk, even in off mode (no stale `passed` can open the new run’s gate)')
|
|
1191
1198
|
}
|
|
1192
1199
|
|
|
1200
|
+
// ===============================================================
|
|
1201
|
+
// 17. leanAsync (default true): the tool ENQUEUES and returns immediately; ONLY a settled, green
|
|
1202
|
+
// job whose file still matches the enqueued content may set `passed` / mint Verified/Lean/.
|
|
1203
|
+
// ===============================================================
|
|
1204
|
+
const settleJob = async (h, jobId, tries = 80) => {
|
|
1205
|
+
for (let i = 0; i < tries; i++) {
|
|
1206
|
+
const r = await h.callTool('vibe_v4_lean_job', { jobId, waitMs: 200 })
|
|
1207
|
+
if (['settled', 'failed', 'timeout', 'interrupted'].indexOf(r.state) !== -1) return r
|
|
1208
|
+
await sleep(30)
|
|
1209
|
+
}
|
|
1210
|
+
return await h.callTool('vibe_v4_lean_job', { jobId })
|
|
1211
|
+
}
|
|
1212
|
+
/** The mock's `ensureDirs` records a shell mkdir but does not materialise directories, so a test
|
|
1213
|
+
* that seeds a project file must create its parent itself. */
|
|
1214
|
+
const writeIn = (h, rel, body) => { const p = join(h.WS, 'VibeMath', ...rel.split('/')); mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, body); return p }
|
|
1215
|
+
section('17 leanAsync=true enqueues immediately; passed only after a settled green job')
|
|
1216
|
+
{
|
|
1217
|
+
const N = await establish()
|
|
1218
|
+
await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanAsync: true, activityTimeoutMs: 60 })
|
|
1219
|
+
writeIn(N, 'Projects/default/Formal/a-async.lean', 'theorem a_async : 1 + 1 = 2 := by decide\n')
|
|
1220
|
+
const runsBefore = leanRuns.length
|
|
1221
|
+
const arc = await N.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-async', content: 'theorem p_async : 1 + 1 = 2 := by decide\n' }, N.resAgent(N.childOf('r-1')))
|
|
1222
|
+
assert(arc.ok === true && arc.async && arc.async.state === 'queued' && /^p-async-[0-9a-f]{12}$/.test(arc.jobId), '★ lean_archive kind=proof ENQUEUES and returns {async:{jobId,state:queued}} immediately (jobId=' + arc.jobId + ')')
|
|
1223
|
+
assert(arc.passed === undefined && !existsSync(join(N.projectRoot, 'Verified', 'Lean', 'p-async.lean')), '★ NO proof is minted at enqueue time (nothing may become passed before the compile lands)')
|
|
1224
|
+
assert(leanRuns.length === runsBefore, '★ the compiler has NOT been spawned yet by the enqueueing call (it is genuinely non-blocking)')
|
|
1225
|
+
const st0 = await N.callTool('vibe_v4_status', {})
|
|
1226
|
+
assert(!st0.formal.passed.includes('p-async'), 'and the object is not reported passed while queued')
|
|
1227
|
+
const done = await settleJob(N, arc.jobId)
|
|
1228
|
+
assert(done.state === 'settled' && done.exitCode === 0 && done.archive === 'Verified/Lean/p-async.lean', '★ the job settles green and reports its archive path (' + done.state + '/' + done.exitCode + ')')
|
|
1229
|
+
assert(existsSync(join(N.projectRoot, 'Verified', 'Lean', 'p-async.lean')), '★ the archived proof exists only AFTER the settle')
|
|
1230
|
+
const st1 = await N.callTool('vibe_v4_status', {})
|
|
1231
|
+
assert(st1.formal.passed.includes('p-async'), '★ the object is now passed')
|
|
1232
|
+
const list = await N.callTool('vibe_v4_lean_job', {})
|
|
1233
|
+
assert(list.ok === true && list.jobs.some(j => j.jobId === arc.jobId && j.receipt === 'Formal/Jobs/' + arc.jobId + '.json' && j.archive === 'Verified/Lean/p-async.lean'), '★ lean_job (no jobId) lists the job with its receipt + archive paths')
|
|
1234
|
+
const nextWake = await N.callTool('vibe_v4_message', { to: 'r-1', content: '继续。' })
|
|
1235
|
+
const promptsText = N.followups.map(promptOf).join('\n')
|
|
1236
|
+
assert(/【形式化结果】/.test(promptsText), '★ the settled result is announced once in the next prompt (【形式化结果】)')
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// ===============================================================
|
|
1240
|
+
// 18. The failure + timeout branches stay `attempted`.
|
|
1241
|
+
// ===============================================================
|
|
1242
|
+
section('18 async failure and timeout never set passed')
|
|
1243
|
+
{
|
|
1244
|
+
const N = await establish()
|
|
1245
|
+
await N.callTool('vibe_v4_set', { formalVerify: 'require', leanAsync: true, activityTimeoutMs: 60 })
|
|
1246
|
+
const arc = await N.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-async-fail', content: 'theorem p_async_fail : False := by sorry\n' })
|
|
1247
|
+
const done = await settleJob(N, arc.jobId)
|
|
1248
|
+
assert(done.state === 'failed' && done.exitCode === 1, '★ a red compile settles as failed (not passed)')
|
|
1249
|
+
assert(!existsSync(join(N.projectRoot, 'Verified', 'Lean', 'p-async-fail.lean')), '★ no proof is archived for a red run')
|
|
1250
|
+
const st = await N.callTool('vibe_v4_status', {})
|
|
1251
|
+
const rec = (st.formal.objects || []).find(o => o.target === 'p-async-fail') || {}
|
|
1252
|
+
assert(!st.formal.passed.includes('p-async-fail') && rec.status === 'attempted', '★ a failed async proof stays attempted and never satisfies the require gate (record status=' + rec.status + ')')
|
|
1253
|
+
// a HANG file: only the framework's own timeout can end it
|
|
1254
|
+
await N.callTool('vibe_v4_set', { leanTimeoutMs: 1000 })
|
|
1255
|
+
writeIn(N, 'Projects/default/Formal/a-hang.lean', '-- HANG\ntheorem a_hang : True := by trivial\n')
|
|
1256
|
+
const hang = await N.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-async-hang', content: '-- HANG\ntheorem p_async_hang : True := by trivial\n' })
|
|
1257
|
+
const hd = await settleJob(N, hang.jobId, 120)
|
|
1258
|
+
assert(hd.state === 'timeout' && hd.timedOut === true, '★ a hanging compile is timed out by the job budget (leanTimeoutMs), not left running')
|
|
1259
|
+
assert(terminated.length >= 1, '★ the timeout path really called handle.terminate() (an active kill, not just a report)')
|
|
1260
|
+
assert(!existsSync(join(N.projectRoot, 'Verified', 'Lean', 'p-async-hang.lean')), 'a timed-out proof is never archived')
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// ===============================================================
|
|
1264
|
+
// 19. dispose/abort terminates the in-flight compile and marks it interrupted.
|
|
1265
|
+
// ===============================================================
|
|
1266
|
+
section('19 dispose marks the in-flight job interrupted (never passed)')
|
|
1267
|
+
{
|
|
1268
|
+
const N = await establish()
|
|
1269
|
+
// The heartbeat is the queue driver, so it must be SHORT before the enqueue arms it (a long
|
|
1270
|
+
// heartbeat armed at enqueue time would leave the job queued forever — which is the point of §2.2).
|
|
1271
|
+
await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanAsync: true, activityTimeoutMs: 40, leanTimeoutMs: 600000 })
|
|
1272
|
+
const arc = await N.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-dispose', content: '-- HANG\ntheorem p_dispose : True := by trivial\n' })
|
|
1273
|
+
for (let i = 0; i < 100; i++) { const r = await N.callTool('vibe_v4_lean_job', { jobId: arc.jobId }); if (r.state === 'running') break; await sleep(25) }
|
|
1274
|
+
const before = await N.callTool('vibe_v4_lean_job', { jobId: arc.jobId })
|
|
1275
|
+
assert(before.state === 'running', 'precondition: the job is genuinely in flight (state=' + before.state + ')')
|
|
1276
|
+
await N.callTool('vibe_v4_abort', {})
|
|
1277
|
+
const after = await N.callTool('vibe_v4_lean_job', { jobId: arc.jobId })
|
|
1278
|
+
assert(after.state === 'interrupted' && after.interrupted === true, '★ abort/dispose marks the in-flight job interrupted (state=' + after.state + ')')
|
|
1279
|
+
assert(!existsSync(join(N.projectRoot, 'Verified', 'Lean', 'p-dispose.lean')), '★ an interrupted job never mints a proof')
|
|
1280
|
+
const st = await N.callTool('vibe_v4_status', {})
|
|
1281
|
+
assert(!st.formal.passed.includes('p-dispose'), 'and the object is not passed')
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// ===============================================================
|
|
1285
|
+
// 20. crash/restart recovery reads Formal/Jobs/*.json and never silently verifies.
|
|
1286
|
+
// ===============================================================
|
|
1287
|
+
section('20 crash recovery: queued re-driven, changed content never verified, settled completed')
|
|
1288
|
+
{
|
|
1289
|
+
const N = await establish()
|
|
1290
|
+
await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanAsync: true, activityTimeoutMs: 600000 })
|
|
1291
|
+
const jobsDir = join(N.projectRoot, 'Formal', 'Jobs')
|
|
1292
|
+
mkdirSync(jobsDir, { recursive: true })
|
|
1293
|
+
const body = 'theorem p_rec : 1 + 1 = 2 := by decide\n'
|
|
1294
|
+
const file = writeIn(N, 'Projects/default/Formal/p-rec.lean', body)
|
|
1295
|
+
const crypto = await import('node:crypto')
|
|
1296
|
+
const sha = crypto.createHash('sha256').update(body.replace(/\r\n?/g, '\n').replace(/[ \t]+$/gm, '').replace(/\n+$/, '') + '\n', 'utf8').digest('hex')
|
|
1297
|
+
// (a) `running` with UNCHANGED content → re-queued (never verified)
|
|
1298
|
+
writeFileSync(join(jobsDir, 'p-rec-aaaaaaaaaaaa.json'), JSON.stringify({ jobId: 'p-rec-aaaaaaaaaaaa', kind: 'proof', key: 'p-rec', target: 'p-rec', rel: 'Formal/p-rec.lean', abs: file, sha256: sha, content: body, state: 'running', attempts: 1, enqueuedAt: 1, startedAt: 2 }))
|
|
1299
|
+
// (b) `running` with CHANGED content → interrupted + attempted, NO auto re-drive
|
|
1300
|
+
const stalePath = writeIn(N, 'Projects/default/Formal/p-stale.lean', 'theorem p_stale : True := by trivial\n')
|
|
1301
|
+
writeFileSync(join(jobsDir, 'p-stale-bbbbbbbbbbbb.json'), JSON.stringify({ jobId: 'p-stale-bbbbbbbbbbbb', kind: 'proof', key: 'p-stale', target: 'p-stale', rel: 'Formal/p-stale.lean', abs: stalePath, sha256: sha, content: body, state: 'running', attempts: 1, enqueuedAt: 1, startedAt: 2 }))
|
|
1302
|
+
// A FRESH run (start) performs the safe half of the recovery table: `queued` jobs are re-driven,
|
|
1303
|
+
// `running` jobs are re-driven only while the content still matches, and a stale `settled` record
|
|
1304
|
+
// is NEVER adopted (that is exactly what the clean formal slate exists to prevent).
|
|
1305
|
+
writeFileSync(join(jobsDir, 'p-done-cccccccccccc.json'), JSON.stringify({ jobId: 'p-done-cccccccccccc', kind: 'proof', key: 'p-done', target: 'p-done', rel: 'Formal/p-done.lean', abs: file, sha256: sha, content: body, state: 'settled', exitCode: 0, verifiedRel: 'Verified/Lean/p-done.lean', attempts: 1, enqueuedAt: 1, startedAt: 2, settledAt: 3 }))
|
|
1306
|
+
await N.callTool('vibe_v4_start', { problem: '恢复扫描', residentCount: 1 })
|
|
1307
|
+
const a = await N.callTool('vibe_v4_lean_job', { jobId: 'p-rec-aaaaaaaaaaaa' })
|
|
1308
|
+
const b = await N.callTool('vibe_v4_lean_job', { jobId: 'p-stale-bbbbbbbbbbbb' })
|
|
1309
|
+
assert(a.state === 'queued' || a.state === 'running' || a.state === 'settled', '★ recovery re-drives a `running` job whose content still matches (state=' + a.state + ')')
|
|
1310
|
+
assert(b.state === 'interrupted', '★ a `running` job whose file CHANGED is interrupted, not verified (state=' + b.state + ')')
|
|
1311
|
+
const st = await N.callTool('vibe_v4_status', {})
|
|
1312
|
+
assert(!st.formal.passed.includes('p-stale'), '★ the changed-content job never reaches `passed`')
|
|
1313
|
+
assert(!st.formal.passed.includes('p-done') && !existsSync(join(N.projectRoot, 'Verified', 'Lean', 'p-done.lean')), '★ a fresh run does NOT adopt a previous run’s `settled` record (no stale passed, no minted proof)')
|
|
1314
|
+
assert(N.followups.map(promptOf).join('\n').length >= 0, 'recovery ran without throwing')
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// ===============================================================
|
|
1318
|
+
// 21. lean_read: verbatim library access, confined to Formal/{Lib,Proved}, 64KB cap.
|
|
1319
|
+
// ===============================================================
|
|
1320
|
+
section('21 lean_read returns the archived text verbatim and refuses escapes')
|
|
1321
|
+
{
|
|
1322
|
+
const N = await establish()
|
|
1323
|
+
await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanAsync: false }) // sync: the file must be on disk for the read
|
|
1324
|
+
const defBody = 'def ZMod9 := Fin 9\n\ntheorem zmod9_card : Fintype.card ZMod9 = 9 := by decide\n'
|
|
1325
|
+
const arch = await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'ZMod9', content: defBody }, N.resAgent(N.childOf('r-1')))
|
|
1326
|
+
assert(arch.ok === true, 'precondition: the definition archived')
|
|
1327
|
+
const rd = await N.callTool('vibe_v4_lean_read', { name: 'ZMod9' })
|
|
1328
|
+
assert(rd.ok === true && rd.kind === 'lib' && rd.file === 'Formal/Lib/ZMod9.lean' && rd.text === defBody, '★ lean_read returns the archived file text VERBATIM (import-or-copy reuse)')
|
|
1329
|
+
assert(/^[0-9a-f]{64}$/.test(rd.sha256) && rd.bytes === defBody.length && rd.truncated === false, 'it reports sha256/bytes/truncated')
|
|
1330
|
+
assert(/import Formal\.Lib\.ZMod9/.test(rd.hint || ''), '★ and names the namespace to import (module root = VibeMath root)')
|
|
1331
|
+
const proved = await N.callTool('vibe_v4_lean_read', { name: 'ZMod9', kind: 'proved' })
|
|
1332
|
+
assert(proved.ok === false && proved.code === 'V4_NOT_FOUND', 'kind=proved does not look in Lib/')
|
|
1333
|
+
for (const bad of ['../../etc/passwd', '..', 'Formal/Lib/ZMod9', 'C:\\Windows\\evil']) {
|
|
1334
|
+
const r = await N.callTool('vibe_v4_lean_read', { name: bad })
|
|
1335
|
+
assert(r.ok === false, '★ lean_read refuses the path-escape name ' + JSON.stringify(bad))
|
|
1336
|
+
}
|
|
1337
|
+
assert((await N.callTool('vibe_v4_lean_read', { name: 'ZMod9', kind: 'bogus' })).ok === false, 'an unknown kind is refused')
|
|
1338
|
+
// 64KB truncation
|
|
1339
|
+
const big = 'def Big := 1\n' + ('-- ' + 'x'.repeat(120) + '\n').repeat(600)
|
|
1340
|
+
await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'BigLib', content: big, run: false })
|
|
1341
|
+
const rb = await N.callTool('vibe_v4_lean_read', { name: 'BigLib' })
|
|
1342
|
+
assert(rb.ok === true && rb.truncated === true && rb.text.length <= 64 * 1024 && rb.bytes > 64 * 1024, '★ a >64KB library file is truncated and flagged (truncated:true)')
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// ===============================================================
|
|
1346
|
+
// 22. Search paths (AMENDMENT §2) + build-context job id (§4) + concurrency (§5) + the new params.
|
|
1347
|
+
// ===============================================================
|
|
1348
|
+
section('22 leanSearchPaths/leanJobsMaxParallel/leanInitiative + build-context job ids')
|
|
1349
|
+
{
|
|
1350
|
+
const N = await establish()
|
|
1351
|
+
await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanAsync: false, leanArgs: [], leanSearchPaths: ['/extra/one', '/extra/one', '/extra/two'] })
|
|
1352
|
+
writeIn(N, 'Projects/default/Formal/a-sp.lean', 'theorem a_sp : True := by trivial\n')
|
|
1353
|
+
await N.callTool('vibe_v4_lean_run', { file: 'Formal/a-sp.lean' }, N.resAgent(N.childOf('r-1')))
|
|
1354
|
+
let last = leanRuns[leanRuns.length - 1]
|
|
1355
|
+
const spIdx = last.argv.indexOf('--search-path')
|
|
1356
|
+
assert(last.argv.filter(a => a === '--search-path').length === 3, '★ one --search-path per DISTINCT root (user paths first, then the automatic VibeMath root)')
|
|
1357
|
+
assert(last.argv[spIdx + 1] === '/extra/one' && last.argv[spIdx + 3] === '/extra/two' && last.argv[spIdx + 5] === N.vibeRoot.replace(/\\/g, '/'), '★ order + de-duplication: user paths in order, automatic root last')
|
|
1358
|
+
assert(last.argv.indexOf('--search-path') < last.argv.length - 1 && /\.lean$/.test(String(last.argv[last.argv.length - 1])), 'the roots stay BEFORE the file name')
|
|
1359
|
+
await N.callTool('vibe_v4_set', { leanArgs: ['--search-path', '/user/own'] })
|
|
1360
|
+
await N.callTool('vibe_v4_lean_run', { file: 'Formal/a-sp.lean' }, N.resAgent(N.childOf('r-1')))
|
|
1361
|
+
last = leanRuns[leanRuns.length - 1]
|
|
1362
|
+
assert(last.argv.filter(a => a === '--search-path').length === 1 && last.argv[last.argv.indexOf('--search-path') + 1] === '/user/own', '★ an explicit --search-path in leanArgs wins outright: nothing is injected')
|
|
1363
|
+
await N.callTool('vibe_v4_set', { leanArgs: ['-R', '/user/root'] })
|
|
1364
|
+
await N.callTool('vibe_v4_lean_run', { file: 'Formal/a-sp.lean' }, N.resAgent(N.childOf('r-1')))
|
|
1365
|
+
assert(leanRuns[leanRuns.length - 1].argv.indexOf('--search-path') === -1, '★ -R/--root count as a user-stated root too')
|
|
1366
|
+
const lib = await N.callTool('vibe_v4_lean_lib', { refresh: false })
|
|
1367
|
+
assert(lib.searchPath === N.vibeRoot.replace(/\\/g, '/') && /import Formal\.Lib\./.test(JSON.stringify(lib.importNamespace)), 'lean_lib shows the injected searchPath and the import namespace')
|
|
1368
|
+
// build-context job id: same content, different engine ⇒ different job
|
|
1369
|
+
await N.callTool('vibe_v4_set', { leanAsync: true, activityTimeoutMs: 600000, leanArgs: [], leanSearchPaths: [], leanCommand: 'lean' })
|
|
1370
|
+
const j1 = await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'CtxOne', content: 'def CtxOne := 1\n' })
|
|
1371
|
+
await N.callTool('vibe_v4_set', { leanCommand: 'lake' })
|
|
1372
|
+
const j2 = await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'CtxOne', content: 'def CtxOne := 1\n' })
|
|
1373
|
+
assert(j1.jobId !== j2.jobId, '★ the job id covers the BUILD CONTEXT: identical content with a different engine is a different job (' + j1.jobId + ' vs ' + j2.jobId + ')')
|
|
1374
|
+
// concurrency cap
|
|
1375
|
+
await N.callTool('vibe_v4_set', { leanCommand: 'lean', leanJobsMaxParallel: 2, activityTimeoutMs: 40 })
|
|
1376
|
+
const h1 = await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'CapA', content: '-- HANG\ndef CapA := 1\n' })
|
|
1377
|
+
const h2 = await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'CapB', content: '-- HANG\ndef CapB := 1\n' })
|
|
1378
|
+
const h3 = await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'CapC', content: '-- HANG\ndef CapC := 1\n' })
|
|
1379
|
+
for (let i = 0; i < 120; i++) { const s = await N.callTool('vibe_v4_lean_job', {}); if (s.running.length >= 2) break; await sleep(25) }
|
|
1380
|
+
const snap = await N.callTool('vibe_v4_lean_job', {})
|
|
1381
|
+
assert(snap.running.length === 2 && snap.queued.length >= 1, '★ leanJobsMaxParallel=2 runs exactly two compiles and leaves the rest queued (' + snap.running.length + ' running / ' + snap.queued.length + ' queued; all=' + JSON.stringify(snap.jobs.map(j => j.jobId + ':' + j.state)) + '; archives=' + JSON.stringify([h1, h2, h3].map(x => x && (x.jobId || x.code || x.message)))) + ')'
|
|
1382
|
+
await N.callTool('vibe_v4_abort', {}) // terminate the hang jobs
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// ===============================================================
|
|
1386
|
+
// 23. leanInitiative separates PROACTIVITY from the verification requirement (AMENDMENT §1).
|
|
1387
|
+
// ===============================================================
|
|
1388
|
+
section('23 leanInitiative: off/normal/eager in the work prompt, formalVerify untouched')
|
|
1389
|
+
{
|
|
1390
|
+
const N = await establish()
|
|
1391
|
+
await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanAsync: false })
|
|
1392
|
+
const normal = await N.prompts('normal', 'r-1')
|
|
1393
|
+
assert(/【顺手形式化(鼓励)】/.test(normal) && /判断标准:① 有价值或可能复用/.test(normal), '★ leanInitiative=normal (default): the daily line carries the three selection criteria')
|
|
1394
|
+
assert(/先 vibe_v4_lean_lib 查已有库/.test(normal) && /没把握就记 blocked/.test(normal) && /import Formal\.Lib\./.test(normal), '★ …and the look-up-first / blocked / namespace rules')
|
|
1395
|
+
assert(!/leanInitiative=eager/.test(normal), 'the normal档 does not claim eagerness')
|
|
1396
|
+
const verify = await N.prompts('verify', 'r-1', { target: 'p-init', stage: 'independent' })
|
|
1397
|
+
assert(/形式化只写你有把握的版本;没把握就记 blocked 并写清难点/.test(verify) && /不得\*\*在它落地前声称已通过/.test(verify), '★ the verification block carries the §5 B contract (blocked-instead-of-guessing + no premature "passed")')
|
|
1398
|
+
await N.callTool('vibe_v4_set', { leanInitiative: 'eager' })
|
|
1399
|
+
const eager = await N.prompts('normal', 'r-1')
|
|
1400
|
+
assert(/leanInitiative=eager/.test(eager) && /主动\*\*顺手形式化并归档/.test(eager), '★ leanInitiative=eager adds the explicit proactive clause')
|
|
1401
|
+
await N.callTool('vibe_v4_set', { leanInitiative: 'off' })
|
|
1402
|
+
const off = await N.prompts('normal', 'r-1')
|
|
1403
|
+
assert(!/【顺手形式化/.test(off), '★ leanInitiative=off removes the proactive daily line entirely')
|
|
1404
|
+
const vOff = await N.prompts('verify', 'r-1', { target: 'p-init', stage: 'independent' })
|
|
1405
|
+
assert(/【Lean 形式化验证(鼓励模式)】/.test(vOff) && /实现难度/.test(vOff), '★ …while the VERIFICATION prompt still states the formalVerify requirement (off ≠ no verification)')
|
|
1406
|
+
// parameter surfaces (AMENDMENT §1/§2/§5 + leanAsync)
|
|
1407
|
+
const setSpec = N.toolRegs.find(t => t.name === 'vibe_v4_set')
|
|
1408
|
+
for (const k of ['leanAsync', 'leanInitiative', 'leanSearchPaths', 'leanJobsMaxParallel']) assert(!!setSpec.parameters.properties[k], '★ vibe_v4_set advertises ' + k)
|
|
1409
|
+
assert(JSON.stringify(setSpec.parameters.properties.leanInitiative.enum) === JSON.stringify(['off', 'normal', 'eager']), 'leanInitiative is a closed enum in the schema')
|
|
1410
|
+
const coerced = await N.callTool('vibe_v4_set', { leanAsync: 'false', leanInitiative: 'bogus', leanSearchPaths: 'a, b ,,c', leanJobsMaxParallel: 0 })
|
|
1411
|
+
const pv = await N.callTool('vibe_v4_status', {})
|
|
1412
|
+
assert(coerced.ok === true && /leanAsync=false/.test(pv.params), '★ the STRING "false" normalises to boolean false (never a truthy string)')
|
|
1413
|
+
assert(/leanInitiative=normal/.test(pv.params), 'an unknown leanInitiative degrades to the default normal')
|
|
1414
|
+
assert(/leanSearchPaths=a,b,c/.test(pv.params), 'a comma string becomes a trimmed, non-empty array')
|
|
1415
|
+
assert(/leanJobsMaxParallel=1/.test(pv.params), 'leanJobsMaxParallel is clamped to ≥1')
|
|
1416
|
+
// The SLASH-COMMAND surface passes RAW STRINGS to the same parameter layer (spec §B: v4's `/v4 set`
|
|
1417
|
+
// hands over strings, so 'false' must not survive as a truthy value there either).
|
|
1418
|
+
const slash = await N.cmd('set leanAsync=false leanInitiative=off')
|
|
1419
|
+
assert(slash.kind === 'success', '/v4 set accepted the Lean parameters')
|
|
1420
|
+
const pv2 = await N.callTool('vibe_v4_status', {})
|
|
1421
|
+
assert(/leanAsync=false/.test(pv2.params) && /leanInitiative=off/.test(pv2.params), '★ `/v4 set leanAsync=false leanInitiative=off` coerces the raw strings through normalizeParam')
|
|
1422
|
+
assert(!/【顺手形式化/.test(await N.prompts('normal', 'r-1')), '★ and leanInitiative=off set through the slash command really silences the proactive line')
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// ===============================================================
|
|
1426
|
+
// 17. audit-B #2: `leanInitiative` is an INDEPENDENT axis from `formalVerify` — `eager` asks for the
|
|
1427
|
+
// daily line even with `formalVerify:'off'` (v5 uses the same rule); 'off' never injects it while
|
|
1428
|
+
// the VERIFICATION prompt keeps following `formalVerify`.
|
|
1429
|
+
// ===============================================================
|
|
1430
|
+
section('17 leanInitiative=eager injects the daily line even with formalVerify=off (independent axes)')
|
|
1431
|
+
{
|
|
1432
|
+
const N = await establish()
|
|
1433
|
+
await N.callTool('vibe_v4_set', { formalVerify: 'off', leanInitiative: 'eager' })
|
|
1434
|
+
const daily = await N.prompts('normal', 'r-1')
|
|
1435
|
+
assert(/【顺手形式化(主动(leanInitiative=eager))】/.test(daily), '★ eager + formalVerify=off STILL injects the daily line (the header names the initiative, not a verification mode)')
|
|
1436
|
+
assert(/判断标准:① 有价值或可能复用/.test(daily) && /先 vibe_v4_lean_lib 查已有库/.test(daily), '★ …with the three criteria and the look-up-first rule')
|
|
1437
|
+
const verifyOff = await N.prompts('verify', 'r-1', { target: 'p-eager-off', stage: 'independent' })
|
|
1438
|
+
assert(!/顺手形式化/.test(verifyOff) && !/【Lean 形式化验证/.test(verifyOff), '★ …while the VERIFICATION prompt stays a true no-op under formalVerify=off')
|
|
1439
|
+
// the default initiative is not eager: off mode injects nothing (unchanged behaviour)
|
|
1440
|
+
await N.callTool('vibe_v4_set', { leanInitiative: 'normal' })
|
|
1441
|
+
assert(!/顺手形式化/.test(await N.prompts('normal', 'r-1')), '★ leanInitiative=normal + formalVerify=off injects NO daily line')
|
|
1442
|
+
// 'off' silences the daily line even when formalVerify asks for Lean, without touching verification
|
|
1443
|
+
await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanInitiative: 'off' })
|
|
1444
|
+
assert(!/顺手形式化/.test(await N.prompts('normal', 'r-1')), '★ leanInitiative=off removes the daily line even with formalVerify=encourage')
|
|
1445
|
+
assert(/【Lean 形式化验证(鼓励模式)】/.test(await N.prompts('verify', 'r-1', { target: 'p-eager-off', stage: 'independent' })), '★ …and the verification prompt is untouched by the initiative knob')
|
|
1446
|
+
// with formalVerify on, the header names the verification mode again (not the eager wording)
|
|
1447
|
+
await N.callTool('vibe_v4_set', { formalVerify: 'require', leanInitiative: 'eager' })
|
|
1448
|
+
assert(/【顺手形式化(强制)】/.test(await N.prompts('normal', 'r-1')), '★ with formalVerify on, the header names the verification mode (require → 强制)')
|
|
1449
|
+
await N.callTool('vibe_v4_set', { formalVerify: 'off', leanInitiative: 'normal' })
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// ===============================================================
|
|
1453
|
+
// 18. audit R2 HIGH: the independence rule must hold at EVERY daily-line injection site, not just
|
|
1454
|
+
// the ordinary work round. A single-site regression to `formalOn()`-only must be catchable, so
|
|
1455
|
+
// each site is asserted separately under BOTH decisive states (eager+off, and initiative=off).
|
|
1456
|
+
// ===============================================================
|
|
1457
|
+
section('18 every daily-line injection site (coreRules/normal/heartbeat) obeys the independence rule')
|
|
1458
|
+
{
|
|
1459
|
+
const N = await establish()
|
|
1460
|
+
const SITES = [
|
|
1461
|
+
['coreRules', 'coreRulesBrief', () => N.prompts('coreRules')],
|
|
1462
|
+
['normal', 'normalPrompt', () => N.prompts('normal', 'r-1')],
|
|
1463
|
+
['heartbeat', 'heartbeatPrompt', () => N.prompts('heartbeat', 'r-1')],
|
|
1464
|
+
]
|
|
1465
|
+
await N.callTool('vibe_v4_set', { formalVerify: 'off', leanInitiative: 'eager' })
|
|
1466
|
+
for (const [which, who, read] of SITES) {
|
|
1467
|
+
const text = await read()
|
|
1468
|
+
assert(/【顺手形式化(主动(leanInitiative=eager))】/.test(text), '★ [' + which + '] eager + formalVerify=off injects the daily line at this site (' + who + ')')
|
|
1469
|
+
assert(/判断标准:① 有价值或可能复用/.test(text) && /先 vibe_v4_lean_lib 查已有库/.test(text), '★ [' + which + '] …with the three criteria and the look-up-first rule (' + who + ')')
|
|
1470
|
+
}
|
|
1471
|
+
const vOff = await N.prompts('verify', 'r-1', { target: 'p-site-off', stage: 'independent' })
|
|
1472
|
+
assert(!/顺手形式化/.test(vOff) && !/【Lean 形式化验证/.test(vOff), '★ [all sites] the verification prompt stays a no-op under formalVerify=off')
|
|
1473
|
+
await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanInitiative: 'off' })
|
|
1474
|
+
for (const [which, who, read] of SITES) {
|
|
1475
|
+
const text = await read()
|
|
1476
|
+
assert(!/顺手形式化/.test(text), '★ [' + which + '] leanInitiative=off removes the daily line at this site (' + who + ')')
|
|
1477
|
+
}
|
|
1478
|
+
assert(/【Lean 形式化验证(鼓励模式)】/.test(await N.prompts('verify', 'r-1', { target: 'p-site-off', stage: 'independent' })), '★ [all sites] …while verification still follows formalVerify (encourage)')
|
|
1479
|
+
await N.callTool('vibe_v4_set', { formalVerify: 'off', leanInitiative: 'normal' })
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1193
1482
|
// ===============================================================
|
|
1194
1483
|
console.log('')
|
|
1195
1484
|
console.log('passed=' + passed + ' failed=' + failed)
|
|
@@ -226,7 +226,10 @@ async function settleInstitute(root, rounds = 12) {
|
|
|
226
226
|
async function foundInstitute(root, problem, params) {
|
|
227
227
|
await callTool('vibe_v5_start', Object.assign({ problem, researcherCount: 2 }, params || {}), root)
|
|
228
228
|
for (const sp of spawnsFor(root)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初见解。', solved: false, contextPct: 10 }); await settle() }
|
|
229
|
-
|
|
229
|
+
// This suite pins the SYNCHRONOUS publish path verbatim (`leanAsync:false`): every assertion
|
|
230
|
+
// below reads the compiler result straight off the tool return. The asynchronous queue (the
|
|
231
|
+
// default) is covered end-to-end by `e2e-v5-round2.test.mjs` (async section).
|
|
232
|
+
await callTool('vibe_v5_set', { maxParallel: 8, leanAsync: false }, root)
|
|
230
233
|
return await settleInstitute(root)
|
|
231
234
|
}
|
|
232
235
|
// Vote a verdict through to completion. `waitRounds` bounds the debate loop.
|
|
@@ -329,6 +332,11 @@ assert(!!toolRegs.find(t => t.name === 'vibe_v5_lean_run') && !!toolRegs.find(t
|
|
|
329
332
|
}
|
|
330
333
|
assert(JSON.stringify(setSpec.parameters.properties.formalVerify.enum) === JSON.stringify(['off', 'encourage', 'require']),
|
|
331
334
|
'the schema narrows formalVerify to the three real modes (a typo must not be a fourth)')
|
|
335
|
+
// audit B3: the four Lean keys must be DISCOVERABLE in the description too (v4 already names
|
|
336
|
+
// them; a schema-only key is invisible in the tool list).
|
|
337
|
+
const setDesc = String(setSpec.description || '')
|
|
338
|
+
assert(['formalVerify', 'leanCommand', 'leanArgs', 'leanTimeoutMs'].every((k) => setDesc.indexOf(k) !== -1),
|
|
339
|
+
'the set description names all four Lean toolchain keys (audit B3)')
|
|
332
340
|
}
|
|
333
341
|
|
|
334
342
|
// A stray `formal` reply in OFF mode must be INERT: the field is not offered in the reply contract
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Fake host seam for the math_computation shared module (integration owner).
|
|
2
|
+
//
|
|
3
|
+
// Purpose: drive `registerMathComputation(host)` with NO real engine and NO real subprocess, so
|
|
4
|
+
// every branch (probe / run / receipt / install, all 11 failure codes, timeout, truncation, argv
|
|
5
|
+
// echo, bad-argv) is testable on a machine with nothing installed. Owner suites that test a
|
|
6
|
+
// PRESET's wiring use the preset's own injected subprocess service instead; this seam tests the
|
|
7
|
+
// shared module itself.
|
|
8
|
+
//
|
|
9
|
+
// Lives under tests/helpers/ so the flat `*.mjs` scan in tests/run-tests.mjs does not treat it as
|
|
10
|
+
// a suite (it only reads files directly inside tests/).
|
|
11
|
+
import { createHash } from 'node:crypto'
|
|
12
|
+
|
|
13
|
+
export const FAKE_ROOT = 'X:/fake/project'
|
|
14
|
+
|
|
15
|
+
const VERSION_MARKERS = ['--version', 'disp(version)', '$Version']
|
|
16
|
+
const LICENCE_MARKERS = ["disp(license('test','MATLAB'))", 'printf("1")', 'Print[$LicenseType]']
|
|
17
|
+
const PACKAGE_MARKERS = ['importlib.util', 'requireNamespace', "pkg('list')", 'find_package', "license('test'", 'with(', 'Needs[']
|
|
18
|
+
|
|
19
|
+
export function makeFakeHost(opts = {}) {
|
|
20
|
+
const installed = (opts.installed || ['python3', 'python', 'Rscript', 'octave', 'julia', 'matlab', 'maple', 'wolframscript']).slice()
|
|
21
|
+
const engineFor = (cmd) => {
|
|
22
|
+
const base = String(cmd).split(/[\\/]/).pop()
|
|
23
|
+
const map = { python3: 'python', python: 'python', py: 'python', Rscript: 'r', R: 'r', octave: 'octave', 'octave-cli': 'octave', julia: 'julia', matlab: 'matlab', maple: 'maple', wolframscript: 'wolfram', WolframKernel: 'wolfram', math: 'wolfram' }
|
|
24
|
+
if (map[base]) return map[base]
|
|
25
|
+
for (const name of Object.keys(map)) if (base.indexOf(name) === 0) return map[name]
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
const files = new Map(Object.entries(opts.files || {}))
|
|
29
|
+
const state = {
|
|
30
|
+
files,
|
|
31
|
+
registrations: [],
|
|
32
|
+
spawns: [],
|
|
33
|
+
listDirCalls: [],
|
|
34
|
+
logs: [],
|
|
35
|
+
version: opts.version || '1.2.3',
|
|
36
|
+
packages: Object.assign({}, opts.packages || {}), // { name: 'present' | 'missing' | null }
|
|
37
|
+
licence: opts.licence !== false,
|
|
38
|
+
exit: opts.exit === undefined ? 0 : opts.exit,
|
|
39
|
+
hang: !!opts.hang,
|
|
40
|
+
hangProbe: !!opts.hangProbe,
|
|
41
|
+
versionProbeFails: !!opts.versionProbeFails,
|
|
42
|
+
// round-7: the real host intermittently answers a spawn with NULL early in a session. The first
|
|
43
|
+
// `nullSpawnTimes` spawns return null so a test can prove the retry recovers from it.
|
|
44
|
+
nullSpawnTimes: Number(opts.nullSpawnTimes) || 0,
|
|
45
|
+
// round-7 (live root cause): the real host answers a spawn whose cwd does NOT exist with
|
|
46
|
+
// `spawned:true, exit:null` (retries cannot help). `projectRoot` can be pointed at a
|
|
47
|
+
// non-existent path to emulate a FRESH workspace.
|
|
48
|
+
projectRoot: opts.projectRoot || '/fake/project',
|
|
49
|
+
freshRoot: opts.projectRoot || null,
|
|
50
|
+
// round-7: null answers ONLY for package probes (classify === 'packages'), so a test can isolate
|
|
51
|
+
// "the precheck could not run" from "the engine could not be probed".
|
|
52
|
+
packageProbeNullTimes: Number(opts.packageProbeNullTimes) || 0,
|
|
53
|
+
stdoutBytes: opts.stdoutBytes || 0,
|
|
54
|
+
argError: opts.argError || null,
|
|
55
|
+
probeCalls: 0,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const classify = (argv) => {
|
|
59
|
+
const joined = argv.join(' ')
|
|
60
|
+
if (LICENCE_MARKERS.some((m) => joined.indexOf(m) !== -1)) return 'licence'
|
|
61
|
+
if (PACKAGE_MARKERS.some((m) => joined.indexOf(m) !== -1)) return 'packages'
|
|
62
|
+
if (VERSION_MARKERS.some((m) => joined.indexOf(m) !== -1)) return 'version'
|
|
63
|
+
return 'run'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const host = {
|
|
67
|
+
register(name, description, parameters, handler) {
|
|
68
|
+
state.registrations.push({ name, description, parameters, handler })
|
|
69
|
+
},
|
|
70
|
+
params: () => (typeof opts.params === 'function' ? opts.params() : (opts.params || {})),
|
|
71
|
+
projectRoot: () => state.freshRoot || opts.root || FAKE_ROOT,
|
|
72
|
+
designator: opts.designator || 'vibe-math-v2',
|
|
73
|
+
writeText: async (rel, text) => { files.set(String(rel).replace(/\\/g, '/'), String(text)); state.rootCreated = true; return true },
|
|
74
|
+
// Optional capability flag (INTERFACE-FREEZE §4): a host that knows it has no subprocess service
|
|
75
|
+
// says so, and the module reports MATH_NO_SUBPROCESS instead of a misleading ENGINE_NOT_FOUND.
|
|
76
|
+
hasSubprocess: ('hasSubprocess' in opts) ? (() => opts.hasSubprocess) : undefined,
|
|
77
|
+
readText: async (rel) => { const v = files.get(String(rel).replace(/\\/g, '/')); return v === undefined ? undefined : v },
|
|
78
|
+
exists: async (rel) => files.has(String(rel).replace(/\\/g, '/')),
|
|
79
|
+
// Optional DSH-fs-like listing (real API: ctx.fs.resolve + listDir, entries {name,type}).
|
|
80
|
+
// Present only when the test asks for it, so "no listDir => no project-level retention check"
|
|
81
|
+
// is testable.
|
|
82
|
+
listDir: ('listDir' in opts) ? (async (rel) => {
|
|
83
|
+
state.listDirCalls.push(String(rel))
|
|
84
|
+
if (typeof opts.listDir === 'function') return opts.listDir(rel)
|
|
85
|
+
return (opts.listDir || []).slice()
|
|
86
|
+
}) : undefined,
|
|
87
|
+
// round-7 (fix 2): OPTIONAL bundled-runtime discovery. `runtimeRoots` are absolute roots and
|
|
88
|
+
// `absTree` maps an absolute directory to its entries, so a test can fake DSH's layout
|
|
89
|
+
// (`<root>/dsh-runtimes/<tree>/dependencies/python/python.exe`).
|
|
90
|
+
runtimeRoots: ('runtimeRoots' in opts) ? (async () => (opts.runtimeRoots || [])) : undefined,
|
|
91
|
+
listDirAbs: opts.absTree ? (async (abs) => (opts.absTree[String(abs).replace(/\\/g, '/')] || [])) : undefined,
|
|
92
|
+
resolveExecutable: async (cmd) => {
|
|
93
|
+
if (opts.resolveThrows) throw new Error('resolve failed: ' + cmd)
|
|
94
|
+
const raw = String(cmd)
|
|
95
|
+
const base = raw.split(/[\\/]/).pop()
|
|
96
|
+
// round-6 (B): an explicit path map lets a test place the interpreter in a conda-style
|
|
97
|
+
// directory or next to a `uv` binary - the manager dispatch reads those facts.
|
|
98
|
+
if (opts.resolveMap && Object.prototype.hasOwnProperty.call(opts.resolveMap, base)) return opts.resolveMap[base]
|
|
99
|
+
// A caller-supplied path (engine='cli') is accepted as-is: that is the whole point of the
|
|
100
|
+
// escape hatch - the tool only has to resolve the command, not know the engine.
|
|
101
|
+
if (raw.indexOf('/') !== -1 || raw.indexOf('\\') !== -1) return raw
|
|
102
|
+
let ok = installed.indexOf(base) !== -1
|
|
103
|
+
if (!ok && opts.cliCommands && opts.cliCommands.indexOf(base) !== -1) ok = true
|
|
104
|
+
if (!ok) throw new Error('not found: ' + cmd)
|
|
105
|
+
return '/fake/bin/' + base
|
|
106
|
+
},
|
|
107
|
+
spawn: async ({ argv, cwd, timeoutMs, stdoutCap, stderrCap }) => {
|
|
108
|
+
if (state.nullSpawnTimes > 0) { state.nullSpawnTimes--; state.spawns.push({ argv: argv.slice(), cwd, timeoutMs, stdoutCap, stderrCap, nullSpawn: true }); return null }
|
|
109
|
+
state.spawns.push({ argv: argv.slice(), cwd, timeoutMs, stdoutCap, stderrCap })
|
|
110
|
+
// Emulate the real host: a spawn whose cwd does not exist answers with exit:null. Note the
|
|
111
|
+
// host's writeText creates parent directories (writeFileAtomic), so a RUN (which writes the
|
|
112
|
+
// receipt first) legitimately makes the project root exist before it spawns.
|
|
113
|
+
if (state.freshRoot && !state.rootCreated && String(cwd) === String(state.freshRoot)) return { exit: null, timedOut: false, killed: false, ms: 130, stdout: '', stderr: '' }
|
|
114
|
+
const kind = classify(argv)
|
|
115
|
+
if (kind === 'packages' && state.packageProbeNullTimes > 0) { state.packageProbeNullTimes--; state.spawns[state.spawns.length - 1].nullSpawn = true; return null }
|
|
116
|
+
if (kind === 'version') {
|
|
117
|
+
if (state.hangProbe) return { exit: null, timedOut: true, killed: true, ms: timeoutMs, stdout: '', stderr: '' }
|
|
118
|
+
// round-7: a DETERMINISTIC probe failure (engine resolved, probe exits non-zero, no version).
|
|
119
|
+
if (state.versionProbeFails) return { exit: 1, timedOut: false, killed: false, ms: 5, stdout: '', stderr: 'probe boom' }
|
|
120
|
+
const exeName = String(argv[0] || 'engine').split(/[\\/]/).pop()
|
|
121
|
+
return { exit: 0, timedOut: false, killed: false, ms: 5, stdout: exeName + ' ' + state.version + '\n', stderr: '' }
|
|
122
|
+
}
|
|
123
|
+
if (kind === 'licence') {
|
|
124
|
+
if (!state.licence) return { exit: 1, timedOut: false, killed: false, ms: 5, stdout: '0\n', stderr: '' }
|
|
125
|
+
return { exit: 0, timedOut: false, killed: false, ms: 5, stdout: '1\n', stderr: '' }
|
|
126
|
+
}
|
|
127
|
+
if (kind === 'packages') {
|
|
128
|
+
const pairs = []
|
|
129
|
+
const joined = argv.join(' ')
|
|
130
|
+
for (const name of Object.keys(state.packages)) {
|
|
131
|
+
if (joined.indexOf(name) === -1) continue
|
|
132
|
+
const v = state.packages[name]
|
|
133
|
+
const present = (v === true || v === 'present' || v === 'ok')
|
|
134
|
+
pairs.push(name + ':' + (present ? 'ok' : 'missing'))
|
|
135
|
+
}
|
|
136
|
+
return { exit: 0, timedOut: false, killed: false, ms: 5, stdout: pairs.join('|') + '\n', stderr: '' }
|
|
137
|
+
}
|
|
138
|
+
// run
|
|
139
|
+
if (state.hang) return { exit: null, timedOut: true, killed: true, ms: timeoutMs, stdout: 'partial', stderr: '' }
|
|
140
|
+
if (state.argError) return { exit: 2, timedOut: false, killed: false, ms: 9, stdout: '', stderr: state.argError }
|
|
141
|
+
// P2a: simulate "another member edits the source while this run is in flight".
|
|
142
|
+
if (opts.mutateOnRun && opts.mutateOnRun.rel) files.set(String(opts.mutateOnRun.rel).replace(/\\/g, '/'), String(opts.mutateOnRun.text == null ? '' : opts.mutateOnRun.text))
|
|
143
|
+
// Audit-A: simulate the source being DELETED mid-run (a change, not "no change").
|
|
144
|
+
if (opts.deleteOnRun && opts.deleteOnRun.rel) files.delete(String(opts.deleteOnRun.rel).replace(/\\/g, '/'))
|
|
145
|
+
const out = state.stdoutBytes > 0 ? 'x'.repeat(state.stdoutBytes) : 'ran-ok\n'
|
|
146
|
+
if (state.exit !== 0) return { exit: state.exit, timedOut: false, killed: false, ms: 9, stdout: '', stderr: 'boom' }
|
|
147
|
+
return { exit: 0, timedOut: false, killed: false, ms: 9, stdout: out, stderr: '' }
|
|
148
|
+
},
|
|
149
|
+
log: (kind, msg) => { state.logs.push({ kind, msg }) },
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
state.handler = () => state.registrations[0] && state.registrations[0].handler
|
|
153
|
+
state.call = async (args) => {
|
|
154
|
+
const reg = state.registrations[0]
|
|
155
|
+
if (!reg) throw new Error('makeFakeHost: nothing registered yet - call registerMathComputation(host) first')
|
|
156
|
+
return await reg.handler(args)
|
|
157
|
+
}
|
|
158
|
+
state.file = (rel) => files.get(String(rel).replace(/\\/g, '/'))
|
|
159
|
+
state.engineFor = engineFor
|
|
160
|
+
return state.__host ? state : Object.assign(state, { host })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function sha256(s) { return createHash('sha256').update(String(s)).digest('hex') }
|