reliability-fix2.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. /**
  2. * 可靠性修复批 2(F1/F4/F5)验收。
  3. *
  4. * F1 语义硬约束契约:出现/禁止两类确定性规则+需审读降级;约束原句不当正文证据。
  5. * F4 目标章边界:材料不泄漏目标章之后的事实;未定归属标注不确定性。
  6. * F5 审稿哈希绑定:同稿有效、换稿失效、恢复视图准确;不加硬闸门。
  7. */
  8. import { afterAll, beforeEach, describe, expect, it } from 'vitest'
  9. import { spawnSync } from 'node:child_process'
  10. import * as fs from 'node:fs'
  11. import * as nodePath from 'node:path'
  12. import * as os from 'node:os'
  13. import {
  14. computeMaterials,
  15. classifyHardConstraint,
  16. confirmOutline,
  17. draftHashOf,
  18. seedMinDesign,
  19. serializeDocument,
  20. writeCandidate,
  21. paths,
  22. writeContract,
  23. type Finding,
  24. } from '../src/index'
  25. import { ingestFindings, registerDefaultChecks, resetChecks, runReview } from '@webnovel/review'
  26. const roots: string[] = []
  27. function mkBook(withGit = true): string {
  28. const dir = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'webnovel-rel2-'))
  29. roots.push(dir)
  30. seedMinDesign(dir)
  31. if (withGit) {
  32. const git = (args: readonly string[]): void => { spawnSync('git', args, { cwd: dir, windowsHide: true }) }
  33. git(['init'])
  34. git(['config', 'user.email', 'test@example.com'])
  35. git(['config', 'user.name', 'test'])
  36. git(['config', 'commit.gpgsign', 'false'])
  37. }
  38. return dir
  39. }
  40. afterAll(() => { for (const r of roots) { try { fs.rmSync(r, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟释放 */ } } })
  41. beforeEach(() => { resetChecks(); registerDefaultChecks() })
  42. const key = { 卷: 1, 章: 1, 章名: '开篇任务' } as const
  43. // ── F1 ──
  44. describe('F1:硬约束判定契约', () => {
  45. it('分类器:禁止出现类、必须出现类、语义类需审读', () => {
  46. expect(classifyHardConstraint('- 〔硬〕本章不得出现「靛蓝色旗幟」')).toEqual({ kind: '禁止', term: '靛蓝色旗幟', 出处: '禁止规则(不得出现):本章不得出现「靛蓝色旗幟」' })
  47. expect(classifyHardConstraint('- 〔硬〕本章必须出现郑姓旗官').kind).toBe('出现')
  48. const 语义 = classifyHardConstraint('- 〔硬〕主角本章不得杀人')
  49. expect(语义.kind).toBe('需审读')
  50. expect(语义.出处).toContain('主角本章不得杀人')
  51. })
  52. it('误报消除:救人叙事不再被「不得杀人」禁令误报(需审读,非违反非通过)', () => {
  53. const root = mkBook(false)
  54. const body = [
  55. '# 章细纲', '', '## 定位段', '',
  56. '### 来源窗口项及拆并关系', '开篇任务,单章承接', '',
  57. '### 章节功能', '', '- 〔硬〕主角本章不得杀人', '',
  58. '### 视角与焦点', '主角视角', '',
  59. '### 时空锚定', '城门口,清晨', '',
  60. '### 起止边界', '从抵达城门到发现异状', '',
  61. '### 故事线与承诺分配', '推进主线承诺', '',
  62. '### 信息边界', '只披露主角所见', '',
  63. '### 情绪与节奏目标', '紧张后留钩子', '',
  64. '### 前置条件核对结果', '已核对来源与窗口,前置设定均非留白', '',
  65. '## 细纲段', '', '### 单元 1', '',
  66. '- 目标: 救下遇险孩童', '- 人物: 主角', '- 时空: 城门口',
  67. '- 行动/冲突: 冲入人群护住孩童', '- 信息披露: 无', '- 状态变化: 警觉', '',
  68. ].join('\n')
  69. writeCandidate(root, { 卷: 1, 章: 1, 章名: key.章名, 来源引用: ['作品契约/契约.md@1', '世界书/人物档案/主角.md@1'], body })
  70. expect(confirmOutline(root, key).ok).toBe(true)
  71. const draftDir = nodePath.join(root, '草稿区/草稿/卷01-开篇任务')
  72. fs.mkdirSync(draftDir, { recursive: true })
  73. fs.writeFileSync(nodePath.join(draftDir, '稿1.md'), serializeDocument({ 角色: '待审稿', 选定: true }, '沈青梧冲进人群护住孩童,把他带到城墙上。'), 'utf-8')
  74. const r = runReview(root, key)
  75. // 不得杀人 这条没有产「违反」发现项(合规叙事),而是 需审读
  76. const 违反 = (r.record?.问题 ?? []).filter((f) => f.问题说明.includes('禁止规则'))
  77. expect(违反).toEqual([])
  78. const 需审读 = (r.record?.问题 ?? []).filter((f) => f.问题说明.startsWith('需审读') && f.问题说明.includes('不得杀人'))
  79. expect(需审读.length).toBe(1)
  80. })
  81. it('漏报堵住:正文含约束原句不再等于满足(需审读,不伪装通过)', () => {
  82. const root = mkBook(false)
  83. const body = [
  84. '# 章细纲', '', '## 定位段', '',
  85. '### 来源窗口项及拆并关系', '开篇任务,单章承接', '',
  86. '### 章节功能', '', '- 〔硬〕主角本章不得杀人', '',
  87. '### 视角与焦点', '主角视角', '',
  88. '### 时空锚定', '城门口,清晨', '',
  89. '### 起止边界', '从抵达城门到发现异状', '',
  90. '### 故事线与承诺分配', '推进主线承诺', '',
  91. '### 信息边界', '只披露主角所见', '',
  92. '### 情绪与节奏目标', '紧张后留钩子', '',
  93. '### 前置条件核对结果', '已核对来源与窗口,前置设定均非留白', '',
  94. '## 细纲段', '', '### 单元 1', '',
  95. '- 目标: 押送死囚', '- 人物: 主角', '- 时空: 城门口',
  96. '- 行动/冲突: 死囚念叨「主角本章不得杀人」这句话', '- 信息披露: 无', '- 状态变化: 厌恶', '',
  97. ].join('\n')
  98. writeCandidate(root, { 卷: 1, 章: 1, 章名: key.章名, 来源引用: ['作品契约/契约.md@1', '世界书/人物档案/主角.md@1'], body })
  99. expect(confirmOutline(root, key).ok).toBe(true)
  100. const draftDir = nodePath.join(root, '草稿区/草稿/卷01-开篇任务')
  101. fs.mkdirSync(draftDir, { recursive: true })
  102. fs.writeFileSync(nodePath.join(draftDir, '稿1.md'), serializeDocument({ 角色: '待审稿', 选定: true }, '死囚在囚车里反复念着「主角本章不得杀人」这句话,像在挑衅。'), 'utf-8')
  103. const r = runReview(root, key)
  104. // 旧实现:正文包含约束原句→零发现(假通过)。F1:语义类→需审读,不伪装通过
  105. const 需审读 = (r.record?.问题 ?? []).filter((f) => f.问题说明.startsWith('需审读'))
  106. expect(需审读.length).toBe(1)
  107. // 证据位置不得是约束原文(它是规则出处,不是正文证据)
  108. expect(需审读[0]!.证据位置).not.toContain('死囚在囚车里')
  109. })
  110. it('禁止类确定性规则:禁词在正文 → 违反发现项,带规则出处;不在 → 零发现', () => {
  111. const root = mkBook(false)
  112. const body = [
  113. '# 章细纲', '', '## 定位段', '',
  114. '### 来源窗口项及拆并关系', '开篇任务,单章承接', '',
  115. '### 章节功能', '', '- 〔硬〕本章不得出现「靛蓝色旗幟」', '',
  116. '### 视角与焦点', '主角视角', '',
  117. '### 时空锚定', '城门口,清晨', '',
  118. '### 起止边界', '从抵达城门到发现异状', '',
  119. '### 故事线与承诺分配', '推进主线承诺', '',
  120. '### 信息边界', '只披露主角所见', '',
  121. '### 情绪与节奏目标', '紧张后留钩子', '',
  122. '### 前置条件核对结果', '已核对来源与窗口,前置设定均非留白', '',
  123. '## 细纲段', '', '### 单元 1', '',
  124. '- 目标: 巡旗', '- 人物: 主角', '- 时空: 城门口',
  125. '- 行动/冲突: 发现旗色不对', '- 信息披露: 无', '- 状态变化: 疑心', '',
  126. ].join('\n')
  127. writeCandidate(root, { 卷: 1, 章: 1, 章名: key.章名, 来源引用: ['作品契约/契约.md@1', '世界书/人物档案/主角.md@1'], body })
  128. expect(confirmOutline(root, key).ok).toBe(true)
  129. const draftDir = nodePath.join(root, '草稿区/草稿/卷01-开篇任务')
  130. fs.mkdirSync(draftDir, { recursive: true })
  131. // 合规正文(无禁词)→ 零发现
  132. fs.writeFileSync(nodePath.join(draftDir, '稿1.md'), serializeDocument({ 角色: '待审稿', 选定: true }, '沈青梧巡看旗幟,全是玄色。'), 'utf-8')
  133. expect(runReview(root, key).record?.问题.filter((f) => f.模块名 === '设定与时序核对')).toEqual([])
  134. // 违规正文(出现禁词)→ 违反发现项,出处带规则名
  135. fs.writeFileSync(nodePath.join(draftDir, '稿1.md'), serializeDocument({ 角色: '待审稿', 选定: true }, '城头一面粉色的靛蓝色旗幟格外扎眼。'), 'utf-8')
  136. const r2 = runReview(root, key)
  137. const 违反 = (r2.record?.问题 ?? []).filter((f) => f.问题说明.includes('禁止规则'))
  138. expect(违反.length).toBe(1)
  139. expect(违反[0]!.问题说明).toContain('本章不得出现「靛蓝色旗幟」')
  140. })
  141. })
  142. // ── F4 ──
  143. describe('F4:目标章边界(世界已发生)', () => {
  144. function writeLedgers(root: string): void {
  145. fs.mkdirSync(nodePath.join(root, '账本'), { recursive: true })
  146. fs.writeFileSync(nodePath.join(root, '账本/时间线.md'), [
  147. '# 时间线', '',
  148. '## 第51章事件', '章号:第0051章', '### 正文', '第51章发生换防', '',
  149. '## 第199章事件', '章号:第0199章', '### 正文', '第199章发生政变', '',
  150. '## 第200章事件', '章号:第0200章', '### 正文', '第200章围城', '',
  151. '## 时序不明事件', '### 正文', '有人影一闪而过', '',
  152. ].join('\n'), 'utf-8')
  153. fs.writeFileSync(nodePath.join(root, '账本/人物弧线.md'), [
  154. '# 人物弧线', '',
  155. '## 配角线', '类型:人物弧线', '状态:已兑现', '计划来源:大纲/故事骨架.md@1',
  156. '实际落点:定稿/卷01/0199-转折.md', '来源:定稿/卷01/0199-转折.md',
  157. '### 正文', '配角在第199章兑现', '',
  158. ].join('\n'), 'utf-8')
  159. }
  160. function confirmFor(root: string, 卷: number, 章: number, 章名: string): void {
  161. const nn = String(卷).padStart(2, '0')
  162. const refs = ['作品契约/契约.md@1']
  163. const body = [
  164. '# 章细纲', '', '## 定位段', '',
  165. '### 来源窗口项及拆并关系', `${章名},单章承接`, '',
  166. '### 章节功能', '', '- 〔软〕推进主线', '',
  167. '### 视角与焦点', '主角视角', '',
  168. '### 时空锚定', '城门口', '',
  169. '### 起止边界', `${章名}全程`, '',
  170. '### 故事线与承诺分配', '推进主线', '',
  171. '### 信息边界', '只披露主角所见', '',
  172. '### 情绪与节奏目标', '平缓', '',
  173. '### 前置条件核对结果', '已核对', '',
  174. '## 细纲段', '', '### 单元 1', '',
  175. '- 目标: 推进', '- 人物: 主角', '- 时空: 城门',
  176. '- 行动/冲突: 推进', '- 信息披露: 无', '- 状态变化: 推进', '',
  177. ].join('\n')
  178. writeCandidate(root, { 卷, 章, 章名, 来源引用: refs, body })
  179. // 近期窗口须有匹配条目,确认门槛才过
  180. fs.mkdirSync(nodePath.join(root, '大纲/卷规划', `卷${nn}`), { recursive: true })
  181. if (!fs.existsSync(nodePath.join(root, '大纲/卷规划', `卷${nn}`, '近期窗口.md'))) {
  182. fs.writeFileSync(nodePath.join(root, '大纲/卷规划', `卷${nn}`, '近期窗口.md'), `# 近期窗口\n\n- ${章名} 〔已确认〕\n`, 'utf-8')
  183. }
  184. const c = confirmOutline(root, { 卷, 章, 章名 })
  185. if (!c.ok) throw new Error(`confirm failed:${c.gaps.join(';')}`)
  186. }
  187. function segOf(root: string, 卷: number, 章: number, 章名: string): string {
  188. const r = computeMaterials(root, { 卷, 章, 章名 })
  189. expect(r.ok).toBe(true)
  190. if (!r.ok) return ''
  191. return r.文件?.find((f) => f.relPath.includes('03-当前事实与连续性'))?.content ?? ''
  192. }
  193. function seg51(root: string): string {
  194. return segOf(root, 51, 51, '换防')
  195. }
  196. function seg200(root: string): string {
  197. return segOf(root, 200, 200, '围城')
  198. }
  199. it('第 51 章材料:不含第 199/200 章事实', () => {
  200. const root = mkBook(false)
  201. writeLedgers(root)
  202. confirmFor(root, 51, 51, '换防')
  203. const seg = seg51(root)
  204. expect(seg).toContain('第51章发生换防')
  205. expect(seg).not.toContain('第199章发生政变')
  206. expect(seg).not.toContain('第200章围城')
  207. expect(seg).not.toContain('配角线')
  208. })
  209. it('第 200 章材料:历史事实(第51章)仍可见', () => {
  210. const root = mkBook(false)
  211. writeLedgers(root)
  212. confirmFor(root, 200, 200, '围城')
  213. const seg = seg200(root)
  214. expect(seg).toContain('第51章发生换防')
  215. })
  216. it('时序未定条目 → 正文带来源与不确定性标注;清单选用原因记录条数', () => {
  217. const root = mkBook(false)
  218. writeLedgers(root)
  219. confirmFor(root, 51, 51, '换防')
  220. const seg = seg51(root)
  221. expect(seg).toContain('时序未定;来源:账本/时间线.md')
  222. const r = computeMaterials(root, { 卷: 51, 章: 51, 章名: '换防' })
  223. expect(r.ok).toBe(true)
  224. if (!r.ok) return
  225. const record = r.段.find((x) => x.段 === '当前事实与连续性')
  226. expect(record?.选用原因).toContain('时序未定条目')
  227. })
  228. })
  229. describe('F5:审稿哈希绑定', () => {
  230. function setup(root: string, 正文: string): void {
  231. const refs = ['作品契约/契约.md@1']
  232. const body = [
  233. '# 章细纲', '', '## 定位段', '',
  234. '### 来源窗口项及拆并关系', '开篇任务,单章承接', '',
  235. '### 章节功能', '', '- 〔软〕推进开场', '',
  236. '### 视角与焦点', '主角视角', '',
  237. '### 时空锚定', '城门口,清晨', '',
  238. '### 起止边界', '从抵达城门到发现异状', '',
  239. '### 故事线与承诺分配', '推进主线承诺', '',
  240. '### 信息边界', '只披露主角所见', '',
  241. '### 情绪与节奏目标', '紧张后留钩子', '',
  242. '### 前置条件核对结果', '已核对来源与窗口,前置设定均非留白', '',
  243. '## 细纲段', '', '### 单元 1', '',
  244. '- 目标: 就任', '- 人物: 主角', '- 时空: 城门',
  245. '- 行动/冲突: 接印', '- 信息披露: 无', '- 状态变化: 到任', '',
  246. ].join('\n')
  247. writeCandidate(root, { 卷: 1, 章: 1, 章名: key.章名, 来源引用: refs, body })
  248. expect(confirmOutline(root, key).ok).toBe(true)
  249. const draftDir = nodePath.join(root, '草稿区/草稿/卷01-开篇任务')
  250. fs.mkdirSync(draftDir, { recursive: true })
  251. fs.writeFileSync(nodePath.join(draftDir, '稿1.md'), serializeDocument({ 角色: '待审稿', 选定: true }, 正文), 'utf-8')
  252. const r = runReview(root, key)
  253. if (!r.ok) throw new Error(`runReview failed:${r.reason ?? ''}`)
  254. // 回写全部隔离子 Agent 模块(空审 fail-closed:回写后才算完成)
  255. for (const [name, st] of Object.entries(r.record?.模块 ?? {})) {
  256. if (st.待回写 === true) {
  257. expect(ingestFindings(root, key, name, []).ok).toBe(true)
  258. }
  259. }
  260. }
  261. it('同稿:审核完成事实保持,记录绑定该稿哈希', () => {
  262. const root = mkBook()
  263. setup(root, '沈青梧接印,查看城防。')
  264. const rec = JSON.parse(fs.readFileSync(nodePath.join(root, '草稿区/审核/卷01-开篇任务.json'), 'utf-8'))
  265. expect(typeof rec.审稿哈希).toBe('string')
  266. expect(rec.完成).toBe(true)
  267. })
  268. it('正文改变(作者导入不同新稿) → 完成事实翻转 false,叠加过期标记;作者仍可推进', () => {
  269. const root = mkBook()
  270. setup(root, '沈青梧接印,查看城防。')
  271. const before = JSON.parse(fs.readFileSync(nodePath.join(root, '草稿区/审核/卷01-开篇任务.json'), 'utf-8'))
  272. expect(before.完成).toBe(true)
  273. // 作者导入完全不同的新稿
  274. const draftDir = nodePath.join(root, '草稿区/草稿/卷01-开篇任务')
  275. fs.writeFileSync(nodePath.join(draftDir, '稿2.md'), serializeDocument({ 角色: '待审稿', 选定: true, 生成模块: '作者手改' }, '完全重写的另一版正文,内容天差地别。'), 'utf-8')
  276. fs.rmSync(nodePath.join(draftDir, '稿1.md'), { force: true })
  277. const scan = scanChapter(root, key)
  278. // 审核完成事实因哈希失配不再成立;叠加标记提示过期
  279. expect(scan.审核完成).toBe(false)
  280. expect(scan.审核证据过期).toBe(true)
  281. expect(scan.需复核标记.some((m) => m.includes('过期'))).toBe(true)
  282. })
  283. it('恢复视图:旧记录完成后导入新稿,derive 停在审核(须重审),不冒充定稿准备', () => {
  284. const root = mkBook()
  285. setup(root, '沈青梧接印,查看城防。')
  286. const draftDir = nodePath.join(root, '草稿区/草稿/卷01-开篇任务')
  287. fs.writeFileSync(nodePath.join(draftDir, '稿2.md'), serializeDocument({ 角色: '待审稿', 选定: true }, '重写稿。'), 'utf-8')
  288. fs.rmSync(nodePath.join(draftDir, '稿1.md'), { force: true })
  289. const facts = deriveFacts(root)
  290. expect(facts.建议.环节).toBe('审核')
  291. })
  292. })
  293. // 辅助:不经 derive 的轻量事实调用(从 core 导出 scanChapter/deriveChapterFacts)
  294. import { scanChapter, deriveChapterFacts } from '../src/index'
  295. function deriveFacts(root: string): ReturnType<typeof deriveChapterFacts> {
  296. return deriveChapterFacts(scanChapter(root, key))
  297. }