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

feat(v7): M3 P2——外环脚本流程(影响分析/回到第N章/吃书)

- impact.analyzeImpact:grep 正文+条目履历+时间线,按 book.yaml 已发布到章
  分「已发布/未发布」两清单(纯脚本,不改文件)
- goto-chapter.gotoChapter:git log --grep ch(N) 定位 → confirm=false 列将丢弃
  的提交 → confirm=true 先建救援 ref 再 reset --hard(不变量 8,可找回)
- retcon.retcon:改设定/条目 + `retcon(N): 原因` commit + 留痕;缺原因拒绝;
  失败回滚(复用 M2 Writer + git restore/clean)
- git.js 扩展:findChapterCommit/commitsAfter/resetHard
- 测试 7 例(影响分析分组、回到N reset+备份 ref、吃书 commit+留痕)
lingfengQAQ 1 день назад
Родитель
Сommit
9271028b1c

+ 21 - 0
v7/src/finalize/git.js

@@ -87,5 +87,26 @@ export function createGit(repoPath) {
     async createBackupRef(name, source = 'HEAD') {
       await run(['update-ref', `refs/${name}`, source])
     },
+    /** 找某章定稿提交(ch(N): …)的最近 hash;无则 null */
+    async findChapterCommit(n) {
+      try {
+        const { stdout } = await run(['log', `--grep=ch(${n}):`, '--format=%H', '-1'])
+        return stdout.trim() || null
+      } catch {
+        return null
+      }
+    },
+    /** ref..HEAD 之间提交的标题列表(影响范围) */
+    async commitsAfter(ref) {
+      try {
+        const { stdout } = await run(['log', '--format=%s', `${ref}..HEAD`])
+        return stdout.split('\n').map((s) => s.trim()).filter(Boolean)
+      } catch {
+        return []
+      }
+    },
+    async resetHard(ref) {
+      await run(['reset', '--hard', ref])
+    },
   }
 }

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

@@ -0,0 +1,43 @@
+import { createGit } from '../../finalize/git.js'
+
+/**
+ * 回到第 N 章(spec §9,git 回滚包装)。执行前展示影响范围 + 作者确认;
+ * confirm 才真 reset,且**先建救援 ref 备份**(不变量 8:作者不碰 git)。
+ * @param {{repoPath: string}} ctx
+ * @param {{chapterNum: number, confirm?: boolean}} args
+ */
+export async function gotoChapter(ctx, { chapterNum, confirm = false } = {}) {
+  if (!Number.isInteger(chapterNum)) return { ok: false, error: '请指定要回到的章号' }
+  const git = createGit(ctx.repoPath)
+
+  const hash = await git.findChapterCommit(chapterNum)
+  if (!hash) return { ok: false, error: `未找到第 ${chapterNum} 章的定稿提交(ch(${chapterNum}):)` }
+
+  const willLose = await git.commitsAfter(hash)
+  if (!confirm) {
+    return {
+      ok: true,
+      needsConfirm: true,
+      target: hash,
+      willLose,
+      message:
+        willLose.length === 0
+          ? `第 ${chapterNum} 章已是最新,无需回退。`
+          : `回到第 ${chapterNum} 章会丢弃其后 ${willLose.length} 个提交:${willLose.join(';')}。确认请带 confirm。`,
+    }
+  }
+
+  const ref = `rescue/goto-${Date.now()}`
+  try {
+    await git.createBackupRef(ref) // 备份当前 HEAD
+    await git.resetHard(hash)
+    return {
+      ok: true,
+      reverted: true,
+      backupRef: `refs/${ref}`,
+      message: `已回到第 ${chapterNum} 章。原状态已备份到 refs/${ref},如需找回:git reset --hard refs/${ref}`,
+    }
+  } catch (err) {
+    return { ok: false, error: `回退失败:${err.message}` }
+  }
+}

+ 68 - 0
v7/src/state-machine/flows/impact.js

