1
0

review-E.md 14 KB

Review E 区:M6 staging 与三消费点叠加 + 批次×手动流程交叉

  • 审查员:Research Agent(E 区)
  • 范围:v7/src/staging/、prep/review/mechanical-check 叠加分支、6 个 batch 命令、dto.batchDetail、goto/relink/finalize/retcon/impact/export 交叉接缝
  • 日期:2026-07-05
  • 对照:spec §8.1(decisions #36/#20)、§9/§10、PRD「本轮新增关注」
  • 方式:只读精读 + 代码在位性核销(无 CLI 探针,标注建议探针供主会话复现)

候选计数:P1×2 / P2×4 / S×3;另核销「在位正确」7 项(见末节)。


P1(支路断 / 数据丢)

E1. goto-chapter 回退定稿到批次起章之下 → 孤儿批次 + 定稿章号静默缺口

[P1] v7/src/state-machine/flows/goto-chapter.js:36-69 + v7/src/finalize/index.js:40-42

  • 问题:goto-chapter 全流程零批次感知,回退 定稿/ 到 active 批次 起章 之下后,工作区/待定稿/ 批次原样残留,随后 finalize-batch 把 staged 章按原章号写回 定稿/,中间缺口静默丢失。
  • 怀疑理由
    • grep batch|待定稿|readBatch 在 goto-chapter.js / finalize.js / finalize/index.js 零命中(本轮实测 exit 1)——无任何批次守卫。
    • goto 的脏树检查(goto-chapter.js:40-52)只看 定稿/大纲 前缀,工作区/待定稿/ 不在检查内;git reset --hard(:57)只动 git 跟踪文件,批次目录(工作区,未入 git)存活。
    • 场景:定稿到 100 章,批次 stage 101-105(预登记事实建立在 96-100 上)。作者 goto-chapter 95 --confirm → 定稿回到 95,批次 101-105 仍在。next → 序 3(待定稿/ 存在)→ 批次续跑,建议照给。finalize-batch → finalizeChapter 无连续性校验(:40-42 只校验 Number.isInteger标题),ChapterWriter 按号写 0101-*.md → 定稿变 [1..95, 101..105],96-100 静默缺口,且 threadUpdates 履历从 ch95 跳到 ch101。
    • spec §8.1「goto 只管已定稿回退、丢弃批次管未定稿,两者职责不混」——设计意图是互斥,但无代码强制
  • 建议探针:脚本建 book 定稿到 ch5,stage 6-8,goto-chapter 3 --confirm,再 finalizeBatch,断言 定稿/正文 出现 [1,2,3,6,7,8] 缺口 / 或期望的拒绝。
  • 置信:HIGH(守卫缺失可证;缺口后果为代码路径直接推导)。

E2. 批次进行中手动 finalize 单章 → 章号双计 + finalize-batch 卡死

[P1] v7/src/commands/finalize.js:26 + v7/src/finalize/index.js:22-42

  • 问题:手动 finalize <章号> 命令对 active 批次无守卫、无连续性校验;定稿一个已在批次内暂存的章号,会在叠加视图双计,并使 finalize-batch 卡死。
  • 怀疑理由
    • finalize.js 全文无批次检查;finalizeChapter 校验仅 Number.isInteger(chapterNum)frontMatter.标题(:41-42),不比对定稿最大章号 / 批次起章。
    • 双计:批次 stage 101-105,手动 finalize 101 后缓存含 101。overlayBookStatus(staging/index.js:235)总章数 = 定稿总章数(含101) + staged.length(含101)卷内进度(:240-246)同样把 101 计两次。
    • 卡死:随后 finalize-batch 再定稿 101 → finalizeChapter 跑 threadCreates → ThreadLedgerWriter.createThread 对已存在条目返回 {ok:false, '条目 … 已存在'}(ThreadLedgerWriter.js:42)→ finalizeChapter 回滚失败 → finalize-batch 停在 101,批次卡住。
    • PRD 本轮新增关注点名此场景「手动 finalize 单章(绕过批次直接定稿下一章→章号连续性与批次起章错位)」。
  • 建议探针:stage 3-5 后 finalizeChapter(ctx, chapterPayload(3)),再 finalizeBatch(ctx),断言卡在第 3 章「已存在」;另断言 overlayBookStatus 总章数把 3 计两次。
  • 置信:HIGH(守卫缺失可证);卡死链路 PLAUSIBLE(依赖 createThread 已存在语义,建议探针坐实)。

P2(稳健性)

E3. finalizeBatch 循环无 try-catch:转正后清目录 / 写 meta 失败会抛裸栈且留状态不一致

[P2] v7/src/staging/index.js:529-556

  • 问题:per-章循环无 try-catch。finalizeChapter 已 commit 后,fs.rm(dirP)(:553)或 writeAtomicBatch(metaFile(remaining))(:555)抛错会裸栈冒泡(作者直面堆栈,违不变量 8),且批次.json 仍把已定稿章列为「待审收」。
  • 怀疑理由
    • finalizeBatch(:508-566)无外层 try-catch;finalize-batch.js:15 直接 await finalizeBatch 也无兜底 → 抛到 CLI dispatcher。
    • 顺序是 finalizeChapter(已 commit + 刷缓存) → rm 目录 → 更新 meta。若 rm/meta 在中途抛(Windows 文件锁/权限),该章已入档但 meta 未更新;重跑 finalize-batch 会对该章再定稿 → createThread「已存在」→ 卡死(同 E2 尾链)。
    • 对比:stageChapter(:274-341)与 finalizeChapter(:47-172)都有外层 try-catch,finalizeBatch 独缺。
  • 建议探针:mock fs.rm 在第 2 章抛 EPERM,跑 finalizeBatch,断言是否裸抛 / 批次.json 与磁盘失步。
  • 置信:MEDIUM(触发需 fs 错误,PLAUSIBLE)。

E4. stageChapter 覆盖重暂存:writeAtomicBatch 成功后清旧目录/工作区抛错 → 报失败但批次已写

[P2] v7/src/staging/index.js:315-338

  • 问题writeAtomicBatch(:315)已落新批次文件后,旧目录清理(:324)或工作区清理(:333-335)抛错,会走外层 catch 返回 ok:false,但批次实际已写入。
  • 怀疑理由
    • 「零写入承诺」对校验失败成立(:277-306 所有校验都在 writeAtomicBatch 之前,审稿单缺失等分支零副作用,在位正确)。
    • 但覆盖重暂存改标题清旧目录 fs.rm(:324)、工作区 fs.rm(:334)在 writeAtomicBatch 之后;抛错 → catch 返回 ok:false(:339-341),宿主视作未暂存重试。重试再 stage 覆盖(幂等性尚可),但「失败=零副作用」的直觉在此不成立。
    • 旧目录若清理失败残留:readBatch 的 seen.has(num) 按章号去重(:69)会跳过同章号旧目录 → 逻辑无害,但磁盘孤儿目录永久残留(次要泄漏)。
  • 建议探针:改标题重 stage 时 mock 旧目录 rm 抛错,断言返回值与批次落盘是否矛盾。
  • 置信:MEDIUM。

E5. finalize-batch --until「前几章先发」后,剩余待审收章的续跑建议指向「继续写下一章」而非「转正剩余」

[P2] v7/src/state-machine/dto.js:80-88

  • 问题--until 只转正前段后,剩余章全为「待审收」,batchDetail 建议落到 else 分支「继续批内下一章(第 N+1 章)」,而非提示「剩余 X 章已就绪,可 finalize-batch 转正」。
  • 怀疑理由
    • batchDetail 建议优先级(:80-88):打回 > 受影响 > 停止 > else「继续下一章」。剩余章非打回非受影响;原停止因(写满)在移除前段后已不成立(judgeStop 只数当前 staged 数),故 停止.stop=false → else 分支。
    • finalize-batch.test.js:246-247 已断言此路径回序 3,但未校验建议文案的合理性;spec §8.1「支持只转正前段(前几章先发)→ 下一批次」,就绪的剩余章应被指向转正而非继续堆章。
    • 后果:作者跟着建议写 106,批次又长回去,原写满停止语义丢失(可无限增长)。
  • 建议探针:stage 3-5,finalizeBatch --until=4,取 buildDto(序3),断言 批次.建议 是否误导为「继续下一章」。
  • 置信:MEDIUM(文案完整性,非数据错,经 --until 测试路径确认)。

E6. 批次.json 损坏重建把已打回空目录降级为不可操作的「受影响」行

[P2] v7/src/staging/index.js:74-78 + 452-475

  • 问题:批次.json 丢失时全量按目录重建、一律标「受影响」(保守,:75 注释在理);但 rejectFrom 保留的空目录(清了草稿/定稿包/审稿单)会重建成一条「受影响」行,其草稿/定稿包已不存在 → 既不能 finalize(读定稿包失败)也不能 restage(无审稿)。
  • 怀疑理由
    • rejectFrom(:469-472)删 3 件套但保留目录 + 元数据行(状态=打回)。批次.json 损坏后走重建分支,rowFromDir(:85-97)读不到草稿 → warn + 仍建行、状态硬编码「受影响」(:96)。
    • 该行 finalizeBatch 时读 定稿包.json 失败(:533-540)→ 整批停;batch-restage 读 工作区/审稿.md 也无(且它本是打回章,restageReview:485-487 会拒)。→ 死行,须作者手动 rewrite。
    • 「起章/连续计数怎么恢复」子问:起章 = 目录最小号(:82,无损);连续无变动 = judgeStop 从 front matter 现算(:378,无损)——二者派生、重建无损。唯一丢失是「打回 vs 待审收」状态区分,且打回空目录退化为死「受影响」行。
  • 建议探针:stage 3-5、reject 4、删批次.json、readBatch,断言第 4 章行状态与可操作性。
  • 置信:MEDIUM。

S(spec 漂移 / 低风险双写)

E7. 批次.json 持久形态与 spec §8.1 声明字段漂移(起章/连续计数未落盘,靠派生)

[S] v7/src/staging/index.js:99-104

  • 问题:spec §8.1 称批次.json 存「起章、各章{章号,标题,状态}、连续无条目变动计数」;metaFile 只持久 {章列表}
  • 怀疑理由:起章(rows[0].章号)与连续计数(judgeStop 现算)均为派生量,落盘缺失不致数据丢(重建无损,见 E6),但持久 schema 与 spec 文本不符——属实现口径回填时的文档漂移。
  • 建议探针:无需运行;文档核对即可。
  • 置信:HIGH(漂移属实,影响近零)。

E8. 弱钩判据 includes('弱钩')||endsWith('-弱') 四处双写

[S/low] v7/src/staging/index.js:252、419;v7/src/prep/book-status.js:34;v7/src/commands/report-weak-hook-streak.js:14

  • 问题:弱钩识别谓词在 4 处逐字复制(staging 内叠加显示:252 与停止判据:419 各一份,另 book-status、report-weak-hook-streak 各一份)。
  • 怀疑理由:钩子格式若变(如新增「弱钩」写法),4 处须同步改,漏一处即判据漂移。历史 bug 模式 #7(常量双写)同类。风险低(当前格式稳定、逻辑简单)。
  • 建议探针:无需运行;建议抽 isWeakHook() 单点。
  • 置信:HIGH(双写属实,风险低)。

E9. 停止「连续无条目变动」只读 front matter 声明,与 stagedFacts 的 payload 条目来源可分叉

[S] v7/src/staging/index.js:377-384 vs 152-193

  • 问题:judgeStop 连续无变动计数只看 parseThreadDeclarations(frontMatter)(:379),而 stagedFacts.threads 还从 payload.threadCreates/threadUpdates(:178-193)派生。若宿主直接构造 payload 带条目变动但 front matter 三数组为空,会被判「无变动」。
  • 怀疑理由:spec §8.1 明写停止判据 =「front matter 三数组声明全空」,故以 front matter 为准属故意;但 front matter 与 payload 由宿主分别提供、可分叉,属契约脆弱点(正常流程二者一致,机检/审稿也都读 front matter,在位一致)。
  • 建议探针:stage 一章 front matter 空三数组但 payload 有 threadCreates,看是否误触连续无变动停止。
  • 置信:MEDIUM(契约提示,非缺陷)。

在位正确 / 复查通过(探针核销,非候选)

  1. before 过滤三消费点全传对:prep/index.js:22、review/index.js:30、mechanical-check/index.js:36 均 stagedFacts(repoPath,{before:chapterNum})——重审受影响章不倒灌后章事实。✓
  2. 容差常量同源不双写AVG_SENTENCE_LEN_TOLERANCE/SENTENCE_VARIANCE_TOLERANCE 唯一定义在 style-stats/index.js:12-13,staging(:11-14)与 mechanical-check(:6)均从该处 import,spec §8.1「口径同机检」达成。✓
  3. parseThreadDeclarations 共用解析器:staging:152、review:37、mechanical-check:193 共用 util/thread-declarations.js,条目声明解析不双写。✓
  4. 无批次零行为变化:stagedFacts 无批次返回 exists:false + 空集合;overlayBookStatus 早退(:231);三消费点对空集合/空 Map/空 Set 循环即无操作;readBatch().exists 各消费点均有防护。✓
  5. staged 数据不入缓存/指纹/意象:cache/rebuilder.js 只读 定稿;finalize-batch.test.js:92-100(AC7)断言批内 fingerprints=0、imagery_top=0。✓
  6. 收卷→序4 接得上:is_volume_end 由 rebuilder.js:100 从 front matter 收卷 派生;批次活动期 序3(待定稿/)优先于序4,不会 mid-batch 误触;finalize-batch 清空待定稿/后,收卷章入缓存 → 序4 卷复盘触发。链路通。✓
  7. export 批次章导不到(符合预期):export/index.js:79 只扫 定稿/正文;批次章在 工作区/待定稿/,不导出——设计如此。✓
  8. finalize-batch 入口硬校验 / 升序原子 / 中途失败停该章保留:staging/index.js:517-556 + finalize-batch.test.js 全覆盖(AC1 逐字段一致、AC3 打回传染拒绝、中途坏包停该章、AC6 丢弃、--until)。✓

主靶小结(批次×手动流程交叉)

手动例外流程 批次感知 结论
goto-chapter 回退定稿 E1:回退到起章下 → 孤儿批次 + 定稿缺口
手动 finalize 单章 E2:双计 + finalize-batch 卡死
relink 补登手改 N/A 只 add 定稿/大纲(detectors.js:99),不碰工作区批次,安全
retcon 吃书 (宽回滚) 批次外,overlay 每次重读定稿,低风险;retcon 宽 restore/clean 属 C/D 区
impact 影响分析 N/A 只读 定稿/大纲,不涉批次,安全
卷复盘(序4) 序3 优先 在位正确(见核销 6)
体检 随 finalize-batch 尾跑 staging/index.js:558-564,失败不阻断,在位

根因收敛:E1/E2 同源——goto-chapter 与手动 finalize 命令缺「active 批次」互斥守卫;spec §8.1 假设二者职责不混但无强制。建议主会话优先探针这两条。