Browse Source

fix(v7): 迁移链类型/别名/序列化收口——第三轮 review 批2(R5/R6/R9-R11)

- R5/G-2 normalizeEntityType 单源:名册写中文(作者域)、缓存存英文(机器域),入库归一;scanCharacters upsert 补 type;fixture 名册改中文消掩盖源;迁移书角色不再隐身(e2e 断言 list-characters 可见)
- R6/A5/A13 splitAliases 单源(全角逗号/顿号同刀、缺列不炸):EntityReader/rebuilder/staging 三处共用
- R9/F-1 迁移 book.yaml 走 serializeYAML——危险书名不再解析失败/类型漂移/书单消失
- R10/F-3 一对多别名降级:首实体保留、其余剥离进「迁移待校对-别名歧义.md」,整体迁移不再硬回滚
- R11/A4/A7/A8/A11 表格读写对称:竖线/反斜杠转义、整表全角归一、多余列告警、upsert 合并保作者额外列
- F-4 伏笔短题按码点截断(顺手,同文件)
lingfengQAQ 1 ngày trước cách đây
mục cha
commit
3a148ee89e

+ 8 - 3
v7/src/cache/rebuilder.js

@@ -2,6 +2,8 @@ import { promises as fs } from 'node:fs'
 import path from 'node:path'
 import { parseFrontMatter } from '../storage/parsers/front-matter.js'
 import { parseMarkdownTable } from '../storage/parsers/markdown-table.js'