@@ -0,0 +1,68 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { BookConfigReader } from '../../storage/adapters/BookConfigReader.js'
+
+/**
+ * 影响分析(spec §9,纯脚本):grep 正文 + 条目履历 + 时间线,列引用清单;
+ * 按 book.yaml `已发布到章`(默认 0)把正文命中分「已发布/未发布」两清单。
+ * @param {{repoPath: string}} ctx
+ * @param {{关键词: string}} args
+ */
+export async function analyzeImpact(ctx, { 关键词 } = {}) {
+  if (!关键词) return { ok: false, error: '请提供要分析影响的关键词或实体名' }
+  const { repoPath } = ctx
+
+  const 正文章号 = []
+  for (const f of await listMd(path.join(repoPath, '定稿', '正文'))) {
+    if ((await fs.readFile(f, 'utf8')).includes(关键词)) {
+      const num = parseInt(path.basename(f).match(/\d+/)?.[0] || '0', 10)
+      if (num) 正文章号.push(num)
+    }
+  }
+
+  const 履历命中 = []
+  for (const f of await walkMd(path.join(repoPath, '大纲'))) {
+    if ((await fs.readFile(f, 'utf8')).includes(关键词)) 履历命中.push(path.relative(repoPath, f))
+  }
+
+  const 时间线命中 = []
+  for (const f of await listMd(path.join(repoPath, '定稿', '设定', '时间线'))) {
+    if ((await fs.readFile(f, 'utf8')).includes(关键词)) 时间线命中.push(path.relative(repoPath, f))
+  }
+
+  const config = await new BookConfigReader(repoPath).read()
+  const 已发布到章 = (config.ok && config.data.已发布到章) || 0
+  const chapters = [...new Set(正文章号)].sort((a, b) => a - b)
+  return {
+    ok: true,
+    关键词,
+    已发布: chapters.filter((c) => c <= 已发布到章),
+    未发布: chapters.filter((c) => c > 已发布到章),
+    履历命中,
+    时间线命中,
+  }
+}
+
+async function listMd(dir) {
+  try {
+    return (await fs.readdir(dir)).filter((f) => f.endsWith('.md')).map((f) => path.join(dir, f))
+  } catch {
+    return []
+  }
+}
+
+async function walkMd(dir) {
+  let out = []
+  let entries
+  try {
+    entries = await fs.readdir(dir, { withFileTypes: true })
+  } catch {
+    return out
+  }
+  for (const e of entries) {
+    const full = path.join(dir, e.name)
+    if (e.isDirectory()) out = out.concat(await walkMd(full))
+    else if (e.name.endsWith('.md')) out.push(full)
+  }
+  return out
+}

+ 52 - 0
v7/src/state-machine/flows/retcon.js

@@ -0,0 +1,52 @@
+import path from 'node:path'
+import { createGit } from '../../finalize/git.js'
+import { EntityWriter } from '../../storage/adapters/EntityWriter.js'
+import { ThreadLedgerWriter } from '../../storage/adapters/ThreadLedgerWriter.js'
+
+/**
+ * 吃书 retcon(spec §9):显式改定稿,commit `retcon(N): 原因`,设定/条目同步,留痕可查。
+ * 圆设定(AI 生成向后兼容方案)留 M4;M3 只做 retcon 的脚本落地。
+ * @param {{repoPath: string}} ctx
+ * @param {{chapterNum: number, 原因: string, characterUpdates?: object[], threadUpdates?: object[]}} args
+ */
+export async function retcon(ctx, { chapterNum, 原因, characterUpdates = [], threadUpdates = [] } = {}) {
+  if (!Number.isInteger(chapterNum)) return { ok: false, error: '请指定吃书涉及的章号' }
+  if (!原因) return { ok: false, error: '吃书必须写明原因(commit 留痕可查)' }
+
+  const { repoPath } = ctx
+  const git = createGit(repoPath)
+  const written = []
+  try {
+    const ew = new EntityWriter(repoPath)
+    for (const c of characterUpdates) {
+      const r = await ew.updateCharacter(c.name, c.updates)
+      if (!r.ok) throw new Error(r.error)
+      written.push(path.join(repoPath, '定稿', '设定', '角色', `${c.name}.md`))
+    }
+
+    const tlw = new ThreadLedgerWriter(repoPath)
+    for (const t of threadUpdates) {
+      if (t.updates) {
+        const r = await tlw.updateThread(t.id, t.updates)
+        if (!r.ok) throw new Error(r.error)
+      }
+      const f = await tlw._findThreadFile(t.id)
+      if (f) written.push(f)
+    }
+
+    if (!written.length) throw new Error('吃书未提供任何设定/条目变更')
+
+    const rel = [...new Set(written)].map((f) => path.relative(repoPath, f))
+    await git.add(rel)
+    const commitHash = await git.commit(`retcon(${chapterNum}): ${原因}`)
+    return { ok: true, commitHash, message: `已吃书并留痕:retcon(${chapterNum}): ${原因}` }
+  } catch (err) {
+    try {
+      await git.restore(['定稿/', '大纲/'])
+      await git.clean(['定稿/', '大纲/'])
+    } catch {
+      // 回滚尽力而为
+    }
+    return { ok: false, error: `吃书失败,已回滚未提交写入:${err.message}` }
+  }
+}

