@seanmars/tospec 0.19.0-beta.0 → 0.19.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (431) hide show
  1. package/CHANGELOG.md +578 -0
  2. package/README.md +71 -77
  3. package/assets/dashboard/app.js +14 -3
  4. package/assets/dashboard/style.css +7 -0
  5. package/assets/metrics/app.js +22 -0
  6. package/assets/metrics/style.css +8 -0
  7. package/assets/rules/tospec/decision.md +3 -0
  8. package/assets/rules/tospec/single-source-of-truth.md +19 -0
  9. package/dist/cli/index.d.ts.map +1 -1
  10. package/dist/cli/index.js +266 -82
  11. package/dist/cli/index.js.map +1 -1
  12. package/dist/commands/config.d.ts +9 -17
  13. package/dist/commands/config.d.ts.map +1 -1
  14. package/dist/commands/config.js +374 -95
  15. package/dist/commands/config.js.map +1 -1
  16. package/dist/commands/dashboard.d.ts +64 -81
  17. package/dist/commands/dashboard.d.ts.map +1 -1
  18. package/dist/commands/dashboard.js +350 -218
  19. package/dist/commands/dashboard.js.map +1 -1
  20. package/dist/commands/decision.d.ts +41 -19
  21. package/dist/commands/decision.d.ts.map +1 -1
  22. package/dist/commands/decision.js +400 -70
  23. package/dist/commands/decision.js.map +1 -1
  24. package/dist/commands/metrics.d.ts +34 -48
  25. package/dist/commands/metrics.d.ts.map +1 -1
  26. package/dist/commands/metrics.js +74 -84
  27. package/dist/commands/metrics.js.map +1 -1
  28. package/dist/commands/shared-output.d.ts +24 -10
  29. package/dist/commands/shared-output.d.ts.map +1 -1
  30. package/dist/commands/shared-output.js +62 -11
  31. package/dist/commands/shared-output.js.map +1 -1
  32. package/dist/commands/show.d.ts +7 -0
  33. package/dist/commands/show.d.ts.map +1 -1
  34. package/dist/commands/show.js +39 -8
  35. package/dist/commands/show.js.map +1 -1
  36. package/dist/commands/validate.d.ts +47 -30
  37. package/dist/commands/validate.d.ts.map +1 -1
  38. package/dist/commands/validate.js +282 -108
  39. package/dist/commands/validate.js.map +1 -1
  40. package/dist/commands/workflow/index.d.ts +6 -10
  41. package/dist/commands/workflow/index.d.ts.map +1 -1
  42. package/dist/commands/workflow/index.js +6 -10
  43. package/dist/commands/workflow/index.js.map +1 -1
  44. package/dist/commands/workflow/instructions.d.ts +21 -8
  45. package/dist/commands/workflow/instructions.d.ts.map +1 -1
  46. package/dist/commands/workflow/instructions.js +254 -95
  47. package/dist/commands/workflow/instructions.js.map +1 -1
  48. package/dist/commands/workflow/new-change.d.ts +4 -5
  49. package/dist/commands/workflow/new-change.d.ts.map +1 -1
  50. package/dist/commands/workflow/new-change.js +90 -25
  51. package/dist/commands/workflow/new-change.js.map +1 -1
  52. package/dist/commands/workflow/schemas.d.ts +3 -5
  53. package/dist/commands/workflow/schemas.d.ts.map +1 -1
  54. package/dist/commands/workflow/schemas.js +37 -11
  55. package/dist/commands/workflow/schemas.js.map +1 -1
  56. package/dist/commands/workflow/shared.d.ts +48 -21
  57. package/dist/commands/workflow/shared.d.ts.map +1 -1
  58. package/dist/commands/workflow/shared.js +36 -33
  59. package/dist/commands/workflow/shared.js.map +1 -1
  60. package/dist/commands/workflow/status.d.ts +10 -6
  61. package/dist/commands/workflow/status.d.ts.map +1 -1
  62. package/dist/commands/workflow/status.js +84 -42
  63. package/dist/commands/workflow/status.js.map +1 -1
  64. package/dist/commands/workflow/templates.d.ts +10 -3
  65. package/dist/commands/workflow/templates.d.ts.map +1 -1
  66. package/dist/commands/workflow/templates.js +39 -40
  67. package/dist/commands/workflow/templates.js.map +1 -1
  68. package/dist/core/archive.d.ts +26 -21
  69. package/dist/core/archive.d.ts.map +1 -1
  70. package/dist/core/archive.js +415 -201
  71. package/dist/core/archive.js.map +1 -1
  72. package/dist/core/artifact-graph/graph.d.ts +29 -36
  73. package/dist/core/artifact-graph/graph.d.ts.map +1 -1
  74. package/dist/core/artifact-graph/graph.js +50 -58
  75. package/dist/core/artifact-graph/graph.js.map +1 -1
  76. package/dist/core/artifact-graph/index.d.ts +2 -2
  77. package/dist/core/artifact-graph/index.d.ts.map +1 -1
  78. package/dist/core/artifact-graph/index.js +2 -2
  79. package/dist/core/artifact-graph/index.js.map +1 -1
  80. package/dist/core/artifact-graph/instruction-loader.d.ts +105 -100
  81. package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
  82. package/dist/core/artifact-graph/instruction-loader.js +176 -110
  83. package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
  84. package/dist/core/artifact-graph/outputs.d.ts +13 -6
  85. package/dist/core/artifact-graph/outputs.d.ts.map +1 -1
  86. package/dist/core/artifact-graph/outputs.js +146 -8
  87. package/dist/core/artifact-graph/outputs.js.map +1 -1
  88. package/dist/core/artifact-graph/resolver.d.ts +44 -63
  89. package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
  90. package/dist/core/artifact-graph/resolver.js +85 -86
  91. package/dist/core/artifact-graph/resolver.js.map +1 -1
  92. package/dist/core/artifact-graph/schema.d.ts +0 -6
  93. package/dist/core/artifact-graph/schema.d.ts.map +1 -1
  94. package/dist/core/artifact-graph/schema.js +7 -32
  95. package/dist/core/artifact-graph/schema.js.map +1 -1
  96. package/dist/core/artifact-graph/state.d.ts +1 -8
  97. package/dist/core/artifact-graph/state.d.ts.map +1 -1
  98. package/dist/core/artifact-graph/state.js +2 -17
  99. package/dist/core/artifact-graph/state.js.map +1 -1
  100. package/dist/core/artifact-graph/stub-detection.d.ts +12 -0
  101. package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -0
  102. package/dist/core/artifact-graph/stub-detection.js +39 -0
  103. package/dist/core/artifact-graph/stub-detection.js.map +1 -0
  104. package/dist/core/artifact-graph/types.d.ts +4 -0
  105. package/dist/core/artifact-graph/types.d.ts.map +1 -1
  106. package/dist/core/artifact-graph/types.js +30 -10
  107. package/dist/core/artifact-graph/types.js.map +1 -1
  108. package/dist/core/available-tools.d.ts +3 -12
  109. package/dist/core/available-tools.d.ts.map +1 -1
  110. package/dist/core/available-tools.js +4 -13
  111. package/dist/core/available-tools.js.map +1 -1
  112. package/dist/core/change-metadata/schema.d.ts +1 -1
  113. package/dist/core/change-metadata/schema.d.ts.map +1 -1
  114. package/dist/core/change-metadata/schema.js +10 -7
  115. package/dist/core/change-metadata/schema.js.map +1 -1
  116. package/dist/core/change-presenter.d.ts +23 -18
  117. package/dist/core/change-presenter.d.ts.map +1 -1
  118. package/dist/core/change-presenter.js +102 -43
  119. package/dist/core/change-presenter.js.map +1 -1
  120. package/dist/core/change-status-policy.d.ts +8 -1
  121. package/dist/core/change-status-policy.d.ts.map +1 -1
  122. package/dist/core/change-status-policy.js +25 -1
  123. package/dist/core/change-status-policy.js.map +1 -1
  124. package/dist/core/codex-metrics.d.ts +25 -45
  125. package/dist/core/codex-metrics.d.ts.map +1 -1
  126. package/dist/core/codex-metrics.js +44 -88
  127. package/dist/core/codex-metrics.js.map +1 -1
  128. package/dist/core/codex-residue.d.ts +22 -0
  129. package/dist/core/codex-residue.d.ts.map +1 -0
  130. package/dist/core/codex-residue.js +61 -0
  131. package/dist/core/codex-residue.js.map +1 -0
  132. package/dist/core/command-generation/adapters/claude.d.ts +2 -9
  133. package/dist/core/command-generation/adapters/claude.d.ts.map +1 -1
  134. package/dist/core/command-generation/adapters/claude.js +2 -12
  135. package/dist/core/command-generation/adapters/claude.js.map +1 -1
  136. package/dist/core/command-generation/adapters/index.d.ts +1 -9
  137. package/dist/core/command-generation/adapters/index.d.ts.map +1 -1
  138. package/dist/core/command-generation/adapters/index.js +1 -9
  139. package/dist/core/command-generation/adapters/index.js.map +1 -1
  140. package/dist/core/command-generation/generator.d.ts +0 -17
  141. package/dist/core/command-generation/generator.d.ts.map +1 -1
  142. package/dist/core/command-generation/generator.js +0 -17
  143. package/dist/core/command-generation/generator.js.map +1 -1
  144. package/dist/core/command-generation/index.d.ts +2 -5
  145. package/dist/core/command-generation/index.d.ts.map +1 -1
  146. package/dist/core/command-generation/index.js +0 -9
  147. package/dist/core/command-generation/index.js.map +1 -1
  148. package/dist/core/command-generation/types.d.ts +10 -36
  149. package/dist/core/command-generation/types.d.ts.map +1 -1
  150. package/dist/core/command-generation/types.js +0 -6
  151. package/dist/core/command-generation/types.js.map +1 -1
  152. package/dist/core/command-generation/yaml.d.ts +3 -18
  153. package/dist/core/command-generation/yaml.d.ts.map +1 -1
  154. package/dist/core/command-generation/yaml.js +5 -23
  155. package/dist/core/command-generation/yaml.js.map +1 -1
  156. package/dist/core/config-prompts.d.ts +2 -4
  157. package/dist/core/config-prompts.d.ts.map +1 -1
  158. package/dist/core/config-prompts.js +2 -7
  159. package/dist/core/config-prompts.js.map +1 -1
  160. package/dist/core/config-schema.d.ts +8 -53
  161. package/dist/core/config-schema.d.ts.map +1 -1
  162. package/dist/core/config-schema.js +49 -62
  163. package/dist/core/config-schema.js.map +1 -1
  164. package/dist/core/config.d.ts +56 -0
  165. package/dist/core/config.d.ts.map +1 -1
  166. package/dist/core/config.js +73 -2
  167. package/dist/core/config.js.map +1 -1
  168. package/dist/core/converters/json-converter.d.ts.map +1 -1
  169. package/dist/core/dashboard-activity.d.ts +7 -9
  170. package/dist/core/dashboard-activity.d.ts.map +1 -1
  171. package/dist/core/dashboard-activity.js +26 -24
  172. package/dist/core/dashboard-activity.js.map +1 -1
  173. package/dist/core/dashboard-data.d.ts +40 -22
  174. package/dist/core/dashboard-data.d.ts.map +1 -1
  175. package/dist/core/dashboard-data.js +84 -68
  176. package/dist/core/dashboard-data.js.map +1 -1
  177. package/dist/core/global-config.d.ts +24 -53
  178. package/dist/core/global-config.d.ts.map +1 -1
  179. package/dist/core/global-config.js +43 -62
  180. package/dist/core/global-config.js.map +1 -1
  181. package/dist/core/init.d.ts +29 -11
  182. package/dist/core/init.d.ts.map +1 -1
  183. package/dist/core/init.js +232 -164
  184. package/dist/core/init.js.map +1 -1
  185. package/dist/core/list.d.ts +1 -1
  186. package/dist/core/list.d.ts.map +1 -1
  187. package/dist/core/list.js +121 -28
  188. package/dist/core/list.js.map +1 -1
  189. package/dist/core/local-server.d.ts +63 -39
  190. package/dist/core/local-server.d.ts.map +1 -1
  191. package/dist/core/local-server.js +99 -53
  192. package/dist/core/local-server.js.map +1 -1
  193. package/dist/core/markdown-render.d.ts +25 -0
  194. package/dist/core/markdown-render.d.ts.map +1 -0
  195. package/dist/core/markdown-render.js +94 -0
  196. package/dist/core/markdown-render.js.map +1 -0
  197. package/dist/core/migrate.d.ts +32 -15
  198. package/dist/core/migrate.d.ts.map +1 -1
  199. package/dist/core/migrate.js +220 -108
  200. package/dist/core/migrate.js.map +1 -1
  201. package/dist/core/parsers/change-parser.d.ts +7 -10
  202. package/dist/core/parsers/change-parser.d.ts.map +1 -1
  203. package/dist/core/parsers/change-parser.js +48 -56
  204. package/dist/core/parsers/change-parser.js.map +1 -1
  205. package/dist/core/parsers/markdown-parser.d.ts +8 -9
  206. package/dist/core/parsers/markdown-parser.d.ts.map +1 -1
  207. package/dist/core/parsers/markdown-parser.js +31 -22
  208. package/dist/core/parsers/markdown-parser.js.map +1 -1
  209. package/dist/core/parsers/requirement-blocks.d.ts +53 -11
  210. package/dist/core/parsers/requirement-blocks.d.ts.map +1 -1
  211. package/dist/core/parsers/requirement-blocks.js +200 -60
  212. package/dist/core/parsers/requirement-blocks.js.map +1 -1
  213. package/dist/core/parsers/requirement-text.d.ts +73 -79
  214. package/dist/core/parsers/requirement-text.d.ts.map +1 -1
  215. package/dist/core/parsers/requirement-text.js +137 -79
  216. package/dist/core/parsers/requirement-text.js.map +1 -1
  217. package/dist/core/parsers/spec-structure.d.ts +1 -1
  218. package/dist/core/parsers/spec-structure.d.ts.map +1 -1
  219. package/dist/core/parsers/spec-structure.js +30 -3
  220. package/dist/core/parsers/spec-structure.js.map +1 -1
  221. package/dist/core/planning-home.js.map +1 -1
  222. package/dist/core/profiles.d.ts +3 -10
  223. package/dist/core/profiles.d.ts.map +1 -1
  224. package/dist/core/profiles.js +5 -12
  225. package/dist/core/profiles.js.map +1 -1
  226. package/dist/core/project-config.d.ts +43 -44
  227. package/dist/core/project-config.d.ts.map +1 -1
  228. package/dist/core/project-config.js +107 -82
  229. package/dist/core/project-config.js.map +1 -1
  230. package/dist/core/project-layout.d.ts +10 -18
  231. package/dist/core/project-layout.d.ts.map +1 -1
  232. package/dist/core/project-layout.js +16 -26
  233. package/dist/core/project-layout.js.map +1 -1
  234. package/dist/core/root-selection.d.ts +11 -7
  235. package/dist/core/root-selection.d.ts.map +1 -1
  236. package/dist/core/root-selection.js +7 -8
  237. package/dist/core/root-selection.js.map +1 -1
  238. package/dist/core/rules.d.ts +10 -0
  239. package/dist/core/rules.d.ts.map +1 -0
  240. package/dist/core/rules.js +43 -0
  241. package/dist/core/rules.js.map +1 -0
  242. package/dist/core/schema-names.d.ts +16 -0
  243. package/dist/core/schema-names.d.ts.map +1 -0
  244. package/dist/core/schema-names.js +16 -0
  245. package/dist/core/schema-names.js.map +1 -0
  246. package/dist/core/schemas/base.schema.d.ts +3 -0
  247. package/dist/core/schemas/base.schema.d.ts.map +1 -1
  248. package/dist/core/schemas/base.schema.js +22 -6
  249. package/dist/core/schemas/base.schema.js.map +1 -1
  250. package/dist/core/schemas/change.schema.d.ts +16 -0
  251. package/dist/core/schemas/change.schema.d.ts.map +1 -1
  252. package/dist/core/schemas/change.schema.js +41 -10
  253. package/dist/core/schemas/change.schema.js.map +1 -1
  254. package/dist/core/schemas/spec.schema.d.ts +2 -0
  255. package/dist/core/schemas/spec.schema.d.ts.map +1 -1
  256. package/dist/core/shared/index.d.ts +3 -8
  257. package/dist/core/shared/index.d.ts.map +1 -1
  258. package/dist/core/shared/index.js +3 -8
  259. package/dist/core/shared/index.js.map +1 -1
  260. package/dist/core/shared/rules-generation.d.ts +27 -8
  261. package/dist/core/shared/rules-generation.d.ts.map +1 -1
  262. package/dist/core/shared/rules-generation.js +151 -16
  263. package/dist/core/shared/rules-generation.js.map +1 -1
  264. package/dist/core/shared/skill-generation.d.ts +38 -53
  265. package/dist/core/shared/skill-generation.d.ts.map +1 -1
  266. package/dist/core/shared/skill-generation.js +82 -51
  267. package/dist/core/shared/skill-generation.js.map +1 -1
  268. package/dist/core/shared/tool-detection.d.ts +40 -62
  269. package/dist/core/shared/tool-detection.d.ts.map +1 -1
  270. package/dist/core/shared/tool-detection.js +88 -80
  271. package/dist/core/shared/tool-detection.js.map +1 -1
  272. package/dist/core/skill-metrics.d.ts +36 -63
  273. package/dist/core/skill-metrics.d.ts.map +1 -1
  274. package/dist/core/skill-metrics.js +34 -73
  275. package/dist/core/skill-metrics.js.map +1 -1
  276. package/dist/core/spec-presenter.d.ts.map +1 -1
  277. package/dist/core/spec-presenter.js +6 -6
  278. package/dist/core/spec-presenter.js.map +1 -1
  279. package/dist/core/specs-apply.d.ts +24 -23
  280. package/dist/core/specs-apply.d.ts.map +1 -1
  281. package/dist/core/specs-apply.js +188 -173
  282. package/dist/core/specs-apply.js.map +1 -1
  283. package/dist/core/templates/fragments/interview.d.ts +2 -6
  284. package/dist/core/templates/fragments/interview.d.ts.map +1 -1
  285. package/dist/core/templates/fragments/interview.js +2 -6
  286. package/dist/core/templates/fragments/interview.js.map +1 -1
  287. package/dist/core/templates/fragments/next-step.d.ts +4 -8
  288. package/dist/core/templates/fragments/next-step.d.ts.map +1 -1
  289. package/dist/core/templates/fragments/next-step.js +4 -8
  290. package/dist/core/templates/fragments/next-step.js.map +1 -1
  291. package/dist/core/templates/fragments/verify.d.ts +9 -12
  292. package/dist/core/templates/fragments/verify.d.ts.map +1 -1
  293. package/dist/core/templates/fragments/verify.js +9 -12
  294. package/dist/core/templates/fragments/verify.js.map +1 -1
  295. package/dist/core/templates/index.d.ts +0 -6
  296. package/dist/core/templates/index.d.ts.map +1 -1
  297. package/dist/core/templates/index.js +0 -7
  298. package/dist/core/templates/index.js.map +1 -1
  299. package/dist/core/templates/skill-templates.d.ts +1 -5
  300. package/dist/core/templates/skill-templates.d.ts.map +1 -1
  301. package/dist/core/templates/skill-templates.js +0 -5
  302. package/dist/core/templates/skill-templates.js.map +1 -1
  303. package/dist/core/templates/types.d.ts +3 -7
  304. package/dist/core/templates/types.d.ts.map +1 -1
  305. package/dist/core/templates/types.js +0 -3
  306. package/dist/core/templates/types.js.map +1 -1
  307. package/dist/core/templates/workflows/apply.d.ts +3 -9
  308. package/dist/core/templates/workflows/apply.d.ts.map +1 -1
  309. package/dist/core/templates/workflows/apply.js +9 -12
  310. package/dist/core/templates/workflows/apply.js.map +1 -1
  311. package/dist/core/templates/workflows/archive.d.ts +0 -6
  312. package/dist/core/templates/workflows/archive.d.ts.map +1 -1
  313. package/dist/core/templates/workflows/archive.js +7 -5
  314. package/dist/core/templates/workflows/archive.js.map +1 -1
  315. package/dist/core/templates/workflows/decision.js +4 -4
  316. package/dist/core/templates/workflows/decision.js.map +1 -1
  317. package/dist/core/templates/workflows/explore.js +1 -1
  318. package/dist/core/templates/workflows/grill.d.ts.map +1 -1
  319. package/dist/core/templates/workflows/grill.js +0 -2
  320. package/dist/core/templates/workflows/grill.js.map +1 -1
  321. package/dist/core/templates/workflows/issue.d.ts +0 -6
  322. package/dist/core/templates/workflows/issue.d.ts.map +1 -1
  323. package/dist/core/templates/workflows/issue.js +3 -0
  324. package/dist/core/templates/workflows/issue.js.map +1 -1
  325. package/dist/core/templates/workflows/propose.d.ts +0 -6
  326. package/dist/core/templates/workflows/propose.d.ts.map +1 -1
  327. package/dist/core/templates/workflows/propose.js +0 -1
  328. package/dist/core/templates/workflows/propose.js.map +1 -1
  329. package/dist/core/templates/workflows/sync.d.ts +2 -8
  330. package/dist/core/templates/workflows/sync.d.ts.map +1 -1
  331. package/dist/core/templates/workflows/sync.js +2 -2
  332. package/dist/core/templates/workflows/sync.js.map +1 -1
  333. package/dist/core/templates/workflows/update.d.ts +0 -6
  334. package/dist/core/templates/workflows/update.d.ts.map +1 -1
  335. package/dist/core/templates/workflows/update.js.map +1 -1
  336. package/dist/core/update.d.ts +26 -21
  337. package/dist/core/update.d.ts.map +1 -1
  338. package/dist/core/update.js +165 -116
  339. package/dist/core/update.js.map +1 -1
  340. package/dist/core/user-state-migration.d.ts +13 -15
  341. package/dist/core/user-state-migration.d.ts.map +1 -1
  342. package/dist/core/user-state-migration.js +16 -20
  343. package/dist/core/user-state-migration.js.map +1 -1
  344. package/dist/core/validation/constants.d.ts +19 -25
  345. package/dist/core/validation/constants.d.ts.map +1 -1
  346. package/dist/core/validation/constants.js +25 -20
  347. package/dist/core/validation/constants.js.map +1 -1
  348. package/dist/core/validation/prose-length.d.ts +15 -0
  349. package/dist/core/validation/prose-length.d.ts.map +1 -0
  350. package/dist/core/validation/prose-length.js +29 -0
  351. package/dist/core/validation/prose-length.js.map +1 -0
  352. package/dist/core/validation/purpose-placeholder.d.ts +9 -16
  353. package/dist/core/validation/purpose-placeholder.d.ts.map +1 -1
  354. package/dist/core/validation/purpose-placeholder.js +30 -44
  355. package/dist/core/validation/purpose-placeholder.js.map +1 -1
  356. package/dist/core/validation/section-validator.d.ts +4 -4
  357. package/dist/core/validation/section-validator.d.ts.map +1 -1
  358. package/dist/core/validation/section-validator.js +43 -7
  359. package/dist/core/validation/section-validator.js.map +1 -1
  360. package/dist/core/validation/task-numbering.d.ts +6 -3
  361. package/dist/core/validation/task-numbering.d.ts.map +1 -1
  362. package/dist/core/validation/task-numbering.js +23 -11
  363. package/dist/core/validation/task-numbering.js.map +1 -1
  364. package/dist/core/validation/types.d.ts +18 -0
  365. package/dist/core/validation/types.d.ts.map +1 -1
  366. package/dist/core/validation/types.js +12 -1
  367. package/dist/core/validation/types.js.map +1 -1
  368. package/dist/core/validation/validator.d.ts +50 -51
  369. package/dist/core/validation/validator.d.ts.map +1 -1
  370. package/dist/core/validation/validator.js +486 -263
  371. package/dist/core/validation/validator.js.map +1 -1
  372. package/dist/prompts/searchable-multi-select.d.ts +3 -8
  373. package/dist/prompts/searchable-multi-select.d.ts.map +1 -1
  374. package/dist/prompts/searchable-multi-select.js +16 -39
  375. package/dist/prompts/searchable-multi-select.js.map +1 -1
  376. package/dist/utils/change-metadata.d.ts +11 -50
  377. package/dist/utils/change-metadata.d.ts.map +1 -1
  378. package/dist/utils/change-metadata.js +48 -67
  379. package/dist/utils/change-metadata.js.map +1 -1
  380. package/dist/utils/change-utils.d.ts +31 -54
  381. package/dist/utils/change-utils.d.ts.map +1 -1
  382. package/dist/utils/change-utils.js +143 -100
  383. package/dist/utils/change-utils.js.map +1 -1
  384. package/dist/utils/file-lock.d.ts +39 -0
  385. package/dist/utils/file-lock.d.ts.map +1 -0
  386. package/dist/utils/file-lock.js +149 -0
  387. package/dist/utils/file-lock.js.map +1 -0
  388. package/dist/utils/file-system.d.ts +12 -32
  389. package/dist/utils/file-system.d.ts.map +1 -1
  390. package/dist/utils/file-system.js +16 -40
  391. package/dist/utils/file-system.js.map +1 -1
  392. package/dist/utils/frontmatter.d.ts +7 -11
  393. package/dist/utils/frontmatter.d.ts.map +1 -1
  394. package/dist/utils/frontmatter.js +11 -11
  395. package/dist/utils/frontmatter.js.map +1 -1
  396. package/dist/utils/interactive.d.ts +4 -9
  397. package/dist/utils/interactive.d.ts.map +1 -1
  398. package/dist/utils/interactive.js +2 -4
  399. package/dist/utils/interactive.js.map +1 -1
  400. package/dist/utils/item-discovery.d.ts +15 -10
  401. package/dist/utils/item-discovery.d.ts.map +1 -1
  402. package/dist/utils/item-discovery.js +42 -47
  403. package/dist/utils/item-discovery.js.map +1 -1
  404. package/dist/utils/link.d.ts +13 -4
  405. package/dist/utils/link.d.ts.map +1 -1
  406. package/dist/utils/link.js +13 -4
  407. package/dist/utils/link.js.map +1 -1
  408. package/dist/utils/match.js.map +1 -1
  409. package/dist/utils/requirement-diff.d.ts +13 -23
  410. package/dist/utils/requirement-diff.d.ts.map +1 -1
  411. package/dist/utils/requirement-diff.js +13 -23
  412. package/dist/utils/requirement-diff.js.map +1 -1
  413. package/dist/utils/spec-files.d.ts +10 -11
  414. package/dist/utils/spec-files.d.ts.map +1 -1
  415. package/dist/utils/spec-files.js +31 -22
  416. package/dist/utils/spec-files.js.map +1 -1
  417. package/dist/utils/task-progress.d.ts +11 -9
  418. package/dist/utils/task-progress.d.ts.map +1 -1
  419. package/dist/utils/task-progress.js +53 -32
  420. package/dist/utils/task-progress.js.map +1 -1
  421. package/dist/utils/timestamp.d.ts +5 -8
  422. package/dist/utils/timestamp.d.ts.map +1 -1
  423. package/dist/utils/timestamp.js +5 -8
  424. package/dist/utils/timestamp.js.map +1 -1
  425. package/package.json +9 -10
  426. package/schemas/decision/templates/decision.md +3 -1
  427. package/schemas/decision/templates/index.md +2 -2
  428. package/schemas/issue/schema.yaml +11 -2
  429. package/schemas/issue/templates/spec.md +37 -3
  430. package/schemas/sdd/schema.yaml +24 -1
  431. package/schemas/sdd/templates/spec.md +37 -3
