dsh-maoying 0.1.19 → 0.1.21

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
@@ -121,9 +121,15 @@ export function buildZip(entries) {
121
121
  * XML 文本转义:& 必须先转(否则把刚生成的 &amp; 又转一遍),再 < > 与引号。
122
122
  * 章节正文与标题里出现的都过这里;属性值一律用生成的 ASCII 名不走它,引号
123
123
  * 转义属防御纵深(将来属性值万一改走用户输入也不破 XML)。
124
+ *
125
+ * L8:XML 1.0 非法控制字符(\x00-\x08、\x0B、\x0C、\x0E-\x1F;\t \n \r 合法
126
+ * 保留)先替换为 U+FFFD 再转义——混入即产非法 XHTML,阅读器可能拒载整本。
127
+ * 选替换不选剥离:脏字符多半是复制粘贴事故,U+FFFD 给作者与阅读器留下
128
+ * 「这里有脏字符」的可见痕迹,静默剥除会无声丢字。
124
129
  */
125
130
  export function escapeXml(text) {
126
131
  return String(text ?? '')
132
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '\uFFFD')
127
133
  .replace(/&/g, '&amp;')
128
134
  .replace(/</g, '&lt;')
129
135
  .replace(/>/g, '&gt;')
@@ -35,6 +35,13 @@ export const CHAPTER_STATUSES = [
35
35
  ['accepted', '已定稿'],
36
36
  ]
37
37
 
38
+ /**
39
+ * 伏笔超期阈值:planted 状态超过该章数未推进即提醒(checkProject 的伏笔超期
40
+ * warn 用)。数字出处:skills/maoying-foreshadow-discipline/SKILL.md「清理纪律」
41
+ * ——planted 超过 30 章未推进应升级(置 progressing 并规划推进)或 abandoned。
42
+ */
43
+ export const FORESHADOW_STALE_CHAPTERS = 30
44
+
38
45
  // CJK 计数范围:基本区 + 扩展 A + 扩展 B(\u{20000}-\u{2A6DF})。扩展 B 在增补
39
46
  // 平面,必须带 u 标志按码点匹配——网文生僻字(如「𠮷」)此前会被漏计。
40
47
  const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf\u{20000}-\u{2a6df}]/gu
