@cloud411716/fancy-webnovel 1.0.46 → 2.2.0

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.
Files changed (134) hide show
  1. package/client.js +183 -0
  2. package/cordis.patch.yml +0 -14
  3. package/index.js +21 -37
  4. package/lib/chinese-num.js +111 -0
  5. package/lib/deployed-state.js +141 -0
  6. package/lib/error-codes.js +104 -0
  7. package/lib/infra.js +569 -0
  8. package/lib/llm-wiki.js +109 -0
  9. package/lib/safe-rm.js +94 -0
  10. package/lib/scraper-utils.js +165 -0
  11. package/package.json +37 -5
  12. package/plugins/fancy-analyze/command-input.js +65 -0
  13. package/plugins/fancy-analyze/index.js +973 -0
  14. package/plugins/fancy-analyze/schema-analyze.md +379 -0
  15. package/plugins/fancy-analyze/schema-write.md +178 -0
  16. package/plugins/fancy-analyze/scripts/split_source.js +292 -0
  17. package/plugins/fancy-analyze/summary-progress.js +431 -0
  18. package/plugins/fancy-analyze/templates.js +881 -0
  19. package/plugins/fancy-bootstrap/index.js +168 -0
  20. package/plugins/fancy-bootstrap/scripts/init.js +143 -0
  21. package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
  22. package/plugins/fancy-bootstrap/templates.js +54 -0
  23. package/plugins/fancy-import/import-core.js +1118 -0
  24. package/plugins/fancy-import/import-driver.js +581 -0
  25. package/plugins/fancy-import/index.js +730 -0
  26. package/plugins/fancy-import/prompts/01-import-prompt.md +106 -0
  27. package/plugins/fancy-import/templates.js +49 -0
  28. package/plugins/fancy-scan/index.js +319 -0
  29. package/plugins/fancy-scan/scripts/run-scan.js +520 -0
  30. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +794 -0
  31. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +339 -0
  32. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +611 -0
  33. package/plugins/fancy-scan/templates.js +295 -0
  34. package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
  35. package/plugins/fancy-story-builder/deslop-driver.js +821 -0
  36. package/plugins/fancy-story-builder/flesh-output.js +155 -0
  37. package/plugins/fancy-story-builder/index.js +3152 -0
  38. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +553 -0
  39. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +540 -0
  40. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +504 -0
  41. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +188 -0
  42. package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
  43. package/plugins/fancy-story-builder/purpose-template.md +107 -0
  44. package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
  45. package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -0
  46. package/plugins/fancy-story-builder/references/glossary.md +585 -0
  47. package/plugins/fancy-story-builder/references/index.md +490 -0
  48. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
  49. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
  50. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
  51. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
  52. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
  53. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
  54. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
  55. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
  56. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
  57. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
  58. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
  59. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
  60. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
  61. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
  62. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
  63. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
  64. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
  65. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
  66. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +163 -0
  67. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
  68. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +239 -0
  69. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
  70. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +181 -0
  71. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
  72. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +177 -0
  73. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
  74. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +208 -0
  75. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
  76. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
  77. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
  78. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
  79. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +142 -0
  80. package/plugins/fancy-story-builder/rhythm-output.js +140 -0
  81. package/plugins/fancy-story-builder/schema-setting.md +324 -0
  82. package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
  83. package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
  84. package/plugins/fancy-story-builder/scripts/content-quality.js +413 -0
  85. package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
  86. package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
  87. package/plugins/fancy-story-builder/scripts/flesh-validate.js +1189 -0
  88. package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
  89. package/plugins/fancy-story-builder/scripts/graph-utils.js +1385 -0
  90. package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
  91. package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
  92. package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
  93. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1216 -0
  94. package/plugins/fancy-story-builder/scripts/semantic-lint.js +377 -0
  95. package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
  96. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1832 -0
  97. package/plugins/fancy-story-builder/scripts/stage-batches.js +1371 -0
  98. package/plugins/fancy-story-builder/scripts/stage-contract.js +417 -0
  99. package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
  100. package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
  101. package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
  102. package/plugins/fancy-story-builder/skeleton-output.js +199 -0
  103. package/plugins/fancy-story-builder/stage-driver.js +1049 -0
  104. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +204 -0
  105. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/347/273/206/347/272/262/346/250/241/346/235/277.json +34 -0
  106. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/350/247/204/345/210/222/346/250/241/346/235/277.json +24 -0
  107. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//350/212/202/345/245/217/345/217/202/346/225/260/346/250/241/346/235/277.json +27 -0
  108. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +66 -0
  109. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +76 -0
  110. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +72 -0
  111. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +37 -0
  112. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +53 -0
  113. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +40 -0
  114. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +79 -0
  115. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +132 -0
  116. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +133 -0
  117. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +160 -0
  118. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/261/202/347/272/247/346/250/241/346/235/277.json +65 -0
  119. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +134 -0
  120. package/plugins/fancy-story-builder/templates.js +1248 -0
  121. package/plugins/fancy-story-builder/wiki-references.js +204 -0
  122. package/plugins/fancy-story-builder/writing-driver.js +1061 -0
  123. package/plugins/fancy-topic/index.js +704 -0
  124. package/plugins/fancy-topic/scripts/select-topic.js +274 -0
  125. package/plugins/fancy-topic/templates.js +373 -0
  126. package/commands/bootstrap/index.js +0 -164
  127. package/commands/bootstrap/init-project.js +0 -121
  128. package/commands/scan/index.js +0 -283
  129. package/commands/scan/platforms/fanqie.js +0 -146
  130. package/commands/scan/platforms/jinjiang.js +0 -143
  131. package/commands/scan/platforms/qidian.js +0 -102
  132. package/commands/scan/platforms/qimao.js +0 -127
  133. package/commands/scan/scraper.js +0 -195
  134. package/infra.js +0 -160