@@ -1,16 +1,54 @@
1
1
  import { readFileSync, promises as fs } from 'fs';
2
2
  import path from 'path';
3
- import { SpecSchema, ChangeSchema } from '../schemas/index.js';
3
+ import { SpecSchema } from '../schemas/index.js';
4
4
  import { MarkdownParser } from '../parsers/markdown-parser.js';
5
- import { ChangeParser } from '../parsers/change-parser.js';
6
- import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
5
+ import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, MAX_DELTAS_PER_CHANGE, VALIDATION_MESSAGES } from './constants.js';
6
+ import { proseLength } from './prose-length.js';
7
7
  import { parseDeltaSpec, normalizeRequirementName, extractRequirementsSection } from '../parsers/requirement-blocks.js';
8
- import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, analyzeScenarios, scanCodeFences, findMissingScenarios, } from '../parsers/requirement-text.js';
8
+ import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust, analyzeScenarios, scanCodeFences, findMissingScenarios, findDroppedBullets, withoutScenarios, normalizeDocument, } from '../parsers/requirement-text.js';
9
9
  import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
10
10
  import { findPurposePlaceholderIssue } from './purpose-placeholder.js';
11
11
  import { FileSystemUtils, extractNameFromPath, isMissingPathError } from '../../utils/file-system.js';