+ 54 - 0
v7/test/state-machine/_helper.js

@@ -0,0 +1,54 @@
+import path from 'node:path'
+import os from 'node:os'
+import { promises as fs } from 'node:fs'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import { CacheManager } from '../../src/cache/index.js'
+
+const execFileAsync = promisify(execFile)
+
+/**
+ * 造 git 书仓库 + 缓存。files={相对路径:内容}。commits=[{message,files}] 逐个额外提交(用于造 ch(N) 历史)。
+ */
+export async function makeGitBook(files, { commits = [] } = {}) {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-sm-'))
+  const git = (a) => execFileAsync('git', a, { cwd: root })
+  await git(['init', '-q'])
+  await git(['config', 'user.email', 't@example.com'])
+  await git(['config', 'user.name', 'test'])
+  await fs.writeFile(path.join(root, '.gitignore'), '.cache/\n工作区/\n', 'utf8')
+  await writeAll(root, files)
+  await git(['add', '-A'])
+  await git(['commit', '-q', '-m', 'init'])
+
+  for (const c of commits) {
+    await writeAll(root, c.files)
+    await git(['add', '-A'])
+    await git(['commit', '-q', '-m', c.message])
+  }
+
+  const dbDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-sm-db-'))
+  const cache = new CacheManager(path.join(dbDir, 'index.db'))
+  await cache.ensureReady(root)
+  return {
+    root,
+    git,
+    ctx: { repoPath: root, cache },
+    cleanup: async () => {
+      await cache.close()
+      await fs.rm(root, { recursive: true, force: true })
+      await fs.rm(dbDir, { recursive: true, force: true })
+    },
+  }
+}
+
+async function writeAll(root, files = {}) {
+  for (const [rel, content] of Object.entries(files)) {
+    const full = path.join(root, rel)
+    await fs.mkdir(path.dirname(full), { recursive: true })
+    await fs.writeFile(full, content, 'utf8')
+  }
+}
+
+export const chapter = (n, body = '正文。', vol = 1) =>
+  `---\n章号: ${n}\n标题: 第${n}章\n卷: ${vol}\n字数: 100\n章定位: 推进\n钩子: 危机钩-强\n情绪定位: 铺垫\n---\n${body}`

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

