1
0

design.md 36 KB

技术设计:M1 格式层核心库 + 派生缓存

1. 设计范围

四层能力(依赖链):

  1. 容错读写库:解析/序列化 front matter、平铺 YAML、Markdown 表格、条目文件
  2. Storage Adapter 小端口:按职责拆分的 8+ 独立 reader/writer 端口(ChapterReader、ThreadLedgerReader 等)
  3. .cache/index.db 五表 + 重建器:node:sqlite,只读源文件全量重建,任何时刻可删
  4. 41 精准读取接口 CLI:分 P0/P1/P2 三层实现,每层一 commit 检查点

下游 M2+ 在这四层地基上长真实业务逻辑(写章流程、状态机、AI 角色)。

2. 架构分层与数据流

┌─────────────────────────────────────────────────────────────┐
│  CLI 41 接口层(P0/P1/P2)                                    │
│  read-chapter / list-threads / report-overdue-threads ...   │
└────────────┬────────────────────────────────────────────────┘
             │
             v
┌─────────────────────────────────────────────────────────────┐
│  Storage Adapter 小端口                                       │
│  ChapterReader / ThreadLedgerReader / EntityReader ...       │
│  (依赖缓存或直接读文件,调用方无感知)                        │
└────────────┬────────────────────────────────────────────────┘
             │
             v
┌──────────────────┐        ┌─────────────────────────────────┐
│  .cache/index.db │  ◄───  │  容错读写库(parser/serializer) │
│  五表 + 索引      │        │  front matter / YAML / Markdown │
└──────────────────┘        └─────────────────────────────────┘
      ▲                                ▲
      │ 重建器                          │
      │                                │
      └────────────────────────────────┘
               只读 定稿/大纲/文风 源文件

关键原则(spec §1.5):

  • 上层不知道下层用了缓存还是直接读文件
  • Storage Adapter 是稳定接口,缓存策略变更不影响调用方
  • 重建器只读源文件,不读工作区

3. 层 1:容错读写库

3.1 模块结构

v7/src/storage/parsers/
├── front-matter.js      # 分离 YAML front matter 与 Markdown 正文
├── yaml-safe.js         # 容错 YAML 解析(保留未知字段)
├── markdown-table.js    # 解析 Markdown 表格(时间线、名册)
└── book-config.js       # book.yaml 平铺字段提取

v7/src/storage/serializers/
├── yaml-dialect.js      # 防呆方言:平铺/块列表/危险值引号
└── front-matter.js      # 组装 front matter + 正文

3.2 Front Matter 解析契约

// front-matter.js
export function parseFrontMatter(content) {
  // 输入:完整文件内容(含 --- 包裹的 YAML + Markdown 正文)
  // 输出:{ ok: boolean, data: object | null, body: string, error: string }
  // ok=true: data 是解析后对象,body 是去掉 front matter 的 Markdown 正文
  // ok=false: error 含错误描述(中文),data=null
}

容错规则

  • --- 不存在或不配对:返回 {ok: false, error: "缺少 front matter 分隔符"}
  • YAML 语法错误:返回 {ok: false, error: "YAML 解析失败:<原因>"}
  • 不崩溃:任何输入都返回对象,不抛异常

3.3 YAML 安全解析

// yaml-safe.js
export function parseYAML(yamlString, options = {}) {
  // options.preserveUnknown: true(默认)保留未知字段
  // 返回:{ ok: boolean, data: object | null, error: string, raw: string }
  // raw: 原始 YAML 字符串(写回时用)
}

保留未知字段策略(不变量 9):

  • 解析时记录原始 YAML 字符串
  • 写回时:已知字段用新值,未知字段从原始串提取后拼接

3.4 防呆序列化

// yaml-dialect.js
export function serializeYAML(data, options = {}) {
  // 输入:JS 对象(平铺,不含嵌套映射)
  // 输出:符合防呆方言的 YAML 字符串
  // 规则:
  // 1. 一律平铺(检测嵌套抛错)
  // 2. 数组输出块格式:\n- item1\n- item2
  // 3. 危险值加引号:数字串/true/false/null/含冒号
  // 4. 两空格缩进
}

function needsQuoting(value) {
  // 判断是否需要引号:
  // - 纯数字字符串:"123" → "\"123\""
  // - 布尔/null 字面值:"true" → "\"true\""
  // - 含冒号/特殊字符:"A:B" → "\"A:B\""
}

3.5 Markdown 表格解析

// markdown-table.js
export function parseMarkdownTable(content) {
  // 输入:Markdown 表格文本(含表头 | A | B | C |)
  // 输出:{ ok: boolean, headers: string[], rows: object[], error: string }
  // rows: [{A: "值1", B: "值2"}, ...]
  // 容错:表头不对齐、空行跳过、不崩溃
}