+import { splitAliases } from '../util/aliases.js'
+import { normalizeEntityType } from '../util/entity-type.js'
 
 /**
  * 全量重建缓存(BEGIN → DELETE 六表 → 扫描源文件 INSERT → COMMIT,中途失败 ROLLBACK 不留半库)。
@@ -254,12 +256,13 @@ async function scanEntities(repoPath, db) {
 
     for (const row of table.rows) {
       const entityId = row.正名
-      const type = row.类型 || 'character'
+      // 名册类型列是作者域中文(G-3),入库统一翻成机器域英文——查询链全按英文过滤
+      const type = normalizeEntityType(row.类型)
 
       entityStmt.run(entityId, type, null, null, null, null, parseInt(row.首现章 || '1', 10), rosterPath)
 
-      // 处理别名
-      const aliases = row.别名.split(',').map((a) => a.trim()).filter((a) => a)
+      // 处理别名(splitAliases 单源:全角逗号/顿号同刀,缺列不炸)
+      const aliases = splitAliases(row.别名)
       for (const alias of aliases) {
         if (aliasMap.has(alias)) {
           return {
@@ -286,10 +289,12 @@ async function scanCharacters(repoPath, db) {
 
   // upsert:角色卡可能不在名册里(名册非强制全覆盖),只 UPDATE 会丢这些角色。
   // 在名册里则补全字段并把 file_path 指向更详细的角色卡。
+  // type 也随 upsert 归一成 'character'(G-2:有角色卡即角色,名册行类型写错不至于让角色隐身)。
   const upsertStmt = db.prepare(`
     INSERT INTO entities (id, type, status, location, realm, possessions, last_changed_chapter, file_path)
     VALUES (?, 'character', ?, ?, ?, ?, ?, ?)
     ON CONFLICT(id) DO UPDATE SET
+      type = 'character',
       status = excluded.status,
       location = excluded.location,
       realm = excluded.realm,

+ 34 - 9
v7/src/migrate/transform.js

@@ -1,6 +1,7 @@
 import { serializeFrontMatter } from '../storage/serializers/front-matter.js'
 import { serializeMarkdownTable } from '../storage/serializers/markdown-table.js'
 import { parseMarkdownTable } from '../storage/parsers/markdown-table.js'
+import { serializeYAML } from '../storage/serializers/yaml-dialect.js'
 import { extractSection } from '../util/markdown.js'
 import { sanitizeFileName } from '../util/filename.js'
 
@@ -38,14 +39,15 @@ export function transformV6(facts) {
   } else if (!GENRE_MAP.get(String(rawGenre).toLowerCase()) && !/^[一-鿿]+$/.test(类型)) {
     待校对.push(`book.yaml:题材「${rawGenre}」没有对应的中文码表,原样保留,请改成中文题材名。`)
   }
-  add('book.yaml', [
-    'spec_version: "7.0"',
-    `书名: ${bookName}`,
-    `类型: ${类型}`,
-    '每章目标字数: 3000',
-    '卷规模: 40',
-    '',
-  ].join('\n'))
+  // book.yaml 走防呆序列化器(R9):书名以 [ * " - 起首或纯数字时,手写拼接会
+  // 解析失败/类型漂移 → 书内设置回落默认、books.jsonl 扫描重建时该书从书单消失
+  add('book.yaml', serializeYAML({
+    spec_version: '7.0',
+    书名: bookName,
+    类型,
+    每章目标字数: 3000,
+    卷规模: 40,
+  }) + '\n')
 
   // —— 卷号推断:卷内布局优先,详细大纲「### 第N章」次之,兜底 1 ——
   const volumeOf = buildVolumeIndex(facts.outlines)
@@ -84,7 +86,8 @@ export function transformV6(facts) {
   // —— 伏笔条目 ——
   facts.foreshadowing.forEach((fb, i) => {
     const id = `伏笔-${String(i + 1).padStart(3, '0')}`
-    const 短题 = sanitizeFileName(fb.content).slice(0, 9) || '迁移条目'
+    // 按码点切前 9 字(F-4):UTF-16 码元切会把 emoji/生僻字的代理对切成两半,文件名损坏
+    const 短题 = Array.from(sanitizeFileName(fb.content)).slice(0, 9).join('') || '迁移条目'
     const planted = fb.plantedChapter ?? 1
     const fm = {
       强度: TIER_TO_STRENGTH.get(fb.tier) || '中',
@@ -109,6 +112,28 @@ export function transformV6(facts) {
   counts.伏笔 = facts.foreshadowing.length
 
   // —— 名册 + 角色卡 ——
+  // 一对多别名降级(R10):v6 允许同一别名指向多实体做消歧,v7 别名唯一(缓存重建
+  // 撞冲突会整体回滚)。首实体保留该别名,其余剥离进待校对文件,迁移不再硬失败。
+  const aliasOwner = new Map()
+  const 别名歧义 = []
+  for (const e of facts.entities) {
+    e.aliases = e.aliases.filter((a) => {
+      const owner = aliasOwner.get(a)
+      if (owner === undefined) {
+        aliasOwner.set(a, e.name)
+        return true
+      }
+      if (owner !== e.name) 别名歧义.push({ 别名: a, 保留: owner, 剥离: e.name })
+      return false
+    })
+  }
+  if (别名歧义.length) {
+    add('定稿/设定/迁移待校对-别名歧义.md',
+      `# 迁移待校对:一对多别名\n\n> v6 里同一别名指向多个实体(用于消歧),v7 要求别名唯一。\n> 迁移时按首个实体保留,其余实体的该别名已剥离——请校对归属:\n> 该给谁就在名册里给谁补上,或换一个不冲突的别名。校对完删掉本文件。\n\n` +
+      别名歧义.map((x) => `- 「${x.别名}」保留给【${x.保留}】,已从【${x.剥离}】剥离`).join('\n') + '\n')
+    待校对.push('定稿/设定/迁移待校对-别名歧义.md:v6 一对多别名按首实体保留,请校对归属。')
+  }
+
   const nameOf = new Map(facts.entities.map((e) => [e.id, e.name]))
   if (facts.entities.length) {
     add('定稿/设定/名册.md', serializeMarkdownTable(

+ 1 - 6
v7/src/staging/index.js

@@ -5,6 +5,7 @@ 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 { splitAliases } from '../util/aliases.js'
 import { writeAtomicBatch } from '../storage/atomic.js'
 import { finalizeChapter } from '../finalize/index.js'
 import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
@@ -216,12 +217,6 @@ export async function stagedFacts(repoPath, opts = {}) {
   return facts
 }
 
-function splitAliases(v) {
-  if (Array.isArray(v)) return v.filter(Boolean).map((s) => String(s).trim()).filter(Boolean)
-  if (typeof v === 'string') return v.split(/[,,、]/).map((s) => s.trim()).filter(Boolean)
-  return []
-}
-
 /**
  * 全书近况叠加(备料/审稿输入消费):staged 章计入位置与总量、连续弱钩接算、
  * 批内已推进的条目从"悬了太久"剔除。assembleBookStatus 本体保持只算定稿。

+ 3 - 2
v7/src/storage/adapters/EntityReader.js

@@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs'
 import path from 'node:path'
 import { parseFrontMatter } from '../parsers/front-matter.js'
 import { parseMarkdownTable } from '../parsers/markdown-table.js'
+import { splitAliases } from '../../util/aliases.js'
 
 /**
  * EntityReader:读取角色卡、解析别名。
@@ -82,9 +83,9 @@ export class EntityReader {
         return { ok: false, canonicalName: null, error: '名册解析失败' }
       }
 
-      // 查找别名
+      // 查找别名(splitAliases 单源:与缓存重建/叠加视图同刀,全角分隔与缺列都兜住)
       for (const row of table.rows) {
-        const aliases = row.别名.split(',').map((a) => a.trim())
+        const aliases = splitAliases(row.别名)
         if (aliases.includes(alias)) {
           return { ok: true, canonicalName: row.正名, error: '' }
         }

+ 2 - 1
v7/src/storage/adapters/EntityWriter.js

@@ -55,8 +55,9 @@ export class EntityWriter {
         // 文件不存在,用默认表头
       }
 
+      // 合并而非整行替换:作者手加的额外列(如 备注)随替换丢失是静默数据丢(A11)
       const idx = rows.findIndex((r) => r.正名 === row.正名)
-      if (idx >= 0) rows[idx] = row
+      if (idx >= 0) rows[idx] = { ...rows[idx], ...row }
       else rows.push(row)
 
       await fs.mkdir(path.dirname(filePath), { recursive: true })

+ 43 - 19
v7/src/storage/parsers/markdown-table.js

@@ -1,7 +1,10 @@
 /**
  * 解析 Markdown 表格。
+ * - 识别 GFM `\|` 转义与 `\\`(与 serializer 对称,R11/A8)
+ * - 整行全角管道 `|` 归一为 `|`(作者用中文输入法打整表的场景,A7)
+ * - 行格数多于表头时截断并记 warning,不再静默(A8)
  * @param {string} content - Markdown 表格文本(含表头 | A | B | C |)
- * @returns {{ok: boolean, headers: string[], rows: object[], error: string}}
+ * @returns {{ok: boolean, headers: string[], rows: object[], warnings: string[], error: string}}
  */
 export function parseMarkdownTable(content) {
   if (typeof content !== 'string') {
@@ -9,17 +12,22 @@ export function parseMarkdownTable(content) {
       ok: false,
       headers: [],
       rows: [],
+      warnings: [],
       error: '内容必须是字符串',
     }
   }
 
-  const lines = content.split('\n').map((line) => line.trim()).filter((line) => line !== '')
+  const lines = content
+    .split('\n')
+    .map((line) => normalizeFullWidthPipes(line.trim()))
+    .filter((line) => line !== '')
 
   if (lines.length < 2) {
     return {
       ok: false,
       headers: [],
       rows: [],
+      warnings: [],
       error: 'Markdown 表格至少需要两行(表头 + 分隔符)',
     }
   }
@@ -31,14 +39,12 @@ export function parseMarkdownTable(content) {
       ok: false,
       headers: [],
       rows: [],
+      warnings: [],
       error: '表头行必须以 | 开头和结尾',
     }
   }
 