@@ -0,0 +1,55 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { gotoChapter } from '../../../src/state-machine/flows/goto-chapter.js'
+import { makeGitBook, chapter } from '../_helper.js'
+
+function bookWithChapters() {
+  return 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) } },
+      ],
+    }
+  )
+}
+
+test('回到第N章 confirm=false:列出将丢弃的提交', async () => {
+  const { ctx, cleanup } = await bookWithChapters()
+  try {
+    const r = await gotoChapter(ctx, { chapterNum: 1, confirm: false })
+    assert.equal(r.ok, true)
+    assert.equal(r.needsConfirm, true)
+    assert.ok(r.willLose.some((s) => s.includes('ch(2)')))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('回到第N章 confirm=true:reset 到该章 + 备份 ref,后续章消失', async () => {
+  const { ctx, root, git, cleanup } = await bookWithChapters()
+  try {
+    const r = await gotoChapter(ctx, { chapterNum: 1, confirm: true })
+    assert.equal(r.ok, true)
+    assert.equal(r.reverted, true)
+    await assert.rejects(() => fs.access(path.join(root, '定稿/正文/0002-承.md')))
+    await fs.access(path.join(root, '定稿/正文/0001-起.md'))
+    const { stdout } = await git(['rev-parse', '--verify', r.backupRef])
+    assert.ok(stdout.trim().length >= 7)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('回到第N章:不存在的章 → ok=false', async () => {
+  const { ctx, cleanup } = await bookWithChapters()
+  try {
+    const r = await gotoChapter(ctx, { chapterNum: 99, confirm: true })
+    assert.equal(r.ok, false)
+  } finally {
+    await cleanup()
+  }
+})

+ 35 - 0
v7/test/state-machine/flows/impact.test.js

@@ -0,0 +1,35 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { analyzeImpact } from '../../../src/state-machine/flows/impact.js'
+import { makeGitBook, chapter } from '../_helper.js'
+
+test('影响分析:按已发布到章分两清单 + 履历/时间线命中', async () => {
+  const { ctx, cleanup } = await makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n已发布到章: 1\n',
+    '定稿/正文/0001-起.md': chapter(1, '林晚得到玉佩。'),
+    '定稿/正文/0002-承.md': chapter(2, '玉佩再次发光。'),
+    '定稿/正文/0003-转.md': chapter(3, '与玉佩无关的一章。'.replace('玉佩', '令牌')),
+    '大纲/伏笔/伏笔-001-玉佩.md': '---\n强度: 高\n状态: 进行\n开启章: 1\n---\n## 履历\n- 第1章:埋下玉佩',
+    '定稿/设定/时间线/第01卷.md': '| 章 | 书内时间 | 一句话事件 | 在场 |\n|--|--|--|--|\n| 1 | 春 | 得玉佩 | 林晚 |',
+  })
+  try {
+    const r = await analyzeImpact(ctx, { 关键词: '玉佩' })
+    assert.equal(r.ok, true)
+    assert.deepEqual(r.已发布, [1]) // 第1章已发布
+    assert.deepEqual(r.未发布, [2]) // 第2章未发布;第3章不含玉佩
+    assert.ok(r.履历命中.some((f) => f.includes('伏笔-001')))
+    assert.ok(r.时间线命中.some((f) => f.includes('第01卷')))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('影响分析:缺关键词 → ok=false', async () => {
+  const { ctx, cleanup } = await makeGitBook({ 'book.yaml': '书名: 测\n' })
+  try {
+    const r = await analyzeImpact(ctx, {})
+    assert.equal(r.ok, false)
+  } finally {
+    await cleanup()
+  }
+})

+ 37 - 0
v7/test/state-machine/flows/retcon.test.js

@@ -0,0 +1,37 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { retcon } from '../../../src/state-machine/flows/retcon.js'
+import { makeGitBook } from '../_helper.js'
+
+test('吃书 retcon:改设定 + retcon(N) commit + 留痕', async () => {
+  const { ctx, root, git, cleanup } = await makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n',
+    '定稿/设定/角色/林晚.md': '---\n姓名: 林晚\n境界: 练气三层\n---\n## 设定\n外门弟子。',
+  })
+  try {
+    const r = await retcon(ctx, {
+      chapterNum: 87,
+      原因: '修正大长老境界设定',
+      characterUpdates: [{ name: '林晚', updates: { 境界: '金丹初期' } }],
+    })
+    assert.equal(r.ok, true, r.error)
+    const card = await fs.readFile(path.join(root, '定稿/设定/角色/林晚.md'), 'utf8')
+    assert.match(card, /境界: 金丹初期/)
+    const { stdout } = await git(['log', '--oneline'])
+    assert.match(stdout, /retcon\(87\):/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('吃书:缺原因 → ok=false(留痕要求)', async () => {
+  const { ctx, cleanup } = await makeGitBook({ 'book.yaml': '书名: 测\n' })
+  try {
+    const r = await retcon(ctx, { chapterNum: 1, characterUpdates: [] })
+    assert.equal(r.ok, false)
+  } finally {
+    await cleanup()
+  }
+})