dsh-ppt-studio 1.1.0 → 1.1.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.md +37 -26
- package/cordis.patch.yml +5 -3
- package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +5 -1
- package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +138 -0
- package/docs/04-/350/267/257/347/272/277/345/233/276/344/270/216/351/207/214/347/250/213/347/242/221.md +11 -2
- package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +28 -2
- package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +6 -3
- package/examples/smoke/imported/source.pptx +0 -0
- package/examples/smoke/out-smoke.pptx +0 -0
- package/lib/commands.js +12 -11
- package/lib/index.js +32 -2
- package/lib/pptd/export-pptx.js +45 -4
- package/lib/preview-server.js +67 -16
- package/lib/scaffold.js +1 -1
- package/lib/templates.js +5 -2
- package/lib/tools.js +24 -7
- package/lib/verify.js +12 -1
- package/package.json +2 -2
- package/scripts/install-desktop.mjs +7 -2
- package/scripts/install.mjs +4 -0
- package/scripts/smoke.mjs +215 -2
- package/skills/ppt-studio-manual/SKILL.md +2 -2
package/scripts/smoke.mjs
CHANGED
|
@@ -1135,10 +1135,15 @@ const routeCalls = []
|
|
|
1135
1135
|
const fakePrefixes = new Map()
|
|
1136
1136
|
const fakeWs = {
|
|
1137
1137
|
prefixes: fakePrefixes, // 真实语义:webServer 暴露前缀表(hasRoute 判据)
|
|
1138
|
-
|
|
1138
|
+
// 【2026-09-26 修正:假对象曾给真 bug 背书】真契约是**同步**的(DSH 0.1.7-rc.2
|
|
1139
|
+
// `dsh-host-webserver/lib/index.js:177-184`):成功返回 disposer **函数**,重复注册**同步抛错**。
|
|
1140
|
+
// 旧假对象写成 `async`(返回 Promise),于是"对返回值调 .then"在真宿主上 TypeError、整个插件条目
|
|
1141
|
+
// 激活失败(桌面端实测),而在这里全绿。见 docs/05「假对象必须复刻真契约」。
|
|
1142
|
+
register: (r) => {
|
|
1143
|
+
if (fakePrefixes.has(r.path)) throw new Error(`webserver: duplicate ${r.kind} route "${r.path}"`)
|
|
1139
1144
|
routeCalls.push(r.path)
|
|
1140
1145
|
fakePrefixes.set(r.path, { kind: r.kind, path: r.path, handler: r.handler })
|
|
1141
|
-
return
|
|
1146
|
+
return () => { fakePrefixes.delete(r.path) } // 同步 disposer(真契约)
|
|
1142
1147
|
},
|
|
1143
1148
|
}
|
|
1144
1149
|
// Cordis 语义(2026-09-14 用真库探针实测,见 docs/02 §2.12):`ctx.effect(cb)` **立即执行 cb**,
|
|
@@ -2482,6 +2487,214 @@ ok('制作手册零指向答疑手册:craft/data/copy 正文不出现 `ppt-stu
|
|
|
2482
2487
|
crossBookPointers.length === 0,
|
|
2483
2488
|
crossBookPointers.length ? `仍指向:${crossBookPointers.join('、')}` : '三本均无指向')
|
|
2484
2489
|
|
|
2490
|
+
// ── 51. npm 全自动发布通道(2026-09-26:Trusted Publishing / OIDC)─────────────────────
|
|
2491
|
+
// 要防的事故形状:本包的发布件是 Release 资产 tgz,而「registry 字节 == Release 资产字节」是不变量。
|
|
2492
|
+
// 谁把 CI 改成重新打包、去掉 id-token、注入传统 npm 凭据、或退回 stage-only,都会**静默**破坏这条通道——
|
|
2493
|
+
// 症状只在下一次发版时出现(而且那时已经晚了:npm 的版本不可覆盖)。所以把前提钉成机器断言。
|
|
2494
|
+
const publishYml = readFileSync(join(root, '.github', 'workflows', 'publish.yml'), 'utf8')
|
|
2495
|
+
ok('npm 发布通道:`.github/workflows/publish.yml` 由 release(published) 触发,并留 workflow_dispatch 兜底',
|
|
2496
|
+
/types:\s*\[published\]/.test(publishYml) && /^\s*workflow_dispatch:/m.test(publishYml),
|
|
2497
|
+
/types:\s*\[published\]/.test(publishYml) ? '触发 = release:published + 手动兜底' : '缺少 release:published 触发')
|
|
2498
|
+
// 断言口径:禁的是"在 YAML 里给它赋值"(`NODE_AUTH_TOKEN: …`)——注释里出现这个名字是在解释为什么不能赋值,不算违规。
|
|
2499
|
+
const noInjectedToken = !/^\s*-?\s*NODE_AUTH_TOKEN\s*[:=]/m.test(publishYml) && /unset NODE_AUTH_TOKEN/.test(publishYml) && !/^\s*registry-url\s*:/m.test(publishYml)
|
|
2500
|
+
ok('npm 发布通道:OIDC 前提齐(id-token: write),且不注入 npm 长期凭据(不写 NODE_AUTH_TOKEN、不给 setup-node 传 registry-url、发布前 unset 兜底)',
|
|
2501
|
+
/id-token:\s*write/.test(publishYml) && noInjectedToken,
|
|
2502
|
+
`id-token: write=${/id-token:\s*write/.test(publishYml)}|无注入凭据=${noInjectedToken}|registry-url=${/registry-url\s*:/.test(publishYml)}`)
|
|
2503
|
+
ok('npm 发布通道:全自动档用 `npm publish`(不是 stage-only),且带 npm >= 11.15.0 门禁',
|
|
2504
|
+
/npm publish/.test(publishYml) && !/npm stage publish/.test(publishYml) && /11\.15\.0/.test(publishYml),
|
|
2505
|
+
`npm publish=${/npm publish/.test(publishYml)}|stage-only=${/npm stage publish/.test(publishYml)}|版本门禁=${/11\.15\.0/.test(publishYml)}`)
|
|
2506
|
+
const newestAsset = /sort_by\(\.createdAt\)\s*\|\s*last/.test(publishYml)
|
|
2507
|
+
ok('npm 发布通道:发布的是**下载下来的 Release 资产**(等 .tgz + 按上传时间取最新 + gh release download),CI 里不得二次打包',
|
|
2508
|
+
/endswith\("\.tgz"\)/.test(publishYml) && newestAsset && /gh release download/.test(publishYml) && !/npm pack/.test(publishYml),
|
|
2509
|
+
`等资产=${/endswith\("\.tgz"\)/.test(publishYml)}|取最新=${newestAsset}|下载资产=${/gh release download/.test(publishYml)}|二次打包=${/npm pack/.test(publishYml)}`)
|
|
2510
|
+
|
|
2511
|
+
// ── 52. 真实 webServer 契约(2026-09-26 桌面端事故的回归)───────────────────────────────
|
|
2512
|
+
// 宿主警告原文(用户桌面端实测):"1 entry did not activate ppt-studio (dsh-ppt-studio):
|
|
2513
|
+
// TypeError: ws.register(...).then is not a function at registerPreviewRoute"。
|
|
2514
|
+
// 机理:真库 register 是**同步**的(返回 disposer 函数),而历史实现对返回值调 .then;异常从 apply 同步
|
|
2515
|
+
// 抛出 ⇒ 整个插件条目激活失败,用户连 ppt_state 都调不到。两条防线各钉一条断言。
|
|
2516
|
+
{
|
|
2517
|
+
delete globalThis.__pptRouteReg
|
|
2518
|
+
const dupPrefixes = new Map()
|
|
2519
|
+
const dupWs = { prefixes: dupPrefixes, register: () => { throw new Error('webserver: duplicate prefix route "/ppt-preview"') } }
|
|
2520
|
+
const dupCtx = { get: () => dupWs, effect: (cb) => { const c = cb(); return () => { if (typeof c === 'function') c() } } }
|
|
2521
|
+
let dupThrew = null
|
|
2522
|
+
let dupDisposer = null
|
|
2523
|
+
try { dupDisposer = rpr(dupCtx) } catch (e) { dupThrew = e }
|
|
2524
|
+
let dupDisposeOk = true
|
|
2525
|
+
try { dupDisposer?.() } catch { dupDisposeOk = false }
|
|
2526
|
+
ok('§52 预览路由:`webServer.register` **同步抛错**(重复注册)时不得把异常扔出,返回的 disposer 可安全调用',
|
|
2527
|
+
dupThrew === null && typeof dupDisposer === 'function' && dupDisposeOk,
|
|
2528
|
+
`threw=${dupThrew ? String(dupThrew.message) : 'null'}|disposer=${typeof dupDisposer}|dispose 安全=${dupDisposeOk}`)
|
|
2529
|
+
|
|
2530
|
+
// 反向:连 `ctx.get('webServer')` 都抛错(最坏情况)时,apply 仍必须完成能力面装配。
|
|
2531
|
+
const indexMod2 = await import('../lib/index.js')
|
|
2532
|
+
let pTools = 0, pCmds = 0, pSkills = 0
|
|
2533
|
+
const pSkillsSvc = { register: () => { pSkills++ } }
|
|
2534
|
+
const pRegistry = { register: () => Promise.resolve(async () => {}) }
|
|
2535
|
+
const pipeCtx = {
|
|
2536
|
+
tools: { register: () => { pTools++ } },
|
|
2537
|
+
commands: { register: () => { pCmds++ } },
|
|
2538
|
+
skills: pSkillsSvc,
|
|
2539
|
+
get: (k) => {
|
|
2540
|
+
if (k === 'webServer') throw new Error('webserver: get 抛错(最坏情况)')
|
|
2541
|
+
return k === 'skills' ? pSkillsSvc : k === 'agentPresets' ? pRegistry : undefined
|
|
2542
|
+
},
|
|
2543
|
+
agentPresets: pRegistry,
|
|
2544
|
+
// 最坏情况:连 inject 都抛错(webServer 这一路)——装配仍必须完成
|
|
2545
|
+
inject: (deps, fn) => {
|
|
2546
|
+
if (deps.includes('webServer')) throw new Error('inject 抛错(最坏情况)')
|
|
2547
|
+
if (deps.includes('agentPresets')) fn({ get: (k) => (k === 'agentPresets' ? pRegistry : undefined) })
|
|
2548
|
+
},
|
|
2549
|
+
on: () => {},
|
|
2550
|
+
effect: (fn) => { fn(); return () => {} },
|
|
2551
|
+
logger: () => ({ info: () => {}, warn: () => {} }),
|
|
2552
|
+
}
|
|
2553
|
+
delete globalThis.__pptCoreReg
|
|
2554
|
+
delete globalThis.__pptRouteReg
|
|
2555
|
+
let applyThrew = null
|
|
2556
|
+
try { indexMod2.apply(pipeCtx, { presetIds: ['ppt'] }) } catch (e) { applyThrew = e }
|
|
2557
|
+
ok('§52 装配韧性:全局管道抛错时 apply 仍完成装配(22 工具 / 命令面 / 4 技能)——附加能力坏掉只该降级',
|
|
2558
|
+
applyThrew === null && pTools === 22 && pCmds >= 1 && pSkills === 4,
|
|
2559
|
+
`threw=${applyThrew ? String(applyThrew.message) : 'null'}|tools=${pTools} cmds=${pCmds} skills=${pSkills}`)
|
|
2560
|
+
|
|
2561
|
+
// ③ 真宿主实测的组合顺序:webServer 比本插件**晚激活**。此时直接 `ctx.get('webServer')` 拿不到服务,
|
|
2562
|
+
// 路由会被静默跳过(诊断原文 `预览路由=skipped(no webServer)`)⇒ 预览链接必然 404。
|
|
2563
|
+
// 修法 = 与 agentPresets 同一姿势走 `inject` 等它出现。这条断言钉住"最终真的挂上了"。
|
|
2564
|
+
const injPrefixes = new Map()
|
|
2565
|
+
const injWs = {
|
|
2566
|
+
prefixes: injPrefixes,
|
|
2567
|
+
register: (r) => { injPrefixes.set(r.path, r); return () => { injPrefixes.delete(r.path) } }, // 真契约:同步返回函数
|
|
2568
|
+
}
|
|
2569
|
+
const injChild = { get: (k) => (k === 'webServer' ? injWs : undefined), effect: (cb) => { const c = cb(); return () => { if (typeof c === 'function') c() } } }
|
|
2570
|
+
let iTools = 0, iCmds = 0, iSkills = 0
|
|
2571
|
+
const iSkillsSvc = { register: () => { iSkills++ } }
|
|
2572
|
+
const iRegistry = { register: () => Promise.resolve(async () => {}) }
|
|
2573
|
+
const injCtx = {
|
|
2574
|
+
tools: { register: () => { iTools++ } },
|
|
2575
|
+
commands: { register: () => { iCmds++ } },
|
|
2576
|
+
skills: iSkillsSvc,
|
|
2577
|
+
// 关键:本 ctx **查不到** webServer(模拟"服务还没注册")
|
|
2578
|
+
get: (k) => (k === 'skills' ? iSkillsSvc : k === 'agentPresets' ? iRegistry : undefined),
|
|
2579
|
+
agentPresets: iRegistry,
|
|
2580
|
+
inject: (deps, fn) => {
|
|
2581
|
+
if (deps.includes('webServer')) fn(injChild)
|
|
2582
|
+
else if (deps.includes('agentPresets')) fn({ get: (k) => (k === 'agentPresets' ? iRegistry : undefined) })
|
|
2583
|
+
},
|
|
2584
|
+
on: () => {},
|
|
2585
|
+
effect: (fn) => { fn(); return () => {} },
|
|
2586
|
+
logger: () => ({ info: () => {}, warn: () => {} }),
|
|
2587
|
+
}
|
|
2588
|
+
delete globalThis.__pptCoreReg
|
|
2589
|
+
delete globalThis.__pptRouteReg
|
|
2590
|
+
let injThrew = null
|
|
2591
|
+
try { indexMod2.apply(injCtx, { presetIds: ['ppt'] }) } catch (e) { injThrew = e }
|
|
2592
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
2593
|
+
ok('§52 预览路由:`webServer` 比插件晚激活时(真宿主实测的组合顺序)走 `inject` 仍要真的挂上路由',
|
|
2594
|
+
injThrew === null && iTools === 22 && injPrefixes.has('/ppt-preview'),
|
|
2595
|
+
`threw=${injThrew ? String(injThrew.message) : 'null'}|tools=${iTools}|路由已挂=${injPrefixes.has('/ppt-preview')}`)
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
// ── 53. 安装通道事实:npm 是**默认**通道(web 与桌面端都支持)────────────────────────────
|
|
2599
|
+
// 用户 2026-09-26 拍板:"把 npm 安装作为默认安装途径,实现一句话无脑安装"。
|
|
2600
|
+
// 这条事实同时在三处出现(README=用户读 / docs/05=维护者读 / 答疑手册=模型读),这里钉住口径一致:
|
|
2601
|
+
// README 的第一条安装命令必须是 npm 包名,且要排在归档资产 URL **之前**;手册用同一条命令(`<p>` 占位)。
|
|
2602
|
+
{
|
|
2603
|
+
const readmeAuth = readFileSync(join(root, 'README.md'), 'utf8')
|
|
2604
|
+
const npmCmd = 'dsh plugin --profile web add dsh-ppt-studio'
|
|
2605
|
+
const manualCmd = 'dsh plugin --profile <p> add dsh-ppt-studio'
|
|
2606
|
+
const npmAt = readmeAuth.indexOf(npmCmd)
|
|
2607
|
+
const assetAt = readmeAuth.indexOf('dsh plugin --profile web add <粘贴那条资产 URL>')
|
|
2608
|
+
const manualText53 = readFileSync(join(root, 'skills', 'ppt-studio-manual', 'SKILL.md'), 'utf8')
|
|
2609
|
+
const docs05Text = readFileSync(join(root, 'docs', '05-迭代流程.md'), 'utf8')
|
|
2610
|
+
ok('§53 安装通道:README 以 **npm 包名**为第一条安装命令(排在归档资产 URL 之前),答疑手册与 docs/05 同口径',
|
|
2611
|
+
npmAt > 0 && assetAt > npmAt && manualText53.includes(manualCmd) && docs05Text.includes(npmCmd),
|
|
2612
|
+
`README npm 位置=${npmAt}|资产 URL 位置=${assetAt}|手册=${manualText53.includes(manualCmd)}|docs/05=${docs05Text.includes(npmCmd)}`)
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
// ── 54. 媒体链路:预览与成品必须对齐(2026-09-26 审计修复的三处真缺陷)──────────────────────
|
|
2616
|
+
// ① 不同目录**同名**媒体:旧实现 addMedia 按完整路径去重、但包内部件名与 rels Target 都取 basename
|
|
2617
|
+
// ⇒ 两张不同的图塌成同一个部件(后写覆盖先写)——**成品静默用错图**,而图数量 parity 依旧 2/2、ok=true。
|
|
2618
|
+
// ② 非 ASCII/空格文件名:预览 handler 不解 percent-encoding ⇒ 图已拷进预览根却 404(导出正常)。
|
|
2619
|
+
// ③ `media/子目录/`:旧 buildPreview 把目录条目当文件 copyFile ⇒ EPERM 整体失败(不是"缺图")。
|
|
2620
|
+
// 另附带一条守门:ASCII 且唯一的部件名**必须保持原名**(既有工程产物零变化)。
|
|
2621
|
+
{
|
|
2622
|
+
const { registerTools: regTools54 } = await import('../lib/tools.js')
|
|
2623
|
+
const { previewFileFor } = await import('../lib/preview-server.js')
|
|
2624
|
+
const fsp = await import('node:fs/promises')
|
|
2625
|
+
const mlWork = join(smokeDir, '.tmp-media-link')
|
|
2626
|
+
await fsp.rm(mlWork, { recursive: true, force: true })
|
|
2627
|
+
await fsp.mkdir(join(mlWork, 'pages'), { recursive: true })
|
|
2628
|
+
await fsp.mkdir(join(mlWork, 'media', 'a'), { recursive: true })
|
|
2629
|
+
await fsp.mkdir(join(mlWork, 'media', 'b'), { recursive: true })
|
|
2630
|
+
const pngA = readFileSync(join(root, 'examples', 'smoke', 'media-test.png'))
|
|
2631
|
+
const pngB = readFileSync(join(root, 'templates', 'academic-white', 'preview.png'))
|
|
2632
|
+
await fsp.writeFile(join(mlWork, 'media', 'a', 'logo.png'), pngA)
|
|
2633
|
+
await fsp.writeFile(join(mlWork, 'media', 'b', 'logo.png'), pngB) // 同名、内容不同
|
|
2634
|
+
await fsp.writeFile(join(mlWork, 'media', '中文 图.png'), pngA) // 非 ASCII + 空格
|
|
2635
|
+
await fsp.writeFile(join(mlWork, 'media', 'plain.png'), pngA) // ASCII 且唯一 → 必须保持原名
|
|
2636
|
+
await fsp.writeFile(join(mlWork, 'deck.yaml'), ['version: 1', 'title: media-link', 'size: [960, 540]', 'theme:', ' colors: {primary: "#2563EB"}', 'pages:', ' - pages/01.yaml', ''].join('\n'))
|
|
2637
|
+
const imgEl = (id, x, src) => [` - elementId: ${id}`, ' elementType: image', ` bounds: [${x}, 40, 200, 200]`, ` src: ${src}`]
|
|
2638
|
+
await fsp.writeFile(join(mlWork, 'pages', '01.yaml'), ['pageType: content', 'elements:', ...imgEl('a', 20, 'media/a/logo.png'), ...imgEl('b', 240, 'media/b/logo.png'), ...imgEl('cn', 460, 'media/中文 图.png'), ...imgEl('plain', 680, 'media/plain.png'), ''].join('\n'))
|
|
2639
|
+
|
|
2640
|
+
const expML = await exportPptx(await resolveDeck(mlWork), { out: join(mlWork, 'out.pptx') })
|
|
2641
|
+
const zipML = zipRead(readFileSync(expML.file))
|
|
2642
|
+
const parts = [...zipML.keys()].filter((k) => k.startsWith('ppt/media/'))
|
|
2643
|
+
const bufOf = (k) => Buffer.from(zipML.get(k))
|
|
2644
|
+
const sameA = parts.filter((k) => bufOf(k).equals(pngA)).length
|
|
2645
|
+
const sameB = parts.filter((k) => bufOf(k).equals(pngB)).length
|
|
2646
|
+
const relsML = String(zipML.get('ppt/slides/_rels/slide1.xml.rels'))
|
|
2647
|
+
const targets = [...relsML.matchAll(/Target="\.\.\/media\/([^"]+)"/g)].map((m) => m[1])
|
|
2648
|
+
ok('§54 导出:不同目录**同名**媒体各占一个部件(不再一张图顶掉另一张),且 rels Target 与包内条目一一对应',
|
|
2649
|
+
parts.length === 4 && sameA === 3 && sameB === 1 && targets.length === 4 && targets.every((t) => zipML.has('ppt/media/' + t)),
|
|
2650
|
+
`部件=${JSON.stringify(parts)}|A内容×${sameA}|B内容×${sameB}|rels=${JSON.stringify(targets)}`)
|
|
2651
|
+
ok('§54 导出:ASCII 且唯一的部件名**保持原名**(既有产物零变化),改名只有 2 个且原因可见',
|
|
2652
|
+
parts.includes('ppt/media/plain.png') && parts.includes('ppt/media/logo.png')
|
|
2653
|
+
&& (expML.mediaRenamed ?? []).length === 2 && (expML.mediaPlaceholders ?? []).length === 0,
|
|
2654
|
+
`plain 原名=${parts.includes('ppt/media/plain.png')}|改名=${JSON.stringify(expML.mediaRenamed ?? [])}`)
|
|
2655
|
+
ok('§54 导出:媒体 parity 自证(不同 srcPath 数 == 包内媒体部件数)——这条才抓得到"两张图塌成一个部件"',
|
|
2656
|
+
expML.parity?.mediaExp === 4 && expML.parity?.mediaOut === 4 && expML.parity?.ok === true,
|
|
2657
|
+
`mediaExp=${expML.parity?.mediaExp} mediaOut=${expML.parity?.mediaOut} ok=${expML.parity?.ok}`)
|
|
2658
|
+
|
|
2659
|
+
// 预览:子目录必须被递归镜像;非 ASCII 名必须能解析(percent-decode)
|
|
2660
|
+
const pvML = await buildPreview(mlWork)
|
|
2661
|
+
const nested = existsSync(join(pvML.previewRoot, 'media', 'a', 'logo.png'))
|
|
2662
|
+
const cnCopied = existsSync(join(pvML.previewRoot, 'media', '中文 图.png'))
|
|
2663
|
+
const encoded = encodeURIComponent('media/中文 图.png').replace(/%2F/gi, '/')
|
|
2664
|
+
const pEnc = previewFileFor(pvML.previewRoot, encoded)
|
|
2665
|
+
const pNested = previewFileFor(pvML.previewRoot, 'media/a/logo.png')
|
|
2666
|
+
ok('§54 预览:`media/子目录/` 递归镜像 + 非 ASCII 名可服务(旧实现是 EPERM 整体失败 + 中文名 404)',
|
|
2667
|
+
nested && cnCopied && pEnc === join(pvML.previewRoot, 'media', '中文 图.png') && existsSync(pEnc) && existsSync(pNested),
|
|
2668
|
+
`子目录已镜像=${nested}|中文已镜像=${cnCopied}|编码路径解析=${existsSync(pEnc)}`)
|
|
2669
|
+
const trav1 = previewFileFor(pvML.previewRoot, 'media/../secret.txt')
|
|
2670
|
+
const trav2 = previewFileFor(pvML.previewRoot, '../outside.html')
|
|
2671
|
+
const malformed = previewFileFor(pvML.previewRoot, '%E4%B8%AD%ZZ.png')
|
|
2672
|
+
const dotted = previewFileFor(pvML.previewRoot, 'media/a..b.png')
|
|
2673
|
+
ok('§54 预览:`..` 越级与畸形 percent-encoding 仍被拦,而文件名里的 `..` 不被误杀(安全边界没被 decode 破坏)',
|
|
2674
|
+
trav1 === null && trav2 === null && malformed === null && dotted !== null,
|
|
2675
|
+
`media/../secret.txt=${trav1 === null ? '拦' : trav1}|../outside.html=${trav2 === null ? '拦' : trav2}|畸形=${malformed === null ? '拦' : malformed}|a..b.png=${dotted !== null ? '放行' : '误杀'}`)
|
|
2676
|
+
|
|
2677
|
+
// 门禁(局部):真跑 ppt_verify handler,验证"只审部分页"不再打印与整册通过同形的门禁行
|
|
2678
|
+
const gWork = join(smokeDir, '.tmp-verify-scope')
|
|
2679
|
+
await fsp.rm(gWork, { recursive: true, force: true })
|
|
2680
|
+
await fsp.mkdir(join(gWork, 'pages'), { recursive: true })
|
|
2681
|
+
await fsp.writeFile(join(gWork, 'deck.yaml'), ['version: 1', 'title: scope', 'size: [960, 540]', 'theme:', ' colors: {primary: "#2563EB"}', ' textStyles:', ' body: {fontSize: 16, color: "$primary"}', 'pages:', ' - pages/01.yaml', ' - pages/02.yaml', ''].join('\n'))
|
|
2682
|
+
await fsp.writeFile(join(gWork, 'pages', '01.yaml'), ['pageType: content', 'elements:', ' - elementId: bad', ' elementType: shape', ' kind: rect', ' bounds: [900, 520, 100, 60]', ' fill: "#2563EB"', ''].join('\n'))
|
|
2683
|
+
await fsp.writeFile(join(gWork, 'pages', '02.yaml'), ['pageType: content', 'elements:', ' - elementId: ok1', ' elementType: text', ' bounds: [40, 40, 300, 40]', ' content: {text: "干净页", style: "$body"}', ''].join('\n'))
|
|
2684
|
+
const captured = new Map()
|
|
2685
|
+
regTools54({ effect: (cb) => { cb(); return () => {} }, tools: { register: (def) => { captured.set(def.name, def) } }, get: () => undefined })
|
|
2686
|
+
const vTool = captured.get('ppt_verify')
|
|
2687
|
+
const fullOut = String(await vTool.execute({ dir: gWork }, {}))
|
|
2688
|
+
const partOut = String(await vTool.execute({ dir: gWork, pages: '2' }, {}))
|
|
2689
|
+
ok('§54 门禁(局部):pages= 只审部分页时门禁行必须带范围,不得与整册"✓ 通过"同形(工具自述要防的正是静默漏检)',
|
|
2690
|
+
/✗ \d+ 个错误/.test(fullOut)
|
|
2691
|
+
&& /门禁(局部):/.test(partOut) && /其余 1 页\*\*本次未检查\*\*/.test(partOut) && !/门禁:✓ 通过/.test(partOut),
|
|
2692
|
+
`整册=「${(fullOut.match(/门禁[^\n]*/) ?? [''])[0].slice(0, 60)}」|局部=「${(partOut.match(/门禁[^\n]*/) ?? [''])[0].slice(0, 80)}」`)
|
|
2693
|
+
|
|
2694
|
+
await fsp.rm(mlWork, { recursive: true, force: true })
|
|
2695
|
+
await fsp.rm(gWork, { recursive: true, force: true })
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2485
2698
|
// 37.10 【必须是最后一条断言】引用计数自证:文档里 "smoke … N 断言" 必须等于本次真实断言总数。
|
|
2486
2699
|
// 历史形状:加断言后 README×3 + docs/02 + docs/06×2 + 手册 全靠人工同步,迟早漏一处。
|
|
2487
2700
|
// 只扫"当前状态"文档(README / 技术报告 / 评审测试矩阵 / 使用手册);docs/01/03/04 里的历史数字是记录,不动。
|
|
@@ -97,8 +97,8 @@ DSH 上做 PPT 的工作区:说需求 → 四类任务工作流 → 「数字
|
|
|
97
97
|
## 7. 开发维护(仅改插件时用)
|
|
98
98
|
|
|
99
99
|
- 改码:`src/` → `node scripts/build.mjs` → **重启 host**(插件经 agent preset 会话装配;`dev_reload_package` 只覆盖注入器装配包,且注入器 junction 已存在时会指向旧安装根)。
|
|
100
|
-
- 回归:`node scripts/smoke.mjs`(
|
|
100
|
+
- 回归:`node scripts/smoke.mjs`(276 断言)→ `node scripts/preflight-1.0.mjs`(发布预检)→ `node scripts/regression-real.mjs`。
|
|
101
101
|
- 跨层验证纪律:**预览层与成品层必须互相验证**——`ppt_render`+`ppt_verify` 只管 HTML/估算层,OOXML 层靠 `ppt_export` 的 parity 自证(表/图/线方向)+ `ppt_visual` 真渲染抽检;只跑单层会漏掉"预览对、成品错"(2026-09-14 连线方向事故)。
|
|
102
102
|
- 文档链:改需求/决策 → docs/01;改机制 → docs/02;每次 → docs/03;验收 → docs/04;发布前 → docs/06。
|
|
103
|
-
-
|
|
103
|
+
- 装配:**默认装法是 npm 包名**(`dsh plugin --profile <p> add dsh-ppt-studio`,profile 级、随 profile 启动装配;**桌面端**在插件管理里填同一个包名 `dsh-ppt-studio`;GitHub Release 的资产 URL 与 `scripts/install*.mjs` 是离线/归档备选)。装完**必须重启 host**。排查:`dsh plugin --profile web list` → `dsh --profile web --dump-config` 找 `ppt-studio` 行。preset 插件行只在"离线 junction"场景用,与 bundle 装法**互斥**。
|
|
104
104
|
- 宿主升级(DSH 换版本):本手册 §开发维护 与 docs/05「宿主升级纪律」——契约逐项 Inspect 核对 + headless 覆盖层真进程验证,别只看单测。
|