用途

  • 时间线:| 章 | 书内时间 | 一句话事件 | 在场 |
  • 名册:| 正名 | 别名 | 类型 | 首现章 |

4. 层 2:Storage Adapter 小端口

4.1 端口清单(拒绝上帝对象)

v7/src/storage/
├── adapters/
│   ├── ChapterReader.js         # 读章节 front matter、正文、范围
│   ├── ChapterWriter.js         # 写新章到定稿(M2 落地,本任务只定接口占位)
│   ├── ThreadLedgerReader.js    # 读三类条目(伏笔/悬念/感情线)
│   ├── ThreadLedgerWriter.js    # 更新条目、追加履历(M2 落地,占位)
│   ├── EntityReader.js          # 读角色卡、解析别名
│   ├── TimelineReader.js        # 读时间线、按在场过滤
│   ├── SecretReader.js          # 读信息差
│   ├── OutlineReader.js         # 读总纲/卷纲
│   └── BookConfigReader.js      # 读 book.yaml
└── index.js                     # 统一导出(按需 import)

4.2 ChapterReader 接口

// ChapterReader.js
export class ChapterReader {
  constructor(repoPath, cache) {
    // repoPath: 书仓库根目录
    // cache: CacheManager 实例(可选,无缓存时直接读文件)
  }

  async readFrontMatter(chapterNum) {
    // 返回:{ ok, data: {章号, 标题, 卷, 视角, ...}, error }
    // 策略:优先查缓存 chapters 表,缺失时读文件
  }

  async readBody(chapterNum) {
    // 返回:{ ok, body: string, error }
    // 纯正文(不含 front matter)
  }

  async readTail(chapterNum, wordCount) {
    // 返回:{ ok, text: string, error }
    // 正文末尾 N 字
  }

  async readHead(chapterNum, wordCount) {
    // 正文开头 N 字
  }

  async readRange(startChapter, endChapter, fields = ['摘要']) {
    // 批量读取章号范围,指定字段
    // 返回:{ ok, chapters: [{章号, ...fields}], error }
  }
}

4.3 ThreadLedgerReader 接口

// ThreadLedgerReader.js
export class ThreadLedgerReader {
  constructor(repoPath, cache) {}

  async readBasicInfo(threadId) {
    // threadId: "伏笔-031" / "悬念-008" / "感情线-012"
    // 返回:{ ok, data: {强度, 状态, 开启章, 预计收尾, 最后推进章}, error }
  }

  async readHistory(threadId) {
    // 返回:{ ok, history: [{章号, 动作, 描述, 证据}], error }
    // 从履历段落解析(第N章:动作——描述(见...))
  }

  async readClosurePlan(threadId) {
    // 返回:{ ok, plan: string, error }
    // 从 "## 收尾计划" 段落提取
  }

  async readDescription(threadId) {
    // 从 "## 描述" 段落提取
  }

  async listOverdue(bookConfig) {
    // 输入:book.yaml 配置(各类型悬了太久章数阈值)
    // 返回:[{id, type, 悬了多少章, 最后推进章}]
    // 计算:当前最大章号 − last_advanced_chapter > 阈值
  }

  async listByType(type, status = null) {
    // type: "foreshadow" / "suspense" / "romance"
    // status: "进行" / "已收尾" / "已放弃"(可选)
    // 返回:[{id, short_title, strength, status}]
  }
}

4.4 EntityReader 接口

// EntityReader.js
export class EntityReader {
  constructor(repoPath, cache) {}

  async readCharacterFrontMatter(name) {
    // name: 正名
    // 返回:{ ok, data: {姓名, 境界, 位置, 状态, 持有, 最后变更章}, error }
  }

  async readCharacterFull(name) {
    // 返回:{ ok, frontMatter, body: string, error }
    // body 是设定/典型对话/关系段落
  }

  async resolveAlias(alias) {
    // 返回:{ ok, canonicalName: string | null, error }
    // 查 entity_aliases 表或解析名册文件
  }

  async listCharacters(filter = {}) {
    // filter: { status: "在世" } 等
    // 返回:[{正名, 状态, 位置, 境界}]
  }
}

4.5 其他端口(简要)

TimelineReaderreadCurrentVolume() / readVolumeRange(start, end) / readByParticipant(name)

SecretReaderreadBasicInfo(id) / readContent(id) / listUnrevealed()

OutlineReaderreadOutlineSection(type, volumeNum?, sectionTitle?) / listVolumes()

BookConfigReaderread() 返回 book.yaml 平铺对象

5. 层 3:.cache/index.db 缓存表与重建器