12
- import { findSpecFiles } from '../../utils/spec-files.js';
13
- import { findSpecUpdates, buildUpdatedSpec } from '../specs-apply.js';
12
+ import { findAllMarkdownFiles } from '../../utils/spec-files.js';
13
+ import { findSpecUpdates, buildUpdatedSpec, extractDeltaPurpose } from '../specs-apply.js';
14
+ /**
15
+ * The one place the `## REMOVED Scenarios` grammar is spelled out in a
16
+ * message. Both the "omits scenario(s)" error and the empty-section error
17
+ * point here, so an author who reached either sees the exact lines to write
18
+ * instead of being sent to the template.
19
+ */
20
+ const REMOVED_SCENARIOS_GRAMMAR = 'Each entry is three bullets: "- Requirement: `<name>`" / "- Scenario: `<name>`" / "- Reason: <why>".';
21
+ /**
22
+ * Merge notices the archive dry run forwards as validation warnings: a REMOVED
23
+ * block naming something the target spec does not have, so archiving deletes
24
+ * nothing. Matched by code, not message text, so rewording a notice cannot
25
+ * switch the check off.
26
+ */
27
+ const REMOVED_NOOP_NOTICE_CODES = new Set([
28
+ 'archive_removed_requirement_absent',
29
+ 'archive_removed_ignored_new_spec',
30
+ ]);
31
+ /**
32
+ * Merge failures the structural pass already reports under its own wording:
33
+ * duplicate or cross-section names (`validation failed - ...`), a section that
34
+ * parsed to nothing, and a MODIFIED that drops a scenario. Matched by the
35
+ * fixed prefix each message opens with; the rest of a message carries names
36
+ * that vary per delta.
37
+ */
38
+ const STRUCTURAL_MERGE_FAILURE = /( validation failed - |^Delta parsing found no operations|current spec contains scenario\(s\) not present)/;
39
+ /**
40
+ * One `path` notation across the report. Zod hands back a segment array that
41
+ * `join('.')` renders as `requirements.0.scenarios`, while every hand-written
42
+ * rule here writes `requirements[0]` — so the same report used two spellings
43
+ * for the same address and neither was safely parseable.
44
+ */
45
+ function formatIssuePath(segments) {
46
+ return segments.reduce((acc, segment) => {
47
+ if (typeof segment === 'number')
48
+ return `${acc}[${segment}]`;
49
+ return acc ? `${acc}.${String(segment)}` : String(segment);
50
+ }, '');
51
+ }
14
52
  export class Validator {
15
53
  strictMode;
16
54
  constructor(strictMode = false) {
@@ -28,14 +66,10 @@ export class Validator {
28
66
  { level: 'ERROR', path: 'file', message: this.enrichTopLevelError(specName, baseMessage) },
29
67
  ]);
30
68
  }
