Преглед изворни кода

feat(v7): M3 P4——人话命令 CLI(next/impact/goto-chapter)+ AC 复核

- impact <关键词> / goto-chapter <章号> [--confirm] 接成 run 契约 CLI
  (next 已在 P1);retcon 不接 CLI(payload 复杂,编排/M4 的 Use Case)
- bin --help 增「状态机/例外流程(M3)」段
- 出口判据复核:7 态 e2e / git 异常样本库零英文堆栈 / 外环流程 / 不变量回归 全达成
- 全量 200 绿
lingfengQAQ пре 15 часа
родитељ
комит
45db5f9012

+ 7 - 5
.trellis/tasks/06-27-m3-state-machine/implement.md

@@ -80,8 +80,10 @@ P4 全量 AC 复核 + 推送验证 CI(git 异常在 Windows 跑)
 
 ## 出口判据(对齐 prd Acceptance)
 
-- [ ] 7 个态各有端到端测试(命中各序判定正确 + 命中即停)
-- [ ] git 异常样本库逐个演练(半提交/冲突/锁/损坏/网盘副本),零英文堆栈,安全网可恢复
-- [ ] 影响分析/回到第N章/吃书 纯脚本流程有测试
-- [ ] 不破坏 M1/M2 不变量(删缓存重建、定稿原子)
-- [ ] CI 双平台绿(git 异常处理在 Windows 验证)
+> 重建后状态(2026-06-27,全量 `node --test` 200 绿):
+
+- [x] 7 个态各有端到端测试(router.test 命中各序 0-6 + 命中即停)
+- [x] git 异常样本库逐个演练(陈旧锁/网盘副本/半提交/合并冲突/.git 损坏),零英文堆栈,安全网可恢复(git-health.test)
+- [x] 影响分析/回到第N章/吃书 纯脚本流程有测试(flows/*.test)
+- [x] 不破坏 M1/M2 不变量(删缓存重建、定稿原子;M1/M2 测试仍全绿)
+- [ ] CI 双平台绿(git 异常处理在 Windows 验证):**待推送验证**

+ 5 - 0
v7/bin/webnovel-writer.js

@@ -49,6 +49,11 @@ if (!command || command === '--help') {
   console.log('写章流程(M2,零 AI 脚本面):')
   console.log('  prepare-chapter <章号>                  备料:写出 工作区/本章写作材料.md')
   console.log('  mechanical-check <章号> [--draft=<路径>]  机检:字数/禁词/禁句式/复读/新专名/信息差候选')
+  console.log('')
+  console.log('状态机 / 例外流程(M3):')
+  console.log('  next                                    继续:状态机判定下一步(git 健康检查先行)')
+  console.log('  impact <关键词>                          影响分析:哪些章建立在这个事实上(已发布/未发布)')
+  console.log('  goto-chapter <章号> [--confirm]          回到第N章(先备份再回滚,作者不碰 git)')
   process.exit(0)
 }
 

+ 15 - 0
v7/src/commands/goto-chapter.js

@@ -0,0 +1,15 @@
+import { gotoChapter } from '../state-machine/flows/goto-chapter.js'
+
+/**
+ * goto-chapter <章号> [--confirm] → 回到第N章(人话命令,先备份再回滚)
+ * 不带 --confirm 只展示影响范围;带 --confirm 才真回退。
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const chapterNum = parseInt(args[0], 10)
+  if (isNaN(chapterNum)) {
+    return { ok: false, error: '请指定要回到的章号' }
+  }
+  const r = await gotoChapter(ctx, { chapterNum, confirm: !!options.confirm })
+  return r.ok ? { ok: true, output: r.message } : { ok: false, error: r.error }
+}

+ 10 - 0
v7/src/commands/impact.js

@@ -0,0 +1,10 @@
+import { analyzeImpact } from '../state-machine/flows/impact.js'
+
+/**
+ * impact <关键词> → 影响分析(哪些章/条目/时间线建立在这个事实上,分已发布/未发布)
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const r = await analyzeImpact(ctx, { 关键词: args[0] })
+  return r.ok ? { ok: true, output: JSON.stringify(r, null, 2) } : { ok: false, error: r.error }
+}

+ 49 - 0
v7/test/commands/m3-flows.test.js

@@ -0,0 +1,49 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { run as impactRun } from '../../src/commands/impact.js'
+import { run as gotoRun } from '../../src/commands/goto-chapter.js'
+import { makeGitBook, chapter } from '../state-machine/_helper.js'
+
+test('impact 命令:输出影响分析 JSON', async () => {
+  const { ctx, cleanup } = await makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n已发布到章: 0\n',
+    '定稿/正文/0001-起.md': chapter(1, '林晚得到玉佩。'),
+  })
+  try {
+    const r = await impactRun(['玉佩'], {}, ctx)
+    assert.equal(r.ok, true)
+    const out = JSON.parse(r.output)
+    assert.deepEqual(out.未发布, [1])
+  } finally {
+    await cleanup()
+  }
+})
+
+test('goto-chapter 命令:不带 --confirm 只展示影响', async () => {
+  const { ctx, cleanup } = await makeGitBook(
+    { 'book.yaml': 'spec_version: "7.0"\n书名: 测\n' },
+    {
+      commits: [
+        { message: 'ch(1): 起', files: { '定稿/正文/0001-起.md': chapter(1) } },
+        { message: 'ch(2): 承', files: { '定稿/正文/0002-承.md': chapter(2) } },
+      ],
+    }
+  )
+  try {
+    const r = await gotoRun(['1'], {}, ctx)
+    assert.equal(r.ok, true)
+    assert.match(r.output, /丢弃|ch\(2\)/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('goto-chapter 命令:非数字章号 → ok=false', async () => {
+  const { ctx, cleanup } = await makeGitBook({ 'book.yaml': '书名: 测\n' })
+  try {
+    const r = await gotoRun(['abc'], {}, ctx)
+    assert.equal(r.ok, false)
+  } finally {
+    await cleanup()
+  }
+})