术语澄清:习称「五表」指五张主表(chapters / threads / secrets / entities / fingerprints),另有 entity_aliases 别名关联表挂在 entities 下,物理上共 6 张 CREATE TABLE。下文 DDL 与 schema.js 以 6 张为准。

5.1 表结构(O4 §1 DDL,精简版)

-- chapters 表
CREATE TABLE chapters (
  chapter_num INTEGER PRIMARY KEY,
  title TEXT NOT NULL,
  volume_num INTEGER NOT NULL,
  perspective TEXT,
  story_time TEXT,
  word_count INTEGER NOT NULL,
  chapter_position TEXT NOT NULL,  -- 推进|过渡|日常
  hook_type TEXT,                  -- 危机钩-强 等
  mood_position TEXT,              -- 压抑|铺垫|小爽|大爽|转折
  file_path TEXT NOT NULL,
  is_key_chapter BOOLEAN DEFAULT 0
);
CREATE INDEX idx_chapters_volume ON chapters(volume_num);
CREATE INDEX idx_chapters_position ON chapters(chapter_position);
CREATE INDEX idx_chapters_hook ON chapters(hook_type);

-- threads 表(三类条目统一)
CREATE TABLE threads (
  id TEXT PRIMARY KEY,             -- 伏笔-031、悬念-008、感情线-012
  type TEXT NOT NULL,              -- foreshadow | suspense | romance
  short_title TEXT NOT NULL,
  strength TEXT NOT NULL,          -- 高|中|低
  status TEXT NOT NULL,            -- 进行|已收尾|已放弃
  opened_chapter INTEGER NOT NULL,
  planned_end TEXT,                -- 第7卷 或 章号
  last_advanced_chapter INTEGER,
  file_path TEXT NOT NULL
);
CREATE INDEX idx_threads_type ON threads(type);
CREATE INDEX idx_threads_status ON threads(status);

-- secrets 表
CREATE TABLE secrets (
  id TEXT PRIMARY KEY,
  short_title TEXT NOT NULL,
  known_to TEXT NOT NULL,          -- JSON array
  reader_knows BOOLEAN NOT NULL,
  registered_chapter INTEGER NOT NULL,
  keywords TEXT NOT NULL,          -- JSON array
  file_path TEXT NOT NULL
);
CREATE INDEX idx_secrets_reader_knows ON secrets(reader_knows);

-- entities 表
CREATE TABLE entities (
  id TEXT PRIMARY KEY,             -- 正名
  type TEXT NOT NULL,              -- character | location | item | faction
  status TEXT,
  location TEXT,
  realm TEXT,
  possessions TEXT,                -- JSON array
  last_changed_chapter INTEGER,
  file_path TEXT NOT NULL
);
CREATE INDEX idx_entities_type ON entities(type);
CREATE INDEX idx_entities_status ON entities(status);

-- entity_aliases 表(别名 → 正名,唯一真相源)
CREATE TABLE entity_aliases (
  alias TEXT PRIMARY KEY,
  entity_id TEXT NOT NULL,
  FOREIGN KEY(entity_id) REFERENCES entities(id)
);
CREATE INDEX idx_entity_aliases_entity ON entity_aliases(entity_id);

-- fingerprints 表
CREATE TABLE fingerprints (
  chapter_range_start INTEGER NOT NULL,
  chapter_range_end INTEGER NOT NULL,
  is_baseline BOOLEAN DEFAULT 0,
  avg_sentence_length REAL,
  sentence_length_variance REAL,
  avg_paragraph_length REAL,
  common_phrase_frequency TEXT,   -- JSON object
  vocabulary_richness REAL,
  fingerprint_data TEXT NOT NULL,  -- 完整指纹 JSON
  PRIMARY KEY(chapter_range_start, chapter_range_end)
);
CREATE INDEX idx_fingerprints_baseline ON fingerprints(is_baseline);

5.2 CacheManager 接口

// v7/src/cache/index.js
export class CacheManager {
  constructor(dbPath) {
    // dbPath: 书仓库/.cache/index.db
    // 使用 node:sqlite (Node ≥ 22.13.0 内置)
  }

  async ensureReady(repoPath) {
    // 检查 index.db 是否存在且可用
    // 不存在或损坏 → 调用 rebuildFromSource(repoPath)
  }

  async rebuildFromSource(repoPath) {
    // 全量重建:DELETE 五表 → 扫描源文件 → INSERT
    // 只读 定稿/、大纲/、文风/
    // 返回:{ ok, warnings: [], errors: [] }
    // warnings: 履历引用不存在的章节文件
    // errors: 别名冲突(致命,拒绝重建)
  }

  async query(sql, params) {
    // 执行 SELECT 查询,返回结果集
  }

  async close() {
    // 关闭数据库连接
  }
}

5.3 重建器逻辑

