dsh-maoying 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/epub.mjs CHANGED
@@ -37,12 +37,19 @@ export function crc32(buf) {
37
37
  return (c ^ -1) >>> 0
38
38
  }
39
39
 
40
- /** 转 DOS 时间(zip 头用,本地时区;秒精度 2 秒一档)。 */
41
- function dosDateTime(date) {
40
+ /**
41
+ * 转 DOS 时间(zip 头用,本地时区;秒精度 2 秒一档)。导出仅为测试可直接
42
+ * 断言 1980 纪元等边界。
43
+ */
44
+ export function dosDateTime(date) {
42
45
  const time =
43
46
  ((date.getHours() & 0x1f) << 11) | ((date.getMinutes() & 0x3f) << 5) | ((date.getSeconds() >> 1) & 0x1f)
47
+ // 年份下限 clamp 到 1980:DOS 时间纪元从 1980 年起、年份只存 7 位,更早的
48
+ // 系统时钟(或被拨错的机器时间)会按无符号位回绕映射成 2107+ 的「未来」,
49
+ // 统一按 1980 处理保证 zip 头时间不早于纪元。
50
+ const year = Math.max(date.getFullYear(), 1980)
44
51
  const day =
45
- (((date.getFullYear() - 1980) & 0x7f) << 9) | (((date.getMonth() + 1) & 0xf) << 5) | (date.getDate() & 0x1f)
52
+ (((year - 1980) & 0x7f) << 9) | (((date.getMonth() + 1) & 0xf) << 5) | (date.getDate() & 0x1f)
46
53
  return { time, day }
47
54
  }
48
55
 
@@ -111,11 +118,17 @@ export function buildZip(entries) {
111
118
  }
112
119
 
113
120
  /**
114
- * XML 文本转义:& 必须先转(否则把刚生成的 &amp; 又转一遍),再 < >。
115
- * 章节正文与标题里出现的都过这里;属性值一律用生成的 ASCII 名,不走它。
121
+ * XML 文本转义:& 必须先转(否则把刚生成的 &amp; 又转一遍),再 < > 与引号。
122
+ * 章节正文与标题里出现的都过这里;属性值一律用生成的 ASCII 名不走它,引号
123
+ * 转义属防御纵深(将来属性值万一改走用户输入也不破 XML)。
116
124
  */
117
125
  export function escapeXml(text) {
118
- return String(text ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
126
+ return String(text ?? '')
127
+ .replace(/&/g, '&amp;')
128
+ .replace(/</g, '&lt;')
129
+ .replace(/>/g, '&gt;')
130
+ .replace(/"/g, '&quot;')
131
+ .replace(/'/g, '&apos;')
119
132
  }
120
133
 
121
134
  /** XML 声明 + DOCTYPE 头:三个 XHTML 文件共用。 */
@@ -151,6 +164,12 @@ function chapFile(index) {
151
164
 
152
165
  /** content.opf:元数据 + manifest + spine。uuid 由调用方给(randomUUID)。 */
153
166
  function contentOpf({ title, author, uuid, chapters, modified }) {
167
+ // uuid 只接受 36 位 hex-dash 形状(即 crypto.randomUUID() 的产物):它不经
168
+ // 转义直接内插进 XML,入前形状校验是调用方契约的硬保证——脏数据在这里
169
+ // 炸出中文报错,而不是产出被注入的 OPF。
170
+ if (typeof uuid !== 'string' || !/^[0-9a-f-]{36}$/i.test(uuid)) {
171
+ throw new TypeError(`buildEpub: uuid 必须是 36 位 UUID 字符串(crypto.randomUUID() 产物),实得「${String(uuid)}」`)
172
+ }
154
173
  const manifestItems = chapters
155
174
  .map((_, i) => ` <item id="c${String(i + 1).padStart(2, '0')}" href="${chapFile(i)}" media-type="application/xhtml+xml"/>`)
156
175
  .join('\n')
@@ -224,6 +243,18 @@ ${paragraphs}
224
243
  * chapters [{ heading, paragraphs }],顺序即 spine 顺序
225
244
  */
226
245
  export function buildEpub({ title, author, uuid, chapters }) {
246
+ // 形状兜底:chapters 必须是数组,且每章 paragraphs 必须是段落数组——上游
247
+ // (章节解析侧)形状漂移时在这里给出带章号的中文报错,而不是抛 undefined
248
+ // 调 .map 的 TypeError 原始栈。
249
+ if (!Array.isArray(chapters)) {
250
+ throw new TypeError(`buildEpub: chapters 必须是章节数组([{ heading, paragraphs }]),实得 ${typeof chapters}`)
251
+ }
252
+ chapters.forEach((chapter, i) => {
253
+ if (!chapter || typeof chapter !== 'object' || !Array.isArray(chapter.paragraphs)) {
254
+ const got = chapter?.paragraphs === null ? 'null' : typeof chapter?.paragraphs
255
+ throw new TypeError(`buildEpub: 第 ${i + 1} 章(${chapter?.heading ?? '无标题'})的 paragraphs 必须是段落数组,实得 ${got}`)
256
+ }
257
+ })
227
258
  const modified = `${new Date().toISOString().replace(/\.\d{3}Z$/, '')}Z`
228
259
  const entries = [
229
260
  // mimetype 必须第一个条目且 stored、内容不带换行——EPUB 规范的身份证
@@ -24,9 +24,11 @@ export const CHAPTER_STATUSES = [
24
24
  ['accepted', '已定稿'],
25
25
  ]
26
26
 
27
- const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf]/g
27
+ // CJK 计数范围:基本区 + 扩展 A + 扩展 B(\u{20000}-\u{2A6DF})。扩展 B 在增补
28
+ // 平面,必须带 u 标志按码点匹配——网文生僻字(如「𠮷」)此前会被漏计。
29
+ const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf\u{20000}-\u{2a6df}]/gu
28
30
 
29
- /** CJK 字符计数(基本区 + 扩展 A 区,覆盖常用汉字)。 */
31
+ /** CJK 字符计数(基本区 + 扩展 A/B 区,含增补平面生僻字)。 */
30
32
  export function countCjk(text) {
31
33
  const matches = String(text ?? '').match(CJK_RE)
32
34
  return matches === null ? 0 : matches.length
@@ -34,7 +36,9 @@ export function countCjk(text) {
34
36
 
35
37
  /**
36
38
  * 行内注释剥除:`#` 只有出现在行首或空白之后才算注释;单双引号内的 # 原样
37
- * 保留(双引号内 \" 是转义引号,翻转前回看前一字符)。
39
+ * 保留(双引号内 \" 是转义引号——判定看引号前连续反斜杠的奇偶:成对说明
40
+ * 反斜杠自身被转义、引号是收尾引号;只回看一个字符会把 "a\\" 的收尾引号
41
+ * 误判成转义,导致注释剥不掉)。
38
42
  */
39
43
  export function stripInlineComment(line) {
40
44
  let inSingle = false
@@ -46,7 +50,11 @@ export function stripInlineComment(line) {
46
50
  continue
47
51
  }
48
52
  if (inDouble) {
49
- if (c === '"' && line[i - 1] !== '\\') inDouble = false
53
+ if (c === '"') {
54
+ let slashes = 0
55
+ for (let j = i - 1; j >= 0 && line[j] === '\\'; j--) slashes++
56
+ if (slashes % 2 === 0) inDouble = false
57
+ }
50
58
  continue
51
59
  }
52
60
  if (c === "'") inSingle = true
@@ -96,11 +104,9 @@ export function parseFlatYamlArray(text) {
96
104
  if (first.startsWith('-')) {
97
105
  const arr = []
98
106
  let current = null
99
- let bare = false
100
107
  const flush = () => {
101
108
  if (current !== null) arr.push(current)
102
109
  current = null
103
- bare = false
104
110
  }
105
111
  for (const line of lines) {
106
112
  const trimmed = line.trim()
@@ -113,7 +119,6 @@ export function parseFlatYamlArray(text) {
113
119
  current[m[1].trim()] = parseScalarText(m[2])
114
120
  } else {
115
121
  arr.push(parseScalarText(rest)) // 裸标量条目
116
- bare = true
117
122
  }
118
123
  } else if (trimmed === '-') {
119
124
  flush()
@@ -124,7 +129,7 @@ export function parseFlatYamlArray(text) {
124
129
  }
125
130
  }
126
131
  flush()
127
- return bare || arr.length > 0 ? arr : []
132
+ return arr // 原式 `bare || arr.length > 0 ? arr : []` 恒等于 arr,删简
128
133
  }
129
134
  // 平键映射
130
135
  const map = {}
@@ -149,27 +154,34 @@ export function parseFlatYamlArray(text) {
149
154
  * `---` 之后的正文(去首个空行)。只切不解析,解析交给上层。
150
155
  */
151
156
  export function splitFrontmatter(text) {
152
- const open = /^---[ \t]*\r?\n/.exec(text)
157
+ // 带 BOM 的文件开头是 \uFEFF,会让下面的开头围栏正则失配 → frontmatter 整体
158
+ // 静默失效(meta 全空、check 全报缺键)。解析入口先剥 BOM:bom 作统一偏移,
159
+ // index 仍按原文计(+1 补回),保住「raw === text.slice(index, index + raw.length)」
160
+ // 的契约——写侧行级手术(panel-core 用 fence.index 拼接替换)不能错位。
161
+ const bom = text.charCodeAt(0) === 0xfeff ? 1 : 0
162
+ const rest = bom === 0 ? text : text.slice(1)
163
+ const open = /^---[ \t]*\r?\n/.exec(rest)
153
164
  if (open === null) return { raw: null, index: -1, body: text }
154
165
  const rawStart = open[0].length
155
- const close = /\r?\n---[ \t]*(?:\r?\n|$)/.exec(text.slice(rawStart))
166
+ const close = /\r?\n---[ \t]*(?:\r?\n|$)/.exec(rest.slice(rawStart))
156
167
  if (close === null) return { raw: null, index: -1, body: text }
157
168
  return {
158
- raw: text.slice(rawStart, rawStart + close.index),
159
- index: rawStart,
160
- body: text.slice(rawStart + close.index + close[0].length).replace(/^\r?\n/, ''),
169
+ raw: text.slice(rawStart + bom, rawStart + bom + close.index),
170
+ index: rawStart + bom,
171
+ body: text.slice(rawStart + bom + close.index + close[0].length).replace(/^\r?\n/, ''),
161
172
  }
162
173
  }
163
174
 
164
175
  /**
165
176
  * 解析章节文件:`---` 围栏 frontmatter(简单键值,一行一个)+ body。
166
- * 无围栏时全部按 body 处理。
177
+ * 无围栏时全部按 body 处理。raw 为围栏内原文(check 侧扫重复键用),无围栏
178
+ * 时为 null。
167
179
  */
168
180
  export function parseChapterFile(text) {
169
181
  const { raw, body } = splitFrontmatter(text)
170
- if (raw === null) return { meta: {}, body }
182
+ if (raw === null) return { meta: {}, body, raw: null }
171
183
  const meta = parseFlatYamlArray(raw)
172
- return { meta: meta !== null && typeof meta === 'object' && !Array.isArray(meta) ? meta : {}, body }
184
+ return { meta: meta !== null && typeof meta === 'object' && !Array.isArray(meta) ? meta : {}, body, raw }
173
185
  }
174
186
 
175
187
  /** 从文件名取排序键:v01-c001.md → { vol: 1, num: 1 };不合规范的文件返回 null。 */
@@ -178,19 +190,29 @@ export function chapterSortKey(stem) {
178
190
  return m === null ? null : { vol: Number(m[1]), num: Number(m[2]) }
179
191
  }
180
192
 
193
+ /**
194
+ * 「列目录下 .md + numeric 感知名称序」在 collectChapters / listOutlines /
195
+ * checkProject 出现三次,收敛到这里。目录不存在给 [](调用方按需先判存在)。
196
+ */
197
+ function listMdFiles(dir) {
198
+ if (!existsSync(dir)) return []
199
+ return readdirSync(dir)
200
+ .filter((f) => f.endsWith('.md'))
201
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
202
+ }
203
+
181
204
  /** 章节五维叙事字段的 frontmatter 键名(值一律中文句子)。 */
182
205
  export const FIVE_DIM_KEYS = ['cognitive_frame', 'emotional_anchor', 'world_texture', 'suspense', 'interpersonal_tension']
183
206
 
184
- /** 收集章节:读取 chapters/*.md,按卷号+章号升序。目录不存在返回 null。 */
207
+ /** 收集章节:读取 chapters/*.md,按卷号+章号升序(文件名不合规范的杂散文件沉底)。目录不存在返回 null。 */
185
208
  export function collectChapters(root) {
186
209
  const chaptersDir = path.join(root, 'chapters')
187
210
  if (!existsSync(chaptersDir)) return null
188
- const files = readdirSync(chaptersDir)
189
- .filter((f) => f.endsWith('.md'))
190
- .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
211
+ const files = listMdFiles(chaptersDir)
191
212
  const chapters = []
192
213
  for (const file of files) {
193
214
  const stem = file.slice(0, -3)
215
+ const key = chapterSortKey(stem) // 排序键一次算好——volume/num/sortKey 三个字段都要用
194
216
  let text = null
195
217
  try {
196
218
  text = readFileSync(path.join(chaptersDir, file), 'utf8')
@@ -202,9 +224,9 @@ export function collectChapters(root) {
202
224
  chapters.push({
203
225
  file,
204
226
  stem,
205
- sortKey: chapterSortKey(stem),
206
- volume: typeof meta.volume === 'string' && meta.volume !== '' ? meta.volume : (chapterSortKey(stem) ? `v${String(chapterSortKey(stem).vol).padStart(2, '0')}` : ''),
207
- num: Number.isInteger(meta.num) ? meta.num : (chapterSortKey(stem) ? chapterSortKey(stem).num : null),
227
+ sortKey: key,
228
+ volume: typeof meta.volume === 'string' && meta.volume !== '' ? meta.volume : (key !== null ? canonicalVolume(key.vol) : ''),
229
+ num: Number.isInteger(meta.num) ? meta.num : (key !== null ? key.num : null),
208
230
  title: typeof meta.title === 'string' && meta.title !== '' ? meta.title : '(无标题)',
209
231
  status,
210
232
  summary: typeof meta.summary === 'string' ? meta.summary : '',
@@ -217,6 +239,10 @@ export function collectChapters(root) {
217
239
  if (a.sortKey !== null && b.sortKey !== null) {
218
240
  return a.sortKey.vol !== b.sortKey.vol ? a.sortKey.vol - b.sortKey.vol : a.sortKey.num - b.sortKey.num
219
241
  }
242
+ // 文件名不合规范的杂散 .md 一律沉底(与 listOutlines 同策略)——否则会排
243
+ // 在正常章之前,混进导出并污染 brief 的「前章」链。
244
+ if (a.sortKey !== null) return -1
245
+ if (b.sortKey !== null) return 1
220
246
  return a.stem.localeCompare(b.stem, undefined, { numeric: true })
221
247
  })
222
248
  return chapters
@@ -227,7 +253,10 @@ export function collectChapters(root) {
227
253
  /**
228
254
  * 流式上下文里的标量落盘:数字直写;字符串在含 YAML 结构字符(流式项分隔、
229
255
  * 键值形态、起首指示符、注释引入、首尾空白、控制字符)时按 JSON 双引号落值
230
- * (JSON 字符串即合法 YAML 双引号标量),否则保持原文。
256
+ * (JSON 字符串即合法 YAML 双引号标量);裸写会被 YAML 读成数字/布尔的串
257
+ * ('1984'、'true'、'3.14' 这类类型漂移)同样要引起来——落值前用自家
258
+ * parseScalarText 试解析,结果 !== 原串(类型或值变了)即按 JSON 引号落值,
259
+ * 保证读回来自始至终是字符串。
231
260
  */
232
261
  export function flowScalar(value) {
233
262
  if (typeof value === 'number' && Number.isFinite(value)) return String(value)
@@ -241,7 +270,8 @@ export function flowScalar(value) {
241
270
  /\s[:#]/.test(s) ||
242
271
  /^\s|\s$/.test(s) ||
243
272
  /[\x00-\x1f]/.test(s)
244
- return unsafe ? JSON.stringify(s) : s
273
+ if (unsafe) return JSON.stringify(s)
274
+ return parseScalarText(s) !== s ? JSON.stringify(s) : s
245
275
  }
246
276
 
247
277
  /** 章节一行条目(flow mapping,num/title/status/words/summary 一行)。 */
@@ -305,8 +335,8 @@ export function buildIndexYaml(chapters, volumesRaw) {
305
335
 
306
336
  const OUTLINES_DIR = 'outlines'
307
337
 
308
- /** 读文本文件;读不到返回 null(本模块私有助手,与 bin 侧同名函数同语义)。 */
309
- function readTextOrNull(file) {
338
+ /** 读文本文件;读不到返回 null(导出供 bin 等其他文件复用,与 bin 侧本地副本同语义)。 */
339
+ export function readTextOrNull(file) {
310
340
  try {
311
341
  return readFileSync(file, 'utf8')
312
342
  } catch {
@@ -335,10 +365,8 @@ export function readOutline(novelDir, chapterFile) {
335
365
  /** 列出全部章纲 [{ file, chapter, words }],按章号升序(不合文件名规范的排最后,按名称序)。 */
336
366
  export function listOutlines(novelDir) {
337
367
  const dir = path.join(novelDir, OUTLINES_DIR)
338
- if (!existsSync(dir)) return []
339
368
  const outlines = []
340
- for (const file of readdirSync(dir)) {
341
- if (!file.endsWith('.md')) continue
369
+ for (const file of listMdFiles(dir)) {
342
370
  const stem = file.slice(0, -3)
343
371
  const text = readTextOrNull(path.join(dir, file))
344
372
  if (text === null) continue
@@ -355,6 +383,14 @@ export function listOutlines(novelDir) {
355
383
  return outlines
356
384
  }
357
385
 
386
+ /**
387
+ * 只数 outlines/ 下的 .md 个数,不读内容。checkProject 的数纲数只需要个数,
388
+ * 走 listOutlines 会把每份章纲全文读进来做 CJK 计数——纯浪费。
389
+ */
390
+ function countOutlineFiles(novelDir) {
391
+ return listMdFiles(path.join(novelDir, OUTLINES_DIR)).length
392
+ }
393
+
358
394
  // ── 写作卡 brief(五期 §9.6)────────────────────────────────────────────────
359
395
  //
360
396
  // 把一章的写作输入打成一个 md(章纲 + 前章结尾 + 出场角色卡 + 活跃伏笔 +
@@ -400,18 +436,21 @@ function collectCharacterCards(novelDir) {
400
436
  if (!file.endsWith('.md')) continue
401
437
  const text = readTextOrNull(path.join(dir, file))
402
438
  if (text === null) continue
403
- const { meta, body } = parseChapterFile(text)
404
- cards.push({ slug: file.slice(0, -3), file: `characters/${file}`, meta, body })
439
+ const { meta, body, raw } = parseChapterFile(text)
440
+ cards.push({ slug: file.slice(0, -3), file: `characters/${file}`, meta, body, raw })
405
441
  }
406
442
  cards.sort((a, b) => a.slug.localeCompare(b.slug))
407
443
  return cards
408
444
  }
409
445
 
410
- /** 顶层数组 yml 读取:文件缺失、解析失败或非数组一律给 [](条目只留对象)。 */
446
+ /** 顶层数组 yml 读取:文件缺失、解析失败或非数组一律给 [](条目只留对象)。
447
+ * `-` 空条目后跟裸标量(`-\n- foo`)会被解析成无任何自有键的空对象,一并过滤。 */
411
448
  function readYamlArray(novelDir, name) {
412
449
  const raw = parseFlatYamlArray(readTextOrNull(path.join(novelDir, name)))
413
450
  if (!Array.isArray(raw)) return []
414
- return raw.filter((item) => item !== null && typeof item === 'object' && !Array.isArray(item))
451
+ return raw.filter(
452
+ (item) => item !== null && typeof item === 'object' && !Array.isArray(item) && Object.keys(item).length > 0,
453
+ )
415
454
  }
416
455
 
417
456
  /**
@@ -675,6 +714,32 @@ function canonicalVolume(vol) {
675
714
  return `v${String(vol).padStart(2, '0')}`
676
715
  }
677
716
 
717
+ /**
718
+ * 扫 frontmatter 原文找重复键:解析器对重复键 last-wins(宽容不炸),但静默
719
+ * 覆盖会藏「改了前一个没生效」的困惑,check 侧给 warn 提醒。判定口径与
720
+ * parseFlatYamlArray 的平键分支对齐:只认零缩进键,键名去首尾引号。
721
+ */
722
+ function findDuplicateFrontmatterKeys(raw) {
723
+ if (raw === null) return []
724
+ const seen = new Set()
725
+ const dups = []
726
+ for (const line of raw.split(/\r?\n/)) {
727
+ const cleaned = stripInlineComment(line)
728
+ const trimmed = cleaned.trim()
729
+ if (trimmed === '' || trimmed.startsWith('#')) continue
730
+ const indent = cleaned.match(/^[ \t]*/)[0].length
731
+ const m = /^([^:]+):/.exec(trimmed)
732
+ if (m === null || indent !== 0) continue
733
+ const key = m[1].trim().replace(/^['"]|['"]$/g, '')
734
+ if (seen.has(key)) {
735
+ if (!dups.includes(key)) dups.push(key)
736
+ } else {
737
+ seen.add(key)
738
+ }
739
+ }
740
+ return dups
741
+ }
742
+
678
743
  /**
679
744
  * 账本里的章节引用是否存在:认 stem(v01-c001,含不补零写法)与裸章号。
680
745
  * 空值视为「未填」,不算悬空。
@@ -691,6 +756,21 @@ function chapterRefExists(ref, stems, nums) {
691
756
  return false
692
757
  }
693
758
 
759
+ /**
760
+ * 章节引用转可比较的序数 {vol, num}:认 v01-c002(含不补零写法)、「卷-章」
761
+ * 与裸章号(裸章号无卷信息,卷号记 0,仅用于与带卷引用的近似比较)。
762
+ * 只做格式解析、不查存在性——expected_resolve 的排期承诺口径用它。
763
+ */
764
+ function parseChapterRefOrdinal(ref) {
765
+ const raw = String(ref ?? '').trim().replace(/[-—–]/g, '-').replace(/\.md$/i, '')
766
+ let m = /^[vV](\d+)-c(\d+)$/.exec(raw)
767
+ if (m !== null) return { vol: Number(m[1]), num: Number(m[2]) }
768
+ m = /^(\d+)-(\d+)$/.exec(raw)
769
+ if (m !== null) return { vol: Number(m[1]), num: Number(m[2]) }
770
+ if (/^\d+$/.test(raw)) return { vol: 0, num: Number(raw) }
771
+ return null
772
+ }
773
+
694
774
  /** 时间线年份还原:数字直用;字符串抓第一段数字;抓不到返回 null。 */
695
775
  function parseTimelineYear(year) {
696
776
  if (typeof year === 'number' && Number.isFinite(year)) return year
@@ -710,11 +790,7 @@ export function checkProject(novelDir) {
710
790
 
711
791
  // —— chapters/:frontmatter 完整性、枚举、章号卷号一致 ——
712
792
  const chaptersDir = path.join(novelDir, 'chapters')
713
- const chapterFiles = existsSync(chaptersDir)
714
- ? readdirSync(chaptersDir)
715
- .filter((f) => f.endsWith('.md'))
716
- .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
717
- : []
793
+ const chapterFiles = listMdFiles(chaptersDir)
718
794
  if (!existsSync(chaptersDir)) push('error', 'chapters/', '找不到 chapters/ 目录——先跑 maoying init')
719
795
 
720
796
  const stems = new Set()
@@ -729,12 +805,20 @@ export function checkProject(novelDir) {
729
805
  push('warn', rel, '文件名不合规范(应为 v<卷号2位>-c<章号3位>.md),章纲与账本对不上它')
730
806
  }
731
807
  let meta = {}
808
+ let fmRaw = null
732
809
  try {
733
- meta = parseChapterFile(readFileSync(path.join(chaptersDir, file), 'utf8')).meta
810
+ const parsed = parseChapterFile(readFileSync(path.join(chaptersDir, file), 'utf8'))
811
+ meta = parsed.meta
812
+ fmRaw = parsed.raw
734
813
  } catch {
735
814
  push('error', rel, '章节文件读取失败')
736
815
  continue
737
816
  }
817
+ // 重复键:解析保持 last-wins 宽容,但静默覆盖值得提醒(角色卡同理,见 characters 段)
818
+ const dupKeys = findDuplicateFrontmatterKeys(fmRaw)
819
+ if (dupKeys.length > 0) {
820
+ push('warn', rel, `frontmatter 重复键(解析时后者覆盖前者):${dupKeys.join('、')}`)
821
+ }
738
822
  for (const k of REQUIRED_CHAPTER_KEYS) {
739
823
  if (meta[k] === undefined || meta[k] === '') push('error', rel, `frontmatter 缺少必填键 ${k}`)
740
824
  }
@@ -750,8 +834,14 @@ export function checkProject(novelDir) {
750
834
  if (typeof meta.volume === 'string' && meta.volume !== '' && meta.volume !== expectedVolume) {
751
835
  push('error', rel, `frontmatter volume(${meta.volume})与文件名卷号(${expectedVolume})不一致`)
752
836
  }
753
- if (meta.num !== undefined && meta.num !== '' && meta.num !== key.num) {
754
- push('error', rel, `frontmatter num(${meta.num})与文件名章号(${key.num})不一致`)
837
+ // num 常被手写成 '3' 这类单引号字符串:比较前按 Number() 归一(与
838
+ // collectChapters 的宽容口径对齐),转不出数字(NaN)时维持严格不等判断。
839
+ if (meta.num !== undefined && meta.num !== '') {
840
+ const parsedNum = Number(meta.num)
841
+ const mismatch = Number.isNaN(parsedNum) ? meta.num !== key.num : parsedNum !== key.num
842
+ if (mismatch) {
843
+ push('error', rel, `frontmatter num(${meta.num})与文件名章号(${key.num})不一致`)
844
+ }
755
845
  }
756
846
  }
757
847
  if (typeof meta.writer === 'string' && meta.writer !== '' && !CHAPTER_WRITERS.includes(meta.writer)) {
@@ -759,7 +849,7 @@ export function checkProject(novelDir) {
759
849
  }
760
850
  }
761
851
 
762
- // —— foreshadows.yml:章号引用存在性、状态枚举、id 去重 ——
852
+ // —— foreshadows.yml:状态枚举、id 去重、章节引用、状态-字段自洽 ——
763
853
  const foreshadows = readYamlArray(novelDir, 'foreshadows.yml')
764
854
  const seenIds = new Map()
765
855
  foreshadows.forEach((f, i) => {
@@ -771,13 +861,44 @@ export function checkProject(novelDir) {
771
861
  if (seenIds.has(f.id)) push('error', 'foreshadows.yml', `伏笔 id 重复:${f.id}(第 ${seenIds.get(f.id) + 1} 条与第 ${i + 1} 条)`)
772
862
  else seenIds.set(f.id, i)
773
863
  }
774
- for (const k of ['planted_chapter', 'expected_resolve', 'resolved_chapter']) {
864
+ // 埋设/回收章是已发生的事实,必须指向已写出的章(空值在此跳过——resolved
865
+ // 缺兑现章属于「漏规则」,在下面单查)。
866
+ for (const k of ['planted_chapter', 'resolved_chapter']) {
775
867
  const ref = f[k]
776
868
  if ((typeof ref !== 'string' || ref.trim() === '') && typeof ref !== 'number') continue
777
869
  if (!chapterRefExists(ref, stems, nums)) {
778
870
  push('error', 'foreshadows.yml', `伏笔 ${label} 的 ${k} 引用章节不存在:${ref}`)
779
871
  }
780
872
  }
873
+ // expected_resolve 是排期承诺(docs/spec.md):目标章天然多数尚未写出,
874
+ // 只查格式合法性(能解析出卷/章),不查存在性——按官方模板排到未来章号
875
+ // 不该报「引用章节不存在」。
876
+ const expected = f.expected_resolve
877
+ if ((typeof expected === 'string' && expected.trim() !== '') || typeof expected === 'number') {
878
+ if (parseChapterRefOrdinal(expected) === null) {
879
+ push('error', 'foreshadows.yml', `伏笔 ${label} 的 expected_resolve 格式不合法:${expected}(应为 v<卷>-c<章>、「卷-章」或裸章号)`)
880
+ } else if (f.status === 'planted' && chapterRefExists(expected, stems, nums)) {
881
+ // 反向提醒:排期目标章已写出却还停在埋设态,多半是忘了登记推进/回收
882
+ push('warn', 'foreshadows.yml', `伏笔 ${label} 的 expected_resolve(${expected})指向的章节已写出,但 status 仍为 planted——确认是否漏登记推进/回收`)
883
+ }
884
+ }
885
+ // docs/spec.md:resolved 回收时 resolved_chapter 必填
886
+ const resolvedRef = f.resolved_chapter
887
+ const resolvedEmpty =
888
+ resolvedRef === undefined || resolvedRef === null || (typeof resolvedRef === 'string' && resolvedRef.trim() === '')
889
+ if (f.status === 'resolved' && resolvedEmpty) {
890
+ push('error', 'foreshadows.yml', `伏笔 ${label} status 为 resolved 但未填 resolved_chapter(回收必须登记兑现章)`)
891
+ }
892
+ // docs/spec.md:resolved_chapter 不得早于 planted_chapter(两章号均可解析才比)
893
+ const plantedOrd = parseChapterRefOrdinal(f.planted_chapter)
894
+ const resolvedOrd = parseChapterRefOrdinal(resolvedRef)
895
+ if (
896
+ plantedOrd !== null &&
897
+ resolvedOrd !== null &&
898
+ (resolvedOrd.vol < plantedOrd.vol || (resolvedOrd.vol === plantedOrd.vol && resolvedOrd.num < plantedOrd.num))
899
+ ) {
900
+ push('error', 'foreshadows.yml', `伏笔 ${label} 的 resolved_chapter(${resolvedRef})早于 planted_chapter(${f.planted_chapter})`)
901
+ }
781
902
  })
782
903
 
783
904
  // —— timeline.yml:年份可解析、不倒挂(warn:故事内纪年本就允许花写)——
@@ -811,11 +932,15 @@ export function checkProject(novelDir) {
811
932
  }
812
933
  })
813
934
 
814
- // —— characters/*.md:name 缺失是硬伤 ——
935
+ // —— characters/*.md:name 缺失是硬伤;重复键给 warn ——
815
936
  for (const card of cards) {
816
937
  if (typeof card.meta.name !== 'string' || card.meta.name.trim() === '') {
817
938
  push('error', card.file, '角色卡缺少 name')
818
939
  }
940
+ const dupKeys = findDuplicateFrontmatterKeys(card.raw)
941
+ if (dupKeys.length > 0) {
942
+ push('warn', card.file, `frontmatter 重复键(解析时后者覆盖前者):${dupKeys.join('、')}`)
943
+ }
819
944
  }
820
945
 
821
946
  // —— project.yml:mode 枚举 ——
@@ -829,7 +954,7 @@ export function checkProject(novelDir) {
829
954
 
830
955
  const summary = {
831
956
  chapters: chapterFiles.length,
832
- outlines: listOutlines(novelDir).length,
957
+ outlines: countOutlineFiles(novelDir), // 只列目录计数,不为数数把章纲全文读进来
833
958
  foreshadows: foreshadows.length,
834
959
  problems: {
835
960
  error: problems.filter((p) => p.level === 'error').length,