@@ -0,0 +1,1216 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 节奏阶段校验脚本 v1.2
4
+ * 源码中零反引号,避免 markdown 渲染问题。
5
+ *
6
+ * v1.2 变更(修复批次 P2-6):
7
+ * - P2-6:单文件与全量模式都强制要求 --upstream 至少包含两个目录
8
+ * (骨架 + 血肉)。原版只检查 length === 0,只提供骨架目录也能跑,
9
+ * 与 03-rhythm-prompt.md 的"必须同时指定骨架和血肉两个目录"不一致。
10
+ * 现在 length < 2 直接 status:2 + UPSTREAM_MISSING。
11
+ *
12
+ * 保留 v1.1 的 P2-4 修复:
13
+ * - 章节号字段清单从 glossary 读
14
+ * - LARGE_FORESHADOW_TYPE / BOOK_ENDING_LEVEL 启动时校验 glossary
15
+ * - parseGlossaryFromMd import
16
+ * - act 有效性检查(RHYTHM_ACT_INVALID)
17
+ * - keyNodes 空时 RHYTHM_COVERAGE_SKIPPED warning
18
+ *
19
+ * 用法:
20
+ * 单文件:node rhythm-validate.js --input=<json> --template=<模板> --file=<类型> --upstream=<骨架目录>,<血肉目录>
21
+ * 全量: node rhythm-validate.js --stage-dir=<节奏目录> --template-dir=<模板/节奏> --upstream=<骨架目录>,<血肉目录> --all
22
+ * 退出码:0=通过 1=内容错误 2=脚本异常
23
+ */
24
+
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { SimpleGraph, getIsolatedNodes, parseGlossaryChecked, mdEscape, renderObject, renderArray, renderRhythmByFileType, readStageGate, safeCheck } from './graph-utils.js';
29
+
30
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
+
32
+ // ============================================================
33
+ // 业务常量(依赖 glossary 的枚举值)
34
+ // ============================================================
35
+ const LARGE_FORESHADOW_TYPE = '大伏笔';
36
+ const BOOK_ENDING_LEVEL = '全书级';
37
+
38
+ // P2-6:节奏阶段必须双上游
39
+ const MIN_UPSTREAM_COUNT = 2;
40
+
41
+ // ============================================================
42
+ // 参数解析
43
+ // ============================================================
44
+ function parseArgs() {
45
+ const args = process.argv.slice(2);
46
+ const result = { upstream: [] };
47
+ for (const arg of args) {
48
+ const m = arg.match(/^--([\w-]+)=(.+)$/);
49
+ if (m) {
50
+ if (m[1] === 'upstream') {
51
+ result.upstream.push(...m[2].split(',').map(s => s.trim()).filter(Boolean));
52
+ } else {
53
+ result[m[1]] = m[2];
54
+ }
55
+ } else if (arg === '--all') {
56
+ result.all = true;
57
+ }
58
+ }
59
+ return result;
60
+ }
61
+
62
+ // ============================================================
63
+ // IO
64
+ // ============================================================
65
+ function readJson(p) {
66
+ if (!fs.existsSync(p)) return { error: '文件不存在: ' + p };
67
+ try {
68
+ return { data: JSON.parse(fs.readFileSync(p, 'utf-8')) };
69
+ } catch (err) {
70
+ return { error: 'JSON 解析失败: ' + err.message };
71
+ }
72
+ }
73
+
74
+ function readText(p) {
75
+ if (!fs.existsSync(p)) return { error: '文件不存在: ' + p };
76
+ try {
77
+ return { data: fs.readFileSync(p, 'utf-8') };
78
+ } catch (err) {
79
+ return { error: '读取失败: ' + err.message };
80
+ }
81
+ }
82
+
83
+ // ============================================================
84
+ // glossary / formulas 加载
85
+ // ============================================================
86
+ function loadGlossary() {
87
+ const r = readText(path.join(__dirname, 'glossary.md'));
88
+ if (r.error) return { error: 'glossary.md 读取失败: ' + r.error };
89
+ return parseGlossaryChecked(r.data);
90
+ }
91
+
92
+ function loadFormulas() {
93
+ const r = readJson(path.join(__dirname, 'formulas-rhythm.json'));
94
+ if (r.error) return { error: 'formulas-rhythm.json 读取失败: ' + r.error };
95
+ return { constraints: r.data.constraints || {} };
96
+ }
97
+
98
+ // ============================================================
99
+ // 启动时校验业务常量是否仍在 glossary 中
100
+ // ============================================================
101
+ function assertBusinessConstants(gl, warnings) {
102
+ const check = (enumName, value) => {
103
+ const values = gl.enums[enumName];
104
+ if (!values) {
105
+ warnings.push({
106
+ code: 'ENUM_MISSING',
107
+ path: 'glossary.' + enumName,
108
+ message: 'glossary 缺少枚举 ' + enumName + ',coverage 检查可能失效',
109
+ hint: null
110
+ });
111
+ return;
112
+ }
113
+ if (!values.includes(value)) {
114
+ warnings.push({
115
+ code: 'ENUM_VALUE_MISSING',
116
+ path: 'glossary.' + enumName,
117
+ message: 'glossary.' + enumName + ' 中不含值 "' + value + '",coverage 检查可能失效',
118
+ hint: '如 glossary 已改名,请同步更新 rhythm-validate.js 的 LARGE_FORESHADOW_TYPE / BOOK_ENDING_LEVEL 常量'
119
+ });
120
+ }
121
+ };
122
+ check('foreshadow_type', LARGE_FORESHADOW_TYPE);
123
+ check('ending_level', BOOK_ENDING_LEVEL);
124
+ }
125
+
126
+ // ============================================================
127
+ // 模板校验
128
+ // ============================================================
129
+ function validateNode(data, spec, p, enums, errors) {
130
+ if (typeof spec === 'string') spec = { type: spec };
131
+ if (spec.required && (data === undefined || data === null || data === '')) {
132
+ errors.push({ code: 'REQUIRED', path: p, message: '必填字段不得为空', hint: null });
133
+ return;
134
+ }
135
+ if (data === undefined || data === null) return;
136
+
137
+ const actualType = Array.isArray(data) ? 'array' : typeof data;
138
+ if (spec.type === 'array' && !Array.isArray(data)) {
139
+ errors.push({ code: 'TYPE', path: p, message: '应为 array,实际为 ' + actualType, hint: null });
140
+ } else if (spec.type === 'object' && (typeof data !== 'object' || Array.isArray(data))) {
141
+ errors.push({ code: 'TYPE', path: p, message: '应为 object,实际为 ' + actualType, hint: null });
142
+ } else if (spec.type === 'string' && typeof data !== 'string') {
143
+ errors.push({ code: 'TYPE', path: p, message: '应为 string,实际为 ' + actualType, hint: null });
144
+ } else if (spec.type === 'number' && typeof data !== 'number') {
145
+ errors.push({ code: 'TYPE', path: p, message: '应为 number,实际为 ' + actualType, hint: null });
146
+ } else if (spec.type === 'boolean' && typeof data !== 'boolean') {
147
+ errors.push({ code: 'TYPE', path: p, message: '应为 boolean,实际为 ' + actualType, hint: null });
148
+ }
149
+
150
+ if (spec.pattern && typeof data === 'string') {
151
+ try {
152
+ if (!new RegExp(spec.pattern).test(data)) {
153
+ errors.push({ code: 'PATTERN', path: p, message: '不匹配正则 ' + spec.pattern, hint: null });
154
+ }
155
+ } catch (e) {}
156
+ }
157
+
158
+ if (spec.enumRef) {
159
+ const allowed = enums[spec.enumRef];
160
+ if (!allowed) {
161
+ errors.push({ code: 'ENUM_MISSING', path: p, message: '枚举引用 ' + spec.enumRef + ' 未在 glossary 中定义', hint: null });
162
+ } else if (!allowed.includes(data)) {
163
+ errors.push({ code: 'ENUM', path: p, message: '值 "' + data + '" 不在允许的枚举范围内', hint: allowed.join(' / ') });
164
+ }
165
+ }
166
+
167
+ if (spec.properties && typeof data === 'object' && !Array.isArray(data)) {
168
+ const allowed = new Set(Object.keys(spec.properties));
169
+ for (const key of Object.keys(data)) {
170
+ if (!allowed.has(key)) {
171
+ errors.push({
172
+ code: 'EXTRA_FIELD',
173
+ path: p ? (p + '.' + key) : key,
174
+ message: '字段 "' + key + '" 不在模板中',
175
+ hint: '数据文件不得出现模板 properties 之外的字段'
176
+ });
177
+ }
178
+ }
179
+ for (const [k, child] of Object.entries(spec.properties)) {
180
+ validateNode(data[k], child, p ? (p + '.' + k) : k, enums, errors);
181
+ }
182
+ }
183
+
184
+ if (spec.type === 'array' && spec.items && Array.isArray(data)) {
185
+ data.forEach((item, i) => validateNode(item, spec.items, p + '[' + i + ']', enums, errors));
186
+ }
187
+ }
188
+
189
+ // ============================================================
190
+ // 字符集 / 占位符
191
+ // ============================================================
192
+ const KOREAN_RE = /[\uAC00-\uD7A3\u1100-\u11FF\u3130-\u318F]/;
193
+ const KANA_RE = /[\u3040-\u309F\u30A0-\u30FF]/;
194
+ const PLACEHOLDER_RE = /\[[XYZW]\]|待确认|待定|N\/A/;
195
+
196
+ function charsetAndForbiddenCheck(data, errors) {
197
+ const walk = (node, p) => {
198
+ if (typeof node === 'string') {
199
+ if (KOREAN_RE.test(node)) {
200
+ errors.push({ code: 'CHARSET_KO', path: p, message: '含韩文: "' + node.slice(0, 30) + '"', hint: null });
201
+ }
202
+ if (KANA_RE.test(node)) {
203
+ errors.push({ code: 'CHARSET_JP', path: p, message: '含日文假名: "' + node.slice(0, 30) + '"', hint: null });
204
+ }
205
+ if (PLACEHOLDER_RE.test(node)) {
206
+ errors.push({ code: 'PLACEHOLDER', path: p, message: '含占位符: "' + node.slice(0, 30) + '"', hint: null });
207
+ }
208
+ return;
209
+ }
210
+ if (Array.isArray(node)) {
211
+ node.forEach((v, i) => walk(v, p + '[' + i + ']'));
212
+ return;
213
+ }
214
+ if (node && typeof node === 'object') {
215
+ for (const [k, v] of Object.entries(node)) walk(v, p ? (p + '.' + k) : k);
216
+ }
217
+ };
218
+ walk(data, '');
219
+ }
220
+
221
+ // ============================================================
222
+ // 时序映射条目的定位串
223
+ // ============================================================
224
+ //
225
+ // 为什么不用数组下标:
226
+ // 时序映射的 character_timeline 等数组合并后有几十条,
227
+ // 校验器报的 `character_timeline[3]` 里的 3 是**合并后的下标**——
228
+ // 模型不知道第 3 条是哪个角色,拿到错误定位不到,改的可能是别的条目。
229
+ // 业务键(char_id / conflict_id / …)模型一眼能对上,改得动。
230
+ //
231
+ // 归因层那边加了「key[标识]」形态来接这种 path(见 stage-batches.js)。
232
+ const TIMELINE_KEY_FIELD = {
233
+ character_timeline: 'char_id',
234
+ conflict_timeline: 'conflict_id',
235
+ foreshadow_timeline: 'fs_id',
236
+ climax_timeline: 'climax_id',
237
+ pleasure_timeline: 'pleasure_id',
238
+ hook_timeline: 'ch',
239
+ main_line_timeline: 'main_id',
240
+ branch_timeline: 'branch_id',
241
+ location_timeline: 'loc_id',
242
+ item_timeline: 'item_id',
243
+ ending_timeline: 'ending_id'
244
+ };
245
+
246
+ /**
247
+ * 条目定位串:`arrName[业务键]`。
248
+ * 条目缺业务键时退回下标(总比没有 path 好)。
249
+ *
250
+ * @param {string} arrName 数组名
251
+ * @param {Object} entry 该条目
252
+ * @param {number} index 该条目在数组里的下标
253
+ * @returns {string}
254
+ */
255
+ function itemPath(arrName, entry, index) {
256
+ const field = TIMELINE_KEY_FIELD[arrName];
257
+ const key = entry && field ? entry[field] : undefined;
258
+ if (key === undefined || key === null || key === '') return arrName + '[' + index + ']';
259
+ return arrName + '[' + key + ']';
260
+ }
261
+
262
+ // ============================================================
263
+ // 章节号越界检查
264
+ // ============================================================
265
+ function checkChapterRanges(data, T, chapterScalarFields, errors) {
266
+ const scalarSet = new Set(chapterScalarFields);
267
+ const walk = (node, p) => {
268
+ if (!node || typeof node !== 'object') return;
269
+ if (Array.isArray(node)) {
270
+ node.forEach((v, i) => walk(v, p + '[' + i + ']'));
271
+ return;
272
+ }
273
+ for (const [k, v] of Object.entries(node)) {
274
+ const curPath = p ? (p + '.' + k) : k;
275
+ if (scalarSet.has(k) && typeof v === 'number') {
276
+ if (v < 1 || v > T) {
277
+ errors.push({
278
+ code: 'RHYTHM_CHAP_RANGE',
279
+ path: curPath,
280
+ message: '章节号 ' + v + ' 越界 [1, ' + T + ']',
281
+ hint: null
282
+ });
283
+ }
284
+ }
285
+ walk(v, curPath);
286
+ }
287
+ };
288
+ walk(data, '');
289
+ }
290
+
291
+ // ============================================================
292
+ // 联合可行性约束
293
+ // ============================================================
294
+ function evalConstraint(expr, ctx) {
295
+ if (typeof expr !== 'string' || expr.length === 0) throw new Error('表达式为空');
296
+ // 与骨架阶段 evalExpr 一致的安全白名单:只允许算术/比较/逻辑运算符、属性访问,
297
+ // 以及约束里用到的中文标识符(骨架 / 节奏参数)。此前此处无白名单,
298
+ // 与 evalExpr 信任级别不对称。
299
+ if (!/^[0-9a-zA-Z_\u4e00-\u9fa5.\[\]()+\-*/%,\s=<>!&|?:]+$/.test(expr)) {
300
+ throw new Error('表达式含非法字符: ' + expr);
301
+ }
302
+ const keys = Object.keys(ctx);
303
+ const fn = new Function(...keys, '"use strict"; return (' + expr + ');');
304
+ return fn(...keys.map(k => ctx[k]));
305
+ }
306
+
307
+ function checkRhythmInfeasible(rhythmData, frameworkData, constraints, errors) {
308
+ const ctx = {
309
+ Math: Math,
310
+ 骨架: {
311
+ capacity: { target_chapters: frameworkData?.capacity?.target_chapters },
312
+ quota: {
313
+ branch_count: frameworkData?.quota?.branch_count,
314
+ volume_count: frameworkData?.quota?.volume_count
315
+ }
316
+ },
317
+ 节奏参数: {
318
+ branch_chapter_total: rhythmData.branch_chapter_total,
319
+ overlap_chapter_count: rhythmData.overlap_chapter_count,
320
+ main_progress_chapter_count: rhythmData.main_progress_chapter_count
321
+ }
322
+ };
323
+
324
+ for (const [name, spec] of Object.entries(constraints)) {
325
+ let result;
326
+ try {
327
+ result = evalConstraint(spec.expr, ctx);
328
+ } catch (e) {
329
+ errors.push({
330
+ code: 'RHYTHM_CONSTRAINT_EVAL',
331
+ path: '节奏参数.' + (spec.applies_to_field || 'root'),
332
+ message: '约束 ' + name + ' 求值失败:' + e.message,
333
+ hint: spec.expr
334
+ });
335
+ continue;
336
+ }
337
+ if (result !== true) {
338
+ errors.push({
339
+ code: 'RHYTHM_INFEASIBLE',
340
+ path: '节奏参数.' + (spec.applies_to_field || 'root'),
341
+ message: spec.message || ('约束 ' + name + ' 不满足'),
342
+ hint: spec.expr
343
+ });
344
+ }
345
+ }
346
+ }
347
+
348
+ // ============================================================
349
+ // 实体 id 集合构建
350
+ // ============================================================
351
+ function buildIdSets(skeleton, flesh) {
352
+ const idSets = {
353
+ char: new Set(), conflict: new Set(), faction: new Set(),
354
+ location: new Set(), item: new Set(), ending: new Set(),
355
+ foreshadow: new Set(), climax: new Set(), pleasure: new Set(),
356
+ main_line: new Set(), branch: new Set(), world_rule: new Set(), volume: new Set()
357
+ };
358
+ const sk = skeleton.entity || {};
359
+ const addAll = (arr, key) => { for (const x of arr || []) if (x.id) idSets[key].add(x.id); };
360
+ addAll(sk.characters, 'char');
361
+ addAll(sk.conflicts, 'conflict');
362
+ addAll(sk.factions, 'faction');
363
+ addAll(sk.locations, 'location');
364
+ addAll(sk.items, 'item');
365
+ addAll(sk.endings, 'ending');
366
+ addAll(sk.foreshadows, 'foreshadow');
367
+ addAll(sk.climaxes, 'climax');
368
+ addAll(sk.pleasures, 'pleasure');
369
+ addAll(sk.main_lines, 'main_line');
370
+ addAll(sk.branches, 'branch');
371
+ addAll(sk.world_rules, 'world_rule');
372
+ if (skeleton.hierarchy) addAll(skeleton.hierarchy.volumes, 'volume');
373
+ if (flesh.characters) addAll(flesh.characters.characters, 'char');
374
+ if (flesh.conflicts) addAll(flesh.conflicts.conflicts, 'conflict');
375
+ if (flesh.factions) addAll(flesh.factions.factions, 'faction');
376
+ if (flesh.locations) addAll(flesh.locations.locations, 'location');
377
+ if (flesh.endings) addAll(flesh.endings.endings, 'ending');
378
+ if (flesh.items) {
379
+ addAll(flesh.items.core_items, 'item');
380
+ addAll(flesh.items.branch_items, 'item');
381
+ addAll(flesh.items.symbolic_items, 'item');
382
+ }
383
+ if (flesh.plot) {
384
+ addAll(flesh.plot.main_lines, 'main_line');
385
+ addAll(flesh.plot.branches, 'branch');
386
+ }
387
+ return idSets;
388
+ }
389
+
390
+ // ============================================================
391
+ // 时序映射完整性
392
+ // ============================================================
393
+ function checkTimelineIntegrity(timeline, idSets, errors) {
394
+ const check = (id, set, label, p) => {
395
+ if (!id) return;
396
+ if (!set.has(id)) {
397
+ errors.push({ code: 'RHYTHM_REF_MISSING', path: p, message: '引用 ' + label + ' "' + id + '" 不存在于骨架/血肉', hint: null });
398
+ }
399
+ };
400
+
401
+ for (const [i, r] of (timeline.character_timeline || []).entries()) {
402
+ check(r.char_id, idSets.char, '角色', 'character_timeline[' + i + '].char_id');
403
+ if (typeof r.appear_start === 'number' && typeof r.appear_end === 'number' && r.appear_start > r.appear_end) {
404
+ errors.push({ code: 'RHYTHM_CHAP_ORDER', path: itemPath('character_timeline', r, i), message: 'appear_start(' + r.appear_start + ') > appear_end(' + r.appear_end + ')', hint: null });
405
+ }
406
+ if (typeof r.exit_chapter === 'number' && typeof r.appear_start === 'number' && typeof r.appear_end === 'number') {
407
+ if (r.exit_chapter < r.appear_start || r.exit_chapter > r.appear_end) {
408
+ errors.push({ code: 'RHYTHM_EXIT_IN_SPAN', path: itemPath('character_timeline', r, i) + '.exit_chapter', message: '退场章 ' + r.exit_chapter + ' 不在出场区间 [' + r.appear_start + ', ' + r.appear_end + ']', hint: null });
409
+ }
410
+ }
411
+ for (const [j, s] of (r.arc_stages || []).entries()) {
412
+ if (typeof s.chapter === 'number' && typeof r.appear_start === 'number') {
413
+ const maxCh = typeof r.exit_chapter === 'number' ? r.exit_chapter : r.appear_end;
414
+ if (s.chapter < r.appear_start || (typeof maxCh === 'number' && s.chapter > maxCh)) {
415
+ errors.push({ code: 'RHYTHM_ARC_OUT_OF_SPAN', path: itemPath('character_timeline', r, i) + '.arc_stages[' + j + '].chapter', message: '弧光章 ' + s.chapter + ' 不在出场区间 [' + r.appear_start + ', ' + maxCh + ']', hint: null });
416
+ }
417
+ }
418
+ }
419
+ const arcs = (r.arc_stages || []).map(s => s.chapter).filter(n => typeof n === 'number');
420
+ for (let k = 1; k < arcs.length; k++) {
421
+ if (arcs[k] < arcs[k - 1]) {
422
+ errors.push({ code: 'RHYTHM_TIMING', path: itemPath('character_timeline', r, i) + '.arc_stages', message: '弧光阶段章节顺序错乱:' + arcs.join(' → '), hint: null });
423
+ break;
424
+ }
425
+ }
426
+ }
427
+
428
+ for (const [i, r] of (timeline.conflict_timeline || []).entries()) {
429
+ check(r.conflict_id, idSets.conflict, '冲突', 'conflict_timeline[' + i + '].conflict_id');
430
+ const escs = (r.escalation_nodes || []).map(n => n.chapter).filter(n => typeof n === 'number');
431
+ for (let k = 1; k < escs.length; k++) {
432
+ if (escs[k] < escs[k - 1]) {
433
+ errors.push({ code: 'RHYTHM_TIMING', path: itemPath('conflict_timeline', r, i) + '.escalation_nodes', message: '冲突升级节点章节应递增:' + escs.join(' → '), hint: null });
434
+ break;
435
+ }
436
+ }
437
+ if (escs.length && typeof r.resolution_chapter === 'number') {
438
+ if (r.resolution_chapter < escs[escs.length - 1]) {
439
+ errors.push({ code: 'RHYTHM_TIMING', path: itemPath('conflict_timeline', r, i) + '.resolution_chapter', message: '收束章 ' + r.resolution_chapter + ' 早于最后升级节点 ' + escs[escs.length - 1], hint: null });
440
+ }
441
+ }
442
+ for (const [j, g] of (r.info_gaps || []).entries()) {
443
+ if (typeof g.created === 'number' && typeof g.resolved === 'number' && g.created >= g.resolved) {
444
+ errors.push({ code: 'RHYTHM_TIMING', path: itemPath('conflict_timeline', r, i) + '.info_gaps[' + j + ']', message: '信息差产生章 ' + g.created + ' 应早于消除章 ' + g.resolved, hint: null });
445
+ }
446
+ }
447
+ }
448
+
449
+ for (const [i, r] of (timeline.foreshadow_timeline || []).entries()) {
450
+ check(r.fs_id, idSets.foreshadow, '伏笔', 'foreshadow_timeline[' + i + '].fs_id');
451
+ if (typeof r.buried_chapter === 'number' && typeof r.recovered_chapter === 'number' && r.buried_chapter >= r.recovered_chapter) {
452
+ errors.push({ code: 'RHYTHM_TIMING', path: itemPath('foreshadow_timeline', r, i), message: '埋设章 ' + r.buried_chapter + ' 应早于回收章 ' + r.recovered_chapter, hint: null });
453
+ }
454
+ }
455
+
456
+ for (const [i, r] of (timeline.climax_timeline || []).entries()) {
457
+ check(r.climax_id, idSets.climax, '高潮', 'climax_timeline[' + i + '].climax_id');
458
+ }
459
+
460
+ for (const [i, r] of (timeline.pleasure_timeline || []).entries()) {
461
+ check(r.pleasure_id, idSets.pleasure, '爽点', 'pleasure_timeline[' + i + '].pleasure_id');
462
+ }
463
+
464
+ for (const [i, r] of (timeline.main_line_timeline || []).entries()) {
465
+ check(r.main_id, idSets.main_line, '主线', 'main_line_timeline[' + i + '].main_id');
466
+ }
467
+
468
+ for (const [i, r] of (timeline.branch_timeline || []).entries()) {
469
+ check(r.branch_id, idSets.branch, '支线', 'branch_timeline[' + i + '].branch_id');
470
+ if (typeof r.start === 'number' && typeof r.turning === 'number' && typeof r.resolution === 'number') {
471
+ if (!(r.start <= r.turning && r.turning <= r.resolution)) {
472
+ errors.push({ code: 'RHYTHM_TIMING', path: itemPath('branch_timeline', r, i), message: '支线章节顺序错乱:start=' + r.start + ', turning=' + r.turning + ', resolution=' + r.resolution, hint: null });
473
+ }
474
+ }
475
+ for (const [j, iw] of (r.interweaves || []).entries()) {
476
+ if (typeof iw.chapter === 'number' && typeof r.start === 'number' && typeof r.resolution === 'number') {
477
+ if (iw.chapter < r.start || iw.chapter > r.resolution) {
478
+ errors.push({ code: 'RHYTHM_TIMING', path: itemPath('branch_timeline', r, i) + '.interweaves[' + j + '].chapter', message: '交织点章 ' + iw.chapter + ' 不在支线区间 [' + r.start + ', ' + r.resolution + ']', hint: null });
479
+ }
480
+ }
481
+ }
482
+ }
483
+
484
+ for (const [i, r] of (timeline.location_timeline || []).entries()) {
485
+ check(r.loc_id, idSets.location, '地点', 'location_timeline[' + i + '].loc_id');
486
+ if (typeof r.appear_start === 'number' && typeof r.appear_end === 'number' && r.appear_start > r.appear_end) {
487
+ errors.push({ code: 'RHYTHM_CHAP_ORDER', path: itemPath('location_timeline', r, i), message: 'appear_start(' + r.appear_start + ') > appear_end(' + r.appear_end + ')', hint: null });
488
+ }
489
+ }
490
+
491
+ for (const [i, r] of (timeline.item_timeline || []).entries()) {
492
+ check(r.item_id, idSets.item, '道具', 'item_timeline[' + i + '].item_id');
493
+ if (typeof r.appear_start === 'number' && typeof r.appear_end === 'number' && r.appear_start > r.appear_end) {
494
+ errors.push({ code: 'RHYTHM_CHAP_ORDER', path: itemPath('item_timeline', r, i), message: 'appear_start(' + r.appear_start + ') > appear_end(' + r.appear_end + ')', hint: null });
495
+ }
496
+ }
497
+
498
+ for (const [i, r] of (timeline.ending_timeline || []).entries()) {
499
+ check(r.ending_id, idSets.ending, '结局', 'ending_timeline[' + i + '].ending_id');
500
+ for (const [j, fr] of (r.foreshadow_recovery || []).entries()) {
501
+ check(fr.fs_id, idSets.foreshadow, '伏笔', 'ending_timeline[' + i + '].foreshadow_recovery[' + j + '].fs_id');
502
+ }
503
+ }
504
+ }
505
+
506
+ // ============================================================
507
+ // 章节规划一致性
508
+ // ============================================================
509
+ function checkChapterPlan(chapters, T, hierarchy, volumeRatios, errors, warnings) {
510
+ const chs = (chapters || []).map(c => c.ch);
511
+ const seen = new Set();
512
+ const dup = [];
513
+ for (const ch of chs) {
514
+ if (seen.has(ch)) dup.push(ch);
515
+ seen.add(ch);
516
+ }
517
+ if (dup.length) {
518
+ errors.push({ code: 'RHYTHM_CHAPTER_DUP', path: 'chapters', message: '章节号重复:' + [...new Set(dup)].join(', '), hint: null });
519
+ }
520
+
521
+ const missing = [];
522
+ for (let ch = 1; ch <= T; ch++) {
523
+ if (!seen.has(ch)) missing.push(ch);
524
+ }
525
+ if (missing.length) {
526
+ errors.push({
527
+ code: 'RHYTHM_CHAPTER_MISSING',
528
+ path: 'chapters',
529
+ message: '缺少章节:' + missing.slice(0, 20).join(', ') + (missing.length > 20 ? ' 等 ' + missing.length + ' 章' : ''),
530
+ hint: null
531
+ });
532
+ }
533
+
534
+ const validVols = new Set((hierarchy?.volumes || []).map(v => v.id));
535
+ if (volumeRatios && volumeRatios.length) {
536
+ for (const x of volumeRatios) validVols.add(x.volume_id);
537
+ }
538
+ for (const [i, c] of (chapters || []).entries()) {
539
+ if (c.volume && validVols.size > 0 && !validVols.has(c.volume)) {
540
+ errors.push({ code: 'RHYTHM_REF_MISSING', path: 'chapters[' + i + '].volume', message: '卷 ' + c.volume + ' 不存在于骨架层级', hint: null });
541
+ }
542
+ }
543
+
544
+ const volActMap = new Map();
545
+ for (const a of (hierarchy?.acts || [])) {
546
+ if (a.volume && typeof a.act_index === 'number') {
547
+ if (!volActMap.has(a.volume)) volActMap.set(a.volume, new Set());
548
+ volActMap.get(a.volume).add(a.act_index);
549
+ }
550
+ }
551
+ for (const [i, c] of (chapters || []).entries()) {
552
+ if (!c.volume || typeof c.act !== 'number') continue;
553
+ const actsInVol = volActMap.get(c.volume);
554
+ if (actsInVol && actsInVol.size > 0 && !actsInVol.has(c.act)) {
555
+ errors.push({
556
+ code: 'RHYTHM_ACT_INVALID',
557
+ path: 'chapters[' + i + '].act',
558
+ message: '第 ' + c.ch + ' 章 act=' + c.act + ' 不在卷 ' + c.volume + ' 的幕集合中(该卷幕为 ' + [...actsInVol].sort((a,b)=>a-b).join(', ') + ')',
559
+ hint: null
560
+ });
561
+ }
562
+ }
563
+
564
+ if (volumeRatios && volumeRatios.length && T > 0) {
565
+ const byVolume = new Map();
566
+ for (const c of chapters || []) {
567
+ if (c.volume) byVolume.set(c.volume, (byVolume.get(c.volume) || 0) + 1);
568
+ }
569
+ for (const vr of volumeRatios) {
570
+ const expected = Math.round(T * vr.chapter_ratio);
571
+ const actual = byVolume.get(vr.volume_id) || 0;
572
+ if (Math.abs(actual - expected) > 1) {
573
+ warnings.push({
574
+ code: 'RHYTHM_VOLUME_DIST',
575
+ path: 'chapters',
576
+ message: '卷 ' + vr.volume_id + ' 期望约 ' + expected + ' 章,实际 ' + actual + ' 章',
577
+ hint: 'volume_ratio=' + vr.chapter_ratio + ',T=' + T
578
+ });
579
+ }
580
+ }
581
+ }
582
+ }
583
+
584
+ // ============================================================
585
+ // 布局吻合度
586
+ // ============================================================
587
+ function checkLayoutMatch(timeline, rhythmData, frameworkData, errors) {
588
+ const keyNodeChs = new Set();
589
+ for (const r of timeline.main_line_timeline || []) {
590
+ for (const n of r.key_nodes || []) {
591
+ if (typeof n.chapter === 'number') keyNodeChs.add(n.chapter);
592
+ }
593
+ }
594
+ if (typeof rhythmData.main_progress_chapter_count === 'number' && keyNodeChs.size !== rhythmData.main_progress_chapter_count) {
595
+ errors.push({
596
+ code: 'RHYTHM_NODES_COUNT',
597
+ path: 'main_line_timeline',
598
+ message: '主线 key_nodes 实际章数 ' + keyNodeChs.size + ' ≠ main_progress_chapter_count ' + rhythmData.main_progress_chapter_count,
599
+ hint: null
600
+ });
601
+ }
602
+
603
+ let actualBranchTotal = 0;
604
+ for (const r of timeline.branch_timeline || []) {
605
+ if (typeof r.start === 'number' && typeof r.resolution === 'number') {
606
+ actualBranchTotal += r.resolution - r.start + 1;
607
+ }
608
+ }
609
+ if (typeof rhythmData.branch_chapter_total === 'number' && actualBranchTotal !== rhythmData.branch_chapter_total) {
610
+ errors.push({
611
+ code: 'RHYTHM_BRANCH_TOTAL',
612
+ path: 'branch_timeline',
613
+ message: '支线实际章数之和 ' + actualBranchTotal + ' ≠ branch_chapter_total ' + rhythmData.branch_chapter_total,
614
+ hint: null
615
+ });
616
+ }
617
+
618
+ const branchOwner = new Map();
619
+ for (const r of timeline.branch_timeline || []) {
620
+ if (typeof r.start !== 'number' || typeof r.resolution !== 'number') continue;
621
+ for (let ch = r.start; ch <= r.resolution; ch++) {
622
+ if (branchOwner.has(ch)) {
623
+ errors.push({
624
+ code: 'RHYTHM_BRANCH_OVERLAP',
625
+ path: 'branch_timeline',
626
+ message: '第 ' + ch + ' 章同时属于支线 ' + branchOwner.get(ch) + ' 和 ' + r.branch_id,
627
+ hint: '支线章节不得重叠'
628
+ });
629
+ } else {
630
+ branchOwner.set(ch, r.branch_id);
631
+ }
632
+ }
633
+ }
634
+
635
+ const branchChs = new Set(branchOwner.keys());
636
+ const inter = [...branchChs].filter(ch => keyNodeChs.has(ch));
637
+ if (typeof rhythmData.overlap_chapter_count === 'number' && inter.length !== rhythmData.overlap_chapter_count) {
638
+ errors.push({
639
+ code: 'RHYTHM_OVERLAP_COUNT',
640
+ path: '节奏参数.overlap_chapter_count',
641
+ message: '实际重叠章数 ' + inter.length + '(支线章集 ∩ 主线 key_node 章集)≠ overlap_chapter_count ' + rhythmData.overlap_chapter_count,
642
+ hint: null
643
+ });
644
+ }
645
+
646
+ const dupChs = [];
647
+ const nodeChList = [];
648
+ for (const r of timeline.main_line_timeline || []) {
649
+ for (const n of r.key_nodes || []) {
650
+ if (typeof n.chapter === 'number') nodeChList.push(n.chapter);
651
+ }
652
+ }
653
+ const nodeSeen = new Set();
654
+ for (const ch of nodeChList) {
655
+ if (nodeSeen.has(ch)) dupChs.push(ch);
656
+ nodeSeen.add(ch);
657
+ }
658
+ if (dupChs.length) {
659
+ errors.push({ code: 'RHYTHM_NODE_DUP', path: 'main_line_timeline', message: '主线 key_node 章节重复:' + [...new Set(dupChs)].join(', '), hint: null });
660
+ }
661
+ }
662
+
663
+ // ============================================================
664
+ // 覆盖性
665
+ // ============================================================
666
+ function checkCoverage(timeline, skeleton, errors, warnings) {
667
+ const keyNodeChs = new Set();
668
+ for (const r of timeline.main_line_timeline || []) {
669
+ for (const n of r.key_nodes || []) {
670
+ if (typeof n.chapter === 'number') keyNodeChs.add(n.chapter);
671
+ }
672
+ }
673
+
674
+ if (keyNodeChs.size === 0) {
675
+ warnings.push({
676
+ code: 'RHYTHM_COVERAGE_SKIPPED',
677
+ path: 'main_line_timeline',
678
+ message: '主线 key_nodes 为空,结局覆盖检查跳过(RHYTHM_NODES_COUNT 会报 error)',
679
+ hint: null
680
+ });
681
+ return;
682
+ }
683
+
684
+ for (const r of timeline.ending_timeline || []) {
685
+ if (typeof r.chapter === 'number' && !keyNodeChs.has(r.chapter)) {
686
+ errors.push({
687
+ code: 'RHYTHM_COVERAGE',
688
+ path: 'ending_timeline.' + r.ending_id + '.chapter',
689
+ message: '结局 ' + r.ending_id + ' 的章节 ' + r.chapter + ' 不在主线 key_nodes 中',
690
+ hint: '结局章节必须是主线关键节点中的一章'
691
+ });
692
+ }
693
+ }
694
+
695
+ const largeFs = new Set();
696
+ for (const fsItem of (skeleton.entity?.foreshadows || [])) {
697
+ if (fsItem.type === LARGE_FORESHADOW_TYPE && fsItem.id) largeFs.add(fsItem.id);
698
+ }
699
+ if (largeFs.size === 0) return;
700
+
701
+ const bookEndingIds = new Set();
702
+ for (const e of (skeleton.entity?.endings || [])) {
703
+ if (e.level === BOOK_ENDING_LEVEL && e.id) bookEndingIds.add(e.id);
704
+ }
705
+ if (bookEndingIds.size === 0) return;
706
+
707
+ const recovered = new Set();
708
+ for (const r of timeline.ending_timeline || []) {
709
+ if (!bookEndingIds.has(r.ending_id)) continue;
710
+ for (const fr of r.foreshadow_recovery || []) {
711
+ if (fr.fs_id) recovered.add(fr.fs_id);
712
+ }
713
+ }
714
+ const missing = [...largeFs].filter(id => !recovered.has(id));
715
+ if (missing.length) {
716
+ errors.push({
717
+ code: 'RHYTHM_LARGE_FS_NOT_RECOVERED',
718
+ path: 'ending_timeline',
719
+ message: '全书级结局缺少 ' + missing.length + ' 个大伏笔的回收记录:' + missing.join(', '),
720
+ hint: '所有大伏笔必须在全书级结局的 foreshadow_recovery 中出现'
721
+ });
722
+ }
723
+ }
724
+
725
+ // ============================================================
726
+ // 钩子检查
727
+ // ============================================================
728
+ function checkHooks(timeline, rhythmData, frameworkData, T, outlines, errors, warnings) {
729
+ const hooks = timeline.hook_timeline || [];
730
+
731
+ const isMin = rhythmData.chapter_hook_rate?.is_minimum;
732
+ const rate = rhythmData.chapter_hook_rate?.rate;
733
+ if (isMin === true && typeof rate === 'number' && typeof T === 'number') {
734
+ const minHooks = Math.ceil(T * rate);
735
+ if (hooks.length < minHooks) {
736
+ errors.push({
737
+ code: 'RHYTHM_HOOK_MIN',
738
+ path: 'hook_timeline',
739
+ message: '钩子数 ' + hooks.length + ' < 框架下限 ceil(' + T + '×' + rate + ')=' + minHooks,
740
+ hint: null
741
+ });
742
+ }
743
+ }
744
+
745
+ // 高潮期豁免章:emotion_phase='释放' 且 hook_strength='强'
746
+ // 来自 章节细纲.json 的 outlines
747
+ const climaxExemptChapters = new Set();
748
+ if (Array.isArray(outlines)) {
749
+ for (const o of outlines) {
750
+ if (typeof o.ch === 'number' && o.emotion_phase === '释放' && o.hook_strength === '强') {
751
+ climaxExemptChapters.add(o.ch);
752
+ }
753
+ }
754
+ }
755
+
756
+ const sorted = [...hooks].sort((a, b) => a.ch - b.ch);
757
+ let consecutiveSame = 1;
758
+ for (let i = 1; i < sorted.length; i++) {
759
+ if (sorted[i].ch === sorted[i - 1].ch + 1 && sorted[i].hook_tail === sorted[i - 1].hook_tail) {
760
+ // 连续章都是高潮豁免章则跳过 RHYTHM_HOOK_DUP 错误
761
+ if (!climaxExemptChapters.has(sorted[i].ch) || !climaxExemptChapters.has(sorted[i - 1].ch)) {
762
+ consecutiveSame++;
763
+ } else {
764
+ consecutiveSame = 1; // 豁免章打断连续计数
765
+ continue;
766
+ }
767
+ } else {
768
+ consecutiveSame = 1;
769
+ }
770
+ if (consecutiveSame === 2) {
771
+ warnings.push({
772
+ code: 'RHYTHM_HOOK_DUP_WARN',
773
+ path: 'hook_timeline.' + sorted[i].ch,
774
+ message: '第 ' + sorted[i - 1].ch + '-' + sorted[i].ch + ' 章章尾钩子同为 "' + sorted[i].hook_tail + '"',
775
+ hint: '建议不同式;若高潮期允许同类,请在章节细纲中注明'
776
+ });
777
+ } else if (consecutiveSame >= 3) {
778
+ errors.push({
779
+ code: 'RHYTHM_HOOK_DUP',
780
+ path: 'hook_timeline.' + sorted[i].ch,
781
+ message: '连续 ' + consecutiveSame + ' 章章尾钩子同为 "' + sorted[i].hook_tail + '"',
782
+ hint: '连续 3 章以上同类钩子读者会疲劳'
783
+ });
784
+ }
785
+ }
786
+ }
787
+
788
+ // ============================================================
789
+ // 章节细纲字数检查
790
+ // ============================================================
791
+ function checkWordTarget(outlines, frameworkData, warnings) {
792
+ const avg = frameworkData?.capacity?.avg_words_per_chapter;
793
+ if (typeof avg !== 'number' || avg <= 0) return;
794
+ const tolLow = Math.round(avg * 0.8);
795
+ const tolHigh = Math.round(avg * 1.2);
796
+ for (const [i, o] of (outlines || []).entries()) {
797
+ if (typeof o.word_target === 'number' && (o.word_target < tolLow || o.word_target > tolHigh)) {
798
+ warnings.push({
799
+ code: 'RHYTHM_WORD_TARGET',
800
+ // outlines 只装重点章,下标与章号不成比例 → 用章号定位
801
+ path: 'outlines[ch=' + (o && o.ch !== undefined ? o.ch : i) + '].word_target',
802
+ message: '章节 ' + o.ch + ' 字数目标 ' + o.word_target + ' 偏离基准 ' + avg + ' 超过 ±20% [' + tolLow + ', ' + tolHigh + ']',
803
+ hint: null
804
+ });
805
+ }
806
+ }
807
+ }
808
+
809
+ // ============================================================
810
+ // 图:孤立章节检查
811
+ // ============================================================
812
+ function checkChapterGraph(timeline, T, errors) {
813
+ const g = new SimpleGraph();
814
+ for (let ch = 1; ch <= T; ch++) {
815
+ g.addNode('ch:' + ch, { type: 'chapter' });
816
+ }
817
+
818
+ const addEdge = (fromCh, rel) => {
819
+ const key = 'ch:' + fromCh;
820
+ if (g.hasNode(key)) {
821
+ g.addEdgeWithKey(key + '--' + rel, key, key, { relation: rel });
822
+ }
823
+ };
824
+
825
+ for (const r of timeline.character_timeline || []) {
826
+ if (typeof r.appear_start === 'number') addEdge(r.appear_start, 'character_appears');
827
+ if (typeof r.appear_end === 'number') addEdge(r.appear_end, 'character_appears');
828
+ for (const s of r.arc_stages || []) if (typeof s.chapter === 'number') addEdge(s.chapter, 'arc');
829
+ }
830
+ for (const r of timeline.foreshadow_timeline || []) {
831
+ if (typeof r.buried_chapter === 'number') addEdge(r.buried_chapter, 'fs_buried');
832
+ if (typeof r.recovered_chapter === 'number') addEdge(r.recovered_chapter, 'fs_recovered');
833
+ }
834
+ for (const r of timeline.conflict_timeline || []) {
835
+ for (const n of r.escalation_nodes || []) if (typeof n.chapter === 'number') addEdge(n.chapter, 'conflict_esc');
836
+ if (typeof r.resolution_chapter === 'number') addEdge(r.resolution_chapter, 'conflict_resolved');
837
+ }
838
+ for (const r of timeline.climax_timeline || []) if (typeof r.chapter === 'number') addEdge(r.chapter, 'climax');
839
+ for (const r of timeline.pleasure_timeline || []) if (typeof r.chapter === 'number') addEdge(r.chapter, 'pleasure');
840
+ for (const r of timeline.main_line_timeline || []) for (const n of r.key_nodes || []) if (typeof n.chapter === 'number') addEdge(n.chapter, 'main_node');
841
+ for (const r of timeline.branch_timeline || []) {
842
+ if (typeof r.start === 'number') addEdge(r.start, 'branch_start');
843
+ if (typeof r.resolution === 'number') addEdge(r.resolution, 'branch_end');
844
+ }
845
+ for (const r of timeline.ending_timeline || []) if (typeof r.chapter === 'number') addEdge(r.chapter, 'ending');
846
+ // 钩子章节也算引用(非重点章可只有钩子无其他引用)
847
+ for (const h of timeline.hook_timeline || []) {
848
+ if (typeof h.ch === 'number') addEdge(h.ch, 'hook');
849
+ }
850
+
851
+ const isolated = getIsolatedNodes(g, []);
852
+ for (const n of isolated) {
853
+ if (n.type === 'chapter') {
854
+ const ch = n.key.replace('ch:', '');
855
+ errors.push({
856
+ code: 'RHYTHM_CHAPTER_UNREF',
857
+ path: n.key,
858
+ message: '第 ' + ch + ' 章没有任何事件/角色/伏笔/钩子引用',
859
+ hint: null
860
+ });
861
+ }
862
+ }
863
+ }
864
+
865
+ // ============================================================
866
+ // derivations 检查
867
+ // ============================================================
868
+ function checkRhythmDerivations(rhythmData, constraints, errors, warnings) {
869
+ const der = rhythmData.derivations || {};
870
+ const constraintKeys = new Set(Object.keys(constraints));
871
+ const derKeys = new Set(Object.keys(der));
872
+
873
+ for (const k of constraintKeys) {
874
+ if (!derKeys.has(k)) {
875
+ warnings.push({
876
+ code: 'RHYTHM_DERIVATION_MISSING',
877
+ path: 'derivations.' + k,
878
+ message: '建议为约束 "' + k + '" 提供 note 说明',
879
+ hint: null
880
+ });
881
+ }
882
+ }
883
+
884
+ for (const k of derKeys) {
885
+ if (!constraintKeys.has(k)) {
886
+ warnings.push({
887
+ code: 'RHYTHM_DERIVATION_EXTRA',
888
+ path: 'derivations.' + k,
889
+ message: '多余的推导记录(formulas-rhythm.json 无约束 ' + k + ')',
890
+ hint: null
891
+ });
892
+ }
893
+ }
894
+ }
895
+
896
+ // ============================================================
897
+ // md 生成 + stage-status(结构化 markdown,零反引号)
898
+ // ============================================================
899
+ const BT = String.fromCharCode(96);
900
+ const BT3 = BT + BT + BT;
901
+
902
+ function jsonToMd(data, fileType) {
903
+ // rhythm 阶段:按 RHYTHM_DISPATCH 把顶层数组分别渲染为 H2 分组
904
+ const body = renderRhythmByFileType(data, fileType);
905
+ return '# ' + fileType + '\n\n> 自动生成于 ' + new Date().toISOString() + '\n\n' +
906
+ body + '\n---\n\n## 源数据\n\n' + BT3 + 'json\n' + JSON.stringify(data, null, 2) + '\n' + BT3 + '\n';
907
+ }
908
+
909
+ function regenerateMd(stageDir) {
910
+ if (!fs.existsSync(stageDir)) return;
911
+ for (const f of fs.readdirSync(stageDir)) {
912
+ // 排除 .stage-status.json:它是校验状态记账文件,不是业务数据
913
+ if (!f.endsWith('.json') || f.startsWith('.')) continue;
914
+ const jsonPath = path.join(stageDir, f);
915
+ const mdPath = jsonPath.replace(/\.json$/, '.md');
916
+ try {
917
+ const data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
918
+ const fileType = f.replace(/\.json$/, '');
919
+ fs.writeFileSync(mdPath, jsonToMd(data, fileType), 'utf-8');
920
+ } catch (e) {}
921
+ }
922
+ }
923
+
924
+ function updateStageStatus(stageDir, fileType, status) {
925
+ const statusPath = path.join(stageDir, '.stage-status.json');
926
+ let s = { stage: '节奏', last_check: null, files: {}, all_check: { status: 'PENDING', rounds: 0 } };
927
+ if (fs.existsSync(statusPath)) {
928
+ try { s = JSON.parse(fs.readFileSync(statusPath, 'utf-8')); } catch (e) {}
929
+ }
930
+ // 兜底:旧 status 文件可能缺 files / all_check 字段
931
+ s.stage = s.stage || '节奏';
932
+ s.files = s.files || {};
933
+ s.all_check = s.all_check || { status: 'PENDING', rounds: 0 };
934
+ s.last_check = new Date().toISOString();
935
+ if (fileType === 'ALL') {
936
+ s.all_check = { status: status === 0 ? 'PASSED' : 'FAILED', rounds: (s.all_check.rounds || 0) + 1 };
937
+ } else {
938
+ const prev = s.files[fileType] || { retries: 0 };
939
+ s.files[fileType] = { status: status === 0 ? 'PASSED' : 'FAILED', retries: (prev.retries || 0) + (status === 1 ? 1 : 0), last_check: new Date().toISOString() };
940
+ // 单文件一旦被改动/失败,之前的全量结论就不再可信(全量含跨文件检查:
941
+ // 配额一致性、图连通性、引用完整性等),必须作废,等重新跑 --all。
942
+ if (s.all_check && s.all_check.status === 'PASSED') {
943
+ s.all_check = {
944
+ status: 'STALE',
945
+ rounds: s.all_check.rounds || 0,
946
+ stale_reason: '文件 ' + fileType + ' 在 ' + s.last_check + ' 被单文件校验改动,全量结论已作废,请重新执行全量校验',
947
+ stale_at: s.last_check
948
+ };
949
+ }
950
+ }
951
+ try { fs.writeFileSync(statusPath, JSON.stringify(s, null, 2), 'utf-8'); } catch (e) {}
952
+ }
953
+
954
+ function emit(status, file, errors, warnings) {
955
+ const payload = { status: status, file: file, errors: errors, warnings: warnings };
956
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
957
+ process.exitCode = status;
958
+ // 返回载荷对象:调用方可能用返回值做守卫(如 `if (err) return err;`),
959
+ // 返回 undefined 会让守卫失效导致继续执行并二次输出。
960
+ return payload;
961
+ }
962
+
963
+ // ============================================================
964
+ // 上游加载
965
+ // ============================================================
966
+ function loadUpstream(upstreamDirs) {
967
+ const result = { framework: null, entity: null, hierarchy: null, flesh: {} };
968
+ const tryRead = (dir, name) => {
969
+ const r = readJson(path.join(dir, name));
970
+ return r.data || null;
971
+ };
972
+ for (const dir of upstreamDirs) {
973
+ if (!result.framework) result.framework = tryRead(dir, '框架.json');
974
+ if (!result.entity) result.entity = tryRead(dir, '实体.json');
975
+ if (!result.hierarchy) result.hierarchy = tryRead(dir, '层级.json');
976
+ if (!result.flesh.characters) result.flesh.characters = tryRead(dir, '角色.json');
977
+ if (!result.flesh.conflicts) result.flesh.conflicts = tryRead(dir, '冲突.json');
978
+ if (!result.flesh.factions) result.flesh.factions = tryRead(dir, '势力.json');
979
+ if (!result.flesh.locations) result.flesh.locations = tryRead(dir, '地点.json');
980
+ if (!result.flesh.endings) result.flesh.endings = tryRead(dir, '结局.json');
981
+ if (!result.flesh.items) result.flesh.items = tryRead(dir, '关键道具.json');
982
+ if (!result.flesh.plot) result.flesh.plot = tryRead(dir, '剧情.json');
983
+ }
984
+ return result;
985
+ }
986
+
987
+ function checkUpstreamReady(skeleton) {
988
+ if (!skeleton.framework) return '骨架 框架.json 读不到';
989
+ if (!skeleton.entity) return '骨架 实体.json 读不到';
990
+ if (typeof skeleton.framework.capacity?.target_chapters !== 'number') return '骨架 框架.json 缺 capacity.target_chapters';
991
+ if (skeleton.framework.capacity.target_chapters <= 0) return '骨架 框架.json capacity.target_chapters 必须 > 0';
992
+ return null;
993
+ }
994
+
995
+ // 阶段门禁:骨架与血肉两个上游都必须已通过全量校验
996
+ // (此前这个判定只写在提示词里,脚本从不读上游状态)
997
+ function checkUpstreamGates(upstreamDirs) {
998
+ const dirs = upstreamDirs || [];
999
+ const skeletonDir = dirs.find(d => fs.existsSync(path.join(d, '实体.json')));
1000
+ const fleshDir = dirs.find(d => fs.existsSync(path.join(d, '角色.json')));
1001
+ for (const pair of [['骨架', skeletonDir], ['血肉', fleshDir]]) {
1002
+ const name = pair[0], dir = pair[1];
1003
+ if (!dir) return name + ' 上游目录未找到(--upstream 需同时包含骨架目录与血肉目录)';
1004
+ const g = readStageGate(dir);
1005
+ if (!g.ok) return name + ':' + g.reason;
1006
+ }
1007
+ return null;
1008
+ }
1009
+
1010
+ // P2-6:统一的上游数量检查
1011
+ function checkUpstreamCount(upstream, file) {
1012
+ if (!upstream || upstream.length < MIN_UPSTREAM_COUNT) {
1013
+ return emit(2, file, [{
1014
+ code: 'UPSTREAM_MISSING',
1015
+ path: '',
1016
+ message: '节奏阶段必须提供 --upstream=<骨架目录>,<血肉目录>(至少 ' + MIN_UPSTREAM_COUNT + ' 个目录)',
1017
+ hint: '实际收到 ' + (upstream ? upstream.length : 0) + ' 个上游目录'
1018
+ }], []);
1019
+ }
1020
+ return null;
1021
+ }
1022
+
1023
+ // ============================================================
1024
+ // 单文件模式
1025
+ // ============================================================
1026
+ function validateSingle(args) {
1027
+ const input = args.input;
1028
+ const template = args.template;
1029
+ const file = args.file;
1030
+
1031
+ // P2-6
1032
+ const upErr = checkUpstreamCount(args.upstream, file);
1033
+ if (upErr) return upErr;
1034
+
1035
+ const gateErr = checkUpstreamGates(args.upstream);
1036
+ if (gateErr) {
1037
+ return emit(2, file, [{ code: 'UPSTREAM_NOT_PASSED', path: '', message: gateErr, hint: '请先对上游阶段执行全量校验并通过' }], []);
1038
+ }
1039
+
1040
+ const errors = [];
1041
+ const warnings = [];
1042
+
1043
+ const gl = loadGlossary();
1044
+ if (gl.error) return emit(2, file, [{ code: 'GLOSSARY_LOAD', path: '', message: gl.error, hint: null }], []);
1045
+ assertBusinessConstants(gl, warnings);
1046
+
1047
+ const fm = loadFormulas();
1048
+ if (fm.error) return emit(2, file, [{ code: 'FORMULAS_LOAD', path: '', message: fm.error, hint: null }], []);
1049
+
1050
+ const inputRes = readJson(input);
1051
+ if (inputRes.error) return emit(2, file, [{ code: 'READ', path: input, message: inputRes.error, hint: null }], []);
1052
+
1053
+ const tplRes = readJson(template);
1054
+ if (tplRes.error) return emit(2, file, [{ code: 'TEMPLATE', path: template, message: tplRes.error, hint: null }], []);
1055
+
1056
+ const skeleton = loadUpstream(args.upstream);
1057
+ const notReady = checkUpstreamReady(skeleton);
1058
+ if (notReady) return emit(2, file, [{ code: 'UPSTREAM_NOT_READY', path: '', message: notReady, hint: null }], []);
1059
+
1060
+ const T = skeleton.framework.capacity.target_chapters;
1061
+ const idSets = buildIdSets(skeleton, skeleton.flesh);
1062
+ const data = inputRes.data;
1063
+
1064
+ safeCheck('validateNode', () => validateNode(data, tplRes.data.structure || {}, '', gl.enums, errors), errors);
1065
+ safeCheck('charsetAndForbiddenCheck', () => charsetAndForbiddenCheck(data, errors), errors);
1066
+ safeCheck('checkChapterRanges', () => checkChapterRanges(data, T, gl.chapterScalarFields, errors), errors);
1067
+
1068
+ if (file === '章节规划') {
1069
+ safeCheck('checkChapterPlan', () => checkChapterPlan(data.chapters, T, skeleton.hierarchy, skeleton.framework.volume_ratio, errors, warnings), errors);
1070
+ } else if (file === '时序映射') {
1071
+ safeCheck('checkTimelineIntegrity', () => checkTimelineIntegrity(data, idSets, errors), errors);
1072
+ } else if (file === '节奏参数') {
1073
+ safeCheck('checkRhythmInfeasible', () => checkRhythmInfeasible(data, skeleton.framework, fm.constraints, errors), errors);
1074
+ safeCheck('checkRhythmDerivations', () => checkRhythmDerivations(data, fm.constraints, errors, warnings), errors);
1075
+ } else if (file === '章节细纲') {
1076
+ safeCheck('checkWordTarget', () => checkWordTarget(data.outlines, skeleton.framework, warnings), errors);
1077
+ }
1078
+
1079
+ const stageDir = path.dirname(input);
1080
+ updateStageStatus(stageDir, file, errors.length === 0 ? 0 : 1);
1081
+ regenerateMd(stageDir);
1082
+
1083
+ // 单文件模式不做跨文件检查,必须在全部文件单检完成后跑一次 --all 才算通过
1084
+ if (errors.length === 0) {
1085
+ warnings.push({
1086
+ code: 'NEED_ALL_CHECK',
1087
+ path: '',
1088
+ message: '本文件单检通过;跨文件一致性检查在 --all 模式下进行,全部文件单检完成后仍需执行一次全量校验(--all)',
1089
+ hint: null
1090
+ });
1091
+ }
1092
+
1093
+ emit(errors.length === 0 ? 0 : 1, file, errors, warnings);
1094
+ }
1095
+
1096
+ // ============================================================
1097
+ // 全量模式
1098
+ // ============================================================
1099
+ function validateAll(args) {
1100
+ const stageDir = args['stage-dir'];
1101
+ const templateDir = args['template-dir'];
1102
+
1103
+ // P2-6
1104
+ const upErr = checkUpstreamCount(args.upstream, 'ALL');
1105
+ if (upErr) return upErr;
1106
+
1107
+ const gateErr = checkUpstreamGates(args.upstream);
1108
+ if (gateErr) {
1109
+ return emit(2, 'ALL', [{ code: 'UPSTREAM_NOT_PASSED', path: '', message: gateErr, hint: '请先对上游阶段执行全量校验并通过' }], []);
1110
+ }
1111
+
1112
+ const errors = [];
1113
+ const warnings = [];
1114
+
1115
+ const gl = loadGlossary();
1116
+ if (gl.error) return emit(2, 'ALL', [{ code: 'GLOSSARY_LOAD', path: '', message: gl.error, hint: null }], []);
1117
+ assertBusinessConstants(gl, warnings);
1118
+
1119
+ const fm = loadFormulas();
1120
+ if (fm.error) return emit(2, 'ALL', [{ code: 'FORMULAS_LOAD', path: '', message: fm.error, hint: null }], []);
1121
+
1122
+ if (!fs.existsSync(stageDir)) {
1123
+ return emit(2, 'ALL', [{ code: 'ARG', path: stageDir, message: '阶段目录不存在', hint: null }], []);
1124
+ }
1125
+
1126
+ const skeleton = loadUpstream(args.upstream);
1127
+ const notReady = checkUpstreamReady(skeleton);
1128
+ if (notReady) return emit(2, 'ALL', [{ code: 'UPSTREAM_NOT_READY', path: '', message: notReady, hint: null }], []);
1129
+
1130
+ const T = skeleton.framework.capacity.target_chapters;
1131
+ const idSets = buildIdSets(skeleton, skeleton.flesh);
1132
+
1133
+ const FILES = [
1134
+ { type: '章节规划', name: '章节规划.json', tpl: '章节规划模板.json' },
1135
+ { type: '时序映射', name: '时序映射.json', tpl: '时序映射模板.json' },
1136
+ { type: '节奏参数', name: '节奏参数.json', tpl: '节奏参数模板.json' },
1137
+ { type: '章节细纲', name: '章节细纲.json', tpl: '章节细纲模板.json' }
1138
+ ];
1139
+
1140
+ const loaded = {};
1141
+
1142
+ for (const f of FILES) {
1143
+ const dataPath = path.join(stageDir, f.name);
1144
+ const tplPath = path.join(templateDir, f.tpl);
1145
+ const inputRes = readJson(dataPath);
1146
+ if (inputRes.error) {
1147
+ errors.push({ code: 'READ', path: dataPath, message: inputRes.error, hint: null });
1148
+ continue;
1149
+ }
1150
+ const tplRes = readJson(tplPath);
1151
+ if (tplRes.error) {
1152
+ errors.push({ code: 'TEMPLATE', path: tplPath, message: tplRes.error, hint: null });
1153
+ continue;
1154
+ }
1155
+ loaded[f.type] = inputRes.data;
1156
+ const data = inputRes.data;
1157
+
1158
+ safeCheck('validateNode', () => validateNode(data, tplRes.data.structure || {}, '', gl.enums, errors), errors);
1159
+ safeCheck('charsetAndForbiddenCheck', () => charsetAndForbiddenCheck(data, errors), errors);
1160
+ safeCheck('checkChapterRanges', () => checkChapterRanges(data, T, gl.chapterScalarFields, errors), errors);
1161
+
1162
+ if (f.type === '章节规划') {
1163
+ safeCheck('checkChapterPlan', () => checkChapterPlan(data.chapters, T, skeleton.hierarchy, skeleton.framework.volume_ratio, errors, warnings), errors);
1164
+ } else if (f.type === '时序映射') {
1165
+ safeCheck('checkTimelineIntegrity', () => checkTimelineIntegrity(data, idSets, errors), errors);
1166
+ } else if (f.type === '节奏参数') {
1167
+ safeCheck('checkRhythmInfeasible', () => checkRhythmInfeasible(data, skeleton.framework, fm.constraints, errors), errors);
1168
+ safeCheck('checkRhythmDerivations', () => checkRhythmDerivations(data, fm.constraints, errors, warnings), errors);
1169
+ } else if (f.type === '章节细纲') {
1170
+ safeCheck('checkWordTarget', () => checkWordTarget(data.outlines, skeleton.framework, warnings), errors);
1171
+ }
1172
+ }
1173
+
1174
+ if (loaded['时序映射']) {
1175
+ if (loaded['节奏参数']) {
1176
+ safeCheck('checkLayoutMatch', () => checkLayoutMatch(loaded['时序映射'], loaded['节奏参数'], skeleton.framework, errors), errors);
1177
+ }
1178
+ safeCheck('checkCoverage', () => checkCoverage(loaded['时序映射'], skeleton, errors, warnings), errors);
1179
+ if (loaded['节奏参数']) {
1180
+ // 高潮期豁免:读章节细纲的 emotion_phase,emotion_phase='释放' 且 hook_strength='强' 的章视为高潮豁免章
1181
+ const outlines = loaded['章节细纲'] && loaded['章节细纲'].outlines ? loaded['章节细纲'].outlines : null;
1182
+ safeCheck('checkHooks', () => checkHooks(loaded['时序映射'], loaded['节奏参数'], skeleton.framework, T, outlines, errors, warnings), errors);
1183
+ }
1184
+ safeCheck('checkChapterGraph', () => checkChapterGraph(loaded['时序映射'], T, errors), errors);
1185
+ }
1186
+
1187
+ updateStageStatus(stageDir, 'ALL', errors.length === 0 ? 0 : 1);
1188
+ regenerateMd(stageDir);
1189
+
1190
+ emit(errors.length === 0 ? 0 : 1, 'ALL', errors, warnings);
1191
+ }
1192
+
1193
+ // ============================================================
1194
+ // 入口
1195
+ // ============================================================
1196
+ function main() {
1197
+ const args = parseArgs();
1198
+
1199
+ if (args.all) {
1200
+ if (!args['stage-dir'] || !args['template-dir']) {
1201
+ return emit(2, 'ALL', [{ code: 'ARG', path: '', message: '全量模式需要 --stage-dir 和 --template-dir', hint: null }], []);
1202
+ }
1203
+ return validateAll(args);
1204
+ }
1205
+
1206
+ if (args.input) {
1207
+ if (!args.template || !args.file) {
1208
+ return emit(2, args.file || 'UNKNOWN', [{ code: 'ARG', path: '', message: '单文件模式需要 --template 和 --file', hint: null }], []);
1209
+ }
1210
+ return validateSingle(args);
1211
+ }
1212
+
1213
+ return emit(2, 'UNKNOWN', [{ code: 'ARG', path: '', message: '需要 --input 或 --all', hint: null }], []);
1214
+ }
1215
+
1216
+ main();