// v7/src/cache/rebuilder.js
export async function rebuildCache(repoPath, db) {
  // 步骤:
  // 1. 清空五表(DELETE)
  // 2. 扫描 定稿/正文/*.md → 填充 chapters 表
  // 3. 扫描 大纲/伏笔|悬念|感情线/*.md → 填充 threads 表
  // 4. 扫描 定稿/设定/信息差/*.md → 填充 secrets 表
  // 5. 解析 定稿/设定/名册.md → 填充 entities + entity_aliases 表
  // 6. 扫描 定稿/设定/角色/*.md → upsert entities 表(type=character)
  //    注意:必须 INSERT-or-UPDATE。角色卡可能不在名册里(名册非强制全覆盖),
  //    只 UPDATE 会丢掉这些角色 → 用 INSERT ... ON CONFLICT(id) DO UPDATE
  // 7. fingerprints 表留空(特征提取随 M3+ 体检补)
  // 8. 校验:
  //    - 履历引用章节文件是否存在(警告)
  //    - 别名唯一性(错误)
  // 9. 返回 warnings 和 errors
}

async function scanChapters(repoPath) {
  // 扫描 定稿/正文/*.md
  // 返回:Map<章号, 文件路径>
  // 用途:履历验证时查映射(避免 fs.access 通配符问题)
}

校验规则(O4 §4.3):

  • 履历证据章节验证(spec 0.8 A3):
    • 步骤:
    • scanChapters(repoPath) 返回章号 → 文件路径映射(Map)
    • 解析条目履历行的"第N章",提取章号
    • 查 Map,章号不存在 → 记 warning(不阻断重建)
    • 格式:第152章:推进——林晚取得实证(见本章结尾对峙段)
    • 验证范围:章节文件存在性(脚本能做的),不做语义验证(AI 两审负责)
  • 别名唯一性:同一 alias 指向多个 entity_id → 记 error,拒绝重建

5.4 派生值策略(不物化)

查询时计算(O4 §0 原则 5):

  • 悬了太久章数 = MAX(chapter_num) FROM chapters − last_advanced_chapter
  • 信息差蓄积章数 = MAX(chapter_num) − registered_chapter
  • 是否超期 = 悬了太久章数 > book.yaml 阈值

不写入表:避免每章定稿刷新全表,避免缓存与真相脱节。

6. 层 4:41 精准读取接口 CLI

6.1 命令组织

v7/bin/webnovel-writer.js  ← 入口,动态 import 分发(按命令名加载 src/commands/${命令}.js)
v7/src/commands/            ← 命令模块目录(21 个文件承载 O4 §2 的 41 个接口,多数接口是同一文件的 --选项)
├── read-chapter.js         # 5 接口:<num> | --front-matter | --tail=N | --head=N | --摘要
├── read-chapters.js        # 2 接口:--range=<a>-<b> --摘要 | --recent=<N> --tail=<M>(复数,批量)
├── list-chapters.js        # 1 接口:--章定位=推进 [--卷=N]
├── read-thread.js          # 4 接口:--fields=基本信息 | --履历 | --收尾计划 | --描述
├── list-threads.js         # 3 接口:--悬了太久 | --type=<t> [--status=<s>] | --strength=<强>
├── read-timeline.js        # 4 接口:--current-volume | --current-and-prev | --卷=N | --在场=<名>
├── read-character.js       # 3 接口:<name> | --front-matter | --section=<标题>
├── resolve-alias.js        # 1 接口:<别名>
├── list-characters.js      # 1 接口:[--status=<状态>]
├── read-worldview.js       # 1 接口:--section=<标题>(读 定稿/设定/世界观.md)
├── read-secret.js          # 2 接口:--基本信息 | --内容
├── list-secrets.js         # 1 接口:--reader-knows=false
├── read-outline.js         # 4 接口:--总纲 --section=<标题> | --总纲 --结局 | --卷=N | --卷=N --section
├── list-volumes.js         # 1 接口:列出所有卷纲
├── grep-story.js           # 2 接口:<关键词> | --regex=<pattern>
├── report-overdue-threads.js       # 1 接口
├── report-secret-accumulation.js   # 1 接口
├── report-weak-hook-streak.js      # 1 接口
├── report-thread-activity.js       # 1 接口:--卷=N
├── report-book-stats.js            # 1 接口
└── report-style-drift.js   # 1 接口:能读指纹表并对比基线,但 M1 不做特征提取(表留空 → 返回友好错误)

接口总数核对(对齐 O4 §2.2-2.9):条目 7 + 大纲 5 + 正文 8 + 时间线 4 + 设定 6 + 信息差 3 + 全文检索 2 + 报表 6 = 41。权威逐条清单见 prd.md AC2。

动态分发逻辑(bin/webnovel-writer.js):

