@seanmars/tospec 0.19.0-beta.8 → 0.21.0

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