review-A.md 15 KB

Review A 区(存储层)候选清单

  • 分区v7/src/storage/{adapters,parsers,serializers,atomic.js} + v7/src/util/
  • 日期:2026-07-05
  • 审查员:Research Agent A(只审不修)
  • 格式[severity file:line 一句话问题 | 怀疑理由 | 建议探针 | 置信]
  • 已现场探针:yaml 方言往返、markdown 表格往返、别名分隔、_findThreadFile 前缀命中、js-yaml 类型强转(脚本未落盘,见各条"探针已跑")。

统计:P1 × 6、P2 × 7、S × 4,共 17 条。其中 6 条已现场复现(CONFIRMED),余为审读推断待主会话探针。


P1(数据丢 / 整文件读失败 / 作者界面漂移)

1. [P1] serializers/yaml-dialect.js:79-121 needsQuoting 漏判大量会被 YAML 误判类型的字符串 → 往返损坏

  • 怀疑理由:§4.3 要求"凡可能被 YAML 误判类型的字符串值必须加引号"。现仅覆盖 纯数字/浮点/true-false/null/含冒号/#开头/-开头/换行。漏:空串、前后空格、0x1F(hex)、1e3(科学计数)、+5~.inf/.nan、前导 YAML 指示符 * & [ { ! ? | > @ % 反引号 单双引号 逗号。
  • 探针已跑(真项目模块 serialize→parse)""→null" 甲 "→"甲""0x1F"→31"1e3"→1000"+5"→5"~"→null 全部 DRIFT。空串→null 与空格被裁最普遍(可选 front matter 字段留空即变 null)。
  • 建议探针:对每个可选章/卡字段写空串或前后带空格值,writeChapterreadFrontMatter 比对;断言 data.某字段===''
  • 置信:高(已复现)。

