dsh-ppt-studio 1.1.2 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +38 -18
  2. package/agent-presets/ppt/agent.cordis.yml +22 -15
  3. package/cordis.patch.yml +19 -14
  4. package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +1 -1
  5. package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +5 -5
  6. package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +105 -0
  7. package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +34 -3
  8. package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +5 -3
  9. package/examples/fx/fx-pro/preview/04_pages_04_chart.html +1 -1
  10. package/examples/fx/fx-pro/preview/deck.html +1 -1
  11. package/examples/fx/fx-pro/preview/measured.json +1 -1
  12. package/examples/fx/fx-pro.pptx +0 -0
  13. package/examples/fx/seed.pptx +0 -0
  14. package/examples/fx/single.pptx +0 -0
  15. package/examples/fx/spliced.pptx +0 -0
  16. package/examples/smoke/imported/deck.yaml +6 -6
  17. package/examples/smoke/imported/import-styles.json +1 -33
  18. package/examples/smoke/imported/loss-audit.json +99 -0
  19. package/examples/smoke/imported/pages/slide_03.yaml +5 -36
  20. package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -1
  21. package/examples/smoke/imported/preview-rt/deck.html +1 -1
  22. package/examples/smoke/imported/preview-rt/layout.json +26 -78
  23. package/examples/smoke/imported/reference/previews/03.png +0 -0
  24. package/examples/smoke/imported/source.pptx +0 -0
  25. package/examples/smoke/out-smoke.pptx +0 -0
  26. package/examples/smoke/preview/03_pages_03_chart.html +1 -1
  27. package/examples/smoke/preview/deck.html +1 -1
  28. package/lib/capabilities.js +189 -0
  29. package/lib/home.js +32 -0
  30. package/lib/index.js +278 -112
  31. package/lib/office-qa.js +111 -0
  32. package/lib/pptd/export-pptx.js +159 -10
  33. package/lib/pptd/import-pptx.js +12 -0
  34. package/lib/pptd/loss-audit.js +363 -0
  35. package/lib/pptd/nativeChart.js +454 -0
  36. package/lib/pptd/schema.js +48 -1
  37. package/lib/pptd/svgCharts.js +180 -37
  38. package/lib/pptxPy.js +96 -11
  39. package/lib/preset-delivery.js +107 -9
  40. package/lib/preview-server.js +7 -5
  41. package/lib/router.js +3 -2
  42. package/lib/state.js +8 -6
  43. package/lib/templates.js +4 -4
  44. package/lib/tools.js +81 -21
  45. package/package.json +2 -1
  46. package/scripts/audit-manual-facts.mjs +4 -2
  47. package/scripts/e2e-1.0.mjs +2 -2
  48. package/scripts/fixtures/workflow-baseline.json +10 -10
  49. package/scripts/install.mjs +13 -11
  50. package/scripts/smoke.mjs +390 -66
  51. package/scripts/verify-loss-audit.mjs +245 -0
  52. package/scripts/verify-native-charts.mjs +529 -0
  53. package/scripts/verify-preset-scope.mjs +593 -0
  54. package/skills/ppt-studio-data/SKILL.md +5 -7
  55. package/skills/ppt-studio-manual/SKILL.md +2 -2
package/scripts/smoke.mjs CHANGED
@@ -6,6 +6,7 @@ import { join, dirname } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
7
  import { rm, mkdir } from 'node:fs/promises'