31
- // Everything past the read is identical to the content path — keeping one
32
- // body is what stops `validate` and `archive` grading the same spec
33
- // differently.
69
+ // One body past the read, so `validate` and `archive` cannot grade the same
70
+ // spec differently.
34
71
  return this.validateSpecContent(specName, content);
35
72
  }
36
- /**
37
- * Validate spec content from a string (used for pre-write validation of rebuilt specs)
38
- */
39
73
  async validateSpecContent(specName, content) {
40
74
  const issues = [];
41
75
  try {
@@ -54,69 +88,37 @@ export class Validator {
54
88
  }
55
89
  return this.createReport(issues);
56
90
  }
57
- async validateChange(filePath) {
58
- const issues = [];
59
- const changeName = extractNameFromPath(filePath);
60
- try {
61
- const content = readFileSync(filePath, 'utf-8');
62
- const changeDir = path.dirname(filePath);
63
- const parser = new ChangeParser(content, changeDir);
64
- const change = await parser.parseChangeWithDeltas(changeName);
65
- const result = ChangeSchema.safeParse(change);
66
- if (!result.success) {
67
- issues.push(...this.convertZodErrors(result.error));
68
- }
69
- issues.push(...this.applyChangeRules(change, content));
70
- }
71
- catch (error) {
72
- const baseMessage = error instanceof Error ? error.message : 'Unknown error';
73
- const enriched = this.enrichTopLevelError(changeName, baseMessage);
74
- issues.push({
75
- level: 'ERROR',
76
- path: 'file',
77
- message: enriched,
78
- });
79
- }
80
- return this.createReport(issues);
81
- }
82
91
  /**
83
- * Validate delta-formatted spec files under a change directory.
84
- * Enforces:
85
- * - At least one delta across all files
86
- * - ADDED/MODIFIED: each requirement has SHALL/MUST and at least one scenario
87
- * - REMOVED: names only; no scenario/description required
88
- * - RENAMED: pairs well-formed
89
- * - No duplicates within sections; no cross-section conflicts per spec
90
- * - MODIFIED: no scenario the current main spec still has is dropped
92
+ * Validate delta-formatted spec files under a change directory: at least one
93
+ * delta overall, ADDED/MODIFIED requirements carrying SHALL/MUST and a
94
+ * scenario, well-formed REMOVED (names only) and RENAMED (FROM:/TO: pairs)
95
+ * entries, no duplicate or cross-section names, and no scenario the current
96
+ * main spec still has dropped by a MODIFIED.
91
97
  *
92
- * `mainSpecsDir` enables that last rule; without it the scenario-loss check is
93
- * skipped, because there is nothing to compare against. Callers that have the
94
- * project root should always pass it — archive refuses these deltas either
95
- * way, and the check only decides whether the user learns now or at archive
96
- * time.
98
+ * `mainSpecsDir` enables that last rule; without it there is nothing to
99
+ * compare against, so it is skipped. Archive refuses such deltas either way.
100
+ *
101
+ * `archivePreflight` is opt-out because the dry run rebuilds every updated
102
+ * spec. For `tospec validate` that is the point; for `archive` it is
103
+ * duplication, since the real merge follows moments later.
97
104
  */
98
- async validateChangeDeltaSpecs(changeDir, mainSpecsDir) {
105
+ async validateChangeDeltaSpecs(changeDir, mainSpecsDir, options = {}) {
106
+ const archivePreflight = options.archivePreflight ?? true;
99
107
  const issues = [];
100
108
  const specsDir = path.join(changeDir, 'specs');
101
109
  let totalDeltas = 0;
102
110
  let hasMisplacedSpec = false;
103
111
  const missingHeaderSpecs = [];
104
- const emptySectionSpecs = [];
112
+ const emptySections = [];
105
113
  try {
106
- // The walk below is recursive, but its purpose is to *reject* anything
107
- // outside specs/<capability>/spec.md, not to support it. The layout is
108
- // fixed at exactly one directory level; see
109
- // tospec/decisions/20260730_014309-delta-spec-layout-one-level.md, which
110
- // reverses the nested multi-area support added in #1182b. Only validation
111
- // ever went recursive — the merge path (findSpecUpdates) never did — so a
112
- // misplaced file that validates clean is a file that archives into
113
- // nothing. Validation is the only place that can catch it.
114
+ // The walk below is recursive in order to *reject* anything outside
115
+ // specs/<capability>/spec.md: the layout is fixed at exactly one directory
116
+ // level (tospec/decisions/20260730_014309-delta-spec-layout-one-level.md).
117
+ // findSpecUpdates never went recursive, so a misplaced file that validates
118
+ // clean is a file that archives into nothing.
114
119
  const rootSpecPath = path.join(specsDir, 'spec.md');
115
- // A spec.md directly at the specs/ root has no capability folder, so the
116
- // apply/archive merge path (findSpecUpdates) drops it: without this error
117
- // the change validates clean and archives while its requirements never
118
- // reach the main specs. Only a regular file counts — a *directory* named
119
- // spec.md is a capability folder like any other.
120
+ // Only a regular file counts — a *directory* named spec.md is a capability
121
+ // folder like any other.
120
122
  const rootSpecStat = await fs.stat(rootSpecPath).catch(() => null);
121
123
  if (rootSpecStat?.isFile() === true) {
122
124
  hasMisplacedSpec = true;
@@ -126,15 +128,20 @@ export class Validator {
126
128
  message: 'Delta spec found at specs/spec.md. Delta specs must live in a capability folder (e.g. specs/<capability>/spec.md) — a file at the specs/ root is ignored when the change is applied or archived.',
127
129
  });
128
130
  }
129
- // Report each misplaced file once, here, and drop it from the delta pass
130
- // below — validating it as if it were a real delta would contradict the
131
- // error that just said it will never be applied.
131
+ // Report each misplaced file once and drop it from the delta pass below —
132
+ // validating it as a real delta would contradict the error that just said
133
+ // it will never be applied.
132
134
  const specFiles = [];
133
- for (const specFile of await findSpecFiles(specsDir)) {
135
+ for (const specFile of await findAllMarkdownFiles(specsDir)) {
134
136
  if (specFile === rootSpecPath)
135
137
  continue; // already reported above
136
138
  const entryPath = FileSystemUtils.toPosixPath(path.relative(specsDir, specFile));
137
139
  const segments = entryPath.split('/');
140
+ // A stray .md directly at specs/ is not this guard's concern: archive's
141
+ // orphan backstop treats it as clutter rather than a lost delta, and the
142
+ // two must keep agreeing.
143
+ if (segments.length === 1)
144
+ continue;
138
145
  if (segments.length > 2) {
139
146
  hasMisplacedSpec = true;
140
147
  // The fix is mechanical, so name the flattened path outright:
@@ -147,10 +154,9 @@ export class Validator {
147
154
  });
148
155
  continue;
149
156
  }
150
- // fast-glob (which computes artifact status from the schema's
151
- // specs/*/spec.md) skips dot-directories, while the merge path's
152
- // readdir does not. A dot capability would archive without ever
153
- // showing as done — the same class of silent divergence.
157
+ // fast-glob (which computes artifact status) skips dot-directories while
158
+ // the merge path's readdir does not, so a dot capability would archive
159
+ // without ever showing done.
154
160
  if (segments[0].startsWith('.')) {
155
161
  hasMisplacedSpec = true;
156
162
  issues.push({
@@ -160,21 +166,44 @@ export class Validator {
160
166
  });
161
167
  continue;
162
168
  }
169
+ // Right folder, wrong basename. findSpecFiles filters on
170
+ // basename === 'spec.md', so the merge path silently drops the file;
171
+ // findAllMarkdownFiles is what makes it visible here at all.
172
+ if (segments[1] !== 'spec.md') {
173
+ hasMisplacedSpec = true;
174
+ issues.push({
175
+ level: 'ERROR',
176
+ path: entryPath,
177
+ message: `Delta spec found at specs/${entryPath}. A capability folder holds exactly specs/${segments[0]}/spec.md — a different filename is ignored when the change is applied or archived. Rename it to specs/${segments[0]}/spec.md.`,
178
+ });
179
+ continue;
180
+ }
163
181
  specFiles.push(specFile);
164
182
  }
165
183
  for (const specFile of specFiles) {
184
+ const entryPath = FileSystemUtils.toPosixPath(path.relative(specsDir, specFile));
166
185
  let content;
167
186
  try {
168
187
  content = await fs.readFile(specFile, 'utf-8');
169
188
  }
170
- catch {
189
+ catch (error) {
190
+ // A file the walk just listed but cannot be read (EACCES, EIO) is a
191
+ // delta, not an absence: skipping it let a change whose only delta
192
+ // was unreadable validate clean and fail inside archive instead. Only
193
+ // a path that vanished between the walk and the read may be skipped.
194
+ if (isMissingPathError(error))
195
+ continue;
196
+ issues.push({
197
+ level: 'ERROR',
198
+ path: entryPath,
199
+ message: `Delta spec specs/${entryPath} exists but could not be read, so it was not validated: ${error instanceof Error ? error.message : String(error)}`,
200
+ });
171
201
  continue;
172
202
  }
173
203
  const plan = parseDeltaSpec(content);
174
- const entryPath = FileSystemUtils.toPosixPath(path.relative(specsDir, specFile));
175
- // An unclosed fence masks every following line — surface it instead
176
- // of silently hiding the rest of the document (report 2.3).
177
- const fenceScan = scanCodeFences(content.split(/\r?\n/));
204
+ // An unclosed fence masks every following line — surface it instead of
205
+ // silently hiding the rest of the document.
206
+ const fenceScan = scanCodeFences(normalizeDocument(content).split('\n'));
178
207
  if (fenceScan.unclosedFenceLine !== null) {
179
208
  issues.push({
180
209
  level: 'ERROR',
@@ -183,31 +212,62 @@ export class Validator {
183
212
  message: `Code fence opened at line ${fenceScan.unclosedFenceLine} is never closed — all content after it is invisible to the parser. Close the fence.`,
184
213
  });
185
214
  }
186
- const sectionNames = [];
187
- if (plan.sectionPresence.added)
188
- sectionNames.push('## ADDED Requirements');
189
- if (plan.sectionPresence.modified)
190
- sectionNames.push('## MODIFIED Requirements');
191
- if (plan.sectionPresence.removed)
192
- sectionNames.push('## REMOVED Requirements');
193
- if (plan.sectionPresence.renamed)
194
- sectionNames.push('## RENAMED Requirements');
195
- const hasSections = sectionNames.length > 0;
215
+ // A new capability's `## Purpose` is carried verbatim into the main spec
216
+ // archive creates, so the main-spec Purpose rules must run before the
217
+ // copy — otherwise the change archives and only then fails
218
+ // `validate --all --strict`, from a source now under changes/archive/.
219
+ // New capabilities only: an existing one owns its Purpose.
220
+ issues.push(...(await this.collectDeltaPurposeIssues(content, entryPath, mainSpecsDir)));
221
+ // Two identical delta headers are an editing accident, and combining
222
+ // them would guess which one the author meant.
223
+ for (const duplicate of plan.duplicateSections) {
224
+ issues.push({
225
+ level: 'ERROR',
226
+ path: entryPath,
227
+ line: duplicate.lines[0],
228
+ message: `Delta section "${duplicate.title}" appears more than once (lines ${duplicate.lines.join(', ')}). Merge them into a single section — two headers with the same name are ambiguous about which requirements belong to which.`,
229
+ });
230
+ }
231
+ // A present section that yields no entry is a grammar miss, not an
232
+ // empty section: the author wrote something there. Left unreported, the
233
+ // MODIFIED block keeps failing the scenario-loss check with no sign that
234
+ // its declaration was skipped.
235
+ if (plan.sectionPresence.removedScenarios && plan.removedScenarios.length === 0) {
236
+ issues.push({
237
+ level: 'ERROR',
238
+ path: entryPath,
239
+ message: `"## REMOVED Scenarios" declares nothing the parser recognises. ${REMOVED_SCENARIOS_GRAMMAR}`,
240
+ });
241
+ }
242
+ const hasSections = plan.sectionPresence.added ||
243
+ plan.sectionPresence.modified ||
244
+ plan.sectionPresence.removed ||
245
+ plan.sectionPresence.renamed;
196
246
  const hasEntries = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0;
247
+ // hasEntries sums all four categories, so once it is false every section
248
+ // present in sectionPresence is individually empty too.
197
249
  if (!hasEntries) {
198
- if (hasSections)
199
- emptySectionSpecs.push({ path: entryPath, sections: sectionNames });
200
- else
250
+ if (hasSections) {
251
+ if (plan.sectionPresence.added)
252
+ emptySections.push({ path: entryPath, kind: 'added', header: '## ADDED Requirements' });
253
+ if (plan.sectionPresence.modified)
254
+ emptySections.push({ path: entryPath, kind: 'modified', header: '## MODIFIED Requirements' });
255
+ if (plan.sectionPresence.removed)
256
+ emptySections.push({ path: entryPath, kind: 'removed', header: '## REMOVED Requirements' });
257
+ if (plan.sectionPresence.renamed)
258
+ emptySections.push({ path: entryPath, kind: 'renamed', header: '## RENAMED Requirements' });
259
+ }
260
+ else {
201
261
  missingHeaderSpecs.push(entryPath);
262
+ }
202
263
  }
203
264
  const addedNames = new Set();
204
265
  const modifiedNames = new Set();
205
266
  const removedNames = new Set();
206
267
  const renamedFrom = new Set();
207
268
  const renamedTo = new Set();
208
- // ADDED and MODIFIED carry identical per-requirement rules. One body
209
- // means a new rule cannot land on only one of the two sections; the
210
- // outer loop preserves the all-ADDED-then-all-MODIFIED issue order.
269
+ // ADDED and MODIFIED carry identical per-requirement rules, so one body
270
+ // stops a new rule landing on only one of them.
211
271
  for (const [section, blocks, seen] of [
212
272
  ['ADDED', plan.added, addedNames],
213
273
  ['MODIFIED', plan.modified, modifiedNames],
@@ -225,25 +285,37 @@ export class Validator {
225
285
  if (!requirementText) {
226
286
  issues.push({ level: 'ERROR', path: entryPath, message: `${section} "${block.name}" is missing requirement text` });
227
287
  }
228
- else if (!this.containsShallOrMust(requirementText)) {
288
+ else if (!containsShallOrMust(requirementText)) {
229
289
  // WARNING, not ERROR: the keyword check is English-only, so at
230
- // ERROR it blocked any requirement whose body states its
231
- // obligation in another language — enforcing a documentation
232
- // language, not normative strength. `--strict` still refuses it
233
- // (strictMode counts warnings as fatal).
234
- // Decision: tospec/decisions/20260814_134116-shall-must-missing-is-a-warning.md
235
- issues.push({ level: 'WARNING', path: entryPath, message: this.buildMissingShallOrMustMessage(`${section} "${block.name}"`, block.name) });
290
+ // ERROR it blocked requirements written in another language.
291
+ // `--strict` still refuses it.
292
+ // tospec/decisions/20260814_134116-shall-must-missing-is-a-warning.md
293
+ issues.push({
294
+ level: 'WARNING',
295
+ path: entryPath,
296
+ ...this.buildMissingShallOrMustMessage(`${section} "${block.name}"`, block.name),
297
+ });
236
298
  }
237
299
  issues.push(...this.collectScenarioIssues(section, block, entryPath));
238
300
  }
239
301
  }
240
- if (mainSpecsDir !== undefined && plan.modified.length > 0) {
302
+ // Also entered on declarations alone: a `## REMOVED Scenarios` with no
303
+ // MODIFIED block matches nothing by construction, and gating on
304
+ // MODIFIED meant the "declaration matched nothing" error below could
305
+ // never fire for the one delta shape where it is always true. The
306
+ // declaration removes nothing by itself (the MODIFIED omission is the
307
+ // mechanism), so such a change archived with the scenario intact.
308
+ // tospec/decisions/20260918_230803-removed-scenarios-is-the-declared-path.md
309
+ if (mainSpecsDir !== undefined && (plan.modified.length > 0 || plan.removedScenarios.length > 0)) {
310
+ const renamedToFrom = new Map();
311
+ for (const { from, to } of plan.renamed) {
312
+ renamedToFrom.set(normalizeRequirementName(to), from);
313
+ }
241
314
  // entryPath is always "<capability>/spec.md" here — misplaced and
242
315
  // dot-prefixed files were reported and dropped above.
243
- issues.push(...(await this.collectDroppedScenarioIssues(mainSpecsDir, entryPath.split('/')[0], plan.modified, entryPath)));
316
+ issues.push(...(await this.collectDroppedScenarioIssues(mainSpecsDir, entryPath.split('/')[0], plan.modified, entryPath, renamedToFrom, plan.removedScenarios)));
244
317
  }
245
- // Validate REMOVED — the template requires Reason and Migration
246
- // fields on every entry (report 2.4).
318
+ // The template requires Reason and Migration on every REMOVED entry.
247
319
  for (const entry of plan.removedEntries) {
248
320
  const key = normalizeRequirementName(entry.name);
249
321
  totalDeltas++;
@@ -270,7 +342,7 @@ export class Validator {
270
342
  });
271
343
  }
272
344
  }
273
- // Orphaned FROM:/TO: lines were silently dropped before (report 2.4).
345
+ // Orphaned FROM:/TO: lines would otherwise be dropped silently.
274
346
  for (const renamedIssue of plan.renamedIssues) {
275
347
  const description = renamedIssue.kind === 'from-without-to'
276
348
  ? `RENAMED FROM "${renamedIssue.name}" (line ${renamedIssue.line}) has no matching TO: line`
@@ -282,7 +354,6 @@ export class Validator {
282
354
  message: `${description} — write RENAMED entries as a FROM:/TO: pair.`,
283
355
  });
284
356
  }
285
- // Validate RENAMED pairs
286
357
  for (const { from, to } of plan.renamed) {
287
358
  const fromKey = normalizeRequirementName(from);
288
359
  const toKey = normalizeRequirementName(to);
@@ -300,7 +371,6 @@ export class Validator {
300
371
  renamedTo.add(toKey);
301
372
  }
302
373
  }
303
- // Cross-section conflicts (within the same spec file)
304
374
  for (const n of modifiedNames) {
305
375
  if (removedNames.has(n)) {
306
376
  issues.push({ level: 'ERROR', path: entryPath, message: `Requirement present in both MODIFIED and REMOVED: "${n}"` });
@@ -326,14 +396,18 @@ export class Validator {
326
396
  }
327
397
  }
328
398
  }
329
- catch {
330
- // If no specs dir, treat as no deltas
399
+ catch (error) {
400
+ // Only an absent specs dir means "no deltas". The walk and the reads
401
+ // above already answer that on their own, so anything else reaching here
402
+ // is a real failure that a bare catch used to turn into a clean verdict.
403
+ if (!isMissingPathError(error))
404
+ throw error;
331
405
  }
332
- for (const { path: specPath, sections } of emptySectionSpecs) {
406
+ for (const { path: specPath, kind, header } of emptySections) {
333
407
  issues.push({
334
408
  level: 'ERROR',
335
409
  path: specPath,
336
- message: `Delta sections ${this.formatSectionList(sections)} were found, but no requirement entries parsed. Ensure each section includes at least one "### Requirement:" block (REMOVED may use bullet list syntax).`,
410
+ message: this.formatEmptySectionMessage(kind, header),
337
411
  });
338
412
  }
339
413
  for (const path of missingHeaderSpecs) {
@@ -343,24 +417,27 @@ export class Validator {
343
417
  message: 'No delta sections found. Add headers such as "## ADDED Requirements" or move non-delta notes outside specs/.',
344
418
  });
345
419
  }
346
- // Runs here, after every structural error has been pushed, so the exclusion
347
- // set below is just "what has already been reported". Calling it inside the
348
- // loop above meant hand-splicing the two lists that are only raised down
349
- // here — a set that any later check would silently fall out of.
350
- //
351
420
  // The checks above compare a delta against itself and, for MODIFIED, against
352
421
  // the main spec's scenarios. None asks whether the main spec can supply the
353
- // target the delta acts on — the merge's own preconditions, which nothing
354
- // consulted until archive.
355
- if (mainSpecsDir !== undefined) {
356
- issues.push(...(await this.findArchiveBlockers(changeDir, mainSpecsDir, issues.filter((issue) => issue.level === 'ERROR').map((issue) => issue.path))));
357
- }
358
- // A misplaced-spec error already names the file and the fix; adding "No
359
- // deltas found" on top would contradict it, since the deltas are sitting in
360
- // the file just reported.
422
+ // target the delta acts on — the merge's own preconditions.
423
+ if (mainSpecsDir !== undefined && archivePreflight) {
424
+ issues.push(...(await this.findArchiveBlockers(changeDir, mainSpecsDir)));
425
+ }
426
+ // A misplaced-spec error already names the file and the fix; "No deltas
427
+ // found" on top would contradict it.
361
428
  if (totalDeltas === 0 && !hasMisplacedSpec) {
362
429
  issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) });
363
430
  }
431
+ // The ceiling `ChangeSchema.deltas.max()` declares. WARNING because the rule
432
+ // is advice about change size, not a correctness claim; `--strict` is what
433
+ // turns advice into a gate.
434
+ if (totalDeltas > MAX_DELTAS_PER_CHANGE) {
435
+ issues.push({
436
+ level: 'WARNING',
437
+ path: 'file',
438
+ message: `${VALIDATION_MESSAGES.CHANGE_TOO_MANY_DELTAS} (found ${totalDeltas}).`,
439
+ });
440
+ }
364
441
  return this.createReport(issues);
365
442
  }
366
443
  convertZodErrors(error) {
@@ -369,16 +446,22 @@ export class Validator {
369
446
  if (message === VALIDATION_MESSAGES.CHANGE_NO_DELTAS) {
370
447
  message = `${message}. ${VALIDATION_MESSAGES.GUIDE_NO_DELTAS}`;
371
448
  }
449
+ // The guidance used to ride on a second, lower-severity copy of this
450
+ // finding emitted by applySpecRules. Appended here instead so the report
451
+ // carries one issue per fact, at the severity that decides the exit code.
452
+ if (message === VALIDATION_MESSAGES.REQUIREMENT_NO_SCENARIOS) {
453
+ message = `${message}. ${VALIDATION_MESSAGES.GUIDE_SCENARIO_FORMAT}`;
454
+ }
372
455
  return {
373
456
  level: 'ERROR',
374
- path: err.path.join('.'),
457
+ path: formatIssuePath(err.path),
375
458
  message,
376
459
  };
377
460
  });
378
461
  }
379
462
  applySpecRules(spec, content) {
380
463
  const issues = [];
381
- const fenceScan = scanCodeFences(content.split(/\r?\n/));
464
+ const fenceScan = scanCodeFences(normalizeDocument(content).split('\n'));
382
465
  if (fenceScan.unclosedFenceLine !== null) {
383
466
  issues.push({
384
467
  level: 'ERROR',
@@ -395,10 +478,8 @@ export class Validator {
395
478
  message: structuralIssue.message,
396
479
  });
397
480
  }
398
- // The generated placeholder is longer than MIN_PURPOSE_LENGTH, so the
399
- // brevity check below cannot reach it; it is reported on its own terms
400
- // instead. Checked first because a hand-written "TBD" is both a placeholder
401
- // and too brief, and only one of those two tells the author what to do.
481
+ // Checked before brevity: a hand-written "TBD" is both a placeholder and too
482
+ // brief, and only the placeholder message tells the author what to do.
402
483
  const placeholder = findPurposePlaceholderIssue(spec.overview, content);
403
484
  if (placeholder) {
404
485
  issues.push({
@@ -408,7 +489,7 @@ export class Validator {
408
489
  message: VALIDATION_MESSAGES.PURPOSE_IS_PLACEHOLDER,
409
490
  });
410
491
  }
411
- else if (spec.overview.length < MIN_PURPOSE_LENGTH) {
492
+ else if (proseLength(spec.overview) < MIN_PURPOSE_LENGTH) {
412
493
  issues.push({
413
494
  level: 'WARNING',
414
495
  path: 'overview',
@@ -423,62 +504,29 @@ export class Validator {
423
504
  message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG,
424
505
  });
425
506
  }
426
- if (req.scenarios.length === 0) {
427
- issues.push({
428
- level: 'WARNING',
429
- path: `requirements[${index}].scenarios`,
430
- message: `${VALIDATION_MESSAGES.REQUIREMENT_NO_SCENARIOS}. ${VALIDATION_MESSAGES.GUIDE_SCENARIO_FORMAT}`,
431
- });
432
- }
507
+ // A missing scenario is not checked here: SpecSchema's `scenarios.min(1)`
508
+ // already reports it as an ERROR, and convertZodErrors appends the same
509
+ // format guidance this branch used to carry. Two copies of one fact, at
510
+ // two severities, only made the report ambiguous about which to fix.
433
511
  });
434
- // SHALL/MUST body-keyword enforcement for main specs (#1156). The main-spec
435
- // parser collapses the requirement header into `text`, so we recover the
436
- // header+body pairs here (the same source the delta path trusts) and reuse
437
- // the delta detection: a body that omits the keyword errors, with the
438
- // targeted "move it to the body line" hint when the keyword is in the header
439
- // only and the generic message otherwise. Emitted exactly once per
440
- // requirement (the Zod refine that used to emit a generic error is removed).
512
+ // The main-spec parser collapses the requirement header into `text`, so
513
+ // recover the header+body pairs here — the same source the delta path
514
+ // trusts — and reuse the delta detection.
441
515
  extractRequirementsSection(content).bodyBlocks.forEach((block, index) => {
442
516
  const requirementText = this.extractRequirementText(block.raw);
443
- // Both conditions stay one branch on purpose. An absent body here is not
444
- // a "missing text" defect: it is the header-only pattern
445
- // (`### Requirement: The system MUST ...` with no body line), and the
446
- // hint that fires for it — move the statement to the body — is the
447
- // intended answer. Splitting them re-reports that case as missing text
448
- // and loses the hint.
517
+ // One branch on purpose: an absent body here is the header-only pattern
518
+ // (`### Requirement: The system MUST ...` with no body line), whose hint —
519
+ // move the statement to the body — is the intended answer.
449
520
  //
450
521
  // WARNING, not ERROR: the keyword check is English-only, so at ERROR it
451
- // blocked any requirement stating its obligation in another language —
452
- // enforcing a documentation language, not normative strength. `--strict`
453
- // still refuses it (strictMode counts warnings as fatal).
454
- // Decision: tospec/decisions/20260814_134116-shall-must-missing-is-a-warning.md
455
- if (!requirementText || !this.containsShallOrMust(requirementText)) {
522
+ // blocked requirements written in another language. `--strict` still
523
+ // refuses it.
524
+ // tospec/decisions/20260814_134116-shall-must-missing-is-a-warning.md
525
+ if (!requirementText || !containsShallOrMust(requirementText)) {
456
526
  issues.push({
457
527
  level: 'WARNING',
458
528
  path: `requirements[${index}]`,
459
- message: this.buildMissingShallOrMustMessage(`Requirement "${block.name}"`, block.name),
460
- });
461
- }
462
- });
463
- return issues;
464
- }
465
- applyChangeRules(change, content) {
466
- const issues = [];
467
- const MIN_DELTA_DESCRIPTION_LENGTH = 10;
468
- change.deltas.forEach((delta, index) => {
469
- if (!delta.description || delta.description.length < MIN_DELTA_DESCRIPTION_LENGTH) {
470
- issues.push({
471
- level: 'WARNING',
472
- path: `deltas[${index}].description`,
473
- message: VALIDATION_MESSAGES.DELTA_DESCRIPTION_TOO_BRIEF,
474
- });
475
- }
476
- if ((delta.operation === 'ADDED' || delta.operation === 'MODIFIED') &&
477
- (!delta.requirements || delta.requirements.length === 0)) {
478
- issues.push({
479
- level: 'WARNING',
480
- path: `deltas[${index}].requirements`,
481
- message: `${delta.operation} ${VALIDATION_MESSAGES.DELTA_MISSING_REQUIREMENTS}`,
529
+ ...this.buildMissingShallOrMustMessage(`Requirement "${block.name}"`, block.name),
482
530
  });
483
531
  }
484
532
  });
@@ -504,78 +552,135 @@ export class Validator {
504
552
  const valid = this.strictMode
505
553
  ? errors === 0 && warnings === 0
506
554
  : errors === 0;
555
+ // One report, one copy of each rule's background: the note belongs to the
556
+ // rule, not to the line that tripped it.
557
+ const notes = [...new Set(issues.map((issue) => issue.note).filter((n) => !!n))];
558
+ const flattened = issues.map(({ note: _note, ...issue }) => issue);
507
559
  return {
508
560
  valid,
509
- issues,
561
+ issues: flattened,
510
562
  summary: {
511
563
  errors,
512
564
  warnings,
513
565
  info,
514
566
  },
567
+ ...(notes.length ? { notes } : {}),
515
568
  };
516
569
  }
517
570
  extractRequirementText(blockRaw) {
518
- // Delegate to the shared, fence-/metadata-/multi-line-aware body reader.
519
- // Validation intentionally does not use the parser/display header-title
520
- // fallback for canonical `### Requirement:` blocks: a SHALL/MUST that
521
- // appears only in the header must still receive the body-keyword hint.
522
- // Line 0 is the `### Requirement: ...` header.
571
+ // Line 0 is the `### Requirement: ...` header. Validation deliberately skips
572
+ // the parser's header-title fallback: a SHALL/MUST that appears only in the
573
+ // header must still receive the body-keyword hint.
523
574
  const [, ...bodyLines] = blockRaw.split('\n');
524
575
  return extractRequirementBodyShared(bodyLines) || undefined;
525
576
  }
526
- containsShallOrMust(text) {
527
- return containsShallOrMustShared(text);
528
- }
529
577
  /**
530
- * Build an error message for a requirement block whose body lacks SHALL/MUST.
531
- *
532
- * When the SHALL/MUST keyword already appears in the requirement header (e.g.
533
- * `### Requirement: The system SHALL ...`) the original generic error
534
- * ("must contain SHALL or MUST") is confusing because the keyword is visibly
535
- * present in the spec. Per the Tospec conventions the keyword has to live
536
- * on the requirement body line (the line right after the header), so we point
537
- * the author at that exact fix when the keyword is found in the header only.
578
+ * When the keyword already appears in the header (`### Requirement: The system
579
+ * SHALL ...`) the generic "must contain SHALL or MUST" reads as wrong, since
580
+ * the keyword is visibly there. The convention puts it on the body line, so
581
+ * point the author at that exact fix instead.
538
582
  */
539
583
  buildMissingShallOrMustMessage(prefix, blockName) {
540
584
  const base = `${prefix} must contain SHALL or MUST`;
541
- if (this.containsShallOrMust(blockName)) {
542
- return `${base} in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.`;
585
+ if (containsShallOrMust(blockName)) {
586
+ return {
587
+ message: `${base} in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.`,
588
+ };
589
+ }
590
+ // Carried as a `note`, not appended to the message: it is identical for
591
+ // every requirement, and inlining reprinted the paragraph once per
592
+ // occurrence, burying the names that actually differ.
593
+ return {
594
+ message: `${base}.`,
595
+ note: 'SHALL/MUST detection matches those two English keywords only — a requirement that states ' +
596
+ 'its obligation in another language will always report this. It stays a warning for that ' +
597
+ 'reason; if your specs are not written in English, prefer `tospec validate` over `--strict`, ' +
598
+ 'which treats warnings as fatal.',
599
+ };
600
+ }
601
+ /**
602
+ * The main spec's two Purpose rules, applied to a delta that is about to
603
+ * create that main spec. Placeholder before brevity, as on the spec path.
604
+ *
605
+ * `mainSpecsDir` absent means new capabilities cannot be told from existing
606
+ * ones, so nothing is reported.
607
+ */
608
+ async collectDeltaPurposeIssues(content, entryPath, mainSpecsDir) {
609
+ if (mainSpecsDir === undefined)
610
+ return [];
611
+ const purpose = extractDeltaPurpose(content);
612
+ const capability = entryPath.split('/')[0];
613
+ try {
614
+ await fs.access(path.join(mainSpecsDir, capability, 'spec.md'));
615
+ return []; // Capability exists: its own spec owns the Purpose.
616
+ }
617
+ catch (error) {
618
+ // Only a genuinely absent spec means "new capability"; guessing "new"
619
+ // would report a Purpose that is never copied.
620
+ if (!isMissingPathError(error))
621
+ return [];
622
+ }
623
+ // WARNING, not ERROR: the schema calls the Purpose mandatory, but archive
624
+ // still completes with a placeholder, so this is advice on where the text
625
+ // belongs rather than a merge precondition. Reported here because archive
626
+ // is the only other place that notices, and by then the delta is under
627
+ // changes/archive/ where nobody edits it.
628
+ if (!purpose) {
629
+ return [{ level: 'WARNING', path: entryPath, message: VALIDATION_MESSAGES.DELTA_PURPOSE_MISSING }];
630
+ }
631
+ const placeholder = findPurposePlaceholderIssue(purpose, content);
632
+ if (placeholder) {
633
+ return [
634
+ {
635
+ level: 'WARNING',
636
+ path: entryPath,
637
+ line: placeholder.line,
638
+ message: VALIDATION_MESSAGES.DELTA_PURPOSE_IS_PLACEHOLDER,
639
+ },
640
+ ];
543
641
  }
544
- return base;
642
+ if (proseLength(purpose) < MIN_PURPOSE_LENGTH) {
643
+ return [{ level: 'WARNING', path: entryPath, message: VALIDATION_MESSAGES.DELTA_PURPOSE_TOO_BRIEF }];
644
+ }
645
+ return [];
545
646
  }
546
647
  /**
547
648
  * Scenarios a MODIFIED block would delete from the current main spec.
548
649
  *
549
650
  * A MODIFIED replaces the whole requirement, so a scenario the block omits is
550
- * lost. `specs-apply` already refuses to apply such a block, but only at
551
- * archive time — so a change could validate clean, be implemented and
552
- * reviewed, and fail days later at the most expensive possible moment. Both
553
- * sides now call `findMissingScenarios`, so validate reports exactly what
554
- * archive refuses.
651
+ * lost. `specs-apply` refuses such a block too, but only at archive time. Both
652
+ * sides call `findMissingScenarios`, so validate reports what archive refuses.
555
653
  *
556
- * Deliberately silent in two cases, because archive gates them separately and
557
- * a warning here would be a false alarm:
558
- * - the main spec file does not exist yet (the capability is new)
559
- * - the requirement header is absent from it (a MODIFIED written against a
560
- * sister change still in flight)
654
+ * Silent when the main spec does not exist yet (new capability) or lacks the
655
+ * requirement header (a MODIFIED written against a sister change still in
656
+ * flight): archive gates both separately.
657
+ */
658
+ async collectDroppedScenarioIssues(mainSpecsDir, capability, modified, entryPath,
659
+ /**
660
+ * New name → old name, for requirements this change also renames. Without
661
+ * it a MODIFIED under the post-rename header finds nothing in the main spec
662
+ * and is skipped, so archive refuses what validate called valid.
663
+ */
664
+ renamedToFrom,
665
+ /**
666
+ * `## REMOVED Scenarios` declarations. A scenario named here is one the
667
+ * author means to drop, so omitting it from the MODIFIED block stops being
668
+ * the accident this check exists to catch.
561
669
  */
562
- async collectDroppedScenarioIssues(mainSpecsDir, capability, modified, entryPath) {
670
+ declaredRemovals) {
563
671
  let content;
564
672
  try {
565
673
  content = await fs.readFile(path.join(mainSpecsDir, capability, 'spec.md'), 'utf-8');
566
674
  }
567
675
  catch (error) {
568
- // Silence belongs to the genuinely-absent case documented above. A spec that
569
- // exists and could not be read means the check never ran, and returning []
570
- // reports that as "nothing to lose" — under the same wording, so the reader
571
- // cannot tell a new capability from a broken one.
676
+ // Silence belongs to the genuinely-absent case only: a spec that exists
677
+ // and could not be read means the check never ran, and [] would report
678
+ // that as "nothing to lose".
572
679
  if (isMissingPathError(error))
573
680
  return [];
574
681
  // Reported rather than thrown: the enclosing handler treats any throw as
575
- // "no specs dir" and would swallow this into a misleading no-deltas error.
576
- // Reporting also keeps the other capabilities' findings, which a throw
577
- // would discard. ERROR because archive reads the same file and now fails
578
- // on it too, so this is what archive refuses.
682
+ // "no specs dir" and would discard the other capabilities' findings.
683
+ // ERROR because archive reads the same file and fails on it too.
579
684
  return [
580
685
  {
581
686
  level: 'ERROR',
@@ -589,40 +694,116 @@ export class Validator {
589
694
  currentByName.set(normalizeRequirementName(block.name), block);
590
695
  }
591
696
  const issues = [];
697
+ // Declarations are consumed as matched, so a leftover is one that removed
698
+ // nothing — a typo that would otherwise read as a successful removal.
699
+ const unmatchedRemovals = new Set(declaredRemovals);
700
+ const declaredFor = (requirementKey) => {
701
+ const names = new Set();
702
+ for (const entry of declaredRemovals) {
703
+ if (entry.requirement === requirementKey)
704
+ names.add(entry.scenario);
705
+ }
706
+ return names;
707
+ };
708
+ for (const entry of declaredRemovals) {
709
+ if (!entry.hasReason) {
710
+ issues.push({
711
+ level: 'WARNING',
712
+ path: entryPath,
713
+ line: entry.startLine,
714
+ message: `REMOVED Scenarios entry for "${entry.scenario}" has no Reason. A scenario removal is a behavior removal — record why, the same as a removed requirement does.`,
715
+ });
716
+ }
717
+ }
592
718
  for (const block of modified) {
593
- const current = currentByName.get(normalizeRequirementName(block.name));
719
+ const key = normalizeRequirementName(block.name);
720
+ // Direct hit first; only fall back to the pre-rename name when this change
721
+ // renames the requirement, so an unrelated MODIFIED is unaffected.
722
+ const renamedFrom = renamedToFrom.get(key);
723
+ const current = currentByName.get(key) ??
724
+ (renamedFrom === undefined ? undefined : currentByName.get(normalizeRequirementName(renamedFrom)));
594
725
  if (!current)
595
726
  continue;
596
- const missing = findMissingScenarios(current.raw, block.raw);
597
- if (missing.length === 0)
727
+ // Named by its spec header, not its new one: that is the block the reader
728
+ // has to open to see the scenarios at risk.
729
+ const target = renamedFrom !== undefined && !currentByName.has(key) ? ` (renamed from "${renamedFrom}")` : '';
730
+ const declared = declaredFor(currentByName.has(key) ? key : normalizeRequirementName(renamedFrom ?? ''));
731
+ const missing = findMissingScenarios(current.raw, block.raw).filter((name) => {
732
+ if (!declared.has(name))
733
+ return true;
734
+ for (const entry of unmatchedRemovals) {
735
+ if (entry.scenario === name)
736
+ unmatchedRemovals.delete(entry);
737
+ }
738
+ return false;
739
+ });
740
+ if (missing.length > 0) {
741
+ issues.push({
742
+ level: 'ERROR',
743
+ path: entryPath,
744
+ message: `MODIFIED "${block.name}"${target} omits scenario(s) the current spec still has: ${missing
745
+ .map((name) => `"${name}"`)
746
+ .join(', ')}. A MODIFIED block replaces the whole requirement, so archiving would delete them — restate them in the block, or declare the removal under "## REMOVED Scenarios" with a Reason. ${REMOVED_SCENARIOS_GRAMMAR}`,
747
+ });
748
+ // One finding per requirement: a block missing whole scenarios is also
749
+ // missing their bullets, and reporting both buries the actionable one.
598
750
  continue;
751
+ }
752
+ // WARNING, not ERROR: rewording a bullet is a legitimate MODIFIED, but on
753
+ // disk it is indistinguishable from a stale block reverting someone else's
754
+ // archived edit.
755
+ //
756
+ // Compared against the current requirement *minus* the scenarios this
757
+ // change declared removed: their bullets go with them by definition.
758
+ const currentRaw = withoutScenarios(current.raw, [...declared]);
759
+ const dropped = findDroppedBullets(currentRaw, block.raw);
760
+ // A rewrite replaces bullets; a stale block loses them. When the delta
761
+ // brings at least as many new bullets as it drops, every dropped line has
762
+ // a successor and the block is a rewording — reporting it made every
763
+ // legitimate MODIFIED fail `--strict`. Fewer bullets than before is the
764
+ // shape of a reverted edit, which is the case this check exists for.
765
+ const replaced = findDroppedBullets(block.raw, currentRaw);
766
+ if (dropped.length > 0 && replaced.length < dropped.length) {
767
+ issues.push({
768
+ level: 'WARNING',
769
+ path: entryPath,
770
+ message: `MODIFIED "${block.name}"${target} drops bullet(s) the current spec still has: ${dropped
771
+ .map((line) => `"${line}"`)
772
+ .join(', ')}. Confirm this is an intentional rewrite and not a block written against an older ` +
773
+ `copy of the requirement — run tospec show <change> --diff to see the full comparison.`,
774
+ });
775
+ }
776
+ }
777
+ // A declaration that matched nothing: the author believes a scenario was
778
+ // removed, the spec still has it, and every command reports success. ERROR
779
+ // because there is no reading of it that is correct.
780
+ for (const entry of unmatchedRemovals) {
599
781
  issues.push({
600
782
  level: 'ERROR',
601
783
  path: entryPath,
602
- message: `MODIFIED "${block.name}" omits scenario(s) the current spec still has: ${missing
603
- .map((name) => `"${name}"`)
604
- .join(', ')}. A MODIFIED block replaces the whole requirement, so archiving would delete them — restate them in the block.`,
784
+ line: entry.startLine,
785
+ message: `REMOVED Scenarios declares "${entry.scenario}" under requirement "${entry.requirement}", but no MODIFIED block for that requirement drops that scenario. Check both names against tospec/specs/${capability}/spec.md — a declaration that matches nothing removes nothing.`,
605
786
  });
606
787
  }
607
788
  return issues;
608
789
  }
609
790
  /**
610
- * Dry-run the merge and report what it would refuse.
791
+ * Dry-run the merge and report what it would refuse, plus the one thing it
792
+ * would accept while doing nothing.
611
793
  *
612
794
  * Reusing the merge builder rather than restating its preconditions is the
613
- * whole point: several of them deliberately read a missing target as
614
- * already-synced rather than as a failure, and a second copy of those rules
615
- * would be free to drift — which shows up as validate and archive disagreeing,
616
- * the one thing this check exists to prevent.
795
+ * point: a second copy would be free to drift, which shows up as validate and
796
+ * archive disagreeing.
617
797
  *
618
- * INFO leaves the verdict alone. A MODIFIED whose target is missing is also
619
- * what a change modifying a sibling's unarchived requirement looks like, and
620
- * that is valid today; what is missing is the information, not the verdict.
798
+ * WARNING, not ERROR or INFO. ERROR is wrong because a MODIFIED whose target
799
+ * is missing is also what a change modifying a sibling's unarchived
800
+ * requirement looks like. INFO was wrong because `--strict` ignores it, so the
801
+ * pre-archive gate waved through changes archive would refuse.
802
+ * tospec/decisions/20260916_154000-archive-dry-run-findings-are-warnings.md
621
803
  */
622
- async findArchiveBlockers(changeDir, mainSpecsDir, alreadyReportedPaths) {
623
- const alreadyReported = new Set(alreadyReportedPaths);
624
- // Only ever reaches the generated skeleton's placeholder Purpose, which this
625
- // dry run discards along with the rest of the rebuilt content.
804
+ async findArchiveBlockers(changeDir, mainSpecsDir) {
805
+ // Only reaches the generated skeleton's placeholder Purpose, which this dry
806
+ // run discards with the rest of the rebuilt content.
626
807
  const changeName = path.basename(changeDir);
627
808
  const issues = [];
628
809
  let updates;
@@ -630,9 +811,8 @@ export class Validator {
630
811
  updates = await findSpecUpdates(changeDir, mainSpecsDir);
631
812
  }
632
813
  catch (error) {
633
- // An advisory check that cannot start must not take the report with it.
634
- // The structural pass walks the same tree with fast-glob and reports what
635
- // it found; those findings are the ones the author can act on.
814
+ // An advisory check that cannot start must not take the report with it:
815
+ // the structural pass walks the same tree and its findings are actionable.
636
816
  return [
637
817
  {
638
818
  level: 'INFO',
@@ -646,35 +826,53 @@ export class Validator {
646
826
  // rebuilds the same entryPath the checks above report under.
647
827
  const capability = path.basename(path.dirname(update.source));
648
828
  const entryPath = FileSystemUtils.toPosixPath(`${capability}/spec.md`);
649
- // A delta those checks already rejected would be reported twice, the
650
- // second time in the merge's wording rather than the wording that names
651
- // the actual mistake.
652
- if (alreadyReported.has(entryPath))
653
- continue;
654
829
  try {
655
- await buildUpdatedSpec(update, changeName, { silent: true });
830
+ const built = await buildUpdatedSpec(update, changeName, { silent: true });
831
+ // The merge would succeed but delete nothing, which is not what the
832
+ // delta appears to say. A mistyped REMOVED header is the likeliest
833
+ // cause and the hardest to catch, since nothing visibly happens.
834
+ //
835
+ // Only the REMOVED codes: the merge's other notices are about Purpose,
836
+ // which this validator judges under its own rules.
837
+ for (const notice of built.notices) {
838
+ if (!REMOVED_NOOP_NOTICE_CODES.has(notice.code))
839
+ continue;
840
+ issues.push({
841
+ level: 'WARNING',
842
+ path: entryPath,
843
+ message: `Archive would accept this delta but remove nothing: ${notice.message}`,
844
+ });
845
+ }
656
846
  }
657
847
  catch (error) {
658
848
  // Only the thrown preconditions, which carry no errno. A filesystem
659
- // error says nothing about whether the delta applies, and `validate
660
- // --all` reads several changes at once — a transient EMFILE reported as
661
- // a collision is a conflict that is not there.
849
+ // error says nothing about whether the delta applies, and under
850
+ // `validate --all` a transient EMFILE would read as a collision.
662
851
  if (error?.code !== undefined)
663
852
  continue;
853
+ const message = error instanceof Error ? error.message : String(error);
854
+ // The structural pass above already reported these in its own, more
855
+ // precise wording; repeating them in the merge's would name the same
856
+ // mistake twice. Filtered by *kind* rather than by path: the merge's
857
+ // other preconditions (a MODIFIED or RENAMED target the main spec does
858
+ // not have, an ADDED that collides) are checked nowhere else, so a delta
859
+ // with one unrelated grammar error used to hide them until that error
860
+ // was fixed — one round trip per finding.
861
+ if (STRUCTURAL_MERGE_FAILURE.test(message))
862
+ continue;
664
863
  issues.push({
665
- level: 'INFO',
864
+ level: 'WARNING',
666
865
  path: entryPath,
667
- message: `Archive would refuse this delta: ${error instanceof Error ? error.message : String(error)}`,
866
+ message: `Archive would refuse this delta: ${message}`,
668
867
  });
669
868
  }
670
869
  }
671
870
  return issues;
672
871
  }
673
872
  /**
674
- * Scenario-quality rules for a delta requirement block (report 2.1): only
675
- * `#### Scenario:` headers count as scenarios, each scenario body must
676
- * contain WHEN and THEN (uppercase, per the spec convention), and stray
677
- * level-4 headers get a WARNING so authors know they were not counted.
873
+ * Scenario-quality rules for a delta requirement block: only `#### Scenario:`
874
+ * headers count, each scenario body must contain uppercase WHEN and THEN, and
875
+ * a stray level-4 header gets a WARNING so the author knows it was not counted.
678
876
  */
679
877
  collectScenarioIssues(section, block, entryPath) {
680
878
  const issues = [];
@@ -689,6 +887,24 @@ export class Validator {
689
887
  message: `${section} "${block.name}" must include at least one "#### Scenario:" block`,
690
888
  });
691
889
  }
890
+ // Scenario names are the only handle the MODIFIED scenario-loss guard has —
891
+ // it counts how many times each name appears on each side. Restating one
892
+ // twice keeps the count and silently deletes the other's body on archive.
893
+ const scenarioNames = new Set();
894
+ for (const scenario of analysis.scenarios) {
895
+ const key = scenario.title.trim().toLowerCase();
896
+ if (scenarioNames.has(key)) {
897
+ issues.push({
898
+ level: 'ERROR',
899
+ path: entryPath,
900
+ line: toLine(scenario.relLine),
901
+ message: `${section} "${block.name}" repeats scenario name "${scenario.title}" (line ${toLine(scenario.relLine)}). Scenario names identify a scenario when a later MODIFIED restates this requirement, so two with the same name cannot be told apart — give each one a distinct name.`,
902
+ });
903
+ }
904
+ else {
905
+ scenarioNames.add(key);
906
+ }
907
+ }
692
908
  for (const scenario of analysis.scenarios) {
693
909
  const missing = [
694
910
  ...(scenario.hasWhen ? [] : ['WHEN']),
@@ -713,14 +929,21 @@ export class Validator {
713
929
  }
714
930
  return issues;
715
931
  }
716
- formatSectionList(sections) {
717
- if (sections.length === 0)
718
- return '';
719
- if (sections.length === 1)
720
- return sections[0];
721
- const head = sections.slice(0, -1);
722
- const last = sections[sections.length - 1];
723
- return `${head.join(', ')} and ${last}`;
932
+ /**
933
+ * RENAMED/REMOVED have their own grammar (FROM:/TO: pairs; name bullets with
934
+ * Reason/Migration), not the `### Requirement:` block ADDED/MODIFIED use, so
935
+ * "add a ### Requirement: block" would send their authors to a fix that does
936
+ * not work.
937
+ */
938
+ formatEmptySectionMessage(kind, header) {
939
+ switch (kind) {
940
+ case 'renamed':
941
+ return `Delta section "${header}" was found, but no rename pairs parsed. Write each entry as a FROM:/TO: pair naming the requirement, e.g. "- FROM: \`Old Name\`" then "- TO: \`New Name\`".`;
942
+ case 'removed':
943
+ return `Delta section "${header}" was found, but no entries parsed. List each removed requirement as a bullet naming it, e.g. "- Old Name", with Reason and Migration fields below it.`;
944
+ default:
945
+ return `Delta section "${header}" was found, but no requirement entries parsed. Ensure it includes at least one "### Requirement:" block.`;
946
+ }
724
947
  }
725
948
  }
726
949
  //# sourceMappingURL=validator.js.map