dsh-soul 0.6.2 → 0.7.0

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.
@@ -3,17 +3,36 @@
3
3
  // 覆盖 lib/config.mjs 的 migrateConfig / sanitizeConfig:
4
4
  // - 旧版本 style+tone 迁移、废弃字段清理、特质脏数据回退
5
5
  // - 白名单 / 类型 / 长度 / 枚举校验的接受与拒绝路径
6
+ // 覆盖 lib/personas.mjs 的内置人设与合并 / 匹配语义(含「应用后必须命中自己」闭环)。
7
+ // 另含清单契约自检:插件图标(DSH 在 app-boot 的 iconOf 中判定)、前后端契约一致性。
6
8
  // 零依赖,直接 `node scripts/verify-config.mjs` 运行。
7
9
 
8
10
  import assert from 'node:assert/strict'
11
+ import { readFileSync, realpathSync, statSync } from 'node:fs'
12
+ import { extname, isAbsolute, relative, resolve, sep } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
9
14
  import {
10
15
  DEFAULT_CONFIG,
11
16
  FIELD_LIMITS,
17
+ PERSONA_FIELDS,
18
+ PERSONA_NAME_MAX,
19
+ REPLY_LENGTH_VALUES,
20
+ STYLE_VALUES,
21
+ TRAIT_VALUES,
12
22
  migrateConfig,
13
23
  normalizePersonas,
14
24
  sanitizeConfig,
15
25
  sanitizePersonaName
16
26
  } from '../lib/config.mjs'
27
+ import {
28
+ BUILTIN_PERSONAS,
29
+ builtinPersonaNames,
30
+ declaredPersonaKeys,
31
+ isBuiltinPersona,
32
+ mergePersonas,
33
+ personaMatches,
34
+ resolvePersona
35
+ } from '../lib/personas.mjs'
17
36
 
18
37
  let passed = 0
19
38
  function check(name, fn) {
@@ -177,6 +196,72 @@ check('migrateConfig:personas 归一化透传,缺省时保持缺席;确认
177
196
  assert.equal(dirty.requireToolConfirmation, false)
178
197
  })
179
198
 
199
+ console.log('特质:表格 / 回复长度')
200
+
201
+ const indexSource = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
202
+
203
+ // 从 index.mjs 的 PROMPT_TEXT 中按缩进切片取出某个文案子表(zh / en 各一处)。
204
+ // 用「块数量必须为 2」做结构断言:文案表若被重构或改名,这里会直接失败而不是静默放过。
205
+ function promptTextBlocks(key) {
206
+ const re = new RegExp(`\\n ${key}: \\{\\n([\\s\\S]*?)\\n \\\},`, 'g')
207
+ return [...indexSource.matchAll(re)].map((m) => m[1])
208
+ }
209
+
210
+ check('DEFAULT_CONFIG 含表格与回复长度的默认值', () => {
211
+ assert.equal(DEFAULT_CONFIG.tables, 'default')
212
+ assert.equal(DEFAULT_CONFIG.replyLength, 'normal')
213
+ })
214
+
215
+ check('sanitizeConfig:表格与回复长度合法值通过', () => {
216
+ const { patch, errors } = sanitizeConfig({ tables: 'more', replyLength: 'detailed' })
217
+ assert.deepEqual(errors, {})
218
+ assert.equal(patch.tables, 'more')
219
+ assert.equal(patch.replyLength, 'detailed')
220
+ })
221
+
222
+ check('sanitizeConfig:表格与回复长度非法枚举被拒绝', () => {
223
+ assert.ok(sanitizeConfig({ tables: 'always' }).errors.tables)
224
+ assert.ok(sanitizeConfig({ replyLength: 'verbose' }).errors.replyLength)
225
+ assert.equal('tables' in sanitizeConfig({ tables: 'x' }).patch, false)
226
+ assert.equal('replyLength' in sanitizeConfig({ replyLength: 'x' }).patch, false)
227
+ })
228
+
229
+ check('migrateConfig:表格与回复长度脏数据回退为默认值', () => {
230
+ const dirty = migrateConfig({ tables: 'max', replyLength: 3 })
231
+ assert.equal(dirty.tables, 'default')
232
+ assert.equal(dirty.replyLength, 'normal')
233
+ })
234
+
235
+ check('PERSONA_FIELDS 覆盖新增的两个维度(人设预设可保存与还原)', () => {
236
+ assert.ok(PERSONA_FIELDS.includes('tables'))
237
+ assert.ok(PERSONA_FIELDS.includes('replyLength'))
238
+ })
239
+
240
+ check('提示词文案:zh / en 均含表格与回复长度,且块数量恰为 2', () => {
241
+ for (const key of ['tables', 'replyLength']) {
242
+ const blocks = promptTextBlocks(key)
243
+ assert.equal(blocks.length, 2, `${key} 文案块数量应为 2(zh / en)`)
244
+ for (const block of blocks) {
245
+ assert.match(block, /.{20,}/, `${key} 文案块不应为空`)
246
+ }
247
+ }
248
+ const tables = promptTextBlocks('tables')
249
+ for (const block of tables) {
250
+ assert.ok(block.includes('more:') && block.includes('less:'), 'tables 文案应含 more / less')
251
+ assert.equal(block.includes('default:'), false, 'tables=default 不应产生提示词文案')
252
+ }
253
+ })
254
+
255
+ check('提示词文案:replyLength 的 normal(适中)不产生任何文案', () => {
256
+ // 这是「新增维度不改变现有用户行为」的核心不变量:
257
+ // buildBehavior 仅在文案表命中时 push,所以 normal 必须没有对应键。
258
+ const blocks = promptTextBlocks('replyLength')
259
+ for (const block of blocks) {
260
+ assert.ok(block.includes('concise:') && block.includes('detailed:'), 'replyLength 文案应含 concise / detailed')
261
+ assert.equal(block.includes('normal:'), false, 'replyLength=normal 不应产生提示词文案')
262
+ }
263
+ })
264
+
180
265
  console.log('输入框光轨')