const command = process.argv[2]
if (!command || command === '--help') {
  console.log('用法:webnovel-writer <命令> [选项]')
  process.exit(0)
}

let cache
try {
  const mod = await import(`../src/commands/${command}.js`)
  const { positionalArgs, options } = parseArgs(process.argv.slice(3))

  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}」。运行 webnovel-writer --help 查看可用命令。`)
    process.exitCode = 1
  } else {
    console.error(`执行命令「${command}」时出错:${err.message}`) // 永不带栈崩
    process.exitCode = 1
  }
} finally {
  if (cache) await cache.close() // 显式关闭,避免 Windows 文件锁
}

分层实现(每层一 sub-commit):

  • P0(8 个):read-timeline (current-and-prev)、read-thread (基本+履历)、read-chapter (tail+摘要)、read-character (front-matter)、resolve-alias、report-overdue-threads
  • P1(7 个):read-chapter (front-matter+按章定位筛选)、list-secrets (未揭晓)、grep-story (关键词)、report-book-stats、report-weak-hook-streak
  • P2(26 个):其余全部

6.2 命令范式(可测契约)

契约:每个命令模块导出 run(args, options, ctx)只返回结果对象,不做 IO 副作用(命令内部不 console.log、不 process.exit、不 new CacheManager)。打印、退出码、缓存生命周期由 bin 入口统一处理。命令因此可被单元测试直接调用(喂 fixture repoPath + 临时 cache,断言返回对象),这是 AC2「41 接口逐条测试」能成立的前提。

历史教训:早期版本让 execute 同时 returnconsole.log + process.exit,契约自相矛盾,导致命令层无法单测、35 个接口被写成空壳。本契约统一为"纯函数返回 + bin 副作用"。

// 命令契约
// @param {string[]} args     位置参数(已剥离选项)
// @param {object}   options  解析后的 --选项(--key=value → {key:value},--flag → {flag:true})
// @param {{repoPath: string, cache: CacheManager}} ctx  注入的运行上下文
// @returns {Promise<{ok: boolean, output?: string, error?: string}>}
//   ok=true  → output 是要打印到 stdout 的字符串(JSON.stringify 或纯文本)
//   ok=false → error 是要打印到 stderr 的中文错误;bin 以 exit(1) 退出

// read-chapter.js 示例
import { ChapterReader } from '../storage/adapters/ChapterReader.js'

export async function run(args, options, ctx) {
  const chapterNum = parseInt(args[0], 10)
  if (isNaN(chapterNum)) {
    return { ok: false, error: '章号必须是数字' }
  }

  const reader = new ChapterReader(ctx.repoPath, ctx.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 }
  }
  // ... 其他分支同理:拿 adapter 结果 → 映射成 {ok, output|error}
}

bin/webnovel-writer.js 是唯一副作用点(打印 / 退出 / 缓存生命周期):

const cache = new CacheManager(path.join(repoPath, '.cache', 'index.db'))
await cache.ensureReady(repoPath)
try {
  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
  }
} finally {
  await cache.close()  // 显式关闭,避免 Windows 文件锁
}

统一输出格式

  • 成功:{ok: true, output} → bin 打印 output 到 stdout,exit 0
  • 失败:{ok: false, error} → bin 打印 error 到 stderr,exit 1
  • 命令内部不碰 process / console / 文件锁,保证可单测

6.3 特殊接口设计

report-overdue-threads

// 查询时计算悬了太久章数
const maxChapter = await db.query('SELECT MAX(chapter_num) FROM chapters')
const overdueThreads = await db.query(`
  SELECT id, type, short_title, last_advanced_chapter,
         (? - last_advanced_chapter) as overdue_count
  FROM threads
  WHERE status = '进行'
`, [maxChapter])

// 按 book.yaml 阈值过滤
const config = await bookConfigReader.read()
const TYPE_CN = { foreshadow: '伏笔', suspense: '悬念', romance: '感情线' }
const filtered = overdueThreads.filter(t => {
  // book.yaml 的 key 是中文「伏笔悬了太久章数」,threads.type 是英文,必须先映射
  const threshold = config.data[`${TYPE_CN[t.type]}悬了太久章数`] ?? 10
  return t.overdue_count > threshold
})

// 按类型分组输出
return groupBy(filtered, 'type')

report-weak-hook-streak

// 从 chapters 表倒序查连续弱钩
const recentChapters = await db.query(`
  SELECT chapter_num, hook_type
  FROM chapters
  ORDER BY chapter_num DESC
  LIMIT 20
