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