8
8
  import { existsSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
9
10
  import zlib from 'node:zlib'
10
11
  import { resolveDeck } from '../lib/pptd/schema.js'
11
12
  import { renderDeck } from '../lib/pptd/render-html.js'
@@ -17,6 +18,16 @@ import { zipRead, decodeXml } from '../lib/zips.js'
17
18
  const root = join(dirname(fileURLToPath(import.meta.url)), '..')
18
19
  const smokeDir = join(root, 'examples', 'smoke')
19
20
 
21
+ // ── 全局用户目录隔离(2026-09-26,方案 B 第二批)──────────────────────────────────────────
22
+ // 为什么:`state.js` / `preview-server.js` 的用户目录此前是**模块常量** `homedir()/.dsh`(忽略 DSH_HOME),
23
+ // 而 `templates.js` / `index.js`(diag) 是调用时求值 ⇒ 本机 `npm test` 会把 smoke 模板与预览缓存写进
24
+ // 开发机**真实**的 `~/.dsh/ppt-studio/`(实测复现、清理过两次),与下面那句"不碰真实目录"的承诺冲突。
25
+ // 现在四处都统一到 `src/home.js`(调用时求值)⇒ 这里把 DSH_HOME 指到构建目录内的临时 home,
26
+ // **整轮 smoke 的用户层读写都关在里面**,真实用户目录一个字节都不碰。位置必须在任何用户目录解析之前。
27
+ const PREV_SMOKE_HOME = process.env.DSH_HOME
28
+ const SMOKE_HOME = join(smokeDir, '.tmp-home')
29
+ process.env.DSH_HOME = SMOKE_HOME
30
+
20
31
  let pass = 0
21
32
  let fail = 0
22
33
  const ok = (name, cond, extra = '') => {
@@ -396,6 +407,24 @@ ok('v0.3.2:D4 导入探测跨页带 → 建议 safeArea 注释(未启用)'
396
407
  const bandPageYaml = await (await import('node:fs/promises')).readFile(join(bandDeck, 'imported', 'pages', 'slide_01.yaml'), 'utf8')
397
408
  ok('v0.4:导入保样式——text color/fontFamily/italic 回环', bandPageYaml.includes('fontFamily') && bandPageYaml.includes('2E4B9F'), bandPageYaml.split('\n').filter((l) => l.includes('fontFamily') || l.includes('color') || l.includes('italic')).join(' | '))
398
409
  ok('v0.4:import-styles.json 生成(含样式清单)', (await import('node:fs/promises')).stat(join(bandDeck, 'imported', 'import-styles.json')).then(() => true).catch(() => false))
410
+ // ── 16b. 启发 B(审计半,2026-09-26 第二轮):重建有损审计 ────────────────────────────────
411
+ // 为什么必须钉:我们的导出来自 DSL 中间层,**不支持**阴影/动画/超链接/切换/SmartArt 等;
412
+ // "整册重渲"对既有精美 PPT 是有损的,而这件事此前**不可见**(用户以为无损)。审计要放进导入产物与输出行。
413
+ // 判据来源:`ppt_import` 落盘 `<outDir>/loss-audit.json`(独立文件,理由见 import-pptx.js 注释)。
414
+ const { lossAuditLine } = await import('../lib/pptd/loss-audit.js')
415
+ const lossAuditJson = JSON.parse(await (await import('node:fs/promises')).readFile(join(bandDeck, 'imported', 'loss-audit.json'), 'utf8'))
416
+ ok('启发B:ppt_import 落盘 loss-audit.json(7 类齐全且每类 count === evidence.length)',
417
+ lossAuditJson.version === 1
418
+ && lossAuditJson.categories.map((c) => c.id).join(',') === 'animation,effect,hyperlink,transition,smartart,media,gradient'
419
+ && lossAuditJson.categories.every((c) => c.count === c.evidence.length),
420
+ `version=${lossAuditJson.version}|类别=${lossAuditJson.categories.map((c) => c.id).join(',')}`)
421
+ ok('启发B:自产导出回环审计 **0 命中**(防误报:空 a:effectLst / 主题 effectStyleLst 不算阴影)',
422
+ lossAuditJson.clean === true && lossAuditJson.total === 0 && String(bandImp.lossLine).includes('未发现'),
423
+ `clean=${lossAuditJson.clean} total=${lossAuditJson.total}`)
424
+ ok('启发B:干净源稿的输出行**不制造噪声**(无 ⚠、无 ppt_splice 建议)',
425
+ !String(bandImp.lossLine).includes('ppt_splice') && !String(bandImp.lossLine).includes('⚠'), String(bandImp.lossLine).slice(0, 90))
426
+ ok('启发B:输出行由报告生成(lossLine === lossAuditLine(落盘 JSON)),不是手写文案',
427
+ bandImp.lossLine === lossAuditLine(lossAuditJson), String(bandImp.lossLine).slice(0, 90))
399
428
  ok('v0.4:theme 聚合建议块写入 deck.yaml', bandDeckYaml.includes('# 建议主题') && bandDeckYaml.includes('textStyles'))
400
429
  const mnErr = validatePage({ elements: [{ elementId: 'l', elementType: 'line', points: [[10, 10], [100, 10], [200, 10]] }] }, 'test.yaml')
401
430
  ok('v0.4:多点折线显式报错(P2-3,不再静默截断)', mnErr !== null && mnErr.messages.some((m) => m.includes('仅支持 2 点')), mnErr?.messages?.join('; '))
@@ -437,13 +466,10 @@ ok('v0.6.2:随包模板每套都自包含(元数据齐 + 有预览图)',
437
466
  builtins.map((t) => `${t.id}:${t.preview ? 'png' : 'MISSING'}`).join(' '))
438
467
 
439
468
  // ── 17b. v1.0.6 用户自建模板库:两层目录 + 入库(.pptx)/ 物化 / 删除 ──
440
- // DSH_HOME 指到临时目录:`userTemplatesDir()` 是**按调用求值**的,所以这里能安全地隔离,
441
- // 不碰开发机/CI 上真实的 `~/.dsh/ppt-studio/templates`。
469
+ // 用户层目录现在是**整轮隔离**的(见文件顶部:DSH_HOME → `examples/smoke/.tmp-home`),
470
+ // 所以这里不必再单独切换 DSH_HOME,也不会碰到开发机/CI 上真实的 `~/.dsh/ppt-studio/templates`。
442
471
  {
443
- const libHome = join(root, 'examples', '.tmp-tpl-home')
444
- await rm(libHome, { recursive: true, force: true })
445
- const prevHome = process.env.DSH_HOME
446
- process.env.DSH_HOME = libHome
472
+ const libHome = SMOKE_HOME
447
473
  try {
448
474
  const libUserDir = tplMod.userTemplatesDir()
449
475
  ok('v1.0.6:用户模板层在插件包之外(升级重新物化包也丢不了)',
@@ -474,9 +500,8 @@ ok('v0.6.2:随包模板每套都自包含(元数据齐 + 有预览图)',
474
500
  !!libRefuse && /随包/.test(libRefuse.message) && existsSync(join(tplMod.TEMPLATES_DIR, 'business-blue', 'template.yaml')),
475
501
  libRefuse?.message?.slice(0, 46) ?? '(没拦住)')
476
502
  } finally {
477
- if (prevHome === undefined) delete process.env.DSH_HOME
478
- else process.env.DSH_HOME = prevHome
479
- await rm(libHome, { recursive: true, force: true })
503
+ // 这里**不**恢复 DSH_HOME、也不删 SMOKE_HOME:隔离是本轮 smoke 的全局前提(见文件顶部),
504
+ // 整轮结束后统一恢复与清理。
480
505
  }
481
506
  }
482
507
  let tplAllOk = true
@@ -555,7 +580,7 @@ ok('v0.7:内置模板正式页(首母版副本)0 错误', tplV0.text.split
555
580
  // 收纳清洗升级:registerTemplate 声明出界元素 + 剩余错误分类(bandDeck safeArea 外元素)
556
581
  const reg2 = await tplMod.registerTemplate(bandDeck, { id: `smoke-wash-${Date.now().toString(36)}`, name: '洗涤测试' }, {})
557
582
  ok('v0.7:收纳清洗——出界声明/重叠声明/剩余分类进入 meta', typeof reg2.meta.cleanup === 'string' && reg2.meta.cleanup.includes('剩余'), reg2.meta.cleanup ?? '')
558
- await (await import('node:fs/promises')).rm(join(tplMod.TEMPLATES_DIR, reg2.id), { recursive: true, force: true })
583
+ await (await import('node:fs/promises')).rm(join(tplMod.userTemplatesDir(), reg2.id), { recursive: true, force: true })
559
584
 
560
585
  // ── 18. v0.5.1:外部模板收纳(ppt_template_add)——导入工程 → 模板库 ──────
561
586
  const regId = `smoke-tpl-${Date.now().toString(36)}`
@@ -567,7 +592,7 @@ const regWS = await tplMod.templateWorkspace(regId)
567
592
  const ctxReg = await resolveDeck(regWS.dir)
568
593
  ok('v0.5.1:收纳模板工作区可校验(theme/页面保留)', ctxReg.theme.colors.primary === '#2563EB' && ctxReg.pages.length === 3, `pages=${ctxReg.pages.length}`)
569
594
  // 清理测试模板
570
- await (await import('node:fs/promises')).rm(join(tplMod.TEMPLATES_DIR, regId), { recursive: true, force: true })
595
+ await (await import('node:fs/promises')).rm(join(tplMod.userTemplatesDir(), regId), { recursive: true, force: true })
571
596
 
572
597
  // ── 19. v0.6.0:对话内预览(ppt_preview)——预览根构建 + 同源相对 URL ────
573
598
  const { buildPreview } = await import('../lib/preview-server.js')
@@ -669,7 +694,7 @@ if (previewSrc) {
669
694
  const dualCtx = await resolveDeck(dualWS)
670
695
  ok('v0.9.0:带 referenceTemplate 的 deck 通过校验(非渲染字段)', dualCtx.pages.length === 1 && dualCtx.deck.referenceTemplate?.id === dualId)
671
696
  // 清理双轨测试模板 + 假 source
672
- await (await import('node:fs/promises')).rm(join(tplMod.TEMPLATES_DIR, dualId), { recursive: true, force: true }).catch(() => {})
697
+ await (await import('node:fs/promises')).rm(join(tplMod.userTemplatesDir(), dualId), { recursive: true, force: true }).catch(() => {})
673
698
  await rm(dualSrc, { force: true }).catch(() => {})
674
699
  await rm(dualWS, { recursive: true, force: true })
675
700
 
@@ -1949,6 +1974,36 @@ if (pyEnv.has) {
1949
1974
  } else {
1950
1975
  ok('python-pptx 兜底:生成脚本真跑出 .pptx,且 python-pptx 能把它读回(无 python-pptx:跳过)', true)
1951
1976
  }
1977
+ // 43.3 原生图表(2026-09-26 第二轮 ③):兜底引擎的图表必须是**原生可编辑图表**
1978
+ // ——包里要有 `ppt/charts/*.xml`(图表部件)**与** `ppt/embeddings/*.xlsx`(内嵌数据工作簿,数据随文件走)。
1979
+ // 旧行为是"图表降级为表格"(用户拿到成品看不见图、也改不了数据),这条断言挡住回退。
1980
+ if (pyEnv.has) {
1981
+ const chartDeck = join(pyDeck, 'charts')
1982
+ await mkdir(join(chartDeck, 'pages'), { recursive: true })
1983
+ await writeFile(join(chartDeck, 'deck.yaml'), ['version: 1', 'title: chart-py', 'size: [960, 540]', 'theme:',
1984
+ ' colors: {primary: "#2563EB", text: "#1F2937"}', ' textStyles:',
1985
+ ' body: {fontSize: 16, color: "$text"}', 'pages:', ' - pages/01.yaml', ''].join('\n'), 'utf8')
1986
+ await writeFile(join(chartDeck, 'pages', '01.yaml'), ['pageType: content', 'elements:',
1987
+ ' - elementId: bar', ' elementType: chart', ' bounds: [60, 60, 400, 260]',
1988
+ ' chart: {type: bar, data: {cols: ["季度", "营收"], rows: [["Q1", 12], ["Q2", 18]]}}',
1989
+ ' - elementId: pie', ' elementType: chart', ' bounds: [500, 60, 300, 260]',
1990
+ ' chart: {type: pie, data: {cols: ["份额", "值"], rows: [["甲", 60], ["乙", 40]]}}', ''].join('\n'), 'utf8')
1991
+ let pass433 = false
1992
+ let info433 = ''
1993
+ try {
1994
+ const outC = join(chartDeck, 'out-charts.pptx')
1995
+ await runPythonExport(await resolveDeck(chartDeck), outC)
1996
+ const { zipRead } = await import('../lib/zips.js')
1997
+ const keys = [...zipRead(readFileSync(outC)).keys()]
1998
+ const charts = keys.filter((k) => k.includes('/charts/') && k.endsWith('.xml') && !k.includes('_rels')).length
1999
+ const embeds = keys.filter((k) => k.includes('embeddings')).length
2000
+ pass433 = charts === 2 && embeds === 2
2001
+ info433 = `chart 部件=${charts} 内嵌工作簿=${embeds}(期望各 2)`
2002
+ } catch (e) { info433 = String(e.message).slice(0, 220) }
2003
+ ok('python-pptx 兜底:图表是**原生可编辑图表**(`ppt/charts/*.xml` + 内嵌数据工作簿各就位,不再降级为表格)', pass433, info433)
2004
+ } else {
2005
+ ok('python-pptx 兜底:图表是原生可编辑图表(无 python-pptx:跳过)', true)
2006
+ }
1952
2007
  await rm(pyDeck, { recursive: true, force: true })
1953
2008
 
1954
2009
  // ── 44. 表格字号:audit 档的最小字号断言不得被表格自己架空(2026-09-18 新增)──────────────
@@ -2203,27 +2258,36 @@ ok('bundle:已转为公开发布 npm(无 private + publishConfig.access=publ
2203
2258
  `package=${rootPkg.name}|patch=${insertRow?.name ?? '(缺)'}|预设行=${presetRowName ?? '无(正确:随包预设不含插件行)'}`)
2204
2259
  }
2205
2260
 
2206
- // 回滚后的装配形状(2026-09-18 第二次修订):`apply()` 在**它被装入的那一层**注册全部能力面
2207
- // (工具 / `/ppt` 命令 / `ppt_state` / 4 本技能)⇒ 装上插件,**所有会话**都能用。
2208
- // 为什么放弃按预设隔离(两件都实测过,代码见 src/index.js 顶部):
2209
- // ① 空白会话**切换**预设时 `agent-presets.swap` 会 `recompose(agent.ctx, id)`,但那一刻该 agent 已存在,
2210
- // 我们的 `agent/created` 监听者才刚在这次组合中注册 ⇒ **不会**为它触发 ⇒ 切过去永远没有工具;
2211
- // ② 技能注册表分层、技能工具在**预设层**读,而我们只能注册到 agent 子层或 profile 根 ⇒ 技能永不出现。
2261
+ // 第四次修订(2026-09-26)**按预设隔离**:`apply()` 按"作用域档"分两种——
2262
+ // · profile 档(`config.scope` 缺省/`auto`/`profile`)= 全局管道 + 预设声明;缺省档还追加一行
2263
+ // **自定位行**(本包入口的绝对 `file://`),让本包被自己的预设再挂一次 ⇒ 预设档。
2264
+ // · preset 档(`config.scope === 'preset'`,由自定位行挂载)= 22 工具 / `/ppt` 命令 / 4 技能 /
2265
+ // 语义路由 / 提示段**全部注册在预设作用域** ⇒ 只对该预设的会话可见(其他预设零影响)。
2266
+ // 安全网:声明失败、注册表不可见、或"声明成功但预设档实例未装配" ⇒ **自动回落全局能力面**。
2267
+ // 历史两次失败的正确结论(2026-09-26 源码 + 真宿主实测):① 只对"插件自己按 agent 动态门控"成立;
2268
+ // ② 只对**裸包名/相对名**成立——绝对 `file://` 行不受 baseUrl 影响,正是它的解药。
2212
2269
  {
2213
2270
  const indexMod = await import('../lib/index.js')
2271
+ const presetMod = await import('../lib/preset-delivery.js')
2214
2272
  let tools = 0, cmds = 0, skills = 0, listeners = 0
2215
2273
  const handlers = new Map()
2216
- const skillsSvc = { register: () => { skills++ } }
2274
+ const skillsSvc = { register: () => { skills++ }, get: () => undefined }
2217
2275
  // 预设注册表 stub(**按 DSH 0.1.7 的真实契约**):`register()` 对重复 id **抛错**
2218
2276
  // (真源码:`if (this.definitions.has(definition.id)) throw new Error(\`Duplicate agent preset: ${def.id}\`)`),
2219
2277
  // 并返回注销函数。于是"双挂载会不会把同一个预设声明两次"在这里是可判定的。
2220
2278
  const declared = []
2221
2279
  const seenIds = new Set()
2280
+ // 真实时序:预设档实例是在 `register()` **期间**被挂载并 apply 的 ⇒ 隔离信号晚于 `apply()` 起点。
2281
+ // 只有本开关打开时 stub 才在 register 里置位信号(用于"隔离生效"场景),别处一律不置位(走回落)。
2282
+ let publishSignalOnRegister = false
2222
2283
  const registry = {
2223
2284
  register: (def) => {
2224
2285
  if (seenIds.has(def.id)) return Promise.reject(new Error(`Duplicate agent preset: ${def.id}`))
2225
2286
  seenIds.add(def.id)
2226
2287
  declared.push(def)
2288
+ if (publishSignalOnRegister) {
2289
+ globalThis.__pptScopeApplied = { at: Date.now(), presetId: def.id, pid: process.pid }
2290
+ }
2227
2291
  return Promise.resolve(async () => { seenIds.delete(def.id) })
2228
2292
  },
2229
2293
  }
@@ -2240,65 +2304,134 @@ ok('bundle:已转为公开发布 npm(无 private + publishConfig.access=publ
2240
2304
  effect: (fn) => { fn(); return () => {} },
2241
2305
  logger: () => ({ info: () => {}, warn: () => {} }),
2242
2306
  })
2243
- // 装配防重计数是**进程级**(globalThis):本节要自己从 0 起算,否则会被前面的用例带偏
2244
- delete globalThis.__pptCoreReg
2245
- indexMod.apply(makeCtx(), { presetIds: ['ppt'] })
2307
+ // 装配防重 / 隔离信号都是**进程级**(globalThis):本节要自己从 0 起算,否则会被前面的用例带偏。
2246
2308
  const flush = () => new Promise((r) => setTimeout(r, 0))
2309
+ const grace = () => new Promise((r) => setTimeout(r, 600)) // 健康判定宽限 400ms + 余量
2310
+ const resetAll = () => {
2311
+ delete globalThis.__pptCoreReg
2312
+ delete globalThis.__pptScopeApplied
2313
+ declared.length = 0
2314
+ seenIds.clear()
2315
+ }
2316
+
2317
+ // ── 自定位行:隔离的载体(本包入口的**绝对 file:// URL**,预设行按注册表 baseUrl 解析 ⇒ 只有绝对名不受影响)
2318
+ const selfRow = presetMod.selfPresetRow()
2319
+ ok('§40 自定位行:指向本包入口的绝对 `file://` URL,且 `config.scope=preset` / `autoPreset=false`',
2320
+ typeof selfRow?.name === 'string' && selfRow.name.startsWith('file:///') && /\/lib\/index\.js$/.test(selfRow.name)
2321
+ && selfRow?.config?.scope === 'preset' && selfRow?.config?.autoPreset === false,
2322
+ JSON.stringify(selfRow))
2323
+ ok('§40 自定位行:入口文件真实存在(拿不到入口就不追加该行——宁可不隔离,也不加一行注定 broken 的)',
2324
+ selfRow !== null && existsSync(fileURLToPath(selfRow.name)), String(selfRow?.name))
2325
+
2326
+ // ── 缺省档(auto):声明 20 行(19 + 自定位行)→ 预设档实例未出现 ⇒ 自动回落全局 ────────────
2327
+ resetAll()
2328
+ indexMod.apply(makeCtx(), { presetIds: ['ppt'] })
2247
2329
  await flush() // 声明走的是异步队列(inject → register),让它落地
2248
- ok('回滚:apply 在**装入层**注册工具与命令面(22 工具 + /ppt 命令)——"装上即可用"',
2249
- tools === 22 && cmds >= 1, `tools=${tools} cmds=${cmds}`)
2250
- ok('回滚:4 本内嵌技能注册在**同一层**(技能注册表分层,技能工具在父层读——注册到子层就永远看不见)',
2251
- skills === 4, `skills=${skills}(应=4)`)
2252
- ok('回滚:隔离代码已移除(不再导出 mountForAgent,也不再用 roster 决定装配)',
2253
- indexMod.mountForAgent === undefined && tools === 22, `mountForAgent=${typeof indexMod.mountForAgent}|tools=${tools}`)
2254
- // ── 0.1.7 的**真适配**:预设靠"向注册表声明"交付(写目录那条路已被上游删除)────────────
2255
- // 判据分三层:① 真的调到 register 了;② 声明形状对(id/名/序/行数/内容);③ 双挂载不会声明两次。
2256
- const d0 = declared[0]
2257
- ok('0.1.7:预设靠 `agentPresets.register()` **声明**(不再写目录——上游已无目录发现路径)',
2258
- declared.length === 1 && d0?.id === 'ppt', `声明次数=${declared.length}|id=${d0?.id ?? '(无)'}`)
2259
- ok('0.1.7:声明形状取自包内 `agent-presets/ppt/`(名字/简介/序号 + 19 条 standard 组合行)',
2260
- d0?.name === 'PPT 工作室' && d0?.order === 2 && d0?.plugins?.length === 19
2261
- && typeof d0?.description === 'string' && d0.description.length > 10,
2262
- `name=${d0?.name ?? '(无)'}|order=${d0?.order}|rows=${d0?.plugins?.length}|desc=${d0?.description?.length ?? 0} 字`)
2263
- // `!!js` 必须**保真**成 Loader 认的 `{__jsExpr}`:降级成普通字符串会让
2264
- // `Boolean(<非空字符串>) === true` ⇒ Windows 上 pwsh 行被误停用(= 没有 shell)。
2265
- const rowOf = (def, id) => def?.plugins?.find((r) => r.id === id)
2266
- const bashD = rowOf(d0, 'tool-bash')?.disabled
2267
- const pwshD = rowOf(d0, 'tool-pwsh')?.disabled
2268
- ok('0.1.7:`!!js` 表达式保真为 `{__jsExpr}`(降级成字符串 ⇒ Windows 上 pwsh 行会被误停用)',
2269
- bashD?.__jsExpr === "process.platform === 'win32'" && pwshD?.__jsExpr === "process.platform !== 'win32'",
2270
- `bash=${JSON.stringify(bashD)}|pwsh=${JSON.stringify(pwshD)}`)
2271
- const first = { tools, cmds, skills }
2272
- indexMod.apply(makeCtx(), { presetIds: ['ppt'] }) // 第二次装配(双挂载场景)
2273
- await flush()
2274
- ok('回滚:同进程第二次装配被防重拦下(首个生效 + 计数),不重复注册',
2275
- tools === first.tools && cmds === first.cmds && skills === first.skills,
2276
- `二次后 tools=${tools}(应=${first.tools})cmds=${cmds} skills=${skills}|refcount=${globalThis.__pptCoreReg?.count ?? '?'}`)
2277
- ok('0.1.7:双挂载也**不会重复声明**预设(真注册表对重复 id 直接 throw ⇒ 声明只发一次)',
2278
- declared.length === 1, `声明次数=${declared.length}(应=1)`)
2279
- ok('回滚:apply 装的全局管道仍在(预览路由/语义路由/提示段注入靠这些监听器)',
2280
- listeners >= 2, `listeners=${listeners}`)
2330
+ ok('§40 缺省档:声明 20 行 = 19 条 standard 组合行 + 第 20 行自定位行("只在预设里可见"靠它)',
2331
+ declared.length === 1 && declared[0]?.plugins?.length === 20 && declared[0]?.plugins?.at(-1)?.id === 'ppt-studio-scope',
2332
+ `声明=${declared.length} 次|行数=${declared[0]?.plugins?.length}|末行=${declared[0]?.plugins?.at(-1)?.id ?? '(无)'}`)
2333
+ ok('§40 缺省档:健康判定期间**不注册**能力面(隔离生效时 profile 档必须零能力面)',
2334
+ tools === 0 && cmds === 0 && skills === 0 && listeners === 0,
2335
+ `tools=${tools} cmds=${cmds} skills=${skills} listeners=${listeners}`)
2336
+ await grace()
2337
+ ok('§40 自动回落:声明成功但预设档实例未装配 ⇒ 回落注册全局能力面(**功能不丢**,只是没隔离)',
2338
+ tools === 22 && cmds >= 1 && skills === 4 && listeners >= 2
2339
+ && presetMod.presetDeliveryStatus().isolation === 'profile-fallback',
2340
+ `tools=${tools} cmds=${cmds} skills=${skills} listeners=${listeners}|isolation=${presetMod.presetDeliveryStatus().isolation}`)
2281
2341
  let promptOk = false
2282
2342
  try {
2283
2343
  const h = handlers.get('system-prompt/assemble')
2284
2344
  const out = h ? await h({ sections: [] }, { agent: { session: { id: 'no-such-session-xyz' } } }, async () => ({ sections: [] })) : null
2285
2345
  promptOk = Boolean(out)
2286
2346
  } catch { promptOk = false }
2287
- ok('回滚:提示段注入不按预设门控(读不到会话状态时原样返回,不抛)', promptOk, promptOk ? '原样返回 ✓' : '未注册/抛错')
2288
- // 注册表缺失时必须**只告警不抛**(极简部署没有 agentPresets):插件仍要完整可用
2347
+ ok('§40 回落档:提示段注入仍在(读不到会话状态时原样返回,不抛)', promptOk, promptOk ? '原样返回 ✓' : '未注册/抛错')
2348
+
2349
+ // ── 隔离生效:预设档实例置位信号 ⇒ profile 档零能力面 ─────────────────────────────
2350
+ resetAll()
2351
+ const isoBase = { tools, cmds, skills, listeners }
2352
+ publishSignalOnRegister = true // 忠实模拟:预设档实例在 register() 期间装配 ⇒ 信号晚于 apply 起点
2353
+ indexMod.apply(makeCtx(), {})
2354
+ await grace()
2355
+ publishSignalOnRegister = false
2356
+ ok('§40 隔离生效:预设档实例已装配 ⇒ profile 档**一个能力面都不注册**(其他预设零影响)',
2357
+ tools === isoBase.tools && cmds === isoBase.cmds && skills === isoBase.skills && listeners === isoBase.listeners
2358
+ && presetMod.presetDeliveryStatus().isolation === 'preset',
2359
+ `增量 tools=${tools - isoBase.tools} cmds=${cmds - isoBase.cmds} skills=${skills - isoBase.skills} listeners=${listeners - isoBase.listeners}|isolation=${presetMod.presetDeliveryStatus().isolation}`)
2360
+
2361
+ // ── 预设档(由自定位行挂载):能力面 + 路由全在预设作用域;**不声明**预设 ─────────────────
2362
+ resetAll()
2363
+ const tierBase = { tools, cmds, skills, listeners }
2364
+ indexMod.apply(makeCtx(), { scope: 'preset', autoPreset: false, presetIds: ['ppt'] })
2365
+ await flush()
2366
+ ok('§40 预设档:22 工具 + `/ppt` 命令 + 4 技能 + 2 监听器**全部注册在预设作用域**(隔离的实现主体)',
2367
+ tools - tierBase.tools === 22 && cmds - tierBase.cmds >= 1 && skills - tierBase.skills === 4 && listeners - tierBase.listeners === 2,
2368
+ `增量 tools=${tools - tierBase.tools} cmds=${cmds - tierBase.cmds} skills=${skills - tierBase.skills} listeners=${listeners - tierBase.listeners}`)
2369
+ ok('§40 预设档:**不声明**预设(否则注册表对重复 id 直接 throw ⇒ 预设消失)',
2370
+ declared.length === 0, `声明次数=${declared.length}`)
2371
+ ok('§40 预设档:置位"预设档实例已装配"信号(profile 档据此判定隔离生效)',
2372
+ typeof globalThis.__pptScopeApplied?.at === 'number' && globalThis.__pptScopeApplied.at > 0,
2373
+ JSON.stringify(globalThis.__pptScopeApplied))
2374
+
2375
+ // ── 传统档(scope=profile):不隔离、能力面全局,声明回到 19 行(不含自定位行)──────────────
2376
+ resetAll()
2377
+ const trBase = { tools, skills }
2378
+ indexMod.apply(makeCtx(), { scope: 'profile' })
2379
+ await grace()
2380
+ ok('§40 传统档:能力面注册在装入层(全局可见),且声明**不含**自定位行(19 行)',
2381
+ tools - trBase.tools === 22 && skills - trBase.skills === 4 && declared.length === 1
2382
+ && declared[0]?.plugins?.length === 19 && declared[0]?.plugins?.at(-1)?.id !== 'ppt-studio-scope'
2383
+ && presetMod.presetDeliveryStatus().isolation === 'profile',
2384
+ `增量 tools=${tools - trBase.tools} skills=${skills - trBase.skills}|行数=${declared[0]?.plugins?.length}|isolation=${presetMod.presetDeliveryStatus().isolation}`)
2385
+
2386
+ // ── 注册表不可见:只告警不抛 + **立即**回落(不留"装上了却什么都没有"的窗口)──────────────
2387
+ resetAll()
2388
+ const noRegBase = { tools, skills }
2289
2389
  let noRegThrew = false
2290
- delete globalThis.__pptCoreReg
2291
2390
  try {
2292
2391
  indexMod.apply({
2293
- tools: { register: () => {} }, commands: { register: () => {} }, skills: skillsSvc,
2294
- get: () => undefined, inject: () => {}, on: () => {}, effect: (fn) => { fn(); return () => {} },
2392
+ tools: { register: () => { tools++ } }, commands: { register: () => { cmds++ } }, skills: skillsSvc,
2393
+ get: () => undefined, inject: () => {}, on: (ev, fn) => { listeners++; handlers.set(ev, fn) },
2394
+ effect: (fn) => { fn(); return () => {} },
2295
2395
  logger: () => ({ info: () => {}, warn: () => {} }),
2296
2396
  }, {})
2297
- await flush()
2397
+ // 真实场景:本部署**没有** agentPresets 服务 ⇒ `inject(['agentPresets'], cb)` 的回调**永不触发**
2398
+ // (不是"触发并给 undefined")⇒ 声明既不成功也不失败 ⇒ 必须靠兜底超时回落(否则零能力面)。
2399
+ await new Promise((r) => setTimeout(r, 3400))
2298
2400
  } catch { noRegThrew = true }
2299
- ok('0.1.7:注册表不可用时只告警不抛(预设没了也不能让插件挂不上)',
2300
- noRegThrew === false, noRegThrew ? '装配抛错了' : '静默降级 ✓')
2301
- delete globalThis.__pptCoreReg
2401
+ ok('§40 注册表不可见:只告警不抛,并靠**兜底超时**回落全局能力面(极简部署仍完整可用)',
2402
+ // 注意:这个假 ctx 连 `skills` 服务都没有(`get` 恒 undefined)⇒ 技能数应为 0,不是 4。
2403
+ noRegThrew === false && tools - noRegBase.tools === 22 && cmds >= 1
2404
+ && presetMod.presetDeliveryStatus().isolation === 'profile-fallback',
2405
+ `抛错=${noRegThrew}|增量 tools=${tools - noRegBase.tools} cmds=${cmds}|isolation=${presetMod.presetDeliveryStatus().isolation}`)
2406
+
2407
+ // ── 防重(按作用域):同一 ctx 重复 apply 只装配一次 ───────────────────────────────
2408
+ resetAll()
2409
+ const dedupeCtx = makeCtx()
2410
+ const ddBase = { tools }
2411
+ indexMod.apply(dedupeCtx, {})
2412
+ indexMod.apply(dedupeCtx, {})
2413
+ await grace()
2414
+ ok('§40 防重:同一作用域重复 apply 只装配一次(profile 与 preset 是两个不同作用域,各自只该装配一次)',
2415
+ tools - ddBase.tools === 22 && declared.length === 1 && [...(globalThis.__pptCoreReg?.keys ?? [])].join() === 'profile',
2416
+ `增量 tools=${tools - ddBase.tools}(应 22)|声明=${declared.length}|keys=${[...(globalThis.__pptCoreReg?.keys ?? [])].join()}`)
2417
+ resetAll()
2418
+ // ── 0.1.7 的**真适配**:预设靠"向注册表声明"交付(写目录那条路已被上游删除)────────────
2419
+ // 判据分三层:① 真的调到 register 了;② 声明形状对(id/名/序/行数/内容);③ 双挂载不会声明两次。
2420
+ const declDirect = presetMod.presetDeclaration()
2421
+ ok('§40 预设组合:名字/简介/序号取自包内 `agent-presets/ppt/`(19 条 standard 组合行;选择器里显示的就是这些)',
2422
+ declDirect.name === 'PPT 工作室' && declDirect.order === 2 && declDirect.plugins.length === 19
2423
+ && typeof declDirect.description === 'string' && declDirect.description.length > 10,
2424
+ `name=${declDirect.name}|order=${declDirect.order}|行数=${declDirect.plugins.length}|desc=${declDirect.description.length} 字`)
2425
+ // `!!js` 必须**保真**成 Loader 认的 `{__jsExpr}`:降级成普通字符串会让
2426
+ // `Boolean(<非空字符串>) === true` ⇒ Windows 上 pwsh 行被误停用(= 没有 shell)。
2427
+ const rowOf = (def, id) => def?.plugins?.find((r) => r.id === id)
2428
+ const bashD = rowOf(declDirect, 'tool-bash')?.disabled
2429
+ const pwshD = rowOf(declDirect, 'tool-pwsh')?.disabled
2430
+ ok('§40 预设组合:`!!js` 表达式保真为 `{__jsExpr}`(降级成字符串 ⇒ Windows 上 pwsh 行会被误停用)',
2431
+ bashD?.__jsExpr === "process.platform === 'win32'" && pwshD?.__jsExpr === "process.platform !== 'win32'",
2432
+ `bash=${JSON.stringify(bashD)}|pwsh=${JSON.stringify(pwshD)}`)
2433
+ ok('§40 隔离代码历史:`mountForAgent` 那套"插件自己按 agent 门控"仍不恢复(实现改走预设作用域)',
2434
+ indexMod.mountForAgent === undefined, `mountForAgent=${typeof indexMod.mountForAgent}`)
2302
2435
  }
2303
2436
 
2304
2437
  // ── 41. 安装器 + 预设**声明**(2026-09-24 为 DSH 0.1.7-rc.1 重写)──────────────────────
@@ -2553,7 +2686,9 @@ ok('npm 发布通道:发布的是**下载下来的 Release 资产**(等 .tgz
2553
2686
  delete globalThis.__pptCoreReg
2554
2687
  delete globalThis.__pptRouteReg
2555
2688
  let applyThrew = null
2556
- try { indexMod2.apply(pipeCtx, { presetIds: ['ppt'] }) } catch (e) { applyThrew = e }
2689
+ // 本节测的是**管道韧性**(附加能力坏掉只该降级),与隔离无关 ⇒ 用传统档(`scope: 'profile'`)
2690
+ // 让能力面直接落在装入层;隔离路径的判定见 §40。
2691
+ try { indexMod2.apply(pipeCtx, { scope: 'profile', presetIds: ['ppt'] }) } catch (e) { applyThrew = e }
2557
2692
  ok('§52 装配韧性:全局管道抛错时 apply 仍完成装配(22 工具 / 命令面 / 4 技能)——附加能力坏掉只该降级',
2558
2693
  applyThrew === null && pTools === 22 && pCmds >= 1 && pSkills === 4,
2559
2694
  `threw=${applyThrew ? String(applyThrew.message) : 'null'}|tools=${pTools} cmds=${pCmds} skills=${pSkills}`)
@@ -2588,7 +2723,7 @@ ok('npm 发布通道:发布的是**下载下来的 Release 资产**(等 .tgz
2588
2723
  delete globalThis.__pptCoreReg
2589
2724
  delete globalThis.__pptRouteReg
2590
2725
  let injThrew = null
2591
- try { indexMod2.apply(injCtx, { presetIds: ['ppt'] }) } catch (e) { injThrew = e }
2726
+ try { indexMod2.apply(injCtx, { scope: 'profile', presetIds: ['ppt'] }) } catch (e) { injThrew = e }
2592
2727
  await new Promise((r) => setTimeout(r, 10))
2593
2728
  ok('§52 预览路由:`webServer` 比插件晚激活时(真宿主实测的组合顺序)走 `inject` 仍要真的挂上路由',
2594
2729
  injThrew === null && iTools === 22 && injPrefixes.has('/ppt-preview'),
@@ -2695,6 +2830,189 @@ ok('npm 发布通道:发布的是**下载下来的 Release 资产**(等 .tgz
2695
2830
  await fsp.rm(gWork, { recursive: true, force: true })
2696
2831
  }
2697
2832
 
2833
+ // ── 55. 用户目录口径统一 + 测试隔离(2026-09-26,方案 B 第二批)──────────────────────────
2834
+ // 事故形状:`state.js` / `preview-server.js` 的用户目录曾是**模块常量** `homedir()/.dsh`(忽略 DSH_HOME),
2835
+ // 而 `templates.js` / `index.js`(diag) 是调用时求值 ⇒ ① 设了 DSH_HOME 的隔离部署里,会话状态与预览
2836
+ // 逃出隔离目录(实测:状态文件落在真实 `~/.dsh`);② 本机 `npm test` 把 smoke 模板与预览缓存写进
2837
+ // 开发机真实 `~/.dsh/ppt-studio/`(实测复现、清理过两次)。修法 = 统一到 `src/home.js`(调用时求值)。
2838
+ {
2839
+ const homeMod = await import('../lib/home.js')
2840
+ const stateMod = await import('../lib/state.js')
2841
+ const { previewRoot } = await import('../lib/preview-server.js')
2842
+ const { userTemplatesDir } = await import('../lib/templates.js')
2843
+ const prevHome55 = process.env.DSH_HOME
2844
+ const probeHome = join(smokeDir, '.tmp-home-probe')
2845
+ process.env.DSH_HOME = probeHome
2846
+ const set55 = { home: homeMod.dshHome(), studio: homeMod.pptStudioDir(), session: stateMod.sessionDir(), preview: previewRoot(), tpl: userTemplatesDir() }
2847
+ delete process.env.DSH_HOME
2848
+ const unset55 = { home: homeMod.dshHome(), studio: homeMod.pptStudioDir(), session: stateMod.sessionDir(), preview: previewRoot(), tpl: userTemplatesDir() }
2849
+ process.env.DSH_HOME = prevHome55
2850
+ ok('§55 用户目录:`dshHome()` 认 DSH_HOME(设/未设两种),未设时回落 `~/.dsh` —— 普通用户行为零变化',
2851
+ set55.home === probeHome && unset55.home === join(homedir(), '.dsh'),
2852
+ `设=${set55.home}|未设=${unset55.home}`)
2853
+ ok('§55 用户目录:state / preview / templates / diag 四个消费方**同源**(都挂在同一个 ppt-studio 根下,不再各写一套)',
2854
+ set55.studio === join(probeHome, 'ppt-studio') && unset55.studio === join(homedir(), '.dsh', 'ppt-studio')
2855
+ && [set55.session, set55.preview, set55.tpl].every((p) => p.startsWith(set55.studio))
2856
+ && [unset55.session, unset55.preview, unset55.tpl].every((p) => p.startsWith(unset55.studio)),
2857
+ `studio=${set55.studio}|session=${set55.session}|preview=${set55.preview}|templates=${set55.tpl}`)
2858
+
2859
+ // 真写一遍:会话状态必须落在**当前** DSH_HOME 下,且真实家目录里不出现这个 probe 文件
2860
+ const sid55 = `probe-${Date.now().toString(36)}`
2861
+ await stateMod.saveSession(sid55, { routing: 'auto', probe55: true })
2862
+ const inIso = join(SMOKE_HOME, 'ppt-studio', `session-${sid55}.json`)
2863
+ const inReal = join(homedir(), '.dsh', 'ppt-studio', `session-${sid55}.json`)
2864
+ const back55 = await stateMod.loadSession(sid55)
2865
+ ok('§55 会话状态:写进当前 DSH_HOME 下的 `<home>/ppt-studio/`,真实家目录不出现该文件(此前漏掉的正是这一半)',
2866
+ existsSync(inIso) && back55.probe55 === true && !existsSync(inReal),
2867
+ `隔离内=${existsSync(inIso)}|真实家目录=${existsSync(inReal)}|读回 probe=${back55.probe55}`)
2868
+ ok('§55 测试隔离:整轮 smoke 的 DSH_HOME 指向构建目录内的临时 home(本轮所有用户层写入都被关在里面)',
2869
+ process.env.DSH_HOME === SMOKE_HOME && SMOKE_HOME.startsWith(root) && SMOKE_HOME.includes('.tmp-'),
2870
+ `DSH_HOME=${process.env.DSH_HOME}`)
2871
+ }
2872
+
2873
+ // ── 56. 兜底增强(2026-09-26,第二轮):能力探测 + 优先用捆绑 Python ─────────────────────
2874
+ // 背景:官方三个 office 技能(`office-docx/pptx/xlsx`)只在桌面端具备,它们假定的运行时
2875
+ // (捆绑 Python + LibreOffice kit)也只在桌面端;于是兜底路径必须**先探测再说话**。
2876
+ // 本节钉四件事:① 探测契约与磁盘一致(不编造);② 候选根的顺序(env > 安装副本 > carrier);
2877
+ // ③ python-pptx 兜底**优先**用捆绑 Python;④ `ppt_state` 把能力与装配状态一次暴露。
2878
+ {
2879
+ const capMod = await import('../lib/capabilities.js')
2880
+ const caps = capMod.detectCapabilities()
2881
+ ok('§56 能力探测:契约形状完整(平台 / 捆绑 Python·Node / LibreOffice kit / office 资产 / primary-runtime 候选)',
2882
+ ['platform', 'bundledPython', 'bundledNode', 'libreofficeKit', 'officeSkillsAssets', 'primaryRuntime'].every((k) => k in caps)
2883
+ && Array.isArray(caps.primaryRuntime) && caps.primaryRuntime.length >= 1 // 候选根数**随部署变化**(桌面 3 个 / CLI 1 个),别写死
2884
+ && caps.primaryRuntime.every((r) => typeof r.root === 'string' && typeof r.source === 'string' && r.present === existsSync(r.root)),
2885
+ `platform=${caps.platform}|python=${caps.bundledPython ? '有' : '无'}|kit=${caps.libreofficeKit ? '有' : '无'}|候选根=${caps.primaryRuntime.length}`)
2886
+ ok('§56 能力探测:探测不到就给 null(**不编造**),给出的路径都真实存在',
2887
+ (caps.bundledPython === null || existsSync(caps.bundledPython.path))
2888
+ && (caps.libreofficeKit === null || existsSync(caps.libreofficeKit.cli))
2889
+ && (caps.officeSkillsAssets === null || existsSync(caps.officeSkillsAssets.skillsDir)),
2890
+ `python=${caps.bundledPython?.path ?? '(无)'}|kit=${caps.libreofficeKit?.cli ?? '(无)'}|office=${caps.officeSkillsAssets?.skillsDir ?? '(无)'}`)
2891
+ const cands = capMod.pythonCandidates()
2892
+ const order = cands.map((c) => (c.source.startsWith('env') ? 'env' : c.source.startsWith('installed') ? 'installed' : 'carrier'))
2893
+ ok('§56 候选顺序:env 缝(若设置)> 安装副本(`dsh-runtimes/dsh-primary-runtime`)> carrier(`resources/runtime`)',
2894
+ // 顺序要**单调**;carrier 只在桌面端存在、env 只在 SDK/容器部署设置 ⇒ 不要求三者齐全。
2895
+ cands.length >= 1
2896
+ && cands.every((c, i) => i === 0 || ({ env: 0, installed: 1, carrier: 2 })[order[i - 1]] <= ({ env: 0, installed: 1, carrier: 2 })[order[i]])
2897
+ && order[0] === ((process.env.DSH_PRIMARY_RUNTIME || process.env.DSH_BUNDLED_PRIMARY_RUNTIME) ? 'env' : 'installed')
2898
+ && cands.every((c) => typeof c.root === 'string' && typeof c.node === 'string'),
2899
+ order.join(' > '))
2900
+ const bundled = capMod.findBundledPython()
2901
+ const found = (await import('../lib/pptxPy.js')).findPython()
2902
+ ok('§56 解释器优先级:探测到捆绑 Python 就必须优先用它(`source=bundled:…`),否则才退回 PATH',
2903
+ bundled === null
2904
+ ? found.source !== 'bundled'
2905
+ : (found.has === true && found.cmd === bundled.path && String(found.source).startsWith('bundled:')),
2906
+ `bundled=${bundled?.path ?? '(无)'}|findPython=${JSON.stringify(found)}`)
2907
+ const fakeNone = await capMod.probeOfficeSkills({ get: () => undefined })
2908
+ const fakeAll = await capMod.probeOfficeSkills({
2909
+ get: (k) => (k === 'skills' ? { get: async () => ({ provider: 'dsh-office', source: 'bundled', content: 'x' }) } : undefined),
2910
+ })
2911
+ ok('§56 官方 office 技能探测:服务缺失 ⇒ available=false(带原因);三本都在 ⇒ available=true',
2912
+ fakeNone.available === false && typeof fakeNone.reason === 'string' && fakeNone.found.length === 0
2913
+ && fakeAll.available === true && fakeAll.found.length === 3,
2914
+ `无服务=${fakeNone.available}(${String(fakeNone.reason).slice(0, 24)})|齐全=${fakeAll.available}(${fakeAll.found.join(',')})`)
2915
+ // resources 上溯:桌面端里插件可能跑在深层子进程 node 中 ⇒ 必须能从任意深度找回 `<install>/resources`。
2916
+ // 用一棵合成目录树验证(与真实安装无关,机器无关)。
2917
+ const synth = join(smokeDir, '.tmp-resources-probe')
2918
+ await rm(synth, { recursive: true, force: true })
2919
+ const deep = join(synth, 'install', 'resources', 'runtime', 'primary-runtime', 'dependencies', 'node', 'bin')
2920
+ await mkdir(deep, { recursive: true })
2921
+ await writeFile(join(synth, 'install', 'resources', 'app.asar'), 'x', 'utf8')
2922
+ await writeFile(join(deep, 'node.exe'), 'x', 'utf8')
2923
+ const foundRes = capMod.resourcesFrom(deep)
2924
+ ok('§56 resources 上溯:从深层子进程 node 的目录能找回 `<install>/resources`(以 app.asar 为锚点)',
2925
+ foundRes === join(synth, 'install', 'resources'),
2926
+ `${String(foundRes).replace(synth, '<合成树>')}`)
2927
+ await rm(synth, { recursive: true, force: true })
2928
+ // 官方 QA 通道(②):用**合成资产树 + 桩 checker**验证契约(与桌面端是否安装无关,机器无关)
2929
+ const qaMod = await import('../lib/office-qa.js')
2930
+ const qaSynth = join(smokeDir, '.tmp-qa-probe')
2931
+ await rm(qaSynth, { recursive: true, force: true })
2932
+ const qaSkills = join(qaSynth, 'runtime', 'office-skills')
2933
+ await mkdir(join(qaSkills, 'scripts'), { recursive: true })
2934
+ await writeFile(join(qaSynth, 'app.asar'), 'x', 'utf8') // 让 resourcesFrom 认这棵树
2935
+ const prevRes = process.env.PPT_STUDIO_RESOURCES_DIR
2936
+ process.env.PPT_STUDIO_RESOURCES_DIR = qaSynth
2937
+ const anyPy = capMod.findAnyPython()
2938
+ const qaDummy = join(qaSynth, 'dummy.pptx')
2939
+ await writeFile(qaDummy, 'not-a-real-pptx', 'utf8') // 桩 checker 不读内容,但我们的入口会先做存在性检查
2940
+ if (anyPy !== null) {
2941
+ await writeFile(join(qaSkills, 'scripts', 'check_office.py'),
2942
+ 'import json\nprint(json.dumps({"format":"pptx","verdict":"pass","checks":[],"summary":{"slides":3}}))\n', 'utf8')
2943
+ const qa1 = qaMod.runOfficeStructureCheck(qaDummy, { count: 3 })
2944
+ ok('§56 官方 QA:有资产 + 有 Python 时真的跑 `check_office.py` 并解析 JSON 判定(pass ⇒ ok,带回 summary)',
2945
+ qa1.available === true && qa1.ok === true && qa1.summary?.slides === 3, JSON.stringify(qa1).slice(0, 150))
2946
+ await writeFile(join(qaSkills, 'scripts', 'check_office.py'),
2947
+ 'import json\nprint(json.dumps({"format":"pptx","verdict":"fail","checks":[{"id":"package","status":"fail","detail":"boom"}]}))\n', 'utf8')
2948
+ const qa2 = qaMod.runOfficeStructureCheck(qaDummy, { count: 3 })
2949
+ ok('§56 官方 QA:`verdict=fail` 必须判失败,并把失败项带回(不吞诊断)',
2950
+ qa2.available === true && qa2.ok === false && /boom/.test(String(qa2.reason)), String(qa2.reason).slice(0, 150))
2951
+ } else {
2952
+ const qa0 = qaMod.runOfficeStructureCheck(qaDummy, {})
2953
+ ok('§56 官方 QA:本机没有任何 Python ⇒ 明确报"不可用 + 原因"(不假装跑过)', qa0.available === false && qa0.reason.length > 0, String(qa0.reason).slice(0, 90))
2954
+ ok('§56 官方 QA:无 Python 时 `officeQaStatus()` 也不该声称可用', qaMod.officeQaStatus().available === false, JSON.stringify(qaMod.officeQaStatus()))
2955
+ }
2956
+ const qa3 = qaMod.runLibreOfficeRender(qaDummy, join(qaSynth, 'out'))
2957
+ ok('§56 官方 QA:资产树里没有 LibreOffice kit ⇒ 渲染通道明确报"不可用 + 原因",不假装跑过',
2958
+ qa3.available === false && typeof qa3.reason === 'string' && qa3.reason.length > 0, String(qa3.reason).slice(0, 90))
2959
+ if (prevRes === undefined) delete process.env.PPT_STUDIO_RESOURCES_DIR
2960
+ else process.env.PPT_STUDIO_RESOURCES_DIR = prevRes
2961
+ await rm(qaSynth, { recursive: true, force: true })
2962
+
2963
+ const captured56 = new Map()
2964
+ const idxMod56 = await import('../lib/index.js')
2965
+ idxMod56.statusToolFor({ effect: (cb) => { cb(); return () => {} }, tools: { register: (d) => captured56.set(d.name, d) }, get: () => undefined }, 'profile')
2966
+ const out56 = JSON.parse(await captured56.get('ppt_state').execute({}, { agent: { session: { id: 'probe-56' } } }))
2967
+ ok('§56 ppt_state:一次看全"装配作用域 + 隔离是否生效 + 能力(含 office 技能)+ 路径"',
2968
+ out56.assembly?.isolation !== undefined && out56.capabilities?.platform !== undefined
2969
+ && out56.capabilities?.officeSkills?.available !== undefined && out56.paths?.pptStudio !== undefined,
2970
+ `assembly=${out56.assembly?.isolation}|capabilities=${Object.keys(out56.capabilities ?? {}).join(',')}`)
2971
+ }
2972
+
2973
+ // ── 57. 成品内容自证(2026-09-26 第二轮 启发 A):从**产物**反推"该写的字/该画的图形在不在" ──────
2974
+ // 为什么必须补:此前 parity 只有计数(表/图/线/媒体)+ audit 的页数/尺寸/字号 ⇒
2975
+ // "预览层对、成品层丢字/串行/表格漏格"这一类抓不到;图表更是**一条 parity 都没有**。
2976
+ {
2977
+ const work57 = join(smokeDir, '.tmp-content-proof')
2978
+ await rm(work57, { recursive: true, force: true })
2979
+ await mkdir(join(work57, 'pages'), { recursive: true })
2980
+ await writeFile(join(work57, 'deck.yaml'), ['version: 1', 'title: content-proof', 'size: [960, 540]', 'theme:',
2981
+ ' colors: {primary: "#2563EB", text: "#1F2937"}', ' textStyles:',
2982
+ ' body: {fontSize: 16, color: "$text"}', 'pages:', ' - pages/01.yaml', ''].join('\n'), 'utf8')
2983
+ await writeFile(join(work57, 'pages', '01.yaml'), ['pageType: content', 'elements:',
2984
+ ' - elementId: t1', ' elementType: text', ' bounds: [40, 40, 700, 60]',
2985
+ ' content: {text: "第一行 <a&b> \\"引号\\"", style: "$body"}',
2986
+ ' - elementId: tb', ' elementType: table', ' bounds: [40, 120, 400, 90]',
2987
+ ' cols: ["指标", "值"]', ' rows: [["营收 <2026>", "1,234"], ["毛利 & 净利", "56%"]]',
2988
+ ' - elementId: ch57', ' elementType: chart', ' bounds: [40, 240, 500, 200]',
2989
+ ' chart: {type: bar, data: {cols: ["季度", "值"], rows: [["Q1", 12], ["Q2", 18]]}}',
2990
+ ' - elementId: t2', ' elementType: text', ' bounds: [560, 40, 360, 40]',
2991
+ ' content: {text: "第二行\\n第三行", style: "$body"}', ''].join('\n'), 'utf8')
2992
+ const r57 = await exportPptx(await resolveDeck(work57), { out: join(work57, 'out.pptx') })
2993
+ const p57 = r57.parity ?? {}
2994
+ ok('§57 成品内容自证:文本/表格内容在产物里**逐条**能反查到(含 <>& 引号与 \\n 多行)——"预览对、成品丢字"这类抓得到',
2995
+ (p57.textExp ?? 0) >= 8 && (p57.textMissing ?? ['x']).length === 0 && p57.ok === true,
2996
+ `textExp=${p57.textExp}|缺失=${JSON.stringify(p57.textMissing ?? null)}|ok=${p57.ok}`)
2997
+ // 默认走原生(用户 2026-09-26 拍板):包里必须出现图表部件 + 内嵌数据工作簿,且数据与 deck **逐格**一致
2998
+ ok('§57 成品内容自证:图表默认 = **原生可编辑图表部件**(`ppt/charts/*.xml` + 内嵌数据工作簿),数据与 deck 逐格一致',
2999
+ p57.chartsExp === 1 && p57.chartsOut === 1 && p57.chartEmbedsOut === 1 && p57.chartSheetsChecked === 1
3000
+ && (p57.chartDataMismatch ?? ['x']).length === 0 && (p57.chartRelsMissing ?? ['x']).length === 0 && p57.ok === true,
3001
+ `部件=${p57.chartsOut}/${p57.chartsExp}|工作簿=${p57.chartEmbedsOut}|已比对工作表=${p57.chartSheetsChecked}|数据不符=${JSON.stringify(p57.chartDataMismatch ?? null)}`)
3002
+ // 显式 render: vector 的降级路必须**还在**(形状计数自证不丢,且该页不产图表部件)
3003
+ await writeFile(join(work57, 'pages', '02.yaml'), ['pageType: content', 'elements:',
3004
+ ' - elementId: vbar', ' elementType: chart', ' bounds: [40, 40, 500, 300]',
3005
+ ' chart: {type: bar, data: {cols: ["季度", "值"], rows: [["Q1", 12], ["Q2", 18]]}, render: vector}', ''].join('\n'), 'utf8')
3006
+ const deckText57 = await (await import('node:fs/promises')).readFile(join(work57, 'deck.yaml'), 'utf8')
3007
+ await writeFile(join(work57, 'deck.yaml'), deckText57.replace(' - pages/01.yaml', ' - pages/01.yaml\n - pages/02.yaml'), 'utf8')
3008
+ const r57v = await exportPptx(await resolveDeck(work57), { out: join(work57, 'out-v.pptx') })
3009
+ const p57v = r57v.parity ?? {}
3010
+ ok('§57 成品内容自证:`chart.render=vector` 仍走矢量拼绘(形状计数自证不丢,且该页不产图表部件)',
3011
+ p57v.chartShapesExp === 2 && p57v.chartShapesOut === 2 && p57v.chartsOut === 1 && r57v.slides === 2,
3012
+ `形状=${p57v.chartShapesOut}/${p57v.chartShapesExp}|全册图表部件=${p57v.chartsOut}(只第 1 页有原生)|页数=${r57v.slides}`)
3013
+ await rm(work57, { recursive: true, force: true })
3014
+ }
3015
+
2698
3016
  // 37.10 【必须是最后一条断言】引用计数自证:文档里 "smoke … N 断言" 必须等于本次真实断言总数。
2699
3017
  // 历史形状:加断言后 README×3 + docs/02 + docs/06×2 + 手册 全靠人工同步,迟早漏一处。
2700
3018
  // 只扫"当前状态"文档(README / 技术报告 / 评审测试矩阵 / 使用手册);docs/01/03/04 里的历史数字是记录,不动。
@@ -2721,5 +3039,11 @@ for (const rel of countFiles) {
2721
3039
  ok('文档计数自证:所有"smoke … N 断言 / N/N"都等于本次真实断言数(加断言必须同步 6 处引用)',
2722
3040
  staleCounts.length === 0, staleCounts.length ? `过期引用:${staleCounts.join('、')}` : `全部 = ${liveTotal}`)
2723
3041
 
3042
+ // 收尾:恢复 DSH_HOME 并清掉整轮隔离目录(用户层产物都在里面,见文件顶部的全局隔离说明)。
3043
+ try { await rm(SMOKE_HOME, { recursive: true, force: true }) } catch { /* 忽略 */ }
3044
+ try { await rm(join(smokeDir, '.tmp-home-probe'), { recursive: true, force: true }) } catch { /* 忽略 */ }
3045
+ if (PREV_SMOKE_HOME === undefined) delete process.env.DSH_HOME
3046
+ else process.env.DSH_HOME = PREV_SMOKE_HOME
3047
+
2724
3048
  console.log(`\n==== 结果:${pass} 通过 / ${fail} 失败 ====`)
2725
3049
  process.exit(fail > 0 ? 1 : 0)