-  const headers = headerLine
-    .slice(1, -1)
-    .split('|')
-    .map((h) => h.trim())
+  const headers = splitCells(headerLine)
 
   // 跳过分隔符行(第二行,GFM:每格仅 - 与 :,至少一横;支持 |---| 与 |-| 与对齐 |:--:|--:|)
   const separatorLine = lines[1]
@@ -47,36 +53,32 @@ export function parseMarkdownTable(content) {
       ok: false,
       headers: [],
       rows: [],
+      warnings: [],
       error: '表格第二行必须是分隔符行(|---|---|)',
     }
   }
 
   // 解析数据行(从第三行开始)
+  const warnings = []
   const rows = []
   for (let i = 2; i < lines.length; i++) {
     const line = lines[i]
 
-    // 跳过空行
-    if (line === '') continue
-
     // 跳过不是表格行的内容
     if (!line.startsWith('|') || !line.endsWith('|')) {
       continue
     }
 
-    const cells = line
-      .slice(1, -1)
-      .split('|')
-      .map((c) => c.trim())
+    const cells = splitCells(line)
 
-    // 容错:单元格数量不匹配表头时跳过(或补空)
-    if (cells.length !== headers.length) {
-      // 补齐或截断
-      while (cells.length < headers.length) {
-        cells.push('')
-      }
+    // 容错:格数少补空;多于表头则截断并告警(内容会丢,作者该修表)
+    if (cells.length > headers.length) {
+      warnings.push(`第 ${i + 1} 行有 ${cells.length} 格,多于表头 ${headers.length} 格,多余内容已忽略:${line}`)
       cells.splice(headers.length)
     }
+    while (cells.length < headers.length) {
+      cells.push('')
+    }
 
     const row = {}
     for (let j = 0; j < headers.length; j++) {
@@ -89,10 +91,32 @@ export function parseMarkdownTable(content) {
     ok: true,
     headers: headers,
     rows: rows,
+    warnings,
     error: '',
   }
 }
 
+/**
+ * 切一行的单元格:先把 `\\` 与 `\|` 换成占位符再按 `|` 切,切完还原——
+ * 与 serializeMarkdownTable 的转义严格对称。
+ */
+function splitCells(line) {
+  const ESC_BACKSLASH = '\x00'
+  const ESC_PIPE = '\x01'
+  return line
+    .slice(1, -1)
+    .replace(/\\\\/g, ESC_BACKSLASH)
+    .replace(/\\\|/g, ESC_PIPE)
+    .split('|')
+    .map((c) => c.trim().replaceAll(ESC_PIPE, '|').replaceAll(ESC_BACKSLASH, '\\'))
+}
+
+/** 整行以全角 | 起围栏时归一为半角(只救整表全角,不动半角行内的全角字符)。 */
+function normalizeFullWidthPipes(line) {
+  if (!line.startsWith('|')) return line
+  return line.replaceAll('|', '|')
+}
+
 /**
  * GFM 分隔符行判定:以 | 围栏,格数与表头一致,每格仅 - 与 : 且至少一横。
  * 接受 |---|、|-|、|:--:|、|--:|、|:--| 等所有合法形态。

+ 18 - 4
v7/src/storage/serializers/markdown-table.js

@@ -1,12 +1,26 @@
 /**
- * 序列化 Markdown 表格(与 parsers/markdown-table.js 配对)。
+ * 序列化 Markdown 表格(与 parsers/markdown-table.js 配对,转义读写对称)。
+ * 单元格内 `|` 转义为 `\|`(R11:别名「刀疤|老王」不再被切丢后半)、`\` 转义为 `\\`、
+ * 换行替换为空格(表格单元格不容纳换行)。
+ * 行对象里表头之外的额外列(作者手加的列)按首次出现序追加在尾部,全表重写不丢列(A11)。
  * @param {string[]} headers 表头
  * @param {object[]} rows 行对象数组(按 header 取值,缺失补空)
  * @returns {string} Markdown 表格文本(含尾换行)
  */
 export function serializeMarkdownTable(headers, rows) {
-  const head = `| ${headers.join(' | ')} |`
-  const sep = `| ${headers.map(() => '---').join(' | ')} |`
-  const body = rows.map((r) => `| ${headers.map((h) => String(r[h] ?? '')).join(' | ')} |`)
+  const allHeaders = [...headers]
+  for (const r of rows) {
+    for (const k of Object.keys(r)) {
+      if (!allHeaders.includes(k)) allHeaders.push(k)
+    }
+  }
+  const cell = (v) =>
+    String(v ?? '')
+      .replace(/\\/g, '\\\\')
+      .replace(/\|/g, '\\|')
+      .replace(/\r?\n/g, ' ')
+  const head = `| ${allHeaders.map(cell).join(' | ')} |`
+  const sep = `| ${allHeaders.map(() => '---').join(' | ')} |`
+  const body = rows.map((r) => `| ${allHeaders.map((h) => cell(r[h])).join(' | ')} |`)
   return [head, sep, ...body].join('\n') + '\n'
 }

+ 11 - 0
v7/src/util/aliases.js

@@ -0,0 +1,11 @@
+/**
+ * 别名分隔单源(R6/A5):中文作者惯用全角逗号/顿号,三处解析点必须同刀。
+ * undefined/缺列容错(A13:名册缺「别名」列不得炸整次重建)。
+ * @param {string|string[]|undefined} v 名册单元格原文或数组
+ * @returns {string[]}
+ */
+export function splitAliases(v) {
+  if (Array.isArray(v)) return v.filter(Boolean).map((s) => String(s).trim()).filter(Boolean)
+  if (typeof v === 'string') return v.split(/[,,、]/).map((s) => s.trim()).filter(Boolean)
+  return []
+}

+ 26 - 0
v7/src/util/entity-type.js

@@ -0,0 +1,26 @@
+/**
+ * 实体类型值归一单源(R5/G-2/G-3):名册「类型」列是作者域(写中文),
+ * 缓存 entities.type 是机器域(存英文 machine 值)——本函数是唯一的翻译点。
+ * 英文旧值恒等收纳(存量书无损);未知值原样保留(不猜)。
+ */
+const TYPE_MAP = new Map([
+  ['角色', 'character'],
+  ['地点', 'location'],
+  ['组织', 'organization'],
+  ['势力', 'organization'],
+  ['物品', 'item'],
+  ['character', 'character'],
+  ['location', 'location'],
+  ['organization', 'organization'],
+  ['item', 'item'],
+])
+
+/**
+ * @param {string|undefined} raw 名册「类型」单元格原文
+ * @returns {string} 机器域类型值;空值默认 character
+ */
+export function normalizeEntityType(raw) {
+  const v = String(raw ?? '').trim()
+  if (!v) return 'character'
+  return TYPE_MAP.get(v) || v
+}

+ 58 - 0
v7/test/cache/rebuilder.test.js

@@ -102,6 +102,64 @@ test('别名冲突 → 报 error 并拒绝重建(AC10)', async () => {
   }
 })
 
+// —— 批 2 回归:R5 / R6 / A13 ——
+
+test('R5:名册中文类型入库归一成英文机器值(角色→character、地点→location)', async () => {
+  const root = await makeRepo({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
+    '定稿/设定/名册.md':
+      '| 正名 | 别名 | 类型 | 首现章 |\n|---|---|---|---|\n| 江遥 | 小江 | 角色 | 1 |\n| 滨海市 |  | 地点 | 1 |\n| 旧英文 |  | character | 1 |\n',
+  })
+  const cache = new CacheManager(path.join(root, '.cache', 'index.db'))
+  try {
+    await cache.ensureReady(root)
+    const typeOf = async (id) =>
+      (await cache.query('SELECT type FROM entities WHERE id = ?', [id]))[0]?.type
+    assert.equal(await typeOf('江遥'), 'character')
+    assert.equal(await typeOf('滨海市'), 'location')
+    assert.equal(await typeOf('旧英文'), 'character', '英文旧值恒等收纳,存量书无损')
+  } finally {
+    await cache.close()
+    await rm(root, { recursive: true, force: true })
+  }
+})
+
+test('R6:全角逗号/顿号分隔的别名全部入 entity_aliases 可解析', async () => {
+  const root = await makeRepo({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
+    '定稿/设定/名册.md':
+      '| 正名 | 别名 | 类型 | 首现章 |\n|---|---|---|---|\n| 林晚 | 阿晚,晚儿、小晚 | 角色 | 1 |\n',
+  })
+  const cache = new CacheManager(path.join(root, '.cache', 'index.db'))
+  try {
+    await cache.ensureReady(root)
+    const aliases = (await cache.query('SELECT alias FROM entity_aliases ORDER BY alias', []))
+      .map((r) => r.alias)
+    assert.deepEqual(aliases, ['小晚', '晚儿', '阿晚'].sort(), `全角分隔的三个别名都应入库:${aliases}`)
+  } finally {
+    await cache.close()
+    await rm(root, { recursive: true, force: true })
+  }
+})
+
+test('A13:名册缺「别名」列 → 实体照常入库,不炸整次重建', async () => {
+  const root = await makeRepo({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
+    '定稿/正文/0001-开局.md': '---\n章号: 1\n标题: 开局\n卷: 1\n字数: 100\n章定位: 推进\n---\n正文。',
+    '定稿/设定/名册.md': '| 正名 | 类型 | 首现章 |\n|---|---|---|\n| 林晚 | 角色 | 1 |\n',
+  })
+  const cache = new CacheManager(path.join(root, '.cache', 'index.db'))
+  try {
+    const result = await cache.rebuildFromSource(root)
+    assert.equal(result.ok, true, `缺别名列不应回滚重建:${JSON.stringify(result.errors)}`)
+    assert.equal((await cache.query('SELECT COUNT(*) AS n FROM entities', []))[0].n, 1)
+    assert.equal((await cache.query('SELECT COUNT(*) AS n FROM chapters', []))[0].n, 1, '章不应被连坐')
+  } finally {
+    await cache.close()
+    await rm(root, { recursive: true, force: true })
+  }
+})
+
 test('别名冲突 → ROLLBACK 不留半库(P1-1:已写 chapters 也回滚)', async () => {
   const root = await makeRepo({
     'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',

+ 2 - 2
v7/test/fixtures/sample-book/定稿/设定/名册.md

@@ -1,4 +1,4 @@
 | 正名 | 别名 | 类型 | 首现章 |
 |------|------|------|---------|
-| 林晚 | 晚晚, 林师妹 | character | 1 |
-| 神秘老者 | 黑衣人 | character | 1 |
+| 林晚 | 晚晚, 林师妹 | 角色 | 1 |
+| 神秘老者 | 黑衣人 | 角色 | 1 |

+ 7 - 0
v7/test/migrate/e2e.test.js

@@ -8,6 +8,7 @@ import { promisify } from 'node:util'
 import { migrateV6 } from '../../src/migrate/index.js'
 import { run as migrateCmd } from '../../src/commands/migrate.js'
 import { CacheManager } from '../../src/cache/index.js'
+import { EntityReader } from '../../src/storage/adapters/EntityReader.js'
 import { determineNextState } from '../../src/state-machine/index.js'
 import { loadBooks } from '../../src/session/index.js'
 import { tempV6, tempV6Sqlite, inlineFixture } from './_v6.js'
@@ -83,6 +84,12 @@ test('AC2 端到端(inline):迁移落位、git 单 commit、缓存可删
       // AC7 口径顺检:迁移不产生指纹(体检才产)
       assert.equal((await cache.query('SELECT COUNT(*) AS n FROM fingerprints', []))[0].n, 0)
 
+      // R5:名册写中文类型(作者域),入缓存归一成英文 machine 值——迁移书的角色不再隐身
+      const types = (await cache.query('SELECT DISTINCT type FROM entities', [])).map((r) => r.type).sort()
+      assert.deepEqual(types, ['character', 'organization'], `entities.type 应全为英文机器值:${types}`)
+      const chars = await new EntityReader(repo, cache).listCharacters()
+      assert.ok(chars.some((c) => c.id === '陆沉'), `list-characters 应含陆沉:${JSON.stringify(chars)}`)
+
       // next 直接进正常流程:序 6 起草第 4 章
       const next = await determineNextState({ repoPath: repo, cache, workdir: ctx.workdir })
       assert.equal(next.序, 6, JSON.stringify(next))

+ 59 - 0
v7/test/migrate/transform.test.js

@@ -125,3 +125,62 @@ test('sqlite → db 实体/摘要/追读力进产物;genre 未知码原样并
     await cleanup()
   }
 })
+
+// —— 批 2 回归:R9 / R10 / F-4(transformV6 纯函数,用最小 facts 直测)——
+import { parseBookConfig } from '../../src/storage/parsers/book-config.js'
+
+function minimalFacts(over = {}) {
+  return {
+    project: { title: '测', genre: '玄幻' },
+    chapters: [], summaries: new Map(), dbSummaries: new Map(),
+    foreshadowing: [], entities: [], relationships: [],
+    protagonistState: {}, outlines: { master: '', volumes: [] },
+    settingFiles: [], scratchpad: {}, patterns: [], stateChanges: [],
+    chaseDebtCount: 0, warnings: [], activeThreads: [],
+    readingPower: new Map(), chapterMeta: new Map(), form: 'inline',
+    ...over,
+  }
+}
+
+test('R9:危险书名走防呆序列化器——[ * 起首与纯数字书名都能读回', () => {
+  for (const title of ['[快穿]反派', '*追读', '"引号"书', '- 开局', '12345', 'true']) {
+    const plan = transformV6(minimalFacts({ project: { title, genre: '玄幻' } }))
+    const cfg = parseBookConfig(fileOf(plan, 'book.yaml'))
+    assert.equal(cfg.ok, true, `书名「${title}」的 book.yaml 解析失败:${cfg.error}`)
+    assert.equal(cfg.data.书名, title, `书名「${title}」往返漂移成 ${JSON.stringify(cfg.data.书名)}`)
+  }
+})
+
+test('R10:一对多别名降级——首实体保留、其余剥离进待校对,迁移不再硬失败', () => {
+  const plan = transformV6(minimalFacts({
+    entities: [
+      { id: 'a', name: '张三', type: '角色', aliases: ['小明', '老张'], current: {}, firstAppearance: 1 },
+      { id: 'b', name: '李四', type: '角色', aliases: ['小明'], current: {}, firstAppearance: 2 },
+    ],
+  }))
+  const roster = fileOf(plan, '定稿/设定/名册.md')
+  assert.match(roster, /\| 张三 \| 小明, 老张 \| 角色 \| 1 \|/)
+  assert.match(roster, /\| 李四 \|  \| 角色 \| 2 \|/)
+  const 歧义 = fileOf(plan, '定稿/设定/迁移待校对-别名歧义.md')
+  assert.match(歧义, /「小明」保留给【张三】,已从【李四】剥离/)
+  assert.ok(plan.report.待校对.some((s) => s.includes('别名歧义')), JSON.stringify(plan.report.待校对))
+})
+
+test('R10:同实体重复别名静默去重,不进歧义清单', () => {
+  const plan = transformV6(minimalFacts({
+    entities: [{ id: 'a', name: '张三', type: '角色', aliases: ['小明', '小明'], current: {}, firstAppearance: 1 }],
+  }))
+  const roster = fileOf(plan, '定稿/设定/名册.md')
+  assert.match(roster, /\| 张三 \| 小明 \| 角色 \| 1 \|/)
+  assert.ok(!plan.files.some((f) => f.path.includes('别名歧义')))
+})
+
+test('F-4:伏笔短题按码点截断,文件名不含半个代理对', () => {
+  const plan = transformV6(minimalFacts({
+    foreshadowing: [{ content: '💠🀄𝌆一二三四五六七八九十', tier: '核心', status: '未回收', plantedChapter: 1 }],
+  }))
+  const fb = plan.files.find((f) => f.path.startsWith('大纲/伏笔/伏笔-001'))
+  assert.ok(fb, '伏笔文件应存在')
+  const lone = fb.path.match(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/)
+  assert.equal(lone, null, `文件名含孤代理:${JSON.stringify(fb.path)}`)
+})

+ 19 - 0
v7/test/storage/adapters/EntityWriter.test.js

@@ -81,3 +81,22 @@ test('upsertRosterRow 已存在则更新该行(不重复)', async () => {
   }
 })
 
+test('A11:upsert 更新行时保留作者手加的额外列', async () => {
+  const 带备注名册 =
+    '| 正名 | 别名 | 类型 | 首现章 | 备注 |\n|---|---|---|---|---|\n| 林晚 | 晚晚 | 角色 | 1 | 主角,勿动 |\n'
+  const root = await makeRepo({ '定稿/设定/名册.md': 带备注名册 })
+  try {
+    const r = await new EntityWriter(root).upsertRosterRow({
+      正名: '林晚',
+      别名: '晚晚, 小晚',
+      类型: '角色',
+      首现章: 1,
+    })
+    assert.equal(r.ok, true)
+    const c = await read(root, '定稿/设定/名册.md')
+    assert.match(c, /\| 林晚 \| 晚晚, 小晚 \| 角色 \| 1 \| 主角,勿动 \|/, `备注列不能丢:\n${c}`)
+  } finally {
+    await cleanup(root)
+  }
+})
+

+ 53 - 0
v7/test/storage/parsers/markdown-table.test.js

@@ -1,6 +1,7 @@
 import { test } from 'node:test'
 import assert from 'node:assert/strict'
 import { parseMarkdownTable } from '../../../src/storage/parsers/markdown-table.js'
+import { serializeMarkdownTable } from '../../../src/storage/serializers/markdown-table.js'
 
 test('正常解析时间线表格', () => {
   const content = `| 章 | 书内时间 | 一句话事件 | 在场 |
@@ -108,3 +109,55 @@ test('分隔符含非 -: 字符 → 报错', () => {
   assert.equal(result.ok, false)
   assert.ok(result.error.includes('分隔符'))
 })
+
+// —— 批 2 回归:R11 / A7 / A8 转义读写对称 ——
+
+test('R11:单元格含竖线/反斜杠 → 序列化转义,读回原值不丢', () => {
+  const rows = [
+    { 正名: '老王', 别名: '刀疤|老王', 类型: '角色', 首现章: '1' },
+    { 正名: '甲', 别名: '路径\\子目录', 类型: '角色', 首现章: '2' },
+    { 正名: '乙', 别名: '字面\\|序列', 类型: '角色', 首现章: '3' },
+  ]
+  const text = serializeMarkdownTable(['正名', '别名', '类型', '首现章'], rows)
+  const parsed = parseMarkdownTable(text)
+  assert.equal(parsed.ok, true, parsed.error)
+  assert.equal(parsed.rows[0].别名, '刀疤|老王', '竖线后半截不能丢')
+  assert.equal(parsed.rows[1].别名, '路径\\子目录')
+  assert.equal(parsed.rows[2].别名, '字面\\|序列')
+  assert.equal(parsed.rows.length, 3)
+})
+
+test('R11:单元格含换行 → 替换为空格,表不散架', () => {
+  const text = serializeMarkdownTable(['章', '一句话事件'], [{ 章: '1', 一句话事件: '第一行\n第二行' }])
+  const parsed = parseMarkdownTable(text)
+  assert.equal(parsed.ok, true)
+  assert.equal(parsed.rows.length, 1)
+  assert.equal(parsed.rows[0].一句话事件, '第一行 第二行')
+})
+
+test('A7:整表全角管道 | → 归一解析,不再静默变空', () => {
+  const content = '| 正名 | 别名 |\n|---|---|\n| 林晚 | 晚晚 |'
+  const result = parseMarkdownTable(content)
+  assert.equal(result.ok, true, result.error)
+  assert.equal(result.rows.length, 1)
+  assert.equal(result.rows[0].正名, '林晚')
+  assert.equal(result.rows[0].别名, '晚晚')
+})
+
+test('A8:数据行格数多于表头 → 截断但记 warning,不再静默', () => {
+  const content = '| A | B |\n|---|---|\n| 1 | 2 | 3 |'
+  const result = parseMarkdownTable(content)
+  assert.equal(result.ok, true)
+  assert.equal(result.rows[0].B, '2')
+  assert.ok(result.warnings.some((w) => w.includes('多于表头')), JSON.stringify(result.warnings))
+})
+
+test('A11(serialize 半边):行对象带表头之外的额外列 → 追加尾列,不丢', () => {
+  const text = serializeMarkdownTable(
+    ['正名', '别名'],
+    [{ 正名: '林晚', 别名: '晚晚', 备注: '主角' }]
+  )
+  const parsed = parseMarkdownTable(text)
+  assert.deepEqual(parsed.headers, ['正名', '别名', '备注'])
+  assert.equal(parsed.rows[0].备注, '主角')
+})