四层能力(依赖链):
.cache/index.db 五表 + 重建器:node:sqlite,只读源文件全量重建,任何时刻可删下游 M2+ 在这四层地基上长真实业务逻辑(写章流程、状态机、AI 角色)。
┌─────────────────────────────────────────────────────────────┐
│ 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):
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 + 正文
// 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 分隔符"}{ok: false, error: "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-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\""
}
// markdown-table.js
export function parseMarkdownTable(content) {
// 输入:Markdown 表格文本(含表头 | A | B | C |)
// 输出:{ ok: boolean, headers: string[], rows: object[], error: string }
// rows: [{A: "值1", B: "值2"}, ...]
// 容错:表头不对齐、空行跳过、不崩溃
}
用途:
| 章 | 书内时间 | 一句话事件 | 在场 || 正名 | 别名 | 类型 | 首现章 |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)
// 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 }
}
}
// 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}]
}
}
// 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: "在世" } 等
// 返回:[{正名, 状态, 位置, 境界}]
}
}
TimelineReader:readCurrentVolume() / readVolumeRange(start, end) / readByParticipant(name)
SecretReader:readBasicInfo(id) / readContent(id) / listUnrevealed()
OutlineReader:readOutlineSection(type, volumeNum?, sectionTitle?) / listVolumes()
BookConfigReader:read() 返回 book.yaml 平铺对象
.cache/index.db 缓存表与重建器术语澄清:习称「五表」指五张主表(
chapters/threads/secrets/entities/fingerprints),另有entity_aliases别名关联表挂在entities下,物理上共 6 张 CREATE TABLE。下文 DDL 与 schema.js 以 6 张为准。
-- 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);
// 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() {
// 关闭数据库连接
}
}
// 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):
scanChapters(repoPath) 返回章号 → 文件路径映射(Map)第152章:推进——林晚取得实证(见本章结尾对峙段)查询时计算(O4 §0 原则 5):
悬了太久章数 = MAX(chapter_num) FROM chapters − last_advanced_chapter信息差蓄积章数 = MAX(chapter_num) − registered_chapter是否超期 = 悬了太久章数 > book.yaml 阈值不写入表:避免每章定稿刷新全表,避免缓存与真相脱节。
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):
契约:每个命令模块导出 run(args, options, ctx),只返回结果对象,不做 IO 副作用(命令内部不 console.log、不 process.exit、不 new CacheManager)。打印、退出码、缓存生命周期由 bin 入口统一处理。命令因此可被单元测试直接调用(喂 fixture repoPath + 临时 cache,断言返回对象),这是 AC2「41 接口逐条测试」能成立的前提。
历史教训:早期版本让
execute同时return又console.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 1process / console / 文件锁,保证可单测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+ 体检/卷复盘实现。
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
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 })
})
选项 A:js-yaml
选项 B:手写轻量解析器
决策:使用 js-yaml 解析 + 手写序列化,理由:
js-yaml 本身零传递依赖(npm ls js-yaml 无子依赖)折中方案:
js-yaml(复杂、依赖成熟度)风险缓解:
js-yaml 是行业标准(每周 7000 万次下载、MIT、维护活跃)使用 Node 内置 fs/promises + path:
fs.readdir(dir, { recursive: true, withFileTypes: true })(Node ≥20)递归扫描0001-、伏笔-)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 函数里不影响上层异步调用。
缓解:
report-style-drift)可暂时返回"功能未实现"占位缓解:
js-yaml 的成熟度缓解:
encoding: 'utf8'os.tmpdir() 动态构建,不依赖硬编码路径缓解:
fs.access),不做语义解析M1 做:
M1 不做:
fingerprints 表建好留空,M3+ 体检补)阶段划分见 implement.md,此处列设计完备性检查:
设计完成。下一步:implement.md 执行计划(四阶段 checklist + 验证命令 + 回滚点)。