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

feat(v7): M1 重建 R1——命令可测契约 + P0 命令测试

确立 design §6.2 修正后的契约:命令导出 run(args,options,ctx) 纯返回
{ok,output?,error?},不碰 console/process/cache 生命周期;bin 唯一负责
解析、缓存 ensureReady/close、打印、退出码。

- bin/webnovel-writer.js:run+ctx 分发,--help 列全 21 命令,未知命令报
  中文错误 + exit 1,finally 显式 close(避免 Windows 文件锁)
- 6 个 P0 命令(read-chapter/read-thread/read-character/resolve-alias/
  read-timeline/report-overdue-threads)由 execute(console.log+exit) 重构为
  run 契约;read-character 顺带补 --section;read-timeline 用 cache 推断
  当前卷(不再硬编码 vol 1)
- 新增 test/commands/:_helper.js(fixture/临时仓库建 ctx)+ 6 个 P0 测试,
  覆盖正常路径与边界(非数字、不存在 ID、缺参、AC9 真实悬了太久检出)
- 真实 CLI 冒烟通过(精准读取出 JSON / 别名解析 / 未知命令 exit 1)
lingfengQAQ 1 день назад
Родитель
Сommit
0a0abb7693

+ 55 - 37
v7/bin/webnovel-writer.js

@@ -2,63 +2,81 @@
 
 import path from 'node:path'
 import { fileURLToPath } from 'node:url'
+import { CacheManager } from '../src/cache/index.js'
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url))
 
-// 解析命令行参数
-const args = process.argv.slice(2)
-const command = args[0]
+const argv = process.argv.slice(2)
+const command = argv[0]
 
 if (!command || command === '--help') {
   console.log('用法:webnovel-writer <命令> [选项]')
   console.log('')
-  console.log('可用命令:')
-  console.log('  read-chapter <章号> [--front-matter|--tail=N|--head=N]')
-  console.log('  read-thread <条目ID> [--fields=基本信息|--履历|--收尾计划|--描述]')
-  console.log('  read-timeline [--current-and-prev|--卷=N|--在场=名]')
-  console.log('  read-character <正名> [--front-matter|--section=标题]')
+  console.log('精准读取接口(41 个,分布于 21 个命令;逐条清单见任务 prd.md AC2):')
+  console.log('  read-chapter <章号> [--front-matter|--tail=N|--head=N|--摘要]')
+  console.log('  read-chapters [--range=a-b --摘要|--recent=N --tail=M]')
+  console.log('  list-chapters --章定位=推进 [--卷=N]')
+  console.log('  read-thread <ID> [--履历|--收尾计划|--描述]')
+  console.log('  list-threads [--悬了太久|--type=<t> [--status=<s>]|--strength=<强>]')
+  console.log('  read-timeline [--current-and-prev|--current-volume|--卷=N|--在场=名]')
+  console.log('  read-character <正名> [--front-matter|--section=<标题>]')
   console.log('  resolve-alias <别名>')
-  console.log('  list-threads [--悬了太久|--type=<t>|--status=<s>]')
-  console.log('  report-overdue-threads')
-  console.log('  ... (更多命令见文档)')
+  console.log('  list-characters [--status=<状态>]')
+  console.log('  read-worldview --section=<标题>')
+  console.log('  read-secret <ID> [--基本信息|--内容]')
+  console.log('  list-secrets [--reader-knows=false]')
+  console.log('  read-outline [--总纲 [--section=<标题>|--结局]|--卷=N [--section=<标题>]]')
+  console.log('  list-volumes')
+  console.log('  grep-story <关键词> [--regex=<pattern>]')
+  console.log('  report-overdue-threads | report-secret-accumulation | report-thread-activity --卷=N')
+  console.log('  report-weak-hook-streak | report-book-stats | report-style-drift')
   process.exit(0)
 }
 