`)

let streak = 0
for (const ch of recentChapters) {
  // spec §4.1: 钩子值形如"危机钩-强"、"危机钩-弱"、"情绪钩-弱"
  if (ch.hook_type?.includes('弱钩') || ch.hook_type?.endsWith('-弱')) {
    streak++
  } else {
    break
  }
}

return { streak }

report-style-drift(M1 边界):

// 读基线指纹(is_baseline=1)
const baseline = await db.query(`
  SELECT * FROM fingerprints WHERE is_baseline = 1
`)

// 读最近章段指纹(假设已有)
const recent = await db.query(`
  SELECT * FROM fingerprints
  ORDER BY chapter_range_end DESC
  LIMIT 1
`)

if (!baseline || !recent) {
  return { ok: false, error: '缺少指纹数据。fingerprints 表为空,请先运行体检以提取特征。' }
}

// 对比特征(简单差值)
const drift = {
  avg_sentence_length_delta: recent.avg_sentence_length - baseline.avg_sentence_length,
  // ... 其他特征
}

return { baseline, recent, drift }

M1 边界说明report-style-drift 接口存在、能读 fingerprints 表并对比基线,但 M1 不实现特征提取函数。测试策略:手工插入基线数据 + 最近数据到 fingerprints 表,测试对比逻辑;真实特征提取(avg_sentence_length 计算等)留 M3+ 体检/卷复盘实现。

7. 测试策略

7.1 测试结构(镜像 src)

v7/test/
├── storage/
│   ├── parsers/
│   │   ├── front-matter.test.js
│   │   ├── yaml-safe.test.js
│   │   └── markdown-table.test.js
│   ├── serializers/
│   │   └── yaml-dialect.test.js
│   └── adapters/
│       ├── ChapterReader.test.js
│       ├── ThreadLedgerReader.test.js
│       └── EntityReader.test.js
├── cache/
│   ├── CacheManager.test.js
│   └── rebuilder.test.js
├── commands/
│   ├── read-chapter.test.js
│   ├── list-threads.test.js
│   └── ... (41 个接口各一测试)
└── fixtures/
    └── sample-book/          # 示例书仓库(定稿/大纲/文风完整结构)
        ├── book.yaml
        ├── 定稿/
        │   ├── 正文/
        │   │   ├── 0001-开局.md
        │   │   └── 0002-初遇.md
        │   ├── 设定/
        │   │   ├── 角色/林晚.md
        │   │   ├── 信息差/信息差-001-灭门真凶.md
        │   │   ├── 时间线/第01卷.md
        │   │   ├── 世界观.md          # read-worldview --section 用
        │   │   └── 名册.md
        │   └── 摘要/
        │       └── 章摘要/
        │           ├── 0001.md
        │           └── 0002.md        # read-chapters --range 摘要用
        └── 大纲/
            ├── 总纲.md                # read-outline --总纲 [--section|--结局] 用
            ├── 第01卷.md              # read-outline --卷 / list-volumes 用
            ├── 伏笔/伏笔-001-神秘老者.md
            ├── 悬念/悬念-001-玉佩来历.md   # list-threads --type=suspense 用
            └── 感情线/感情线-001-大师兄.md # list-threads --type=romance 用

扩充说明:原 fixture 只够测 P0/P1。P2 的 read-outline / list-volumes / read-worldview 需要 大纲/总纲.md大纲/第NN卷.md定稿/设定/世界观.md;list-threads 按类型筛选需要悬念/感情线各一条;read-chapters 范围摘要需要 ≥2 个章摘要。这些在「阶段 D 重建」前补齐。

Fixture 完整示例

0001-开局.md(章节文件):

---
章号: 1
标题: 开局
卷: 1
视角: 林晚
书内时间: 大历1023年春月初一
字数: 2800
章定位: 推进
钩子: 危机钩-强
情绪定位: 铺垫
伏笔:
  - 埋下 伏笔-001
本章要写到的事:
  - 林晚初入宗门
  - 神秘老者出现
---

林晚抬头望着宗门大殿,心中忐忑不安。

(正文约 2800 字...)

结尾处,一道黑影闪过,留下一枚玉佩。

伏笔-001-神秘老者.md(条目文件):

---
强度: 高
状态: 进行
开启章: 1
预计收尾: 第3卷
最后推进章: 1
---
## 描述
神秘老者的真实身份。

## 收尾计划
第三卷揭晓:曾是宗门长老,因禁术被逐。

## 履历
- 第1章:埋下——神秘老者留玉佩(见本章结尾黑影段落)

林晚.md(角色卡):

---
姓名: 林晚
别名:
  - 晚晚
  - 林师妹
状态: 在世
位置: 青云宗
境界: 练气三层
持有:
  - 青霜剑
最后变更章: 1
---
## 设定
外门弟子,天赋中等但心性坚韧。

## 典型对话
"本姑娘才不怕!"

## 关系
与大师兄关系微妙。

第01卷.md(时间线):

| 章 | 书内时间 | 一句话事件 | 在场 |
|----|----------|------------|------|
| 1 | 1023春月初一 | 林晚入宗门,神秘老者留玉佩 | 林晚 |

名册.md

| 正名 | 别名 | 类型 | 首现章 |
|------|------|------|---------|
| 林晚 | 晚晚, 林师妹 | character | 1 |
| 神秘老者 | 黑衣人 | character | 1 |

book.yaml

spec_version: "7.0"
书名: 测试书
类型: 玄幻
每章目标字数: 3000
卷规模: 40
文体基线起: 1
文体基线止: 30
伏笔悬了太久章数: 10
悬念悬了太久章数: 10
感情线悬了太久章数: 30
连续弱钩上限: 3
关键章稿数: 3
自动确认细纲: false
连写批次大小: 8

7.2 关键测试用例

AC1 删光 .cache 全量重建

// test/cache/rebuilder.test.js
test('删除缓存后全量重建,查询结果不变', async () => {
  const repoPath = 'test/fixtures/sample-book'
  const dbPath = `${repoPath}/.cache/index.db`
  
  // 第一次重建
  const cache1 = new CacheManager(dbPath)
  await cache1.ensureReady(repoPath)
  const result1 = await cache1.query('SELECT COUNT(*) as count FROM chapters')
  
  // 删除缓存
  await fs.rm(dbPath, { force: true })
  
  // 第二次重建
  const cache2 = new CacheManager(dbPath)
  await cache2.ensureReady(repoPath)
  const result2 = await cache2.query('SELECT COUNT(*) as count FROM chapters')
  
  assert.equal(result1[0].count, result2[0].count)
})

AC3 容错读取保留未知字段

// test/storage/parsers/yaml-safe.test.js
test('未知字段保留并原样写回', async () => {
  const original = `---
