scrum-skills 0.3.2 → 0.3.3

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 (384) hide show
  1. package/.agents/skills/diagram-design/LICENSE +4 -0
  2. package/.agents/skills/diagram-design/SKILL.md +413 -0
  3. package/.agents/skills/diagram-design/UPSTREAM.json +76 -0
  4. package/.agents/skills/diagram-design/UPSTREAM.md +23 -0
  5. package/.agents/skills/diagram-design/assets/example-architecture-containment.html +205 -0
  6. package/.agents/skills/diagram-design/assets/example-architecture-delta.html +193 -0
  7. package/.agents/skills/diagram-design/assets/example-architecture.html +183 -0
  8. package/.agents/skills/diagram-design/assets/example-axonometric-plan-campus.html +108 -0
  9. package/.agents/skills/diagram-design/assets/example-axonometric-plan-coffee-shop.html +121 -0
  10. package/.agents/skills/diagram-design/assets/example-axonometric-plan-warehouse.html +127 -0
  11. package/.agents/skills/diagram-design/assets/example-axonometric-plan.html +114 -0
  12. package/.agents/skills/diagram-design/assets/example-bar.html +129 -0
  13. package/.agents/skills/diagram-design/assets/example-beeswarm.html +384 -0
  14. package/.agents/skills/diagram-design/assets/example-box-plot.html +127 -0
  15. package/.agents/skills/diagram-design/assets/example-bubble.html +147 -0
  16. package/.agents/skills/diagram-design/assets/example-bump.html +142 -0
  17. package/.agents/skills/diagram-design/assets/example-data-flow.html +143 -0
  18. package/.agents/skills/diagram-design/assets/example-datalake.html +269 -0
  19. package/.agents/skills/diagram-design/assets/example-db-schema.html +302 -0
  20. package/.agents/skills/diagram-design/assets/example-dependency.html +224 -0
  21. package/.agents/skills/diagram-design/assets/example-deployment.html +190 -0
  22. package/.agents/skills/diagram-design/assets/example-dp-integration.html +81 -0
  23. package/.agents/skills/diagram-design/assets/example-dp-security-matrix.html +88 -0
  24. package/.agents/skills/diagram-design/assets/example-er.html +202 -0
  25. package/.agents/skills/diagram-design/assets/example-exploded-ai-stack.html +85 -0
  26. package/.agents/skills/diagram-design/assets/example-exploded-keyboard.html +84 -0
  27. package/.agents/skills/diagram-design/assets/example-exploded-phone.html +83 -0
  28. package/.agents/skills/diagram-design/assets/example-exploded-unboxing.html +84 -0
  29. package/.agents/skills/diagram-design/assets/example-exploded.html +83 -0
  30. package/.agents/skills/diagram-design/assets/example-fishbone.html +152 -0
  31. package/.agents/skills/diagram-design/assets/example-flowchart.html +163 -0
  32. package/.agents/skills/diagram-design/assets/example-gantt.html +137 -0
  33. package/.agents/skills/diagram-design/assets/example-heatmap.html +224 -0
  34. package/.agents/skills/diagram-design/assets/example-high-level-vertical.html +325 -0
  35. package/.agents/skills/diagram-design/assets/example-high-level.html +301 -0
  36. package/.agents/skills/diagram-design/assets/example-import-drawio.html +185 -0
  37. package/.agents/skills/diagram-design/assets/example-import-excalidraw.html +70 -0
  38. package/.agents/skills/diagram-design/assets/example-import-mermaid.html +70 -0
  39. package/.agents/skills/diagram-design/assets/example-it-state.html +128 -0
  40. package/.agents/skills/diagram-design/assets/example-journey.html +148 -0
  41. package/.agents/skills/diagram-design/assets/example-kanban.html +174 -0
  42. package/.agents/skills/diagram-design/assets/example-layers.html +123 -0
  43. package/.agents/skills/diagram-design/assets/example-line.html +111 -0
  44. package/.agents/skills/diagram-design/assets/example-loop.html +100 -0
  45. package/.agents/skills/diagram-design/assets/example-marimekko.html +148 -0
  46. package/.agents/skills/diagram-design/assets/example-medallion.html +159 -0
  47. package/.agents/skills/diagram-design/assets/example-nested.html +136 -0
  48. package/.agents/skills/diagram-design/assets/example-org-chart.html +45 -0
  49. package/.agents/skills/diagram-design/assets/example-polar.html +116 -0
  50. package/.agents/skills/diagram-design/assets/example-process.html +64 -0
  51. package/.agents/skills/diagram-design/assets/example-pyramid.html +119 -0
  52. package/.agents/skills/diagram-design/assets/example-quadrant-consultant.html +166 -0
  53. package/.agents/skills/diagram-design/assets/example-quadrant.html +135 -0
  54. package/.agents/skills/diagram-design/assets/example-radar.html +139 -0
  55. package/.agents/skills/diagram-design/assets/example-ridgeline.html +104 -0
  56. package/.agents/skills/diagram-design/assets/example-sankey.html +150 -0
  57. package/.agents/skills/diagram-design/assets/example-scatter.html +141 -0
  58. package/.agents/skills/diagram-design/assets/example-sequence-oauth.html +227 -0
  59. package/.agents/skills/diagram-design/assets/example-sequence.html +222 -0
  60. package/.agents/skills/diagram-design/assets/example-service-blueprint.html +185 -0
  61. package/.agents/skills/diagram-design/assets/example-slopegraph.html +119 -0
  62. package/.agents/skills/diagram-design/assets/example-state-lifecycle.html +92 -0
  63. package/.agents/skills/diagram-design/assets/example-state.html +147 -0
  64. package/.agents/skills/diagram-design/assets/example-story-map.html +184 -0
  65. package/.agents/skills/diagram-design/assets/example-streamgraph.html +99 -0
  66. package/.agents/skills/diagram-design/assets/example-swimlane.html +172 -0
  67. package/.agents/skills/diagram-design/assets/example-timeline.html +135 -0
  68. package/.agents/skills/diagram-design/assets/example-tree-block-decomposition.html +151 -0
  69. package/.agents/skills/diagram-design/assets/example-tree.html +173 -0
  70. package/.agents/skills/diagram-design/assets/example-treemap.html +107 -0
  71. package/.agents/skills/diagram-design/assets/example-uml-class.html +188 -0
  72. package/.agents/skills/diagram-design/assets/example-venn.html +110 -0
  73. package/.agents/skills/diagram-design/assets/example-wardley.html +153 -0
  74. package/.agents/skills/diagram-design/assets/example-waterfall.html +129 -0
  75. package/.agents/skills/diagram-design/assets/template-dark.html +99 -0
  76. package/.agents/skills/diagram-design/assets/template-full.html +421 -0
  77. package/.agents/skills/diagram-design/assets/template-motion.html +448 -0
  78. package/.agents/skills/diagram-design/assets/template-terminal.html +188 -0
  79. package/.agents/skills/diagram-design/assets/template.html +100 -0
  80. package/.agents/skills/diagram-design/references/animation.md +143 -0
  81. package/.agents/skills/diagram-design/references/export-registry.md +78 -0
  82. package/.agents/skills/diagram-design/references/export.md +376 -0
  83. package/.agents/skills/diagram-design/references/import-drawio.md +181 -0
  84. package/.agents/skills/diagram-design/references/import-excalidraw.md +130 -0
  85. package/.agents/skills/diagram-design/references/import-mermaid.md +137 -0
  86. package/.agents/skills/diagram-design/references/layout-budget.md +97 -0
  87. package/.agents/skills/diagram-design/references/onboarding.md +368 -0
  88. package/.agents/skills/diagram-design/references/output-spec.md +277 -0
  89. package/.agents/skills/diagram-design/references/primitive-annotation.md +36 -0
  90. package/.agents/skills/diagram-design/references/primitive-icons.md +827 -0
  91. package/.agents/skills/diagram-design/references/primitive-sketchy.md +43 -0
  92. package/.agents/skills/diagram-design/references/primitive-terminal.md +76 -0
  93. package/.agents/skills/diagram-design/references/primitives-core.md +146 -0
  94. package/.agents/skills/diagram-design/references/profiles.md +183 -0
  95. package/.agents/skills/diagram-design/references/semantic-patterns.md +154 -0
  96. package/.agents/skills/diagram-design/references/style-guide.md +236 -0
  97. package/.agents/skills/diagram-design/references/style-vivid.md +148 -0
  98. package/.agents/skills/diagram-design/references/type-architecture-delta.md +143 -0
  99. package/.agents/skills/diagram-design/references/type-architecture.md +117 -0
  100. package/.agents/skills/diagram-design/references/type-axonometric-plan.md +83 -0
  101. package/.agents/skills/diagram-design/references/type-bar.md +108 -0
  102. package/.agents/skills/diagram-design/references/type-data-flow.md +371 -0
  103. package/.agents/skills/diagram-design/references/type-db-schema.md +48 -0
  104. package/.agents/skills/diagram-design/references/type-dependency.md +44 -0
  105. package/.agents/skills/diagram-design/references/type-deployment.md +45 -0
  106. package/.agents/skills/diagram-design/references/type-dp-integration.md +407 -0
  107. package/.agents/skills/diagram-design/references/type-dp-security-matrix.md +379 -0
  108. package/.agents/skills/diagram-design/references/type-er.md +25 -0
  109. package/.agents/skills/diagram-design/references/type-exploded.md +115 -0
  110. package/.agents/skills/diagram-design/references/type-fishbone.md +75 -0
  111. package/.agents/skills/diagram-design/references/type-flowchart.md +23 -0
  112. package/.agents/skills/diagram-design/references/type-gantt.md +45 -0
  113. package/.agents/skills/diagram-design/references/type-heatmap.md +80 -0
  114. package/.agents/skills/diagram-design/references/type-high-level.md +470 -0
  115. package/.agents/skills/diagram-design/references/type-it-state.md +468 -0
  116. package/.agents/skills/diagram-design/references/type-journey.md +55 -0
  117. package/.agents/skills/diagram-design/references/type-kanban.md +49 -0
  118. package/.agents/skills/diagram-design/references/type-layers.md +26 -0
  119. package/.agents/skills/diagram-design/references/type-line.md +337 -0
  120. package/.agents/skills/diagram-design/references/type-loop.md +223 -0
  121. package/.agents/skills/diagram-design/references/type-medallion.md +356 -0
  122. package/.agents/skills/diagram-design/references/type-nested.md +22 -0
  123. package/.agents/skills/diagram-design/references/type-org-chart.md +44 -0
  124. package/.agents/skills/diagram-design/references/type-polar.md +136 -0
  125. package/.agents/skills/diagram-design/references/type-process.md +490 -0
  126. package/.agents/skills/diagram-design/references/type-pyramid.md +33 -0
  127. package/.agents/skills/diagram-design/references/type-quadrant.md +81 -0
  128. package/.agents/skills/diagram-design/references/type-radar.md +82 -0
  129. package/.agents/skills/diagram-design/references/type-sankey.md +75 -0
  130. package/.agents/skills/diagram-design/references/type-scatter.md +276 -0
  131. package/.agents/skills/diagram-design/references/type-sequence.md +130 -0
  132. package/.agents/skills/diagram-design/references/type-service-blueprint.md +103 -0
  133. package/.agents/skills/diagram-design/references/type-state.md +30 -0
  134. package/.agents/skills/diagram-design/references/type-story-map.md +46 -0
  135. package/.agents/skills/diagram-design/references/type-swimlane.md +22 -0
  136. package/.agents/skills/diagram-design/references/type-timeline.md +20 -0
  137. package/.agents/skills/diagram-design/references/type-tree.md +24 -0
  138. package/.agents/skills/diagram-design/references/type-treemap.md +137 -0
  139. package/.agents/skills/diagram-design/references/type-uml-class.md +65 -0
  140. package/.agents/skills/diagram-design/references/type-venn.md +26 -0
  141. package/.agents/skills/diagram-design/references/type-wardley.md +39 -0
  142. package/.agents/skills/diagram-design/references/type-waterfall.md +75 -0
  143. package/.agents/skills/scrum-meta/SKILL.md +36 -36
  144. package/.agents/skills/scrum-planning/SKILL.md +61 -61
  145. package/.agents/skills/scrum-planning/references/schema.md +384 -336
  146. package/.agents/skills/scrum-report/SKILL.md +42 -42
  147. package/.agents/skills/scrum-sprint/SKILL.md +49 -49
  148. package/.agents/skills/scrum-tdd/SKILL.md +140 -127
  149. package/README.md +224 -201
  150. package/assets/board/200.html +1 -1
  151. package/assets/board/404.html +1 -1
  152. package/assets/board/_nuxt/1XlYaCp9.js +1 -0
  153. package/assets/board/_nuxt/4gQF_HS3.js +1 -0
  154. package/assets/board/_nuxt/5okfNy5D.js +1 -0
  155. package/assets/board/_nuxt/{Bb3Limos.js → B0C2jsdk.js} +1 -1
  156. package/assets/board/_nuxt/B1j6UC4U.js +1 -0
  157. package/assets/board/_nuxt/B3LKy6o2.js +1 -0
  158. package/assets/board/_nuxt/BAQQngAq.js +13 -0
  159. package/assets/board/_nuxt/BE6t7Q2m.js +1 -0
  160. package/assets/board/_nuxt/BLvbupFS.js +1 -0
  161. package/assets/board/_nuxt/BQgzP7ur.js +1 -0
  162. package/assets/board/_nuxt/BRZIF_wo.js +1 -0
  163. package/assets/board/_nuxt/BW3xJdEx.js +1 -0
  164. package/assets/board/_nuxt/{Cm9lkUvP.js → BY36DFyj.js} +1 -1
  165. package/assets/board/_nuxt/BZlZD94i.js +1 -0
  166. package/assets/board/_nuxt/BZvXNwev.js +3 -0
  167. package/assets/board/_nuxt/BbxmvdIF.js +1 -0
  168. package/assets/board/_nuxt/BiWrMOsL.js +1 -0
  169. package/assets/board/_nuxt/BizDeOK5.js +1 -0
  170. package/assets/board/_nuxt/BkcaOYQ_.js +95 -0
  171. package/assets/board/_nuxt/BkoKCNUY.js +1 -0
  172. package/assets/board/_nuxt/BlKmZhhF.js +1 -0
  173. package/assets/board/_nuxt/BmBpmU73.js +1 -0
  174. package/assets/board/_nuxt/BrWE0up6.js +1 -0
  175. package/assets/board/_nuxt/Bs52qkgk.js +1 -0
  176. package/assets/board/_nuxt/BtzUbHO_.js +1 -0
  177. package/assets/board/_nuxt/BuBzMkFZ.js +1 -0
  178. package/assets/board/_nuxt/Bubk0xn-.js +1 -0
  179. package/assets/board/_nuxt/BxuBMtk_.js +3 -0
  180. package/assets/board/_nuxt/By1KXXLR.js +1 -0
  181. package/assets/board/_nuxt/BygIGL69.js +2 -0
  182. package/assets/board/_nuxt/C1iO5KH3.js +1 -0
  183. package/assets/board/_nuxt/C2YPmTzt.js +1 -0
  184. package/assets/board/_nuxt/C3SpAhCW.js +1 -0
  185. package/assets/board/_nuxt/C3ZRWz5L.js +1 -0
  186. package/assets/board/_nuxt/C4WdPP6K.js +1 -0
  187. package/assets/board/_nuxt/C6o03JHe.js +1 -0
  188. package/assets/board/_nuxt/C8qB5llI.js +1 -0
  189. package/assets/board/_nuxt/C9MgRdhu.js +1 -0
  190. package/assets/board/_nuxt/CCA81-4P.js +1 -0
  191. package/assets/board/_nuxt/{D2O8Q8x9.js → CFCM18fF.js} +1 -1
  192. package/assets/board/_nuxt/CGXljzd_.js +1 -0
  193. package/assets/board/_nuxt/CI0scS-N.js +1 -0
  194. package/assets/board/_nuxt/CJs9B42o.js +1 -0
  195. package/assets/board/_nuxt/COecDWbb.js +1 -0
  196. package/assets/board/_nuxt/COmtTtfB.js +1 -0
  197. package/assets/board/_nuxt/CPYm4J_K.js +1 -0
  198. package/assets/board/_nuxt/CReA4TEB.js +3 -0
  199. package/assets/board/_nuxt/CXG7GZes.js +1 -0
  200. package/assets/board/_nuxt/CYGiZn-X.js +1 -0
  201. package/assets/board/_nuxt/CaE8VlUp.js +1 -0
  202. package/assets/board/_nuxt/CcGiNUgA.js +1 -0
  203. package/assets/board/_nuxt/Ccgv3lDj.js +1 -0
  204. package/assets/board/_nuxt/CdPGFZyy.js +1 -0
  205. package/assets/board/_nuxt/{DfR3IZN_.js → Cdf_T8Xa.js} +1 -1
  206. package/assets/board/_nuxt/CgQ30j_6.js +1 -0
  207. package/assets/board/_nuxt/ChUHb39z.js +1 -0
  208. package/assets/board/_nuxt/Ck0-FqVI.js +1 -0
  209. package/assets/board/_nuxt/CkWKLtjO.js +1 -0
  210. package/assets/board/_nuxt/Cq7hsCKB.js +1 -0
  211. package/assets/board/_nuxt/CtFknnHZ.js +1 -0
  212. package/assets/board/_nuxt/D166WVUG.js +1 -0
  213. package/assets/board/_nuxt/D4Dpzo9e.js +1 -0
  214. package/assets/board/_nuxt/D5i92OZO.js +1 -0
  215. package/assets/board/_nuxt/D6dUp4Nm.js +9 -0
  216. package/assets/board/_nuxt/DFQDFspC.js +1 -0
  217. package/assets/board/_nuxt/DHX2tw9J.js +1 -0
  218. package/assets/board/_nuxt/DJM7u8P3.js +1 -0
  219. package/assets/board/_nuxt/{BPTrRhcw.js → DLr85ebo.js} +1 -1
  220. package/assets/board/_nuxt/DOMM1wfl.js +1 -0
  221. package/assets/board/_nuxt/DU6ArlUN.js +1 -0
  222. package/assets/board/_nuxt/{B_SlFcWp.js → DUgGsde6.js} +1 -1
  223. package/assets/board/_nuxt/DV4ti-Pq.js +1 -0
  224. package/assets/board/_nuxt/DZ--fXuU.js +1 -0
  225. package/assets/board/_nuxt/D_ct-Stz.js +2 -0
  226. package/assets/board/_nuxt/DbWAComO.js +1 -0
  227. package/assets/board/_nuxt/DbwlcHAt.js +1 -0
  228. package/assets/board/_nuxt/DjzLR9Bl.js +1 -0
  229. package/assets/board/_nuxt/Dki9VkAL.js +3 -0
  230. package/assets/board/_nuxt/DlNJhOoc.js +1 -0
  231. package/assets/board/_nuxt/DqG7CtD4.js +1 -0
  232. package/assets/board/_nuxt/DqUSsCJ2.js +1 -0
  233. package/assets/board/_nuxt/Du2F4WXh.js +1 -0
  234. package/assets/board/_nuxt/DuRiEXn4.js +1 -0
  235. package/assets/board/_nuxt/DxBwVNyP.js +2 -0
  236. package/assets/board/_nuxt/DzmVW5-D.js +1 -0
  237. package/assets/board/_nuxt/I-sGtDIB.js +6 -0
  238. package/assets/board/_nuxt/JU9-x68N.js +1 -0
  239. package/assets/board/_nuxt/Lozm7hhE.js +1 -0
  240. package/assets/board/_nuxt/MMsTMkcB.js +1 -0
  241. package/assets/board/_nuxt/MS7CGmCB.js +1 -0
  242. package/assets/board/_nuxt/OKKaKILz.js +1 -0
  243. package/assets/board/_nuxt/{Csqgy925.js → P6qlvBHA.js} +1 -1
  244. package/assets/board/_nuxt/SrNUvNwO.js +1 -0
  245. package/assets/board/_nuxt/{DSaWoFHS.js → SywdkA98.js} +1 -1
  246. package/assets/board/_nuxt/TBIRpVLG.js +1 -0
  247. package/assets/board/_nuxt/TlTrMIMm.js +1 -0
  248. package/assets/board/_nuxt/Zv9-SOQF.js +1 -0
  249. package/assets/board/_nuxt/_id_.ItzoSaBL.css +1 -0
  250. package/assets/board/_nuxt/builds/latest.json +1 -1
  251. package/assets/board/_nuxt/builds/meta/2218461a-f45b-41d6-ae44-b0d2332a9511.json +1 -0
  252. package/assets/board/_nuxt/burndown.CT8fx1Ge.css +1 -0
  253. package/assets/board/_nuxt/diagrams.D1ybqBem.css +1 -0
  254. package/assets/board/_nuxt/{entry.CKHePurH.css → entry.BCuHiFgF.css} +1 -1
  255. package/assets/board/_nuxt/epics.CUeDIEa0.css +1 -0
  256. package/assets/board/_nuxt/error-404.Y9imKiDC.css +1 -0
  257. package/assets/board/_nuxt/error-500.DufSBGVC.css +1 -0
  258. package/assets/board/_nuxt/gantt.CiC2Ts1t.css +1 -0
  259. package/assets/board/_nuxt/jbri6q1p.js +1 -0
  260. package/assets/board/_nuxt/last-change.C05Vc3BF.css +1 -0
  261. package/assets/board/_nuxt/milestones.Civ3sMiQ.css +1 -0
  262. package/assets/board/_nuxt/oSJF4yHw.js +1 -0
  263. package/assets/board/_nuxt/pages.BshWbOyo.css +1 -0
  264. package/assets/board/_nuxt/q_8bxRMt.js +1 -0
  265. package/assets/board/_nuxt/tests.Ci9fBCka.css +1 -0
  266. package/assets/board/burndown/index.html +1 -1
  267. package/assets/board/diagrams/index.html +1 -0
  268. package/assets/board/epics/index.html +1 -1
  269. package/assets/board/favicon.svg +16 -16
  270. package/assets/board/gantt/index.html +1 -1
  271. package/assets/board/index.html +1 -1
  272. package/assets/board/last-change/index.html +1 -1
  273. package/assets/board/milestones/index.html +1 -1
  274. package/assets/board/tests/index.html +1 -1
  275. package/bin/scrum-skills.mjs +204 -191
  276. package/docs/ai-assistant-guide.md +1008 -1008
  277. package/package.json +42 -41
  278. package/templates/AGENTS.md +84 -74
  279. package/tools/agile-tools.mjs +510 -510
  280. package/tools/ai.mjs +464 -464
  281. package/tools/diagram-import.mjs +238 -0
  282. package/tools/diagram-lint.mjs +518 -0
  283. package/tools/diagram.mjs +299 -0
  284. package/tools/git-commit.mjs +216 -216
  285. package/tools/last-change.mjs +326 -326
  286. package/tools/projects.mjs +240 -215
  287. package/tools/serve.mjs +180 -154
  288. package/tools/validate.mjs +781 -664
  289. package/assets/board/_nuxt/1eqboJxR.js +0 -1
  290. package/assets/board/_nuxt/2K7ag6ES.js +0 -1
  291. package/assets/board/_nuxt/51OWHj8c.js +0 -2
  292. package/assets/board/_nuxt/6gFtTBwm.js +0 -1
  293. package/assets/board/_nuxt/B3jkABw9.js +0 -1
  294. package/assets/board/_nuxt/B5n2_jZN.js +0 -1
  295. package/assets/board/_nuxt/BDK2Hj7B.js +0 -1
  296. package/assets/board/_nuxt/BDcnzXHq.js +0 -1
  297. package/assets/board/_nuxt/BaG8y0Wt.js +0 -1
  298. package/assets/board/_nuxt/BbwJq5pb.js +0 -1
  299. package/assets/board/_nuxt/BgLeKwx0.js +0 -1
  300. package/assets/board/_nuxt/Bi9Axj3V.js +0 -1
  301. package/assets/board/_nuxt/BmxvtaMq.js +0 -1
  302. package/assets/board/_nuxt/BodFvto3.js +0 -1
  303. package/assets/board/_nuxt/BpWeHLuN.js +0 -1
  304. package/assets/board/_nuxt/BuOKDFiJ.js +0 -1
  305. package/assets/board/_nuxt/C40iTe-x.js +0 -1
  306. package/assets/board/_nuxt/C7K0R7F-.js +0 -2
  307. package/assets/board/_nuxt/C8vL692X.js +0 -1
  308. package/assets/board/_nuxt/C9StZL3D.js +0 -3
  309. package/assets/board/_nuxt/CGI_ZV79.js +0 -1
  310. package/assets/board/_nuxt/CJ2mz0WY.js +0 -1
  311. package/assets/board/_nuxt/CJMBJQuf.js +0 -1
  312. package/assets/board/_nuxt/CK-lOYG9.js +0 -1
  313. package/assets/board/_nuxt/CK9_veoz.js +0 -1
  314. package/assets/board/_nuxt/CLBcBrYf.js +0 -1
  315. package/assets/board/_nuxt/CMUi2-Lj.js +0 -1
  316. package/assets/board/_nuxt/COf6E6h4.js +0 -1
  317. package/assets/board/_nuxt/CPCUMJnF.js +0 -1
  318. package/assets/board/_nuxt/CPcKlmSm.js +0 -1
  319. package/assets/board/_nuxt/CQTvWh4i.js +0 -1
  320. package/assets/board/_nuxt/CSEEUF8S.js +0 -1
  321. package/assets/board/_nuxt/CSSb154I.js +0 -13
  322. package/assets/board/_nuxt/CgDTd7jS.js +0 -1
  323. package/assets/board/_nuxt/ChG76uh9.js +0 -1
  324. package/assets/board/_nuxt/Ci0qbkgh.js +0 -1
  325. package/assets/board/_nuxt/CqoQ__I_.js +0 -1
  326. package/assets/board/_nuxt/CuJUHLl8.js +0 -1
  327. package/assets/board/_nuxt/CvLiA4_O.js +0 -1
  328. package/assets/board/_nuxt/Cvs12oA0.js +0 -3
  329. package/assets/board/_nuxt/CyEXFe3V.js +0 -1
  330. package/assets/board/_nuxt/Cz6rZcLK.js +0 -1
  331. package/assets/board/_nuxt/D2FhHl1o.js +0 -1
  332. package/assets/board/_nuxt/D3YCAmfu.js +0 -1
  333. package/assets/board/_nuxt/D5CE81jK.js +0 -1
  334. package/assets/board/_nuxt/D75vbfSb.js +0 -1
  335. package/assets/board/_nuxt/D99LbGfb.js +0 -9
  336. package/assets/board/_nuxt/DEfLc7p3.js +0 -6
  337. package/assets/board/_nuxt/DF-nsa1Z.js +0 -1
  338. package/assets/board/_nuxt/DIc8yx98.js +0 -1
  339. package/assets/board/_nuxt/DLkAKotw.js +0 -1
  340. package/assets/board/_nuxt/DRPlYvb3.js +0 -1
  341. package/assets/board/_nuxt/DRlQgul3.js +0 -1
  342. package/assets/board/_nuxt/DSLbmoWa.js +0 -1
  343. package/assets/board/_nuxt/DWO9c9yp.js +0 -1
  344. package/assets/board/_nuxt/DZo3dYLJ.js +0 -3
  345. package/assets/board/_nuxt/D_S6N7_b.js +0 -1
  346. package/assets/board/_nuxt/DajA-Tkt.js +0 -1
  347. package/assets/board/_nuxt/DbHiyab-.js +0 -1
  348. package/assets/board/_nuxt/Dc7Ql6eE.js +0 -1
  349. package/assets/board/_nuxt/Dd9ZukPU.js +0 -1
  350. package/assets/board/_nuxt/Dg2LiqmN.js +0 -1
  351. package/assets/board/_nuxt/DhuleNnm.js +0 -1
  352. package/assets/board/_nuxt/DkI1Qxpp.js +0 -95
  353. package/assets/board/_nuxt/DnDHGSYI.js +0 -1
  354. package/assets/board/_nuxt/DpXJ_tCU.js +0 -1
  355. package/assets/board/_nuxt/DqZgF3hU.js +0 -1
  356. package/assets/board/_nuxt/DqcxvfbD.js +0 -1
  357. package/assets/board/_nuxt/DsA2lVci.js +0 -1
  358. package/assets/board/_nuxt/Dtsa-1NS.js +0 -1
  359. package/assets/board/_nuxt/Du91iq0p.js +0 -1
  360. package/assets/board/_nuxt/Dx1wcYFo.js +0 -1
  361. package/assets/board/_nuxt/EYTShbt4.js +0 -1
  362. package/assets/board/_nuxt/MaXJprwN.js +0 -1
  363. package/assets/board/_nuxt/P9x67aq2.js +0 -1
  364. package/assets/board/_nuxt/S08nFESZ.js +0 -3
  365. package/assets/board/_nuxt/V0uynWE2.js +0 -1
  366. package/assets/board/_nuxt/Vo4wMbnS.js +0 -1
  367. package/assets/board/_nuxt/ZVTqot-_.js +0 -1
  368. package/assets/board/_nuxt/ZngEd3HB.js +0 -1
  369. package/assets/board/_nuxt/_id_.B2NDKSlX.css +0 -1
  370. package/assets/board/_nuxt/assMu_Wn.js +0 -1
  371. package/assets/board/_nuxt/builds/meta/8e23aec9-5b67-4569-b8bf-d4f07e50ffdf.json +0 -1
  372. package/assets/board/_nuxt/burndown.Do6IbM6o.css +0 -1
  373. package/assets/board/_nuxt/epics.Dmessplu.css +0 -1
  374. package/assets/board/_nuxt/error-404.BFmXOnCT.css +0 -1
  375. package/assets/board/_nuxt/error-500.BZ-DdjQ6.css +0 -1
  376. package/assets/board/_nuxt/gantt.Dmp00GUo.css +0 -1
  377. package/assets/board/_nuxt/last-change.1Su4GrCN.css +0 -1
  378. package/assets/board/_nuxt/milestones.U0xKCsEo.css +0 -1
  379. package/assets/board/_nuxt/pages.Dbj2IVk8.css +0 -1
  380. package/assets/board/_nuxt/rlePnzpc.js +0 -1
  381. package/assets/board/_nuxt/tests.dXlUX16A.css +0 -1
  382. package/assets/board/_nuxt/uKzCKCTq.js +0 -1
  383. package/assets/board/_nuxt/vwxaNGfc.js +0 -1
  384. package/assets/board/_nuxt/yh6sf_D5.js +0 -1