-// 动态 import 命令模块
-try {
-  const commandPath = path.join(__dirname, '../src/commands', `${command}.js`)
-  const commandUrl = new URL(`file:///${commandPath.replace(/\\/g, '/')}`).href
-  const commandModule = await import(commandUrl)
-
-  // 解析选项(简化实现:--key=value 或 --flag)
+// 解析选项与位置参数:--key=value → {key:value},--flag → {flag:true}
+function parseArgs(rest) {
   const options = {}
   const positionalArgs = []
-
-  for (let i = 1; i < args.length; i++) {
-    const arg = args[i]
+  for (const arg of rest) {
     if (arg.startsWith('--')) {
-      const match = arg.match(/^--([^=]+)(?:=(.*))?$/)
-      if (match) {
-        const key = match[1]
-        const value = match[2] !== undefined ? match[2] : true
-        options[key] = value
-      }
+      const m = arg.match(/^--([^=]+)(?:=(.*))?$/)
+      if (m) options[m[1]] = m[2] !== undefined ? m[2] : true
     } else {
       positionalArgs.push(arg)
     }
   }
+  return { options, positionalArgs }
+}
+
+let cache
+try {
+  const commandPath = path.join(__dirname, '../src/commands', `${command}.js`)
+  const commandUrl = new URL(`file:///${commandPath.replace(/\\/g, '/')}`).href
+  const mod = await import(commandUrl)
+
+  const { options, positionalArgs } = parseArgs(argv.slice(1))
 
-  // 执行命令
-  await commandModule.execute(positionalArgs, options)
+  const repoPath = process.cwd() // M3 状态机后续处理工作目录定位
+  cache = new CacheManager(path.join(repoPath, '.cache', 'index.db'))
+  await cache.ensureReady(repoPath)
+
+  const result = await mod.run(positionalArgs, options, { repoPath, cache })
+  if (result.ok) {
+    if (result.output) console.log(result.output)
+    process.exitCode = 0
+  } else {
+    console.error(result.error)
+    process.exitCode = 1
+  }
 } catch (err) {
   if (err.code === 'ERR_MODULE_NOT_FOUND' || err.code === 'ENOENT') {
-    console.error(`未知命令「${command}」。`)
-    console.error('运行 webnovel-writer --help 查看可用命令。')
-    process.exit(1)
+    console.error(`未知命令「${command}」。运行 webnovel-writer --help 查看可用命令。`)
+    process.exitCode = 1
+  } else {
+    // 永不带栈崩溃(错误规范 §1)
+    console.error(`执行命令「${command}」时出错:${err.message}`)
+    process.exitCode = 1
   }
-
-  // 其他错误
-  console.error(`执行命令「${command}」时出错:`)
-  console.error(err.message)
-  process.exit(1)
+} finally {
+  if (cache) await cache.close() // 显式关闭,避免 Windows 文件锁
 }

+ 35 - 56
v7/src/commands/read-chapter.js

@@ -1,72 +1,51 @@
 import { ChapterReader } from '../storage/adapters/ChapterReader.js'
-import { CacheManager } from '../cache/index.js'
 import { promises as fs } from 'node:fs'
 import path from 'node:path'
 
 /**
  * read-chapter <章号> [--front-matter|--tail=N|--head=N|--摘要]
+ * 契约:纯返回 {ok, output?, error?},不碰 console/process/cache 生命周期(见 design §6.2)。
  */
-export async function execute(args, options) {
+export async function run(args, options, ctx) {
   const chapterNum = parseInt(args[0], 10)
   if (isNaN(chapterNum)) {
-    console.error('章号必须是数字')
-    process.exit(1)
+    return { ok: false, error: '章号必须是数字' }
   }
 
-  const repoPath = process.cwd()
-  const cache = new CacheManager(path.join(repoPath, '.cache', 'index.db'))
-  await cache.ensureReady(repoPath)
+  const reader = new ChapterReader(ctx.repoPath, ctx.cache)
 
-  const reader = new ChapterReader(repoPath, cache)
+  if (options['front-matter']) {
+    const r = await reader.readFrontMatter(chapterNum)
+    return r.ok ? { ok: true, output: JSON.stringify(r.data, null, 2) } : { ok: false, error: r.error }
+  }
+
+  if (options.tail) {
+    const r = await reader.readTail(chapterNum, parseInt(options.tail, 10))
+    return r.ok ? { ok: true, output: r.text } : { ok: false, error: r.error }
+  }
 
-  try {
-    if (options['front-matter']) {
-      const result = await reader.readFrontMatter(chapterNum)
-      if (result.ok) {
-        console.log(JSON.stringify(result.data, null, 2))
-      } else {
-        console.error(result.error)
-        process.exit(1)
-      }
-    } else if (options.tail) {
-      const wordCount = parseInt(options.tail, 10)
-      const result = await reader.readTail(chapterNum, wordCount)
-      if (result.ok) {
-        console.log(result.text)
-      } else {
-        console.error(result.error)
-        process.exit(1)
-      }
-    } else if (options.head) {
-      const wordCount = parseInt(options.head, 10)
-      const result = await reader.readHead(chapterNum, wordCount)
-      if (result.ok) {
-        console.log(result.text)
-      } else {
-        console.error(result.error)
-        process.exit(1)
-      }
-    } else if (options['摘要']) {
-      // 读摘要文件
-      const summaryPath = path.join(repoPath, '定稿', '摘要', '章摘要', `${String(chapterNum).padStart(4, '0')}.md`)
-      try {
-        const summary = await fs.readFile(summaryPath, 'utf8')
-        console.log(summary.trim())
-      } catch (err) {
-        console.error(`章节 ${chapterNum} 摘要不存在`)
-        process.exit(1)
-      }
-    } else {
-      // 默认:读正文
-      const result = await reader.readBody(chapterNum)
-      if (result.ok) {
-        console.log(result.body)
-      } else {
-        console.error(result.error)
-        process.exit(1)
-      }
+  if (options.head) {
+    const r = await reader.readHead(chapterNum, parseInt(options.head, 10))
+    return r.ok ? { ok: true, output: r.text } : { ok: false, error: r.error }
+  }
+
+  if (options['摘要']) {
+    const summaryPath = path.join(
+      ctx.repoPath,
+      '定稿',
+      '摘要',
+      '章摘要',
+      `${String(chapterNum).padStart(4, '0')}.md`
+    )
+    try {
+      const summary = await fs.readFile(summaryPath, 'utf8')
+      return { ok: true, output: summary.trim() }
+    } catch {
+      return { ok: false, error: `章节 ${chapterNum} 摘要不存在` }
     }
-  } finally {
-    await cache.close()
   }
+
+  // 默认:读正文
+  const r = await reader.readBody(chapterNum)
+  return r.ok ? { ok: true, output: r.body } : { ok: false, error: r.error }
 }

+ 41 - 20
v7/src/commands/read-character.js

@@ -1,31 +1,52 @@
 import { EntityReader } from '../storage/adapters/EntityReader.js'
-import path from 'node:path'
 
-export async function execute(args, options) {
+/**
+ * read-character <正名> [--front-matter|--section=<标题>](默认完整)
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
   const name = args[0]
   if (!name) {
-    console.error('请指定角色正名')
-    process.exit(1)
+    return { ok: false, error: '请指定角色正名' }
   }
 
-  const repoPath = process.cwd()
-  const reader = new EntityReader(repoPath)
+  const reader = new EntityReader(ctx.repoPath, ctx.cache)
 
   if (options['front-matter']) {
-    const result = await reader.readCharacterFrontMatter(name)
-    if (result.ok) {
-      console.log(JSON.stringify(result.data, null, 2))
-    } else {
-      console.error(result.error)
-      process.exit(1)
-    }
-  } else {
-    const result = await reader.readCharacterFull(name)
-    if (result.ok) {
-      console.log(JSON.stringify({ frontMatter: result.frontMatter, body: result.body }, null, 2))
-    } else {
-      console.error(result.error)
-      process.exit(1)
+    const r = await reader.readCharacterFrontMatter(name)
+    return r.ok ? { ok: true, output: JSON.stringify(r.data, null, 2) } : { ok: false, error: r.error }
+  }
+
+  if (options.section) {
+    const r = await reader.readCharacterFull(name)
+    if (!r.ok) return { ok: false, error: r.error }
+    const section = extractSection(r.body, options.section)
+    return section
+      ? { ok: true, output: section }
+      : { ok: false, error: `角色 ${name} 无「${options.section}」小节` }
+  }
+
+  // 默认:完整
+  const r = await reader.readCharacterFull(name)
+  return r.ok
+    ? { ok: true, output: JSON.stringify({ frontMatter: r.frontMatter, body: r.body }, null, 2) }
+    : { ok: false, error: r.error }
+}
+
+// 从正文提取指定 ## 小节
+function extractSection(body, title) {
+  const lines = body.split('\n')
+  let inSection = false
+  const out = []
+  for (const line of lines) {
+    if (line.startsWith('## ')) {
+      if (inSection) break
+      if (line.includes(title)) {
+        inSection = true
+        continue
+      }
     }
+    if (inSection) out.push(line)
   }
+  return out.join('\n').trim()
 }

+ 21 - 47
v7/src/commands/read-thread.js

@@ -1,59 +1,33 @@
 import { ThreadLedgerReader } from '../storage/adapters/ThreadLedgerReader.js'
-import { CacheManager } from '../cache/index.js'
-import path from 'node:path'
 
 /**
- * read-thread <条目ID> [--fields=基本信息|--履历|--收尾计划|--描述]
+ * read-thread <条目ID> [--履历|--收尾计划|--描述](默认基本信息)
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
  */
-export async function execute(args, options) {
+export async function run(args, options, ctx) {
   const threadId = args[0]
   if (!threadId) {
-    console.error('请指定条目 ID(如 伏笔-001)')
-    process.exit(1)
+    return { ok: false, error: '请指定条目 ID(如 伏笔-001)' }
   }
 
-  const repoPath = process.cwd()
-  const cache = new CacheManager(path.join(repoPath, '.cache', 'index.db'))
-  await cache.ensureReady(repoPath)
+  const reader = new ThreadLedgerReader(ctx.repoPath, ctx.cache)
 
-  const reader = new ThreadLedgerReader(repoPath, cache)
+  if (options['履历']) {
+    const r = await reader.readHistory(threadId)
+    return r.ok ? { ok: true, output: JSON.stringify(r.history, null, 2) } : { ok: false, error: r.error }
+  }
+
+  if (options['收尾计划']) {
+    const r = await reader.readClosurePlan(threadId)
+    return r.ok ? { ok: true, output: r.plan } : { ok: false, error: r.error }
+  }
 
-  try {
-    if (options['履历']) {
-      const result = await reader.readHistory(threadId)
-      if (result.ok) {
-        console.log(JSON.stringify(result.history, null, 2))
-      } else {
-        console.error(result.error)
-        process.exit(1)
-      }
-    } else if (options['收尾计划']) {
-      const result = await reader.readClosurePlan(threadId)
-      if (result.ok) {
-        console.log(result.plan)
-      } else {
-        console.error(result.error)
-        process.exit(1)
-      }
-    } else if (options['描述']) {
-      const result = await reader.readDescription(threadId)
-      if (result.ok) {
-        console.log(result.description)
-      } else {
-        console.error(result.error)
-        process.exit(1)
-      }
-    } else {
-      // 默认:基本信息
-      const result = await reader.readBasicInfo(threadId)
-      if (result.ok) {
-        console.log(JSON.stringify(result.data, null, 2))
-      } else {
-        console.error(result.error)
-        process.exit(1)
-      }
-    }
-  } finally {
-    await cache.close()
+  if (options['描述']) {
+    const r = await reader.readDescription(threadId)
+    return r.ok ? { ok: true, output: r.description } : { ok: false, error: r.error }
   }
+
+  // 默认:基本信息
+  const r = await reader.readBasicInfo(threadId)
+  return r.ok ? { ok: true, output: JSON.stringify(r.data, null, 2) } : { ok: false, error: r.error }
 }

+ 24 - 15
v7/src/commands/read-timeline.js

@@ -1,21 +1,30 @@
 import { TimelineReader } from '../storage/adapters/TimelineReader.js'
-import path from 'node:path'
 
-export async function execute(args, options) {
-  const repoPath = process.cwd()
-  const reader = new TimelineReader(repoPath)
+// M1 无状态机:当前卷 = chapters 表里最大的卷号(无章则默认第 1 卷)
+async function currentVolume(ctx) {
+  try {
+    const rows = await ctx.cache.query('SELECT MAX(volume_num) AS v FROM chapters')
+    return rows[0]?.v || 1
+  } catch {
+    return 1
+  }
+}
+
+/**
+ * read-timeline [--current-and-prev](R4 再补 --current-volume|--卷=N|--在场=名)
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const reader = new TimelineReader(ctx.repoPath, ctx.cache)
 
   if (options['current-and-prev']) {
-    // 简化:假设当前卷=1,读第1卷
-    const result = await reader.readVolumeRange(1, 1)
-    if (result.ok) {
-      console.log(JSON.stringify(result.timeline, null, 2))
-    } else {
-      console.error(result.error)
-      process.exit(1)
-    }
-  } else {
-    console.error('请指定选项(如 --current-and-prev)')
-    process.exit(1)
+    const cur = await currentVolume(ctx)
+    const r = await reader.readVolumeRange(Math.max(1, cur - 1), cur)
+    return r.ok ? { ok: true, output: JSON.stringify(r.timeline, null, 2) } : { ok: false, error: r.error }
+  }
+
+  return {
+    ok: false,
+    error: '请指定选项(如 --current-and-prev、--current-volume、--卷=N、--在场=名)',
   }
 }

+ 19 - 26
v7/src/commands/report-overdue-threads.js

@@ -1,34 +1,27 @@
 import { ThreadLedgerReader } from '../storage/adapters/ThreadLedgerReader.js'
 import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
-import { CacheManager } from '../cache/index.js'
-import path from 'node:path'
 
-export async function execute(args, options) {
-  const repoPath = process.cwd()
-  const cache = new CacheManager(path.join(repoPath, '.cache', 'index.db'))
-  await cache.ensureReady(repoPath)
+/**
+ * report-overdue-threads → 按类型分组的悬了太久条目清单(查询时计算,见 design §6.3)
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const threadReader = new ThreadLedgerReader(ctx.repoPath, ctx.cache)
+  const configReader = new BookConfigReader(ctx.repoPath)
 
-  const threadReader = new ThreadLedgerReader(repoPath, cache)
-  const configReader = new BookConfigReader(repoPath)
-
-  try {
-    const configResult = await configReader.read()
-    if (!configResult.ok) {
-      console.error('无法读取 book.yaml')
-      process.exit(1)
-    }
-
-    const overdueThreads = await threadReader.listOverdue(configResult.data)
+  const configResult = await configReader.read()
+  if (!configResult.ok) {
+    return { ok: false, error: '无法读取 book.yaml' }
+  }
 
-    // 按类型分组输出
-    const grouped = {}
-    for (const t of overdueThreads) {
-      if (!grouped[t.type]) grouped[t.type] = []
-      grouped[t.type].push(t)
-    }
+  const overdueThreads = await threadReader.listOverdue(configResult.data)
 
-    console.log(JSON.stringify(grouped, null, 2))
-  } finally {
-    await cache.close()
+  // 按类型分组
+  const grouped = {}
+  for (const t of overdueThreads) {
+    if (!grouped[t.type]) grouped[t.type] = []
+    grouped[t.type].push(t)
   }
+
+  return { ok: true, output: JSON.stringify(grouped, null, 2) }
 }

+ 9 - 14
v7/src/commands/resolve-alias.js

@@ -1,21 +1,16 @@
 import { EntityReader } from '../storage/adapters/EntityReader.js'
-import path from 'node:path'
 
-export async function execute(args, options) {
+/**
+ * resolve-alias <别名> → 正名
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
   const alias = args[0]
   if (!alias) {
-    console.error('请指定别名')
-    process.exit(1)
+    return { ok: false, error: '请指定别名' }
   }
 
-  const repoPath = process.cwd()
-  const reader = new EntityReader(repoPath)
-
-  const result = await reader.resolveAlias(alias)
-  if (result.ok) {
-    console.log(result.canonicalName)
-  } else {
-    console.error(result.error)
-    process.exit(1)
-  }
+  const reader = new EntityReader(ctx.repoPath, ctx.cache)
+  const r = await reader.resolveAlias(alias)
+  return r.ok ? { ok: true, output: r.canonicalName } : { ok: false, error: r.error }
 }

+ 48 - 0
v7/test/commands/_helper.js

@@ -0,0 +1,48 @@
+import path from 'node:path'
+import os from 'node:os'
+import { fileURLToPath } from 'node:url'
+import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
+import { CacheManager } from '../../src/cache/index.js'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+
+// 共享的示例书仓库(只读,命令测试默认喂它)
+export const fixtureRoot = path.join(__dirname, '../fixtures/sample-book')
+
+/**
+ * 用共享 sample-book fixture 建命令运行上下文。
+ * @returns {Promise<{ctx: {repoPath, cache}, cleanup: () => Promise<void>}>}
+ */
+export function fixtureCtx() {
+  return repoCtx(fixtureRoot, null)
+}
+
+/**
+ * 建命令运行上下文。
+ * @param {string|null} repoPath 现成书仓库路径(与 files 二选一)
+ * @param {object|null} files {相对路径: 内容} → 在临时目录现造一个书仓库
+ */
+export async function repoCtx(repoPath, files) {
+  let tmpRepo = null
+  if (files) {
+    tmpRepo = await mkdtemp(path.join(os.tmpdir(), 'wnw-cmd-repo-'))
+    for (const [rel, content] of Object.entries(files)) {
+      const full = path.join(tmpRepo, rel)
+      await mkdir(path.dirname(full), { recursive: true })
+      await writeFile(full, content, 'utf8')
+    }
+    repoPath = tmpRepo
+  }
+  // db 放独立临时目录,绝不写进书仓库
+  const dbDir = await mkdtemp(path.join(os.tmpdir(), 'wnw-cmd-db-'))
+  const cache = new CacheManager(path.join(dbDir, 'index.db'))
+  await cache.ensureReady(repoPath)
+  return {
+    ctx: { repoPath, cache },
+    cleanup: async () => {
+      await cache.close()
+      await rm(dbDir, { recursive: true, force: true })
+      if (tmpRepo) await rm(tmpRepo, { recursive: true, force: true })
+    },
+  }
+}

+ 44 - 0
v7/test/commands/read-chapter.test.js

@@ -0,0 +1,44 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/read-chapter.js'
+import { fixtureCtx } from './_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('read-chapter --front-matter 返回 JSON 含标题', async () => {
+  const r = await run(['1'], { 'front-matter': true }, ctx)
+  assert.equal(r.ok, true)
+  const data = JSON.parse(r.output)
+  assert.equal(data.title ?? data.标题, '开局')
+})
+
+test('read-chapter --tail=N 返回正文末尾 N 字', async () => {
+  const r = await run(['1'], { tail: '6' }, ctx)
+  assert.equal(r.ok, true)
+  assert.equal([...r.output].length, 6)
+})
+
+test('read-chapter 默认返回正文(不含 front matter)', async () => {
+  const r = await run(['1'], {}, ctx)
+  assert.equal(r.ok, true)
+  assert.ok(!r.output.includes('章号:'))
+  assert.ok(r.output.includes('林晚'))
+})
+
+test('read-chapter 不存在的章号 → ok=false 且不崩', async () => {
+  const r = await run(['999'], { 'front-matter': true }, ctx)
+  assert.equal(r.ok, false)
+  assert.match(r.error, /不存在/)
+})
+
+test('read-chapter 非数字章号 → ok=false', async () => {
+  const r = await run(['abc'], {}, ctx)
+  assert.equal(r.ok, false)
+  assert.match(r.error, /数字/)
+})

+ 38 - 0
v7/test/commands/read-character.test.js

@@ -0,0 +1,38 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/read-character.js'
+import { fixtureCtx } from './_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('read-character --front-matter 返回 JSON 含姓名', async () => {
+  const r = await run(['林晚'], { 'front-matter': true }, ctx)
+  assert.equal(r.ok, true)
+  const data = JSON.parse(r.output)
+  assert.equal(data.姓名, '林晚')
+})
+
+test('read-character 默认返回完整(frontMatter + body)', async () => {
+  const r = await run(['林晚'], {}, ctx)
+  assert.equal(r.ok, true)
+  const data = JSON.parse(r.output)
+  assert.ok(data.frontMatter)
+  assert.ok(data.body.includes('设定'))
+})
+
+test('read-character 缺正名 → ok=false', async () => {
+  const r = await run([], {}, ctx)
+  assert.equal(r.ok, false)
+})
+
+test('read-character 不存在的角色 → ok=false 且不崩', async () => {
+  const r = await run(['查无此人'], { 'front-matter': true }, ctx)
+  assert.equal(r.ok, false)
+  assert.match(r.error, /不存在/)
+})

+ 39 - 0
v7/test/commands/read-thread.test.js

@@ -0,0 +1,39 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/read-thread.js'
+import { fixtureCtx } from './_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('read-thread 默认返回基本信息(强度/状态)', async () => {
+  const r = await run(['伏笔-001'], {}, ctx)
+  assert.equal(r.ok, true)
+  const data = JSON.parse(r.output)
+  assert.equal(data.strength ?? data.强度, '高')
+  assert.equal(data.status ?? data.状态, '进行')
+})
+
+test('read-thread --履历 返回履历列表(含章号)', async () => {
+  const r = await run(['伏笔-001'], { 履历: true }, ctx)
+  assert.equal(r.ok, true)
+  const history = JSON.parse(r.output)
+  assert.ok(Array.isArray(history))
+  assert.ok(history.some((h) => h.章号 === 1))
+})
+
+test('read-thread 缺 ID → ok=false', async () => {
+  const r = await run([], {}, ctx)
+  assert.equal(r.ok, false)
+})
+
+test('read-thread 不存在的条目 → ok=false 且不崩', async () => {
+  const r = await run(['伏笔-999'], {}, ctx)
+  assert.equal(r.ok, false)
+  assert.match(r.error, /不存在/)
+})

+ 25 - 0
v7/test/commands/read-timeline.test.js

@@ -0,0 +1,25 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/read-timeline.js'
+import { fixtureCtx } from './_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('read-timeline --current-and-prev 返回时间线数组', async () => {
+  const r = await run([], { 'current-and-prev': true }, ctx)
+  assert.equal(r.ok, true)
+  const timeline = JSON.parse(r.output)
+  assert.ok(Array.isArray(timeline))
+  assert.ok(timeline.length >= 1)
+})
+
+test('read-timeline 无任何选项 → ok=false(提示需指定)', async () => {
+  const r = await run([], {}, ctx)
+  assert.equal(r.ok, false)
+})

+ 42 - 0
v7/test/commands/report-overdue-threads.test.js

@@ -0,0 +1,42 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/report-overdue-threads.js'
+import { fixtureCtx, repoCtx } from './_helper.js'
+
+test('report-overdue-threads 在 fixture 上返回合法分组 JSON', async () => {
+  const { ctx, cleanup } = await fixtureCtx()
+  try {
+    const r = await run([], {}, ctx)
+    assert.equal(r.ok, true)
+    const grouped = JSON.parse(r.output)
+    assert.equal(typeof grouped, 'object')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('report-overdue-threads 检出真正悬了太久的条目并按类型分组(AC9)', async () => {
+  // 20 章,伏笔最后推进在第 1 章 → 悬了 19 章 > 阈值 10
+  const chapters = {}
+  for (let i = 1; i <= 20; i++) {
+    const n = String(i).padStart(4, '0')
+    chapters[`定稿/正文/${n}-第${i}章.md`] =
+      `---\n章号: ${i}\n标题: 第${i}章\n卷: 1\n字数: 100\n章定位: 推进\n---\n正文。`
+  }
+  const { ctx, cleanup } = await repoCtx(null, {
+    'book.yaml': 'spec_version: "7.0"\n书名: 测试\n伏笔悬了太久章数: 10\n',
+    '定稿/设定/名册.md': '| 正名 | 别名 | 类型 | 首现章 |\n|--|--|--|--|\n| 甲 | 乙 | character | 1 |\n',
+    '大纲/伏笔/伏笔-001-老坑.md':
+      '---\n强度: 高\n状态: 进行\n开启章: 1\n最后推进章: 1\n---\n## 描述\n埋了很久。',
+    ...chapters,
+  })
+  try {
+    const r = await run([], {}, ctx)
+    assert.equal(r.ok, true)
+    const grouped = JSON.parse(r.output)
+    assert.ok(grouped.foreshadow, `应按类型分组检出伏笔,实际:${r.output}`)
+    assert.equal(grouped.foreshadow[0].id, '伏笔-001')
+  } finally {
+    await cleanup()
+  }
+})

+ 29 - 0
v7/test/commands/resolve-alias.test.js

@@ -0,0 +1,29 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/resolve-alias.js'
+import { fixtureCtx } from './_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('resolve-alias 已登记别名 → 返回正名', async () => {
+  const r = await run(['晚晚'], {}, ctx)
+  assert.equal(r.ok, true)
+  assert.equal(r.output, '林晚')
+})
+
+test('resolve-alias 未登记别名 → ok=false(不崩)', async () => {
+  const r = await run(['查无此名'], {}, ctx)
+  assert.equal(r.ok, false)
+  assert.match(r.error, /未找到/)
+})
+
+test('resolve-alias 缺参数 → ok=false', async () => {
+  const r = await run([], {}, ctx)
+  assert.equal(r.ok, false)
+})