dsh-soul 0.6.0 → 0.6.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 CHANGED
@@ -46,6 +46,24 @@ dsh plugin --profile web update dsh-soul
46
46
  name: dsh-soul
47
47
  ```
48
48
 
49
+ ## 兼容性
50
+
51
+ 插件不打包 DSH 运行时包,全部由宿主提供。DSH 会在装载前校验 `peerDependencies`,**比较对象是 DSH 运行时版本**(`@deepseek-ai/dsh-app-boot` 的 version),而不是这些包各自的版本:
52
+
53
+ | 包 | 范围 |
54
+ | --- | --- |
55
+ | `@deepseek-ai/dsh-llm` | `>=0.1.1-rc.2 <0.3.0-0` |
56
+ | `@deepseek-ai/dsh-tools` | `>=0.1.0-rc.6 <0.3.0-0` |
57
+ | `@deepseek-ai/cordis` | `^4.0.1 \|\| ^4.0.5-alpha.1` |
58
+
59
+ 即支持 DSH **0.1.x 与 0.2.x**(含 prerelease 版本)。可用 `npm run verify:compat` 在当前环境确认声明是否成立。
60
+
61
+ 若 DSH 提示「dsh-soul@x.y.z 与 DSH a.b.c 不兼容」,说明插件版本早于该 DSH 版本,升级插件即可:
62
+
63
+ ```powershell
64
+ dsh plugin --profile web update dsh-soul
65
+ ```
66
+
49
67
  ## 截图
50
68
 
51
69
  **设置页(关于你 + 特质)**
package/README_EN.md CHANGED
@@ -46,6 +46,24 @@ The plugin is registered via `cordis.patch.yml`:
46
46
  name: dsh-soul
47
47
  ```
48
48
 
49
+ ## Compatibility
50
+
51
+ The plugin ships no DSH runtime packages; the host provides them all. DSH validates `peerDependencies` before loading, comparing against the **DSH runtime version** (the version of `@deepseek-ai/dsh-app-boot`), not the individual packages' versions:
52
+
53
+ | Package | Range |
54
+ | --- | --- |
55
+ | `@deepseek-ai/dsh-llm` | `>=0.1.1-rc.2 <0.3.0-0` |
56
+ | `@deepseek-ai/dsh-tools` | `>=0.1.0-rc.6 <0.3.0-0` |
57
+ | `@deepseek-ai/cordis` | `^4.0.1 \|\| ^4.0.5-alpha.1` |
58
+
59
+ So DSH **0.1.x and 0.2.x** are supported, prereleases included. Run `npm run verify:compat` to check the declaration against your environment.
60
+
61
+ If DSH reports "dsh-soul@x.y.z is incompatible with DSH a.b.c", the plugin predates that DSH version — updating it is enough:
62
+
63
+ ```powershell
64
+ dsh plugin --profile web update dsh-soul
65
+ ```
66
+
49
67
  ## Screenshots
50
68
 
51
69
  **Settings page (About you + Traits)**
