Просмотр исходного кода

fix(v7): 互斥守卫+序列化器+原子写时序——第三轮 review 批1(R1-R4/R7/R8/R12)

- R1/R2 goto 与手动 finalize 加 active 批次守卫,人话指引 finalize-batch/batch-discard
- R3 persistRepair 按文件类型分派校验器(book.yaml/名册/时间线不再被 front matter 一刀切锁死序0)
- R4/C1/C2/G-4/C4 finalize 清理 recursive+永不反转 ok;normalizeWorkspaceRel 单源(剥前缀+段级拒 ..);stageChapter 清理失败只记 warning(E4 顺带)
- R7/R8 yaml-dialect needsQuoting 补齐(空串/空格/数字变体/指示符起首/行内注释)+ 双引号分支完整转义(反斜杠/控制字符);30 条往返回归表
- R12 atomic restorePlan 加 renamedIn 三段式——写 tmp/备份 rename 先失败时不再误删从未动过的原文;双故障注入测试
lingfengQAQ 2 дней назад
Родитель
Сommit
b68bf6cd2c

+ 18 - 7
v7/src/commands/finalize.js

@@ -1,27 +1,37 @@
 import { finalizeChapter } from '../finalize/index.js'
+import { readBatch } from '../staging/index.js'
 import { readJsonInput } from '../util/json-input.js'
 
 /**
  * finalize <章号> --payload=<定稿包json路径>:原子 commit(正文入定稿、条目/设定/时间线更新、
- * 章摘要入档、工作区清理)+ 缓存刷新。payload 字段见 finalizeChapter。
- * workspaceFiles 是工作区内文件名;宿主写成「工作区/xx」也接受(此处归一,防静默漏清)。
+ * 章摘要入档、工作区清理)+ 缓存刷新。payload 字段见 finalizeChapter(workspaceFiles
+ * 的前缀归一与 `..` 防护在本体统一做,壳不再重复)。
+ * 有进行中的待定稿批次时拒绝手动定稿(R2):批次与手动定稿互斥,否则章号双计、
+ * finalize-batch 会撞「条目已存在」整批卡死。
  * 契约:纯返回 {ok, output?, error?}。
  */
 export async function run(args, options, ctx) {
   const chapterNum = parseInt(args[0], 10)
   if (isNaN(chapterNum)) return { ok: false, error: '章号必须是数字' }
 
+  const batch = await readBatch(ctx.repoPath)
+  if (batch.exists) {
+    const 尾 = batch.章列表[batch.章列表.length - 1].章号
+    const inBatch = batch.章列表.some((r) => r.章号 === chapterNum)
+    return {
+      ok: false,
+      error: inBatch
+        ? `第 ${chapterNum} 章已在待定稿批次中(批内第 ${batch.起章}-${尾} 章)。批内章请用 finalize-batch 批量转正;整批不要就用 batch-discard 丢弃。`
+        : `有进行中的待定稿批次(第 ${batch.起章}-${尾} 章),手动定稿会造成章号错位。请先用 finalize-batch 转正批次,或 batch-discard 丢弃批次,再手动定稿。`,
+    }
+  }
+
   const spec = await readJsonInput(ctx, options.payload ?? options.file, 'payload')
   if (!spec.ok) return { ok: false, error: spec.error }
   const payload = spec.data
   if (payload.chapterNum !== undefined && payload.chapterNum !== chapterNum) {
     return { ok: false, error: `章号不一致:命令行是 ${chapterNum},payload 里是 ${payload.chapterNum}` }
   }
-  if (Array.isArray(payload.workspaceFiles)) {
-    payload.workspaceFiles = payload.workspaceFiles.map((f) =>
-      String(f).replace(/^工作区[\\/]/, '')
-    )
-  }
 
   const r = await finalizeChapter(ctx, { ...payload, chapterNum })
   if (!r.ok) return { ok: false, error: r.error }
@@ -30,6 +40,7 @@ export async function run(args, options, ctx) {
   if (r.cacheRefresh && r.cacheRefresh.ok === false) {
     lines.push(`缓存刷新失败(下次命令会自动重建):${(r.cacheRefresh.errors || []).join(';')}`)
   }
+  for (const w of r.warnings || []) lines.push(w)
   lines.push('继续运行 next 判定下一步。')
   return { ok: true, output: lines.join('\n') }
 }

+ 1 - 5
v7/src/commands/stage-chapter.js

@@ -14,16 +14,12 @@ export async function run(args, options, ctx) {
   const spec = await readJsonInput(ctx, options.payload ?? options.file, 'payload')
   if (!spec.ok) return { ok: false, error: spec.error }
   const payload = spec.data
-  if (Array.isArray(payload.workspaceFiles)) {
-    payload.workspaceFiles = payload.workspaceFiles.map((f) =>
-      String(f).replace(/^工作区[\\/]/, '')
-    )
-  }
 
   const r = await stageChapter(ctx, { chapterNum, payload })
   if (!r.ok) return { ok: false, error: r.error }
 
   const lines = [`第 ${chapterNum} 章已暂存(批内 ${r.staged} 章,未入档——批量定稿时才 commit)。`]
+  for (const w of r.warnings || []) lines.push(w)
   if (r.停止.stop) {
     lines.push('批次到此为止,转人工:')
     for (const reason of r.停止.reasons) lines.push(`- ${reason}`)

+ 26 - 5
v7/src/finalize/index.js

@@ -8,6 +8,7 @@ import { SecretWriter } from '../storage/adapters/SecretWriter.js'
 import { SummaryWriter } from '../storage/adapters/SummaryWriter.js'
 import { createGit } from './git.js'
 import { refreshCacheAfterSourceChange } from '../cache/index.js'
+import { normalizeWorkspaceRel } from '../util/workspace-path.js'
 
 /**
  * 定稿:原子 commit(D3)。写工作树 → git add → commit → 最后清工作区。
@@ -138,12 +139,11 @@ export async function finalizeChapter(ctx, payload, opts = {}) {
     // 重建失败不阻断定稿(已 commit 入档),但不能继续保留旧缓存,否则 next 会读旧章号。
     cacheRefresh = await refreshCacheAfterSourceChange(ctx)
 
-    // 4. 清工作区(必须在 commit 成功之后)
-    for (const wf of workspaceFiles) {
-      await fs.rm(path.join(repoPath, '工作区', wf), { force: true })
-    }
+    // 4. 清工作区(必须在 commit 成功之后;失败只记 warning——
+    //    commit 已经发生,任何清理问题都不得把结果反转成"失败/已回滚"(R4))
+    const warnings = await cleanWorkspaceFiles(repoPath, workspaceFiles)
 
-    return { ok: true, commitHash, cacheRefresh, error: '' }
+    return { ok: true, commitHash, cacheRefresh, warnings, error: '' }
   } catch (err) {
     // commit 前中断:回滚本次 stage/rollback 集合(非整棵 定稿/大纲 子树,避免误伤同子树其他章手改)。
     // 逐文件 restore:新章文件未跟踪会让整条 restore 报错被吞,逐个跑才能精确复原已跟踪文件。
@@ -180,3 +180,24 @@ function buildCommitMessage(chapterNum, title, lines) {
   if (extras.length) msg += '\n\n' + extras.join('\n')
   return msg
 }
+
+/**
+ * commit 后的工作区清理:归一路径(剥前缀+拒 `..`,C2/G-4)、目录也能清(recursive),
+ * 任何失败只收 warning 永不抛——本函数在原子点之后运行,不许影响定稿结果。
+ */
+async function cleanWorkspaceFiles(repoPath, workspaceFiles) {
+  const warnings = []
+  for (const wf of workspaceFiles) {
+    const name = normalizeWorkspaceRel(wf)
+    if (!name) {
+      warnings.push(`工作区清理跳过可疑路径「${wf}」(不在工作区内)。`)
+      continue
+    }
+    try {
+      await fs.rm(path.join(repoPath, '工作区', name), { recursive: true, force: true })
+    } catch (err) {
+      warnings.push(`工作区/${name} 清理失败(${err.message})——本章已入档,稍后手动删除即可。`)
+    }
+  }
+  return warnings
+}

+ 18 - 5
v7/src/staging/index.js

@@ -4,6 +4,7 @@ import { parseFrontMatter } from '../storage/parsers/front-matter.js'
 import { serializeFrontMatter } from '../storage/serializers/front-matter.js'
 import { parseThreadDeclarations, OPENING_VERBS } from '../util/thread-declarations.js'
 import { sanitizeFileName } from '../util/filename.js'
+import { normalizeWorkspaceRel } from '../util/workspace-path.js'
 import { writeAtomicBatch } from '../storage/atomic.js'
 import { finalizeChapter } from '../finalize/index.js'
 import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
@@ -319,23 +320,35 @@ export async function stageChapter(ctx, { chapterNum, payload }) {
       metaFile(rows),
     ])
 
+    // 批次已落盘;此后的清理失败只记 warning,不得把已成功的暂存反转成失败(E4)
+    const warnings = []
+
     // 覆盖重暂存且标题变了:旧目录成孤儿,写成后清掉
     if (existing && existing.目录 !== dirName) {
-      await fs.rm(path.join(repoPath, BATCH_DIR, existing.目录), { recursive: true, force: true })
+      try {
+        await fs.rm(path.join(repoPath, BATCH_DIR, existing.目录), { recursive: true, force: true })
+      } catch (err) {
+        warnings.push(`旧章目录 ${existing.目录} 清理失败(${err.message}),不影响批次,可手动删除。`)
+      }
     }
 
     // 清本章工作区单章文件(已搬进批次目录;评审报告是本章两审产物一并清)
     const clears = new Set(['细纲.md', '本章写作材料.md', '草稿-A.md', '审稿.md', '评审报告'])
     for (const wf of payload.workspaceFiles || []) {
-      const name = String(wf).replace(/^工作区[\\/]/, '')
-      if (!name.includes('..')) clears.add(name)
+      const name = normalizeWorkspaceRel(wf)
+      if (name) clears.add(name)
+      else warnings.push(`工作区清理跳过可疑路径「${wf}」(不在工作区内)。`)
     }
     for (const wf of clears) {
-      await fs.rm(path.join(repoPath, '工作区', wf), { recursive: true, force: true })
+      try {
+        await fs.rm(path.join(repoPath, '工作区', wf), { recursive: true, force: true })
+      } catch (err) {
+        warnings.push(`工作区/${wf} 清理失败(${err.message}),本章已暂存,稍后手动删除即可。`)
+      }
     }
 
     const 停止 = await judgeStop(ctx, await readBatch(repoPath))
-    return { ok: true, 章号: chapterNum, staged: rows.length, 停止, error: '' }
+    return { ok: true, 章号: chapterNum, staged: rows.length, 停止, warnings, error: '' }
   } catch (err) {
     return { ok: false, error: `暂存第 ${chapterNum} 章失败:${err.message}` }
   }

+ 13 - 0
v7/src/state-machine/flows/goto-chapter.js

@@ -1,6 +1,7 @@
 import { createGit } from '../../finalize/git.js'
 import { checkGitHealth } from '../git-health.js'
 import { refreshCacheAfterSourceChange } from '../../cache/index.js'
+import { readBatch } from '../../staging/index.js'
 
 /**
  * 回到第 N 章(spec §9,git 回滚包装)。执行前展示影响范围 + 作者确认;
@@ -12,6 +13,18 @@ export async function gotoChapter(ctx, { chapterNum, confirm = false } = {}) {
   if (!Number.isInteger(chapterNum)) return { ok: false, error: '请指定要回到的章号' }
   const git = createGit(ctx.repoPath)
 
+  // R1:批次与回退互斥(spec §8.1「goto 管已定稿、丢弃批次管未定稿,职责不混」在此强制)。
+  // 批次是 gitignore 的工作区文件,reset 动不了它——放行会留下孤儿批次,
+  // 随后 finalize-batch 按原章号转正,定稿区出现静默断档。
+  const batch = await readBatch(ctx.repoPath)
+  if (batch.exists) {
+    const 尾 = batch.章列表[batch.章列表.length - 1].章号
+    return {
+      ok: false,
+      error: `有进行中的待定稿批次(第 ${batch.起章}-${尾} 章),回退会让批次成为孤儿、定稿章号断档。请先处理批次:finalize-batch 批量转正,或 batch-discard 丢弃,再回退。`,
+    }
+  }
+
   // P1-6:先跑 git 健康检查(此前漏跑,与状态机主入口一致)
   const gitHealth = await checkGitHealth(ctx)
 

+ 24 - 3
v7/src/state-machine/persist.js

@@ -2,6 +2,8 @@ import { promises as fs } from 'node:fs'
 import path from 'node:path'
 import { serializeYAML } from '../storage/serializers/yaml-dialect.js'
 import { parseFrontMatter } from '../storage/parsers/front-matter.js'
+import { parseMarkdownTable } from '../storage/parsers/markdown-table.js'
+import { parseBookConfig } from '../storage/parsers/book-config.js'
 import { writeAtomicBatch } from '../storage/atomic.js'
 import { createGit } from '../finalize/git.js'
 import { refreshCacheAfterSourceChange } from '../cache/index.js'
@@ -111,6 +113,25 @@ export async function persistVolumeReview(ctx, { 卷号, 卷摘要, 下卷卷纲
   }
 }
 
+/**
+ * 序0 修复回写的内容校验:按文件类型分派解析器(R3)——
+ * 与 detectors.detectParseFailures 的检测器选型一刀对齐:book.yaml 是纯 YAML、
+ * 名册/时间线是纯表格,用 parseFrontMatter 一刀切会把合法修复拒之门外,书锁死在序 0。
+ */
+function validateRepairContent(file, content) {
+  const rel = String(file).replaceAll('\\', '/')
+  if (rel === 'book.yaml') {
+    const r = parseBookConfig(content)
+    return r.ok ? { ok: true } : { ok: false, error: r.error }
+  }
+  if (rel === '定稿/设定/名册.md' || rel.startsWith('定稿/设定/时间线/')) {
+    const r = parseMarkdownTable(content)
+    return r.ok ? { ok: true } : { ok: false, error: r.error }
+  }
+  const r = parseFrontMatter(content)
+  return r.ok ? { ok: true } : { ok: false, error: r.error }
+}
+
 /**
  * 序0 修复确认 → 写回修复后的源文件。安全网:
  * 只写在 allowedFiles(M3 检测到的失败清单)内的文件;修复内容必须能解析,否则不写。
@@ -120,9 +141,9 @@ export async function persistRepair(ctx, { repairs }, { allowedFiles = [] } = {}
     if (!allowedFiles.includes(r.file)) {
       return { ok: false, written: [], error: `拒绝写入非失败清单文件:${r.file}` }
     }
-    const parsed = parseFrontMatter(r.content)
-    if (!parsed.ok) {
-      return { ok: false, written: [], error: `修复内容仍解析失败(${r.file}):${parsed.error}` }
+    const check = validateRepairContent(r.file, r.content)
+    if (!check.ok) {
+      return { ok: false, written: [], error: `修复内容仍解析失败(${r.file}):${check.error}` }
     }
   }
   try {

+ 7 - 4
v7/src/storage/atomic.js

@@ -22,7 +22,7 @@ export async function writeAtomicBatch(repoPath, files) {
       const n = counter++
       const tmp = `${full}.wnwtmp.${process.pid}.${n}`
       const backup = `${full}.wnwbackup.${process.pid}.${n}`
-      const plan = { tmp, final: full, backup, existed: false, rel: f.path }
+      const plan = { tmp, final: full, backup, existed: false, renamedIn: false, rel: f.path }
       plans.push(plan)
       await fs.writeFile(tmp, f.content, 'utf8')
 
@@ -38,6 +38,7 @@ export async function writeAtomicBatch(repoPath, files) {
 
     for (const p of plans) {
       await fs.rename(p.tmp, p.final)
+      p.renamedIn = true
     }
     for (const p of plans) {
       if (p.existed) await fs.rm(p.backup, { force: true })
@@ -58,11 +59,13 @@ async function restorePlan(plan) {
     // 尽力回滚
   }
   try {
-    if (plan.existed) {
+    // 只删本次真正写入过 final 的内容;existed=false 且未 renamedIn 时 final 位置
+    // 是从未动过的原文件(写 tmp/备份 rename 先失败的场景),绝不能删(R12)。
+    if (plan.renamedIn) {
       await fs.rm(plan.final, { force: true })
+    }
+    if (plan.existed) {
       await fs.rename(plan.backup, plan.final)
-    } else {
-      await fs.rm(plan.final, { force: true })
     }
   } catch {
     // 尽力回滚;调用方会收到原始错误

+ 43 - 13
v7/src/storage/serializers/yaml-dialect.js

@@ -63,27 +63,51 @@ function serializeValue(value) {
 
   // 字符串:判断是否需要引号
   if (needsQuoting(value)) {
-    // 简单引号转义(双引号内的双引号转义为 \")
-    const escaped = value.replace(/"/g, '\\"')
-    return `"${escaped}"`
+    return `"${escapeDoubleQuoted(value)}"`
   }
 
   return value
 }
 
+/**
+ * 双引号标量的完整转义:反斜杠必须最先转,随后引号与控制字符——
+ * 漏任何一类都会让写出的 front matter 自己读不回(R8)。
+ */
+function escapeDoubleQuoted(value) {
+  return value
+    .replace(/\\/g, '\\\\')
+    .replace(/"/g, '\\"')
+    .replace(/\n/g, '\\n')
+    .replace(/\r/g, '\\r')
+    .replace(/\t/g, '\\t')
+    // 其余 C0 控制字符转 \xXX,防不可见字符炸解析
+    // eslint-disable-next-line no-control-regex
+    .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
+}
+
 /**
  * 判断字符串是否需要引号(防止被 YAML 误判类型)。
  * @param {string} value
  * @returns {boolean}
  */
 function needsQuoting(value) {
-  // 纯数字字符串:123 → "123"
-  if (/^\d+$/.test(value)) {
+  // 空串裸写会变 null;前后空格裸写会被裁
+  if (value === '' || value !== value.trim()) {
+    return true
+  }
+
+  // 数字形态全家:整数/浮点/带符号/科学计数(1e3、+5、-2.5E-3)
+  if (/^[+-]?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(value)) {
+    return true
+  }
+
+  // 进制前缀数字:0x1F / 0o17 / 0b101
+  if (/^0[xX][0-9a-fA-F]+$/.test(value) || /^0[oO][0-7]+$/.test(value) || /^0[bB][01]+$/.test(value)) {
     return true
   }
 
-  // 浮点数:1.23 → "1.23"
-  if (/^\d+\.\d+$/.test(value)) {
+  // 特殊浮点字面值:.inf / .nan(含符号与大小写变体)
+  if (/^[+-]?\.(inf|Inf|INF)$/.test(value) || /^\.(nan|NaN|NAN)$/.test(value)) {
     return true
   }
 
@@ -92,8 +116,8 @@ function needsQuoting(value) {
     return true
   }
 
-  // null 字面值:null/Null/NULL → "null"
-  if (/^(null|Null|NULL)$/i.test(value)) {
+  // null 字面值:null/Null/NULL/~ → "null"
+  if (/^(null|Null|NULL|~)$/.test(value)) {
     return true
   }
 
@@ -102,8 +126,13 @@ function needsQuoting(value) {
     return true
   }
 
-  // 以 # 开头(注释):#comment → "#comment"
-  if (value.startsWith('#')) {
+  // 空格+井号 = 行内注释起点,后半截会被当注释吞掉
+  if (value.includes(' #')) {
+    return true
+  }
+
+  // 起首 YAML 指示符/引号/别名锚点等:[ ] { } * & ! | > @ % ` " ' , 以及 # 与 ? 后随空白
+  if (/^[[\]{}*&!|>@%`"',#]/.test(value) || /^\?(\s|$)/.test(value)) {
     return true
   }
 
@@ -112,8 +141,9 @@ function needsQuoting(value) {
     return true
   }
 
-  // 包含换行符
-  if (value.includes('\n')) {
+  // 包含换行/制表等控制字符
+  // eslint-disable-next-line no-control-regex
+  if (/[\x00-\x1f]/.test(value)) {
     return true
   }
 

+ 17 - 0
v7/src/util/workspace-path.js

@@ -0,0 +1,17 @@
+import path from 'node:path'
+
+/**
+ * 工作区相对路径归一(R4/C2/G-4/C4 单源):剥「工作区/」前缀 + 按路径段拒绝 `..` 穿越。
+ * 拒绝时返回 null——调用方跳过并告警,不得静默清到工作区外。
+ * 段级检查而非子串检查:`笔记..草稿.md` 是合法文件名,不误拦。
+ * @param {string} name 宿主给的工作区文件名(可能带「工作区/」前缀)
+ * @returns {string|null}
+ */
+export function normalizeWorkspaceRel(name) {
+  const stripped = String(name).replace(/^工作区[\\/]/, '').replace(/[\\/]+$/, '')
+  if (!stripped) return null
+  if (path.isAbsolute(stripped)) return null
+  const segments = stripped.split(/[\\/]/).filter(Boolean)
+  if (!segments.length || segments.some((s) => s === '..')) return null
+  return segments.join(path.sep)
+}

+ 61 - 0
v7/test/commands/finalize-guard.test.js

@@ -0,0 +1,61 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { run as finalizeCmd } from '../../src/commands/finalize.js'
+import { tempBookCtx } from './_helper.js'
+
+// R2:批次进行中的手动 finalize 必须被拒——放行会让 overlay 双计总章数,
+// 且 finalize-batch 再转正同章时撞「条目已存在」整批卡死。守卫在读 payload 之前,
+// 故测试无需构造定稿包。
+async function injectBatch(root, nums) {
+  const rows = []
+  for (const n of nums) {
+    const dirName = `${String(n).padStart(4, '0')}-批内章`
+    await fs.mkdir(path.join(root, '工作区', '待定稿', dirName), { recursive: true })
+    rows.push({ 章号: n, 标题: '批内章', 状态: '待审收', 目录: dirName })
+  }
+  await fs.writeFile(
+    path.join(root, '工作区', '待定稿', '批次.json'),
+    JSON.stringify({ 章列表: rows }),
+    'utf8'
+  )
+}
+
+test('R2:目标章在批内 → 手动 finalize 被拒,指向 finalize-batch', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    await injectBatch(ctx.repoPath, [3, 4])
+    const r = await finalizeCmd(['3'], {}, ctx)
+    assert.equal(r.ok, false)
+    assert.match(r.error, /已在待定稿批次/)
+    assert.match(r.error, /finalize-batch/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('R2:批次在场但目标章不在批内 → 同样被拒(防章号错位)', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    await injectBatch(ctx.repoPath, [3, 4])
+    const r = await finalizeCmd(['9'], {}, ctx)
+    assert.equal(r.ok, false)
+    assert.match(r.error, /待定稿批次/)
+    assert.match(r.error, /batch-discard/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('无批次时 finalize 不受守卫影响(走正常校验路径)', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    const r = await finalizeCmd(['3'], {}, ctx)
+    // 没给 payload:应报缺 payload 而不是批次守卫错
+    assert.equal(r.ok, false)
+    assert.doesNotMatch(r.error, /批次/)
+  } finally {
+    await cleanup()
+  }
+})

+ 27 - 0
v7/test/finalize/finalize.test.js

@@ -246,6 +246,33 @@ test('finalizeChapter threadCreates 断电回滚:新条目文件不残留', as
   }
 })
 
+// R4/C1/C2:commit 之后的清理问题绝不能反转定稿结果,也绝不能清到工作区外。
+test('finalizeChapter 清理遇目录与可疑路径:仍 ok、目录被清、逃逸被拒', async () => {
+  const { ctx, cleanup } = await gitBookCtx()
+  try {
+    // 评审报告是目录(旧实现 fs.rm 无 recursive 会抛 → 谎报"已回滚")
+    const reportDir = path.join(ctx.repoPath, '工作区', '评审报告')
+    await fs.mkdir(reportDir, { recursive: true })
+    await fs.writeFile(path.join(reportDir, '事实审查.md'), '通过', 'utf8')
+
+    const p = payload()
+    p.workspaceFiles = ['细纲.md', '评审报告', '工作区/审稿.md', '../定稿/正文/0001-开局.md']
+    const r = await finalizeChapter(ctx, p)
+    assert.equal(r.ok, true, r.error)
+    assert.ok(r.commitHash, '应正常入档')
+
+    await assert.rejects(() => fs.access(reportDir), undefined, '目录应被递归清掉')
+    // `..` 逃逸目标(已定稿正文)必须原样存活,且有 warning 提示
+    await fs.access(path.join(ctx.repoPath, '定稿/正文/0001-开局.md'))
+    assert.ok(
+      (r.warnings || []).some((w) => w.includes('可疑路径')),
+      `应有可疑路径告警:${JSON.stringify(r.warnings)}`
+    )
+  } finally {
+    await cleanup()
+  }
+})
+
 test('gitBookCtx 仓库形态对齐真实建书:工作区不入跟踪、quotepath 关闭', async () => {
   const { ctx, cleanup } = await gitBookCtx()
   try {

+ 28 - 0
v7/test/state-machine/flows/goto-chapter.test.js

@@ -72,3 +72,31 @@ test('P1-6:定稿有未登记手改 + confirm → 拒绝 reset(不丢手改
     await cleanup()
   }
 })
+
+test('R1:有进行中批次 → goto 直接拒绝,批次与定稿都原样', async () => {
+  const { ctx, root, cleanup } = await bookWithChapters()
+  try {
+    const dir = path.join(root, '工作区', '待定稿', '0003-批内章')
+    await fs.mkdir(dir, { recursive: true })
+    await fs.writeFile(
+      path.join(root, '工作区', '待定稿', '批次.json'),
+      JSON.stringify({ 章列表: [{ 章号: 3, 标题: '批内章', 状态: '待审收', 目录: '0003-批内章' }] }),
+      'utf8'
+    )
+
+    const r = await gotoChapter(ctx, { chapterNum: 1, confirm: true })
+    assert.equal(r.ok, false, '批次在场应拒绝回退')
+    assert.match(r.error, /待定稿批次/)
+    assert.match(r.error, /finalize-batch|batch-discard/)
+    // 定稿未被 reset、批次原样
+    await fs.access(path.join(root, '定稿/正文/0002-承.md'))
+    await fs.access(dir)
+
+    // needsConfirm 预览路径同样被拦(不给出误导性的"确认请带 confirm")
+    const preview = await gotoChapter(ctx, { chapterNum: 1, confirm: false })
+    assert.equal(preview.ok, false)
+    assert.match(preview.error, /待定稿批次/)
+  } finally {
+    await cleanup()
+  }
+})

+ 50 - 0
v7/test/state-machine/persist.test.js

@@ -125,3 +125,53 @@ test('persistRepair:修复内容仍解析失败 → ok=false 不写', async ()
     assert.equal(r.ok, false)
   } finally { await cleanup() }
 })
+
+// R3:book.yaml / 名册 / 时间线不是 front matter 文件,校验必须按类型分派,
+// 否则合法修复被「缺少 front matter 分隔符」拒收,书锁死在序 0。
+test('persistRepair:book.yaml 的合法修复被接受(纯 YAML,无 --- 围栏)', async () => {
+  const { ctx, root, cleanup } = await tmpRepo()
+  try {
+    const good = 'spec_version: "7.0"\n书名: 测试书\n类型: 玄幻\n每章目标字数: 3000\n卷规模: 40\n'
+    const r = await persistRepair(
+      ctx,
+      { repairs: [{ file: 'book.yaml', content: good }] },
+      { allowedFiles: ['book.yaml'] }
+    )
+    assert.equal(r.ok, true, r.error)
+    assert.match(await read(root, 'book.yaml'), /书名: 测试书/)
+  } finally { await cleanup() }
+})
+
+test('persistRepair:名册/时间线的合法修复被接受(纯表格)', async () => {
+  const { ctx, root, cleanup } = await tmpRepo()
+  try {
+    const roster = '| 正名 | 别名 | 类型 | 首现章 |\n|---|---|---|---|\n| 林晚 |  | 角色 | 1 |\n'
+    const timeline = '| 章 | 书内时间 | 一句话事件 | 在场 |\n|---|---|---|---|\n| 1 | 春月初一 | 开局 | 林晚 |\n'
+    const r = await persistRepair(
+      ctx,
+      {
+        repairs: [
+          { file: '定稿/设定/名册.md', content: roster },
+          { file: '定稿/设定/时间线/第01卷.md', content: timeline },
+        ],
+      },
+      { allowedFiles: ['定稿/设定/名册.md', '定稿/设定/时间线/第01卷.md'] }
+    )
+    assert.equal(r.ok, true, r.error)
+    assert.match(await read(root, '定稿/设定/名册.md'), /林晚/)
+    assert.match(await read(root, '定稿/设定/时间线/第01卷.md'), /春月初一/)
+  } finally { await cleanup() }
+})
+
+test('persistRepair:名册修复内容仍是坏表格 → ok=false 不写', async () => {
+  const { ctx, cleanup } = await tmpRepo()
+  try {
+    const r = await persistRepair(
+      ctx,
+      { repairs: [{ file: '定稿/设定/名册.md', content: '不是表格的东西\n' }] },
+      { allowedFiles: ['定稿/设定/名册.md'] }
+    )
+    assert.equal(r.ok, false)
+    assert.match(r.error, /名册/)
+  } finally { await cleanup() }
+})

+ 65 - 0
v7/test/storage/atomic.test.js

@@ -25,3 +25,68 @@ test('writeAtomicBatch:后续文件失败时,已替换文件恢复原样', a
     await fs.rm(root, { recursive: true, force: true })
   }
 })
+
+// R12 故障注入:atomic.js 与本测试共享 node:fs 的 promises 单例,patch 即生效。
+test('writeAtomicBatch:写 tmp 就失败的文件,其原文绝不能被回滚删除(R12)', async () => {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-atomic-'))
+  const origWriteFile = fs.writeFile
+  try {
+    await origWriteFile.call(fs, path.join(root, 'a.txt'), 'oldA', 'utf8')
+    await origWriteFile.call(fs, path.join(root, 'b.txt'), 'oldB', 'utf8')
+
+    fs.writeFile = async (p, ...rest) => {
+      if (String(p).includes('b.txt') && String(p).includes('.wnwtmp')) {
+        const err = new Error('注入:EPERM 模拟(文件被占用/路径超长)')
+        err.code = 'EPERM'
+        throw err
+      }
+      return origWriteFile.call(fs, p, ...rest)
+    }
+
+    await assert.rejects(() =>
+      writeAtomicBatch(root, [
+        { path: 'a.txt', content: 'newA' },
+        { path: 'b.txt', content: 'newB' },
+      ])
+    )
+
+    assert.equal(await fs.readFile(path.join(root, 'a.txt'), 'utf8'), 'oldA', 'A 应从备份恢复')
+    assert.equal(await fs.readFile(path.join(root, 'b.txt'), 'utf8'), 'oldB', 'B 从未动过,原文必须还在')
+  } finally {
+    fs.writeFile = origWriteFile
+    await fs.rm(root, { recursive: true, force: true })
+  }
+})
+
+test('writeAtomicBatch:备份 rename 失败的文件,其原文绝不能被回滚删除(R12)', async () => {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-atomic-'))
+  const origRename = fs.rename
+  try {
+    await fs.writeFile(path.join(root, 'a.txt'), 'oldA', 'utf8')
+    await fs.writeFile(path.join(root, 'b.txt'), 'oldB', 'utf8')
+
+    fs.rename = async (from, to, ...rest) => {
+      if (String(from).endsWith('b.txt') && String(to).includes('.wnwbackup')) {
+        const err = new Error('注入:EPERM 模拟(原文件被编辑器占用)')
+        err.code = 'EPERM'
+        throw err
+      }
+      return origRename.call(fs, from, to, ...rest)
+    }
+
+    await assert.rejects(() =>
+      writeAtomicBatch(root, [
+        { path: 'a.txt', content: 'newA' },
+        { path: 'b.txt', content: 'newB' },
+      ])
+    )
+
+    assert.equal(await fs.readFile(path.join(root, 'a.txt'), 'utf8'), 'oldA', 'A 应从备份恢复')
+    assert.equal(await fs.readFile(path.join(root, 'b.txt'), 'utf8'), 'oldB', 'B 原文必须还在')
+    const leftovers = (await fs.readdir(root)).filter((f) => f.includes('.wnwtmp') || f.includes('.wnwbackup'))
+    assert.deepEqual(leftovers, [], '不应残留 tmp/backup 文件')
+  } finally {
+    fs.rename = origRename
+    await fs.rm(root, { recursive: true, force: true })
+  }
+})

+ 52 - 0
v7/test/storage/serializers/yaml-dialect.test.js

@@ -1,6 +1,8 @@
 import { test } from 'node:test'
 import assert from 'node:assert/strict'
 import { serializeYAML } from '../../../src/storage/serializers/yaml-dialect.js'
+import { serializeFrontMatter } from '../../../src/storage/serializers/front-matter.js'
+import { parseFrontMatter } from '../../../src/storage/parsers/front-matter.js'
 
 test('列表输出块格式', () => {
   const data = { 伏笔: ['伏笔-001', '伏笔-002'] }
@@ -82,3 +84,53 @@ test('混合字段', () => {
   assert.ok(yaml.includes('伏笔:\n  - 伏笔-001'))
   assert.ok(yaml.includes('危险数字: "123"'))
 })
+
+// —— R7/R8 往返回归表:写出的 front matter 必须原样读得回 ——
+// 每个值都是第三轮 review 复现过 DRIFT(类型漂移/被裁)或 PARSEFAIL(整文件读不回)的案例。
+const 往返用例 = [
+  ['空串', ''],
+  ['前后空格', '  甲  '],
+  ['中间制表符', 'a\tb'],
+  ['十六进制形态', '0x1F'],
+  ['八进制形态', '0o17'],
+  ['二进制形态', '0b101'],
+  ['科学计数', '1e3'],
+  ['带符号整数', '+5'],
+  ['带符号科学计数', '-2.5E-3'],
+  ['波浪线', '~'],
+  ['inf 字面值', '.inf'],
+  ['nan 字面值', '.NaN'],
+  ['方括号起首', '[快穿]反派'],
+  ['星号起首', '*追读'],
+  ['锚点起首', '&a'],
+  ['双引号起首', '"引号"书'],
+  ['单引号起首', "'单引'"],
+  ['横杠空格起首', '- 开局'],
+  ['反斜杠加冒号', 'C:\\Users\\x'],
+  ['纯反斜杠路径', '路径\\子目录\\文件'],
+  ['折叠符起首', '|折叠'],
+  ['引用符起首', '>引用'],
+  ['叹号起首', '!标签'],
+  ['百分号起首', '%指令'],
+  ['逗号起首', ',逗号'],
+  ['问号加空格起首', '? 问'],
+  ['行内注释', '前半 #后半'],
+  ['花括号起首', '{流式}'],
+  ['换行值', '第一行\n第二行'],
+]
+
+for (const [名称, 值] of 往返用例) {
+  test(`往返保真:${名称}`, () => {
+    const content = serializeFrontMatter({ 标题: '往返', 值 }, '正文\n')
+    const parsed = parseFrontMatter(content)
+    assert.equal(parsed.ok, true, `解析失败:${parsed.error}`)
+    assert.equal(parsed.data.值, 值, `往返漂移:${JSON.stringify(parsed.data.值)} !== ${JSON.stringify(值)}`)
+  })
+}
+
+test('往返保真:数组项含危险值', () => {
+  const content = serializeFrontMatter({ 标题: '往返', 本章要写到的事: ['[伏笔] 埋线', '*重点*', '1e3'] }, '')
+  const parsed = parseFrontMatter(content)
+  assert.equal(parsed.ok, true, `解析失败:${parsed.error}`)
+  assert.deepEqual(parsed.data.本章要写到的事, ['[伏笔] 埋线', '*重点*', '1e3'])
+})