章号: 1
标题: 测试
自定义字段: 自定义值
---
正文`
  
  const parsed = parseFrontMatter(original)
  assert.equal(parsed.ok, true)
  assert.equal(parsed.data.自定义字段, '自定义值')
  
  // 修改已知字段
  parsed.data.标题 = '新标题'
  
  // 写回
  const serialized = serializeFrontMatter(parsed.data, parsed.body)
  assert.ok(serialized.includes('自定义字段: 自定义值'))
  assert.ok(serialized.includes('标题: 新标题'))
})

AC4 防呆写出

// test/storage/serializers/yaml-dialect.test.js
test('列表输出块格式', () => {
  const data = { 伏笔: ['伏笔-001', '伏笔-002'] }
  const yaml = serializeYAML(data)
  assert.ok(yaml.includes('伏笔:\n  - 伏笔-001\n  - 伏笔-002'))
  assert.ok(!yaml.includes('[伏笔-001, 伏笔-002]'))
})

test('危险值加引号', () => {
  const data = { 章号: '123', 开关: 'true', 标题: '包含:冒号' }
  const yaml = serializeYAML(data)
  assert.ok(yaml.includes('章号: "123"'))
  assert.ok(yaml.includes('开关: "true"'))
  assert.ok(yaml.includes('标题: "包含:冒号"'))
})

AC5 Windows 中文路径

// test/integration/chinese-path.test.js
test('Windows 中文路径全链路', async (t) => {
  if (process.platform !== 'win32') {
    t.skip('Windows 专用测试')
    return
  }
  
  const tmpDir = path.join(os.tmpdir(), '测试书仓库')
  // 构建含中文目录/文件的 fixture
  await setupChinesePathFixture(tmpDir)
  
  // 重建缓存
  const cache = new CacheManager(`${tmpDir}/.cache/index.db`)
  await cache.ensureReady(tmpDir)
  
  // 读取接口
  const reader = new ChapterReader(tmpDir, cache)
  const result = await reader.readFrontMatter(1)
  
  assert.equal(result.ok, true)
  assert.equal(result.data.标题, '测试章节')
  
  // 显式关闭数据库连接(避免 Windows 文件锁导致清理失败)
  await cache.close()
  
  // 清理
  await fs.rm(tmpDir, { recursive: true, force: true })
})

7.3 测试覆盖率目标

  • 容错读写库:每个 parser/serializer 覆盖正常路径 + 至少 2 个错误路径
  • Storage Adapter:每个端口主方法有正常用例 + 边界(不存在的 ID、空结果)
  • 缓存重建器:删缓存重建、校验规则(履历章节不存在、别名冲突)
  • 41 接口:P0 每个接口 ≥2 用例(正常+边界),P1/P2 每个接口 ≥1 用例

8. 技术选型

8.1 YAML 库

选项 A:js-yaml

  • 优势:成熟、广泛使用、MIT、依赖树极小(仅一个传递依赖 argparse)
  • 劣势:需引入一个运行时依赖(后端规范 §1.1 要求零依赖)

选项 B:手写轻量解析器

  • 优势:零依赖
  • 劣势:开发成本高、边界情况多、YAML 规范复杂(1.2 版 100+ 页)

决策:使用 js-yaml 解析 + 手写序列化,理由:

  1. YAML 规范复杂,手写解析器风险高(嵌套、转义、多行字符串、锚点等边界情况)
  2. js-yaml 依赖树极小:npm ls 仅一个传递依赖 argparse(同 nodeca 维护、MIT、供其 CLI 用)
  3. 防呆方言限制了输出格式复杂度(平铺、块列表),序列化可手写控制
  4. 后端规范 §1.1 的"零依赖"目标在于避免臃肿依赖树,单一成熟库(4.1.0, 19kB min+gzip)是可接受的折中

折中方案

  • 解析用 js-yaml(复杂、依赖成熟度)
  • 序列化手写(简单、可控、防呆方言强制执行)

风险缓解

  • js-yaml 是行业标准(每周 7000 万次下载、MIT、维护活跃)
  • prd Constraints C1 明确标注为"唯一例外"
  • 若未来需移除依赖,替换点单一(parsers/yaml-safe.js)

8.2 文件扫描

使用 Node 内置 fs/promises + path

  • fs.readdir(dir, { recursive: true, withFileTypes: true })(Node ≥20)递归扫描
  • 按文件名前缀过滤(0001-伏笔-

8.3 node:sqlite

Node ≥ 22.13.0 内置(无需 --experimental-sqlite flag):

import { DatabaseSync } from 'node:sqlite'

const db = new DatabaseSync('path/to/db.db')
db.exec('CREATE TABLE ...')
const stmt = db.prepare('SELECT * FROM chapters WHERE chapter_num = ?')
const rows = stmt.all(123)

注意node:sqlite 是同步 API(DatabaseSync),但包在 async 函数里不影响上层异步调用。

9. 风险与缓解

9.1 风险:41 接口工作量大

缓解

  • 地基做扎实后,每个接口大多是"一次查询 + 格式化"的薄封装
  • 按 P0→P1→P2 分层,每层一 commit 检查点,可随时切分任务
  • P2 若时间紧,文体指纹相关接口(report-style-drift)可暂时返回"功能未实现"占位

9.2 风险:YAML 解析边界情况多

缓解

  • 防呆方言限制了输出格式复杂度(平铺、块列表)
  • 容错读取只需"不崩溃 + 保留未知字段",不需 100% 正确解析
  • 依赖 js-yaml 的成熟度

9.3 风险:Windows 中文路径测试假阴性

缓解

  • 所有 IO 显式 encoding: 'utf8'
  • CI Windows job 真实跑中文路径 fixture
  • 测试用 os.tmpdir() 动态构建,不依赖硬编码路径

9.4 风险:重建器校验逻辑复杂

缓解

  • 履历章节验证只查文件存在性(fs.access),不做语义解析
  • 别名唯一性用 Map 去重,O(n) 复杂度
  • 两审负责语义校验(履历真假),重建器只做脚本能做的

10. 边界与非目标

M1 做

  • ✅ 容错读取、防呆写出
  • ✅ Storage Adapter 小端口(Reader 全部、Writer 接口占位)
  • ✅ 五表 DDL + 全量重建器
  • ✅ 41 精准读取接口 CLI(P0/P1/P2 全部)
  • ✅ 删缓存重建 CI 测试

M1 不做

  • ❌ Writer 端口真实实现(M2 定稿流程调用)
  • ❌ 增量更新缓存(定稿时只写变化行)
  • ❌ 文体特征提取算法(fingerprints 表建好留空,M3+ 体检补)
  • ❌ CLI 命令注册机制优化(当前硬编码 switch,M3+ 重构)
  • ❌ 向量库、语义检索(7.x 可选插件)

11. 实施检查清单

阶段划分见 implement.md,此处列设计完备性检查:

  • 四层架构清晰(读写库 → 小端口 → 缓存 → CLI)
  • 每个端口接口签名明确(参数、返回值、错误处理)
  • 五表 DDL 可执行(语法正确、索引完整)
  • 重建器逻辑明确(扫描顺序、校验规则、错误处理)
  • 41 接口清单完整(对齐 O4 §2)
  • 测试策略覆盖 AC1-AC12(prd.md 验收标准)
  • 技术选型有理由(YAML 库、node:sqlite)
  • 风险有缓解措施
  • 边界清晰(Writer 占位、文体特征留空)

设计完成。下一步:implement.md 执行计划(四阶段 checklist + 验证命令 + 回滚点)。