package/client/index.mjs CHANGED
@@ -47,7 +47,7 @@ window.__ModuleLoader__.load({
47
47
  // -------------------------------------------------------------------------
48
48
 
49
49
  const NS = 'soul'
50
- const VERSION = '0.6.0'
50
+ const VERSION = '0.6.2'
51
51
  const INITIAL = {
52
52
  enabled: true,
53
53
  nickname: '',
@@ -113,9 +113,22 @@ window.__ModuleLoader__.load({
113
113
  // (旋转 conic-gradient 的角速度恒定,但周长线速度会随位置变化,故不采用)。
114
114
  // 渐隐拖尾:8 层等长 dash 依次错位叠加、透明度递减 —— 头部各层重叠最亮,
115
115
  // 向后剩余层数递减形成拖尾;所有层共用同一时长,整体仍严格匀速。
116
- // 与边框重合:stroke 中心线落在容器边框盒边缘(rect 即 0,0,w,h),
117
- // 并靠 overflow:visible 让外半圈可见;光轨开启时隐藏宿主自身细边框,
116
+ // 与边框重合:stroke 中心线落在宿主边框盒边缘;外半圈由「外扩盒」容纳
117
+ // (见 TRAIL_PAD),不再依赖 overflow:visible;光轨开启时隐藏宿主自身细边框,
118
118
  // 保证边界上只有一条线。
119
+ //
120
+ // ⚠️ 隔离不变量(v0.6.2 修复 issue #2 的尺寸正反馈环):
121
+ // 环是「写在宿主内部、尺寸随宿主变化」的绝对定位元素。一旦它进入宿主布局流
122
+ // (例如类规则里的 position:absolute 被第三方皮肤/主题的更具体选择器或
123
+ // !important 覆盖),就会闭合成环:卡片变高 → ResizeObserver → 按新尺寸改写
124
+ // SVG → 元素自身再变高 → 卡片再变高 → …(实测 60 次 sync 即可把 134px 撑到 9202px)。
125
+ // 故本实现用三层独立手段保证它绝不参与宿主的任何尺寸测量:
126
+ // 1) 挂载点优先选卡片内那个 `position:absolute;height:0` 的 overlay 锚点 ——
127
+ // 锚点高度写死为 0,环即使退回常规流也撑不动卡片;
128
+ // 2) 关键几何(position/left/top/width/height/contain)全部内联 ——
129
+ // 内联样式只可能被 !important 击败,比单类选择器可靠得多;
130
+ // 3) contain:strict(含 size/layout/paint)+ 墨迹全部收进自身盒内,
131
+ // 尺寸、布局、绘制都与外部彻底隔离。
119
132
 
120
133
  const TRAIL_DASH = 12
121
134
  const TRAIL_LAYERS = 8
@@ -126,6 +139,13 @@ window.__ModuleLoader__.load({
126
139
  const TRAIL_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/
127
140
  const TRAIL_SPEED_VALUES = ['slow', 'normal', 'fast']
128
141
  const TRAIL_WIDTH_VALUES = ['thin', 'normal', 'thick']
142
+ // 最粗描边(4px)的一半:环的外盒比宿主边框盒每边外扩这么多,
143
+ // 好让描边的外半圈也落在 SVG 自己的盒内(contain:paint 会按盒裁切)。
144
+ // 这样就不需要 overflow:visible —— 墨迹不再外溢,不可能被祖先的 scrollHeight 计入。
145
+ const TRAIL_PAD = 2
146
+ // 失控熔断:输入框卡片不可能达到这个量级;命中即停止同步并隐藏,防止未知宿主
147
+ // 测高逻辑把环拖进任何残余的正反馈(正常路径下永不触发)。
148
+ const TRAIL_MAX_SIDE = 20000
129
149
 
130
150
  // 颜色容错:输入中途(如只打了 #6)回退到默认色,避免示例与提示丢失;
131
151
  // 统一大写,与宿主归一化结果及调色板取值一致
@@ -157,39 +177,156 @@ window.__ModuleLoader__.load({
157
177
  return svg
158
178
  }
159
179
 
160
- // 让环与容器像素尺寸一致(viewBox 用像素),圆角半径与宿主输入框卡片一致
161
- function sizeTrailSvg(svg, width, height) {
162
- if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return
163
- if (svg.dataset.trailWidth === String(width) && svg.dataset.trailHeight === String(height)) return
164
- svg.dataset.trailWidth = String(width)
165
- svg.dataset.trailHeight = String(height)
166
- svg.setAttribute('viewBox', `0 0 ${width} ${height}`)
180
+ // 环外盒相对挂载锚点的偏移。锚点的 padding box 原点与宿主的 padding box 原点重合,
181
+ // 因此左/上各再减「宿主边框宽度 + TRAIL_PAD」,才能把外盒对到宿主边框盒上。
182
+ function trailBoxOffset(host) {
183
+ let borderLeft = 0
184
+ let borderTop = 0
185
+ try {
186
+ const style = typeof globalThis.getComputedStyle === 'function' ? globalThis.getComputedStyle(host) : null
187
+ if (style) {
188
+ borderLeft = parseFloat(style.borderLeftWidth) || 0
189
+ borderTop = parseFloat(style.borderTopWidth) || 0
190
+ }
191
+ } catch {
192
+ // 取不到计算样式就按无边框处理(DSH 输入框卡片 border:0)
193
+ }
194
+ return { left: -borderLeft - TRAIL_PAD, top: -borderTop - TRAIL_PAD }
195
+ }
196
+
197
+ // 让环与宿主「边框盒」逐像素对齐:外盒 = 宿主边框盒 + TRAIL_PAD×2,
198
+ // viewBox 以像素为单位,描边中心线因此正好压在宿主边框盒边缘;
199
+ // 圆角半径与宿主输入框卡片一致。尺寸未变时不重复写(避免多余样式写入)。
200
+ function sizeTrailSvg(svg, host, width, height) {
201
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return false
202
+ const boxWidth = width + TRAIL_PAD * 2
203
+ const boxHeight = height + TRAIL_PAD * 2
204
+ const offset = trailBoxOffset(host)
205
+ const key = `${width}x${height}@${offset.left},${offset.top}`
206
+ if (svg.dataset.trailKey === key) return false
207
+ svg.dataset.trailKey = key
208
+ svg.style.left = `${offset.left}px`
209
+ svg.style.top = `${offset.top}px`
210
+ svg.style.width = `${boxWidth}px`
211
+ svg.style.height = `${boxHeight}px`
212
+ svg.setAttribute('viewBox', `0 0 ${boxWidth} ${boxHeight}`)
167
213
  for (const rect of svg.querySelectorAll('.soul-trail-layer')) {
168
- rect.setAttribute('x', '0')
169
- rect.setAttribute('y', '0')
214
+ rect.setAttribute('x', String(TRAIL_PAD))
215
+ rect.setAttribute('y', String(TRAIL_PAD))
170
216
  rect.setAttribute('width', String(width))
171
217
  rect.setAttribute('height', String(height))
172
218
  rect.setAttribute('rx', String(TRAIL_RADIUS))
173
219
  }
220
+ return true
221
+ }
222
+
223
+ // 在卡片内解析环的挂载点:必须是卡片内一个「零高度容器」——DSH composer 的
224
+ // .overlayAnchor 与设置页示例的 .soul-trail-anchor 都是这个形态。零高度容器
225
+ // 即便环退回常规流也撑不动卡片(issue #2 的第一层隔离),且其 padding box 原点
226
+ // 与卡片的 padding box 原点重合,环才能与卡片边框盒对齐。
227
+ // 两种落点:
228
+ // 1) anchor 本身就是卡片的直接子元素(设置页示例)→ 就是它;
229
+ // 2) anchor 渲染在卡片内的某个容器里(线上:conversation.input.overlay 槽位
230
+ // 把组件挂在 DSH 的 .overlayAnchor 内)→ 取「anchor 的、作为卡片直接子元素的祖先」。
231
+ // 判据只看 DOM 结构、不看计算样式:样式表失效恰恰是最需要零高挂载点的时刻,
232
+ // 那种场景下 anchor 的计算样式反而不可信。两者都不满足时退回卡片本身,
233
+ // 由内联 position/contain 兜底。
234
+ function resolveTrailMountPoint(card, anchor) {
235
+ if (anchor && anchor.parentElement === card) return anchor
236
+ const parent = anchor && anchor.parentElement
237
+ if (!parent) return card
238
+ let node = parent
239
+ while (node !== card && node.parentElement && node.parentElement !== card) node = node.parentElement
240
+ if (node !== card && node.parentElement === card) return node
241
+ return card
174
242
  }
175
243
 
176
- // 把环挂到宿主元素上并跟随其尺寸变化(输入框随内容增高时环不变形);返回卸载函数
177
- function mountTrailRing(host) {
244
+ // 把环挂到宿主卡片(内部锚点)上并跟随宿主尺寸变化;返回 { svg, setVisible, unmount }
245
+ function mountTrailRing(host, anchor) {
178
246
  const svg = buildTrailSvg()
179
- host.appendChild(svg)
180
- const sync = () => sizeTrailSvg(svg, host.clientWidth, host.clientHeight)
181
- sync()
247
+ // 关键几何内联:position 若被样式表覆盖,环会退回常规流并触发尺寸正反馈环,
248
+ // 后果是整页布局被撑爆,因此不能只依赖类规则(见渲染层「隔离不变量」注释)
249
+ svg.style.cssText = [
250
+ 'position:absolute',
251
+ 'display:none',
252
+ 'pointer-events:none',
253
+ 'contain:strict',
254
+ 'overflow:hidden',
255
+ 'margin:0',
256
+ 'padding:0',
257
+ 'border:0',
258
+ 'transform:none',
259
+ 'animation:none',
260
+ 'max-width:none',
261
+ 'max-height:none'
262
+ ].join(';')
263
+ ;(anchor || host).appendChild(svg)
264
+
265
+ let stopped = false
182
266
  let observer = null
267
+ let geometryChecked = false
268
+
269
+ // 停用并留下线索:与其画出巨大错位的图形或拖垮布局,不如安静退出
270
+ const disable = (reason) => {
271
+ stopped = true
272
+ svg.style.display = 'none'
273
+ if (observer) observer.disconnect()
274
+ if (typeof console !== 'undefined' && typeof console.warn === 'function') {
275
+ console.warn(`[dsh-soul] 已停用输入框光轨:${reason}`)
276
+ }
277
+ }
278
+
279
+ const sync = () => {
280
+ if (stopped) return
281
+ // 用 offsetWidth/Height(边框盒)而非 clientWidth/Height(内边距盒),
282
+ // 环才能与宿主边框盒对齐
283
+ const width = host.offsetWidth
284
+ const height = host.offsetHeight
285
+ if (width > TRAIL_MAX_SIDE || height > TRAIL_MAX_SIDE) {
286
+ disable('输入框尺寸异常(疑似尺寸正反馈)')
287
+ return
288
+ }
289
+ if (!sizeTrailSvg(svg, host, width, height)) return
290
+ if (geometryChecked) return
291
+ geometryChecked = true
292
+ // 自检:样式表若用更具体的选择器或 !important 把 position 改回常规流,
293
+ // 环就会重新参与宿主排版(这正是 issue #2 的成因),此时主动停用
294
+ if (typeof globalThis.getComputedStyle !== 'function') return
295
+ let position = 'absolute'
296
+ try {
297
+ position = globalThis.getComputedStyle(svg).position
298
+ } catch {
299
+ return
300
+ }
301
+ if (position !== 'absolute') disable(`光轨样式被第三方样式表覆盖(position: ${position})`)
302
+ }
303
+
304
+ sync()
183
305
  if (typeof ResizeObserver === 'function') {
184
306
  observer = new ResizeObserver(sync)
185
307
  observer.observe(host)
186
308
  } else if (typeof globalThis.addEventListener === 'function') {
187
309
  globalThis.addEventListener('resize', sync)
188
310
  }
189
- return () => {
190
- if (observer) observer.disconnect()
191
- else if (typeof globalThis.removeEventListener === 'function') globalThis.removeEventListener('resize', sync)
192
- svg.remove()
311
+ // 首帧同步就可能已判定停用(尺寸异常 / 样式被覆盖),此时不必再观察
312
+ if (stopped && observer) observer.disconnect()
313
+
314
+ // 显隐也走内联:不只依赖 `[data-soul-trail="off"]` 类规则,
315
+ // 样式表被覆盖时不至于把环留在页面上
316
+ const setVisible = (visible) => {
317
+ if (stopped) return
318
+ svg.style.display = visible ? 'block' : 'none'
319
+ }
320
+
321
+ return {
322
+ svg,
323
+ setVisible,
324
+ unmount: () => {
325
+ stopped = true
326
+ if (observer) observer.disconnect()
327
+ else if (typeof globalThis.removeEventListener === 'function') globalThis.removeEventListener('resize', sync)
328
+ svg.remove()
329
+ }
193
330
  }
194
331
  }
195
332
 
@@ -403,8 +540,11 @@ window.__ModuleLoader__.load({
403
540
  '.soul-toast-error{background:#fff1f0;border:1px solid #ffa39e;color:#f5222d}',
404
541
  '@keyframes fadeInOut{0%{opacity:0;transform:translate(-50%,-50%) scale(0.9)}15%{opacity:1;transform:translate(-50%,-50%) scale(1)}85%{opacity:1;transform:translate(-50%,-50%) scale(1)}100%{opacity:0;transform:translate(-50%,-50%) scale(0.9)}}',
405
542
  // —— 输入框光轨 ——
406
- // 环挂在卡片上:stroke 中心线即卡片边缘,外半圈靠 overflow:visible 显示
407
- '.soul-trail-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none}',
543
+ // 几何规则(position/尺寸/contain)同时内联在元素上,见 mountTrailRing;
544
+ // 这里保留一份等价声明作为兜底,并让样式可读、可被第三方主题安全覆盖(仅颜色/速度/粗细)
545
+ '.soul-trail-svg{position:absolute;display:block;pointer-events:none;contain:strict;overflow:hidden;margin:0;padding:0;border:0}',
546
+ // 设置页示例用的零高度挂载锚点:只定位占坑,不参与任何尺寸计算
547
+ '.soul-trail-anchor{position:absolute;inset:0 0 auto;height:0;pointer-events:none}',
408
548
  // 速度档位(整体比首版慢一档):normal 3.6s / slow 4.8s / fast 2.4s
409
549
  '.soul-trail-layer{fill:none;stroke:var(--soul-trail-color,#679EFE);stroke-width:2.5;stroke-linecap:butt;animation-name:soul-trail-run-0;animation-duration:3.6s;animation-timing-function:linear;animation-iteration-count:infinite}',
410
550
  '.soul-trail-layer[data-layer="0"]{opacity:1;stroke-linecap:round}',
@@ -427,6 +567,7 @@ window.__ModuleLoader__.load({
427
567
  '[data-soul-trail-speed="fast"] .soul-trail-layer{animation-duration:2.4s}',
428
568
  '[data-soul-trail-width="thin"] .soul-trail-layer{stroke-width:1.5}',
429
569
  '[data-soul-trail-width="thick"] .soul-trail-layer{stroke-width:4}',
570
+ // 显隐的兜底规则(主路径由 mountTrailRing 的 setVisible 写内联 display)
430
571
  '[data-soul-trail="off"] .soul-trail-svg{display:none}',
431
572
  // 光轨开启时隐藏宿主输入框自身的细边框(保留 1px 占位,避免布局跳动),
432
573
  // 使边界上只存在光轨一条线
@@ -1266,18 +1407,31 @@ window.__ModuleLoader__.load({
1266
1407
  // 输入框光轨:组件
1267
1408
  // -------------------------------------------------------------------------
1268
1409
 
1269
- // 设置页内的实时示例:与线上光轨共用同一渲染层与 CSS,
1270
- // 因此「示例所见」即「回复时所得」;颜色/速度/粗细随表单实时联动。
1410
+ // 设置页内的实时示例:与线上光轨共用同一渲染层、同一挂载点解析(resolveTrailMountPoint)
1411
+ // 与同一份 CSS,因此「示例所见」即「回复时所得」;颜色/速度/粗细随表单实时联动。
1271
1412
  function SoulTrailPreview(props) {
1272
1413
  const t = typeof props.t === 'function' ? props.t : FALLBACK_T
1273
1414
  const hostRef = React.useRef(null)
1415
+ const anchorRef = React.useRef(null)
1416
+ const mountedRef = React.useRef(null)
1274
1417
 
1275
1418
  React.useEffect(() => {
1276
1419
  const host = hostRef.current
1277
1420
  if (!host) return undefined
1278
- return mountTrailRing(host)
1421
+ // 与线上 SoulTrail 同一条路径:解析出的挂载点即示例内的零高度锚点 .soul-trail-anchor
1422
+ const mounted = mountTrailRing(host, resolveTrailMountPoint(host, anchorRef.current))
1423
+ mountedRef.current = mounted
1424
+ return () => {
1425
+ mountedRef.current = null
1426
+ mounted.unmount()
1427
+ }
1279
1428
  }, [])
1280
1429
 
1430
+ React.useEffect(() => {
1431
+ const mounted = mountedRef.current
1432
+ if (mounted) mounted.setVisible(props.enabled !== false)
1433
+ }, [props.enabled])
1434
+
1281
1435
  return e('div', {
1282
1436
  ref: hostRef,
1283
1437
  className: 'soul-trail-preview',
@@ -1286,6 +1440,8 @@ window.__ModuleLoader__.load({
1286
1440
  'data-soul-trail-width': props.width || 'thin',
1287
1441
  style: { '--soul-trail-color': safeTrailColor(props.color) }
1288
1442
  },
1443
+ // 零高度挂载锚点(与 DSH composer 的 .overlayAnchor 同形态):环挂在这里,见 resolveTrailMountPoint
1444
+ e('div', { ref: anchorRef, className: 'soul-trail-anchor', 'aria-hidden': 'true' }),
1289
1445
  e('span', { className: 'soul-trail-preview-text' }, t('trail.previewText'))
1290
1446
  )
1291
1447
  }
@@ -1307,36 +1463,44 @@ window.__ModuleLoader__.load({
1307
1463
  React.useEffect(() => {
1308
1464
  const anchor = anchorRef.current
1309
1465
  // 组件位于卡片内的 overlay 锚点中,closest 定位可避免全局查询误命中
1310
- const host = anchor && typeof anchor.closest === 'function'
1466
+ const card = anchor && typeof anchor.closest === 'function'
1311
1467
  ? anchor.closest('[data-composer-card]')
1312
1468
  : null
1313
- if (!host) return undefined
1314
-
1315
- mountedRef.current = { host, unmount: mountTrailRing(host) }
1316
- // 先置为隐藏:效果在首帧绘制后执行,先写 off 可避免挂载瞬间闪出一帧光轨
1317
- host.setAttribute('data-soul-trail', 'off')
1469
+ if (!card) return undefined
1470
+
1471
+ // 环挂到卡片内那个零高度的 overlay 锚点上(锚点高度写死为 0,环退回常规流
1472
+ // 也撑不动卡片);退回卡片本身时仍由内联 position/contain 兜底,
1473
+ // 见渲染层「隔离不变量」注释
1474
+ const mountPoint = resolveTrailMountPoint(card, anchor)
1475
+ const mounted = mountTrailRing(card, mountPoint)
1476
+ mountedRef.current = { card, mounted }
1477
+ // 先置为隐藏:效果在首帧绘制后执行,先隐藏可避免挂载瞬间闪出一帧光轨
1478
+ card.setAttribute('data-soul-trail', 'off')
1479
+ mounted.setVisible(false)
1318
1480
  return () => {
1319
- const mounted = mountedRef.current
1481
+ const current = mountedRef.current
1320
1482
  mountedRef.current = null
1321
- if (!mounted) return
1322
- mounted.unmount()
1323
- mounted.host.removeAttribute('data-soul-trail')
1324
- mounted.host.removeAttribute('data-soul-trail-speed')
1325
- mounted.host.removeAttribute('data-soul-trail-width')
1326
- mounted.host.style.removeProperty('--soul-trail-color')
1483
+ if (!current) return
1484
+ current.mounted.unmount()
1485
+ current.card.removeAttribute('data-soul-trail')
1486
+ current.card.removeAttribute('data-soul-trail-speed')
1487
+ current.card.removeAttribute('data-soul-trail-width')
1488
+ current.card.style.removeProperty('--soul-trail-color')
1327
1489
  }
1328
1490
  }, [])
1329
1491
 
1330
1492
  // 显隐与外观同步:仅「Agent 回复中 + 光轨开关开启」时显示
1331
1493
  React.useEffect(() => {
1332
- const mounted = mountedRef.current
1333
- if (!mounted) return
1334
- const { host } = mounted
1494
+ const current = mountedRef.current
1495
+ if (!current) return
1496
+ const { card, mounted } = current
1335
1497
  const enabled = state !== null && state.trailEnabled === true
1336
- host.setAttribute('data-soul-trail', running && enabled ? 'on' : 'off')
1337
- host.setAttribute('data-soul-trail-speed', (state && state.trailSpeed) || 'slow')
1338
- host.setAttribute('data-soul-trail-width', (state && state.trailWidth) || 'thin')
1339
- host.style.setProperty('--soul-trail-color', safeTrailColor(state && state.trailColor))
1498
+ const visible = running && enabled
1499
+ card.setAttribute('data-soul-trail', visible ? 'on' : 'off')
1500
+ card.setAttribute('data-soul-trail-speed', (state && state.trailSpeed) || 'slow')
1501
+ card.setAttribute('data-soul-trail-width', (state && state.trailWidth) || 'thin')
1502
+ card.style.setProperty('--soul-trail-color', safeTrailColor(state && state.trailColor))
1503
+ mounted.setVisible(visible)
1340
1504
  }, [running, state])
1341
1505
 
1342
1506
  return e('span', { ref: anchorRef, hidden: true, 'aria-hidden': true })
package/index.mjs CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  sanitizeConfig,
32
32
  sanitizePersonaName
33
33
  } from './lib/config.mjs'
34
+ import { createInjectionSource } from './lib/injection.mjs'
34
35
 
35
36
  const name = 'soul'
36
37
 
@@ -350,15 +351,9 @@ function injectPromptToAllAgents(ctx, config) {
350
351
  type: 'text',
351
352
  text: prompt ? `${T.injectUpdatedHeader}\n${T.injectUpdatedBody}\n\n${prompt}` : snapshotText
352
353
  }],
353
- source: {
354
- kind: 'plugin',
355
- plugin: 'dsh-soul',
356
- form: 'snapshot',
357
- sections: [{
358
- name: 'soul:persona',
359
- text: snapshotText
360
- }]
361
- }
354
+ // 会话格式 v4 要求生产者自持 kind;旧的 `kind: 'plugin'` + `plugin` 组合
355
+ // 会让每一轮在 step 开始前失败(issue #1,构造与校验见 lib/injection.mjs)
356
+ source: createInjectionSource(snapshotText)
362
357
  }))
363
358
  // console.log(`[dsh-soul] 已向 Agent ${agent.id} 注入最新配置`)
364
359
  } catch (err) {
@@ -0,0 +1,44 @@
1
+ // lib/injection.mjs — 会话注入消息的 source 构造(纯函数,零依赖)
2
+ //
3
+ // 背景(issue #1):DSH 0.1.7 把会话格式升到 v4 后,**废弃了共享的
4
+ // `kind: 'plugin'` + `plugin` 字段组合**,要求每个生产者声明自己的 kind,
5
+ // 并显式拒绝字面量 'plugin':
6
+ //
7
+ // @deepseek-ai/dsh-session-format-v3-to-v4 的来源准入
8
+ // if (typeof value.kind !== 'string' || value.kind.length === 0 || value.kind === 'plugin')
9
+ // throw new SessionFormatError('format v4 message requires a producer-owned source kind')
10
+ //
11
+ // dsh-llm 的消息类型亦注明:kind 是可合并扩展的联合类型,
12
+ // 「没有共享的 catch-all plugin kind」。
13
+ //
14
+ // 未声明自持 kind 时,注入(面向所有活动会话、并在下一次 step 被读取)会把
15
+ // **每一轮**都打成失败:`format v4 message requires a producer-owned source kind`。
16
+ //
17
+ // 因此这里统一构造合规来源,并由 scripts/verify-compat.mjs 对着**实际安装的
18
+ // DSH 校验器**做回归验证——避免只改代码、不验契约。
19
+
20
+ // 生产者自持 kind。
21
+ // 取 `plugin:dsh-soul` 而非裸名,是为了与 DSH 自带 v3→v4 迁移对第三方插件生成的
22
+ // `plugin:${plugin}`(producerKind 的回落分支)保持一致:升级前后的历史事件与
23
+ // 新增事件归属同一个生产者,而不是在同一个会话里出现两个身份。
24
+ export const INJECTION_PRODUCER_KIND = 'plugin:dsh-soul'
25
+
26
+ // 快照区块名,与 systemPrompt section 的 name 对应
27
+ export const INJECTION_SECTION_NAME = 'soul:persona'
28
+
29
+ /**
30
+ * 构造注入消息的 source:自持 kind + snapshot 形态 + 命名区块。
31
+ *
32
+ * 不要再补 `plugin` 字段:v4 的迁移会主动丢弃它(rewritePluginSource 过滤掉
33
+ * `plugin` 键),保留只会在日志里留下一份易混淆的重复归属。
34
+ *
35
+ * @param snapshotText - 注入给模型的快照文本(同时用作快照区块内容)。
36
+ * @returns 符合会话格式 v4 的来源记录。
37
+ */
38
+ export function createInjectionSource(snapshotText) {
39
+ return {
40
+ kind: INJECTION_PRODUCER_KIND,
41
+ form: 'snapshot',
42
+ sections: [{ name: INJECTION_SECTION_NAME, text: snapshotText }]
43
+ }
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-soul",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "为 DeepSeek Harness 添加个性化设置",
5
5
  "keywords": [
6
6
  "dsh",
@@ -17,7 +17,9 @@
17
17
  "type": "module",
18
18
  "main": "index.mjs",
19
19
  "scripts": {
20
- "verify": "node scripts/verify-config.mjs"
20
+ "verify": "node scripts/verify-config.mjs && node scripts/verify-compat.mjs && node scripts/verify-trail.mjs",
21
+ "verify:compat": "node scripts/verify-compat.mjs",
22
+ "verify:trail": "node scripts/verify-trail.mjs"
21
23
  },
22
24
  "license": "MIT",
23
25
  "repository": {
@@ -25,9 +27,9 @@
25
27
  "url": "git+https://github.com/Aliuyanfeng/dsh-soul.git"
26
28
  },
27
29
  "peerDependencies": {
28
- "@deepseek-ai/cordis": "^4.0.1",
29
- "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
30
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
30
+ "@deepseek-ai/cordis": "^4.0.1 || ^4.0.5-alpha.1",
31
+ "@deepseek-ai/dsh-llm": ">=0.1.1-rc.2 <0.3.0-0",
32
+ "@deepseek-ai/dsh-tools": ">=0.1.0-rc.6 <0.3.0-0"
31
33
  },
32
34
  "dsh": {
33
35
  "bundle": {
@@ -0,0 +1,372 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 校验本插件的 peerDependencies 是否与当前 DSH 运行时兼容。
4
+ *
5
+ * DSH 装载插件前会做同一件事(见 @deepseek-ai/dsh-app-boot 的 evaluatePluginCompatibility):
6
+ *
7
+ * 1. 只校验 peerDependencies 里名字等于 `@deepseek-ai/dsh` 或以 `@deepseek-ai/dsh-` 开头的条目;
8
+ * 其余(例如 `@deepseek-ai/cordis`)不参与判定。
9
+ * 2. 被比较的一方是 **DSH 运行时版本**(取自 `@deepseek-ai/dsh-app-boot/package.json` 的 version),
10
+ * 而不是插件真正 import 到的那个包的版本。
11
+ * 3. 用 `semver.satisfies(runtimeVersion, range, { includePrerelease: true })` 判定。
12
+ * `includePrerelease` 带来两个后果:
13
+ * - `^0.1.1-rc.2` 的隐含上界是 `<0.2.0-0`,因此**挡不住** 0.2.x 线;
14
+ * - range 必须显式覆盖对方的 [major,minor,patch],否则同线上的 prerelease 也不匹配。
15
+ *
16
+ * 不通过时 DSH 会拒绝装载,并在插件管理器里给出
17
+ * 「{插件} 与 DSH {版本} 不兼容(要求 {peer})」。因此 DSH 升级后应重跑本脚本。
18
+ *
19
+ * 除 peer 范围外,本脚本还验证**注入来源契约**:DSH 0.1.7(会话格式 v4)起拒绝共享的
20
+ * `kind: 'plugin'`,要求生产者自持 kind——未适配时 agent.inject() 会让每一轮失败
21
+ * (issue #1)。判定直接用已安装 DSH 的准入校验器,并先证明它对废弃形态确有判断力。
22
+ *
23
+ * 用法:
24
+ * node scripts/verify-compat.mjs # 自动定位 DSH
25
+ * node scripts/verify-compat.mjs --dsh <目录> # 指定 DSH 安装目录(含 @deepseek-ai 或 @deepseek-ai/dsh)
26
+ * node scripts/verify-compat.mjs --runtime <版本> # 直接指定运行时版本,跳过定位
27
+ *
28
+ * 退出码:0 = 兼容,或无法定位运行时(跳过);1 = 存在不兼容的 peer 或来源契约不满足。
29
+ */
30
+ import { createRequire } from 'node:module'
31
+ import { readFileSync, existsSync, readdirSync } from 'node:fs'
32
+ import { fileURLToPath, pathToFileURL } from 'node:url'
33
+ import { dirname, join, resolve } from 'node:path'
34
+ import { homedir } from 'node:os'
35
+ import { createInjectionSource } from '../lib/injection.mjs'
36
+
37
+ const HERE = dirname(fileURLToPath(import.meta.url))
38
+ const PKG_DIR = resolve(HERE, '..')
39
+ const MANIFEST = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8'))
40
+ const APP_BOOT = '@deepseek-ai/dsh-app-boot'
41
+
42
+ // ==================== 最小 semver 实现(仅在借不到宿主 semver 时使用)====================
43
+
44
+ function parseVersion(input) {
45
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(input).trim())
46
+ if (!m) return null
47
+ return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ? m[4].split('.') : [] }
48
+ }
49
+
50
+ function comparePre(a, b) {
51
+ if (a.length === 0 || b.length === 0) return a.length === b.length ? 0 : a.length === 0 ? 1 : -1
52
+ const len = Math.max(a.length, b.length)
53
+ for (let i = 0; i < len; i++) {
54
+ const x = a[i]
55
+ const y = b[i]
56
+ if (x === undefined) return -1
57
+ if (y === undefined) return 1
58
+ const xn = /^\d+$/.test(x)
59
+ const yn = /^\d+$/.test(y)
60
+ if (xn && yn) {
61
+ if (Number(x) !== Number(y)) return Number(x) < Number(y) ? -1 : 1
62
+ } else if (xn !== yn) {
63
+ return xn ? -1 : 1
64
+ } else if (x !== y) {
65
+ return x < y ? -1 : 1
66
+ }
67
+ }
68
+ return 0
69
+ }
70
+
71
+ function compareVersions(a, b) {
72
+ const pa = parseVersion(a)
73
+ const pb = parseVersion(b)
74
+ if (!pa || !pb) return NaN
75
+ for (let i = 0; i < 3; i++) if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] < pb.nums[i] ? -1 : 1
76
+ return comparePre(pa.pre, pb.pre)
77
+ }
78
+
79
+ /** 计算 ^ 与 ~ 的隐式上界,规则与 node-semver 一致(目标带 prerelease 时上界加 `-0`)。 */
80
+ function upperBound(op, t) {
81
+ let cap
82
+ if (op === '^') {
83
+ if (t.nums[0] !== 0) cap = [t.nums[0] + 1, 0, 0]
84
+ else if (t.nums[1] !== 0) cap = [0, t.nums[1] + 1, 0]
85
+ else cap = [0, 0, t.nums[2] + 1]
86
+ } else {
87
+ if (t.nums[0] !== 0) cap = [t.nums[0], t.nums[1] + 1, 0]
88
+ else cap = [0, t.nums[1] + 1, 0]
89
+ }
90
+ return `${cap[0]}.${cap[1]}.${cap[2]}${t.pre.length > 0 ? '-0' : ''}`
91
+ }
92
+
93
+ function testComparator(version, comparator) {
94
+ const m = /^(>=|<=|>|<|=|\^|~)?\s*(.+)$/.exec(comparator.trim())
95
+ if (!m) return false
96
+ const op = m[1] || '='
97
+ const raw = m[2]
98
+ if (!parseVersion(raw) || !parseVersion(version)) return false
99
+ const c = compareVersions(version, raw)
100
+ switch (op) {
101
+ case '>=': return c >= 0
102
+ case '<=': return c <= 0
103
+ case '>': return c > 0
104
+ case '<': return c < 0
105
+ case '=': return c === 0
106
+ case '^':
107
+ case '~': return c >= 0 && compareVersions(version, upperBound(op, parseVersion(raw))) < 0
108
+ default: return false
109
+ }
110
+ }
111
+
112
+ /** 等价于 semver.satisfies(v, r, { includePrerelease: true }),仅覆盖本项目用到的语法。 */
113
+ export function satisfiesFallback(version, range) {
114
+ return String(range)
115
+ .split('||')
116
+ .some((alt) => alt.trim().split(/\s+/).filter(Boolean).every((c) => testComparator(version, c)))
117
+ }
118
+
119
+ // ==================== 定位 DSH 运行时 ====================
120
+
121
+ const argv = process.argv.slice(2)
122
+ const argValue = (flag) => {
123
+ const i = argv.indexOf(flag)
124
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : undefined
125
+ }
126
+
127
+ function anchors() {
128
+ const list = [process.cwd(), PKG_DIR, join(PKG_DIR, 'node_modules')]
129
+ const explicit = argValue('--dsh') || process.env.DSH_INSTALL_DIR || process.env.DSH_APP_BOOT_DIR
130
+ if (explicit) list.unshift(resolve(explicit))
131
+ const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh')
132
+ const profiles = join(dshHome, 'profiles')
133
+ if (existsSync(profiles)) {
134
+ try {
135
+ for (const p of readdirSync(profiles)) list.push(join(profiles, p))
136
+ } catch { /* 忽略 */ }
137
+ }
138
+ return list
139
+ }
140
+
141
+ async function loadModule(anchorsList, spec) {
142
+ for (const anchor of anchorsList) {
143
+ try {
144
+ const req = createRequire(join(anchor, 'noop.js'))
145
+ const resolved = req.resolve(spec)
146
+ const mod = await import(pathToFileURL(resolved).href)
147
+ return { mod, resolved }
148
+ } catch { /* 换下一个 */ }
149
+ }
150
+ return null
151
+ }
152
+
153
+ function locateAppBoot() {
154
+ const explicit = argValue('--dsh') || process.env.DSH_INSTALL_DIR || process.env.DSH_APP_BOOT_DIR
155
+ if (explicit) {
156
+ const base = resolve(explicit)
157
+ for (const p of [join(base, 'node_modules', APP_BOOT), join(base, APP_BOOT), base]) {
158
+ const pj = join(p, 'package.json')
159
+ if (!existsSync(pj)) continue
160
+ try {
161
+ if (JSON.parse(readFileSync(pj, 'utf8')).name === APP_BOOT) return p
162
+ } catch { /* 继续 */ }
163
+ }
164
+ }
165
+ for (const anchor of anchors()) {
166
+ try {
167
+ const req = createRequire(join(anchor, 'noop.js'))
168
+ return dirname(req.resolve(`${APP_BOOT}/package.json`))
169
+ } catch { /* 换下一个 */ }
170
+ }
171
+ return null
172
+ }
173
+
174
+ const isCheckedPeer = (name) => name === '@deepseek-ai/dsh' || name.startsWith('@deepseek-ai/dsh-')
175
+
176
+ // ==================== 主流程 ====================
177
+
178
+ async function main() {
179
+ console.log(`dsh-soul v${MANIFEST.version} — DSH 兼容性自检`)
180
+ console.log('')
181
+
182
+ const peers = MANIFEST.peerDependencies || {}
183
+ console.log('peerDependencies')
184
+ for (const [name, range] of Object.entries(peers)) {
185
+ console.log(` ${isCheckedPeer(name) ? '· 校验' : '· 忽略'} ${name.padEnd(30)} ${range}`)
186
+ }
187
+ console.log('')
188
+
189
+ const appBootDir = locateAppBoot()
190
+ const searchAnchors = []
191
+ if (appBootDir) searchAnchors.push(appBootDir, join(appBootDir, '..'), join(appBootDir, '..', '..'), join(appBootDir, '..', '..', '..'))
192
+ searchAnchors.push(...anchors())
193
+
194
+ let runtimeVersion = argValue('--runtime') || null
195
+ let evalFn = null
196
+ if (appBootDir) {
197
+ const boot = await loadModule([appBootDir], APP_BOOT)
198
+ if (boot) {
199
+ const mod = boot.mod
200
+ // 仅在未显式指定 --runtime 时采用宿主自报的版本(否则会把用户指定的版本覆盖掉)
201
+ if (runtimeVersion === null && typeof mod.getDshRuntimeVersion === 'function') {
202
+ try { runtimeVersion = mod.getDshRuntimeVersion() } catch { /* 回退到文件读取 */ }
203
+ }
204
+ if (typeof mod.evaluatePluginCompatibility === 'function') evalFn = mod.evaluatePluginCompatibility
205
+ }
206
+ if (!runtimeVersion) {
207
+ try {
208
+ runtimeVersion = JSON.parse(readFileSync(join(appBootDir, 'package.json'), 'utf8')).version
209
+ } catch { /* 保持 null */ }
210
+ }
211
+ }
212
+
213
+ if (!runtimeVersion) {
214
+ console.log('跳过:未能定位 DSH 运行时。')
215
+ console.log('可用 --dsh <目录> 指定 DSH 安装目录,或用 --runtime <版本> 直接给出运行时版本。')
216
+ process.exit(0)
217
+ }
218
+
219
+ console.log(`DSH 运行时版本:${runtimeVersion}`)
220
+ console.log(`定位来源:${appBootDir || '(--runtime 指定)'}`)
221
+ console.log('')
222
+
223
+ // semver 优先借宿主的;借不到用内置最小实现
224
+ let satisfies = null
225
+ const sem = await loadModule(searchAnchors, 'semver')
226
+ if (sem) {
227
+ const m = sem.mod.satisfies ? sem.mod : sem.mod.default
228
+ if (typeof m?.satisfies === 'function') satisfies = (v, r) => m.satisfies(v, r, { includePrerelease: true })
229
+ }
230
+ const mode = evalFn ? 'DSH 原生 evaluatePluginCompatibility' : satisfies ? '宿主 semver' : '内置最小实现'
231
+
232
+ console.log('判定')
233
+ const failures = []
234
+ for (const [name, range] of Object.entries(peers)) {
235
+ if (!isCheckedPeer(name)) {
236
+ console.log(` – 忽略 ${name.padEnd(30)} ${range} (DSH 不校验此类 peer)`)
237
+ continue
238
+ }
239
+ let ok
240
+ if (evalFn) {
241
+ const issue = evalFn({ name: MANIFEST.name, version: MANIFEST.version, peerDependencies: { [name]: range } }, {}, runtimeVersion)
242
+ ok = issue === undefined
243
+ } else {
244
+ ok = satisfies ? satisfies(runtimeVersion, range) : satisfiesFallback(runtimeVersion, range)
245
+ }
246
+ console.log(` ${ok ? '✓ 通过' : '✗ 不通过'} ${name.padEnd(30)} ${range}`)
247
+ if (!ok) failures.push([name, range])
248
+ }
249
+ console.log(` (判定方式:${mode})`)
250
+
251
+ console.log('')
252
+ console.log('运行时符号抽查(DSH 不做这层校验,仅作预警)')
253
+ let symbolMissing = 0
254
+ const audits = [
255
+ ['@deepseek-ai/dsh-llm', ['createUserMessage']],
256
+ ['@deepseek-ai/dsh-tools', ['defineTool', 'TOOL_RUNTIME_SCHEDULER']]
257
+ ]
258
+ for (const [spec, symbols] of audits) {
259
+ const hit = await loadModule(searchAnchors, spec)
260
+ if (!hit) { console.log(` – ${spec}:未解析到,跳过`); continue }
261
+ const missing = symbols.filter((s) => !(s in hit.mod))
262
+ if (missing.length) symbolMissing += missing.length
263
+ console.log(` ${missing.length ? '✗' : '✓'} ${spec}:${missing.length ? '缺少 ' + missing.join(', ') : '符号齐备'}`)
264
+ }
265
+ for (const spec of MANIFEST.dsh?.client?.inject || []) {
266
+ const hit = await loadModule(searchAnchors, spec)
267
+ console.log(` ${hit ? '✓' : '–'} ${spec}:${hit ? '已解析' : '未解析到,跳过'}`)
268
+ }
269
+
270
+ // ---- 注入来源契约 ----
271
+ // 用生产代码同一个构造函数取来源,再交给已安装 DSH 的准入校验器判定;
272
+ // 判定前先确认校验器确实会拒绝已废弃形态,否则本次判定没有判断力。
273
+ console.log('')
274
+ console.log('注入来源契约(会话格式 v4)')
275
+ const source = createInjectionSource('dsh-soul self-check snapshot')
276
+
277
+ let contractFailures = 0
278
+ const shapeIssues = []
279
+ if (typeof source.kind !== 'string' || source.kind.length === 0) shapeIssues.push('kind 必须为非空字符串')
280
+ else if (source.kind === 'plugin') shapeIssues.push("kind 不得为已废弃的 'plugin'")
281
+ if ('plugin' in source) shapeIssues.push('source 不应再携带 plugin 字段(v4 迁移会丢弃它)')
282
+ if (source.form !== 'snapshot') shapeIssues.push("form 必须为 'snapshot'")
283
+ if (!Array.isArray(source.sections) || source.sections.length === 0) shapeIssues.push('snapshot 必须携带非空 sections')
284
+ for (const [index, section] of (Array.isArray(source.sections) ? source.sections : []).entries()) {
285
+ if (typeof section?.name !== 'string' || section.name === '' || typeof section?.text !== 'string') {
286
+ shapeIssues.push(`sections[${index}] 需要非空 name 与字符串 text`)
287
+ }
288
+ }
289
+ for (const issue of shapeIssues) console.log(` ✗ 形状:${issue}`)
290
+ contractFailures += shapeIssues.length
291
+ if (shapeIssues.length === 0) {
292
+ console.log(` ✓ 形状:kind=${source.kind} / form=${source.form} / sections=${source.sections.length}`)
293
+ }
294
+
295
+ const FORMAT_PKG = '@deepseek-ai/dsh-session-format-v3-to-v4'
296
+ const formatHit = await loadModule(searchAnchors, FORMAT_PKG)
297
+ const admit = formatHit && typeof formatHit.mod.assertV4RowAdmission === 'function'
298
+ ? formatHit.mod.assertV4RowAdmission
299
+ : null
300
+
301
+ // 先用本机 dsh-llm 真实构造注入消息(而不是只测形状字面量),让判定贴近生产路径
302
+ const llmHit = await loadModule(searchAnchors, '@deepseek-ai/dsh-llm')
303
+ let realMessage = null
304
+ if (llmHit && typeof llmHit.mod.createUserMessage === 'function') {
305
+ try {
306
+ realMessage = llmHit.mod.createUserMessage({
307
+ content: [{ type: 'text', text: 'dsh-soul self-check' }],
308
+ source
309
+ })
310
+ console.log(' ✓ 构造:已用本机 dsh-llm 的 createUserMessage 生成真实注入消息')
311
+ } catch (err) {
312
+ contractFailures += 1
313
+ console.log(` ✗ 构造:createUserMessage 拒绝了当前 source:${String((err && err.message) || err)}`)
314
+ }
315
+ } else {
316
+ console.log(' – 构造:本机未解析到 @deepseek-ai/dsh-llm,改用形状字面量判定')
317
+ }
318
+
319
+ if (admit === null) {
320
+ console.log(` – ${FORMAT_PKG}:准入校验器不可用,跳过运行时判定(仅检查形状)`)
321
+ } else {
322
+ // 有真实消息就用它,否则退回形状字面量
323
+ const currentRow = realMessage === null
324
+ ? { type: 'user/message', data: { source } }
325
+ : { type: 'user/message', data: realMessage }
326
+ const retired = { kind: 'plugin', plugin: 'dsh-soul', form: 'snapshot', sections: source.sections }
327
+ let guardLive = false
328
+ try {
329
+ admit({ type: 'user/message', data: { source: retired } }, new Set(['user/message']))
330
+ } catch {
331
+ guardLive = true
332
+ }
333
+ if (!guardLive) {
334
+ console.log(' – 校验器未拒绝已废弃形态,本次判定不可信,跳过运行时判定')
335
+ } else {
336
+ console.log(" ✓ 校验器有效:已废弃的 kind:'plugin' 确实被拒绝(issue #1 的复现条件)")
337
+ try {
338
+ admit(currentRow, new Set(['user/message']))
339
+ console.log(` ✓ 通过 ${source.kind}:当前注入来源可被本运行时接纳`)
340
+ } catch (err) {
341
+ contractFailures += 1
342
+ console.log(` ✗ 不通过 ${source.kind}:${String((err && err.message) || err)}`)
343
+ }
344
+ }
345
+ }
346
+
347
+ console.log('')
348
+ if (failures.length === 0 && contractFailures === 0) {
349
+ console.log('结果:兼容。')
350
+ if (symbolMissing > 0) console.log(`注意:仍有 ${symbolMissing} 个运行时符号缺失,需同步适配代码。`)
351
+ process.exit(0)
352
+ }
353
+ console.log('结果:不兼容')
354
+ for (const [name, range] of failures) console.log(` peer 范围未覆盖 ${runtimeVersion}:${name} ${range}`)
355
+ if (contractFailures > 0) console.log(` 注入来源契约不满足:${contractFailures} 项(见上方 ✗ 行)`)
356
+ console.log('')
357
+ console.log('处理方式:')
358
+ console.log(' 1. 修正代码或 peer 声明使其满足上面的约束,然后升版本重新发布;')
359
+ console.log(' 2. 或让用户升级插件到已适配该 DSH 版本的版本。')
360
+ if (symbolMissing > 0) console.log(` 另:抽查到 ${symbolMissing} 个运行时符号缺失,说明不只是声明过期,代码也需适配。`)
361
+ process.exit(1)
362
+ }
363
+
364
+ const invokedDirectly = process.argv[1] !== undefined &&
365
+ import.meta.url === pathToFileURL(resolve(process.argv[1])).href
366
+
367
+ if (invokedDirectly) {
368
+ main().catch((error) => {
369
+ console.error('自检脚本异常:', error)
370
+ process.exit(1)
371
+ })
372
+ }
@@ -0,0 +1,413 @@
1
+ // scripts/verify-trail.mjs — 输入框光轨离线回归(npm run verify:trail)
2
+ //
3
+ // 为什么需要它:issue #2 的故障(composer 卡片高度无界增长)只在**特定环境**出现——
4
+ // 插件往宿主 DOM 写的绝对定位元素被第三方皮肤/主题用更具体的选择器或 `!important`
5
+ // 打回常规流,于是尺寸随宿主变化、又反过来撑大宿主,闭合成正反馈环。这种故障
6
+ // 在常规环境里复现不出来(样式齐全时同步 60 次全是空操作),所以必须专门构造
7
+ // 最坏条件来回归。
8
+ //
9
+ // 做法:不需要跑 DSH。把 `client/index.mjs` 里那段**纯 DOM 渲染层**原样抽出,
10
+ // 注入一个静态页,在无头浏览器里跑一组确定性模型:
11
+ //
12
+ // M1 现状(真实代码 + 几何被 !important 破坏) → 高度必须不变(熔断 + 零高锚点)
13
+ // M2 对照:只屏蔽熔断,其余同 M1 → 高度必须不变(证明零高锚点单独即够)
14
+ // M3 对照:屏蔽熔断**且**把环挂到卡片本身 → 高度必须显著增长(证明本实验有判断力)
15
+ // M4 正常条件 → 空操作,且与宿主边框盒逐像素对齐
16
+ // M5 尺寸熔断(宿主超过 TRAIL_MAX_SIDE) → 必须停用
17
+ // M6 挂载点解析(示例结构 / composer 结构 / 无锚点) → 三种落点各自正确
18
+ //
19
+ // 无头浏览器里 rAF / ResizeObserver 只产生一帧(实测 raf=1 ro=1),所以这里用
20
+ // 「重复调用同步函数 + 每次比对 offsetHeight」的确定性驱动,不依赖任何动画帧。
21
+ //
22
+ // 用法:
23
+ // node scripts/verify-trail.mjs [--chrome <可执行文件>] [--keep]
24
+ // node scripts/verify-trail.mjs --emit <页面.html> # 只产出页面(分步验证)
25
+ // node scripts/verify-trail.mjs --dump <dumpdom.html> # 解析已有 dump-dom 输出
26
+ // 环境变量 CHROME_PATH 亦可指定浏览器;找不到浏览器时优雅跳过(退出码 0)。
27
+ //
28
+ // 抽取是「按标记切片 + 断言补丁生效」:一旦 client/index.mjs 的结构变化导致切片
29
+ // 或补丁失效,本脚本会直接报错退出(而不是悄悄退化成永不失败的检查)。
30
+
31
+ import fs from 'node:fs'
32
+ import os from 'node:os'
33
+ import path from 'node:path'
34
+ import { fileURLToPath, pathToFileURL } from 'node:url'
35
+ import { spawnSync } from 'node:child_process'
36
+
37
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
38
+ const ROOT = path.resolve(__dirname, '..')
39
+ const CLIENT_FILE = path.join(ROOT, 'client', 'index.mjs')
40
+ const TRIALS = 60
41
+
42
+ function parseArgs(argv) {
43
+ const opts = { chrome: null, keep: false, emit: null, dump: null }
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const arg = argv[i]
46
+ if (arg === '--chrome') opts.chrome = argv[++i]
47
+ else if (arg === '--keep') opts.keep = true
48
+ else if (arg === '--emit') opts.emit = argv[++i]
49
+ else if (arg === '--dump') opts.dump = argv[++i]
50
+ }
51
+ return opts
52
+ }
53
+
54
+ function findBrowser(explicit) {
55
+ const candidates = []
56
+ if (explicit) candidates.push(explicit)
57
+ if (process.env.CHROME_PATH) candidates.push(process.env.CHROME_PATH)
58
+ if (process.platform === 'win32') {
59
+ candidates.push(
60
+ 'C:/Program Files/Google/Chrome/Application/chrome.exe',
61
+ 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
62
+ path.join(process.env.LOCALAPPDATA || '', 'Google/Chrome/Application/chrome.exe'),
63
+ 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
64
+ 'C:/Program Files/Microsoft/Edge/Application/msedge.exe'
65
+ )
66
+ } else if (process.platform === 'darwin') {
67
+ candidates.push(
68
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
69
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'
70
+ )
71
+ } else {
72
+ candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/microsoft-edge')
73
+ }
74
+ for (const candidate of candidates) {
75
+ if (!candidate) continue
76
+ try {
77
+ if (fs.existsSync(candidate)) return candidate
78
+ } catch {
79
+ /* 忽略不可读路径 */
80
+ }
81
+ }
82
+ return null
83
+ }
84
+
85
+ function slice(src, startMark, endMark) {
86
+ const i = src.indexOf(startMark)
87
+ if (i < 0) throw new Error(`client/index.mjs 中找不到起点标记:${JSON.stringify(startMark)}`)
88
+ const j = src.indexOf(endMark, i)
89
+ if (j < 0) throw new Error(`client/index.mjs 中找不到终点标记:${JSON.stringify(endMark)}`)
90
+ return src.slice(i, j)
91
+ }
92
+
93
+ // 把内部同步函数暴露到返回值上(纯追加,不改变原有行为)
94
+ const SYNC_NEEDLE = ' return {\n svg,\n setVisible,'
95
+ function exposeSync(code) {
96
+ if (!code.includes(SYNC_NEEDLE)) throw new Error('渲染层返回值结构已变化,请更新本脚本的抽取补丁')
97
+ return code.replace(SYNC_NEEDLE, ' return {\n svg,\n setVisible,\n sync,')
98
+ }
99
+
100
+ // 屏蔽熔断(在 disable 首行插入 return),用于构造「只剩隔离层」的对照模型
101
+ const DISABLE_NEEDLE = ' const disable = (reason) => {'
102
+ function muteCircuitBreaker(code) {
103
+ if (!code.includes(DISABLE_NEEDLE)) throw new Error('熔断实现已变化,请更新本脚本的抽取补丁')
104
+ return code.replace(DISABLE_NEEDLE, `${DISABLE_NEEDLE}\n return`)
105
+ }
106
+
107
+ function buildPage(consts, renderer) {
108
+ // 常量块必须与渲染层拼在一起:渲染层里引用 TRAIL_DASH / TRAIL_PAD / TRAIL_MAX_SIDE 等
109
+ const base = `${consts}\n${exposeSync(renderer)}`
110
+ const noBreaker = `${consts}\n${muteCircuitBreaker(exposeSync(renderer))}`
111
+ return `<!DOCTYPE html>
112
+ <html><head><meta charset="utf-8">
113
+ <style>
114
+ /* 破坏性样式表:模拟第三方皮肤/主题用 !important 把环打回常规流。
115
+ author !important 胜过内联普通声明——这正是 issue #2 的真实触发条件。
116
+ 用 media 开关,正常条件与最坏条件在同一页面里对照。 */
117
+ </style>
118
+ <style id="brk" media="not all">.soul-trail-svg{position:static !important;contain:none !important}</style>
119
+ </head>
120
+ <body style="margin:0">
121
+ <pre id="RESULT"></pre>
122
+ <script>
123
+ var VARIANT_BASE = ${JSON.stringify(base)}
124
+ var VARIANT_NO_BREAKER = ${JSON.stringify(noBreaker)}
125
+
126
+ function loadVariant(code) {
127
+ // new Function 体在全局作用域执行:document / globalThis / ResizeObserver 均可见
128
+ return (new Function(code + '\\n;return { resolveTrailMountPoint: resolveTrailMountPoint, mountTrailRing: mountTrailRing, TRAIL_PAD: TRAIL_PAD, TRAIL_MAX_SIDE: TRAIL_MAX_SIDE };'))()
129
+ }
130
+
131
+ var results = []
132
+ function record(name, pass, detail) { results.push({ name: name, pass: !!pass, detail: detail || '' }) }
133
+
134
+ // 整段模型都包在 try 里:一旦页面脚本抛错,把栈写进结果,而不是留一个空结果让人猜
135
+ var V_BASE = null
136
+ var V_NO_BREAKER = null
137
+ try {
138
+ V_BASE = loadVariant(VARIANT_BASE)
139
+ V_NO_BREAKER = loadVariant(VARIANT_NO_BREAKER)
140
+
141
+ // 与 DSH composer 同构的卡片:overlayAnchor 是第一个子元素,高度写死为 0
142
+ function makeCard(label, options) {
143
+ options = options || {}
144
+ var card = document.createElement('div')
145
+ card.setAttribute('data-composer-card', '')
146
+ card.style.cssText = 'position:relative;display:flex;flex-direction:column;gap:12px;padding-top:8px;border:0;width:600px' +
147
+ (options.height ? ';height:' + options.height + 'px' : '')
148
+ var anchor = document.createElement('div')
149
+ anchor.className = 'uV2eYG_overlayAnchor'
150
+ anchor.style.cssText = 'height:0;position:absolute;inset:0 0 auto'
151
+ var editor = document.createElement('div')
152
+ editor.style.cssText = 'height:120px;background:#eee;font:12px monospace'
153
+ editor.textContent = label
154
+ card.appendChild(anchor)
155
+ card.appendChild(editor)
156
+ document.body.appendChild(card)
157
+ return { card: card, anchor: anchor }
158
+ }
159
+
160
+ // 示例结构(设置页 SoulTrailPreview):anchor 直接是 host 的子元素
161
+ function makePreview(label) {
162
+ var host = document.createElement('div')
163
+ host.className = 'soul-trail-preview'
164
+ host.style.cssText = 'position:relative;margin-top:6px;padding:16px;border:1px solid #bbb;border-radius:22px;width:520px'
165
+ var anchor = document.createElement('div')
166
+ anchor.className = 'soul-trail-anchor'
167
+ var text = document.createElement('span')
168
+ text.textContent = label
169
+ host.appendChild(anchor)
170
+ host.appendChild(text)
171
+ document.body.appendChild(host)
172
+ return { host: host, anchor: anchor }
173
+ }
174
+
175
+ function round2(n) { return Math.round(n * 100) / 100 }
176
+ function alignDelta(svg, host) {
177
+ var a = svg.getBoundingClientRect(), b = host.getBoundingClientRect()
178
+ return { left: round2(a.left - b.left), top: round2(a.top - b.top), w: round2(a.width - b.width), h: round2(a.height - b.height) }
179
+ }
180
+
181
+ // ---- M6:挂载点解析(不依赖破坏性样式) ----
182
+ var preview = makePreview('设置页示例')
183
+ var previewResolved = V_BASE.resolveTrailMountPoint(preview.host, preview.anchor)
184
+ record('M6a 示例结构(anchor 是 host 直接子元素)解析为锚点本身',
185
+ previewResolved === preview.anchor,
186
+ 'resolve -> ' + (previewResolved === preview.anchor ? '.soul-trail-anchor' : (previewResolved === preview.host ? '宿主本身(错)' : '其他节点')))
187
+
188
+ var composer = makeCard('composer 结构')
189
+ var pluginRoot = document.createElement('div')
190
+ composer.anchor.appendChild(pluginRoot)
191
+ var composerResolved = V_BASE.resolveTrailMountPoint(composer.card, pluginRoot)
192
+ record('M6b composer 结构(overlay 槽位)解析为 .overlayAnchor',
193
+ composerResolved === composer.anchor,
194
+ 'resolve -> ' + (composerResolved === composer.anchor ? '.uV2eYG_overlayAnchor' : (composerResolved === composer.card ? '卡片本身(错)' : '其他节点')))
195
+
196
+ record('M6c 无锚点信息时退回卡片本身',
197
+ V_BASE.resolveTrailMountPoint(composer.card, null) === composer.card, 'resolve(card, null) -> 卡片本身')
198
+ record('M6d 锚点不在卡片内时退回卡片本身',
199
+ V_BASE.resolveTrailMountPoint(composer.card, document.body) === composer.card, 'resolve(card, body) -> 卡片本身')
200
+
201
+ // ---- M4:正常条件(几何完好) ----
202
+ var normal = makeCard('M4 正常条件')
203
+ var mountedNormal = V_BASE.mountTrailRing(normal.card, V_BASE.resolveTrailMountPoint(normal.card, normal.anchor))
204
+ mountedNormal.setVisible(true)
205
+ var heightNormal0 = normal.card.offsetHeight
206
+ var changed = 0
207
+ for (var i = 0; i < ${TRIALS}; i++) if (mountedNormal.sync()) changed++
208
+ var delta = alignDelta(mountedNormal.svg, normal.card)
209
+ var pad = V_BASE.TRAIL_PAD
210
+ record('M4 正常条件:60 次同步全为空操作且高度不变',
211
+ changed === 0 && normal.card.offsetHeight === heightNormal0,
212
+ 'sizeChanged=' + changed + '/' + ${TRIALS} + ',高度 ' + heightNormal0 + ' -> ' + normal.card.offsetHeight)
213
+ record('M4 正常条件:环外盒 = 宿主边框盒每边外扩 TRAIL_PAD(' + pad + 'px)',
214
+ delta.left === -pad && delta.top === -pad && delta.w === pad * 2 && delta.h === pad * 2,
215
+ 'dLeft=' + delta.left + ' dTop=' + delta.top + ' dW=' + delta.w + ' dH=' + delta.h)
216
+
217
+ // ---- 开启破坏性样式:几何被 !important 打回常规流 ----
218
+ document.getElementById('brk').media = 'all'
219
+
220
+ // M1:现状(真实代码)
221
+ var m1 = makeCard('M1 现状 + 破坏几何')
222
+ var mounted1 = V_BASE.mountTrailRing(m1.card, V_BASE.resolveTrailMountPoint(m1.card, m1.anchor))
223
+ mounted1.setVisible(true)
224
+ var h1 = m1.card.offsetHeight
225
+ for (var k = 0; k < ${TRIALS}; k++) mounted1.sync()
226
+ record('M1 现状:几何被第三方样式表打回常规流后,60 次同步不撑大卡片',
227
+ m1.card.offsetHeight === h1,
228
+ '高度 ' + h1 + ' -> ' + m1.card.offsetHeight + ',环 display=' + (mounted1.svg.style.display || '(空)'))
229
+ record('M1 现状:熔断已接管(环被隐藏)',
230
+ mounted1.svg.style.display === 'none',
231
+ 'display=' + (mounted1.svg.style.display || '(空)'))
232
+
233
+ // M2:只屏蔽熔断 —— 零高锚点这一层是否单独够用
234
+ var m2 = makeCard('M2 去熔断 + 破坏几何 + 挂锚点')
235
+ var mounted2 = V_NO_BREAKER.mountTrailRing(m2.card, V_NO_BREAKER.resolveTrailMountPoint(m2.card, m2.anchor))
236
+ mounted2.setVisible(true)
237
+ var h2 = m2.card.offsetHeight
238
+ for (var k2 = 0; k2 < ${TRIALS}; k2++) mounted2.sync()
239
+ var grew2 = m2.card.offsetHeight / h2
240
+ record('M2 对照(屏蔽熔断):零高锚点单独即足以阻断正反馈',
241
+ grew2 < 1.01,
242
+ '高度 ' + h2 + ' -> ' + m2.card.offsetHeight + '(×' + round2(grew2) + ')')
243
+
244
+ // M3:屏蔽熔断 + 把环挂到卡片本身 —— 证明本实验有判断力
245
+ var m3 = makeCard('M3 去熔断 + 破坏几何 + 挂卡片本身')
246
+ var mounted3 = V_NO_BREAKER.mountTrailRing(m3.card, m3.card)
247
+ mounted3.setVisible(true)
248
+ var h3 = m3.card.offsetHeight
249
+ for (var k3 = 0; k3 < ${TRIALS}; k3++) mounted3.sync()
250
+ var grew3 = m3.card.offsetHeight / h3
251
+ record('M3 对照(屏蔽熔断 + 挂卡片):同样的破坏条件下必须出现正反馈',
252
+ grew3 > 5,
253
+ '高度 ' + h3 + ' -> ' + m3.card.offsetHeight + '(×' + round2(grew3) + ')—— 这就是 issue #2 的成因')
254
+
255
+ // ---- M5:尺寸熔断(几何完好,仅宿主超限) ----
256
+ document.getElementById('brk').media = 'not all'
257
+ var m5 = makeCard('M5 尺寸熔断', { height: V_BASE.TRAIL_MAX_SIDE + 5000 })
258
+ var mounted5 = V_BASE.mountTrailRing(m5.card, V_BASE.resolveTrailMountPoint(m5.card, m5.anchor))
259
+ mounted5.setVisible(true)
260
+ mounted5.sync()
261
+ record('M5 宿主尺寸超过 TRAIL_MAX_SIDE(' + V_BASE.TRAIL_MAX_SIDE + 'px) 时停用',
262
+ mounted5.svg.style.display === 'none',
263
+ '卡片高度 ' + m5.card.offsetHeight + ',环 display=' + (mounted5.svg.style.display || '(空)'))
264
+
265
+ } catch (err) {
266
+ record('页面脚本异常(其后的检查未执行)', false, String((err && err.stack) || err).slice(0, 800))
267
+ }
268
+ document.getElementById('RESULT').textContent = JSON.stringify(results)
269
+ </script>
270
+ </body></html>`
271
+ }
272
+
273
+ function extract() {
274
+ const src = fs.readFileSync(CLIENT_FILE, 'utf8')
275
+ const consts = slice(src, ' const TRAIL_DASH = 12', '\n\n // 颜色容错')
276
+ const renderer = slice(src, ' function createSvgNode(', '\n var SoulController = class')
277
+ for (const needle of ['TRAIL_PAD', 'TRAIL_MAX_SIDE']) {
278
+ if (!consts.includes(needle)) throw new Error(`常量块缺少 ${needle},请更新本脚本的切片标记`)
279
+ }
280
+ for (const needle of ['function resolveTrailMountPoint', 'function mountTrailRing', 'function sizeTrailSvg']) {
281
+ if (!renderer.includes(needle)) throw new Error(`渲染层缺少 ${needle},请更新本脚本的切片标记`)
282
+ }
283
+ return buildPage(consts, renderer)
284
+ }
285
+
286
+ function readDump(file) {
287
+ const raw = fs.readFileSync(file, 'utf8')
288
+ const match = raw.match(/<pre id="RESULT"[^>]*>([\s\S]*?)<\/pre>/)
289
+ if (!match) throw new Error('未在 dump 输出中找到结果节点(页面脚本可能抛错,或文件不是本脚本产出的页面)')
290
+ const text = match[1]
291
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&')
292
+ let checks
293
+ try {
294
+ checks = JSON.parse(text)
295
+ } catch {
296
+ throw new Error(`结果不是合法 JSON:${text.slice(0, 300)}`)
297
+ }
298
+ return checks
299
+ }
300
+
301
+ function report(checks) {
302
+ let failed = 0
303
+ for (const check of checks) {
304
+ if (!check.pass) failed++
305
+ console.log(` ${check.pass ? '✓' : '✗'} ${check.name}`)
306
+ if (check.detail) console.log(` ${check.detail}`)
307
+ }
308
+ console.log('')
309
+ if (failed === 0) {
310
+ console.log(`结果:通过(${checks.length} 项)。`)
311
+ process.exit(0)
312
+ }
313
+ console.log(`结果:失败(${failed}/${checks.length} 项)。`)
314
+ console.log('说明:M1/M2 失败代表隔离层失效;M3 失败代表本实验失去判断力(对照没复现出正反馈),需检查脚本与客户端代码是否仍同源。')
315
+ process.exit(1)
316
+ }
317
+
318
+ function main() {
319
+ const opts = parseArgs(process.argv.slice(2))
320
+ const page = extract()
321
+
322
+ if (opts.emit) {
323
+ fs.writeFileSync(opts.emit, page, 'utf8')
324
+ console.log(`已生成回归页面:${opts.emit}`)
325
+ console.log('用无头浏览器 dump-dom 取出页面后,以 `--dump <文件>` 交回本脚本判定。')
326
+ process.exit(0)
327
+ }
328
+
329
+ console.log('输入框光轨离线回归(无头浏览器 + 插件真实渲染层)')
330
+ console.log(` 取样数:每次同步 ${TRIALS} 轮`)
331
+
332
+ if (opts.dump) {
333
+ console.log(` 来源:${opts.dump}(--dump,未启动浏览器)`)
334
+ console.log('')
335
+ report(readDump(opts.dump))
336
+ return
337
+ }
338
+
339
+ const browser = findBrowser(opts.chrome)
340
+ if (!browser) {
341
+ console.log(' – 未找到 Chrome / Edge,跳过(可用 --chrome <路径> 或 CHROME_PATH 指定)')
342
+ console.log('结果:跳过。')
343
+ process.exit(0)
344
+ }
345
+ console.log(` 浏览器:${browser}`)
346
+ console.log('')
347
+
348
+ const htmlPath = path.join(os.tmpdir(), `dsh-soul-verify-trail-${process.pid}.html`)
349
+ fs.writeFileSync(htmlPath, page, 'utf8')
350
+ // 独立 user-data-dir:避免与用户正在运行的 Chrome/Edge 抢同一个 profile
351
+ const profileDir = path.join(os.tmpdir(), `dsh-soul-verify-trail-profile-${process.pid}`)
352
+
353
+ let stdout = ''
354
+ let skip = null
355
+ try {
356
+ const run = spawnSync(browser, [
357
+ '--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
358
+ '--no-first-run', '--no-default-browser-check', '--disable-extensions',
359
+ `--user-data-dir=${profileDir}`,
360
+ '--window-size=900,1400', '--virtual-time-budget=8000',
361
+ '--allow-file-access-from-files', '--dump-dom',
362
+ `file:///${htmlPath.replace(/\\/g, '/')}`
363
+ ], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
364
+ // 受限环境(沙箱 / 安全软件 / 已有实例抢占 profile)可能根本起不来浏览器:
365
+ // 这种情况跳过而不是判失败——本脚本是回归增强,不是发布门禁
366
+ if (run.error) skip = `无法启动浏览器(${run.error.code || run.error.message})`
367
+ else if (run.status !== 0 && !run.stdout) skip = `浏览器退出码 ${run.status}`
368
+ else stdout = run.stdout || ''
369
+ } finally {
370
+ if (opts.keep) {
371
+ console.log(` (保留页面:${htmlPath})`)
372
+ console.log(` (保留 profile:${profileDir})`)
373
+ } else {
374
+ fs.rmSync(htmlPath, { force: true })
375
+ try {
376
+ fs.rmSync(profileDir, { recursive: true, force: true })
377
+ } catch {
378
+ /* 浏览器子进程可能仍持有句柄,忽略 */
379
+ }
380
+ }
381
+ }
382
+
383
+ if (skip) {
384
+ console.log(` – ${skip},跳过`)
385
+ console.log(' 提示:可用「--emit <页面>」产出页面,手动 dump-dom 后再以「--dump <文件>」交回本脚本判定')
386
+ console.log('结果:跳过。')
387
+ process.exit(0)
388
+ }
389
+
390
+ const match = stdout.match(/<pre id="RESULT"[^>]*>([\s\S]*?)<\/pre>/)
391
+ if (!match) throw new Error('未从浏览器输出中取到结果(页面脚本可能抛错)')
392
+ const text = match[1]
393
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&')
394
+ let checks
395
+ try {
396
+ checks = JSON.parse(text)
397
+ } catch {
398
+ throw new Error(`结果不是合法 JSON:${text.slice(0, 300)}`)
399
+ }
400
+ report(checks)
401
+ }
402
+
403
+ const invokedDirectly = process.argv[1] !== undefined &&
404
+ import.meta.url === pathToFileURL(process.argv[1]).href
405
+
406
+ if (invokedDirectly) {
407
+ try {
408
+ main()
409
+ } catch (error) {
410
+ console.error('回归脚本异常:', error instanceof Error ? error.message : error)
411
+ process.exit(1)
412
+ }
413
+ }