dsh-vibe-math 2.7.0 → 2.7.2
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 +681 -685
- package/README.md +9 -13
- package/cordis.patch.yml +12 -6
- package/docs/AUDIT-CHECKLIST.md +518 -518
- package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
- package/docs/final-paper.md +172 -172
- package/docs/formal-verification.md +588 -588
- package/docs/math-computation.md +187 -121
- package/docs/release-notes/README.en.md +48 -0
- package/docs/release-notes/README.md +47 -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/release-notes/RELEASE-NOTES-2.7.2.en.md +72 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.2.md +72 -0
- package/docs/release-notes/TEMPLATE.md +120 -0
- package/docs/test-timing.md +154 -154
- package/package.json +208 -201
- package/prompt-corpus-persona/persona-corpus.json +8 -8
- package/prompt-corpus-persona/persona-corpus.md +24 -12
- package/prompt-corpus-v2/formal-verify-v2.json +176 -98
- package/prompt-corpus-v2/formal-verify-v2.md +1161 -96
- package/prompt-corpus-v3/formal-verify-v3.json +201 -63
- package/prompt-corpus-v3/formal-verify-v3.md +1401 -57
- package/prompt-corpus-v4/formal-verify-v4.json +10 -10
- package/prompt-corpus-v4/formal-verify-v4.md +40 -20
- package/prompt-corpus-v5/prompt-corpus-v5.json +81 -81
- package/prompt-corpus-v5/prompt-corpus-v5.md +199 -128
- package/tests/audit-math-computation-contract.mjs +56 -1
- package/tests/audit-math-computation-parity.mjs +30 -1
- package/tests/audit-math-computation-sensitivity.mjs +2 -2
- package/tests/audit-v5-sensitivity.mjs +3 -1
- package/tests/e2e-v5-round2.test.mjs +116 -0
- package/tests/formal-verify-v2.test.mjs +108 -2
- package/tests/formal-verify-v3.test.mjs +117 -2
- package/tests/formal-verify-v4.test.mjs +87 -0
- package/tests/helpers/math-computation-fake-seam.mjs +30 -2
- package/tests/math-computation-shared.test.mjs +430 -3
- package/vibe-math-v2/agent.cordis.yml +4 -2
- package/vibe-math-v2/math-computation.js +509 -55
- package/vibe-math-v2/math-engines.js +47 -6
- package/vibe-math-v2/vibe-math-v2.js +92 -12
- package/vibe-math-v3/agent.cordis.yml +4 -2
- package/vibe-math-v3/math-computation.js +509 -55
- package/vibe-math-v3/math-engines.js +47 -6
- package/vibe-math-v3/vibe-math-v3.js +77 -14
- package/vibe-math-v4/agent.cordis.yml +8 -4
- package/vibe-math-v4/math-computation.js +509 -55
- package/vibe-math-v4/math-engines.js +47 -6
- package/vibe-math-v4/vibe-math-v4.js +70 -15
- package/vibe-math-v5/agent.cordis.yml +8 -4
- package/vibe-math-v5/math-computation.js +509 -55
- package/vibe-math-v5/math-engines.js +47 -6
- package/vibe-math-v5/vibe-math-v5.js +152 -31
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1660 -1655
- package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
|
@@ -62,7 +62,9 @@ export const MATH_TOOL_DESCRIPTION = '数学计算:先用 op:\'probe\' 预检
|
|
|
62
62
|
+ '且结论必须标注未经工具归档))。每次执行都会归档成可复核回执(Computation/<id>/),回执里含 scriptPath/scriptHash 与**实际 argv**;'
|
|
63
63
|
+ '脚本原件就在那个归档目录里,你可以用普通文件工具打开/编辑它,编辑后用 mode:\'file\' 重跑会得到一份**新回执**——'
|
|
64
64
|
+ '**不得**拿旧回执当作修改后代码的证据。缺引擎/缺包只报告并给"用户自装指引"或"代理代装计划"(先计划、再确认);'
|
|
65
|
-
+ '商业引擎只给厂商指引;引擎选项报错时会回显 argv 并提示用 mathEngineOverride
|
|
65
|
+
+ '商业引擎只给厂商指引;引擎选项报错时会回显 argv 并提示用 mathEngineOverride。'
|
|
66
|
+
+ '替代方案若改变精确性或结论强度(精确解→数值近似、闭式解→采样/求积、改精度/容差/假设、换算法类)**必须在结论里声明**,不得读起来像得到了原本的结果。'
|
|
67
|
+
+ '计算结果是经验证据,不是证明。'
|
|
66
68
|
|
|
67
69
|
export const MATH_PERSONA_TOOL_LINE = '- math_computation {op: probe|run|receipt|install, engine, mode: code|file|expr, …} — '
|
|
68
70
|
+ '先 probe 预检引擎/包/许可,再调引擎计算并把脚本与输出归档成可复核回执;缺引擎/缺包只报告与给安装指引/计划;shell 兜底不算归档。'
|
|
@@ -70,11 +72,20 @@ export const MATH_PERSONA_TOOL_LINE = '- math_computation {op: probe|run|receipt
|
|
|
70
72
|
// P2a: the archive -> edit -> re-run workflow. Injected into every preset's rule block (and appended
|
|
71
73
|
// to both persona blocks) so an agent cannot miss that (a) the script is archived, (b) it may edit
|
|
72
74
|
// it, and (c) re-running is what produces evidence for the edited code.
|
|
73
|
-
export const MATH_ARCHIVE_WORKFLOW_LINE = '- 归档→编辑→重跑:mode:\'code\' 的脚本原件在回执的 scriptPath(Computation/<id>/script.<ext
|
|
75
|
+
export const MATH_ARCHIVE_WORKFLOW_LINE = '- 归档→编辑→重跑:mode:\'code\' 的脚本原件在回执的 scriptPath(Computation/<id>/script.<ext>,**相对项目根**);'
|
|
76
|
+
+ '**成员的文件工具是按会话 cwd 解析的**,所以打开它要用**绝对路径** `receipt.scriptAbs`,或把 `receipt.cwd` 与 `receipt.scriptPath` 拼起来(回执两个字段都有);'
|
|
74
77
|
+ '编辑后用 mode:\'file\' 指向它重跑,会写出一份**新回执/新 attempt**(含新的 scriptHash)。**旧回执对修改后的代码无效**——'
|
|
75
78
|
+ '报告里必须引用与当前代码哈希一致的那份回执;工具会在 scriptChanged / scriptChangedDuringRun 为 true 时显式告警。'
|
|
76
79
|
|
|
77
|
-
export const MATH_ARCHIVE_WORKFLOW_LINE_EN = '- Archive -> edit -> re-run: for mode:\'code\' the script original is at the receipt\'s scriptPath (Computation/<id>/script.<ext
|
|
80
|
+
export const MATH_ARCHIVE_WORKFLOW_LINE_EN = '- Archive -> edit -> re-run: for mode:\'code\' the script original is at the receipt\'s scriptPath (Computation/<id>/script.<ext>, **relative to the project root**); member file tools resolve paths against the SESSION CWD, so open it via the ABSOLUTE `receipt.scriptAbs`, or join `receipt.cwd` with `receipt.scriptPath` (both are in the receipt); after editing, re-run it with mode:\'file\' to write a NEW receipt/attempt with a NEW scriptHash. **An old receipt is NOT evidence for edited code** - cite the receipt whose scriptHash matches the current code; the tool warns explicitly via scriptChanged / scriptChangedDuringRun.'
|
|
81
|
+
|
|
82
|
+
// Round-6 (A): honesty about SUBSTITUTIONS. An alternative that weakens exactness or conclusion
|
|
83
|
+
// strength must be declared, and the conclusion must never read as if the requested (exact) result
|
|
84
|
+
// had been obtained. Injected through mathAvailabilityLine (rule block) AND appended to both persona
|
|
85
|
+
// blocks by the presets - same mechanism as the archive-workflow line.
|
|
86
|
+
export const MATH_SUBSTITUTION_RULE_LINE = '- 替代必须声明(诚实性):当替代方案改变了**精确性或结论强度**时(精确符号解 → 数值近似、闭式解 → 采样/求积、改了精度/容差/假设、换了算法类),结论里**必须写明**,不得读起来像得到了原本(精确/所要求的)结果;拿不到精确结果就直说。'
|
|
87
|
+
|
|
88
|
+
export const MATH_SUBSTITUTION_RULE_LINE_EN = '- Declare substitutions (honesty): when an alternative changes EXACTNESS or conclusion strength (exact symbolic solution -> numerical approximation, closed form -> sampling/quadrature, changed precision/tolerances/assumptions, a different algorithm class), the conclusion MUST say so explicitly and must not read as if the original (exact/requested) result had been obtained; if the exact result is unavailable, say so plainly.'
|
|
78
89
|
|
|
79
90
|
export const MATH_RULE_LINES = Object.freeze([
|
|
80
91
|
'- 需要精确数值、符号化简、反例搜索、统计或线性代数时调用 math_computation:先 probe,再 run。',
|
|
@@ -82,6 +93,7 @@ export const MATH_RULE_LINES = Object.freeze([
|
|
|
82
93
|
'- 归档即引用:报告里带 Computation/<id>/receipt.json;这是"支撑材料"的用法。',
|
|
83
94
|
'- 判断标准:① 有价值或可能复用;② 较为关键或必要;③ 你对该陈述有把握(置信度高)——没把握的先别入库。',
|
|
84
95
|
'- 不得把计算结果当成"已证明":对象是否已验证仍只由本预设既有的验证/共识路径给出。',
|
|
96
|
+
MATH_SUBSTITUTION_RULE_LINE,
|
|
85
97
|
])
|
|
86
98
|
|
|
87
99
|
// The shell-fallback sentence is deliberately NOT part of MATH_RULE_LINES: it is only advertised
|
|
@@ -96,6 +108,7 @@ export const MATH_RULE_LINES_EN = Object.freeze([
|
|
|
96
108
|
'- Archive means cite: put Computation/<id>/receipt.json in your report - that is what "supporting material" means.',
|
|
97
109
|
'- Criteria: (1) valuable or likely reusable; (2) important or necessary; (3) you are confident in it - do not archive what you are unsure about.',
|
|
98
110
|
'- Never treat a computation result as "proved": whether an object is verified is still decided only by this preset\'s existing verification/consensus path.',
|
|
111
|
+
MATH_SUBSTITUTION_RULE_LINE_EN,
|
|
99
112
|
])
|
|
100
113
|
|
|
101
114
|
export const MATH_SHELL_RULE_LINE_EN = '- Shell fallback: if the tool cannot run you may use the host shell, but mark the conclusion "not tool-archived (shell path)" - shell runs have no receipt and no timeout/output guarantees; only tool-routed computations count as reproducible supporting material.'
|
|
@@ -210,7 +223,14 @@ export function validateMathArgs(args, params) {
|
|
|
210
223
|
if (typeof args.engine !== 'string') return bad('engine must be a string')
|
|
211
224
|
if (args.engine !== 'auto' && !MATH_ENGINES[args.engine]) return bad('unknown engine: ' + args.engine)
|
|
212
225
|
}
|
|
213
|
-
if (args.packages !== undefined
|
|
226
|
+
if (args.packages !== undefined) {
|
|
227
|
+
if (!Array.isArray(args.packages) || args.packages.some((x) => typeof x !== 'string')) return bad('packages must be a string[]')
|
|
228
|
+
// round-6 (D): accept the manager-understood pass-through forms; refuse anything else explicitly.
|
|
229
|
+
for (const spec of args.packages) {
|
|
230
|
+
const parsed = parsePackageSpec(spec)
|
|
231
|
+
if (!parsed.ok) return bad(parsed.problem, 'MATH_INVALID_ARGUMENT', next('reason', { reason: 'unsupported-version-syntax' }))
|
|
232
|
+
}
|
|
233
|
+
}
|
|
214
234
|
if (args.captureFiles !== undefined && (!Array.isArray(args.captureFiles) || args.captureFiles.some((x) => typeof x !== 'string'))) return bad('captureFiles must be a string[]')
|
|
215
235
|
if (args.timeoutMs !== undefined) {
|
|
216
236
|
const n = Number(args.timeoutMs)
|
|
@@ -266,14 +286,42 @@ function fail(code, engine, message, extra) {
|
|
|
266
286
|
if (extra && extra.engines) out.engines = extra.engines
|
|
267
287
|
if (extra && extra.available !== undefined) out.available = extra.available
|
|
268
288
|
if (extra && extra.packages) out.packages = extra.packages
|
|
289
|
+
// round-7 (live-session fix): the probe diagnostic must survive into the response, otherwise
|
|
290
|
+
// "存在但不可用" is opaque (a real session showed exactly that).
|
|
291
|
+
if (extra && extra.probe) out.probe = jsonSafeDiag(extra.probe)
|
|
292
|
+
// round-9 (F7): the version-constraint policy travels with failures too (no silent drop).
|
|
293
|
+
if (extra && extra.versionPolicy) out.versionPolicy = extra.versionPolicy
|
|
294
|
+
if (extra && extra.constraintsNotEnforced) out.constraintsNotEnforced = extra.constraintsNotEnforced
|
|
269
295
|
return out
|
|
270
296
|
}
|
|
271
297
|
|
|
272
|
-
|
|
298
|
+
// round-9 (real-engine finding): the suggested install command may not be runnable on this machine
|
|
299
|
+
// (e.g. `winget` is absent), and handing a user a command that cannot run is misleading. So the
|
|
300
|
+
// command is emitted ONLY when its package manager is actually resolvable; otherwise we return the
|
|
301
|
+
// official vendor/download pointer plus an explicit note. Async because resolution goes through the host.
|
|
302
|
+
async function userInstallNext(H, engine) {
|
|
273
303
|
const d = MATH_ENGINES[engine] || {}
|
|
274
304
|
const per = d.userInstall || {}
|
|
275
305
|
const platform = process && process.platform === 'win32' ? 'windows' : (process && process.platform === 'darwin' ? 'macos' : 'linux')
|
|
276
|
-
|
|
306
|
+
const raw = per[platform] || ''
|
|
307
|
+
const mgr = (function () { const m = /^\s*(winget|brew|apt-get|apt|choco|scoop|dnf|pacman|zypper)\b/.exec(String(raw)); return m ? m[1] : null })()
|
|
308
|
+
let available = true
|
|
309
|
+
if (mgr) {
|
|
310
|
+
try { available = !!(await H.resolveExecutable(mgr)) } catch (e) { available = false }
|
|
311
|
+
}
|
|
312
|
+
return next('user-install', {
|
|
313
|
+
engine: engine,
|
|
314
|
+
perOs: per,
|
|
315
|
+
command: (mgr && !available) ? '' : raw,
|
|
316
|
+
suggestedCommand: raw,
|
|
317
|
+
platform: platform,
|
|
318
|
+
packageManager: mgr,
|
|
319
|
+
packageManagerAvailable: mgr ? available : null,
|
|
320
|
+
vendorUrl: d.vendor || null,
|
|
321
|
+
note: (mgr && !available)
|
|
322
|
+
? ('本机没有检测到包管理器 ' + mgr + ':建议命令无法运行,请按上面的官方地址手动安装(或先自行安装一个包管理器)。')
|
|
323
|
+
: null,
|
|
324
|
+
})
|
|
277
325
|
}
|
|
278
326
|
|
|
279
327
|
// ── host adaptation ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -294,6 +342,9 @@ function adaptHost(host) {
|
|
|
294
342
|
spawn: h.spawn,
|
|
295
343
|
hasSubprocess: typeof h.hasSubprocess === 'function' ? h.hasSubprocess : null,
|
|
296
344
|
listDir: typeof h.listDir === 'function' ? h.listDir : null,
|
|
345
|
+
// round-7 (fix 2): optional bundled-runtime discovery (absolute roots + absolute listing).
|
|
346
|
+
runtimeRoots: typeof h.runtimeRoots === 'function' ? h.runtimeRoots : null,
|
|
347
|
+
listDirAbs: typeof h.listDirAbs === 'function' ? h.listDirAbs : null,
|
|
297
348
|
log: typeof h.log === 'function' ? h.log : function () {},
|
|
298
349
|
}
|
|
299
350
|
adapted.__mathHost = true
|
|
@@ -328,6 +379,42 @@ export async function probeMathEngines(host, opts) {
|
|
|
328
379
|
return result
|
|
329
380
|
}
|
|
330
381
|
|
|
382
|
+
// round-9 (real-engine finding): an engine can be INSTALLED yet invisible, because discovery only
|
|
383
|
+
// looked at the server process's PATH (R 4.6.1 landed in C:\Program Files\R\R-4.6.1\bin and was NOT
|
|
384
|
+
// added to PATH). Add the known per-OS INSTALL locations, globbed by version, still AFTER PATH and
|
|
385
|
+
// after the bundled runtime; existence is always proven by LISTING, never assumed.
|
|
386
|
+
async function knownInstallCandidates(H, engineName) {
|
|
387
|
+
if (typeof H.listDirAbs !== 'function') return []
|
|
388
|
+
const cands = mathEngineCandidates(engineName)
|
|
389
|
+
if (!cands) return []
|
|
390
|
+
const env = (typeof process !== 'undefined' && process.env) || {}
|
|
391
|
+
const win = !!(typeof process !== 'undefined' && process.platform === 'win32')
|
|
392
|
+
const exts = win ? ['', '.exe', '.cmd'] : ['']
|
|
393
|
+
const out = []
|
|
394
|
+
const listDirs = async (dir) => { try { return ((await H.listDirAbs(dir)) || []).filter((e) => e && e.type === 'directory').map((e) => e.name) } catch (e) { return [] } }
|
|
395
|
+
const listFiles = async (dir) => { try { return ((await H.listDirAbs(dir)) || []).filter((e) => e && e.type === 'file').map((e) => e.name) } catch (e) { return [] } }
|
|
396
|
+
const match = async (dir) => {
|
|
397
|
+
const files = await listFiles(dir)
|
|
398
|
+
for (const c of cands) for (const x of exts) if (files.indexOf(c + x) !== -1) out.push(dir + '/' + c + x)
|
|
399
|
+
}
|
|
400
|
+
if (engineName === 'r') {
|
|
401
|
+
const roots = win
|
|
402
|
+
? [String(env.ProgramFiles || 'C:/Program Files') + '/R', String(env['ProgramFiles(x86)'] || 'C:/Program Files (x86)') + '/R', env.LOCALAPPDATA ? String(env.LOCALAPPDATA) + '/Programs/R' : null]
|
|
403
|
+
: ['/usr/lib/R', '/Library/Frameworks/R.framework/Resources', '/usr/local/lib/R', '/opt/R', '/usr/lib64/R']
|
|
404
|
+
for (const root of roots) {
|
|
405
|
+
if (!root) continue
|
|
406
|
+
for (const d of await listDirs(root)) await match(root + '/' + d + '/bin') // windows versioned dirs
|
|
407
|
+
await match(root + '/bin') // unix / framework layout
|
|
408
|
+
}
|
|
409
|
+
} else if (engineName === 'python' && win) {
|
|
410
|
+
const local = env.LOCALAPPDATA ? String(env.LOCALAPPDATA) + '/Programs/Python' : null
|
|
411
|
+
for (const d of (local ? await listDirs(local) : [])) await match(local + '/' + d)
|
|
412
|
+
const pf = String(env.ProgramFiles || 'C:/Program Files')
|
|
413
|
+
for (const d of await listDirs(pf)) if (/^Python[0-9]/i.test(d)) await match(pf + '/' + d)
|
|
414
|
+
}
|
|
415
|
+
return Array.from(new Set(out))
|
|
416
|
+
}
|
|
417
|
+
|
|
331
418
|
async function resolveCandidate(H, d) {
|
|
332
419
|
const cands = mathEngineCandidates(d.name)
|
|
333
420
|
if (!cands) return null
|
|
@@ -337,20 +424,142 @@ async function resolveCandidate(H, d) {
|
|
|
337
424
|
if (typeof p === 'string' && p) return p
|
|
338
425
|
} catch (e) { /* not this one */ }
|
|
339
426
|
}
|
|
427
|
+
// round-7 (fix 2): DSH ships its own runtimes; PATH always wins, so this is a LAST resort. The
|
|
428
|
+
// tree name is globbed (never hard-coded) and executable names come from the descriptor candidates.
|
|
429
|
+
for (const p of await dshRuntimeCandidates(H, d.name)) {
|
|
430
|
+
if (typeof p === 'string' && p) return p
|
|
431
|
+
}
|
|
432
|
+
// round-9: then the known per-OS install locations (an engine installed without touching PATH).
|
|
433
|
+
for (const p of await knownInstallCandidates(H, d.name)) {
|
|
434
|
+
if (typeof p === 'string' && p) return p
|
|
435
|
+
}
|
|
340
436
|
return null
|
|
341
437
|
}
|
|
342
438
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
439
|
+
// Generic discovery of a DSH-bundled runtime for one engine:
|
|
440
|
+
// <root>/dsh-runtimes/<tree>/dependencies/<engine>/<candidate><ext>
|
|
441
|
+
// `runtimeRoots` and `listDirAbs` are OPTIONAL host fields (each preset knows its own home and fs);
|
|
442
|
+
// when the host does not provide them this returns [] and behaviour is exactly as before.
|
|
443
|
+
async function dshRuntimeCandidates(H, engineName) {
|
|
444
|
+
if (typeof H.listDirAbs !== 'function' || typeof H.runtimeRoots !== 'function') return []
|
|
445
|
+
const cands = mathEngineCandidates(engineName)
|
|
446
|
+
if (!cands) return []
|
|
447
|
+
let roots = []
|
|
448
|
+
try { roots = (await H.runtimeRoots()) || [] } catch (e) { return [] }
|
|
449
|
+
if (!Array.isArray(roots) || !roots.length) return []
|
|
450
|
+
const win = !!(typeof process !== 'undefined' && process.platform === 'win32')
|
|
451
|
+
const exts = win ? ['.exe', '.cmd', ''] : ['']
|
|
452
|
+
const dirAliases = engineName === 'r' ? ['r', 'R'] : [engineName]
|
|
453
|
+
const out = []
|
|
454
|
+
for (const root of roots) {
|
|
455
|
+
if (!root) continue
|
|
456
|
+
const base = String(root).replace(/[\\/]+$/, '') + '/dsh-runtimes'
|
|
457
|
+
let trees = []
|
|
458
|
+
try { trees = (await H.listDirAbs(base)) || [] } catch (e) { continue }
|
|
459
|
+
for (const tree of trees) {
|
|
460
|
+
if (!tree || tree.type !== 'directory' || !tree.name) continue
|
|
461
|
+
for (const dirName of dirAliases) {
|
|
462
|
+
const depDir = base + '/' + tree.name + '/dependencies/' + dirName
|
|
463
|
+
let entries = []
|
|
464
|
+
try { entries = (await H.listDirAbs(depDir)) || [] } catch (e) { continue }
|
|
465
|
+
// Only names the descriptor itself would look for (python3/python/py, …), so a bundled
|
|
466
|
+
// runtime cannot smuggle in an unrelated binary.
|
|
467
|
+
for (const c of cands) {
|
|
468
|
+
for (const ext of exts) {
|
|
469
|
+
const hit = entries.find((e) => e && e.type === 'file' && String(e.name) === c + ext)
|
|
470
|
+
if (hit) out.push(depDir + '/' + hit.name)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return Array.from(new Set(out))
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// round-7 (live root cause): probe-ish spawns used `projectRoot()` as cwd, but on a BRAND-NEW session
|
|
480
|
+
// that directory does not exist yet, and the host answers such a spawn with `spawned:true, exit:null`
|
|
481
|
+
// (retries cannot help - same cwd, same result). Runs are unaffected because they write the receipt
|
|
482
|
+
// (creating the tree) before spawning. So probes use a cwd that is guaranteed to exist: an explicit
|
|
483
|
+
// host `probeCwd`, else the OS temp dir, else the project root.
|
|
484
|
+
async function probeCwd(H) {
|
|
485
|
+
if (typeof H.probeCwd === 'function') {
|
|
486
|
+
try { const p = await H.probeCwd(); if (typeof p === 'string' && p) return p } catch (e) { /* fall through */ }
|
|
487
|
+
}
|
|
488
|
+
const env = (typeof process !== 'undefined' && process.env) || {}
|
|
489
|
+
const tmp = env.TEMP || env.TMPDIR || env.TMP
|
|
490
|
+
if (typeof tmp === 'string' && tmp) return tmp
|
|
491
|
+
return await H.projectRoot()
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function probeVersion(H, d, exe, opts) {
|
|
495
|
+
const o = opts || {}
|
|
496
|
+
const argv = [exe].concat(d.versionArgv || [])
|
|
497
|
+
const timeoutMs = Number(o.timeoutMs) || 20000 // a COLD host runner can take seconds; 5s was too tight
|
|
498
|
+
const r = await H.spawn({ argv: argv, cwd: await probeCwd(H), timeoutMs: timeoutMs, stdoutCap: 8192, stderrCap: 8192 })
|
|
499
|
+
const diag = {
|
|
500
|
+
argv: argv.slice(0, 3),
|
|
501
|
+
exit: r ? (r.exit === undefined ? null : r.exit) : null,
|
|
502
|
+
timedOut: !!(r && r.timedOut),
|
|
503
|
+
ms: r ? r.ms : null,
|
|
504
|
+
spawned: !!r,
|
|
505
|
+
stderrTail: String((r && r.stderr) || '').slice(-300),
|
|
506
|
+
}
|
|
507
|
+
if (!r) return { ok: false, version: 'unknown', diag: diag }
|
|
508
|
+
if (r.timedOut) return { ok: false, version: 'unknown', diag: diag }
|
|
346
509
|
const m = new RegExp(d.versionRe).exec(String(r.stdout || '') + '\n' + String(r.stderr || ''))
|
|
347
|
-
return { ok: r.exit === 0 && !!m, version: m ? m[1] : 'unknown' }
|
|
510
|
+
return { ok: r.exit === 0 && !!m, version: m ? m[1] : 'unknown', diag: diag }
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// round-7 (live-session fix): an engine that RESOLVED but whose version probe failed is retried once
|
|
514
|
+
// with a longer budget WHEN the failure looks like a cold-start/timeout (timedOut, or a spawn that
|
|
515
|
+
// produced no exit code). A deterministic failure (non-zero exit, unparsable output) is NOT retried -
|
|
516
|
+
// retrying every failure would double every probe spawn. The diagnostic travels with the result
|
|
517
|
+
// either way, so "存在但不可用" is never opaque.
|
|
518
|
+
// round-7 (live regression): diagnostics must be JSON-SAFE. An earlier version stored a retry diag
|
|
519
|
+
// that was the SAME object as its parent, so `JSON.stringify(response)` threw ("circular structure")
|
|
520
|
+
// and the caller saw no probe result at all. Project plain fields only, never keep back-references.
|
|
521
|
+
function jsonSafeDiag(d) {
|
|
522
|
+
if (!d || typeof d !== 'object') return null
|
|
523
|
+
const out = { argv: Array.isArray(d.argv) ? d.argv.map(String).slice(0, 4) : [] }
|
|
524
|
+
for (const k of ['exit', 'timedOut', 'ms', 'spawned', 'stderrTail', 'attempts', 'retried']) {
|
|
525
|
+
const v = d[k]
|
|
526
|
+
if (v === undefined) continue
|
|
527
|
+
out[k] = (v === null || typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string') ? v : (Array.isArray(v) ? v.map((x) => (x && typeof x === 'object') ? jsonSafeDiag(x) : x) : null)
|
|
528
|
+
}
|
|
529
|
+
if (d.retryDiag) out.retryDiag = jsonSafeDiag(d.retryDiag)
|
|
530
|
+
return out
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function probeVersionWithRetry(H, d, exe) {
|
|
534
|
+
const attempts = []
|
|
535
|
+
let last = null
|
|
536
|
+
for (let i = 0; i < 3; i++) {
|
|
537
|
+
last = await probeVersion(H, d, exe, { timeoutMs: i === 0 ? 20000 : 45000 })
|
|
538
|
+
attempts.push(jsonSafeDiag(last.diag))
|
|
539
|
+
if (last.ok) break
|
|
540
|
+
// A NULL spawn (host returned nothing usable) and a timeout both look like the session-early
|
|
541
|
+
// flakiness the live test hit; a deterministic failure (non-zero exit, unparsable output) is not
|
|
542
|
+
// retried, so a genuinely broken engine is reported immediately instead of three times slower.
|
|
543
|
+
const looksCold = !!(last.diag && (last.diag.timedOut || last.diag.exit === null || !last.diag.spawned))
|
|
544
|
+
if (!looksCold) break
|
|
545
|
+
await new Promise((resolve) => setTimeout(resolve, 250 + i * 400))
|
|
546
|
+
}
|
|
547
|
+
if (last && last.diag) {
|
|
548
|
+
last.diag = jsonSafeDiag(last.diag)
|
|
549
|
+
last.diag.attempts = attempts.length
|
|
550
|
+
if (attempts.length > 1) {
|
|
551
|
+
last.diag.retried = true
|
|
552
|
+
// COPIES, never a reference to this same object (that produced the circular payload).
|
|
553
|
+
last.diag.retryDiag = attempts[attempts.length - 1]
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return last
|
|
348
557
|
}
|
|
349
558
|
|
|
350
559
|
async function checkLicence(H, d, exe) {
|
|
351
560
|
if (d.license !== 'commercial') return { ok: true }
|
|
352
561
|
if (!d.licenseProbe) return { ok: false }
|
|
353
|
-
const r = await H.spawn({ argv: [exe].concat(d.licenseProbe.argv), cwd: await H
|
|
562
|
+
const r = await H.spawn({ argv: [exe].concat(d.licenseProbe.argv), cwd: await probeCwd(H), timeoutMs: 10000, stdoutCap: 8192, stderrCap: 8192 })
|
|
354
563
|
if (!r || r.timedOut || r.exit !== 0) return { ok: false }
|
|
355
564
|
if (d.licenseProbe.okWhen === 'trim-1') return { ok: String(r.stdout || '').trim() === '1' }
|
|
356
565
|
if (d.licenseProbe.okWhen === 'not-unlicensed') return { ok: !/Unlicensed/i.test(String(r.stdout || '')) }
|
|
@@ -370,26 +579,42 @@ async function resolveEngine(H, requested, params, args) {
|
|
|
370
579
|
if (params.mathMode !== 'typed+shell') return fail('MATH_REFUSED', name, 'cli 被策略禁用(mathMode=' + params.mathMode + ')', { next: next('reason', { reason: 'policy' }) })
|
|
371
580
|
let exe = null
|
|
372
581
|
try { exe = await H.resolveExecutable(args.cli.command) } catch (e) { exe = null }
|
|
373
|
-
if (!exe) return fail('MATH_ENGINE_NOT_FOUND', name, 'cli 命令无法解析:' + args.cli.command, { next: userInstallNext('cli') })
|
|
374
|
-
|
|
582
|
+
if (!exe) return fail('MATH_ENGINE_NOT_FOUND', name, 'cli 命令无法解析:' + args.cli.command, { next: await userInstallNext(H, 'cli') })
|
|
583
|
+
// round-7 (finding 5): report the REAL version when the command's family is recognisable (the
|
|
584
|
+
// descriptor for that family carries the version probe); unknown commands stay 'unknown'.
|
|
585
|
+
let cliVersion = 'unknown'
|
|
586
|
+
let cliProbe = null
|
|
587
|
+
const fam = engineFamilyForExe(exe)
|
|
588
|
+
if (fam && MATH_ENGINES[fam]) {
|
|
589
|
+
const fv = await probeVersionWithRetry(H, MATH_ENGINES[fam], exe)
|
|
590
|
+
cliProbe = fv.diag || null
|
|
591
|
+
if (fv.ok) cliVersion = fv.version
|
|
592
|
+
}
|
|
593
|
+
return { ok: true, name: name, desc: d, exe: exe, version: cliVersion, family: fam || null, probe: cliProbe }
|
|
375
594
|
}
|
|
376
595
|
const hit = await resolveCandidate(H, d)
|
|
377
596
|
if (!hit) continue
|
|
378
|
-
const v = await
|
|
597
|
+
const v = await probeVersionWithRetry(H, d, hit)
|
|
379
598
|
if (!v.ok) {
|
|
380
599
|
if (d.license === 'commercial' || d.versionOptional) {
|
|
381
600
|
const lic = await checkLicence(H, d, hit)
|
|
382
601
|
if (!lic.ok) return fail('MATH_ENGINE_LICENSE_REQUIRED', name, name + ' 已安装但许可不可用(仅厂商可激活)', { next: next('vendor', { engine: name, url: d.vendor || '' }) })
|
|
383
602
|
return { ok: true, name: name, desc: d, exe: hit, version: 'unknown' }
|
|
384
603
|
}
|
|
385
|
-
|
|
604
|
+
// round-7 (live-session fix): the engine WAS found - so an install guide would be misleading
|
|
605
|
+
// (the user does not need to install anything; the host path is what failed). Report the probe
|
|
606
|
+
// diagnostic instead and keep the escape hatch advisory.
|
|
607
|
+
return fail('MATH_ENGINE_UNUSABLE', name, name + ' 存在但不可用(版本探针失败:exit=' + String(v.diag.exit) + (v.diag.timedOut ? ',超时' : '') + ',argv=' + JSON.stringify(v.diag.argv) + ')', {
|
|
608
|
+
probe: v.diag,
|
|
609
|
+
next: next('note', { guidance: '引擎已找到但探测失败:请检查宿主 subprocess 通路(冷启动/超时/权限),或用 mathEngineOverride 调整 versionArgv 模板;已装的引擎不需要重装。' }),
|
|
610
|
+
})
|
|
386
611
|
}
|
|
387
612
|
const lic = await checkLicence(H, d, hit)
|
|
388
613
|
if (!lic.ok) return fail('MATH_ENGINE_LICENSE_REQUIRED', name, name + ' 已安装但许可不可用(仅厂商可激活)', { next: next('vendor', { engine: name, url: d.vendor || '' }) })
|
|
389
614
|
return { ok: true, name: name, desc: d, exe: hit, version: v.version }
|
|
390
615
|
}
|
|
391
616
|
const first = requested === 'auto' ? (params.mathEngines[0] || 'python') : requested
|
|
392
|
-
return fail('MATH_ENGINE_NOT_FOUND', first, '本机没有可用的计算引擎(试过:' + names.join(', ') + ')', { next: userInstallNext(first) })
|
|
617
|
+
return fail('MATH_ENGINE_NOT_FOUND', first, '本机没有可用的计算引擎(试过:' + names.join(', ') + ')', { next: await userInstallNext(H, first) })
|
|
393
618
|
}
|
|
394
619
|
|
|
395
620
|
// ── package probe ───────────────────────────────────────────────────────────────────────────────
|
|
@@ -397,26 +622,63 @@ function buildProbeArgv(d, pkgs) {
|
|
|
397
622
|
const quoted = pkgs.map((p) => '"' + String(p).replace(/"/g, '') + '"').join(',')
|
|
398
623
|
const plain = pkgs.join(',')
|
|
399
624
|
const code = String(d.probeCode || '').replace(/__QPKGS__/g, quoted).replace(/__PKGS__/g, plain)
|
|
400
|
-
|
|
625
|
+
const out = []
|
|
626
|
+
for (const a of (d.packageProbe.argv || [])) {
|
|
627
|
+
// `<pkgs...>` expands to ONE argv item per package (probes that read `sys.argv`); `<pkgs>` stays
|
|
628
|
+
// the comma-joined string for templates that interpolate it into code (R/Julia/Octave).
|
|
629
|
+
if (a === '<probeCode>') out.push(code)
|
|
630
|
+
else if (a === '<pkgs>') out.push(plain)
|
|
631
|
+
else if (a === '<pkgs...>') for (const p of pkgs) out.push(String(p))
|
|
632
|
+
else out.push(a)
|
|
633
|
+
}
|
|
634
|
+
return out
|
|
401
635
|
}
|
|
402
636
|
|
|
403
637
|
async function probePackages(H, det, pkgs) {
|
|
638
|
+
// round-6 (D): presence is probed by BASE NAME (a version constraint is the manager's business);
|
|
639
|
+
// the full spec is what the install plan passes through.
|
|
640
|
+
const names = []
|
|
641
|
+
for (const s of pkgs) { const n = parsePackageSpec(s).name || String(s); if (names.indexOf(n) === -1) names.push(n) }
|
|
404
642
|
const found = {}
|
|
405
|
-
for (const p of
|
|
406
|
-
if (!
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
643
|
+
for (const p of names) found[p] = null
|
|
644
|
+
if (!names.length || !det.desc.packageProbe) return { requested: names, found: found }
|
|
645
|
+
const argv = [det.exe].concat(buildProbeArgv(det.desc, names))
|
|
646
|
+
// round-7 (live-session fix): a NULL spawn (the host's subprocess seam returned nothing - seen in
|
|
647
|
+
// real sessions right after session start) or a timeout must NOT be reported as "package missing".
|
|
648
|
+
// Retry a couple of times, and if the precheck still cannot run, say so explicitly (fail-open).
|
|
649
|
+
let r = null
|
|
650
|
+
const attempts = []
|
|
651
|
+
for (let i = 0; i < 3; i++) {
|
|
652
|
+
r = await H.spawn({ argv: argv, cwd: await probeCwd(H), timeoutMs: 15000 + i * 15000, stdoutCap: 16384, stderrCap: 16384 })
|
|
653
|
+
attempts.push(r ? { exit: r.exit === undefined ? null : r.exit, timedOut: !!r.timedOut } : null)
|
|
654
|
+
const usable = !!(r && !r.timedOut && r.exit !== null && r.exit !== undefined)
|
|
655
|
+
if (usable) break
|
|
656
|
+
await new Promise((resolve) => setTimeout(resolve, 250 + i * 400))
|
|
657
|
+
}
|
|
658
|
+
const ran = !!(r && !r.timedOut && r.exit !== null && r.exit !== undefined)
|
|
659
|
+
if (!ran) return { requested: names, found: found, probeFailed: true, attempts: attempts, argv: argv.slice(0, 3) }
|
|
660
|
+
if (det.desc.packageProbe.parse === 'lines') {
|
|
661
|
+
const text = String(r.stdout || '')
|
|
662
|
+
for (const p of pkgs) if (new RegExp('(^|\\s)' + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(\\s|$)', 'm').test(text)) found[p] = 'present'
|
|
663
|
+
} else {
|
|
664
|
+
// Accept BOTH shapes: `name:ok|name:missing` (historical) and one-per-line `name ok` (round-7:
|
|
665
|
+
// the probe codes avoid shell metacharacters such as `|`, which a real host's spawn rejected).
|
|
666
|
+
for (const rawTok of String(r.stdout || '').split(/[|\n]/)) {
|
|
667
|
+
const tok = rawTok.trim()
|
|
668
|
+
if (!tok) continue
|
|
669
|
+
let name = ''
|
|
670
|
+
let state = ''
|
|
671
|
+
const idx = tok.indexOf(':')
|
|
672
|
+
if (idx > 0) {
|
|
673
|
+
name = tok.slice(0, idx).trim()
|
|
674
|
+
state = tok.slice(idx + 1).trim()
|
|
675
|
+
} else {
|
|
676
|
+
const m = /^(\S+)\s+(\S+)$/.exec(tok)
|
|
677
|
+
if (!m) continue
|
|
678
|
+
name = m[1]
|
|
679
|
+
state = m[2]
|
|
419
680
|
}
|
|
681
|
+
if (Object.prototype.hasOwnProperty.call(found, name)) found[name] = (state === 'ok' || state === '1' || state === 'TRUE' || state === 'True') ? 'present' : null
|
|
420
682
|
}
|
|
421
683
|
}
|
|
422
684
|
return { requested: pkgs.slice(), found: found }
|
|
@@ -427,7 +689,24 @@ function assembleArgv(det, mode, payload) {
|
|
|
427
689
|
const d = det.desc
|
|
428
690
|
if (det.name === 'cli') {
|
|
429
691
|
const cliArgv = (payload.cli && Array.isArray(payload.cli.argv)) ? payload.cli.argv : []
|
|
430
|
-
|
|
692
|
+
const argv = [det.exe].concat(cliArgv)
|
|
693
|
+
// round-7 (finding 4): with mode:'code' the script is archived as `script.txt`, but the caller
|
|
694
|
+
// cannot know the runId in advance - so the archived script path is appended automatically and
|
|
695
|
+
// the documented "run this code" flow actually RUNS it (instead of dropping into a REPL, exit 0,
|
|
696
|
+
// empty stdout).
|
|
697
|
+
// round-7 (live nuance): ONLY when the caller's argv does not already provide a program slot.
|
|
698
|
+
// `python -c 'print(1)'` runs the caller's code, and appending the archived file would silently
|
|
699
|
+
// feed it as `sys.argv[1]` - the caller's argv must win there.
|
|
700
|
+
const runsOwnCode = cliArgv.some((a) => a === '-c' || a === '-m' || a === '-e' || a === '--eval' || a === '--command')
|
|
701
|
+
const appendScript = !!payload.scriptAbs && (mode === 'code' || mode === 'file') && !runsOwnCode
|
|
702
|
+
if (appendScript) argv.push(payload.scriptAbs)
|
|
703
|
+
return {
|
|
704
|
+
argv: argv,
|
|
705
|
+
cli: {
|
|
706
|
+
command: payload.cli.command, argv: cliArgv, scriptAppended: appendScript,
|
|
707
|
+
scriptSkipped: (!!payload.scriptAbs && (mode === 'code' || mode === 'file') && runsOwnCode) ? 'caller argv already provides a program slot (-c/-m/-e/--eval)' : null,
|
|
708
|
+
},
|
|
709
|
+
}
|
|
431
710
|
}
|
|
432
711
|
if (mode === 'code' || mode === 'file') {
|
|
433
712
|
return { argv: [det.exe].concat((d.scriptArgv || []).map((a) => a === '<script>' ? payload.scriptAbs : a)) }
|
|
@@ -439,6 +718,19 @@ function assembleArgv(det, mode, payload) {
|
|
|
439
718
|
return { refused: 'unknown mode' }
|
|
440
719
|
}
|
|
441
720
|
|
|
721
|
+
// round-7 (findings 3/5): for `engine:'cli'` the descriptor is generic, so the FAMILY of the resolved
|
|
722
|
+
// command has to be inferred from its basename to (a) run the right package precheck and (b) report a
|
|
723
|
+
// real version. Unknown commands are reported as unknown and their package precheck is SKIPPED (a
|
|
724
|
+
// false "missing" must never block the escape hatch).
|
|
725
|
+
function engineFamilyForExe(exe) {
|
|
726
|
+
const base = String(exe || '').replace(/\\/g, '/').split('/').pop().toLowerCase().replace(/\.(exe|cmd|bat|sh)$/, '')
|
|
727
|
+
if (/^python[0-9.]*$/.test(base) || base === 'py') return 'python'
|
|
728
|
+
if (base === 'rscript' || base === 'r') return 'r'
|
|
729
|
+
if (base === 'octave' || base === 'octave-cli') return 'octave'
|
|
730
|
+
if (base === 'julia') return 'julia'
|
|
731
|
+
return null
|
|
732
|
+
}
|
|
733
|
+
|
|
442
734
|
// AUDIT-B FIX (HIGH): `applyOverride` used to read only `params.mathEngineOverride`, which no preset
|
|
443
735
|
// can ever set (the six frozen params are copied verbatim by effectiveMathParams), while the tool
|
|
444
736
|
// description, MATH_BAD_ARGV_HINT and the BAD_ARGV message all advertised it. The per-call ARGUMENT
|
|
@@ -561,18 +853,67 @@ async function opProbe(H, args, params) {
|
|
|
561
853
|
const probe = await probeMathEngines(H, { refresh: !!(args.packages && args.packages.indexOf('refresh') !== -1) })
|
|
562
854
|
const pkgs = (args.packages || []).filter((p) => p !== 'refresh')
|
|
563
855
|
const want = pkgs.length ? pkgs : params.mathPackages
|
|
856
|
+
// round-7 (fix 1): a probe that NAMES an engine must describe THAT engine. Previously the response
|
|
857
|
+
// carried `engine: null` (or the first ALLOWED engine) and the failure path always blamed
|
|
858
|
+
// `params.mathEngines[0]` (python) - so `probe {engine:'r'}` echoed python and told the user to
|
|
859
|
+
// install python. Resolve the requested engine up front instead.
|
|
860
|
+
const requested = (args.engine && args.engine !== 'auto') ? String(args.engine) : null
|
|
861
|
+
const chosen = requested
|
|
862
|
+
? await resolveEngine(H, requested, params, args)
|
|
863
|
+
: (probe.engines.length ? await resolveEngine(H, probe.engines[0].name, params, args) : null)
|
|
564
864
|
let packages = { requested: want.slice(), found: {} }
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
865
|
+
const probeDet = familyProbeDet(chosen)
|
|
866
|
+
if (want.length && probeDet) packages = await probePackages(H, probeDet, want)
|
|
867
|
+
else if (want.length && chosen && chosen.ok && chosen.name === 'cli') packages = { requested: want.map((s) => parsePackageSpec(s).name || s), found: {}, precheckSkipped: 'engine=cli:无法从命令名判断引擎族(不阻塞)' }
|
|
868
|
+
if (requested && chosen && !chosen.ok) {
|
|
869
|
+
// Policy/absence refusal for the REQUESTED engine, with its own guidance.
|
|
870
|
+
const out = Object.assign({}, chosen)
|
|
871
|
+
out.op = 'probe'
|
|
872
|
+
out.engines = probe.engines
|
|
873
|
+
out.available = !!probe.available
|
|
874
|
+
out.packages = packages
|
|
875
|
+
return out
|
|
568
876
|
}
|
|
569
|
-
if (!probe.available) {
|
|
570
|
-
const first = params.mathEngines[0] || 'python'
|
|
571
|
-
const out = fail('MATH_ENGINE_NOT_FOUND', first, '
|
|
877
|
+
if (!probe.available && !(chosen && chosen.ok)) {
|
|
878
|
+
const first = requested || params.mathEngines[0] || 'python'
|
|
879
|
+
const out = fail('MATH_ENGINE_NOT_FOUND', first, '本机没有可用的计算引擎(请求:' + first + ')', { next: await userInstallNext(H, first), engines: [], available: false, packages: packages })
|
|
572
880
|
out.op = 'probe'
|
|
573
881
|
return out
|
|
574
882
|
}
|
|
575
|
-
|
|
883
|
+
// A REQUESTED engine that resolved (e.g. `cli`, or a runtime-discovered interpreter) counts as
|
|
884
|
+
// available even when auto-detection found nothing - otherwise probe denied what run would do.
|
|
885
|
+
const engines = probe.engines.slice()
|
|
886
|
+
if (chosen && chosen.ok && !engines.some((e) => e.name === chosen.name)) {
|
|
887
|
+
engines.push({ name: chosen.name, path: chosen.exe, version: chosen.version, license: (MATH_ENGINES[chosen.name] || {}).license })
|
|
888
|
+
}
|
|
889
|
+
const engineInfo = (chosen && chosen.ok) ? { name: chosen.name, path: chosen.exe, version: chosen.version } : null
|
|
890
|
+
// round-9 (F3): the availability line must never be silently partial - name the CONFIGURED engines
|
|
891
|
+
// that were not found (and why), instead of just listing the ones that happened to resolve.
|
|
892
|
+
const foundNames = engines.map((e) => e.name)
|
|
893
|
+
// Per-engine absence explanation. The key is `why` (NOT the reserved machine-readable vocabulary
|
|
894
|
+
// key), so the parity guard's exhaustive `next.reason` set stays exactly the refusal vocabulary.
|
|
895
|
+
const absent = params.mathEngines.filter((e) => foundNames.indexOf(e) === -1).map((e) => {
|
|
896
|
+
if (MATH_ENGINES[e] && MATH_ENGINES[e].resolveFrom === 'cli.command') return { engine: e, why: 'needs-a-caller-supplied-command' }
|
|
897
|
+
return { engine: e, why: 'not-found-on-this-machine' }
|
|
898
|
+
})
|
|
899
|
+
return {
|
|
900
|
+
ok: true, op: 'probe', engine: engineInfo ? engineInfo.name : null, engineInfo: engineInfo,
|
|
901
|
+
engines: engines, available: true, packages: packages,
|
|
902
|
+
configured: params.mathEngines.slice(), absent: absent,
|
|
903
|
+
message: '可用引擎:' + engines.map((e) => e.name + ' ' + e.version).join('、')
|
|
904
|
+
+ (engineInfo ? '(请求 ' + engineInfo.name + ':' + engineInfo.version + ')' : '')
|
|
905
|
+
+ (absent.length ? ';已配置但本机未发现:' + absent.map((a) => a.engine).join('、') : ';已配置的引擎都在'),
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// round-7 (finding 3): a cli command has no package probe of its own, so the FAMILY descriptor of
|
|
910
|
+
// the resolved executable must provide one. Returns null when the family is unknown - the caller then
|
|
911
|
+
// SKIPS the precheck (never a false "missing", which blocked the escape hatch).
|
|
912
|
+
function familyProbeDet(det) {
|
|
913
|
+
if (!det || det.name !== 'cli') return det
|
|
914
|
+
const fam = det.family || engineFamilyForExe(det.exe)
|
|
915
|
+
const fd = fam ? MATH_ENGINES[fam] : null
|
|
916
|
+
return (fd && fd.packageProbe) ? { ok: true, name: fam, desc: fd, exe: det.exe, version: det.version } : null
|
|
576
917
|
}
|
|
577
918
|
|
|
578
919
|
// ── op: run ─────────────────────────────────────────────────────────────────────────────────────
|
|
@@ -588,13 +929,24 @@ async function opRun(H, args, params) {
|
|
|
588
929
|
const engineLabel = det.name === 'cli' ? ('cli:' + cliCommand) : det.name
|
|
589
930
|
|
|
590
931
|
const want = (args.packages && args.packages.length) ? args.packages : params.mathPackages
|
|
591
|
-
|
|
592
|
-
const
|
|
932
|
+
// round-9 (F7): version constraints are NOT enforced - we only check existence by base name.
|
|
933
|
+
const pinnedSpecs = want.filter((sp) => parsePackageSpec(sp).pinned)
|
|
934
|
+
const versionPolicy = 'version-constraints-are-existence-only: 只按 base name 检查是否存在,约束本身从不校验(版本求解交给包管理器)' + (pinnedSpecs.length ? ';本次未校验的约束:' + pinnedSpecs.join(', ') : ';本次没有版本约束')
|
|
935
|
+
// round-7 (finding 3): for cli the precheck runs against the FAMILY of `cli.command` (same
|
|
936
|
+
// discovery-shaped logic); an unrecognisable command SKIPS the precheck instead of reporting a
|
|
937
|
+
// false "missing" that would block the escape hatch.
|
|
938
|
+
const probeDet = familyProbeDet(det2)
|
|
939
|
+
const pk = probeDet ? await probePackages(H, probeDet, want) : { requested: want.map((s) => parsePackageSpec(s).name || s), found: {} }
|
|
940
|
+
// Presence is keyed by BASE NAME (round-6 D); the plan keeps the original specs so the manager
|
|
941
|
+
// receives the version constraint verbatim. round-7: a precheck that could not RUN (null spawn /
|
|
942
|
+
// timeout) must not be read as "missing" - that misled a real session.
|
|
943
|
+
const missing = (probeDet && !pk.probeFailed) ? want.map((s) => parsePackageSpec(s).name || s).filter((p) => Object.prototype.hasOwnProperty.call(pk.found, p) && !pk.found[p]) : []
|
|
593
944
|
if (missing.length) {
|
|
594
|
-
return fail('MATH_MISSING_PACKAGES', det.name, '需要的包未安装:' + missing.join(', '), {
|
|
945
|
+
return fail('MATH_MISSING_PACKAGES', det.name, '需要的包未安装:' + missing.join(', ') + '(只检查是否存在;版本求解交给包管理器)', {
|
|
595
946
|
missing: missing,
|
|
596
|
-
next: next('agent-install', { engine: det.name, packages: missing, dryRun: true }),
|
|
947
|
+
next: next('agent-install', { engine: det.name, packages: missing, specs: want.slice(), dryRun: true }),
|
|
597
948
|
packages: pk,
|
|
949
|
+
versionPolicy: versionPolicy, constraintsNotEnforced: pinnedSpecs.slice(),
|
|
598
950
|
})
|
|
599
951
|
}
|
|
600
952
|
|
|
@@ -602,10 +954,17 @@ async function opRun(H, args, params) {
|
|
|
602
954
|
let scriptText = ''
|
|
603
955
|
let fileRel = null
|
|
604
956
|
let sourceHashBefore = null
|
|
957
|
+
// round-9 (N1): `mode:'file'` is keyed by the SOURCE PATH, so re-running an ORIGINAL source lands on
|
|
958
|
+
// the same archive id (attempt>=2, scriptChanged). Pointing it at an ARCHIVED script instead targets
|
|
959
|
+
// a different path and therefore a NEW archive id by design - the prior attempt is never touched, but
|
|
960
|
+
// there is no previous receipt FOR THAT ID, so scriptChanged/previousReceipt are necessarily empty.
|
|
961
|
+
// That is not a bug, but it must be SAID rather than looking like broken change detection.
|
|
962
|
+
let archivedScriptSource = false
|
|
605
963
|
if (mode === 'code') scriptText = args.code
|
|
606
964
|
else if (mode === 'file') {
|
|
607
965
|
fileRel = projectRel('', args.file)
|
|
608
966
|
if (fileRel === null) return fail('MATH_REFUSED', det.name, 'file 必须位于项目内:' + args.file, { next: next('reason', { reason: 'path-outside-project' }) })
|
|
967
|
+
archivedScriptSource = /^Computation\//.test(String(fileRel))
|
|
609
968
|
const txt = await H.readText(fileRel)
|
|
610
969
|
if (txt === undefined) return fail('MATH_REFUSED', det.name, '找不到文件:' + args.file, { next: next('reason', { reason: 'file-not-found' }) })
|
|
611
970
|
scriptText = txt
|
|
@@ -669,6 +1028,9 @@ async function opRun(H, args, params) {
|
|
|
669
1028
|
}
|
|
670
1029
|
|
|
671
1030
|
const warnings = []
|
|
1031
|
+
if (archivedScriptSource) warnings.push(warning('ARCHIVED_SCRIPT_RERUN', 'mode:\'file\' 指向的是**归档脚本**(Computation/…):按设计归档 id 以**源路径**为键,所以这是一次**新归档**(新目录、attempt 1),没有该 id 的历史回执 ⇒ scriptChanged/previousReceipt 为空是**预期**,且**旧 attempt 绝不会被覆盖**。要做"编辑→重跑→attempt≥2 + scriptChanged"的核对,请把 mode:\'file\' 指向**原始源文件**并编辑它,而不是指向归档副本。'))
|
|
1032
|
+
if (!probeDet && want.length) warnings.push(warning('PACKAGE_PRECHECK_SKIPPED', 'engine=cli:无法从命令名判断引擎族,已跳过包预检(不阻塞执行,也不会误报缺包);如需预检请直接用 python/r/octave/julia 或改用可识别的解释器路径。'))
|
|
1033
|
+
if (pk && pk.probeFailed && want.length) warnings.push(warning('PACKAGE_PRECHECK_UNKNOWN', '包预检未能执行(宿主 subprocess 在这几次尝试里返回空/超时,会话早期常见):本次不判定缺包、不阻塞执行;如需确定性结论请重试或直接用对应引擎。probe=' + JSON.stringify(pk.argv) + ' attempts=' + JSON.stringify(pk.attempts)))
|
|
672
1034
|
if (fullOut.length > MATH_CAPS.stdout) warnings.push(warning('OUTPUT_TRUNCATED', 'stdout 超过 64KB,完整版见 ' + dir + '/stdout.txt'))
|
|
673
1035
|
if (scriptChanged) warnings.push(warning(MATH_SCRIPT_CHANGED_WARNING, 'scriptChanged:文件 ' + fileRel + ' 自上一次回执以来已改变(旧回执不再代表当前代码),本次已写出新 attempt ' + attempt + '。'))
|
|
674
1036
|
if (scriptChangedDuringRun) warnings.push(warning(MATH_SCRIPT_CHANGED_DURING_RUN_WARNING, 'scriptChangedDuringRun:文件 ' + fileRel + ' 在本次运行期间被改动(可能有另一个成员在编辑);本次回执的 scriptHash 是运行前版本 ' + String(sourceHashBefore).slice(0, 12) + '…,请重跑后再引用。'))
|
|
@@ -681,7 +1043,15 @@ async function opRun(H, args, params) {
|
|
|
681
1043
|
engine: { name: engineLabel, path: det.exe, version: det.version, source: det.name === 'cli' ? 'cli' : 'path' },
|
|
682
1044
|
script: { path: scriptRel, sha256: scriptHash, bytes: Buffer.byteLength(scriptText, 'utf8') },
|
|
683
1045
|
scriptPath: scriptRel, scriptHash: scriptHash,
|
|
1046
|
+
// round-8 (P0/D3): member file tools are session-cwd relative, so the ABSOLUTE locations must be
|
|
1047
|
+
// in the receipt: `scriptAbs` is directly openable, `cwd` is the project root to join with.
|
|
1048
|
+
scriptAbs: scriptAbs, cwd: root,
|
|
684
1049
|
sourceFile: fileRel || null,
|
|
1050
|
+
fileIsArchivedScript: archivedScriptSource,
|
|
1051
|
+
// round-9 (N2): a TIMEOUT must still carry the evidence it produced - the capped partial output
|
|
1052
|
+
// lives in the receipt as well as in stdout.txt/stderr.txt on disk.
|
|
1053
|
+
partialStdout: String(fullOut).slice(-2000),
|
|
1054
|
+
partialStderr: String(fullErr).slice(-2000),
|
|
685
1055
|
sourceHashBefore: sourceHashBefore, sourceHashAfter: sourceHashAfter,
|
|
686
1056
|
scriptChanged: scriptChanged, scriptChangedDuringRun: scriptChangedDuringRun,
|
|
687
1057
|
previousReceipt: prevReceipt ? { runId: prevReceipt.runId, attempt: prevReceipt.attempt || 1, scriptHash: prevReceipt.scriptHash || (prevReceipt.script && prevReceipt.script.sha256) || null } : null,
|
|
@@ -698,7 +1068,7 @@ async function opRun(H, args, params) {
|
|
|
698
1068
|
warnings: warnings,
|
|
699
1069
|
determinism: { inputSha256: input, packagesSorted: true, noWallClockInId: true },
|
|
700
1070
|
}
|
|
701
|
-
if (assembled.cli) receipt.cli = { command: assembled.cli.command, argv: assembled.cli.argv, stdinUsed: false }
|
|
1071
|
+
if (assembled.cli) receipt.cli = { command: assembled.cli.command, argv: assembled.cli.argv, stdinUsed: false, scriptAppended: !!assembled.cli.scriptAppended, scriptSkipped: assembled.cli.scriptSkipped || null }
|
|
702
1072
|
const receiptJson = JSON.stringify(receipt, null, 2)
|
|
703
1073
|
await H.writeText(dir + '/receipt.json', receiptJson)
|
|
704
1074
|
await H.writeText(dir + '/receipt.md', renderReceiptMd(receipt, fullOut, fullErr))
|
|
@@ -707,13 +1077,22 @@ async function opRun(H, args, params) {
|
|
|
707
1077
|
|
|
708
1078
|
const shell = {
|
|
709
1079
|
ok: true, op: 'run', engine: engineLabel, mode: mode,
|
|
1080
|
+
runId: runId, attempt: attempt,
|
|
1081
|
+
versionPolicy: versionPolicy, constraintsNotEnforced: pinnedSpecs.slice(),
|
|
710
1082
|
engineInfo: receipt.engine,
|
|
711
1083
|
argv: assembled.argv.slice(),
|
|
712
1084
|
scriptPath: scriptRel, scriptHash: scriptHash,
|
|
1085
|
+
scriptAbs: scriptAbs, cwd: root,
|
|
713
1086
|
attempt: attempt, attemptDir: dir, baseRunDir: baseDir,
|
|
714
1087
|
scriptChanged: scriptChanged, scriptChangedDuringRun: scriptChangedDuringRun,
|
|
715
1088
|
sourceFile: fileRel || null,
|
|
1089
|
+
fileIsArchivedScript: archivedScriptSource,
|
|
1090
|
+
// round-9 (N2): a TIMEOUT must still carry the evidence it produced - the capped partial output
|
|
1091
|
+
// lives in the receipt as well as in stdout.txt/stderr.txt on disk.
|
|
1092
|
+
partialStdout: String(fullOut).slice(-2000),
|
|
1093
|
+
partialStderr: String(fullErr).slice(-2000),
|
|
716
1094
|
previousReceipt: receipt.previousReceipt,
|
|
1095
|
+
cliScriptAppended: !!(receipt.cli && receipt.cli.scriptAppended),
|
|
717
1096
|
packages: pk.found,
|
|
718
1097
|
exit: receipt.exit, timedOut: receipt.timedOut, ms: receipt.ms,
|
|
719
1098
|
stdout: fullOut.slice(0, MATH_CAPS.stdout), stderr: fullErr.slice(0, MATH_CAPS.stderr),
|
|
@@ -721,8 +1100,13 @@ async function opRun(H, args, params) {
|
|
|
721
1100
|
warnings: warnings,
|
|
722
1101
|
}
|
|
723
1102
|
if (receipt.timedOut) {
|
|
724
|
-
|
|
725
|
-
|
|
1103
|
+
// round-9 (F5): failure EVIDENCE must be uniform - a timeout carries the SAME fields a non-zero
|
|
1104
|
+
// exit does (exit + stderr), plus timedOut:true.
|
|
1105
|
+
const out = fail('MATH_TIMEOUT', engineLabel, '执行超时(' + timeoutMs + 'ms)已被终止', { argv: assembled.argv.slice(), receipt: receiptRef })
|
|
1106
|
+
out.exit = null
|
|
1107
|
+
out.stdout = String(fullOut).slice(0, MATH_CAPS.stdout)
|
|
1108
|
+
out.stderr = String(fullErr).slice(0, MATH_CAPS.stderr)
|
|
1109
|
+
return Object.assign(out, archiveFields(), { ms: receipt.ms, timedOut: true })
|
|
726
1110
|
}
|
|
727
1111
|
if (r.exit !== 0) {
|
|
728
1112
|
const blob = fullErr + '\n' + fullOut
|
|
@@ -832,15 +1216,44 @@ async function opInstall(H, args, params) {
|
|
|
832
1216
|
})
|
|
833
1217
|
}
|
|
834
1218
|
const scope = args.scope || params.mathInstallScope || 'user' // per-call only; never persisted anywhere
|
|
835
|
-
|
|
836
|
-
|
|
1219
|
+
// round-6 (B): resolve the interpreter the SAME way the run path does (the descriptor's candidate
|
|
1220
|
+
// list, in order) so the plan targets the interpreter that will actually run - and so the manager
|
|
1221
|
+
// detection sees that interpreter's REAL path (a conda `python3` is not a bare `python`).
|
|
837
1222
|
let exe = engineName
|
|
838
|
-
|
|
1223
|
+
{
|
|
1224
|
+
const cands = mathEngineCandidates(engineName) || [engineName === 'r' ? 'Rscript' : engineName]
|
|
1225
|
+
for (const c of cands) {
|
|
1226
|
+
try { const hit = await H.resolveExecutable(c); if (hit) { exe = hit; break } } catch (e) { /* try the next candidate */ }
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
// round-6 (B): python dispatches on the DETECTED environment; every other engine keeps its own
|
|
1230
|
+
// single template. The chosen manager (and whether it is only an assumption) is part of the plan.
|
|
1231
|
+
let managerInfo = null
|
|
1232
|
+
let mgrTmpl = null
|
|
1233
|
+
if (d.install.managers) {
|
|
1234
|
+
managerInfo = await detectPythonManager(H, exe)
|
|
1235
|
+
mgrTmpl = d.install.managers[managerInfo.manager] || null
|
|
1236
|
+
}
|
|
1237
|
+
const activeManager = managerInfo ? managerInfo.manager : d.install.manager
|
|
1238
|
+
const userTmpl = (mgrTmpl && mgrTmpl.userArgv) || d.install.userArgv
|
|
1239
|
+
const systemTmpl = (mgrTmpl && mgrTmpl.systemArgv) || (mgrTmpl ? null : d.install.systemArgv)
|
|
1240
|
+
const tmpl = scope === 'system' ? systemTmpl : userTmpl
|
|
1241
|
+
if (!tmpl) {
|
|
1242
|
+
// round-6 (C): the refusal says WHY per engine (or per detected manager) instead of a generic line.
|
|
1243
|
+
const why = (mgrTmpl && mgrTmpl.systemUnsupportedReason) || d.install.systemUnsupportedReason || '该引擎只提供用户级安装'
|
|
1244
|
+
return fail('MATH_REFUSED', engineName, engineName + ' 不支持 system 作用域:' + why, { next: next('reason', { reason: 'system-scope-unsupported' }) })
|
|
1245
|
+
}
|
|
1246
|
+
const uninstallTmpl = (mgrTmpl && mgrTmpl.uninstallArgv) || d.install.uninstallArgv
|
|
839
1247
|
const commands = args.packages.map((p) => ({ argv: substInstall(tmpl, engineName, exe, p), why: '缺少 ' + p }))
|
|
840
|
-
const plan = {
|
|
1248
|
+
const plan = {
|
|
1249
|
+
engine: engineName, scope: scope, manager: activeManager, commands: commands,
|
|
1250
|
+
managerAssumed: !!(managerInfo && managerInfo.assumed), managerWhy: managerInfo ? managerInfo.why : '',
|
|
1251
|
+
versionPolicy: '版本求解交给包管理器(本工具只检查是否已安装,并把 pkg<op>version 原样透传)',
|
|
1252
|
+
note: scope === 'user' ? '将安装到用户级目录' : '仅本次系统作用域(不会被记住)',
|
|
1253
|
+
}
|
|
841
1254
|
const planToken = sha256(JSON.stringify({ engine: engineName, packages: args.packages, scope: scope, commands: commands }) + '|planVersion=1').slice(0, 32)
|
|
842
1255
|
if (!args.confirm) {
|
|
843
|
-
return { ok: true, op: 'install', engine: engineName, plan: plan, planToken: planToken, next: next('agent-install', { dryRun: true }), message: '
|
|
1256
|
+
return { ok: true, op: 'install', engine: engineName, plan: plan, planToken: planToken, next: next('agent-install', { dryRun: true, manager: activeManager, managerAssumed: !!plan.managerAssumed }), message: '安装计划(尚未执行,管理器=' + activeManager + (plan.managerAssumed ? '(假设:' + plan.managerWhy + ')' : '') + '):' + commands.map((c) => c.argv.join(' ')).join(' ; ') }
|
|
844
1257
|
}
|
|
845
1258
|
if (args.confirm !== planToken) {
|
|
846
1259
|
return fail('MATH_REFUSED', engineName, 'planToken 不匹配(计划可能已变),请重新出计划', { next: next('reason', { reason: 'plan-token-mismatch' }) })
|
|
@@ -851,7 +1264,7 @@ async function opInstall(H, args, params) {
|
|
|
851
1264
|
const r = await H.spawn({ argv: c.argv, cwd: await H.projectRoot(), timeoutMs: timeoutMs, stdoutCap: MATH_CAPS.file, stderrCap: MATH_CAPS.file })
|
|
852
1265
|
results.push({ argv: c.argv, exit: r ? r.exit : null, timedOut: !!(r && r.timedOut), ms: r ? r.ms : 0, stdoutTail: String((r && r.stdout) || '').slice(-2000), stderrTail: String((r && r.stderr) || '').slice(-2000) })
|
|
853
1266
|
if (!r || r.timedOut || r.exit !== 0) {
|
|
854
|
-
const audit0 = { schema: 'vibe-math/math-computation-install@1', planToken: planToken, scope: scope, manager:
|
|
1267
|
+
const audit0 = { schema: 'vibe-math/math-computation-install@1', planToken: planToken, scope: scope, manager: activeManager, commands: commands, results: results, exit: r ? r.exit : null, timedOut: !!(r && r.timedOut), installed: args.packages, rollback: rollbackFor(d, exe, args.packages, uninstallTmpl), network: 'not-enforced-by-plugin' }
|
|
855
1268
|
await H.writeText('Computation/installs/' + planToken + '.json', JSON.stringify(audit0, null, 2))
|
|
856
1269
|
const code = (r && r.timedOut) ? 'MATH_TIMEOUT' : 'MATH_NONZERO_EXIT'
|
|
857
1270
|
const out = fail(code, engineName, '安装失败(' + c.argv.join(' ') + '):' + (r ? ('exit=' + r.exit) : 'no subprocess'), { next: next('note', { audit: 'Computation/installs/' + planToken + '.json' }) })
|
|
@@ -859,13 +1272,54 @@ async function opInstall(H, args, params) {
|
|
|
859
1272
|
return out
|
|
860
1273
|
}
|
|
861
1274
|
}
|
|
862
|
-
const audit = { schema: 'vibe-math/math-computation-install@1', planToken: planToken, scope: scope, manager:
|
|
1275
|
+
const audit = { schema: 'vibe-math/math-computation-install@1', planToken: planToken, scope: scope, manager: activeManager, commands: commands, results: results, exit: 0, timedOut: false, installed: args.packages, before: {}, after: {}, rollback: rollbackFor(d, exe, args.packages, uninstallTmpl), network: 'not-enforced-by-plugin' }
|
|
863
1276
|
await H.writeText('Computation/installs/' + planToken + '.json', JSON.stringify(audit, null, 2))
|
|
864
1277
|
return { ok: true, op: 'install', engine: engineName, executed: true, scope: scope, audit: 'Computation/installs/' + planToken + '.json', message: '已安装:' + args.packages.join(', ') + '(审计:Computation/installs/' + planToken + '.json)' }
|
|
865
1278
|
}
|
|
866
1279
|
|
|
867
|
-
|
|
868
|
-
|
|
1280
|
+
// ── round-6 (B/D): package specs + Python package-manager dispatch ──────────────────────────────
|
|
1281
|
+
// Version SOLVING is delegated to the package manager: the tool only checks presence (by base name)
|
|
1282
|
+
// and passes the spec through. The accepted grammar is small and manager-understood; anything else
|
|
1283
|
+
// (spaces, `;`, `|`, `&`, `$`, backticks, `@`, parentheses, empty) is refused with an explicit
|
|
1284
|
+
// reason instead of being handed to a shell.
|
|
1285
|
+
const PKG_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9._,-]+\])?(?:(?:==|>=|<=|~=|!=|>|<|=)[A-Za-z0-9._*+!-]+)?$/
|
|
1286
|
+
export function parsePackageSpec(spec) {
|
|
1287
|
+
const raw = String(spec == null ? '' : spec).trim()
|
|
1288
|
+
if (!raw) return { ok: false, problem: 'empty package spec' }
|
|
1289
|
+
if (!PKG_SPEC_RE.test(raw)) {
|
|
1290
|
+
return { ok: false, problem: 'unsupported version syntax: "' + raw + '"(版本求解交给包管理器:请用它能理解的形式,例如 pip 的 pkg==1.2 或 conda 的 pkg=1.2;本工具只检查是否已安装)' }
|
|
1291
|
+
}
|
|
1292
|
+
const name = raw.replace(/(?:==|>=|<=|~=|!=|>|<|=).*$/, '').replace(/\[.*$/, '')
|
|
1293
|
+
return { ok: true, name: name, spec: raw, pinned: raw !== name }
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Detect WHICH python package manager the interpreter's own environment implies - detect, don't
|
|
1297
|
+
// guess. conda/mamba markers live in the interpreter path (…/envs/<name>/… or …/miniconda3/…) and
|
|
1298
|
+
// must be confirmed by resolving the manager; uv counts only when it sits NEXT TO the interpreter
|
|
1299
|
+
// (a uv-managed venv). Anything ambiguous falls back to pip, and `assumed: true` travels into the
|
|
1300
|
+
// plan/message so the operator is told which manager the plan assumes.
|
|
1301
|
+
async function detectPythonManager(H, exe) {
|
|
1302
|
+
const p = String(exe || '').replace(/\\/g, '/').toLowerCase()
|
|
1303
|
+
if (/\/envs\/|\/conda|\/miniconda|\/anaconda|\/mambaforge|\/miniforge/.test(p)) {
|
|
1304
|
+
for (const mgr of ['conda', 'mamba']) {
|
|
1305
|
+
try {
|
|
1306
|
+
const found = await H.resolveExecutable(mgr)
|
|
1307
|
+
if (found) return { manager: mgr, assumed: false, why: '解释器路径看起来像 ' + mgr + ' 环境(' + p.split('/').slice(-3).join('/') + ')' }
|
|
1308
|
+
} catch (e) { /* try the next candidate */ }
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
try {
|
|
1312
|
+
const uv = await H.resolveExecutable('uv')
|
|
1313
|
+
if (uv) {
|
|
1314
|
+
const dirOf = (x) => String(x).replace(/\\/g, '/').replace(/\/[^/]*$/, '')
|
|
1315
|
+
if (dirOf(uv) === dirOf(exe)) return { manager: 'uv', assumed: false, why: 'uv 与解释器同目录(uv 管理的虚拟环境)' }
|
|
1316
|
+
}
|
|
1317
|
+
} catch (e) { /* fall through to pip */ }
|
|
1318
|
+
return { manager: 'pip', assumed: true, why: '未检测到 conda/mamba 标记,也没有与解释器同目录的 uv ⇒ 按文档回退到 pip(计划中的管理器是**假设**)' }
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function rollbackFor(d, exe, packages, uninstallTmpl) {
|
|
1322
|
+
const tmpl = uninstallTmpl || (d.install && d.install.uninstallArgv)
|
|
869
1323
|
if (!tmpl) return { supported: 'partial', commands: [], note: '该管理器未提供卸载模板;本工具不做自动回滚' }
|
|
870
1324
|
return { supported: 'partial', commands: packages.map((p) => substInstall(tmpl, d.name, exe, p)), note: '包管理器各自支持卸载;本工具不做自动回滚' }
|
|
871
1325
|
}
|