@@ -99,15 +106,21 @@ export function parseScalarText(raw) {
99
106
  * - 顶层数组(volumes.yml / foreshadows.yml 等):`- key: value` 起条目,
100
107
  * 缩进行补键;裸 `- 标量` 收作字符串条目。
101
108
  * 深嵌套不在支持范围(数据规范本身就禁止)。解析不了返回 null。
109
+ *
110
+ * H2:onWarn(可选回调)接收「认不出但没炸」的行告警——目前只有数组分支的
111
+ * 零缩进散键行一种;调用方(panel-core collectArrayYml / checkProject /
112
+ * buildBrief)把消息各自挂上文件名再入自家告警通道,散键不再静默蒸发。
102
113
  */
103
- export function parseFlatYamlArray(text) {
114
+ export function parseFlatYamlArray(text, onWarn = null) {
104
115
  if (text === null) return null
105
116
  const lines = []
106
- for (const raw of text.split(/\r?\n/)) {
117
+ const lineNos = [] // 源行号随行登记:散键告警要能定位到文件行
118
+ text.split(/\r?\n/).forEach((raw, i) => {
107
119
  const line = stripInlineComment(raw).replace(/\s+$/, '')
108
- if (line.trim() === '' || line.trim().startsWith('#')) continue
120
+ if (line.trim() === '' || line.trim().startsWith('#')) return
109
121
  lines.push(line)
110
- }
122
+ lineNos.push(i + 1)
123
+ })
111
124
  if (lines.length === 0) return null
112
125
  const first = lines[0].trim()
113
126
  if (first === '[]') return []
@@ -119,7 +132,8 @@ export function parseFlatYamlArray(text) {
119
132
  if (current !== null) arr.push(current)
120
133
  current = null
121
134
  }
122
- for (const line of lines) {
135
+ for (let li = 0; li < lines.length; li++) {
136
+ const line = lines[li]
123
137
  const trimmed = line.trim()
124
138
  if (trimmed.startsWith('- ')) {
125
139
  flush()
@@ -134,6 +148,11 @@ export function parseFlatYamlArray(text) {
134
148
  } else if (trimmed === '-') {
135
149
  flush()
136
150
  current = {}
151
+ } else if (!/^[ \t]/.test(line)) {
152
+ // H2:数组形态里零缩进、非「-」起头的散键行不属于任何条目——旧实现会
153
+ // 静默并入前一条目,手改残留键能覆盖前条 status 且零告警。丢弃并告警
154
+ // (顶层纯键值不经过本分支——那是合法映射形态)。
155
+ if (onWarn !== null) onWarn(`第 ${lineNos[li]} 行零缩进散键「${trimmed}」不属于任何条目,已丢弃`)
137
156
  } else if (current !== null) {
138
157
  const m = /^([^:]+):\s*(.*)$/.exec(trimmed)
139
158
  if (m !== null) current[m[1].trim()] = parseScalarText(m[2])
@@ -208,7 +227,9 @@ export function chapterSortKey(stem) {
208
227
  function listMdFiles(dir) {
209
228
  if (!existsSync(dir)) return []
210
229
  return readdirSync(dir)
211
- .filter((f) => f.endsWith('.md'))
230
+ // M10:.md 大小写不敏感(/\.md$/i)——panel-core 的 collectOutlines /
231
+ // collectMdEntities 同口径收录 .MD,CLI 侧不得把同一批文件漏在门外
232
+ .filter((f) => /\.md$/i.test(f))
212
233
  .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
213
234
  }
214
235
 
@@ -310,6 +331,9 @@ export function buildIndexYaml(chapters, volumesRaw) {
310
331
  if (volumes.length === 0) lines.push(' []')
311
332
  for (const volume of volumes) {
312
333
  const id = String(volume.id ?? '')
334
+ // M8:重复 id 不在此处去重——index 重建是确定性纯函数、静默择一反而把数据
335
+ // 问题藏进产物;重复 id 由 checkProject 报 error 兜底,源头修 volumes.yml
336
+ // (claimed 用 Set,同批章节重复入卷只影响卷内字数之和,orphans 不受影响)。
313
337
  const members = chapters.filter((ch) => ch.volume === id)
314
338
  for (const ch of members) claimed.add(ch.stem)
315
339
  lines.push(` - id: ${flowScalar(id || '(缺 id)')}`)
@@ -444,7 +468,8 @@ function collectCharacterCards(novelDir) {
444
468
  if (!existsSync(dir)) return []
445
469
  const cards = []
446
470
  for (const file of readdirSync(dir)) {
447
- if (!file.endsWith('.md')) continue
471
+ // M10:口径与 listMdFiles 一致——.MD 大小写不敏感(面板实体卡目录收录同款文件)
472
+ if (!/\.md$/i.test(file)) continue
448
473
  const text = readTextOrNull(path.join(dir, file))
449
474
  if (text === null) continue
450
475
  const { meta, body, raw } = parseChapterFile(text)
@@ -455,9 +480,11 @@ function collectCharacterCards(novelDir) {
455
480
  }
456
481
 
457
482
  /** 顶层数组 yml 读取:文件缺失、解析失败或非数组一律给 [](条目只留对象)。
458
- * `-` 空条目后跟裸标量(`-\n- foo`)会被解析成无任何自有键的空对象,一并过滤。 */
459
- function readYamlArray(novelDir, name) {
460
- const raw = parseFlatYamlArray(readTextOrNull(path.join(novelDir, name)))
483
+ * `-` 空条目后跟裸标量(`-\n- foo`)会被解析成无任何自有键的空对象,一并过滤。
484
+ * onWarn(H2)透传给 parseFlatYamlArray 收散键丢弃告警,消息不含文件名,
485
+ * 由调用方按需挂(checkProject 入 problems、buildBrief 入 warnings)。 */
486
+ function readYamlArray(novelDir, name, onWarn = null) {
487
+ const raw = parseFlatYamlArray(readTextOrNull(path.join(novelDir, name)), onWarn)
461
488
  if (!Array.isArray(raw)) return []
462
489
  return raw.filter(
463
490
  (item) => item !== null && typeof item === 'object' && !Array.isArray(item) && Object.keys(item).length > 0,
@@ -599,8 +626,10 @@ export function buildBrief(novelDir, chapterRef, options = {}) {
599
626
  }
600
627
  const idx = chapters.findIndex((c) => c.stem === target.stem)
601
628
 
602
- // 卷名:volumes.yml 的 title,缺了退「卷N」/卷 id
603
- const volumesRaw = parseFlatYamlArray(readTextOrNull(path.join(novelDir, 'volumes.yml')))
629
+ // 卷名:volumes.yml 的 title,缺了退「卷N」/卷 id(H2:散键告警挂文件名入 warnings)
630
+ const volumesRaw = parseFlatYamlArray(readTextOrNull(path.join(novelDir, 'volumes.yml')), (w) =>
631
+ warnings.push(`volumes.yml: ${w}`),
632
+ )
604
633
  const volTitles = new Map()
605
634
  if (Array.isArray(volumesRaw)) {
606
635
  for (const v of volumesRaw) {
@@ -654,7 +683,7 @@ export function buildBrief(novelDir, chapterRef, options = {}) {
654
683
  // 出场角色卡:有章纲按章纲选人(卡名/ slug 命中 + 「出场角色:」行点名);
655
684
  // 章纲缺失时回退「前章出场角色 + 主角」(主角判定见 protagonistCards 注释)
656
685
  const cards = collectCharacterCards(novelDir)
657
- const relations = readYamlArray(novelDir, 'relations.yml')
686
+ const relations = readYamlArray(novelDir, 'relations.yml', (w) => warnings.push(`relations.yml: ${w}`))
658
687
  let roleCards = []
659
688
  let missingNames = []
660
689
  if (outline !== null) {
@@ -683,7 +712,7 @@ export function buildBrief(novelDir, chapterRef, options = {}) {
683
712
  // 活跃伏笔:status ∈ planted/progressing 的条目照录
684
713
  const hasForeshadowsFile = existsSync(path.join(novelDir, 'foreshadows.yml'))
685
714
  if (!hasForeshadowsFile) warnings.push('未找到 foreshadows.yml,活跃伏笔一节按空处理')
686
- const activeForeshadows = readYamlArray(novelDir, 'foreshadows.yml').filter(
715
+ const activeForeshadows = readYamlArray(novelDir, 'foreshadows.yml', (w) => warnings.push(`foreshadows.yml: ${w}`)).filter(
687
716
  (f) => f.status === 'planted' || f.status === 'progressing',
688
717
  )
689
718
  const fsParts = activeForeshadows.map((f) => renderForeshadowBrief(f))
@@ -717,6 +746,15 @@ const CHAPTER_WRITERS = ['author', 'ai']
717
746
  /** 伏笔状态机枚举(docs/spec.md)。 */
718
747
  const FORESHADOW_STATUSES = ['planted', 'progressing', 'resolved', 'abandoned']
719
748
 
749
+ /** 伏笔 source 枚举(templates/novel/foreshadows.yml 声明;缺省归 plan 不报)。 */
750
+ const FORESHADOW_SOURCES = ['plan', 'backfill']
751
+
752
+ /** clue-board.yml 的 kind 枚举(templates/novel/clue-board.yml 声明)。 */
753
+ const CLUE_KINDS = ['clue', 'red-herring', 'deduction', 'question']
754
+
755
+ /** memories.yml 的 category 枚举(templates/novel/memories.yml 声明)。 */
756
+ const MEMORY_CATEGORIES = ['character', 'location', 'item', 'event', 'promise', 'appearance', 'arc', 'other']
757
+
720
758
  /** project.yml 的共创模式枚举(五期 §9.1)。 */
721
759
  const PROJECT_MODES = ['ai-draft', 'co-write', 'author-draft']
722
760
 
@@ -796,6 +834,83 @@ function parseTimelineYear(year) {
796
834
  return m === null ? null : Number(m[0])
797
835
  }
798
836
 
837
+ // ── AI 味统计(checkProject 第三组规则的实现;独立纯函数便于测试与复用)──────
838
+
839
+ /** 连续同式开头判定下限:连续 ≥ 3 句以相同前 2 字开头算句式重复。 */
840
+ export const STYLE_REPEAT_RUN = 3
841
+ /** 比喻词密度阈值:每千 CJK 字超过 2 处判 AI 味风险。 */
842
+ export const STYLE_METAPHOR_PER_KILO = 2
843
+ /** 句长波动检查的最少句数:句数不足时统计无意义,不查。 */
844
+ export const STYLE_SENTENCE_MIN = 20
845
+ /** 句长变异系数(标准差/均值)下限:低于它说明长短句交替不足。 */
846
+ export const STYLE_SENTENCE_CV_MIN = 0.3
847
+
848
+ /** AI 高频比喻词清单(字面计数,不做修辞判定)。 */
849
+ const METAPHOR_WORDS = ['仿佛', '宛如', '好似', '如同', '恍若', '好像']
850
+
851
+ /** 章纲「钩子」小节的标题行:#{1,3} 级、「钩子」或「章尾钩子」打头即算。 */
852
+ const OUTLINE_HOOK_RE = /^#{1,3}[ \t]*(?:章尾)?钩子/m
853
+
854
+ /**
855
+ * 章节正文的 AI 味统计(纯函数,不读文件、不调模型):按句切分(。!?…;,切分符不含逗号;
856
+ * 保留句文本),三条指标互相独立、命中才报,返回 warn 消息数组(level/file
857
+ * 由调用方挂):
858
+ * a. 连续同式开头——取最长的同前缀连续句段,达到 STYLE_REPEAT_RUN 报一次/章;
859
+ * b. 比喻词密度——METAPHOR_WORDS 总次数 / 千 CJK 字超过 STYLE_METAPHOR_PER_KILO
860
+ * (分母用 countCjk,与全库字数统计同口径);
861
+ * c. 句长波动——句数达 STYLE_SENTENCE_MIN 且变异系数(总体标准差/均值)
862
+ * 低于 STYLE_SENTENCE_CV_MIN。
863
+ */
864
+ export function checkChapterStyle(body) {
865
+ const text = String(body ?? '')
866
+ const messages = []
867
+ const sentences = text
868
+ .split(/[。!?…;]+/)
869
+ .map((s) => s.trim())
870
+ .filter((s) => s !== '')
871
+ if (sentences.length === 0) return messages
872
+ // a. 连续同式开头:一次线性扫描,记最长的同前缀连续段
873
+ let best = null
874
+ let curPrefix = null
875
+ let curRun = 0
876
+ for (const sentence of sentences) {
877
+ const prefix = sentence.slice(0, 2)
878
+ if (prefix === curPrefix) curRun++
879
+ else {
880
+ curPrefix = prefix
881
+ curRun = 1
882
+ }
883
+ if (best === null || curRun > best.run) best = { prefix, run: curRun }
884
+ }
885
+ if (best.run >= STYLE_REPEAT_RUN) {
886
+ messages.push(`疑似句式重复:连续 ${best.run} 句以「${best.prefix}」开头`)
887
+ }
888
+ // b. 比喻词密度:字面总次数 / 千 CJK 字,严格超过阈值才报
889
+ let metaphorCount = 0
890
+ for (const word of METAPHOR_WORDS) metaphorCount += text.split(word).length - 1
891
+ const kilo = countCjk(text) / 1000
892
+ if (kilo > 0 && metaphorCount / kilo > STYLE_METAPHOR_PER_KILO) {
893
+ messages.push(
894
+ `比喻词密度偏高:${metaphorCount} 处 / ${Number(kilo.toFixed(1))}千字(阈值 ${STYLE_METAPHOR_PER_KILO}/千字)——AI 味风险`,
895
+ )
896
+ }
897
+ // c. 句长波动:句数足够且变异系数过低才报(均值 0 只可能全空句,已被过滤)
898
+ if (sentences.length >= STYLE_SENTENCE_MIN) {
899
+ const lengths = sentences.map((s) => s.length)
900
+ const mean = lengths.reduce((sum, l) => sum + l, 0) / lengths.length
901
+ if (mean > 0) {
902
+ const variance = lengths.reduce((sum, l) => sum + (l - mean) ** 2, 0) / lengths.length
903
+ const cv = Math.sqrt(variance) / mean
904
+ if (cv < STYLE_SENTENCE_CV_MIN) {
905
+ messages.push(
906
+ `句长节奏过平:${sentences.length} 句变异系数 ${cv.toFixed(2)}(阈值 ${STYLE_SENTENCE_CV_MIN.toFixed(2)})——长短句交替不足`,
907
+ )
908
+ }
909
+ }
910
+ }
911
+ return messages
912
+ }
913
+
799
914
  /**
800
915
  * 静态体检(五期 §9.6):只查文件规范,不调模型。返回
801
916
  * { ok, problems: [{ level: 'error'|'warn', file, message }], summary }
@@ -806,6 +921,16 @@ export function checkProject(novelDir) {
806
921
  const problems = []
807
922
  const push = (level, file, message) => problems.push({ level, file, message })
808
923
 
924
+ // H2:顶层数组账本 yml 的极简解析告警(零缩进散键被丢弃等)并入 problems——
925
+ // 与面板快照的 warnings 通道对齐,散键不能再静默蒸发。
926
+ const readAccountYml = (name) => readYamlArray(novelDir, name, (w) => push('warn', name, w))
927
+ // M9:账本章节引用统一校验——空值是「未填」不算悬空,指向未写章节报 error
928
+ // (口径与 foreshadows planted_chapter 的既有引用校验一致)。
929
+ const refCheck = (file, label, field, ref) => {
930
+ if ((typeof ref !== 'string' || ref.trim() === '') && typeof ref !== 'number') return
931
+ if (!chapterRefExists(ref, stems, nums)) push('error', file, `${label}的 ${field} 引用章节不存在:${ref}`)
932
+ }
933
+
809
934
  // —— chapters/:frontmatter 完整性、枚举、章号卷号一致 ——
810
935
  const chaptersDir = path.join(novelDir, 'chapters')
811
936
  const chapterFiles = listMdFiles(chaptersDir)
@@ -813,6 +938,9 @@ export function checkProject(novelDir) {
813
938
 
814
939
  const stems = new Set()
815
940
  const nums = new Set()
941
+ // 全书已写章节的最大卷章序数(vol,num):伏笔超期检查的比对基准。规范文件名
942
+ // 优先;不合规范但 frontmatter 卷号章号可解析的章按 frontmatter 口径补入。
943
+ let maxWritten = null
816
944
  for (const file of chapterFiles) {
817
945
  const stem = file.slice(0, -3)
818
946
  stems.add(stem)
@@ -824,10 +952,12 @@ export function checkProject(novelDir) {
824
952
  }
825
953
  let meta = {}
826
954
  let fmRaw = null
955
+ let chapterBody = '' // AI 味统计要在循环尾用正文,try 块里的 parsed 出了作用域
827
956
  try {
828
957
  const parsed = parseChapterFile(readFileSync(path.join(chaptersDir, file), 'utf8'))
829
958
  meta = parsed.meta
830
959
  fmRaw = parsed.raw
960
+ chapterBody = parsed.body
831
961
  } catch {
832
962
  push('error', rel, '章节文件读取失败')
833
963
  continue
@@ -844,6 +974,16 @@ export function checkProject(novelDir) {
844
974
  push('error', rel, `status 越枚举:${meta.status}(合法值 pending|writing|review|accepted)`)
845
975
  }
846
976
  if (Number.isInteger(meta.num)) nums.add(meta.num)
977
+ // 汇总最大卷章序数:文件名可解析用文件名;否则退 frontmatter volume+num
978
+ // (裸章号口径的卷号记 0)。vol*1000+num 线性化比较——单卷章数远不到千。
979
+ const ordNum = key !== null ? key.num : (Number.isInteger(meta.num) ? meta.num : null)
980
+ if (ordNum !== null) {
981
+ const volMatch = /^v(\d+)$/.exec(typeof meta.volume === 'string' ? meta.volume.trim() : '')
982
+ const ordVol = key !== null ? key.vol : (volMatch !== null ? Number(volMatch[1]) : 0)
983
+ if (maxWritten === null || ordVol * 1000 + ordNum > maxWritten.vol * 1000 + maxWritten.num) {
984
+ maxWritten = { vol: ordVol, num: ordNum }
985
+ }
986
+ }
847
987
  const missingDims = FIVE_DIM_KEYS.filter((k) => meta[k] === undefined || meta[k] === '')
848
988
  if (missingDims.length > 0) push('warn', rel, `五维字段缺失:${missingDims.join('、')}`)
849
989
  if (meta.summary === undefined || meta.summary === '') push('warn', rel, 'summary 缺失(index.yml 与 stats 直接引用)')
@@ -865,16 +1005,25 @@ export function checkProject(novelDir) {
865
1005
  if (typeof meta.writer === 'string' && meta.writer !== '' && !CHAPTER_WRITERS.includes(meta.writer)) {
866
1006
  push('error', rel, `writer 越枚举:${meta.writer}(合法值 author|ai)`)
867
1007
  }
1008
+ // AI 味统计(warn):只查有正文的章;指标与阈值见 checkChapterStyle
1009
+ if (chapterBody.trim() !== '') {
1010
+ for (const message of checkChapterStyle(chapterBody)) push('warn', rel, message)
1011
+ }
868
1012
  }
869
1013
 
870
1014
  // —— foreshadows.yml:状态枚举、id 去重、章节引用、状态-字段自洽 ——
871
- const foreshadows = readYamlArray(novelDir, 'foreshadows.yml')
1015
+ const foreshadows = readAccountYml('foreshadows.yml')
872
1016
  const seenIds = new Map()
873
1017
  foreshadows.forEach((f, i) => {
874
1018
  const label = typeof f.id === 'string' && f.id !== '' ? f.id : `第 ${i + 1} 条`
875
1019
  if (typeof f.status === 'string' && f.status !== '' && !FORESHADOW_STATUSES.includes(f.status)) {
876
1020
  push('error', 'foreshadows.yml', `伏笔 ${label} status 越枚举:${f.status}(合法值 ${FORESHADOW_STATUSES.join('|')})`)
877
1021
  }
1022
+ // M9:source 枚举(模板声明 plan|backfill)——缺省不报,面板快照侧同样把
1023
+ // 非 backfill 归一成 plan,两侧口径一致
1024
+ if (typeof f.source === 'string' && f.source !== '' && !FORESHADOW_SOURCES.includes(f.source)) {
1025
+ push('error', 'foreshadows.yml', `伏笔 ${label} source 越枚举:${f.source}(合法值 ${FORESHADOW_SOURCES.join('|')})`)
1026
+ }
878
1027
  if (typeof f.id === 'string' && f.id !== '') {
879
1028
  if (seenIds.has(f.id)) push('error', 'foreshadows.yml', `伏笔 id 重复:${f.id}(第 ${seenIds.get(f.id) + 1} 条与第 ${i + 1} 条)`)
880
1029
  else seenIds.set(f.id, i)
@@ -917,12 +1066,45 @@ export function checkProject(novelDir) {
917
1066
  ) {
918
1067
  push('error', 'foreshadows.yml', `伏笔 ${label} 的 resolved_chapter(${resolvedRef})早于 planted_chapter(${f.planted_chapter})`)
919
1068
  }
1069
+ // 清理纪律(skills/maoying-foreshadow-discipline/SKILL.md「清理纪律」:
1070
+ // planted 超过 30 章未推进应升级推进或弃用):planted 且埋设章可解析时,
1071
+ // 与全书已写章节的最大卷章序数求间隔章数,超过阈值给 warn。
1072
+ // abandoned/resolved/progressing 不查,埋设章解析不出(非规范写法)跳过。
1073
+ // 与上面 expected_resolve 的反向提醒语义不同(那边查排期目标章是否已写出,
1074
+ // 这边查时间跨度),同一条伏笔可能双报——允许。
1075
+ if (f.status === 'planted' && plantedOrd !== null && maxWritten !== null) {
1076
+ // 带卷引用按 vol*1000+num 线性化相减(卷内即章号差,跨卷差值上千、必然
1077
+ // 超阈值);裸章号无卷信息,与最大章号直接相减(同 nums 的存在性口径)。
1078
+ const gap =
1079
+ plantedOrd.vol === 0
1080
+ ? maxWritten.num - plantedOrd.num
1081
+ : maxWritten.vol * 1000 + maxWritten.num - (plantedOrd.vol * 1000 + plantedOrd.num)
1082
+ if (gap > FORESHADOW_STALE_CHAPTERS) {
1083
+ push('warn', 'foreshadows.yml', `伏笔 ${label} 埋设于 ${f.planted_chapter},已 ${gap} 章未推进(阈值 ${FORESHADOW_STALE_CHAPTERS} 章)——确认是否遗忘`)
1084
+ }
1085
+ }
1086
+ })
1087
+
1088
+ // —— volumes.yml:id 去重(M8:重复 id 会让同批章节被 filter 重复计入多个
1089
+ // 卷,卷字数之和 ≠ total;写法对齐 foreshadows id 去重)——
1090
+ const volumes = readAccountYml('volumes.yml')
1091
+ const seenVolIds = new Map()
1092
+ volumes.forEach((v, i) => {
1093
+ if (typeof v.id === 'string' && v.id !== '') {
1094
+ if (seenVolIds.has(v.id)) {
1095
+ push('error', 'volumes.yml', `分卷 id 重复:${v.id}(第 ${seenVolIds.get(v.id) + 1} 条与第 ${i + 1} 条)——同 id 卷会把章节重复计入`)
1096
+ } else {
1097
+ seenVolIds.set(v.id, i)
1098
+ }
1099
+ }
920
1100
  })
921
1101
 
922
1102
  // —— timeline.yml:年份可解析、不倒挂(warn:故事内纪年本就允许花写)——
923
- const timeline = readYamlArray(novelDir, 'timeline.yml')
1103
+ const timeline = readAccountYml('timeline.yml')
924
1104
  let prevYear = null
925
1105
  timeline.forEach((t, i) => {
1106
+ // M9:chapter 指向已写正文;放在年份解析之前,免得花写年份把引用校验跳过
1107
+ refCheck('timeline.yml', `第 ${i + 1} 条时间线`, 'chapter', t.chapter)
926
1108
  const parsed = parseTimelineYear(t.year)
927
1109
  if (parsed === null) {
928
1110
  push('warn', 'timeline.yml', `第 ${i + 1} 条 year 无法解析为数字:${t.year}`)
@@ -934,14 +1116,34 @@ export function checkProject(novelDir) {
934
1116
  prevYear = parsed
935
1117
  })
936
1118
 
1119
+ // —— memories.yml:category 枚举 + source_chapter 引用(M9 补对称校验,
1120
+ // 枚举以 templates/novel/memories.yml 声明为准)——
1121
+ readAccountYml('memories.yml').forEach((m, i) => {
1122
+ if (typeof m.category === 'string' && m.category !== '' && !MEMORY_CATEGORIES.includes(m.category)) {
1123
+ push('error', 'memories.yml', `第 ${i + 1} 条记忆 category 越枚举:${m.category}(合法值 ${MEMORY_CATEGORIES.join('|')})`)
1124
+ }
1125
+ refCheck('memories.yml', `第 ${i + 1} 条记忆`, 'source_chapter', m.source_chapter)
1126
+ })
1127
+
1128
+ // —— clue-board.yml:kind 枚举 + related_chapter 引用(M9 补对称校验,
1129
+ // 枚举以 templates/novel/clue-board.yml 声明为准)——
1130
+ readAccountYml('clue-board.yml').forEach((c, i) => {
1131
+ if (typeof c.kind === 'string' && c.kind !== '' && !CLUE_KINDS.includes(c.kind)) {
1132
+ push('error', 'clue-board.yml', `第 ${i + 1} 条线索 kind 越枚举:${c.kind}(合法值 ${CLUE_KINDS.join('|')})`)
1133
+ }
1134
+ refCheck('clue-board.yml', `第 ${i + 1} 条线索`, 'related_chapter', c.related_chapter)
1135
+ })
1136
+
937
1137
  // —— relations.yml:a/b 角色有卡(warn:可能是已死亡/未建档角色,故不升 error)——
938
1138
  const cards = collectCharacterCards(novelDir)
939
1139
  const knownRoles = new Set(cards.map((c) => c.slug))
940
1140
  for (const card of cards) {
941
1141
  if (typeof card.meta.name === 'string' && card.meta.name !== '') knownRoles.add(card.meta.name)
942
1142
  }
943
- const relations = readYamlArray(novelDir, 'relations.yml')
1143
+ const relations = readAccountYml('relations.yml')
944
1144
  relations.forEach((r, i) => {
1145
+ // M9:started/ended 指向已写正文(ended 空串是「未结束」,refCheck 视作未填跳过)
1146
+ for (const k of ['started', 'ended']) refCheck('relations.yml', `第 ${i + 1} 条关系`, k, r[k])
945
1147
  for (const side of ['a', 'b']) {
946
1148
  const value = typeof r[side] === 'string' ? r[side].trim() : ''
947
1149
  if (value !== '' && !knownRoles.has(value)) {
@@ -950,6 +1152,17 @@ export function checkProject(novelDir) {
950
1152
  }
951
1153
  })
952
1154
 
1155
+ // —— outlines/*.md:章纲缺「钩子」小节(warn:章纲本身可选,写了就须带钩子节)——
1156
+ // 章纲纯 markdown 无 frontmatter;「钩子」或「章尾钩子」须独立成节写明
1157
+ // (#{1,3} 级标题行,见 OUTLINE_HOOK_RE)。空 outlines/ 目录(或缺目录)不报。
1158
+ const outlinesDir = path.join(novelDir, OUTLINES_DIR)
1159
+ for (const file of listMdFiles(outlinesDir)) {
1160
+ const text = readTextOrNull(path.join(outlinesDir, file))
1161
+ if (text !== null && !OUTLINE_HOOK_RE.test(text)) {
1162
+ push('warn', outlineFile(file.slice(0, -3)), '章纲缺「钩子」小节(## 钩子 或 ## 章尾钩子)——章尾钩子须独立成节写明')
1163
+ }
1164
+ }
1165
+
953
1166
  // —— characters/*.md:name 缺失是硬伤;重复键给 warn ——
954
1167
  for (const card of cards) {
955
1168
  if (typeof card.meta.name !== 'string' || card.meta.name.trim() === '') {
@@ -988,12 +1201,14 @@ export function checkProject(novelDir) {
988
1201
  if (!hasWorldLore) {
989
1202
  push('error', 'world/', 'phase 已过 setting 但 world/ 与 science/ 均无条目(setting → outline 判据:世界观核心条目须落盘)')
990
1203
  }
991
- if (readYamlArray(novelDir, 'volumes.yml').length === 0) {
1204
+ // 复用上方已读的 volumes / relations(M8/H2 起 readAccountYml 带告警采集,
1205
+ // 这里重复读会导致同一份文件告警计两次)
1206
+ if (volumes.length === 0) {
992
1207
  push('error', 'volumes.yml', 'phase 已过 setting 但 volumes.yml 为空或缺失(setting → outline 判据:分卷规划须在案)')
993
1208
  }
994
1209
  // 关系网判据(六期补):两张及以上角色卡时 relations.yml 不得为空——
995
1210
  // 空关系网意味着设定阶段漏登关系,关系图与大纲师的戏剧权重都无从谈起。
996
- if (cards.length >= 2 && readYamlArray(novelDir, 'relations.yml').length === 0) {
1211
+ if (cards.length >= 2 && relations.length === 0) {
997
1212
  push('error', 'relations.yml', 'phase 已过 setting 但 relations.yml 为空(setting → outline 判据:两张及以上角色卡时关系网须登记)')
998
1213
  }
999
1214
  }