2. [P1] serializers/yaml-dialect.js:65-71 双引号分支只转义 " 不转义反斜杠 → 触发引号的值含 \ 时整份 front matter 解析失败

  • 怀疑理由value.replace(/"/g,'\\"') 未处理 \。当值同时触发加引号(如含冒号)且含反斜杠(Windows 路径/注释),产出 "C:\Users\x",js-yaml 见 \U 当非法转义 → 抛错 → parseFrontMatter 整文件 ok:false,适配器返回"解析失败"。\(反斜杠+空格)则被静默吞掉。
  • 探针已跑"C:\Users\x" → 序列化 f: "C:\Users\x" → parse PARSEFAIL(expected hexadecimal character)。同类:值以 *ptr/&a/[TBD 开头 → 分别 PARSEFAIL/→null。
  • 建议探针:角色卡/条目某字段写 路径:C:\a\b 或以 [ * 开头的短题,写盘后 readCharacterFrontMatter 断言 ok
  • 置信:高(已复现,属整文件读失败,比 #1 更重)。

3. [P1] atomic.js:27-51,60-66 出错回滚会删掉尚未备份的原文件 → 覆盖写失败即丢原文

  • 怀疑理由plan.existed 仅在 fs.rename(full→backup) 成功后(line 33)才置 true。若 writeFile(tmp)(line 27) 或 rename(full→backup)(line 32) 先失败,existed 仍为 false,restorePlan(line 64) 走 fs.rm(plan.final)从未动过的原文件删掉。多文件批次里靠后文件失败时,其原文无备份即被删。Windows 触发面:full+.wnwtmp.pid.n 超 MAX_PATH 260 → tmp 写 ENAMETOOLONG;或原文件被编辑器/杀软占用 → rename EPERM。消费方 state-machine/persist.js:100/129review/index.js:254 会覆盖既有 大纲/细纲 源文件。
  • 建议探针:mock fs.writeFile 对第 2 个文件抛错,writeAtomicBatch(repo,[已存在A,已存在B]),断言 B 原文仍在;或构造接近 260 字符的 repoPath 触发 tmp 超长。
  • 置信:中(逻辑清晰,需故障注入复现;与 bug 模式 #6/#8 吻合)。

4. [P1] serializers/markdown-table.js:10 序列化不转义单元格内 | 与换行 → 名册/时间线单元格数据丢失

  • 怀疑理由String(r[h]??'') 直接拼进 | ... |。值含 | 会在读回时被 split('|') 切成多格 → 列数不匹配 → 补齐/截断 → 尾部内容丢。含 \n 则整表错行。
  • 探针已跑:别名 "刀疤|老王" 序列化 | 甲 | 刀疤|老王 | → 读回 别名:"刀疤"("老王"丢)。
  • 建议探针upsertRosterRow({正名:'甲',别名:'刀疤|老王'})resolveAlias('老王') 断言命中;应 MISS 复现。
  • 置信:高(已复现)。

5. [P1] adapters/EntityReader.js:87 + cache/rebuilder.js:262 别名按 ASCII , 拆,staging/index.js:220[,,、] 拆 → 三源分裂,中文分隔的别名解析失败

  • 怀疑理由:中文作者惯用全角逗号 或顿号 分隔别名。EntityReader(文件降级)与 rebuilder(entity_aliases 真源)只切 ASCII 逗号 → 整串当一个别名;resolveAlias MISS、别名唯一性校验漏冲突;而 staging splitAliases 三种都切 → 叠加视图与真源对不上(bug 模式 #7 双源 + #6 中文)。migrate 写 join(', ')(transform.js:117) 是 ASCII,迁移书暂安全;手改/AI 写的名册中招。
  • 探针已跑:名册 别名: 阿晚,晚儿、小晚resolveAlias('阿晚'/'晚儿'/'小晚') 全部 MISS。
  • 建议探针:同上;再删缓存重建后查 entity_aliases 表,断言只有 1 行 bogus 别名。
  • 置信:高(已复现)。B 区 rebuilder 同病,接缝一并记。

6. [P1] adapters/ChapterReader.js:19-32ThreadLedgerReader.js:19-33 命中缓存返回英文列名、降级读文件返回中文键 → 精准读接口形状随缓存冷热漂移

  • 怀疑理由:缓存命中 return rows[0]chapter_num/title/word_count…英文 snake_case);文件降级 return parsed.data章号/标题/字数…中文)。read-chapter.js:15/19read-thread.js:13/32 都传 ctx.cacheJSON.stringify(r.data) → 作者/AI 看到的键名随缓存是否命中而变;暖缓存下输出机器味英文列(bug 模式 #10 + #7)。schema.js 列名与 fixture 0001-开局.md 中文键已证形状不同;现有测试仅覆盖冷缓存路径(ChapterReader.test.js 无 cache 实参)。
  • 建议探针:同一 repo,先 readFrontMatter(1) 无缓存记键集,再建缓存后重调,断言键集一致;预期出现 chapter_num vs 章号 差异。
  • 置信:高(漂移确凿)/ 中(对 AI 消费的实际危害取决于下游是否读中文键)。

P2(支路错 / 稳健性 / 幂等)

7. [P2] parsers/markdown-table.js:29-36,100-105 全角管道符 表格整表解析失败 → 名册/时间线静默变空

  • 怀疑理由:只认 ASCII | 作围栏。作者用中文输入法打出 | 正名 |… 整行不以 ASCII | 起止 → 表头判失败 → ok:false → resolveAlias/TimelineReader 拿空。
  • 探针已跑| 正名 | 别名 |\n|---|---|\n…parseMarkdownTable.ok=false, rows=0
  • 建议探针:写全角管道名册,resolveAlias 任意别名断言 MISS 且无报错日志。
  • 置信:高(行为)/ 中(严重度:仅手改触发)。

8. [P2] parsers/markdown-table.js:67-79 不识别 GFM \| 转义、且列数不符时静默截断多余列 → 作者额外列在改写时被丢

  • 怀疑理由\|(GFM 单元格内字面竖线)被当分隔切开;行 cell 多于表头时 splice(headers.length) 丢尾列。名册/时间线若被作者加了额外列(如 备注),writer 全表重写会丢。
  • 探针已跑| 甲 | a\|b | → 读回 别名:"a"(headers 仍 2)。
  • 建议探针:给名册加第 5 列 备注upsertRosterRow 改一行后读回,断言 备注 列还在(预期丢)。
  • 置信:中。

9. [P2] adapters/ThreadLedgerReader.js:213ThreadLedgerWriter.js:99SecretReader.js:115 startsWith(id) 无界前缀命中 → 查不存在的短号命中长号条目

  • 怀疑理由files.find(f=>f.startsWith(threadId)) 无尾分隔符。查 伏笔-1(无此文件)会命中 伏笔-10-x.md → 读/改到错条目,appendHistory 追到错文件。createThread 去重用 ${idStr}-(有界,line 41)、_find 用无界(双源不一致 #7)。id 宽度未强制(createThread 只校验 \S+-\d+,migrate 补 3 位但 AI/手动可给非补零)。
  • 探针已跑:建 伏笔-1-断剑.md伏笔-10-血书.md_findThreadFile('伏笔-1') 本次返回正确(因 readdir 序 伏笔-1- < 伏笔-10);最坏例为目标不存在、长号存在时返回长号。
  • 建议探针:只建 伏笔-10-x.mdreadBasicInfo('伏笔-1') 断言应"不存在",预期错误命中 伏笔-10。
  • 置信:中(机制确凿,危害依赖非补零 id 出现)。

10. [P2] adapters/EntityWriter.js:26EntityReader.js:21 角色卡文件名用未净化 ${name}.mdmigrate/transform.js:142 却用 sanitizeFileName(e.name) → 名字含可净化字符时 migrate 写的卡 updateCharacter/reader 找不到

  • 怀疑理由:ChapterWriter/ThreadLedgerWriter 都过 sanitizeFileName,角色卡读写两处不过(问题 #4 同源)。名字含 Windows 非法字符或前后空格/多空格时,migrate 落盘在净化名、updateCharacter 按原名找 → 返回"角色不存在" → finalize characterUpdates 中断。且名字含 :/? 时 updateCharacter 直接写失败。
  • 建议探针migrate 一个含空格或 : 的角色名,随后 updateCharacter(原名,…) 断言 ok;预期 MISS。
  • 置信:中(触发面窄但确有分裂)。

11. [P2] adapters/EntityWriter.js:58-60 upsert 名册用 rows[idx]=row 整行替换 → 丢该行作者额外列

  • 怀疑理由:只带 正名/别名/类型/首现章 的新 row 覆盖旧整行对象;旧行的额外列(如 备注)随之丢,其它行保留 → 同表内不一致。
  • 建议探针:名册某行带 备注 列,upsertRosterRow 同正名后读回该行,断言 备注 保留(预期丢)。
  • 置信:中。

12. [P2] adapters/TimelineWriter.js:41 appendRow 恒追加不按 去重 → finalize 重跑/批次重处理同章 → 时间线重复行累积

  • 怀疑理由:与 EntityWriter.upsertRosterRow(按正名去重)相反,无幂等。定稿失败重试或 goto/relink 后重定稿同章会叠行(bug 模式 #2)。
  • 建议探针:同 volumeNumappendRow 两次,读回断言 1 行(预期 2 行)。
  • 置信:中。

13. [P2] adapters/EntityReader.js:87 row.别名.split 在名册缺 别名 列时对 undefined 调用 → 抛 JS TypeError 泄漏到作者域

  • 怀疑理由:表头无 别名 列时 row.别名 为 undefined,.splitCannot read properties of undefined,被 catch 原样回传(line 95)→ 作者看到英文 JS 栈味错误(bug 模式 #10)。
  • 建议探针:名册表头去掉 别名 列,resolveAlias('x') 看 error 文案是否为 JS 原生报错。
  • 置信:中。

S(spec 漂移 / 死代码 / 低危稳健性)

14. [S] serializers/front-matter.js:31-69 extractUnknownFields 为死代码(无人传第 3 参 originalYAML),内含 latent bug

  • 怀疑理由:全部 serializeFrontMatter( 调用点(ChapterWriter/EntityWriter/ThreadLedgerWriter/SecretWriter/migrate/staging)都只传两参。§4.5"未知字段保留"实际靠 {...parsed.data,...updates} 展开达成(parseFrontMatter 把全部键放进 data)。此函数 line 48 ^([^:]+): 还会把含冒号的已知列表项误判成未知键重复追加——但因死代码未触发。
  • 建议探针:无(记为清理项/latent)。:靠 data 展开保留意味着作者写的嵌套映射未知字段会让 serializeYAML 抛错(line 20-22)→ updateThread/updateCharacter 返回 ok:false,符合 §4.5"嵌套走修复确认"但错误文案是机器味。
  • 置信:高(死代码确凿)。

15. [S] adapters/ChapterReader.js:127-144 readRange 无外部调用者,且默认 fields=['摘要'] 既不在章 front matter 也不在 chapters 表 → 两条路径都返回空摘要

  • 怀疑理由:全仓 readRange( 仅定义处与自身。摘要真源在 定稿/摘要/章摘要/NNNN.md(SummaryWriter),front matter 无 摘要 键(见 fixture)。即便有人调,命中缓存/降级都取不到摘要。
  • 建议探针:无(死代码 + 契约错位,记清理/修契约)。
  • 置信:中。

16. [S] parsers/book-config.js:20-22 requiredFields/missingFields 计算后从不使用 → 死校验;缺必需字段静默套默认

  • 怀疑理由:line 21 算出 missingFields 后无分支消费,恒 ok:true。缺 书名 也返回默认"未命名"。若这是有意"永远给默认",则校验代码应删;若是漏接,则缺字段该报。
  • 建议探针:无(读代码即证)。相关:line 43 {...defaults,...data} 会把全部默认灌进返回对象;当前无 BookConfigWriter 回写故不落盘,若日后加 writer 需防默认污染 book.yaml。
  • 置信:高(死校验)/ 低(危害)。

17. [S] util/markdown.js:31-57/util/thread-declarations.js:33 段匹配与声明解析偏脆

  • 怀疑理由appendUnderSectionincludes(sectionTitle) 子串匹配 ## 段——两个标题都含关键词(如"履历"与"补充履历")会追到先出现的错段;parseThreadDeclarations^(\S+)\s+(\S+)$ 单空格两段式,动词或 id 含空格即判 malformed。均属低频。
  • 建议探针:条目正文放两个含"履历"的 ##,appendHistory 看落点;低优先。
  • 置信:低。

已核对无问题 / 无漂移(缩小复查面)

  • EntityReader.resolveAlias 返回值两路径一致(缓存 entity_id / 文件 正名 都是正名),无形状漂移;漂移只在 readFrontMatter/readBasicInfo 返回整行处(#6)。
  • SecretReader.readBasicInfo 不走缓存(只 _findSecretFile 读文件),无 #6 式中英漂移;但 _findSecretFile#9 前缀命中问题。
  • ChapterReader._findChapterFile(line 156) 与 ChapterWriter.backupOldChapterFiles(line 24) 均用 4 位补零 + 尾 - 有界前缀,无 #9 式碰撞;章号文件名同源用 sanitizeFileName(ChapterWriter:69),与 staging 草稿标题取值同经 parsed.data.标题——章文件净化同源,仅角色卡不同源(#10)。
  • 2026-07-05/yes-no-on-off/010/1_000 经 js-yaml 5.2.0 DEFAULT 仍为字符串(010^\d+$ 已被 needsQuoting 引住)——日期/YAML1.1 布尔构成 #1 漏引项,已排除。

Caveats

  • 现场探针脚本用 node --input-type=module -e 即时跑,未落盘(遵守"只写 review-A.md");主会话复核可复用各条"探针已跑/建议探针"。
  • rebuilder.js/staging/index.js/finalize/index.js 属 B/C/E 区,仅在接缝处(别名分隔 #5、名册缺列致整次重建 ROLLBACK 见下)点到即止。
  • 接缝附记(B 区)cache/rebuilder.js:262 名册缺 别名 列时 undefined.split 抛错 → line 276 catch 返回 ok:false → 整次重建 ROLLBACK;违反 §5.2"名册格式问题应软跳过、只有别名冲突才硬错"。建议 B 区探针:名册去 别名 列后 rebuildFromSource,断言 chapters/threads 仍入库、仅 warning。