@@ -1,664 +1,781 @@
1
- #!/usr/bin/env node
2
- /**
3
- * 敏捷数据校验脚本(零依赖,schema v3:三文件结构)
4
- * 用法: node tools/validate.mjs [目标目录] 默认当前工作目录
5
- * 校验 <目标>/agile/ 下 backlog.json / sprints.json / journal.json:
6
- * 引用完整性(双向)、状态机、TDD 纪律、里程碑达成口径、
7
- * 需求属性(type/priority/value/缺陷详情/验收覆盖)、依赖无环、阻塞与范围变更。
8
- * 另外校验可选的 last-change.json(每次交付的汇报):引用、动作枚举、验收口径、调试地址。
9
- * 退出码: 0 通过, 1 有错误。
10
- */
11
- import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
12
- import { join, resolve } from 'node:path';
13
-
14
- const TARGET = resolve(process.argv[2] || process.cwd());
15
- const AGILE = join(TARGET, 'agile');
16
- const errors = [];
17
- const warns = [];
18
- const err = m => errors.push(m);
19
- const warn = m => warns.push(m);
20
-
21
- const FILES = { backlog: 'backlog.json', sprints: 'sprints.json', journal: 'journal.json' };
22
- const data = {};
23
- for (const [key, name] of Object.entries(FILES)) {
24
- const p = join(AGILE, name);
25
- if (!existsSync(p)) {
26
- err(`agile/${name} 缺失(schema v2 只认三文件结构)`);
27
- continue;
28
- }
29
- try {
30
- data[key] = JSON.parse(readFileSync(p, 'utf8'));
31
- } catch (e) {
32
- err(`agile/${name}: JSON 解析失败 - ${e.message}`);
33
- continue;
34
- }
35
- if (data[key].schemaVersion !== 3) err(`agile/${name}: schemaVersion 应为 3,实际 ${data[key].schemaVersion}`);
36
- }
37
-
38
- // v2 不兼容散文件结构:残留就报错,提醒合并
39
- for (const rel of ['backlog/epics', 'backlog/stories', 'sprints', 'journal']) {
40
- const p = join(AGILE, rel);
41
- if (existsSync(p) && statSync(p).isDirectory() && readdirSync(p).some(f => f.endsWith('.json')))
42
- err(`agile/${rel}/ 仍有散文件 JSON:v2 只认三文件结构,请合并进 backlog.json / sprints.json / journal.json`);
43
- }
44
-
45
- const backlog = data.backlog ?? {};
46
- const sprintsFile = data.sprints ?? {};
47
- const journalFile = data.journal ?? {};
48
- const epics = Array.isArray(backlog.epics) ? backlog.epics : [];
49
- const stories = Array.isArray(backlog.stories) ? backlog.stories : [];
50
- const sprints = Array.isArray(sprintsFile.sprints) ? sprintsFile.sprints : [];
51
- const milestones = Array.isArray(sprintsFile.milestones) ? sprintsFile.milestones : [];
52
- const journals = Array.isArray(journalFile.journals) ? journalFile.journals : [];
53
-
54
- const STORY_FLOW = ['draft', 'ready', 'in-progress', 'review', 'done', 'cancelled'];
55
- // 终态:done 与 cancelled 都不再往前走,也不再要求验收 / 任务全部做完
56
- const STORY_TERMINAL = ['done', 'cancelled'];
57
- const TASK_FLOW = ['todo', 'red', 'green', 'refactored', 'done'];
58
- const EPIC_FLOW = ['draft', 'active', 'done', 'cancelled'];
59
- const MILESTONE_FLOW = ['planned', 'at-risk', 'reached', 'missed'];
60
- const STORY_KIND = ['feature', 'bug', 'debt', 'spike'];
61
- const PRIORITY = ['P0', 'P1', 'P2', 'P3'];
62
- const SEVERITY = ['blocker', 'major', 'minor', 'trivial'];
63
- const SCOPE_KIND = ['added-mid-sprint', 'split', 'expanded'];
64
- const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
65
- /**
66
- * 交付时间:`YYYY-MM-DD` 或 `YYYY-MM-DD HH:mm:ss`(也接受 ISO 的 T 分隔)。
67
- * 日期必须真实存在(2026-13-01、2026-02-30 都不行),时分秒越界同样拦掉。
68
- */
69
- const STAMP_RE = /^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
70
- const stampOk = value => {
71
- const m = STAMP_RE.exec(typeof value === 'string' ? value : '');
72
- if (!m) return false;
73
- const [, date, hh, mm, ss] = m;
74
- // 用 UTC 构造再比对:本地时区(东八区)下 new Date('…T00:00:00') 转 ISO 会退到前一天
75
- const [y, mo, da] = date.split('-').map(Number);
76
- const d = new Date(Date.UTC(y, mo - 1, da));
77
- if (d.getUTCFullYear() !== y || d.getUTCMonth() !== mo - 1 || d.getUTCDate() !== da) return false;
78
- if (hh === undefined) return true;
79
- return Number(hh) <= 23 && Number(mm) <= 59 && Number(ss ?? 0) <= 59;
80
- };
81
- const stampDate = value => (typeof value === 'string' ? value.slice(0, 10) : '');
82
- // 用本地日期判定「今天」,避免 UTC 偏移导致活跃迭代判定差一天
83
- const now = new Date();
84
- const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
85
-
86
- // ---- 全局唯一 id ----
87
- const ids = new Set();
88
- function claim(id, where) {
89
- if (!id) { err(`${where}: 缺少 id`); return; }
90
- if (ids.has(id)) err(`${where}: id ${id} 重复`);
91
- else ids.add(id);
92
- }
93
- for (const e of epics) claim(e.id, 'epic');
94
- for (const s of stories) {
95
- claim(s.id, 'story');
96
- for (const t of s.tasks ?? []) {
97
- claim(t.id, `story ${s.id} 的 task`);
98
- if (t.id && !t.id.startsWith(`${s.id}-T`)) err(`task ${t.id}: 命名应为 ${s.id}-T<n>`);
99
- }
100
- }
101
- for (const sp of sprints) claim(sp.id, 'sprint');
102
- for (const m of milestones) claim(m.id, 'milestone');
103
-
104
- const epicById = new Map(epics.map(e => [e.id, e]));
105
- const storyById = new Map(stories.map(s => [s.id, s]));
106
- const sprintById = new Map(sprints.map(s => [s.id, s]));
107
- const taskById = new Map();
108
- for (const s of stories) for (const t of s.tasks ?? []) taskById.set(t.id, { task: t, story: s });
109
-
110
- // ---- 依赖图必须是有向无环 ----
111
- {
112
- const state = new Map(); // 1=访问中 2=已完成
113
- const visit = (id, path) => {
114
- const st = state.get(id) ?? 0;
115
- if (st === 2) return;
116
- if (st === 1) { err(`story 依赖成环: ${[...path, id].join(' → ')}`); return; }
117
- state.set(id, 1);
118
- for (const dep of storyById.get(id)?.dependsOn ?? []) if (storyById.has(dep)) visit(dep, [...path, id]);
119
- state.set(id, 2);
120
- };
121
- for (const s of stories) visit(s.id, []);
122
- }
123
-
124
- // ---- Epic ----
125
- for (const e of epics) {
126
- if (!EPIC_FLOW.includes(e.status)) err(`epic ${e.id}: 非法状态 ${e.status}`);
127
- if (!e.title) err(`epic ${e.id}: 缺少 title`);
128
- if (!e.goal) err(`epic ${e.id}: 缺少 goal`);
129
- if (!Array.isArray(e.keyResults) || e.keyResults.length === 0) warn(`epic ${e.id}: 没有 keyResults(史诗要有量化目标,否则完成与否全靠感觉)`);
130
- for (const kr of e.keyResults ?? []) {
131
- if (!kr.id) err(`epic ${e.id}: keyResult 缺少 id`);
132
- if (!kr.text) err(`epic ${e.id}: keyResult ${kr.id ?? '?'} 缺少 text`);
133
- if (!Number.isFinite(kr.target)) err(`epic ${e.id}: keyResult ${kr.id ?? '?'} 的 target 必须是数字`);
134
- if (!Number.isFinite(kr.current)) warn(`epic ${e.id}: keyResult ${kr.id ?? '?'} 没有 current,算不出进度`);
135
- }
136
- for (const sid of e.stories ?? []) {
137
- const st = storyById.get(sid);
138
- if (!st) { err(`epic ${e.id}: 引用 story ${sid} 不存在`); continue; }
139
- if (st.epicId !== e.id) err(`epic ${e.id}: story ${sid} 的 epicId 是 ${st.epicId},双向引用不一致`);
140
- }
141
- if (e.status === 'done') {
142
- for (const sid of e.stories ?? []) {
143
- const st = storyById.get(sid);
144
- if (st && !['done', 'cancelled'].includes(st.status))
145
- err(`epic ${e.id} 已 done 但 story ${sid} 状态为 ${st.status}`);
146
- }
147
- }
148
- }
149
-
150
- // ---- Story ----
151
- for (const s of stories) {
152
- const { id, epicId, sprintId, status, acceptance, tasks, points } = s;
153
- if (!s.title) err(`story ${id}: 缺少 title`);
154
- if (!epicId) warn(`story ${id}: 未挂 epic`);
155
- else if (!epicById.has(epicId)) err(`story ${id}: epicId ${epicId} 不存在`);
156
- else if (!(epicById.get(epicId).stories ?? []).includes(id))
157
- err(`story ${id}: 挂在 ${epicId} 上,但该 epic 的 stories 里没有它(双向引用不一致)`);
158
-
159
- if (!STORY_FLOW.includes(status))
160
- err(`story ${id}: 非法状态 ${status}(合法:${STORY_FLOW.join('/')})`);
161
- if (!Number.isFinite(points)) err(`story ${id}: points 必须是数字`);
162
- else if (![1, 2, 3, 5, 8, 13].includes(points)) warn(`story ${id}: points ${points} 不在斐波那契序列`);
163
-
164
- // 需求属性:类型 / 优先级 / 价值
165
- if (!STORY_KIND.includes(s.type)) err(`story ${id}: type 必须是 ${STORY_KIND.join('/')},实际 ${s.type}`);
166
- if (!PRIORITY.includes(s.priority)) err(`story ${id}: priority 必须是 ${PRIORITY.join('/')},实际 ${s.priority}`);
167
- if (!Number.isFinite(s.value) || s.value <= 0) err(`story ${id}: value 必须是正数(用于算 WSJF)`);
168
- if (s.type === 'bug') {
169
- if (!s.bug) err(`story ${id}: type 为 bug 但缺少 bug 详情`);
170
- else {
171
- if (!SEVERITY.includes(s.bug.severity)) err(`story ${id}: bug.severity 必须是 ${SEVERITY.join('/')}`);
172
- if (!s.bug.environment) err(`story ${id}: bug 缺少 environment(什么环境能复现)`);
173
- if (!s.bug.steps) err(`story ${id}: bug 缺少 steps(怎么复现)`);
174
- }
175
- }
176
-
177
- // 取消掉的故事不会再交付,别逼着为它补验收标准
178
- if (['ready', 'in-progress', 'review', 'done'].includes(status) && (!acceptance || acceptance.length === 0))
179
- err(`story ${id}: 状态 ${status} 但验收标准为空`);
180
- for (const [i, a] of (acceptance ?? []).entries()) {
181
- if (!a.given || !a.when || !a.then) err(`story ${id}: 第 ${i + 1} 条验收标准缺 given/when/then`);
182
- // 每条验收都要能被某个 task 的测试验证,否则「验收」只是口号
183
- for (const tid of a.verifyBy ?? []) {
184
- const hit = taskById.get(tid);
185
- if (!hit) err(`story ${id}: 第 ${i + 1} 条验收的 verifyBy ${tid} 不存在`);
186
- else if (hit.story.id !== id) err(`story ${id}: 第 ${i + 1} 条验收的 verifyBy ${tid} 属于 ${hit.story.id}`);
187
- }
188
- if (!(a.verifyBy ?? []).length && !['draft', 'cancelled'].includes(status))
189
- warn(`story ${id}: 第 ${i + 1} 条验收没有 verifyBy(没绑定验证任务)`);
190
- }
191
-
192
- // 阻塞 / 依赖
193
- for (const dep of s.dependsOn ?? []) {
194
- if (dep === id) err(`story ${id}: 不能依赖自己`);
195
- else if (!storyById.has(dep)) err(`story ${id}: dependsOn 引用了不存在的 story ${dep}`);
196
- }
197
- if (s.blocked) {
198
- if (!s.blocked.reason) err(`story ${id}: blocked 缺少 reason(堵在哪要说清)`);
199
- if (!DATE_RE.test(s.blocked.since ?? '')) err(`story ${id}: blocked.since 格式应为 YYYY-MM-DD`);
200
- if (status === 'done') err(`story ${id}: 已 done 却还标着 blocked`);
201
- if (status === 'cancelled') err(`story ${id}: 已取消却还标着 blocked(不做了就别再挂着阻塞)`);
202
- if (sprintId && !STORY_TERMINAL.includes(status))
203
- warn(`story ${id}: 处于 ${sprintId} 但被阻塞(${s.blocked.reason}),站会必须处理`);
204
- }
205
-
206
- // 范围变更标记
207
- if (s.scopeChange) {
208
- if (!SCOPE_KIND.includes(s.scopeChange.kind)) err(`story ${id}: scopeChange.kind 必须是 ${SCOPE_KIND.join('/')}`);
209
- if (!DATE_RE.test(s.scopeChange.at ?? '')) err(`story ${id}: scopeChange.at 格式应为 YYYY-MM-DD`);
210
- else if (s.scopeChange.at > today) warn(`story ${id}: scopeChange.at ${s.scopeChange.at} 在未来`);
211
- if (!s.scopeChange.reason) warn(`story ${id}: scopeChange 缺少 reason`);
212
- }
213
-
214
- if (sprintId) {
215
- const sp = sprintById.get(sprintId);
216
- if (!sp) err(`story ${id}: sprintId ${sprintId} 不存在`);
217
- else if (!(sp.stories ?? []).includes(id))
218
- err(`story ${id}: 指向 ${sprintId},但该 sprint 的 stories 里没有它(双向引用不一致)`);
219
- if (status !== 'cancelled' && (!acceptance || acceptance.length === 0))
220
- err(`story ${id}: 已进 sprint 但无验收标准(不满足 ready)`);
221
- }
222
-
223
- if (s.schedule) {
224
- const { start, end } = s.schedule;
225
- if (!DATE_RE.test(start ?? '') || !DATE_RE.test(end ?? '')) err(`story ${id}: schedule 日期格式应为 YYYY-MM-DD`);
226
- else if (start > end) err(`story ${id}: schedule 起点 ${start} 晚于终点 ${end}`);
227
- else if (!sprintId) warn(`story ${id}: 未排期却有计划区间 ${start}~${end}`);
228
- else {
229
- const sp = sprintById.get(sprintId);
230
- if (sp && (start < sp.start || end > sp.end))
231
- warn(`story ${id}: 计划区间 ${start}~${end} 超出 ${sprintId}(${sp.start}~${sp.end})`);
232
- }
233
- }
234
-
235
- if (!Array.isArray(tasks) || tasks.length === 0) warn(`story ${id}: 没有拆 task,TDD 无从落地`);
236
- for (const t of tasks ?? []) {
237
- if (!TASK_FLOW.includes(t.status)) err(`task ${t.id}: 非法状态 ${t.status}`);
238
- if (!['test', 'impl', 'refactor', 'chore'].includes(t.type)) err(`task ${t.id}: 非法类型 ${t.type}`);
239
- if (!t.title) err(`task ${t.id}: 缺少 title`);
240
- }
241
- if (status === 'done') {
242
- for (const t of tasks ?? []) {
243
- if (t.status !== 'done') err(`story ${id} 已 done 但 task ${t.id} 状态为 ${t.status}`);
244
- }
245
- }
246
- }
247
-
248
- // ---- Sprint ----
249
- let active = 0;
250
- for (const sp of sprints) {
251
- if (!sp.name) warn(`sprint ${sp.id}: 缺少 name`);
252
- if (!DATE_RE.test(sp.start ?? '') || !DATE_RE.test(sp.end ?? '')) err(`sprint ${sp.id}: 起止日期格式应为 YYYY-MM-DD`);
253
- else if (sp.start > sp.end) err(`sprint ${sp.id}: 起点 ${sp.start} 晚于终点 ${sp.end}`);
254
- if (!Number.isFinite(sp.capacity)) err(`sprint ${sp.id}: capacity 必须是数字`);
255
- for (const sid of sp.stories ?? []) {
256
- const st = storyById.get(sid);
257
- if (!st) { err(`sprint ${sp.id}: 引用 story ${sid} 不存在`); continue; }
258
- if (st.sprintId !== sp.id) err(`sprint ${sp.id}: story ${sid} 的 sprintId 是 ${st.sprintId},双向引用不一致`);
259
- if (st.status === 'cancelled')
260
- warn(`story ${sid}: 已取消但仍排在 ${sp.id} 里,建议从迭代移出(点数已不计入承诺)`);
261
- }
262
- // 已取消的故事不再占迭代容量:承诺点数只算还可能交付的
263
- const committed = (sp.stories ?? []).reduce((a, sid) => {
264
- const st = storyById.get(sid);
265
- return st && st.status !== 'cancelled' ? a + (st.points ?? 0) : a;
266
- }, 0);
267
- if (Number.isFinite(sp.capacity) && committed > sp.capacity)
268
- warn(`sprint ${sp.id}: 承诺 ${committed} 点超过容量 ${sp.capacity} 点`);
269
- if (sp.start <= today && sp.end >= today) active++;
270
- }
271
- if (active > 1) err(`同时存在 ${active} 个活跃 sprint,只允许一个`);
272
-
273
- // ---- 迭代 id 规则:R<发布周期>-S<期内冲刺号>,或纯序号 S-01 ----
274
- {
275
- const SPRINT_ID_RE = /^(?:R(\d+)-)?S-?(\d{1,2})$/;
276
- const parsed = [];
277
- for (const sp of sprints) {
278
- const m = SPRINT_ID_RE.exec(sp.id ?? '');
279
- if (!m) { err(`sprint ${sp.id}: id 必须形如 R1-S1(发布周期-冲刺)或 S-01(纯序号)`); continue; }
280
- parsed.push({ sp, release: m[1] ?? null, seq: Number(m[2]) });
281
- }
282
- const styles = new Set(parsed.map(p => (p.release === null ? 'seq' : 'rel')));
283
- if (styles.size > 1) err('sprint id 风格混用:R<n>-S<n> 与 S-<n> 不许同时出现');
284
-
285
- const byRelease = new Map();
286
- for (const p of parsed) {
287
- const key = p.release ?? 'seq';
288
- if (!byRelease.has(key)) byRelease.set(key, []);
289
- byRelease.get(key).push(p);
290
- }
291
-
292
- for (const [key, list] of byRelease) {
293
- const seqs = list.map(p => p.seq).sort((a, b) => a - b);
294
- const dup = seqs.find((v, i) => i > 0 && v === seqs[i - 1]);
295
- if (dup) err(`发布周期 ${key} 内冲刺序号重复:S${dup}`);
296
- else if (seqs.some((v, i) => v !== i + 1))
297
- err(`发布周期 ${key} 内冲刺序号应为 S1..S${seqs.length} 连续,实际 ${seqs.map(s => `S${s}`).join('/')}`);
298
- if (list.length > 2) warn(`发布周期 ${key} 内有 ${list.length} 个冲刺(约定一期不超过 2 个)`);
299
- }
300
-
301
- // 期号越大,起始日期应越晚
302
- const rels = [...byRelease.entries()]
303
- .filter(([k]) => k !== 'seq')
304
- .map(([k, list]) => ({ r: Number(k), start: list.map(p => p.sp.start).sort()[0] }))
305
- .sort((a, b) => a.r - b.r);
306
- for (let i = 1; i < rels.length; i++) {
307
- if (rels[i].start < rels[i - 1].start)
308
- warn(`发布周期 R${rels[i].r} 起始 ${rels[i].start} 早于 R${rels[i - 1].r}(${rels[i - 1].start}),期号与时间顺序不一致`);
309
- }
310
- }
311
-
312
- // ---- Milestone ----
313
- for (const m of milestones) {
314
- if (!m.title) err(`milestone ${m.id}: 缺少 title`);
315
- if (!['mvp', 'release', 'checkpoint'].includes(m.kind)) err(`milestone ${m.id}: 非法 kind ${m.kind}`);
316
- if (!MILESTONE_FLOW.includes(m.status)) err(`milestone ${m.id}: 非法状态 ${m.status}`);
317
- if (!DATE_RE.test(m.due ?? '')) err(`milestone ${m.id}: due 格式应为 YYYY-MM-DD`);
318
- if (!m.criteria) warn(`milestone ${m.id}: 缺少 criteria(达成口径),没法判断是否 reached`);
319
- if (m.sprintId) {
320
- const sp = sprintById.get(m.sprintId);
321
- if (!sp) err(`milestone ${m.id}: sprintId ${m.sprintId} 不存在`);
322
- else if (DATE_RE.test(m.due ?? '') && m.due > sp.end)
323
- warn(`milestone ${m.id}: due ${m.due} 晚于所属 ${m.sprintId} 结束日 ${sp.end}`);
324
- }
325
- if (!(m.stories ?? []).length) warn(`milestone ${m.id}: 没有挂任何 story,无法验证达成`);
326
- for (const sid of m.stories ?? []) {
327
- if (!storyById.has(sid)) err(`milestone ${m.id}: 引用 story ${sid} 不存在`);
328
- }
329
- if (m.status === 'reached') {
330
- for (const sid of m.stories ?? []) {
331
- const st = storyById.get(sid);
332
- if (st && !['done', 'cancelled'].includes(st.status))
333
- err(`milestone ${m.id} 已 reached 但 story ${sid} 状态为 ${st.status}`);
334
- }
335
- }
336
- if (m.status === 'planned' && DATE_RE.test(m.due ?? '') && m.due < today)
337
- warn(`milestone ${m.id}: 状态 planned 但 due ${m.due} 已过期,应改成 at-risk / missed`);
338
- }
339
-
340
- // ---- Journal 与 TDD 纪律 ----
341
- const journalByTask = new Map();
342
- for (const j of journals) {
343
- if (!j.taskId) { err('journal: 缺少 taskId'); continue; }
344
- if (journalByTask.has(j.taskId)) err(`journal: taskId ${j.taskId} 重复`);
345
- journalByTask.set(j.taskId, j);
346
- const hit = taskById.get(j.taskId);
347
- if (!hit) { err(`journal ${j.taskId}: 不指向任何真实 task`); continue; }
348
- if (j.storyId !== hit.story.id) err(`journal ${j.taskId}: storyId ${j.storyId} 与 task 所属 ${hit.story.id} 不一致`);
349
- let prev = '';
350
- for (const e of j.entries ?? []) {
351
- if (!['red', 'green', 'refactor'].includes(e.phase)) err(`journal ${j.taskId}: 非法 phase ${e.phase}`);
352
- if (!DATE_RE.test(e.date ?? '')) err(`journal ${j.taskId}: date 格式应为 YYYY-MM-DD`);
353
- else if (e.date > today) warn(`journal ${j.taskId}: 记录日期 ${e.date} 在未来`);
354
- if (prev && e.date < prev) err(`journal ${j.taskId}: 记录日期倒序 ${prev} → ${e.date}`);
355
- prev = e.date;
356
- if (!e.note) warn(`journal ${j.taskId}: ${e.phase} 缺 note`);
357
- if (!e.result) warn(`journal ${j.taskId}: ${e.phase} 缺 result`);
358
- }
359
- }
360
-
361
- for (const [tid, { task }] of taskById) {
362
- const hasRed = ['red', 'green', 'refactored', 'done'].includes(task.status);
363
- const hasGreen = ['green', 'refactored', 'done'].includes(task.status);
364
- const j = journalByTask.get(tid);
365
- if (!hasRed) {
366
- if (j && (j.entries ?? []).length > 0) warn(`${tid}: 尚在 todo 但 journal 已有实施记录(检查是否提前动了手)`);
367
- continue;
368
- }
369
- if (!j) { err(`task ${tid}: 状态 ${task.status} 但 journal 缺失`); continue; }
370
- const entries = j.entries ?? [];
371
- const firstRed = entries.findIndex(e => e.phase === 'red');
372
- const firstGreen = entries.findIndex(e => e.phase === 'green');
373
- if (firstRed < 0) err(`task ${tid}: journal 没有 red 记录(先写失败测试的铁律被违反)`);
374
- else if (entries[firstRed].result && !/^fail/i.test(entries[firstRed].result))
375
- err(`task ${tid}: 第一条 red 记录的 result 应为 fail,实际 "${entries[firstRed].result}"`);
376
- if (hasGreen) {
377
- if (firstGreen < 0) err(`task ${tid}: 状态已过 red 但 journal 没有 green 记录`);
378
- else if (entries[firstGreen].result && !/^pass/i.test(entries[firstGreen].result))
379
- err(`task ${tid}: 第一条 green 记录的 result 应为 pass`);
380
- }
381
- if (task.status === 'done') {
382
- const last = entries[entries.length - 1];
383
- if (!last || !last.result || !/^pass/i.test(last.result))
384
- err(`task ${tid}: 已 done 但 journal 末条记录不是 pass`);
385
- }
386
- }
387
-
388
- // ---- last-change.json(每次交付的汇报,可选文件) ----
389
- // 文件不存在 = 还没交付过东西,合法;存在就必须能被看板「最后更新」页原样渲染。
390
- {
391
- const p = join(AGILE, 'last-change.json');
392
- if (existsSync(p)) {
393
- const ACTION = ['added', 'updated', 'removed'];
394
- const CHECK = ['pass', 'fail', 'partial', 'skipped'];
395
- let doc = null;
396
- try {
397
- doc = JSON.parse(readFileSync(p, 'utf8'));
398
- } catch (e) {
399
- err(`agile/last-change.json: JSON 解析失败 - ${e.message}`);
400
- }
401
- if (doc) {
402
- if (doc.schemaVersion !== 3) err(`agile/last-change.json: schemaVersion 应为 3,实际 ${doc.schemaVersion}`);
403
- const c = doc.lastChange;
404
- if (!c || typeof c !== 'object' || Array.isArray(c)) {
405
- err('last-change: 缺少 lastChange 对象(整个交付说明都装在里面)');
406
- } else {
407
- if (!c.title) err('last-change: 缺少 title(这次交付做成了什么)');
408
- if (!stampOk(c.at)) {
409
- err('last-change: at 格式应为 YYYY-MM-DD HH:mm:ss(时分秒可省,但省了页面上只显示到天)');
410
- } else if (typeof c.at === 'string' && c.at.length === 10) {
411
- warn('last-change: at 只有日期,页面只会显示到天;建议写成 YYYY-MM-DD HH:mm:ss');
412
- } else if (stampDate(c.at) > today) {
413
- warn(`last-change: at ${c.at} 在未来`);
414
- }
415
-
416
- // 1. 故事 / 任务变更清单
417
- const changes = Array.isArray(c.changes) ? c.changes : [];
418
- if (!changes.length) warn('last-change: changes 为空(这次交付没碰任何故事或任务?)');
419
- for (const [i, ch] of changes.entries()) {
420
- const at = `last-change.changes[${i}]`;
421
- if (!['story', 'task'].includes(ch.kind)) err(`${at}: kind 必须是 story / task,实际 ${ch.kind}`);
422
- if (!ch.id) err(`${at}: 缺少 id`);
423
- if (!ACTION.includes(ch.action)) err(`${at}: action 必须是 ${ACTION.join('/')},实际 ${ch.action}`);
424
- if (!ch.title) warn(`${at}: 缺 title,看板表格里只剩一个编号`);
425
- if (ch.files !== undefined && !Array.isArray(ch.files)) err(`${at}: files 必须是数组`);
426
- if (ch.kind === 'story' && ch.id && !storyById.has(ch.id)) err(`${at}: 故事 ${ch.id} 不存在`);
427
- if (ch.kind === 'task' && ch.id && !taskById.has(ch.id)) err(`${at}: 任务 ${ch.id} 不存在`);
428
- }
429
-
430
- // 2. 额外做的事
431
- for (const [i, x] of (Array.isArray(c.extras) ? c.extras : []).entries()) {
432
- if (!x?.title) err(`last-change.extras[${i}]: 缺少 title(做了什么要写清)`);
433
- if (x?.files !== undefined && !Array.isArray(x.files)) err(`last-change.extras[${i}]: files 必须是数组`);
434
- }
435
-
436
- // 3. 验证结果
437
- for (const [i, v] of (Array.isArray(c.verification) ? c.verification : []).entries()) {
438
- if (!v?.what) err(`last-change.verification[${i}]: 缺少 what(验的是哪一项)`);
439
- if (!CHECK.includes(v?.result)) err(`last-change.verification[${i}]: result 必须是 ${CHECK.join('/')},实际 ${v?.result}`);
440
- }
441
-
442
- // 4. 账号密码与调试地址
443
- if (c.access !== undefined) {
444
- for (const [i, u] of (Array.isArray(c.access?.urls) ? c.access.urls : []).entries()) {
445
- if (!u?.label) warn(`last-change.access.urls[${i}]: 缺 label,看板只会显示一串地址`);
446
- if (!/^https?:\/\/\S+$/.test(u?.url ?? '')) err(`last-change.access.urls[${i}]: url 必须是 http(s) 开头的完整地址,实际 ${u?.url}`);
447
- }
448
- for (const [i, a] of (Array.isArray(c.access?.accounts) ? c.access.accounts : []).entries()) {
449
- if (!a?.username) err(`last-change.access.accounts[${i}]: 缺少 username`);
450
- if (a?.password !== undefined && typeof a.password !== 'string') err(`last-change.access.accounts[${i}]: password 必须是字符串`);
451
- }
452
- }
453
-
454
- // 5. 测试前准备
455
- for (const [i, s] of (Array.isArray(c.preparation) ? c.preparation : []).entries()) {
456
- if (!s?.step) err(`last-change.preparation[${i}]: 缺少 step(要做什么)`);
457
- }
458
-
459
- // 6. 逐条测试场景
460
- for (const [i, s] of (Array.isArray(c.scenarios) ? c.scenarios : []).entries()) {
461
- const at = `场景 ${s?.id ?? `#${i + 1}`}`;
462
- if (!s?.id) warn(`last-change.scenarios[${i}]: 缺 id,逐条核对时没法引用`);
463
- if (!s?.title) err(`last-change.${at}: 缺少 title`);
464
- if (!s?.expect) err(`last-change.${at}: 缺少 expect(验收标准是什么)`);
465
- if (s?.steps !== undefined && (!Array.isArray(s.steps) || s.steps.some(x => typeof x !== 'string' || !x.trim())))
466
- err(`last-change.${at}: steps 必须是非空字符串数组(一步一条)`);
467
- if (Array.isArray(s?.steps) && !s.steps.length) warn(`last-change.${at}: steps 为空,用户照着没法走`);
468
- }
469
-
470
- // 合并标记:未提交的旧记录被并进来时才出现
471
- if (c.merged !== undefined && typeof c.merged !== 'boolean')
472
- err('last-change.merged: 必须是布尔值');
473
- if (c.mergedFrom !== undefined) {
474
- if (!Array.isArray(c.mergedFrom)) err('last-change.mergedFrom: 必须是数组');
475
- else for (const [i, m] of c.mergedFrom.entries()) {
476
- if (!m?.title) err(`last-change.mergedFrom[${i}]: 缺少 title(并进来的是哪次交付)`);
477
- if (m?.at !== undefined && !stampOk(m.at)) err(`last-change.mergedFrom[${i}]: at 格式应为 YYYY-MM-DD HH:mm:ss`);
478
- }
479
- if (Array.isArray(c.mergedFrom) && !c.mergedFrom.length) warn('last-change.mergedFrom: 空数组没有意义,删掉它');
480
- }
481
- if (c.merged === true && !(Array.isArray(c.mergedFrom) && c.mergedFrom.length))
482
- warn('last-change.merged 为 true 但没写 mergedFrom,看板讲不清并了哪几次');
483
-
484
- // 7. 说明 / 遗留
485
- for (const key of ['notes', 'open']) {
486
- if (c[key] !== undefined && (!Array.isArray(c[key]) || c[key].some(x => typeof x !== 'string')))
487
- err(`last-change.${key}: 必须是字符串数组`);
488
- }
489
- }
490
- }
491
- }
492
- }
493
-
494
- // ---- tests.json(测试资产,可选文件) ----
495
- // 不存在 = 这个项目还没登记测试用例,合法(老项目升级不受影响);
496
- // 存在就必须能被 /tests 页原样渲染,且能支撑「覆盖率缺口」的判断。
497
- {
498
- const p = join(AGILE, 'tests.json');
499
- if (existsSync(p)) {
500
- const LAYER = ['frontend', 'backend', 'e2e'];
501
- const LEVEL = ['unit', 'integration', 'e2e'];
502
- const CASE_STATUS = ['pass', 'fail', 'skipped', 'blocked', 'not-run'];
503
- const COVER_KEYS = ['lines', 'branches', 'functions', 'statements'];
504
- const COVER_REF = /^(S-\d+)-A(\d+)$/;
505
- let doc = null;
506
- try {
507
- doc = JSON.parse(readFileSync(p, 'utf8'));
508
- } catch (e) {
509
- err(`agile/tests.json: JSON 解析失败 - ${e.message}`);
510
- }
511
- if (doc) {
512
- if (doc.schemaVersion !== 3) err(`agile/tests.json: schemaVersion 应为 3,实际 ${doc.schemaVersion}`);
513
- const suites = Array.isArray(doc.suites) ? doc.suites : [];
514
- const runs = Array.isArray(doc.runs) ? doc.runs : [];
515
- if (!Array.isArray(doc.suites)) err('tests: suites 必须是数组(没有用例就写空数组)');
516
- if (!Array.isArray(doc.runs)) err('tests: runs 必须是数组(没跑过就写空数组)');
517
-
518
- const caseById = new Map();
519
- const cases = [];
520
- const coveredRefs = new Set();
521
- const registeredStories = new Set();
522
-
523
- for (const su of suites) {
524
- claim(su.id, 'tests suite');
525
- if (!su.name) err(`tests suite ${su.id ?? '?'}: 缺少 name`);
526
- if (!LAYER.includes(su.layer)) err(`tests suite ${su.id}: layer 必须是 ${LAYER.join('/')},实际 ${su.layer}`);
527
- if (!LEVEL.includes(su.level)) err(`tests suite ${su.id}: level 必须是 ${LEVEL.join('/')},实际 ${su.level}`);
528
- if (su.files !== undefined && (!Array.isArray(su.files) || su.files.some(f => typeof f !== 'string')))
529
- err(`tests suite ${su.id}: files 必须是字符串数组`);
530
- if (!Array.isArray(su.cases) || su.cases.length === 0) {
531
- warn(`tests suite ${su.id}: 没有用例(空套件不该留在清单里)`);
532
- }
533
- for (const c of su.cases ?? []) {
534
- claim(c.id, `tests suite ${su.id} 的 case`);
535
- if (!c.title) err(`tests case ${c.id ?? '?'}: 缺少 title`);
536
- if (typeof c.automated !== 'boolean') err(`tests case ${c.id}: automated 必须是布尔值(手工用例也要登记)`);
537
- if (!CASE_STATUS.includes(c.status))
538
- err(`tests case ${c.id}: status 必须是 ${CASE_STATUS.join('/')},实际 ${c.status}`);
539
- if (!c.expect) err(`tests case ${c.id}: 缺少 expect(预期结果,QA 交付物不能只有标题)`);
540
- if (c.status !== 'not-run') {
541
- if (!DATE_RE.test(c.lastRun ?? '')) err(`tests case ${c.id}: lastRun 格式应为 YYYY-MM-DD(没跑过就写 not-run)`);
542
- else if (c.lastRun > today) warn(`tests case ${c.id}: lastRun ${c.lastRun} 在未来`);
543
- }
544
- if (c.durationMs !== undefined && (!Number.isFinite(c.durationMs) || c.durationMs < 0))
545
- err(`tests case ${c.id}: durationMs 必须是非负数字`);
546
- if (c.covers !== undefined && !Array.isArray(c.covers)) err(`tests case ${c.id}: covers 必须是数组`);
547
- const covers = Array.isArray(c.covers) ? c.covers : [];
548
- for (const ref of covers) {
549
- const m = COVER_REF.exec(typeof ref === 'string' ? ref : '');
550
- if (!m) { err(`tests case ${c.id}: covers 的 ${ref} 格式应为 S-xxx-A<n>`); continue; }
551
- const st = storyById.get(m[1]);
552
- if (!st) { err(`tests case ${c.id}: covers 指向不存在的故事 ${m[1]}`); continue; }
553
- const total = (st.acceptance ?? []).length;
554
- if (Number(m[2]) < 1 || Number(m[2]) > total)
555
- err(`tests case ${c.id}: covers ${ref} 越界(${st.id} 只有 ${total} 条验收)`);
556
- else { coveredRefs.add(ref); registeredStories.add(st.id); }
557
- }
558
- if (c.taskId) {
559
- const hit = taskById.get(c.taskId);
560
- if (!hit) err(`tests case ${c.id}: task ${c.taskId} 不存在`);
561
- else {
562
- const storyIds = new Set(covers.map(r => String(r).replace(/-A\d+$/, '')).filter(id => storyById.has(id)));
563
- if (storyIds.size && !storyIds.has(hit.story.id))
564
- err(`tests case ${c.id}: task ${c.taskId} 属于 ${hit.story.id},与 covers 指向的故事不一致`);
565
- }
566
- }
567
- caseById.set(c.id, c);
568
- cases.push(c);
569
- }
570
- }
571
-
572
- // 执行记录:时间正序(最后一条 = 最近一次),汇总要自洽
573
- let prev = '';
574
- for (const [i, r] of runs.entries()) {
575
- if (!stampOk(r.at)) err(`tests.runs[${i}]: at 格式应为 YYYY-MM-DD HH:mm:ss`);
576
- else {
577
- if (prev && r.at < prev) err(`tests.runs[${i}]: 必须按时间正序排(最新一条放最后),${prev} 之后出现了 ${r.at}`);
578
- prev = r.at;
579
- }
580
- if (!r.command) warn(`tests.runs[${i}]: 缺少 command(这次是怎么跑起来的)`);
581
- const t = r.totals;
582
- if (!t || typeof t !== 'object') err(`tests.runs[${i}]: 缺少 totals`);
583
- else {
584
- for (const k of ['total', 'pass', 'fail', 'skipped']) {
585
- if (!Number.isFinite(t[k])) err(`tests.runs[${i}].totals.${k}: 必须是数字`);
586
- }
587
- if (Number.isFinite(t.total) && (t.pass ?? 0) + (t.fail ?? 0) + (t.skipped ?? 0) > t.total)
588
- err(`tests.runs[${i}].totals: 通过 + 失败 + 跳过 加总超过总数 ${t.total},执行记录自相矛盾`);
589
- }
590
- if (r.failedCases !== undefined && !Array.isArray(r.failedCases))
591
- err(`tests.runs[${i}]: failedCases 必须是数组`);
592
- for (const id of r.failedCases ?? []) {
593
- if (!caseById.has(id)) err(`tests.runs[${i}]: failedCases 里的 ${id} 不在用例清单里`);
594
- }
595
- if (r.coverage !== undefined) {
596
- if (!r.coverage || typeof r.coverage !== 'object' || Array.isArray(r.coverage))
597
- err(`tests.runs[${i}].coverage: 必须是按层的对象(frontend / backend)`);
598
- for (const [layer, cov] of Object.entries(r.coverage ?? {})) {
599
- if (!LAYER.includes(layer)) err(`tests.runs[${i}].coverage: 层的键只能是 ${LAYER.join('/')},实际 ${layer}`);
600
- if (!cov || typeof cov !== 'object') { err(`tests.runs[${i}].coverage.${layer}: 必须是对象`); continue; }
601
- if (!cov.tool) warn(`tests.runs[${i}].coverage.${layer}: 缺少 tool(覆盖率是哪个工具出的)`);
602
- for (const k of COVER_KEYS) {
603
- if (cov[k] === undefined) continue;
604
- if (!Number.isFinite(cov[k]) || cov[k] < 0 || cov[k] > 100)
605
- err(`tests.runs[${i}].coverage.${layer}.${k}: 百分比必须在 0~100 之间,实际 ${cov[k]}`);
606
- }
607
- }
608
- }
609
- }
610
-
611
- const latest = runs[runs.length - 1];
612
- for (const id of latest?.failedCases ?? []) {
613
- const c = caseById.get(id);
614
- if (c && c.status !== 'fail')
615
- err(`tests: 最新一次执行的 failedCases 里的 ${id} 状态是 ${c.status},应为 fail(执行记录与用例清单不一致)`);
616
- }
617
- if (latest?.totals?.total > cases.length) {
618
- warn(`tests.json 只登记了 ${cases.length} 条用例,最近一次执行共 ${latest.totals.total} 条:还有 ${latest.totals.total - cases.length} 条没登记`);
619
- }
620
-
621
- // 收口纪律:登记过用例的故事,不许漏 test 任务、不许漏验收
622
- for (const s of stories) {
623
- if (!registeredStories.has(s.id)) continue;
624
- for (const t of s.tasks ?? []) {
625
- if (t.type === 'test' && t.status === 'done' && !cases.some(c => c.taskId === t.id))
626
- err(`task ${t.id}: 已 done 的 test 型任务没有登记用例(story ${s.id} 已经登记了用例,别漏这一条)`);
627
- }
628
- if (['review', 'done'].includes(s.status)) {
629
- (s.acceptance ?? []).forEach((_, i) => {
630
- const ref = `${s.id}-A${i + 1}`;
631
- if (!coveredRefs.has(ref))
632
- err(`story ${s.id}: 验收 ${ref} 没有任何用例覆盖(故事已到 ${s.status})`);
633
- });
634
- }
635
- for (const c of cases) {
636
- if (!c.taskId || !c.lastRun) continue;
637
- const hit = taskById.get(c.taskId);
638
- if (!hit || hit.story.id !== s.id) continue;
639
- const passes = (journalByTask.get(c.taskId)?.entries ?? []).filter(e => /^pass/i.test(e.result ?? ''));
640
- const lastPass = passes[passes.length - 1]?.date;
641
- if (lastPass && c.lastRun < lastPass)
642
- warn(`tests case ${c.id}: lastRun ${c.lastRun} 早于 task ${c.taskId} 最后一次通过 ${lastPass}(结果该刷新了)`);
643
- }
644
- }
645
-
646
- const doneStories = stories.filter(s => s.status === 'done');
647
- const missing = doneStories.filter(s => !registeredStories.has(s.id));
648
- if (missing.length) {
649
- warn(`tests.json 只登记了 ${registeredStories.size} 个故事的用例,还有 ${missing.length} 个已完成故事完全没有用例`);
650
- }
651
- }
652
- }
653
- }
654
-
655
- // ---- 输出 ----
656
- for (const w of warns) console.warn(` WARN ${w}`);
657
- if (errors.length === 0) {
658
- const taskCount = taskById.size;
659
- console.log(`✔ 校验通过:${epics.length} epics, ${stories.length} stories (${taskCount} tasks), ${sprints.length} sprints, ${milestones.length} milestones, ${journals.length} journals`);
660
- } else {
661
- console.error(`✘ 校验失败,共 ${errors.length} 处错误:`);
662
- for (const e of errors) console.error(` - ${e}`);
663
- process.exit(1);
664
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 敏捷数据校验脚本(零依赖,schema v3:三文件结构)
4
+ * 用法: node tools/validate.mjs [目标目录] 默认当前工作目录
5
+ * 校验 <目标>/agile/ 下 backlog.json / sprints.json / journal.json:
6
+ * 引用完整性(双向)、状态机、TDD 纪律、里程碑达成口径、
7
+ * 需求属性(type/priority/value/缺陷详情/验收覆盖)、依赖无环、阻塞与范围变更。
8
+ * 另外校验可选的 last-change.json(每次交付的汇报):引用、动作枚举、验收口径、调试地址。
9
+ * 退出码: 0 通过, 1 有错误。
10
+ */
11
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
12
+ import { join, resolve, normalize, isAbsolute } from 'node:path';
13
+
14
+ const TARGET = resolve(process.argv[2] || process.cwd());
15
+ const AGILE = join(TARGET, 'agile');
16
+ const errors = [];
17
+ const warns = [];
18
+ const err = m => errors.push(m);
19
+ const warn = m => warns.push(m);
20
+
21
+ const FILES = { backlog: 'backlog.json', sprints: 'sprints.json', journal: 'journal.json' };
22
+ const data = {};
23
+ for (const [key, name] of Object.entries(FILES)) {
24
+ const p = join(AGILE, name);
25
+ if (!existsSync(p)) {
26
+ err(`agile/${name} 缺失(schema v2 只认三文件结构)`);
27
+ continue;
28
+ }
29
+ try {
30
+ data[key] = JSON.parse(readFileSync(p, 'utf8'));
31
+ } catch (e) {
32
+ err(`agile/${name}: JSON 解析失败 - ${e.message}`);
33
+ continue;
34
+ }
35
+ if (data[key].schemaVersion !== 3) err(`agile/${name}: schemaVersion 应为 3,实际 ${data[key].schemaVersion}`);
36
+ }
37
+
38
+ // v2 不兼容散文件结构:残留就报错,提醒合并
39
+ for (const rel of ['backlog/epics', 'backlog/stories', 'sprints', 'journal']) {
40
+ const p = join(AGILE, rel);
41
+ if (existsSync(p) && statSync(p).isDirectory() && readdirSync(p).some(f => f.endsWith('.json')))
42
+ err(`agile/${rel}/ 仍有散文件 JSON:v2 只认三文件结构,请合并进 backlog.json / sprints.json / journal.json`);
43
+ }
44
+
45
+ const backlog = data.backlog ?? {};
46
+ const sprintsFile = data.sprints ?? {};
47
+ const journalFile = data.journal ?? {};
48
+ const epics = Array.isArray(backlog.epics) ? backlog.epics : [];
49
+ const stories = Array.isArray(backlog.stories) ? backlog.stories : [];
50
+ const sprints = Array.isArray(sprintsFile.sprints) ? sprintsFile.sprints : [];
51
+ const milestones = Array.isArray(sprintsFile.milestones) ? sprintsFile.milestones : [];
52
+ const journals = Array.isArray(journalFile.journals) ? journalFile.journals : [];
53
+
54
+ const STORY_FLOW = ['draft', 'ready', 'in-progress', 'review', 'done', 'cancelled'];
55
+ // 终态:done 与 cancelled 都不再往前走,也不再要求验收 / 任务全部做完
56
+ const STORY_TERMINAL = ['done', 'cancelled'];
57
+ const TASK_FLOW = ['todo', 'red', 'green', 'refactored', 'done'];
58
+ const EPIC_FLOW = ['draft', 'active', 'done', 'cancelled'];
59
+ const MILESTONE_FLOW = ['planned', 'at-risk', 'reached', 'missed'];
60
+ const STORY_KIND = ['feature', 'bug', 'debt', 'spike'];
61
+ const PRIORITY = ['P0', 'P1', 'P2', 'P3'];
62
+ const SEVERITY = ['blocker', 'major', 'minor', 'trivial'];
63
+ const SCOPE_KIND = ['added-mid-sprint', 'split', 'expanded'];
64
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
65
+ /**
66
+ * 交付时间:`YYYY-MM-DD` 或 `YYYY-MM-DD HH:mm:ss`(也接受 ISO 的 T 分隔)。
67
+ * 日期必须真实存在(2026-13-01、2026-02-30 都不行),时分秒越界同样拦掉。
68
+ */
69
+ const STAMP_RE = /^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
70
+ const stampOk = value => {
71
+ const m = STAMP_RE.exec(typeof value === 'string' ? value : '');
72
+ if (!m) return false;
73
+ const [, date, hh, mm, ss] = m;
74
+ // 用 UTC 构造再比对:本地时区(东八区)下 new Date('…T00:00:00') 转 ISO 会退到前一天
75
+ const [y, mo, da] = date.split('-').map(Number);
76
+ const d = new Date(Date.UTC(y, mo - 1, da));
77
+ if (d.getUTCFullYear() !== y || d.getUTCMonth() !== mo - 1 || d.getUTCDate() !== da) return false;
78
+ if (hh === undefined) return true;
79
+ return Number(hh) <= 23 && Number(mm) <= 59 && Number(ss ?? 0) <= 59;
80
+ };
81
+ const stampDate = value => (typeof value === 'string' ? value.slice(0, 10) : '');
82
+ // 用本地日期判定「今天」,避免 UTC 偏移导致活跃迭代判定差一天
83
+ const now = new Date();
84
+ const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
85
+
86
+ // ---- 全局唯一 id ----
87
+ const ids = new Set();
88
+ function claim(id, where) {
89
+ if (!id) { err(`${where}: 缺少 id`); return; }
90
+ if (ids.has(id)) err(`${where}: id ${id} 重复`);
91
+ else ids.add(id);
92
+ }
93
+ for (const e of epics) claim(e.id, 'epic');
94
+ for (const s of stories) {
95
+ claim(s.id, 'story');
96
+ for (const t of s.tasks ?? []) {
97
+ claim(t.id, `story ${s.id} 的 task`);
98
+ if (t.id && !t.id.startsWith(`${s.id}-T`)) err(`task ${t.id}: 命名应为 ${s.id}-T<n>`);
99
+ }
100
+ }
101
+ for (const sp of sprints) claim(sp.id, 'sprint');
102
+ for (const m of milestones) claim(m.id, 'milestone');
103
+
104
+ const epicById = new Map(epics.map(e => [e.id, e]));
105
+ const storyById = new Map(stories.map(s => [s.id, s]));
106
+ const sprintById = new Map(sprints.map(s => [s.id, s]));
107
+ const taskById = new Map();
108
+ for (const s of stories) for (const t of s.tasks ?? []) taskById.set(t.id, { task: t, story: s });
109
+
110
+ // ---- 依赖图必须是有向无环 ----
111
+ {
112
+ const state = new Map(); // 1=访问中 2=已完成
113
+ const visit = (id, path) => {
114
+ const st = state.get(id) ?? 0;
115
+ if (st === 2) return;
116
+ if (st === 1) { err(`story 依赖成环: ${[...path, id].join(' → ')}`); return; }
117
+ state.set(id, 1);
118
+ for (const dep of storyById.get(id)?.dependsOn ?? []) if (storyById.has(dep)) visit(dep, [...path, id]);
119
+ state.set(id, 2);
120
+ };
121
+ for (const s of stories) visit(s.id, []);
122
+ }
123
+
124
+ // ---- Epic ----
125
+ for (const e of epics) {
126
+ if (!EPIC_FLOW.includes(e.status)) err(`epic ${e.id}: 非法状态 ${e.status}`);
127
+ if (!e.title) err(`epic ${e.id}: 缺少 title`);
128
+ if (!e.goal) err(`epic ${e.id}: 缺少 goal`);
129
+ if (!Array.isArray(e.keyResults) || e.keyResults.length === 0) warn(`epic ${e.id}: 没有 keyResults(史诗要有量化目标,否则完成与否全靠感觉)`);
130
+ for (const kr of e.keyResults ?? []) {
131
+ if (!kr.id) err(`epic ${e.id}: keyResult 缺少 id`);
132
+ if (!kr.text) err(`epic ${e.id}: keyResult ${kr.id ?? '?'} 缺少 text`);
133
+ if (!Number.isFinite(kr.target)) err(`epic ${e.id}: keyResult ${kr.id ?? '?'} 的 target 必须是数字`);
134
+ if (!Number.isFinite(kr.current)) warn(`epic ${e.id}: keyResult ${kr.id ?? '?'} 没有 current,算不出进度`);
135
+ }
136
+ for (const sid of e.stories ?? []) {
137
+ const st = storyById.get(sid);
138
+ if (!st) { err(`epic ${e.id}: 引用 story ${sid} 不存在`); continue; }
139
+ if (st.epicId !== e.id) err(`epic ${e.id}: story ${sid} 的 epicId 是 ${st.epicId},双向引用不一致`);
140
+ }
141
+ if (e.status === 'done') {
142
+ for (const sid of e.stories ?? []) {
143
+ const st = storyById.get(sid);
144
+ if (st && !['done', 'cancelled'].includes(st.status))
145
+ err(`epic ${e.id} 已 done 但 story ${sid} 状态为 ${st.status}`);
146
+ }
147
+ }
148
+ }
149
+
150
+ // ---- Story ----
151
+ for (const s of stories) {
152
+ const { id, epicId, sprintId, status, acceptance, tasks, points } = s;
153
+ if (!s.title) err(`story ${id}: 缺少 title`);
154
+ if (!epicId) warn(`story ${id}: 未挂 epic`);
155
+ else if (!epicById.has(epicId)) err(`story ${id}: epicId ${epicId} 不存在`);
156
+ else if (!(epicById.get(epicId).stories ?? []).includes(id))
157
+ err(`story ${id}: 挂在 ${epicId} 上,但该 epic 的 stories 里没有它(双向引用不一致)`);
158
+
159
+ if (!STORY_FLOW.includes(status))
160
+ err(`story ${id}: 非法状态 ${status}(合法:${STORY_FLOW.join('/')})`);
161
+ if (!Number.isFinite(points)) err(`story ${id}: points 必须是数字`);
162
+ else if (![1, 2, 3, 5, 8, 13].includes(points)) warn(`story ${id}: points ${points} 不在斐波那契序列`);
163
+
164
+ // 需求属性:类型 / 优先级 / 价值
165
+ if (!STORY_KIND.includes(s.type)) err(`story ${id}: type 必须是 ${STORY_KIND.join('/')},实际 ${s.type}`);
166
+ if (!PRIORITY.includes(s.priority)) err(`story ${id}: priority 必须是 ${PRIORITY.join('/')},实际 ${s.priority}`);
167
+ if (!Number.isFinite(s.value) || s.value <= 0) err(`story ${id}: value 必须是正数(用于算 WSJF)`);
168
+ if (s.type === 'bug') {
169
+ if (!s.bug) err(`story ${id}: type 为 bug 但缺少 bug 详情`);
170
+ else {
171
+ if (!SEVERITY.includes(s.bug.severity)) err(`story ${id}: bug.severity 必须是 ${SEVERITY.join('/')}`);
172
+ if (!s.bug.environment) err(`story ${id}: bug 缺少 environment(什么环境能复现)`);
173
+ if (!s.bug.steps) err(`story ${id}: bug 缺少 steps(怎么复现)`);
174
+ }
175
+ }
176
+
177
+ // 取消掉的故事不会再交付,别逼着为它补验收标准
178
+ if (['ready', 'in-progress', 'review', 'done'].includes(status) && (!acceptance || acceptance.length === 0))
179
+ err(`story ${id}: 状态 ${status} 但验收标准为空`);
180
+ for (const [i, a] of (acceptance ?? []).entries()) {
181
+ if (!a.given || !a.when || !a.then) err(`story ${id}: 第 ${i + 1} 条验收标准缺 given/when/then`);
182
+ // 每条验收都要能被某个 task 的测试验证,否则「验收」只是口号
183
+ for (const tid of a.verifyBy ?? []) {
184
+ const hit = taskById.get(tid);
185
+ if (!hit) err(`story ${id}: 第 ${i + 1} 条验收的 verifyBy ${tid} 不存在`);
186
+ else if (hit.story.id !== id) err(`story ${id}: 第 ${i + 1} 条验收的 verifyBy ${tid} 属于 ${hit.story.id}`);
187
+ }
188
+ if (!(a.verifyBy ?? []).length && !['draft', 'cancelled'].includes(status))
189
+ warn(`story ${id}: 第 ${i + 1} 条验收没有 verifyBy(没绑定验证任务)`);
190
+ }
191
+
192
+ // 阻塞 / 依赖
193
+ for (const dep of s.dependsOn ?? []) {
194
+ if (dep === id) err(`story ${id}: 不能依赖自己`);
195
+ else if (!storyById.has(dep)) err(`story ${id}: dependsOn 引用了不存在的 story ${dep}`);
196
+ }
197
+ if (s.blocked) {
198
+ if (!s.blocked.reason) err(`story ${id}: blocked 缺少 reason(堵在哪要说清)`);
199
+ if (!DATE_RE.test(s.blocked.since ?? '')) err(`story ${id}: blocked.since 格式应为 YYYY-MM-DD`);
200
+ if (status === 'done') err(`story ${id}: 已 done 却还标着 blocked`);
201
+ if (status === 'cancelled') err(`story ${id}: 已取消却还标着 blocked(不做了就别再挂着阻塞)`);
202
+ if (sprintId && !STORY_TERMINAL.includes(status))
203
+ warn(`story ${id}: 处于 ${sprintId} 但被阻塞(${s.blocked.reason}),站会必须处理`);
204
+ }
205
+
206
+ // 范围变更标记
207
+ if (s.scopeChange) {
208
+ if (!SCOPE_KIND.includes(s.scopeChange.kind)) err(`story ${id}: scopeChange.kind 必须是 ${SCOPE_KIND.join('/')}`);
209
+ if (!DATE_RE.test(s.scopeChange.at ?? '')) err(`story ${id}: scopeChange.at 格式应为 YYYY-MM-DD`);
210
+ else if (s.scopeChange.at > today) warn(`story ${id}: scopeChange.at ${s.scopeChange.at} 在未来`);
211
+ if (!s.scopeChange.reason) warn(`story ${id}: scopeChange 缺少 reason`);
212
+ }
213
+
214
+ if (sprintId) {
215
+ const sp = sprintById.get(sprintId);
216
+ if (!sp) err(`story ${id}: sprintId ${sprintId} 不存在`);
217
+ else if (!(sp.stories ?? []).includes(id))
218
+ err(`story ${id}: 指向 ${sprintId},但该 sprint 的 stories 里没有它(双向引用不一致)`);
219
+ if (status !== 'cancelled' && (!acceptance || acceptance.length === 0))
220
+ err(`story ${id}: 已进 sprint 但无验收标准(不满足 ready)`);
221
+ }
222
+
223
+ if (s.schedule) {
224
+ const { start, end } = s.schedule;
225
+ if (!DATE_RE.test(start ?? '') || !DATE_RE.test(end ?? '')) err(`story ${id}: schedule 日期格式应为 YYYY-MM-DD`);
226
+ else if (start > end) err(`story ${id}: schedule 起点 ${start} 晚于终点 ${end}`);
227
+ else if (!sprintId) warn(`story ${id}: 未排期却有计划区间 ${start}~${end}`);
228
+ else {
229
+ const sp = sprintById.get(sprintId);
230
+ if (sp && (start < sp.start || end > sp.end))
231
+ warn(`story ${id}: 计划区间 ${start}~${end} 超出 ${sprintId}(${sp.start}~${sp.end})`);
232
+ }
233
+ }
234
+
235
+ if (!Array.isArray(tasks) || tasks.length === 0) warn(`story ${id}: 没有拆 task,TDD 无从落地`);
236
+ for (const t of tasks ?? []) {
237
+ if (!TASK_FLOW.includes(t.status)) err(`task ${t.id}: 非法状态 ${t.status}`);
238
+ if (!['test', 'impl', 'refactor', 'chore'].includes(t.type)) err(`task ${t.id}: 非法类型 ${t.type}`);
239
+ if (!t.title) err(`task ${t.id}: 缺少 title`);
240
+ }
241
+ if (status === 'done') {
242
+ for (const t of tasks ?? []) {
243
+ if (t.status !== 'done') err(`story ${id} 已 done 但 task ${t.id} 状态为 ${t.status}`);
244
+ }
245
+ }
246
+ }
247
+
248
+ // ---- Sprint ----
249
+ let active = 0;
250
+ for (const sp of sprints) {
251
+ if (!sp.name) warn(`sprint ${sp.id}: 缺少 name`);
252
+ if (!DATE_RE.test(sp.start ?? '') || !DATE_RE.test(sp.end ?? '')) err(`sprint ${sp.id}: 起止日期格式应为 YYYY-MM-DD`);
253
+ else if (sp.start > sp.end) err(`sprint ${sp.id}: 起点 ${sp.start} 晚于终点 ${sp.end}`);
254
+ if (!Number.isFinite(sp.capacity)) err(`sprint ${sp.id}: capacity 必须是数字`);
255
+ for (const sid of sp.stories ?? []) {
256
+ const st = storyById.get(sid);
257
+ if (!st) { err(`sprint ${sp.id}: 引用 story ${sid} 不存在`); continue; }
258
+ if (st.sprintId !== sp.id) err(`sprint ${sp.id}: story ${sid} 的 sprintId 是 ${st.sprintId},双向引用不一致`);
259
+ if (st.status === 'cancelled')
260
+ warn(`story ${sid}: 已取消但仍排在 ${sp.id} 里,建议从迭代移出(点数已不计入承诺)`);
261
+ }
262
+ // 已取消的故事不再占迭代容量:承诺点数只算还可能交付的
263
+ const committed = (sp.stories ?? []).reduce((a, sid) => {
264
+ const st = storyById.get(sid);
265
+ return st && st.status !== 'cancelled' ? a + (st.points ?? 0) : a;
266
+ }, 0);
267
+ if (Number.isFinite(sp.capacity) && committed > sp.capacity)
268
+ warn(`sprint ${sp.id}: 承诺 ${committed} 点超过容量 ${sp.capacity} 点`);
269
+ if (sp.start <= today && sp.end >= today) active++;
270
+ }
271
+ if (active > 1) err(`同时存在 ${active} 个活跃 sprint,只允许一个`);
272
+
273
+ // ---- 迭代 id 规则:R<发布周期>-S<期内冲刺号>,或纯序号 S-01 ----
274
+ {
275
+ const SPRINT_ID_RE = /^(?:R(\d+)-)?S-?(\d{1,2})$/;
276
+ const parsed = [];
277
+ for (const sp of sprints) {
278
+ const m = SPRINT_ID_RE.exec(sp.id ?? '');
279
+ if (!m) { err(`sprint ${sp.id}: id 必须形如 R1-S1(发布周期-冲刺)或 S-01(纯序号)`); continue; }
280
+ parsed.push({ sp, release: m[1] ?? null, seq: Number(m[2]) });
281
+ }
282
+ const styles = new Set(parsed.map(p => (p.release === null ? 'seq' : 'rel')));
283
+ if (styles.size > 1) err('sprint id 风格混用:R<n>-S<n> 与 S-<n> 不许同时出现');
284
+
285
+ const byRelease = new Map();
286
+ for (const p of parsed) {
287
+ const key = p.release ?? 'seq';
288
+ if (!byRelease.has(key)) byRelease.set(key, []);
289
+ byRelease.get(key).push(p);
290
+ }
291
+
292
+ for (const [key, list] of byRelease) {
293
+ const seqs = list.map(p => p.seq).sort((a, b) => a - b);
294
+ const dup = seqs.find((v, i) => i > 0 && v === seqs[i - 1]);
295
+ if (dup) err(`发布周期 ${key} 内冲刺序号重复:S${dup}`);
296
+ else if (seqs.some((v, i) => v !== i + 1))
297
+ err(`发布周期 ${key} 内冲刺序号应为 S1..S${seqs.length} 连续,实际 ${seqs.map(s => `S${s}`).join('/')}`);
298
+ if (list.length > 2) warn(`发布周期 ${key} 内有 ${list.length} 个冲刺(约定一期不超过 2 个)`);
299
+ }
300
+
301
+ // 期号越大,起始日期应越晚
302
+ const rels = [...byRelease.entries()]
303
+ .filter(([k]) => k !== 'seq')
304
+ .map(([k, list]) => ({ r: Number(k), start: list.map(p => p.sp.start).sort()[0] }))
305
+ .sort((a, b) => a.r - b.r);
306
+ for (let i = 1; i < rels.length; i++) {
307
+ if (rels[i].start < rels[i - 1].start)
308
+ warn(`发布周期 R${rels[i].r} 起始 ${rels[i].start} 早于 R${rels[i - 1].r}(${rels[i - 1].start}),期号与时间顺序不一致`);
309
+ }
310
+ }
311
+
312
+ // ---- Milestone ----
313
+ for (const m of milestones) {
314
+ if (!m.title) err(`milestone ${m.id}: 缺少 title`);
315
+ if (!['mvp', 'release', 'checkpoint'].includes(m.kind)) err(`milestone ${m.id}: 非法 kind ${m.kind}`);
316
+ if (!MILESTONE_FLOW.includes(m.status)) err(`milestone ${m.id}: 非法状态 ${m.status}`);
317
+ if (!DATE_RE.test(m.due ?? '')) err(`milestone ${m.id}: due 格式应为 YYYY-MM-DD`);
318
+ if (!m.criteria) warn(`milestone ${m.id}: 缺少 criteria(达成口径),没法判断是否 reached`);
319
+ if (m.sprintId) {
320
+ const sp = sprintById.get(m.sprintId);
321
+ if (!sp) err(`milestone ${m.id}: sprintId ${m.sprintId} 不存在`);
322
+ else if (DATE_RE.test(m.due ?? '') && m.due > sp.end)
323
+ warn(`milestone ${m.id}: due ${m.due} 晚于所属 ${m.sprintId} 结束日 ${sp.end}`);
324
+ }
325
+ if (!(m.stories ?? []).length) warn(`milestone ${m.id}: 没有挂任何 story,无法验证达成`);
326
+ for (const sid of m.stories ?? []) {
327
+ if (!storyById.has(sid)) err(`milestone ${m.id}: 引用 story ${sid} 不存在`);
328
+ }
329
+ if (m.status === 'reached') {
330
+ for (const sid of m.stories ?? []) {
331
+ const st = storyById.get(sid);
332
+ if (st && !['done', 'cancelled'].includes(st.status))
333
+ err(`milestone ${m.id} 已 reached 但 story ${sid} 状态为 ${st.status}`);
334
+ }
335
+ }
336
+ if (m.status === 'planned' && DATE_RE.test(m.due ?? '') && m.due < today)
337
+ warn(`milestone ${m.id}: 状态 planned 但 due ${m.due} 已过期,应改成 at-risk / missed`);
338
+ }
339
+
340
+ // ---- Journal 与 TDD 纪律 ----
341
+ const journalByTask = new Map();
342
+ for (const j of journals) {
343
+ if (!j.taskId) { err('journal: 缺少 taskId'); continue; }
344
+ if (journalByTask.has(j.taskId)) err(`journal: taskId ${j.taskId} 重复`);
345
+ journalByTask.set(j.taskId, j);
346
+ const hit = taskById.get(j.taskId);
347
+ if (!hit) { err(`journal ${j.taskId}: 不指向任何真实 task`); continue; }
348
+ if (j.storyId !== hit.story.id) err(`journal ${j.taskId}: storyId ${j.storyId} 与 task 所属 ${hit.story.id} 不一致`);
349
+ let prev = '';
350
+ for (const e of j.entries ?? []) {
351
+ if (!['red', 'green', 'refactor'].includes(e.phase)) err(`journal ${j.taskId}: 非法 phase ${e.phase}`);
352
+ if (!DATE_RE.test(e.date ?? '')) err(`journal ${j.taskId}: date 格式应为 YYYY-MM-DD`);
353
+ else if (e.date > today) warn(`journal ${j.taskId}: 记录日期 ${e.date} 在未来`);
354
+ if (prev && e.date < prev) err(`journal ${j.taskId}: 记录日期倒序 ${prev} → ${e.date}`);
355
+ prev = e.date;
356
+ if (!e.note) warn(`journal ${j.taskId}: ${e.phase} 缺 note`);
357
+ if (!e.result) warn(`journal ${j.taskId}: ${e.phase} 缺 result`);
358
+ }
359
+ }
360
+
361
+ for (const [tid, { task }] of taskById) {
362
+ const hasRed = ['red', 'green', 'refactored', 'done'].includes(task.status);
363
+ const hasGreen = ['green', 'refactored', 'done'].includes(task.status);
364
+ const j = journalByTask.get(tid);
365
+ if (!hasRed) {
366
+ if (j && (j.entries ?? []).length > 0) warn(`${tid}: 尚在 todo 但 journal 已有实施记录(检查是否提前动了手)`);
367
+ continue;
368
+ }
369
+ if (!j) { err(`task ${tid}: 状态 ${task.status} 但 journal 缺失`); continue; }
370
+ const entries = j.entries ?? [];
371
+ const firstRed = entries.findIndex(e => e.phase === 'red');
372
+ const firstGreen = entries.findIndex(e => e.phase === 'green');
373
+ if (firstRed < 0) err(`task ${tid}: journal 没有 red 记录(先写失败测试的铁律被违反)`);
374
+ else if (entries[firstRed].result && !/^fail/i.test(entries[firstRed].result))
375
+ err(`task ${tid}: 第一条 red 记录的 result 应为 fail,实际 "${entries[firstRed].result}"`);
376
+ if (hasGreen) {
377
+ if (firstGreen < 0) err(`task ${tid}: 状态已过 red 但 journal 没有 green 记录`);
378
+ else if (entries[firstGreen].result && !/^pass/i.test(entries[firstGreen].result))
379
+ err(`task ${tid}: 第一条 green 记录的 result 应为 pass`);
380
+ }
381
+ if (task.status === 'done') {
382
+ const last = entries[entries.length - 1];
383
+ if (!last || !last.result || !/^pass/i.test(last.result))
384
+ err(`task ${tid}: 已 done 但 journal 末条记录不是 pass`);
385
+ }
386
+ }
387
+
388
+ // ---- last-change.json(每次交付的汇报,可选文件) ----
389
+ // 文件不存在 = 还没交付过东西,合法;存在就必须能被看板「最后更新」页原样渲染。
390
+ {
391
+ const p = join(AGILE, 'last-change.json');
392
+ if (existsSync(p)) {
393
+ const ACTION = ['added', 'updated', 'removed'];
394
+ const CHECK = ['pass', 'fail', 'partial', 'skipped'];
395
+ let doc = null;
396
+ try {
397
+ doc = JSON.parse(readFileSync(p, 'utf8'));
398
+ } catch (e) {
399
+ err(`agile/last-change.json: JSON 解析失败 - ${e.message}`);
400
+ }
401
+ if (doc) {
402
+ if (doc.schemaVersion !== 3) err(`agile/last-change.json: schemaVersion 应为 3,实际 ${doc.schemaVersion}`);
403
+ const c = doc.lastChange;
404
+ if (!c || typeof c !== 'object' || Array.isArray(c)) {
405
+ err('last-change: 缺少 lastChange 对象(整个交付说明都装在里面)');
406
+ } else {
407
+ if (!c.title) err('last-change: 缺少 title(这次交付做成了什么)');
408
+ if (!stampOk(c.at)) {
409
+ err('last-change: at 格式应为 YYYY-MM-DD HH:mm:ss(时分秒可省,但省了页面上只显示到天)');
410
+ } else if (typeof c.at === 'string' && c.at.length === 10) {
411
+ warn('last-change: at 只有日期,页面只会显示到天;建议写成 YYYY-MM-DD HH:mm:ss');
412
+ } else if (stampDate(c.at) > today) {
413
+ warn(`last-change: at ${c.at} 在未来`);
414
+ }
415
+
416
+ // 1. 故事 / 任务变更清单
417
+ const changes = Array.isArray(c.changes) ? c.changes : [];
418
+ if (!changes.length) warn('last-change: changes 为空(这次交付没碰任何故事或任务?)');
419
+ for (const [i, ch] of changes.entries()) {
420
+ const at = `last-change.changes[${i}]`;
421
+ if (!['story', 'task'].includes(ch.kind)) err(`${at}: kind 必须是 story / task,实际 ${ch.kind}`);
422
+ if (!ch.id) err(`${at}: 缺少 id`);
423
+ if (!ACTION.includes(ch.action)) err(`${at}: action 必须是 ${ACTION.join('/')},实际 ${ch.action}`);
424
+ if (!ch.title) warn(`${at}: 缺 title,看板表格里只剩一个编号`);
425
+ if (ch.files !== undefined && !Array.isArray(ch.files)) err(`${at}: files 必须是数组`);
426
+ if (ch.kind === 'story' && ch.id && !storyById.has(ch.id)) err(`${at}: 故事 ${ch.id} 不存在`);
427
+ if (ch.kind === 'task' && ch.id && !taskById.has(ch.id)) err(`${at}: 任务 ${ch.id} 不存在`);
428
+ }
429
+
430
+ // 2. 额外做的事
431
+ for (const [i, x] of (Array.isArray(c.extras) ? c.extras : []).entries()) {
432
+ if (!x?.title) err(`last-change.extras[${i}]: 缺少 title(做了什么要写清)`);
433
+ if (x?.files !== undefined && !Array.isArray(x.files)) err(`last-change.extras[${i}]: files 必须是数组`);
434
+ }
435
+
436
+ // 3. 验证结果
437
+ for (const [i, v] of (Array.isArray(c.verification) ? c.verification : []).entries()) {
438
+ if (!v?.what) err(`last-change.verification[${i}]: 缺少 what(验的是哪一项)`);
439
+ if (!CHECK.includes(v?.result)) err(`last-change.verification[${i}]: result 必须是 ${CHECK.join('/')},实际 ${v?.result}`);
440
+ }
441
+
442
+ // 4. 账号密码与调试地址
443
+ if (c.access !== undefined) {
444
+ for (const [i, u] of (Array.isArray(c.access?.urls) ? c.access.urls : []).entries()) {
445
+ if (!u?.label) warn(`last-change.access.urls[${i}]: 缺 label,看板只会显示一串地址`);
446
+ if (!/^https?:\/\/\S+$/.test(u?.url ?? '')) err(`last-change.access.urls[${i}]: url 必须是 http(s) 开头的完整地址,实际 ${u?.url}`);
447
+ }
448
+ for (const [i, a] of (Array.isArray(c.access?.accounts) ? c.access.accounts : []).entries()) {
449
+ if (!a?.username) err(`last-change.access.accounts[${i}]: 缺少 username`);
450
+ if (a?.password !== undefined && typeof a.password !== 'string') err(`last-change.access.accounts[${i}]: password 必须是字符串`);
451
+ }
452
+ }
453
+
454
+ // 5. 测试前准备
455
+ for (const [i, s] of (Array.isArray(c.preparation) ? c.preparation : []).entries()) {
456
+ if (!s?.step) err(`last-change.preparation[${i}]: 缺少 step(要做什么)`);
457
+ }
458
+
459
+ // 6. 逐条测试场景
460
+ for (const [i, s] of (Array.isArray(c.scenarios) ? c.scenarios : []).entries()) {
461
+ const at = `场景 ${s?.id ?? `#${i + 1}`}`;
462
+ if (!s?.id) warn(`last-change.scenarios[${i}]: 缺 id,逐条核对时没法引用`);
463
+ if (!s?.title) err(`last-change.${at}: 缺少 title`);
464
+ if (!s?.expect) err(`last-change.${at}: 缺少 expect(验收标准是什么)`);
465
+ if (s?.steps !== undefined && (!Array.isArray(s.steps) || s.steps.some(x => typeof x !== 'string' || !x.trim())))
466
+ err(`last-change.${at}: steps 必须是非空字符串数组(一步一条)`);
467
+ if (Array.isArray(s?.steps) && !s.steps.length) warn(`last-change.${at}: steps 为空,用户照着没法走`);
468
+ }
469
+
470
+ // 合并标记:未提交的旧记录被并进来时才出现
471
+ if (c.merged !== undefined && typeof c.merged !== 'boolean')
472
+ err('last-change.merged: 必须是布尔值');
473
+ if (c.mergedFrom !== undefined) {
474
+ if (!Array.isArray(c.mergedFrom)) err('last-change.mergedFrom: 必须是数组');
475
+ else for (const [i, m] of c.mergedFrom.entries()) {
476
+ if (!m?.title) err(`last-change.mergedFrom[${i}]: 缺少 title(并进来的是哪次交付)`);
477
+ if (m?.at !== undefined && !stampOk(m.at)) err(`last-change.mergedFrom[${i}]: at 格式应为 YYYY-MM-DD HH:mm:ss`);
478
+ }
479
+ if (Array.isArray(c.mergedFrom) && !c.mergedFrom.length) warn('last-change.mergedFrom: 空数组没有意义,删掉它');
480
+ }
481
+ if (c.merged === true && !(Array.isArray(c.mergedFrom) && c.mergedFrom.length))
482
+ warn('last-change.merged 为 true 但没写 mergedFrom,看板讲不清并了哪几次');
483
+
484
+ // 7. 说明 / 遗留
485
+ for (const key of ['notes', 'open']) {
486
+ if (c[key] !== undefined && (!Array.isArray(c[key]) || c[key].some(x => typeof x !== 'string')))
487
+ err(`last-change.${key}: 必须是字符串数组`);
488
+ }
489
+ }
490
+ }
491
+ }
492
+ }
493
+
494
+ // ---- tests.json(测试资产,可选文件) ----
495
+ // 不存在 = 这个项目还没登记测试用例,合法(老项目升级不受影响);
496
+ // 存在就必须能被 /tests 页原样渲染,且能支撑「覆盖率缺口」的判断。
497
+ {
498
+ const p = join(AGILE, 'tests.json');
499
+ if (existsSync(p)) {
500
+ const LAYER = ['frontend', 'backend', 'e2e'];
501
+ const LEVEL = ['unit', 'integration', 'e2e'];
502
+ const CASE_STATUS = ['pass', 'fail', 'skipped', 'blocked', 'not-run'];
503
+ const COVER_KEYS = ['lines', 'branches', 'functions', 'statements'];
504
+ const COVER_REF = /^(S-\d+)-A(\d+)$/;
505
+ let doc = null;
506
+ try {
507
+ doc = JSON.parse(readFileSync(p, 'utf8'));
508
+ } catch (e) {
509
+ err(`agile/tests.json: JSON 解析失败 - ${e.message}`);
510
+ }
511
+ if (doc) {
512
+ if (doc.schemaVersion !== 3) err(`agile/tests.json: schemaVersion 应为 3,实际 ${doc.schemaVersion}`);
513
+ const suites = Array.isArray(doc.suites) ? doc.suites : [];
514
+ const runs = Array.isArray(doc.runs) ? doc.runs : [];
515
+ if (!Array.isArray(doc.suites)) err('tests: suites 必须是数组(没有用例就写空数组)');
516
+ if (!Array.isArray(doc.runs)) err('tests: runs 必须是数组(没跑过就写空数组)');
517
+
518
+ const caseById = new Map();
519
+ const cases = [];
520
+ const coveredRefs = new Set();
521
+ const registeredStories = new Set();
522
+
523
+ for (const su of suites) {
524
+ claim(su.id, 'tests suite');
525
+ if (!su.name) err(`tests suite ${su.id ?? '?'}: 缺少 name`);
526
+ if (!LAYER.includes(su.layer)) err(`tests suite ${su.id}: layer 必须是 ${LAYER.join('/')},实际 ${su.layer}`);
527
+ if (!LEVEL.includes(su.level)) err(`tests suite ${su.id}: level 必须是 ${LEVEL.join('/')},实际 ${su.level}`);
528
+ if (su.files !== undefined && (!Array.isArray(su.files) || su.files.some(f => typeof f !== 'string')))
529
+ err(`tests suite ${su.id}: files 必须是字符串数组`);
530
+ if (!Array.isArray(su.cases) || su.cases.length === 0) {
531
+ warn(`tests suite ${su.id}: 没有用例(空套件不该留在清单里)`);
532
+ }
533
+ for (const c of su.cases ?? []) {
534
+ claim(c.id, `tests suite ${su.id} 的 case`);
535
+ if (!c.title) err(`tests case ${c.id ?? '?'}: 缺少 title`);
536
+ if (typeof c.automated !== 'boolean') err(`tests case ${c.id}: automated 必须是布尔值(手工用例也要登记)`);
537
+ if (!CASE_STATUS.includes(c.status))
538
+ err(`tests case ${c.id}: status 必须是 ${CASE_STATUS.join('/')},实际 ${c.status}`);
539
+ if (!c.expect) err(`tests case ${c.id}: 缺少 expect(预期结果,QA 交付物不能只有标题)`);
540
+ if (c.status !== 'not-run') {
541
+ if (!DATE_RE.test(c.lastRun ?? '')) err(`tests case ${c.id}: lastRun 格式应为 YYYY-MM-DD(没跑过就写 not-run)`);
542
+ else if (c.lastRun > today) warn(`tests case ${c.id}: lastRun ${c.lastRun} 在未来`);
543
+ }
544
+ if (c.durationMs !== undefined && (!Number.isFinite(c.durationMs) || c.durationMs < 0))
545
+ err(`tests case ${c.id}: durationMs 必须是非负数字`);
546
+ if (c.covers !== undefined && !Array.isArray(c.covers)) err(`tests case ${c.id}: covers 必须是数组`);
547
+ const covers = Array.isArray(c.covers) ? c.covers : [];
548
+ for (const ref of covers) {
549
+ const m = COVER_REF.exec(typeof ref === 'string' ? ref : '');
550
+ if (!m) { err(`tests case ${c.id}: covers 的 ${ref} 格式应为 S-xxx-A<n>`); continue; }
551
+ const st = storyById.get(m[1]);
552
+ if (!st) { err(`tests case ${c.id}: covers 指向不存在的故事 ${m[1]}`); continue; }
553
+ const total = (st.acceptance ?? []).length;
554
+ if (Number(m[2]) < 1 || Number(m[2]) > total)
555
+ err(`tests case ${c.id}: covers ${ref} 越界(${st.id} 只有 ${total} 条验收)`);
556
+ else { coveredRefs.add(ref); registeredStories.add(st.id); }
557
+ }
558
+ if (c.taskId) {
559
+ const hit = taskById.get(c.taskId);
560
+ if (!hit) err(`tests case ${c.id}: task ${c.taskId} 不存在`);
561
+ else {
562
+ const storyIds = new Set(covers.map(r => String(r).replace(/-A\d+$/, '')).filter(id => storyById.has(id)));
563
+ if (storyIds.size && !storyIds.has(hit.story.id))
564
+ err(`tests case ${c.id}: task ${c.taskId} 属于 ${hit.story.id},与 covers 指向的故事不一致`);
565
+ }
566
+ }
567
+ caseById.set(c.id, c);
568
+ cases.push(c);
569
+ }
570
+ }
571
+
572
+ // 执行记录:时间正序(最后一条 = 最近一次),汇总要自洽
573
+ let prev = '';
574
+ for (const [i, r] of runs.entries()) {
575
+ if (!stampOk(r.at)) err(`tests.runs[${i}]: at 格式应为 YYYY-MM-DD HH:mm:ss`);
576
+ else {
577
+ if (prev && r.at < prev) err(`tests.runs[${i}]: 必须按时间正序排(最新一条放最后),${prev} 之后出现了 ${r.at}`);
578
+ prev = r.at;
579
+ }
580
+ if (!r.command) warn(`tests.runs[${i}]: 缺少 command(这次是怎么跑起来的)`);
581
+ const t = r.totals;
582
+ if (!t || typeof t !== 'object') err(`tests.runs[${i}]: 缺少 totals`);
583
+ else {
584
+ for (const k of ['total', 'pass', 'fail', 'skipped']) {
585
+ if (!Number.isFinite(t[k])) err(`tests.runs[${i}].totals.${k}: 必须是数字`);
586
+ }
587
+ if (Number.isFinite(t.total) && (t.pass ?? 0) + (t.fail ?? 0) + (t.skipped ?? 0) > t.total)
588
+ err(`tests.runs[${i}].totals: 通过 + 失败 + 跳过 加总超过总数 ${t.total},执行记录自相矛盾`);
589
+ }
590
+ if (r.failedCases !== undefined && !Array.isArray(r.failedCases))
591
+ err(`tests.runs[${i}]: failedCases 必须是数组`);
592
+ for (const id of r.failedCases ?? []) {
593
+ if (!caseById.has(id)) err(`tests.runs[${i}]: failedCases 里的 ${id} 不在用例清单里`);
594
+ }
595
+ if (r.coverage !== undefined) {
596
+ if (!r.coverage || typeof r.coverage !== 'object' || Array.isArray(r.coverage))
597
+ err(`tests.runs[${i}].coverage: 必须是按层的对象(frontend / backend)`);
598
+ for (const [layer, cov] of Object.entries(r.coverage ?? {})) {
599
+ if (!LAYER.includes(layer)) err(`tests.runs[${i}].coverage: 层的键只能是 ${LAYER.join('/')},实际 ${layer}`);
600
+ if (!cov || typeof cov !== 'object') { err(`tests.runs[${i}].coverage.${layer}: 必须是对象`); continue; }
601
+ if (!cov.tool) warn(`tests.runs[${i}].coverage.${layer}: 缺少 tool(覆盖率是哪个工具出的)`);
602
+ for (const k of COVER_KEYS) {
603
+ if (cov[k] === undefined) continue;
604
+ if (!Number.isFinite(cov[k]) || cov[k] < 0 || cov[k] > 100)
605
+ err(`tests.runs[${i}].coverage.${layer}.${k}: 百分比必须在 0~100 之间,实际 ${cov[k]}`);
606
+ }
607
+ }
608
+ }
609
+ }
610
+
611
+ const latest = runs[runs.length - 1];
612
+ for (const id of latest?.failedCases ?? []) {
613
+ const c = caseById.get(id);
614
+ if (c && c.status !== 'fail')
615
+ err(`tests: 最新一次执行的 failedCases 里的 ${id} 状态是 ${c.status},应为 fail(执行记录与用例清单不一致)`);
616
+ }
617
+ if (latest?.totals?.total > cases.length) {
618
+ warn(`tests.json 只登记了 ${cases.length} 条用例,最近一次执行共 ${latest.totals.total} 条:还有 ${latest.totals.total - cases.length} 条没登记`);
619
+ }
620
+
621
+ // 收口纪律:登记过用例的故事,不许漏 test 任务、不许漏验收
622
+ for (const s of stories) {
623
+ if (!registeredStories.has(s.id)) continue;
624
+ for (const t of s.tasks ?? []) {
625
+ if (t.type === 'test' && t.status === 'done' && !cases.some(c => c.taskId === t.id))
626
+ err(`task ${t.id}: 已 done 的 test 型任务没有登记用例(story ${s.id} 已经登记了用例,别漏这一条)`);
627
+ }
628
+ if (['review', 'done'].includes(s.status)) {
629
+ (s.acceptance ?? []).forEach((_, i) => {
630
+ const ref = `${s.id}-A${i + 1}`;
631
+ if (!coveredRefs.has(ref))
632
+ err(`story ${s.id}: 验收 ${ref} 没有任何用例覆盖(故事已到 ${s.status})`);
633
+ });
634
+ }
635
+ for (const c of cases) {
636
+ if (!c.taskId || !c.lastRun) continue;
637
+ const hit = taskById.get(c.taskId);
638
+ if (!hit || hit.story.id !== s.id) continue;
639
+ const passes = (journalByTask.get(c.taskId)?.entries ?? []).filter(e => /^pass/i.test(e.result ?? ''));
640
+ const lastPass = passes[passes.length - 1]?.date;
641
+ if (lastPass && c.lastRun < lastPass)
642
+ warn(`tests case ${c.id}: lastRun ${c.lastRun} 早于 task ${c.taskId} 最后一次通过 ${lastPass}(结果该刷新了)`);
643
+ }
644
+ }
645
+
646
+ const doneStories = stories.filter(s => s.status === 'done');
647
+ const missing = doneStories.filter(s => !registeredStories.has(s.id));
648
+ if (missing.length) {
649
+ warn(`tests.json 只登记了 ${registeredStories.size} 个故事的用例,还有 ${missing.length} 个已完成故事完全没有用例`);
650
+ }
651
+ }
652
+ }
653
+ }
654
+
655
+ // ---- diagrams.json(架构图例,可选文件) ----
656
+ // 和 tests.json 同一套设计:不存在就跳过(老项目升级不受影响);
657
+ // 存在就必须能被 /diagrams 页原样渲染,且索引与产物文件一一对应。
658
+ {
659
+ const p = join(AGILE, 'diagrams.json');
660
+ if (existsSync(p)) {
661
+ const KIND = ['architecture', 'sequence', 'flow', 'erd', 'state', 'deploy', 'other'];
662
+ const LINT_RESULT = ['pass', 'skipped'];
663
+ let doc = null;
664
+ try {
665
+ doc = JSON.parse(readFileSync(p, 'utf8'));
666
+ } catch (e) {
667
+ err(`agile/diagrams.json: JSON 解析失败 - ${e.message}`);
668
+ }
669
+ if (doc) {
670
+ if (doc.schemaVersion !== 3) err(`agile/diagrams.json: schemaVersion 应为 3,实际 ${doc.schemaVersion}`);
671
+ if (!Array.isArray(doc.diagrams)) err('diagrams: diagrams 必须是数组(没有图例就写空数组)');
672
+ const diagrams = Array.isArray(doc.diagrams) ? doc.diagrams : [];
673
+ const byId = new Map();
674
+ const indexed = new Set();
675
+
676
+ for (const d of diagrams) {
677
+ const where = `diagram ${d.id ?? '?'}`;
678
+ claim(d.id, 'diagram');
679
+ if (!/^DG-\d{3,}$/.test(d.id ?? '')) err(`${where}: id 必须形如 DG-001`);
680
+ if (!d.title) err(`${where}: 缺少 title`);
681
+ if (!KIND.includes(d.kind)) err(`${where}: kind 必须是 ${KIND.join('/')},实际 ${d.kind}`);
682
+ if (d.catalog !== undefined && typeof d.catalog !== 'string') err(`${where}: catalog 必须是字符串(存 diagram-design 的原始图型名)`);
683
+ if (d.lint !== undefined && !LINT_RESULT.includes(d.lint)) err(`${where}: lint 只能是 ${LINT_RESULT.join('/')}`);
684
+
685
+ // 产物路径:只认 agile/ 下的相对路径,不许 .. 也不许绝对路径
686
+ if (!d.file) err(`${where}: 缺少 file(产物 HTML 的路径)`);
687
+ else {
688
+ const rel = normalize(d.file).replace(/\\/g, '/');
689
+ if (isAbsolute(d.file) || rel.startsWith('../') || !rel.startsWith('agile/diagrams/'))
690
+ err(`${where}: file 必须是 agile/diagrams/ 下的相对路径,实际 ${d.file}`);
691
+ else {
692
+ if (!existsSync(join(TARGET, rel))) err(`${where}: 产物文件不存在 ${d.file}`);
693
+ indexed.add(rel);
694
+ }
695
+ }
696
+ if (d.svg !== undefined) {
697
+ const rel = normalize(d.svg).replace(/\\/g, '/');
698
+ if (isAbsolute(d.svg) || !rel.startsWith('agile/diagrams/')) err(`${where}: svg 必须是 agile/diagrams/ 下的相对路径`);
699
+ else if (!existsSync(join(TARGET, rel))) warn(`${where}: svg 导出文件不存在 ${d.svg}`);
700
+ else indexed.add(rel);
701
+ }
702
+
703
+ // 关联:故事必须存在;任务必须存在且属于所挂的故事(与用例的 taskId 规则一致)
704
+ if (d.storyIds !== undefined && !Array.isArray(d.storyIds)) err(`${where}: storyIds 必须是数组`);
705
+ const storyIds = Array.isArray(d.storyIds) ? d.storyIds : [];
706
+ for (const sid of storyIds) if (!storyById.has(sid)) err(`${where}: storyIds 里的 ${sid} 不存在`);
707
+ if (d.taskId) {
708
+ const hit = taskById.get(d.taskId);
709
+ if (!hit) err(`${where}: task ${d.taskId} 不存在`);
710
+ else if (storyIds.length && !storyIds.includes(hit.story.id))
711
+ err(`${where}: task ${d.taskId} 属于 ${hit.story.id},与 storyIds 不一致`);
712
+ }
713
+
714
+ for (const key of ['createdAt', 'updatedAt']) {
715
+ if (!DATE_RE.test(d[key] ?? '')) err(`${where}: ${key} 格式应为 YYYY-MM-DD`);
716
+ else if (d[key] > today) warn(`${where}: ${key} ${d[key]} 在未来`);
717
+ }
718
+ if (DATE_RE.test(d.createdAt ?? '') && DATE_RE.test(d.updatedAt ?? '') && d.updatedAt < d.createdAt)
719
+ err(`${where}: updatedAt ${d.updatedAt} 早于 createdAt ${d.createdAt}`);
720
+ if (d.supersedes !== undefined) {
721
+ if (!byId.has(d.supersedes) && !diagrams.some(x => x.id === d.supersedes))
722
+ err(`${where}: supersedes 指向不存在的图例 ${d.supersedes}`);
723
+ if (d.supersedes === d.id) err(`${where}: 不许 supersedes 自己`);
724
+ }
725
+ byId.set(d.id, d);
726
+ }
727
+
728
+ // supersedes 链不能成环
729
+ for (const d of diagrams) {
730
+ const seen = new Set();
731
+ let cur = d;
732
+ while (cur?.supersedes) {
733
+ if (seen.has(cur.id)) { err(`diagram ${d.id}: supersedes 成环(${[...seen].join(' → ')})`); break; }
734
+ seen.add(cur.id);
735
+ cur = byId.get(cur.supersedes);
736
+ }
737
+ }
738
+
739
+ // 孤儿文件:索引没登记就不该躺在 agile/diagrams/ 里(绕过工具手放的最常见姿势)
740
+ const dir = join(AGILE, 'diagrams');
741
+ if (existsSync(dir) && statSync(dir).isDirectory()) {
742
+ for (const name of readdirSync(dir)) {
743
+ if (!/\.html?$/i.test(name)) continue;
744
+ const rel = `agile/diagrams/${name}`;
745
+ if (!indexed.has(rel)) warn(`${rel} 没有登记进 diagrams.json(用 node tools/diagram.mjs add 登记)`);
746
+ }
747
+ }
748
+
749
+ // 过期:图比故事最后一次通过的日志还旧,很可能画的是改之前的版本
750
+ const lastPassByStory = new Map();
751
+ for (const s of stories) {
752
+ let last = '';
753
+ for (const t of s.tasks ?? []) {
754
+ for (const e of journalByTask.get(t.id)?.entries ?? []) {
755
+ if (/^pass/i.test(e.result ?? '') && e.date > last) last = e.date;
756
+ }
757
+ }
758
+ if (last) lastPassByStory.set(s.id, last);
759
+ }
760
+ for (const d of diagrams) {
761
+ if (!DATE_RE.test(d.updatedAt ?? '')) continue;
762
+ for (const sid of d.storyIds ?? []) {
763
+ const pass = lastPassByStory.get(sid);
764
+ if (pass && d.updatedAt < pass)
765
+ warn(`diagram ${d.id}: updatedAt ${d.updatedAt} 早于 ${sid} 最后一次通过 ${pass}(图可能过期)`);
766
+ }
767
+ }
768
+ }
769
+ }
770
+ }
771
+
772
+ // ---- 输出 ----
773
+ for (const w of warns) console.warn(` WARN ${w}`);
774
+ if (errors.length === 0) {
775
+ const taskCount = taskById.size;
776
+ console.log(`✔ 校验通过:${epics.length} epics, ${stories.length} stories (${taskCount} tasks), ${sprints.length} sprints, ${milestones.length} milestones, ${journals.length} journals`);
777
+ } else {
778
+ console.error(`✘ 校验失败,共 ${errors.length} 处错误:`);
779
+ for (const e of errors) console.error(` - ${e}`);
780
+ process.exit(1);
781
+ }