probe-after-fix.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. // 第三轮 review 修复后探针:与 07-05 probe-m1-m7.mjs / probe-followup.mjs 同场景,
  2. // 断言修复后语义(守卫拒绝/修复接受/角色可见/别名命中/ok 不改写/重号硬错)。
  3. // 库级直调真实路径(persistCreateBook 建书)。跑完自删临时目录,全 PASS 即收口留档。
  4. import { promises as fs } from 'node:fs'
  5. import os from 'node:os'
  6. import path from 'node:path'
  7. import { fileURLToPath } from 'node:url'
  8. const V7 = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'v7')
  9. const m = (p) => import(`file:///${path.join(V7, p).replace(/\\/g, '/')}`)
  10. const { persistCreateBook, persistRepair } = await m('src/state-machine/persist.js')
  11. const { finalizeChapter } = await m('src/finalize/index.js')
  12. const { stageChapter, readBatch } = await m('src/staging/index.js')
  13. const { gotoChapter } = await m('src/state-machine/flows/goto-chapter.js')
  14. const { determineNextState } = await m('src/state-machine/index.js')
  15. const { CacheManager } = await m('src/cache/index.js')
  16. const { EntityReader } = await m('src/storage/adapters/EntityReader.js')
  17. const { migrateV6 } = await m('src/migrate/index.js')
  18. const { run: listCharacters } = await m('src/commands/list-characters.js')
  19. const { run: finalizeCmd } = await m('src/commands/finalize.js')
  20. const { createGit } = await m('src/finalize/git.js')
  21. const results = []
  22. const P = (id, pass, detail) => {
  23. results.push({ id, pass })
  24. console.log(`${pass ? '【PASS】' : '【FAIL】'} ${id}: ${detail}`)
  25. }
  26. const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-fix-probe-'))
  27. let n = 0
  28. async function makeBook(extraYaml = '') {
  29. const repo = path.join(tmpRoot, `book-${++n}`)
  30. await fs.mkdir(repo, { recursive: true })
  31. const r = await persistCreateBook({ repoPath: repo, cache: null }, {
  32. book: { spec_version: '7.0', 书名: `修复探针${n}`, 类型: '玄幻', 每章目标字数: 3000, 卷规模: 40 },
  33. 总纲: '# 总纲\n\n## 结局\n主角胜。\n',
  34. 卷纲: '# 第1卷\n\n第一卷纲要。\n',
  35. })
  36. if (!r.ok) throw new Error(`建书失败: ${r.error}`)
  37. if (extraYaml) {
  38. const p = path.join(repo, 'book.yaml')
  39. await fs.writeFile(p, (await fs.readFile(p, 'utf8')) + extraYaml, 'utf8')
  40. }
  41. const cache = new CacheManager(path.join(repo, '.cache', 'index.db'))
  42. await cache.ensureReady(repo)
  43. return { repoPath: repo, cache }
  44. }
  45. const payloadOf = (num, extra = {}) => ({
  46. chapterNum: num,
  47. frontMatter: {
  48. 章号: num, 标题: `第${num}章题`, 卷: 1, 书内时间: `夏月初${num}`, 字数: 100,
  49. 章定位: '推进', 钩子: '危机钩-强', 情绪定位: '铺垫',
  50. },
  51. body: `第${num}章正文内容,情节推进一格。`,
  52. summary: `第${num}章摘要。`,
  53. commitLines: {},
  54. workspaceFiles: [],
  55. ...extra,
  56. })
  57. async function stage(ctx, num, extra = {}) {
  58. await fs.mkdir(path.join(ctx.repoPath, '工作区'), { recursive: true })
  59. await fs.writeFile(path.join(ctx.repoPath, '工作区', '审稿.md'), `# 第 ${num} 章审稿单\n\n> 共 0 个问题:0 阻断。\n`, 'utf8')
  60. const r = await stageChapter(ctx, { chapterNum: num, payload: payloadOf(num, extra) })
  61. if (!r.ok) throw new Error(`stage ${num} 失败: ${r.error}`)
  62. }
  63. // P-1 (R1):批次在场 goto → 人话拒绝,批次原样;无批次时 goto 照常可用
  64. try {
  65. const ctx = await makeBook()
  66. for (const c of [1, 2, 3]) {
  67. const r = await finalizeChapter(ctx, payloadOf(c))
  68. if (!r.ok) throw new Error(`定稿${c}: ${r.error}`)
  69. }
  70. await stage(ctx, 4)
  71. const g = await gotoChapter(ctx, { chapterNum: 2, confirm: true })
  72. const batch = await readBatch(ctx.repoPath)
  73. const humane = /finalize-batch|batch-discard/.test(g.error || '')
  74. P('P-1 goto×批次守卫(R1)', !g.ok && humane && batch.exists,
  75. `goto被拒=${!g.ok} 指引人话=${humane} 批次完好=${batch.exists} err=${(g.error || '').slice(0, 80)}`)
  76. await ctx.cache.close()
  77. } catch (e) { P('P-1 goto×批次守卫(R1)', false, `探针异常: ${e.message}`) }
  78. // P-2 (R2):批次在场手动 finalize(命令层)→ 双文案人话拒绝;finalizeChapter 本体不带守卫(finalizeBatch 内部要用)
  79. try {
  80. const ctx = await makeBook()
  81. await finalizeChapter(ctx, payloadOf(1))
  82. await stage(ctx, 2)
  83. const inBatch = await finalizeCmd(['2'], { payload: '不会被读到.json' }, ctx)
  84. const outBatch = await finalizeCmd(['9'], { payload: '不会被读到.json' }, ctx)
  85. const okIn = !inBatch.ok && /finalize-batch/.test(inBatch.error || '')
  86. const okOut = !outBatch.ok && /章号错位|finalize-batch/.test(outBatch.error || '')
  87. P('P-2 手动finalize守卫(R2)', okIn && okOut,
  88. `批内被拒=${okIn} 批外被拒=${okOut} errIn=${(inBatch.error || '').slice(0, 60)}`)
  89. await ctx.cache.close()
  90. } catch (e) { P('P-2 手动finalize守卫(R2)', false, `探针异常: ${e.message}`) }
  91. // P-3 (R3):book.yaml 坏 → 序0;合法修复被接受;修复后序离开 0
  92. try {
  93. const ctx = await makeBook('卷规模: 40\n') // 追加重复键 → YAML 解析失败
  94. const next = await determineNextState(ctx)
  95. const failures = next.dto?.failures?.map((f) => f.file) || []
  96. const good = 'spec_version: "7.0"\n书名: 探针修复\n类型: 玄幻\n每章目标字数: 3000\n卷规模: 40\n'
  97. const rep = await persistRepair(ctx, { repairs: [{ file: 'book.yaml', content: good }] }, { allowedFiles: failures })
  98. const after = await determineNextState(ctx)
  99. P('P-3 修复分派校验(R3)', next.序 === 0 && failures.includes('book.yaml') && rep.ok && after.序 !== 0,
  100. `序0=${next.序 === 0} 修复接受=${rep.ok} 修复后序=${after.序} err=${(rep.error || '').slice(0, 60)}`)
  101. await ctx.cache.close()
  102. } catch (e) { P('P-3 修复分派校验(R3)', false, `探针异常: ${e.message}`) }
  103. // P-4 (B1):坏 front matter 章 → rebuild warning 不再静默;主链由序0 拦(上轮 REFUTED 主链保持)
  104. try {
  105. const ctx = await makeBook()
  106. for (const c of [1, 2, 3]) await finalizeChapter(ctx, payloadOf(c))
  107. const chDir = path.join(ctx.repoPath, '定稿', '正文')
  108. const f3 = (await fs.readdir(chDir)).find((f) => f.startsWith('0003-'))
  109. await fs.writeFile(path.join(chDir, f3), '---\n章号: [broken\n---\n正文', 'utf8')
  110. const rb = await ctx.cache.rebuildFromSource(ctx.repoPath)
  111. const next = await determineNextState(ctx)
  112. P('P-4 解析失败有warning(B1)', (rb.warnings || []).length > 0 && next.序 === 0,
  113. `rebuild.ok=${rb.ok} warnings=${(rb.warnings || []).length} 条 next序=${next.序}(序0 修复确认拦住重抄)`)
  114. await ctx.cache.close()
  115. } catch (e) { P('P-4 解析失败有warning(B1)', false, `探针异常: ${e.message}`) }
  116. // P-4b (B2):同章号两个合法文件 → UNIQUE 硬错冒泡人话报错,不再静默丢章
  117. try {
  118. const ctx = await makeBook()
  119. for (const c of [1, 2, 3]) await finalizeChapter(ctx, payloadOf(c))
  120. const chDir = path.join(ctx.repoPath, '定稿', '正文')
  121. const f3 = (await fs.readdir(chDir)).find((f) => f.startsWith('0003-'))
  122. await fs.copyFile(path.join(chDir, f3), path.join(chDir, '0003-旧稿备份.md'))
  123. const rb = await ctx.cache.rebuildFromSource(ctx.repoPath)
  124. const errText = (rb.errors || []).join(';') // rebuild 返回 errors 数组(非 error 单数)
  125. P('P-4b 重号硬错冒泡(B2)', rb.ok === false && /重复档案|同章号/.test(errText),
  126. `rebuild.ok=${rb.ok} err=${errText.slice(0, 80)}`)
  127. await ctx.cache.close()
  128. } catch (e) { P('P-4b 重号硬错冒泡(B2)', false, `探针异常: ${e.message}`) }
  129. // P-5 (R5):sqlite v6 迁移书 → list-characters 可见、entities.type 为英文 machine 值
  130. try {
  131. const workdir = path.join(tmpRoot, 'workdir-g2')
  132. await fs.mkdir(path.join(workdir, '.webnovel'), { recursive: true })
  133. const { tempV6Sqlite } = await m('test/migrate/_v6.js')
  134. const v6 = await tempV6Sqlite()
  135. const mig = await migrateV6({ workdir }, v6.v6Path)
  136. const repo = path.join(workdir, '潮汐之下')
  137. const cache = new CacheManager(path.join(repo, '.cache', 'index.db'))
  138. await cache.ensureReady(repo)
  139. const lc = await listCharacters([], {}, { repoPath: repo, cache })
  140. const rows = await cache.query('SELECT type FROM entities', [])
  141. const machineTypes = rows.every((r) => /^[a-z]+$/.test(r.type))
  142. P('P-5 迁移书角色可见(R5)', mig.ok && lc.ok && (lc.output || '').includes('江遥') && machineTypes,
  143. `migrate.ok=${mig.ok} 含江遥=${(lc.output || '').includes('江遥')} type全machine值=${machineTypes} [${rows.map((r) => r.type).join(',')}]`)
  144. await cache.close()
  145. await v6.cleanup()
  146. } catch (e) { P('P-5 迁移书角色可见(R5)', false, `探针异常: ${e.message}`) }
  147. // P-6 (R6):名册全角/顿号混合分隔别名 → resolveAlias 全命中(英文类型恒等收纳兼验)
  148. try {
  149. const ctx = await makeBook()
  150. await fs.mkdir(path.join(ctx.repoPath, '定稿', '设定'), { recursive: true })
  151. await fs.writeFile(path.join(ctx.repoPath, '定稿', '设定', '名册.md'),
  152. '| 正名 | 别名 | 类型 | 首现章 |\n|---|---|---|---|\n| 林晚 | 阿晚,晚儿、小晚 | character | 1 |\n', 'utf8')
  153. await ctx.cache.rebuildFromSource(ctx.repoPath)
  154. const er = new EntityReader(ctx.repoPath, ctx.cache)
  155. const hits = []
  156. for (const a of ['阿晚', '晚儿', '小晚']) hits.push((await er.resolveAlias(a)).ok)
  157. P('P-6 别名中文分隔全命中(R6)', hits.every(Boolean), `resolveAlias(阿晚/晚儿/小晚)=[${hits}]`)
  158. await ctx.cache.close()
  159. } catch (e) { P('P-6 别名中文分隔全命中(R6)', false, `探针异常: ${e.message}`) }
  160. // P-7/P-7b (R4):workspaceFiles 含目录(带/不带 工作区/ 前缀)→ ok:true + commit 在 + 目录清掉
  161. try {
  162. for (const [id, rel] of [['P-7 目录带前缀', '工作区/评审报告'], ['P-7b 目录裸名', '评审报告']]) {
  163. const ctx = await makeBook()
  164. await fs.mkdir(path.join(ctx.repoPath, '工作区', '评审报告'), { recursive: true })
  165. await fs.writeFile(path.join(ctx.repoPath, '工作区', '评审报告', 'a.md'), 'x', 'utf8')
  166. const r = await finalizeChapter(ctx, payloadOf(1, { workspaceFiles: [rel] }))
  167. const committed = await createGit(ctx.repoPath).findChapterCommit(1)
  168. const dirLeft = await fs.access(path.join(ctx.repoPath, '工作区', '评审报告')).then(() => true, () => false)
  169. P(`${id}(R4)`, r.ok && !!committed && !dirLeft,
  170. `finalize.ok=${r.ok} commit=${committed ? '在' : '无'} 目录残留=${dirLeft} warnings=${(r.warnings || []).length}`)
  171. await ctx.cache.close()
  172. }
  173. } catch (e) { P('P-7 finalize清理(R4)', false, `探针异常: ${e.message}`) }
  174. console.log('\n===== 修复后探针汇总 =====')
  175. for (const r of results) console.log(`${r.pass ? 'PASS' : 'FAIL'} | ${r.id}`)
  176. const failed = results.filter((r) => !r.pass).length
  177. console.log(failed ? `\n${failed} 项未过` : '\n全部通过')
  178. await fs.rm(tmpRoot, { recursive: true, force: true })
  179. process.exitCode = failed ? 1 : 0