181
266
 
182
267
  check('DEFAULT_CONFIG 含光轨默认值', () => {
@@ -232,4 +317,357 @@ check('migrateConfig:合法光轨配置保留,颜色转大写', () => {
232
317
  assert.equal(config.trailWidth, 'thick')
233
318
  })
234
319
 
320
+ console.log('插件清单与图标')
321
+
322
+ // DSH 读取 package.json 的 icon 字段(app-boot 的 readPluginMeta / iconOf),
323
+ // 在插件管理列表与侧栏入口渲染。图标缺失只是回退默认插图,但路径写错、
324
+ // 或没进 files 白名单,就会出现「仓库里有、装完没有」的静默失败,故固化为检查。
325
+ const manifestPath = fileURLToPath(new URL('../package.json', import.meta.url))
326
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
327
+ const packageRoot = resolve(manifestPath, '..')
328
+ const iconRef = typeof manifest.icon === 'string' ? manifest.icon : ''
329
+ const iconPath = iconRef ? resolve(packageRoot, iconRef) : ''
330
+ const iconRelative = iconRef ? relative(packageRoot, iconPath) : ''
331
+
332
+ check('清单:icon 为包内相对路径,且指向真实文件', () => {
333
+ assert.ok(iconRef, 'package.json 缺少 icon 字段')
334
+ // 宿主的判定(dsh-app-boot 的 iconOf):
335
+ // 1) 绝对路径、Windows 盘符、以及任何带 URL scheme 的值直接抛错
336
+ // 2) 以 manifest 所在目录的 realpath 为根,relative(root, realpath(target)) 不得为 ..
337
+ assert.equal(isAbsolute(iconRef), false, 'icon 必须是相对路径(会随包一起发布)')
338
+ assert.equal(/^[A-Za-z][A-Za-z\d+.-]*:/.test(iconRef), false, 'icon 不能是带 scheme 的 URL(宿主只接受相对路径)')
339
+ assert.equal(statSync(iconPath).isFile(), true, `icon 指向的不是文件:${iconRef}`)
340
+ const outside = relative(realpathSync(packageRoot), realpathSync(iconPath))
341
+ assert.equal(
342
+ outside === '..' || outside.startsWith(`..${sep}`) || isAbsolute(outside),
343
+ false,
344
+ 'icon 不能通过符号链接逃出包目录'
345
+ )
346
+ })
347
+
348
+ check('清单:icon 扩展名在宿主白名单内(SVG / PNG / JPEG / WebP)', () => {
349
+ assert.ok(
350
+ ['.svg', '.png', '.jpg', '.jpeg', '.webp'].includes(extname(iconPath).toLowerCase()),
351
+ `宿主只接受 SVG / PNG / JPEG / WebP,当前为 ${extname(iconPath) || '(无扩展名)'}`
352
+ )
353
+ })
354
+
355
+ check('清单:icon 体积不超过 256 KiB(宿主上限,超出会拒绝装载)', () => {
356
+ const { size } = statSync(iconPath)
357
+ assert.ok(size > 0, 'icon 不应为空文件')
358
+ assert.ok(size <= 256 * 1024, `icon 为 ${size} 字节,超过宿主上限 262144 字节`)
359
+ })
360
+
361
+ check('清单:icon 落在 files 白名单内(会随 npm 包一起发布)', () => {
362
+ assert.ok(Array.isArray(manifest.files) && manifest.files.length > 0, 'package.json 缺少 files 白名单')
363
+ const topLevel = iconRelative.split(sep)[0]
364
+ assert.ok(manifest.files.includes(topLevel), `files 白名单应包含 ${topLevel},否则图标不会进发布包`)
365
+ })
366
+
367
+ const iconSvg = iconRef ? readFileSync(iconPath, 'utf8') : ''
368
+
369
+ check('图标:SVG 自带命名空间,且 viewBox 为正方形', () => {
370
+ assert.match(iconSvg, /<svg[\s>]/, 'icon 不是 SVG 文档')
371
+ assert.match(iconSvg, /xmlns="http:\/\/www\.w3\.org\/2000\/svg"/, 'SVG 缺少 xmlns,脱离独立文档渲染时会失败')
372
+ const viewBox = iconSvg.match(/viewBox="([\d.\-\s]+)"/)
373
+ assert.ok(viewBox, 'SVG 缺少 viewBox,无法按容器尺寸缩放')
374
+ const parts = viewBox[1].trim().split(/\s+/).map(Number)
375
+ assert.equal(parts.length, 4, 'viewBox 应为 4 个数字')
376
+ assert.equal(parts[2], parts[3], 'viewBox 应为正方形(图标显示区是方的,非方会被拉伸或留白)')
377
+ })
378
+
379
+ check('图标:不含脚本、位图与外部引用(发布物第一方自足)', () => {
380
+ assert.equal(/<script/i.test(iconSvg), false, 'SVG 不应内嵌脚本')
381
+ assert.equal(/<image[\s>]/i.test(iconSvg), false, 'SVG 不应内嵌位图')
382
+ assert.equal(/<foreignObject/i.test(iconSvg), false, 'SVG 不应使用 foreignObject')
383
+ assert.equal(/(?:href|xlink:href)\s*=/i.test(iconSvg), false, 'SVG 不应包含任何链接引用')
384
+ const withoutXmlns = iconSvg.replace(/xmlns="http:\/\/www\.w3\.org\/2000\/svg"/g, '')
385
+ assert.equal(/https?:\/\//i.test(withoutXmlns), false, 'SVG 不应引用外部资源(xmlns 除外)')
386
+ })
387
+
388
+ check('图标:渐变引用都能解析到已定义的 id', () => {
389
+ const defined = new Set([...iconSvg.matchAll(/\sid="([^"]+)"/g)].map((m) => m[1]))
390
+ const refs = [...iconSvg.matchAll(/url\(#([^)]+)\)/g)].map((m) => m[1])
391
+ assert.ok(refs.length > 0, '图标应使用渐变填充,以适配浅色 / 深色主题')
392
+ for (const ref of refs) {
393
+ assert.ok(defined.has(ref), `引用了未定义的 id:${ref}`)
394
+ }
395
+ })
396
+
397
+ // ==================== 内置人设预设 ====================
398
+
399
+ console.log('\n内置人设预设')
400
+
401
+ check('内置人设非空,名称合法且不超上限', () => {
402
+ const names = builtinPersonaNames()
403
+ assert.ok(names.length >= 4, `内置人设过少:${names.length}`)
404
+ for (const name of names) {
405
+ assert.equal(sanitizePersonaName(name), name, `内置名不合法:${name}`)
406
+ assert.ok(name.length <= PERSONA_NAME_MAX, `内置名超长:${name}`)
407
+ assert.ok(name.trim() === name, `内置名含首尾空白:${name}`)
408
+ }
409
+ })
410
+
411
+ check('内置人设只声明 PERSONA_FIELDS 内的键', () => {
412
+ for (const name of builtinPersonaNames()) {
413
+ for (const key of Object.keys(BUILTIN_PERSONAS[name])) {
414
+ assert.ok(PERSONA_FIELDS.includes(key), `${name} 含非人设字段:${key}`)
415
+ }
416
+ }
417
+ })
418
+
419
+ check('内置人设不声明用户自有信息、输出语言与总开关', () => {
420
+ // nickname / occupation / bio 是「关于你」的用户资料,预设去写它们等于把用户
421
+ // 的昵称职业清空;language 属于用户偏好,写死会让另一种语言的用户被强行切回;
422
+ // enabled 是总开关,预设不该能替你关掉个性化。
423
+ for (const name of builtinPersonaNames()) {
424
+ for (const key of ['nickname', 'occupation', 'bio', 'language', 'enabled']) {
425
+ assert.equal(key in BUILTIN_PERSONAS[name], false, `${name} 不应声明 ${key}`)
426
+ }
427
+ }
428
+ })
429
+
430
+ check('内置人设的枚举值与文本长度合法,且都有自定义指令', () => {
431
+ const enums = {
432
+ style: STYLE_VALUES,
433
+ headingLists: TRAIT_VALUES,
434
+ emoji: TRAIT_VALUES,
435
+ tables: TRAIT_VALUES,
436
+ replyLength: REPLY_LENGTH_VALUES
437
+ }
438
+ for (const name of builtinPersonaNames()) {
439
+ const entry = BUILTIN_PERSONAS[name]
440
+ for (const [field, allowed] of Object.entries(enums)) {
441
+ if (!(field in entry)) continue
442
+ assert.ok(allowed.includes(entry[field]), `${name}.${field} 非法取值:${entry[field]}`)
443
+ }
444
+ // customInstructions 是人格差异的主要载体:枚举字段只能表达「更啰嗦 / 更简洁」
445
+ // 这类粗粒度倾向,所以内置预设必须有它。
446
+ assert.equal(typeof entry.customInstructions, 'string', `${name} 缺少 customInstructions`)
447
+ assert.ok(entry.customInstructions.length > 0, `${name} 的 customInstructions 为空`)
448
+ assert.ok(
449
+ entry.customInstructions.length <= FIELD_LIMITS.customInstructions,
450
+ `${name} 的 customInstructions 超长(${entry.customInstructions.length} > ${FIELD_LIMITS.customInstructions})`
451
+ )
452
+ }
453
+ })
454
+
455
+ check('内置人设都能通过 sanitizeConfig(数据本身合法)', () => {
456
+ for (const name of builtinPersonaNames()) {
457
+ const { errors } = sanitizeConfig(resolvePersona(name, {}))
458
+ assert.deepEqual(errors, {}, `${name} 未通过校验:${JSON.stringify(errors)}`)
459
+ }
460
+ })
461
+
462
+ check('mergePersonas:内置优先并带 builtin 标记,用户条目不受影响', () => {
463
+ const names = builtinPersonaNames()
464
+ const merged = mergePersonas({ '极简主义者': { style: 'roast' }, 我的预设: { style: 'casual' } })
465
+ assert.equal(Object.keys(merged).length, names.length + 1, '同名用户条目应被内置遮蔽而非并存')
466
+ assert.equal(merged['极简主义者'].style, BUILTIN_PERSONAS['极简主义者'].style, '同名时应取内置内容')
467
+ assert.equal(merged['极简主义者'].builtin, true)
468
+ assert.equal(merged['我的预设'].builtin, undefined, '用户预设不应带 builtin 标记')
469
+ assert.deepEqual(merged['我的预设'], { style: 'casual' })
470
+ for (const name of names) assert.equal(merged[name].builtin, true, `${name} 应带 builtin 标记`)
471
+ // 非对象输入不应抛错
472
+ assert.equal(Object.keys(mergePersonas(null)).length, names.length)
473
+ assert.equal(Object.keys(mergePersonas('x')).length, names.length)
474
+ })
475
+
476
+ check('isBuiltinPersona / resolvePersona 的判定与优先级', () => {
477
+ const names = builtinPersonaNames()
478
+ assert.equal(isBuiltinPersona(names[0]), true)
479
+ assert.equal(isBuiltinPersona('不存在的名字'), false)
480
+ assert.equal(isBuiltinPersona(123), false)
481
+ assert.equal(isBuiltinPersona(null), false)
482
+ assert.deepEqual(resolvePersona(names[0], {}), BUILTIN_PERSONAS[names[0]])
483
+ // 内置名即使磁盘上有同名条目也应解析到内置内容
484
+ assert.deepEqual(resolvePersona(names[0], { [names[0]]: { style: 'roast' } }), BUILTIN_PERSONAS[names[0]])
485
+ assert.deepEqual(resolvePersona('我的预设', { '我的预设': { style: 'casual' } }), { style: 'casual' })
486
+ assert.equal(resolvePersona('不存在', { '我的预设': { style: 'casual' } }), null)
487
+ assert.equal(resolvePersona(undefined, {}), null)
488
+ })
489
+
490
+ check('应用内置预设后能命中它自己(★ 标记闭环)', () => {
491
+ // 这条是「预设能用」的最小闭环:应用 → 表单回读 → 列表应显示 ★。
492
+ // 若匹配语义写成全字段相等,apply 会失败而这条会立刻报出来。
493
+ for (const name of builtinPersonaNames()) {
494
+ const entry = resolvePersona(name, {})
495
+ const { patch } = sanitizeConfig(entry)
496
+ const applied = { ...DEFAULT_CONFIG, ...patch }
497
+ assert.equal(personaMatches(entry, applied), true, `${name} 应用后匹配不上自己`)
498
+ }
499
+ })
500
+
501
+ check('匹配是部分覆盖语义:已声明字段敏感、未声明字段免疫', () => {
502
+ const entry = resolvePersona('极简主义者', {})
503
+ const applied = { ...DEFAULT_CONFIG, ...sanitizeConfig(entry).patch }
504
+ assert.equal(personaMatches(entry, DEFAULT_CONFIG), false, '未应用时不应匹配')
505
+ assert.equal(personaMatches(entry, { ...applied, style: 'casual' }), false, '改动已声明字段后不应匹配')
506
+ assert.equal(
507
+ personaMatches(entry, { ...applied, customInstructions: `${applied.customInstructions}x` }),
508
+ false,
509
+ '自定义指令变化后不应匹配'
510
+ )
511
+ assert.equal(personaMatches(entry, { ...applied, language: 'en' }), true, '预设未声明 language,语言变化不应影响匹配')
512
+ assert.equal(personaMatches(entry, { ...applied, nickname: '小明' }), true, '预设未声明 nickname,改昵称不应影响匹配')
513
+ })
514
+
515
+ check('未声明任何字段的条目不匹配(避免匹配一切)', () => {
516
+ assert.equal(personaMatches({}, DEFAULT_CONFIG), false)
517
+ assert.equal(personaMatches({ updatedAt: 'x', builtin: true }, DEFAULT_CONFIG), false)
518
+ assert.equal(personaMatches(null, DEFAULT_CONFIG), false)
519
+ assert.deepEqual(declaredPersonaKeys({ style: 'casual', updatedAt: 'x', builtin: true, extra: 1 }), ['style'])
520
+ })
521
+
522
+ // ==================== 前后端契约一致性 ====================
523
+
524
+ console.log('\n前后端契约一致性')
525
+
526
+ const clientSource = readFileSync(new URL('../client/index.mjs', import.meta.url), 'utf8')
527
+
528
+ check('客户端摘要的默认值表与宿主 DEFAULT_CONFIG 逐项一致', () => {
529
+ const block = clientSource.match(/const META_FIELD_DEFAULTS = \{([\s\S]*?)\}/)
530
+ assert.ok(block, '客户端缺少 META_FIELD_DEFAULTS')
531
+ const pairs = [...block[1].matchAll(/(\w+):\s*'([^']*)'/g)].map((m) => [m[1], m[2]])
532
+ assert.ok(pairs.length >= 5, `默认值表条目过少:${pairs.length}`)
533
+ for (const [field, value] of pairs) {
534
+ assert.equal(
535
+ value,
536
+ String(DEFAULT_CONFIG[field]),
537
+ `客户端 ${field} 默认值 ${value} 与宿主 DEFAULT_CONFIG 的 ${DEFAULT_CONFIG[field]} 不一致(摘要会显示相反的结果)`
538
+ )
539
+ }
540
+ })
541
+
542
+ check('客户端摘要覆盖的字段集合固定', () => {
543
+ const block = clientSource.match(/const META_FIELD_VALUES = \{([\s\S]*?)\}/)
544
+ assert.ok(block, '客户端缺少 META_FIELD_VALUES')
545
+ const fields = [...block[1].matchAll(/(\w+):/g)].map((m) => m[1]).sort()
546
+ assert.deepEqual(fields, ['emoji', 'headingLists', 'language', 'replyLength', 'tables'])
547
+ })
548
+
549
+ check('客户端为内置预设提供中英双语文案键', () => {
550
+ const keys = [
551
+ 'personas.builtin',
552
+ 'personas.hint',
553
+ 'meta.replyLength.concise',
554
+ 'meta.replyLength.detailed',
555
+ 'meta.headingLists.more',
556
+ 'meta.headingLists.less',
557
+ 'meta.emoji.more',
558
+ 'meta.emoji.less',
559
+ 'meta.tables.more',
560
+ 'meta.tables.less',
561
+ 'meta.language.zh',
562
+ 'meta.language.en'
563
+ ]
564
+ for (const key of keys) {
565
+ const hits = clientSource.split(`'${key}':`).length - 1
566
+ assert.equal(hits, 2, `${key} 应在 zh / en 各出现一次,实际 ${hits} 次`)
567
+ }
568
+ })
569
+
570
+ check('客户端内置预设行不渲染删除按钮,改为「内置」标记', () => {
571
+ assert.ok(
572
+ /builtin && e\('span', \{ className: 'soul-persona-badge' \}/.test(clientSource),
573
+ '内置标记未渲染'
574
+ )
575
+ assert.ok(/!builtin && e\('button'/.test(clientSource), '删除按钮未被 builtin 条件包裹')
576
+ })
577
+
578
+ check('服务端四条写路径都拒绝内置名,读取路径都走合并库', () => {
579
+ // HTTP 保存 / 删除各一处,/soul 命令的 save / del 各一处
580
+ const rejections = indexSource.split('isBuiltinPersona(personaName)').length - 1
581
+ assert.equal(rejections, 4, `内置名拒绝点应为 4 处(HTTP 与命令层各 2),实际 ${rejections} 处`)
582
+ assert.ok(indexSource.split('mergePersonas(').length - 1 >= 3, '预设库读取应统一走 mergePersonas')
583
+ assert.ok(/resolvePersona\(personaName, config\.personas\)/.test(indexSource), '命令层 use 未走 resolvePersona')
584
+ assert.ok(/resolvePersona\(personaName, current\.personas\)/.test(indexSource), 'HTTP use 未走 resolvePersona')
585
+ })
586
+
587
+ check('匹配逻辑只在纯模块实现一份(不留本地重复实现)', () => {
588
+ assert.equal(
589
+ indexSource.includes('function declaredPersonaKeys'),
590
+ false,
591
+ 'index.mjs 不应自带 declaredPersonaKeys——已下沉到 lib/personas.mjs,两份实现会漂移'
592
+ )
593
+ assert.ok(/personaMatches\(/.test(indexSource), 'index.mjs 应调用 personaMatches 做 ★ 判定')
594
+ })
595
+
596
+ // ---------- 提示词预览(草稿编译)----------
597
+
598
+ // 抽出参与提示词编译的全部函数源码:compilePrompt 及其两个子构建器。
599
+ // 只扫部分函数会漏掉字段,故三者一起扫。
600
+ function promptCompilerSource() {
601
+ const chunks = []
602
+ for (const name of ['buildUserProfile', 'buildBehavior', 'compilePrompt']) {
603
+ const matched = indexSource.match(new RegExp(`function ${name}\\([\\s\\S]*?\\n\\}`))
604
+ assert.ok(matched, `index.mjs 中找不到函数 ${name}`)
605
+ chunks.push(matched[0])
606
+ }
607
+ return chunks.join('\n')
608
+ }
609
+
610
+ check('草稿预览端点复用保存路径的校验与编译,且不落盘、不注入', () => {
611
+ const matched = indexSource.match(/path: '\/api\/soul\/prompt\/preview'[\s\S]*?\n \}\)/)
612
+ assert.ok(matched, '缺少 /api/soul/prompt/preview 端点')
613
+ const body = matched[0]
614
+ assert.ok(/sanitizeConfig\(parsed\.body\)/.test(body), '草稿预览未复用 sanitizeConfig(未校验的草稿会编出误导性提示词)')
615
+ assert.ok(/compilePrompt\(draft\)/.test(body), '草稿预览未调用 compilePrompt')
616
+ assert.ok(/req\.method !== 'POST'/.test(body), '草稿预览应限制为 POST')
617
+ assert.equal(/saveConfig\(/.test(body), false, '草稿预览不得落盘(不应调用 saveConfig)')
618
+ assert.equal(/refreshPromptAndInject\(/.test(body), false, '草稿预览不得注入会话(不应调用 refreshPromptAndInject)')
619
+ })
620
+
621
+ check('客户端「参与编译的字段」与宿主 compilePrompt 实际读取的字段一致', () => {
622
+ const block = clientSource.match(/const PROMPT_FIELD_KEYS = \[([\s\S]*?)\]/)
623
+ assert.ok(block, '客户端缺少 PROMPT_FIELD_KEYS')
624
+ const clientKeys = [...block[1].matchAll(/'([^']+)'/g)].map((m) => m[1]).sort()
625
+
626
+ const driven = new Set(['enabled', ...PERSONA_FIELDS])
627
+ const hostKeys = [...new Set([...promptCompilerSource().matchAll(/config\.(\w+)/g)].map((m) => m[1]))]
628
+ .filter((key) => driven.has(key))
629
+ .sort()
630
+
631
+ assert.deepEqual(
632
+ clientKeys,
633
+ hostKeys,
634
+ 'PROMPT_FIELD_KEYS 与实际参与编译的字段不一致:漏字段会让预览显示陈旧内容,多字段会触发无谓重编译'
635
+ )
636
+ assert.ok(clientKeys.length >= 5, `参与编译的字段过少:${clientKeys.length}`)
637
+ })
638
+
639
+ check('客户端在有未保存编辑时预览草稿,否则预览已生效提示词', () => {
640
+ assert.ok(/async previewPrompt\(draft\)/.test(clientSource), '控制器缺少 previewPrompt')
641
+ assert.ok(/await controller\.previewPrompt\(promptDraft\(\)\)/.test(clientSource), '未调用草稿预览端点')
642
+ assert.ok(/const promptDirty = PROMPT_FIELD_KEYS\.some\(/.test(clientSource), 'promptDirty 应由 PROMPT_FIELD_KEYS 判定(改光轨不该让预览变成草稿)')
643
+ // 只断言下面的三元语法不够:把 asDraft 写死成 false 也能让三元看起来「正确」,
644
+ // 但预览就永远只看已生效内容了。必须把「草稿判定来源于 promptDirty」这一环钉住。
645
+ assert.ok(/const asDraft = promptDirty\b/.test(clientSource), 'asDraft 必须取自 promptDirty,否则预览会永远停留在已生效内容')
646
+ assert.ok(/asDraft\s*\?\s*await controller\.previewPrompt/.test(clientSource), '草稿分支未按 asDraft 选择端点')
647
+ })
648
+
649
+ check('提示词预览的编译入口唯一(避免重复请求与过期闭包)', () => {
650
+ assert.equal(
651
+ clientSource.includes('if (showPrompt) await loadPrompt()'),
652
+ false,
653
+ '保存/重置/应用预设处理器内不应再直接编译预览——刷新统一由 effect 驱动,两处调用会重复请求并可能用到过期闭包'
654
+ )
655
+ const effect = clientSource.match(
656
+ /const promptSettled = React\.useRef\(false\)[\s\S]*?\}, \[showPrompt, promptDraftKey, promptDirty\]\)/
657
+ )
658
+ assert.ok(effect, '缺少预览驱动 effect')
659
+ assert.equal(
660
+ effect[0].split('void loadPrompt()').length - 1,
661
+ 2,
662
+ 'effect 应有两个编译触发点:刚展开时立即一次、此后防抖一次'
663
+ )
664
+ })
665
+
666
+ check('提示词预览新增文案键中英各一份', () => {
667
+ for (const key of ['prompt.titleDraft', 'prompt.summaryDraft', 'prompt.invalid']) {
668
+ const hits = clientSource.split(`'${key}':`).length - 1
669
+ assert.equal(hits, 2, `${key} 应在 zh / en 各出现一次,实际 ${hits} 次`)
670
+ }
671
+ })
672
+
235
673
  console.log(`\n全部通过:${passed} 项检查`)