@seanmars/tospec 0.23.0 → 0.26.0-beta

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 (358) hide show
  1. package/CHANGELOG.md +281 -0
  2. package/README.md +27 -9
  3. package/assets/claude-mod/tospec-dashboard/.claude-plugin/plugin.json +7 -0
  4. package/assets/claude-mod/tospec-dashboard/hooks/hooks.json +1 -0
  5. package/assets/claude-mod/tospec-dashboard/hooks/register.ts +695 -0
  6. package/assets/claude-mod/tospec-dashboard/tests/register.test.ts +448 -0
  7. package/assets/claude-mod/tospec-dashboard/types/index.d.ts +8 -0
  8. package/assets/dashboard/app.js +218 -1180
  9. package/assets/dashboard/index.html +31 -22
  10. package/assets/dashboard/lib/charts.js +355 -0
  11. package/assets/dashboard/lib/derive.js +375 -0
  12. package/assets/dashboard/lib/dom.js +121 -0
  13. package/assets/dashboard/lib/routes.js +52 -0
  14. package/assets/dashboard/lib/store.js +326 -0
  15. package/assets/dashboard/style.css +3103 -664
  16. package/assets/dashboard/views/alerts.js +170 -0
  17. package/assets/dashboard/views/change.js +234 -0
  18. package/assets/dashboard/views/docs.js +214 -0
  19. package/assets/dashboard/views/home.js +357 -0
  20. package/assets/dashboard/views/project.js +168 -0
  21. package/assets/dashboard/views/session.js +270 -0
  22. package/assets/dashboard/views/sidebar.js +131 -0
  23. package/dist/cli/index.d.ts.map +1 -1
  24. package/dist/cli/index.js +116 -102
  25. package/dist/cli/index.js.map +1 -1
  26. package/dist/commands/config.d.ts.map +1 -1
  27. package/dist/commands/config.js +213 -100
  28. package/dist/commands/config.js.map +1 -1
  29. package/dist/commands/dashboard-relay.d.ts +11 -0
  30. package/dist/commands/dashboard-relay.d.ts.map +1 -0
  31. package/dist/commands/dashboard-relay.js +88 -0
  32. package/dist/commands/dashboard-relay.js.map +1 -0
  33. package/dist/commands/dashboard.d.ts +69 -100
  34. package/dist/commands/dashboard.d.ts.map +1 -1
  35. package/dist/commands/dashboard.js +420 -598
  36. package/dist/commands/dashboard.js.map +1 -1
  37. package/dist/commands/decision.d.ts +38 -1
  38. package/dist/commands/decision.d.ts.map +1 -1
  39. package/dist/commands/decision.js +303 -27
  40. package/dist/commands/decision.js.map +1 -1
  41. package/dist/commands/metrics.d.ts +3 -4
  42. package/dist/commands/metrics.d.ts.map +1 -1
  43. package/dist/commands/metrics.js +3 -5
  44. package/dist/commands/metrics.js.map +1 -1
  45. package/dist/commands/shared-output.d.ts +24 -0
  46. package/dist/commands/shared-output.d.ts.map +1 -1
  47. package/dist/commands/shared-output.js +21 -2
  48. package/dist/commands/shared-output.js.map +1 -1
  49. package/dist/commands/show.d.ts.map +1 -1
  50. package/dist/commands/show.js +12 -9
  51. package/dist/commands/show.js.map +1 -1
  52. package/dist/commands/validate.d.ts +19 -0
  53. package/dist/commands/validate.d.ts.map +1 -1
  54. package/dist/commands/validate.js +243 -64
  55. package/dist/commands/validate.js.map +1 -1
  56. package/dist/commands/workflow/instructions.d.ts +7 -1
  57. package/dist/commands/workflow/instructions.d.ts.map +1 -1
  58. package/dist/commands/workflow/instructions.js +299 -81
  59. package/dist/commands/workflow/instructions.js.map +1 -1
  60. package/dist/commands/workflow/new-change.d.ts.map +1 -1
  61. package/dist/commands/workflow/new-change.js +26 -17
  62. package/dist/commands/workflow/new-change.js.map +1 -1
  63. package/dist/commands/workflow/prompt-escape.d.ts +25 -0
  64. package/dist/commands/workflow/prompt-escape.d.ts.map +1 -0
  65. package/dist/commands/workflow/prompt-escape.js +62 -0
  66. package/dist/commands/workflow/prompt-escape.js.map +1 -0
  67. package/dist/commands/workflow/schemas.d.ts.map +1 -1
  68. package/dist/commands/workflow/schemas.js +34 -16
  69. package/dist/commands/workflow/schemas.js.map +1 -1
  70. package/dist/commands/workflow/shared.d.ts +46 -10
  71. package/dist/commands/workflow/shared.d.ts.map +1 -1
  72. package/dist/commands/workflow/shared.js +66 -30
  73. package/dist/commands/workflow/shared.js.map +1 -1
  74. package/dist/commands/workflow/status.d.ts +5 -2
  75. package/dist/commands/workflow/status.d.ts.map +1 -1
  76. package/dist/commands/workflow/status.js +62 -28
  77. package/dist/commands/workflow/status.js.map +1 -1
  78. package/dist/core/archive.d.ts +38 -0
  79. package/dist/core/archive.d.ts.map +1 -1
  80. package/dist/core/archive.js +492 -75
  81. package/dist/core/archive.js.map +1 -1
  82. package/dist/core/artifact-graph/index.d.ts +2 -2
  83. package/dist/core/artifact-graph/index.d.ts.map +1 -1
  84. package/dist/core/artifact-graph/index.js +2 -2
  85. package/dist/core/artifact-graph/index.js.map +1 -1
  86. package/dist/core/artifact-graph/instruction-loader.d.ts +8 -0
  87. package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
  88. package/dist/core/artifact-graph/instruction-loader.js +58 -36
  89. package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
  90. package/dist/core/artifact-graph/resolver.d.ts +39 -12
  91. package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
  92. package/dist/core/artifact-graph/resolver.js +82 -36
  93. package/dist/core/artifact-graph/resolver.js.map +1 -1
  94. package/dist/core/artifact-graph/schema.d.ts +24 -0
  95. package/dist/core/artifact-graph/schema.d.ts.map +1 -1
  96. package/dist/core/artifact-graph/schema.js +32 -1
  97. package/dist/core/artifact-graph/schema.js.map +1 -1
  98. package/dist/core/artifact-graph/stub-detection.d.ts +10 -2
  99. package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -1
  100. package/dist/core/artifact-graph/stub-detection.js +18 -11
  101. package/dist/core/artifact-graph/stub-detection.js.map +1 -1
  102. package/dist/core/artifact-graph/types.d.ts +2 -2
  103. package/dist/core/artifact-graph/types.d.ts.map +1 -1
  104. package/dist/core/artifact-graph/types.js +10 -1
  105. package/dist/core/artifact-graph/types.js.map +1 -1
  106. package/dist/core/change-metadata/schema.d.ts.map +1 -1
  107. package/dist/core/change-metadata/schema.js +18 -4
  108. package/dist/core/change-metadata/schema.js.map +1 -1
  109. package/dist/core/change-presenter.d.ts.map +1 -1
  110. package/dist/core/change-presenter.js +13 -8
  111. package/dist/core/change-presenter.js.map +1 -1
  112. package/dist/core/change-status-policy.d.ts.map +1 -1
  113. package/dist/core/change-status-policy.js +3 -1
  114. package/dist/core/change-status-policy.js.map +1 -1
  115. package/dist/core/codex-metrics.d.ts +1 -1
  116. package/dist/core/codex-metrics.js +1 -1
  117. package/dist/core/config-schema.d.ts +8 -0
  118. package/dist/core/config-schema.d.ts.map +1 -1
  119. package/dist/core/config-schema.js +10 -0
  120. package/dist/core/config-schema.js.map +1 -1
  121. package/dist/core/dashboard-activity.d.ts +4 -4
  122. package/dist/core/dashboard-activity.js +4 -4
  123. package/dist/core/dashboard-data.d.ts +11 -5
  124. package/dist/core/dashboard-data.d.ts.map +1 -1
  125. package/dist/core/dashboard-data.js +54 -28
  126. package/dist/core/dashboard-data.js.map +1 -1
  127. package/dist/core/dashboard-hub.d.ts +258 -0
  128. package/dist/core/dashboard-hub.d.ts.map +1 -0
  129. package/dist/core/dashboard-hub.js +655 -0
  130. package/dist/core/dashboard-hub.js.map +1 -0
  131. package/dist/core/dashboard-routes.d.ts +57 -0
  132. package/dist/core/dashboard-routes.d.ts.map +1 -0
  133. package/dist/core/dashboard-routes.js +398 -0
  134. package/dist/core/dashboard-routes.js.map +1 -0
  135. package/dist/core/global-config.d.ts +71 -2
  136. package/dist/core/global-config.d.ts.map +1 -1
  137. package/dist/core/global-config.js +149 -4
  138. package/dist/core/global-config.js.map +1 -1
  139. package/dist/core/id.d.ts +9 -0
  140. package/dist/core/id.d.ts.map +1 -1
  141. package/dist/core/id.js +37 -0
  142. package/dist/core/id.js.map +1 -1
  143. package/dist/core/init.d.ts +9 -2
  144. package/dist/core/init.d.ts.map +1 -1
  145. package/dist/core/init.js +119 -33
  146. package/dist/core/init.js.map +1 -1
  147. package/dist/core/list.d.ts.map +1 -1
  148. package/dist/core/list.js +69 -45
  149. package/dist/core/list.js.map +1 -1
  150. package/dist/core/local-server.d.ts +10 -3
  151. package/dist/core/local-server.d.ts.map +1 -1
  152. package/dist/core/local-server.js +25 -5
  153. package/dist/core/local-server.js.map +1 -1
  154. package/dist/core/markdown-render.d.ts.map +1 -1
  155. package/dist/core/markdown-render.js +7 -2
  156. package/dist/core/markdown-render.js.map +1 -1
  157. package/dist/core/migrate.d.ts +24 -2
  158. package/dist/core/migrate.d.ts.map +1 -1
  159. package/dist/core/migrate.js +335 -59
  160. package/dist/core/migrate.js.map +1 -1
  161. package/dist/core/parsers/change-parser.d.ts +20 -1
  162. package/dist/core/parsers/change-parser.d.ts.map +1 -1
  163. package/dist/core/parsers/change-parser.js +76 -73
  164. package/dist/core/parsers/change-parser.js.map +1 -1
  165. package/dist/core/parsers/markdown-parser.d.ts +24 -0
  166. package/dist/core/parsers/markdown-parser.d.ts.map +1 -1
  167. package/dist/core/parsers/markdown-parser.js +65 -10
  168. package/dist/core/parsers/markdown-parser.js.map +1 -1
  169. package/dist/core/parsers/requirement-blocks.d.ts +127 -6
  170. package/dist/core/parsers/requirement-blocks.d.ts.map +1 -1
  171. package/dist/core/parsers/requirement-blocks.js +349 -56
  172. package/dist/core/parsers/requirement-blocks.js.map +1 -1
  173. package/dist/core/parsers/requirement-text.d.ts +25 -1
  174. package/dist/core/parsers/requirement-text.d.ts.map +1 -1
  175. package/dist/core/parsers/requirement-text.js +62 -6
  176. package/dist/core/parsers/requirement-text.js.map +1 -1
  177. package/dist/core/parsers/spec-structure.d.ts.map +1 -1
  178. package/dist/core/parsers/spec-structure.js +36 -24
  179. package/dist/core/parsers/spec-structure.js.map +1 -1
  180. package/dist/core/project-config.d.ts +9 -2
  181. package/dist/core/project-config.d.ts.map +1 -1
  182. package/dist/core/project-config.js +30 -7
  183. package/dist/core/project-config.js.map +1 -1
  184. package/dist/core/project-layout.d.ts +26 -0
  185. package/dist/core/project-layout.d.ts.map +1 -1
  186. package/dist/core/project-layout.js +51 -1
  187. package/dist/core/project-layout.js.map +1 -1
  188. package/dist/core/schema-names.d.ts +17 -0
  189. package/dist/core/schema-names.d.ts.map +1 -1
  190. package/dist/core/schema-names.js +24 -0
  191. package/dist/core/schema-names.js.map +1 -1
  192. package/dist/core/shared/dashboard-mod.d.ts +24 -0
  193. package/dist/core/shared/dashboard-mod.d.ts.map +1 -0
  194. package/dist/core/shared/dashboard-mod.js +44 -0
  195. package/dist/core/shared/dashboard-mod.js.map +1 -0
  196. package/dist/core/shared/index.d.ts +2 -1
  197. package/dist/core/shared/index.d.ts.map +1 -1
  198. package/dist/core/shared/index.js +2 -1
  199. package/dist/core/shared/index.js.map +1 -1
  200. package/dist/core/shared/rules-generation.d.ts +12 -0
  201. package/dist/core/shared/rules-generation.d.ts.map +1 -1
  202. package/dist/core/shared/rules-generation.js +26 -2
  203. package/dist/core/shared/rules-generation.js.map +1 -1
  204. package/dist/core/shared/skill-generation.d.ts +7 -0
  205. package/dist/core/shared/skill-generation.d.ts.map +1 -1
  206. package/dist/core/shared/skill-generation.js +19 -0
  207. package/dist/core/shared/skill-generation.js.map +1 -1
  208. package/dist/core/shared/tool-detection.d.ts +10 -1
  209. package/dist/core/shared/tool-detection.d.ts.map +1 -1
  210. package/dist/core/shared/tool-detection.js +37 -6
  211. package/dist/core/shared/tool-detection.js.map +1 -1
  212. package/dist/core/spec-presenter.d.ts.map +1 -1
  213. package/dist/core/spec-presenter.js +23 -1
  214. package/dist/core/spec-presenter.js.map +1 -1
  215. package/dist/core/specs-apply.d.ts +98 -1
  216. package/dist/core/specs-apply.d.ts.map +1 -1
  217. package/dist/core/specs-apply.js +426 -63
  218. package/dist/core/specs-apply.js.map +1 -1
  219. package/dist/core/templates/fragments/change-diff.d.ts +37 -0
  220. package/dist/core/templates/fragments/change-diff.d.ts.map +1 -0
  221. package/dist/core/templates/fragments/change-diff.js +37 -0
  222. package/dist/core/templates/fragments/change-diff.js.map +1 -0
  223. package/dist/core/templates/fragments/sync-fail.d.ts +8 -0
  224. package/dist/core/templates/fragments/sync-fail.d.ts.map +1 -0
  225. package/dist/core/templates/fragments/sync-fail.js +8 -0
  226. package/dist/core/templates/fragments/sync-fail.js.map +1 -0
  227. package/dist/core/templates/fragments/validate.d.ts +14 -1
  228. package/dist/core/templates/fragments/validate.d.ts.map +1 -1
  229. package/dist/core/templates/fragments/validate.js +14 -1
  230. package/dist/core/templates/fragments/validate.js.map +1 -1
  231. package/dist/core/templates/fragments/verify.d.ts +2 -2
  232. package/dist/core/templates/fragments/verify.d.ts.map +1 -1
  233. package/dist/core/templates/fragments/verify.js +8 -5
  234. package/dist/core/templates/fragments/verify.js.map +1 -1
  235. package/dist/core/templates/workflows/apply.d.ts +1 -1
  236. package/dist/core/templates/workflows/apply.d.ts.map +1 -1
  237. package/dist/core/templates/workflows/apply.js +29 -10
  238. package/dist/core/templates/workflows/apply.js.map +1 -1
  239. package/dist/core/templates/workflows/archive.d.ts.map +1 -1
  240. package/dist/core/templates/workflows/archive.js +27 -9
  241. package/dist/core/templates/workflows/archive.js.map +1 -1
  242. package/dist/core/templates/workflows/decision.d.ts.map +1 -1
  243. package/dist/core/templates/workflows/decision.js +10 -4
  244. package/dist/core/templates/workflows/decision.js.map +1 -1
  245. package/dist/core/templates/workflows/explore.d.ts +2 -2
  246. package/dist/core/templates/workflows/explore.d.ts.map +1 -1
  247. package/dist/core/templates/workflows/explore.js +7 -3
  248. package/dist/core/templates/workflows/explore.js.map +1 -1
  249. package/dist/core/templates/workflows/grill.d.ts +2 -2
  250. package/dist/core/templates/workflows/grill.d.ts.map +1 -1
  251. package/dist/core/templates/workflows/grill.js +5 -4
  252. package/dist/core/templates/workflows/grill.js.map +1 -1
  253. package/dist/core/templates/workflows/issue.d.ts.map +1 -1
  254. package/dist/core/templates/workflows/issue.js +9 -3
  255. package/dist/core/templates/workflows/issue.js.map +1 -1
  256. package/dist/core/templates/workflows/propose.d.ts.map +1 -1
  257. package/dist/core/templates/workflows/propose.js +4 -1
  258. package/dist/core/templates/workflows/propose.js.map +1 -1
  259. package/dist/core/templates/workflows/sync.d.ts.map +1 -1
  260. package/dist/core/templates/workflows/sync.js +25 -11
  261. package/dist/core/templates/workflows/sync.js.map +1 -1
  262. package/dist/core/templates/workflows/update.d.ts.map +1 -1
  263. package/dist/core/templates/workflows/update.js +17 -10
  264. package/dist/core/templates/workflows/update.js.map +1 -1
  265. package/dist/core/update.d.ts +5 -0
  266. package/dist/core/update.d.ts.map +1 -1
  267. package/dist/core/update.js +69 -29
  268. package/dist/core/update.js.map +1 -1
  269. package/dist/core/validation/constants.d.ts +7 -1
  270. package/dist/core/validation/constants.d.ts.map +1 -1
  271. package/dist/core/validation/constants.js +16 -2
  272. package/dist/core/validation/constants.js.map +1 -1
  273. package/dist/core/validation/purpose-placeholder.d.ts.map +1 -1
  274. package/dist/core/validation/purpose-placeholder.js +35 -11
  275. package/dist/core/validation/purpose-placeholder.js.map +1 -1
  276. package/dist/core/validation/section-validator.d.ts +7 -0
  277. package/dist/core/validation/section-validator.d.ts.map +1 -1
  278. package/dist/core/validation/section-validator.js +22 -2
  279. package/dist/core/validation/section-validator.js.map +1 -1
  280. package/dist/core/validation/sister-changes.d.ts +93 -0
  281. package/dist/core/validation/sister-changes.d.ts.map +1 -0
  282. package/dist/core/validation/sister-changes.js +180 -0
  283. package/dist/core/validation/sister-changes.js.map +1 -0
  284. package/dist/core/validation/task-markers.d.ts +9 -0
  285. package/dist/core/validation/task-markers.d.ts.map +1 -0
  286. package/dist/core/validation/task-markers.js +71 -0
  287. package/dist/core/validation/task-markers.js.map +1 -0
  288. package/dist/core/validation/types.d.ts +58 -0
  289. package/dist/core/validation/types.d.ts.map +1 -1
  290. package/dist/core/validation/types.js +22 -0
  291. package/dist/core/validation/types.js.map +1 -1
  292. package/dist/core/validation/validator.d.ts +79 -2
  293. package/dist/core/validation/validator.d.ts.map +1 -1
  294. package/dist/core/validation/validator.js +565 -84
  295. package/dist/core/validation/validator.js.map +1 -1
  296. package/dist/utils/change-metadata.d.ts +62 -5
  297. package/dist/utils/change-metadata.d.ts.map +1 -1
  298. package/dist/utils/change-metadata.js +193 -31
  299. package/dist/utils/change-metadata.js.map +1 -1
  300. package/dist/utils/change-utils.d.ts +21 -0
  301. package/dist/utils/change-utils.d.ts.map +1 -1
  302. package/dist/utils/change-utils.js +168 -53
  303. package/dist/utils/change-utils.js.map +1 -1
  304. package/dist/utils/command-line.d.ts +11 -0
  305. package/dist/utils/command-line.d.ts.map +1 -0
  306. package/dist/utils/command-line.js +43 -0
  307. package/dist/utils/command-line.js.map +1 -0
  308. package/dist/utils/display-width.d.ts +10 -0
  309. package/dist/utils/display-width.d.ts.map +1 -0
  310. package/dist/utils/display-width.js +15 -0
  311. package/dist/utils/display-width.js.map +1 -0
  312. package/dist/utils/file-system.d.ts +16 -0
  313. package/dist/utils/file-system.d.ts.map +1 -1
  314. package/dist/utils/file-system.js +49 -2
  315. package/dist/utils/file-system.js.map +1 -1
  316. package/dist/utils/frontmatter.d.ts.map +1 -1
  317. package/dist/utils/frontmatter.js +11 -5
  318. package/dist/utils/frontmatter.js.map +1 -1
  319. package/dist/utils/interactive.d.ts.map +1 -1
  320. package/dist/utils/interactive.js +3 -1
  321. package/dist/utils/interactive.js.map +1 -1
  322. package/dist/utils/item-discovery.d.ts +3 -2
  323. package/dist/utils/item-discovery.d.ts.map +1 -1
  324. package/dist/utils/item-discovery.js +34 -16
  325. package/dist/utils/item-discovery.js.map +1 -1
  326. package/dist/utils/link.d.ts +0 -1
  327. package/dist/utils/link.d.ts.map +1 -1
  328. package/dist/utils/link.js +0 -1
  329. package/dist/utils/link.js.map +1 -1
  330. package/dist/utils/match.d.ts.map +1 -1
  331. package/dist/utils/match.js +10 -1
  332. package/dist/utils/match.js.map +1 -1
  333. package/dist/utils/option-flags.d.ts +9 -0
  334. package/dist/utils/option-flags.d.ts.map +1 -0
  335. package/dist/utils/option-flags.js +12 -0
  336. package/dist/utils/option-flags.js.map +1 -0
  337. package/dist/utils/sentence.d.ts +9 -0
  338. package/dist/utils/sentence.d.ts.map +1 -0
  339. package/dist/utils/sentence.js +12 -0
  340. package/dist/utils/sentence.js.map +1 -0
  341. package/dist/utils/spec-files.d.ts +84 -1
  342. package/dist/utils/spec-files.d.ts.map +1 -1
  343. package/dist/utils/spec-files.js +192 -3
  344. package/dist/utils/spec-files.js.map +1 -1
  345. package/dist/utils/task-progress.d.ts.map +1 -1
  346. package/dist/utils/task-progress.js +32 -9
  347. package/dist/utils/task-progress.js.map +1 -1
  348. package/dist/utils/timestamp.d.ts +27 -0
  349. package/dist/utils/timestamp.d.ts.map +1 -1
  350. package/dist/utils/timestamp.js +37 -0
  351. package/dist/utils/timestamp.js.map +1 -1
  352. package/package.json +6 -2
  353. package/schemas/decision/templates/decision.md +2 -2
  354. package/schemas/issue/schema.yaml +12 -6
  355. package/schemas/issue/templates/task.md +1 -1
  356. package/schemas/sdd/schema.yaml +21 -10
  357. package/schemas/sdd/templates/proposal.md +1 -1
  358. package/assets/dashboard/chart.umd.js +0 -14
@@ -1,14 +1,111 @@
1
1
  /** Applies a change's delta specs to the main specs, independently of archiving. */
2
2
  import { promises as fs } from 'fs';
3
3
  import path from 'path';
4
- import { extractRequirementsSection, parseDeltaSpec, normalizeRequirementName, foldRequirementName, REQUIREMENT_HEADER_REGEX, } from './parsers/requirement-blocks.js';
4
+ import { extractRequirementsSection, parseDeltaSpec, normalizeRequirementName, foldRequirementName, findFoldedMatch, matchRequirementHeader, sectionHeaderKey, postRenameRemovalHint, } from './parsers/requirement-blocks.js';
5
5
  import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
6
- import { isAbsentDirectoryError, isMissingPathError } from '../utils/file-system.js';
6
+ import { headingLevel } from './parsers/markdown-parser.js';
7
+ import { isAbsentDirectoryError, isDirectoryEntry, isMissingPathError } from '../utils/file-system.js';
7
8
  import { buildCodeFenceMask, findMissingScenarios, normalizeDocument } from './parsers/requirement-text.js';
8
9
  import { PURPOSE_PLACEHOLDER_PREFIX, PURPOSE_PLACEHOLDER_SUFFIX, } from './validation/constants.js';
10
+ /**
11
+ * A merge refused for what the main spec holds, not the delta. Typed so the
12
+ * caller can point its fix at the main spec: every merge error used to read
13
+ * "Fix the change delta specs", including a structural fault in the main spec,
14
+ * where no edit to the delta helps.
15
+ */
16
+ export class MainSpecMergeError extends Error {
17
+ specPath;
18
+ constructor(message,
19
+ /** Project-relative POSIX path of the main spec to edit. */
20
+ specPath) {
21
+ super(message);
22
+ this.specPath = specPath;
23
+ this.name = 'MainSpecMergeError';
24
+ }
25
+ }
26
+ /**
27
+ * A MODIFIED, REMOVED or RENAMED aimed at a capability that has no main spec. Typed so
28
+ * validate can tell this refusal from the others: it is the one another
29
+ * in-progress change's archive can resolve, and validate names that change.
30
+ */
31
+ export class MissingTargetSpecError extends Error {
32
+ constructor(message) {
33
+ super(message);
34
+ this.name = 'MissingTargetSpecError';
35
+ }
36
+ }
37
+ /**
38
+ * A MODIFIED or RENAMED whose requirement the main spec lacks, with no near
39
+ * miss to blame. Typed for the reason `MissingTargetSpecError` is: a sister
40
+ * change may still be adding that requirement, and validate names it.
41
+ */
42
+ export class MissingTargetRequirementError extends Error {
43
+ requirement;
44
+ operation;
45
+ constructor(message,
46
+ /** The requirement name as the delta wrote it. */
47
+ requirement,
48
+ /** Which entry named it, so validate reads it as the delta touch it is. */
49
+ operation) {
50
+ super(message);
51
+ this.requirement = requirement;
52
+ this.operation = operation;
53
+ this.name = 'MissingTargetRequirementError';
54
+ }
55
+ }
56
+ /**
57
+ * A MODIFIED, REMOVED or RENAMED (FROM) name the main spec holds only under
58
+ * a spelling that differs in case or spacing. Typed so validate can hand the
59
+ * main spec's exact name on as `suggestedHeader`: reading it back out of the
60
+ * message would break on any rewording of it.
61
+ */
62
+ export class RequirementNearMissError extends Error {
63
+ requirement;
64
+ existing;
65
+ operation;
66
+ constructor(message,
67
+ /** The requirement name as the delta wrote it. */
68
+ requirement,
69
+ /** The main spec's exact name, the one the delta has to use. */
70
+ existing, operation) {
71
+ super(message);
72
+ this.requirement = requirement;
73
+ this.existing = existing;
74
+ this.operation = operation;
75
+ this.name = 'RequirementNearMissError';
76
+ }
77
+ }
9
78
  export async function findSpecUpdates(changeDir, mainSpecsDir) {
10
79
  const updates = [];
11
- const changeSpecsDir = path.join(changeDir, 'specs');
80
+ for (const { capability, source } of await findDeltaSpecSources(path.join(changeDir, 'specs'))) {
81
+ const targetFile = path.join(mainSpecsDir, capability, 'spec.md');
82
+ // Same rule: calling an unreachable target absent sends the merge down the
83
+ // new-capability path, which rebuilds the spec from a skeleton.
84
+ let exists = true;
85
+ try {
86
+ await fs.access(targetFile);
87
+ }
88
+ catch (error) {
89
+ if (!isMissingPathError(error))
90
+ throw error;
91
+ exists = false;
92
+ }
93
+ updates.push({
94
+ source,
95
+ target: targetFile,
96
+ exists,
97
+ });
98
+ }
99
+ return updates;
100
+ }
101
+ /**
102
+ * The delta files the merge reads, one per capability folder. `show` reads
103
+ * this list too: with a walk of its own it listed a `SPEC.md` (on a
104
+ * case-insensitive file system) and a dot folder's spec.md as deltas, which
105
+ * validate and archive never read.
106
+ */
107
+ export async function findDeltaSpecSources(changeSpecsDir) {
108
+ const sources = [];
12
109
  let entries;
13
110
  try {
14
111
  entries = await fs.readdir(changeSpecsDir, { withFileTypes: true });
@@ -17,42 +114,47 @@ export async function findSpecUpdates(changeDir, mainSpecsDir) {
17
114
  // An empty result means "no deltas", and archive merges nothing and reports
18
115
  // success on it. Only a genuinely absent directory may say that.
19
116
  if (isAbsentDirectoryError(error))
20
- return updates;
117
+ return sources;
21
118
  throw error;
22
119
  }
23
120
  for (const entry of entries) {
24
- if (!entry.isDirectory())
121
+ // Dot-directories are not capabilities: validate rejects one as a delta
122
+ // folder, and list, show, validate --specs and artifact status all skip
123
+ // one. Merging it wrote a main spec at tospec/specs/.<name>/ that none of
124
+ // them can reach; left out here, archive names it as an unmerged file.
125
+ // A linked capability folder is followed, as status and instructions
126
+ // follow it: skipped here, its delta was reported as nothing to merge.
127
+ if (!isDirectoryEntry(changeSpecsDir, entry) || entry.name.startsWith('.'))
25
128
  continue;
26
- const specFile = path.join(changeSpecsDir, entry.name, 'spec.md');
27
- const targetFile = path.join(mainSpecsDir, entry.name, 'spec.md');
129
+ const capabilityDir = path.join(changeSpecsDir, entry.name);
130
+ const specFile = path.join(capabilityDir, 'spec.md');
131
+ // Spelled exactly spec.md, as validate, findSpecFiles and artifact status
132
+ // read it. Asking the file system for the path lets it decide the case: on
133
+ // Windows and default macOS a specs/<capability>/SPEC.md merged here while
134
+ // validate called it ignored, and a case-sensitive file system dropped it.
135
+ // Left out on both, archive names it with the other misnamed deltas.
136
+ if (!(await fs.readdir(capabilityDir)).includes('spec.md'))
137
+ continue;
138
+ // stat, not Dirent.isFile(): it follows a link, so a linked spec.md stays a
139
+ // delta. A directory named spec.md is not one; taken as a source, it failed
140
+ // the merge with EISDIR. The access() this replaced only repeated the name
141
+ // check above and let that directory through.
142
+ let stats;
28
143
  try {
29
- await fs.access(specFile);
144
+ stats = await fs.stat(specFile);
30
145
  }
31
146
  catch (error) {
32
- // A capability folder with no spec.md is not a delta; an unreachable
33
- // spec.md is one, and skipping it drops its requirements silently.
147
+ // A dangling link is not a delta; an unreachable spec.md is one, and
148
+ // skipping it drops its requirements silently.
34
149
  if (isMissingPathError(error))
35
150
  continue;
36
151
  throw error;
37
152
  }
38
- // Same rule: calling an unreachable target absent sends the merge down the
39
- // new-capability path, which rebuilds the spec from a skeleton.
40
- let exists = true;
41
- try {
42
- await fs.access(targetFile);
43
- }
44
- catch (error) {
45
- if (!isMissingPathError(error))
46
- throw error;
47
- exists = false;
48
- }
49
- updates.push({
50
- source: specFile,
51
- target: targetFile,
52
- exists,
53
- });
153
+ if (!stats.isFile())
154
+ continue;
155
+ sources.push({ capability: entry.name, source: specFile });
54
156
  }
55
- return updates;
157
+ return sources;
56
158
  }
57
159
  /**
58
160
  * The capability directory's real name on disk, or `fallback` when absent.
@@ -60,7 +162,7 @@ export async function findSpecUpdates(changeDir, mainSpecsDir) {
60
162
  * `specs/regexcap` are one directory — this normalises a message, it does not
61
163
  * merge anything.
62
164
  */
63
- async function resolveOnDiskSpecName(targetPath, fallback) {
165
+ export async function resolveOnDiskSpecName(targetPath, fallback) {
64
166
  const parent = path.dirname(path.dirname(targetPath));
65
167
  try {
66
168
  const entries = await fs.readdir(parent);
@@ -77,6 +179,19 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
77
179
  if (!options.silent)
78
180
  console.log(`Warning: ${message}`);
79
181
  };
182
+ const structuralFailures = [];
183
+ const structural = (message) => {
184
+ if (!options.deferStructural)
185
+ throw new Error(message);
186
+ structuralFailures.push(message);
187
+ };
188
+ // Names the delta contradicts itself about (a duplicate, a cross-section
189
+ // conflict, an unpaired rename). Its intent for them is unknown, so a deferred
190
+ // run applies no operation on them: checking one against the main spec would
191
+ // report a consequence of the contradiction (a MODIFIED "not found" after the
192
+ // REMOVED that conflicts with it ran) as a second mistake. Stays empty when
193
+ // not deferring, since the contradiction throws first.
194
+ const contested = new Set();
80
195
  const changeContent = await fs.readFile(update.source, 'utf-8');
81
196
  const plan = parseDeltaSpec(changeContent);
82
197
  // The capability as the delta spelled it — messages about the delta's own
@@ -86,12 +201,21 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
86
201
  // filesystem: a delta under `specs/RegexCap/` resolves to the existing
87
202
  // `tospec/specs/regexcap/`.
88
203
  const onDiskSpecName = await resolveOnDiskSpecName(update.target, specName);
204
+ const mainSpecPath = `tospec/specs/${onDiskSpecName}/spec.md`;
205
+ // Duplicates are judged folded, the rule the cross-section checks below and
206
+ // the main-spec guard apply: `Foo` + `foo` in one ADDED landed two
207
+ // requirements no reader can tell apart, and in REMOVED the second turned
208
+ // into a silent "already removed" no-op.
209
+ const foldedDuplicateNote = (earlier, name) => earlier === name ? '' : ` (differs only in case or spacing from "${earlier}")`;
89
210
  const collectUnique = (names, section) => {
90
211
  const seen = new Set();
91
212
  for (const original of names) {
92
213
  const name = normalizeRequirementName(original);
93
- if (seen.has(name)) {
94
- throw new Error(`${specName} validation failed - duplicate requirement in ${section} for header "### Requirement: ${original}"`);
214
+ const earlier = findFoldedMatch(seen, name);
215
+ if (earlier !== undefined) {
216
+ structural(`${specName} validation failed - duplicate requirement in ${section} for header "### Requirement: ${original}"${foldedDuplicateNote(earlier, name)}`);
217
+ contested.add(earlier).add(name);
218
+ continue;
95
219
  }
96
220
  seen.add(name);
97
221
  }
@@ -100,6 +224,19 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
100
224
  const addedNames = collectUnique(plan.added.map((add) => add.name), 'ADDED');
101
225
  const modifiedNames = collectUnique(plan.modified.map((mod) => mod.name), 'MODIFIED');
102
226
  const removedNamesSet = collectUnique(plan.removed, 'REMOVED');
227
+ // A FROM or TO with no partner. The pairs the parser did form around it are
228
+ // a guess (`FROM a / FROM b / TO x / TO y` pairs b->x), and validate rejects
229
+ // the delta; without this, `--no-validate` applied the guess.
230
+ if (plan.renamedIssues.length > 0) {
231
+ const issue = plan.renamedIssues[0];
232
+ const side = issue.kind === 'from-without-to' ? 'FROM' : 'TO';
233
+ const missing = issue.kind === 'from-without-to' ? 'TO:' : 'FROM:';
234
+ structural(`${specName} validation failed - RENAMED ${side} "${issue.name}" (line ${issue.line}) has no matching ${missing} line; write RENAMED entries as FROM:/TO: pairs`);
235
+ // Every pair the parser formed is a guess once one side is unpaired.
236
+ for (const { from, to } of plan.renamed) {
237
+ contested.add(normalizeRequirementName(from)).add(normalizeRequirementName(to));
238
+ }
239
+ }
103
240
  // Checked per entry rather than via collectUnique: a pair that duplicates
104
241
  // both sides must report the FROM/TO the original interleaving would.
105
242
  const renamedFromSet = new Set();
@@ -107,11 +244,17 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
107
244
  for (const { from, to } of plan.renamed) {
108
245
  const fromNorm = normalizeRequirementName(from);
109
246
  const toNorm = normalizeRequirementName(to);
110
- if (renamedFromSet.has(fromNorm)) {
111
- throw new Error(`${specName} validation failed - duplicate FROM in RENAMED for header "### Requirement: ${from}"`);
247
+ const earlierFrom = findFoldedMatch(renamedFromSet, fromNorm);
248
+ if (earlierFrom !== undefined) {
249
+ structural(`${specName} validation failed - duplicate FROM in RENAMED for header "### Requirement: ${from}"${foldedDuplicateNote(earlierFrom, fromNorm)}`);
250
+ contested.add(earlierFrom).add(fromNorm).add(toNorm);
251
+ continue;
112
252
  }
113
- if (renamedToSet.has(toNorm)) {
114
- throw new Error(`${specName} validation failed - duplicate TO in RENAMED for header "### Requirement: ${to}"`);
253
+ const earlierTo = findFoldedMatch(renamedToSet, toNorm);
254
+ if (earlierTo !== undefined) {
255
+ structural(`${specName} validation failed - duplicate TO in RENAMED for header "### Requirement: ${to}"${foldedDuplicateNote(earlierTo, toNorm)}`);
256
+ contested.add(earlierTo).add(fromNorm).add(toNorm);
257
+ continue;
115
258
  }
116
259
  renamedFromSet.add(fromNorm);
117
260
  renamedToSet.add(toNorm);
@@ -133,27 +276,46 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
133
276
  // A REMOVED naming the FROM side contradicts the rename. Since a missing
134
277
  // REMOVED target is a no-op (early-sync), this must be rejected explicitly.
135
278
  // Folded, so a case or whitespace variant cannot slip past as a no-op.
136
- const removedFoldMatch = [...removedNamesSet].find((r) => foldRequirementName(r) === foldRequirementName(fromNorm));
279
+ const removedFoldMatch = findFoldedMatch(removedNamesSet, fromNorm);
137
280
  if (removedFoldMatch !== undefined) {
138
- throw new Error(`${specName} validation failed - requirement present in multiple sections (RENAMED and REMOVED) for header "### Requirement: ${from}"` +
281
+ structural(`${specName} validation failed - requirement present in multiple sections (RENAMED and REMOVED) for header "### Requirement: ${from}"` +
139
282
  (removedFoldMatch === fromNorm ? '' : ` (REMOVED spells it "${removedFoldMatch}")`));
283
+ contested.add(removedFoldMatch).add(fromNorm).add(toNorm);
140
284
  }
141
285
  if (modifiedNames.has(fromNorm)) {
142
- throw new Error(`${specName} validation failed - when a rename exists, MODIFIED must reference the NEW header "### Requirement: ${to}"`);
286
+ structural(`${specName} validation failed - when a rename exists, MODIFIED must reference the NEW header "### Requirement: ${to}"`);
287
+ contested.add(fromNorm).add(toNorm);
143
288
  }
144
- if (addedNames.has(toNorm)) {
145
- throw new Error(`${specName} validation failed - RENAMED TO header collides with ADDED for "### Requirement: ${to}"`);
289
+ // Folded: `LATE FEES` and `late fees` are one name to a reader, and both
290
+ // landing would leave two requirements no one can tell apart.
291
+ const addedFoldMatch = findFoldedMatch(addedNames, toNorm);
292
+ if (addedFoldMatch !== undefined) {
293
+ structural(`${specName} validation failed - RENAMED TO header collides with ADDED for "### Requirement: ${to}"` +
294
+ (addedFoldMatch === toNorm ? '' : ` (ADDED spells it "${addedFoldMatch}")`));
295
+ contested.add(addedFoldMatch).add(fromNorm).add(toNorm);
146
296
  }
147
297
  }
148
298
  if (conflicts.length > 0) {
149
299
  const c = conflicts[0];
150
- throw new Error(`${specName} validation failed - requirement present in multiple sections (${c.a} and ${c.b}) for header "### Requirement: ${c.name}"`);
300
+ structural(`${specName} validation failed - requirement present in multiple sections (${c.a} and ${c.b}) for header "### Requirement: ${c.name}"`);
301
+ for (const conflict of conflicts)
302
+ contested.add(conflict.name);
151
303
  }
152
304
  const hasAnyDelta = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0;
153
305
  if (!hasAnyDelta) {
154
- throw new Error(`Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` +
306
+ structural(`Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` +
155
307
  `Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.`);
156
308
  }
309
+ // The merge reads delta sections only. Anything else in the file stays in the
310
+ // archived change and never reaches the main spec, so name it at the moment
311
+ // the author can still move it.
312
+ const strays = [
313
+ ...plan.orphanRequirements.map((orphan) => `requirement "${orphan.name}" (line ${orphan.line})`),
314
+ ...plan.unmergedSections.map((section) => `"${section.title}" (line ${section.line})`),
315
+ ];
316
+ if (strays.length > 0) {
317
+ notice('archive_delta_content_not_merged', `${specName} - content outside the delta sections is not merged into the main spec: ${strays.join(', ')}.`);
318
+ }
157
319
  let targetContent;
158
320
  let isNewSpec = false;
159
321
  try {
@@ -164,11 +326,14 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
164
326
  // may exist with content the skeleton rebuild below would discard.
165
327
  if (!isMissingPathError(error))
166
328
  throw error;
167
- if (plan.modified.length > 0 || plan.renamed.length > 0) {
168
- throw new Error(`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.`);
169
- }
170
- if (plan.removed.length > 0) {
171
- notice('archive_removed_ignored_new_spec', `${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).`);
329
+ // REMOVED included: it was a notice ("nothing to remove") and the merge went
330
+ // on, so a REMOVED-only delta failed later as an empty rebuilt spec, and one
331
+ // beside an ADDED archived with its removal dropped. A REMOVED names a
332
+ // requirement its author expects to exist; with no main spec, that is a
333
+ // sister change's ADDED (archived after this, it brings the requirement
334
+ // back) or a wrong capability, and either way the refusal is the answer.
335
+ if (plan.modified.length > 0 || plan.removed.length > 0 || plan.renamed.length > 0) {
336
+ throw new MissingTargetSpecError(`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED, REMOVED and RENAMED operations require an existing spec.`);
172
337
  }
173
338
  isNewSpec = true;
174
339
  // Only plain prose is carried; a Purpose that could restructure the spec
@@ -209,7 +374,7 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
209
374
  const details = structureIssues
210
375
  .map(issue => `line ${issue.line}: ${issue.message}`)
211
376
  .join('\n');
212
- throw new Error(`${specName}: target spec is structurally invalid and cannot be updated until fixed:\n${details}`);
377
+ throw new MainSpecMergeError(`${specName}: target spec is structurally invalid and cannot be updated until fixed:\n${details}`, mainSpecPath);
213
378
  }
214
379
  const parts = extractRequirementsSection(targetContent);
215
380
  const nameToBlock = new Map();
@@ -225,16 +390,33 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
225
390
  for (const r of plan.renamed) {
226
391
  const from = normalizeRequirementName(r.from);
227
392
  const to = normalizeRequirementName(r.to);
393
+ if (contested.has(from) || contested.has(to))
394
+ continue;
228
395
  if (!nameToBlock.has(from)) {
229
396
  // Source gone but target present: the rename was already synced.
230
397
  if (nameToBlock.has(to)) {
231
398
  continue;
232
399
  }
233
- throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found`);
400
+ // The near miss MODIFIED and REMOVED report. "source not found" alone
401
+ // is also what a sister change still adding the requirement looks like,
402
+ // so a case typo here read as an archive-order problem.
403
+ const nearMiss = findFoldedMatch(nameToBlock.keys(), from);
404
+ if (nearMiss !== undefined) {
405
+ const existing = nameToBlock.get(nearMiss).name;
406
+ throw new RequirementNearMissError(`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found, but "### Requirement: ${existing}" exists; fix the FROM line to match it exactly`, r.from, existing, 'RENAMED');
407
+ }
408
+ throw new MissingTargetRequirementError(`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found`, r.from, 'RENAMED');
234
409
  }
235
410
  if (nameToBlock.has(to)) {
236
411
  throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - target already exists`);
237
412
  }
413
+ // The source is exempt, so a case-only rename (`late fees` -> `Late Fees`)
414
+ // stays legal; any other folded match is a second spelling of a name the
415
+ // spec already has.
416
+ const renameNearMiss = findFoldedNameClash(nameToBlock, to, from);
417
+ if (renameNearMiss !== undefined) {
418
+ throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - "### Requirement: ${renameNearMiss}" already exists and differs only in case or spacing`);
419
+ }
238
420
  const block = nameToBlock.get(from);
239
421
  const newHeader = `### Requirement: ${to}`;
240
422
  const rawLines = block.raw.split('\n');
@@ -262,17 +444,19 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
262
444
  const removedKeys = new Set();
263
445
  for (const name of plan.removed) {
264
446
  const key = normalizeRequirementName(name);
447
+ if (contested.has(key))
448
+ continue;
265
449
  if (!nameToBlock.has(key)) {
266
450
  // Already gone means the removal was already synced. A near miss — one
267
451
  // still present differing only in case or interior whitespace — is a typo
268
- // instead, and stays a hard abort.
269
- if (!isNewSpec) {
270
- const nearMiss = [...nameToBlock.keys()].find((k) => foldRequirementName(k) === foldRequirementName(key));
271
- if (nearMiss !== undefined) {
272
- throw new Error(`${specName} REMOVED failed for header "### Requirement: ${name}" - not found, but "### Requirement: ${nameToBlock.get(nearMiss).name}" exists; fix the header to match it exactly`);
273
- }
274
- notice('archive_removed_requirement_absent', `${specName} - REMOVED requirement "${name}" is not in the current spec; treating it as already removed. Nothing was deleted — if you meant to remove a requirement, check the header spelling against tospec/specs/${onDiskSpecName}/spec.md.`);
452
+ // instead, and stays a hard abort. A new spec never gets here: a REMOVED
453
+ // without a main spec is refused above.
454
+ const nearMiss = findFoldedMatch(nameToBlock.keys(), key);
455
+ if (nearMiss !== undefined) {
456
+ const existing = nameToBlock.get(nearMiss).name;
457
+ throw new RequirementNearMissError(`${specName} REMOVED failed for header "### Requirement: ${name}" - not found, but "### Requirement: ${existing}" exists; fix the header to match it exactly`, name, existing, 'REMOVED');
275
458
  }
459
+ notice('archive_removed_requirement_absent', `${specName} - REMOVED requirement "${name}" is not in the current spec; treating it as already removed. Nothing was deleted — if you meant to remove a requirement, check the header spelling against tospec/specs/${onDiskSpecName}/spec.md.`);
276
460
  continue;
277
461
  }
278
462
  nameToBlock.delete(key);
@@ -288,12 +472,21 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
288
472
  const unmatchedDeclarations = new Set(plan.removedScenarios);
289
473
  for (const mod of plan.modified) {
290
474
  const key = normalizeRequirementName(mod.name);
475
+ if (contested.has(key))
476
+ continue;
291
477
  const currentBlock = nameToBlock.get(key);
292
478
  if (!currentBlock) {
293
- throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`);
294
- }
295
- const modHeaderMatch = mod.raw.split('\n')[0].match(REQUIREMENT_HEADER_REGEX);
296
- if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) {
479
+ // The same near-miss hint REMOVED gives: "not found" alone sends the
480
+ // author looking for a missing requirement rather than a case typo.
481
+ const nearMiss = findFoldedMatch(nameToBlock.keys(), key);
482
+ const notFound = `${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`;
483
+ if (nearMiss === undefined)
484
+ throw new MissingTargetRequirementError(notFound, mod.name, 'MODIFIED');
485
+ const existing = nameToBlock.get(nearMiss).name;
486
+ throw new RequirementNearMissError(`${notFound}, but "### Requirement: ${existing}" exists; fix the header to match it exactly`, mod.name, existing, 'MODIFIED');
487
+ }
488
+ const modHeaderName = matchRequirementHeader(mod.raw.split('\n')[0]);
489
+ if (modHeaderName === null || normalizeRequirementName(modHeaderName) !== key) {
297
490
  throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`);
298
491
  }
299
492
  // Guards against a stale MODIFIED block dropping scenarios a previous
@@ -310,7 +503,9 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
310
503
  return false;
311
504
  });
312
505
  if (missingScenarios.length > 0) {
313
- throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${missingScenarios.map(name => `"${name}"`).join(', ')}. Refresh the change spec before archiving to avoid dropping scenarios.`);
506
+ // Deferred, the block is applied as written: the scenario loss is the
507
+ // only fault, and it does not change which names the spec holds.
508
+ structural(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${missingScenarios.map(name => `"${name}"`).join(', ')}. Refresh the change spec before archiving to avoid dropping scenarios.`);
314
509
  }
315
510
  // A plain `### Notes` after the requirement is absorbed into its block (see
316
511
  // the recompose pass below), and replacing the block wholesale deleted it
@@ -320,11 +515,23 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
320
515
  nameToBlock.set(key, absorbed ? { ...mod, raw: `${mod.raw.trimEnd()}\n\n${absorbed}` } : mod);
321
516
  }
322
517
  for (const entry of unmatchedDeclarations) {
323
- throw new Error(`${specName} REMOVED Scenarios declares "${entry.scenario}" under requirement "${entry.requirement}", but no MODIFIED block for that requirement drops that scenario - the declaration removes nothing on its own. Restate the requirement under "## MODIFIED Requirements" without that scenario, or check both names against the current spec.`);
518
+ const renameHint = postRenameRemovalHint(entry, plan.renamed);
519
+ const message = `${specName} REMOVED Scenarios declares "${entry.scenario}" under requirement "${entry.requirement}", but no MODIFIED block for that requirement drops that scenario - the declaration removes nothing on its own. ` +
520
+ (renameHint === undefined
521
+ ? 'Restate the requirement under "## MODIFIED Requirements" without that scenario, or check both names against the current spec.'
522
+ : `${renameHint}.`);
523
+ // Structural only against an existing main spec: that is the only case
524
+ // validate's own check reads (it has no spec to match a new capability's
525
+ // declarations against), so deferring a new capability's would hide it.
526
+ if (isNewSpec)
527
+ throw new Error(message);
528
+ structural(message);
324
529
  }
325
530
  let addedApplied = 0;
326
531
  for (const add of plan.added) {
327
532
  const key = normalizeRequirementName(add.name);
533
+ if (contested.has(key))
534
+ continue;
328
535
  const existing = nameToBlock.get(key);
329
536
  if (existing) {
330
537
  // Identical content means it was already synced; only differing content
@@ -334,6 +541,10 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
334
541
  }
335
542
  throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`);
336
543
  }
544
+ const addNearMiss = findFoldedNameClash(nameToBlock, key);
545
+ if (addNearMiss !== undefined) {
546
+ throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - "### Requirement: ${addNearMiss}" already exists and differs only in case or spacing`);
547
+ }
337
548
  nameToBlock.set(key, add);
338
549
  addedApplied++;
339
550
  }
@@ -375,7 +586,17 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
375
586
  // delete that content with the file; writing it would produce a spec with no
376
587
  // requirements, which cannot validate. Neither is safe to choose silently.
377
588
  if (keptRequirements === 0 && removedApplied > 0 && salvagedCount > 0) {
378
- throw new Error(`${specName}: removing every requirement would leave content that is not part of any requirement (e.g. a "### Notes" section). Move that content out of ${specName}/spec.md first, then archive again.`);
589
+ throw new MainSpecMergeError(`${specName}: removing every requirement would leave content that is not part of any requirement (e.g. a "### Notes" section). Move that content out of ${specName}/spec.md first, then archive again.`, mainSpecPath);
590
+ }
591
+ // The same refusal for a section outside `## Requirements`: retiring deleted
592
+ // a `## Notes` or `# Appendix` with the file, with no word, while the same
593
+ // notes inside a requirement block were refused above. The title, Purpose and
594
+ // the Requirements section itself are what a retirement is meant to delete.
595
+ if (keptRequirements === 0 && removedApplied > 0) {
596
+ const lost = sectionsLostOnRetire(parts.before, parts.after);
597
+ if (lost.length > 0) {
598
+ throw new MainSpecMergeError(`${specName}: removing every requirement would delete ${onDiskSpecName}/spec.md, and with it the content outside its requirements: ${lost.map((title) => `"${title}"`).join(', ')}. Move that content out of ${specName}/spec.md first, then archive again.`, mainSpecPath);
599
+ }
379
600
  }
380
601
  const reqBody = [parts.preamble && parts.preamble.trim() ? parts.preamble.trimEnd() : '']
381
602
  .filter(Boolean)
@@ -403,8 +624,60 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
403
624
  removed: removedApplied,
404
625
  renamed: renamedApplied,
405
626
  },
627
+ // The main spec as read, for a caller that must tell whether a rebuilt
628
+ // spec fails because of the delta or because of what it was merged into.
629
+ // Absent for a new capability, whose skeleton is the merge's own text.
630
+ original: isNewSpec ? undefined : { path: mainSpecPath, content: targetContent },
631
+ // Non-empty only under `deferStructural`, and then `rebuilt` is a partial
632
+ // merge: it skipped what the failures name and must not be judged as the
633
+ // spec archive would write.
634
+ structuralFailures,
406
635
  };
407
636
  }
637
+ /**
638
+ * The name of a requirement already in `blocks` that differs from `name` only
639
+ * in case or interior whitespace, ignoring `exempt` (a rename's own source).
640
+ * Matching stays exact everywhere else; this only refuses a second spelling.
641
+ */
642
+ function findFoldedNameClash(blocks, name, exempt) {
643
+ const folded = foldRequirementName(name);
644
+ for (const [key, block] of blocks) {
645
+ if (key === name || key === exempt)
646
+ continue;
647
+ if (foldRequirementName(key) === folded)
648
+ return block.name;
649
+ }
650
+ return undefined;
651
+ }
652
+ /**
653
+ * The sections of a main spec, outside `## Requirements`, that hold a
654
+ * non-blank line: the content a retirement would delete with the file. The
655
+ * title line and `## Purpose` (its `###` subheadings included) are not
656
+ * counted; text under the title, or under any other `#` or `##` heading, is.
657
+ * A heading with nothing under it loses nothing, as migrate drops an empty
658
+ * section. Fenced lines are content and never headings.
659
+ */
660
+ function sectionsLostOnRetire(before, after) {
661
+ const lost = [];
662
+ for (const text of [before, after]) {
663
+ const lines = text.split('\n');
664
+ const mask = buildCodeFenceMask(lines);
665
+ let current = { title: 'the text above the title', kept: false, reported: false };
666
+ for (let i = 0; i < lines.length; i++) {
667
+ const level = mask[i] ? null : headingLevel(lines[i]);
668
+ if (level !== null && level <= 2) {
669
+ const kept = level === 2 && sectionHeaderKey(lines[i]) === 'purpose';
670
+ current = { title: lines[i].trim(), kept, reported: false };
671
+ continue;
672
+ }
673
+ if (current.kept || current.reported || lines[i].trim() === '')
674
+ continue;
675
+ lost.push(current.title);
676
+ current.reported = true;
677
+ }
678
+ }
679
+ return lost;
680
+ }
408
681
  function normalizeBlockRaw(raw) {
409
682
  return normalizeDocument(raw).trim();
410
683
  }
@@ -473,6 +746,93 @@ function salvageAbsorbedContent(requirementRaw) {
473
746
  }
474
747
  return '';
475
748
  }
749
+ const MAX_LINK_HOPS = 40;
750
+ /**
751
+ * Where a path really lands: the realpath of its deepest existing ancestor,
752
+ * with the not-yet-created remainder appended. A target that does not exist yet
753
+ * (a new capability) still resolves through a linked `specs/` above it.
754
+ *
755
+ * `fs.realpath` follows both a POSIX symlink and a Windows junction, so one
756
+ * check covers both — they differ in how they are created and recorded, not in
757
+ * where a write through them lands.
758
+ *
759
+ * A dangling link (file or directory) makes `realpath` fail exactly like an
760
+ * absent path, yet `writeFile` and `mkdir` follow it. Appending its name to the
761
+ * ancestor judged the link's own in-project path, so the first missing segment
762
+ * is `lstat`ed and, when it is a link, resolution restarts from its target.
763
+ */
764
+ async function resolveRealPath(target) {
765
+ let pending = path.resolve(target);
766
+ // Bounds a chain of dangling links that point at each other; `realpath`
767
+ // reports a cycle of existing links itself (ELOOP).
768
+ for (let hop = 0; hop < MAX_LINK_HOPS; hop++) {
769
+ const rest = [];
770
+ let existing = pending;
771
+ let resolved = null;
772
+ while (resolved === null) {
773
+ try {
774
+ resolved = await fs.realpath(existing);
775
+ }
776
+ catch (error) {
777
+ if (!isMissingPathError(error))
778
+ throw error;
779
+ const parent = path.dirname(existing);
780
+ if (parent === existing)
781
+ return pending;
782
+ rest.unshift(path.basename(existing));
783
+ existing = parent;
784
+ }
785
+ }
786
+ if (rest.length === 0)
787
+ return resolved;
788
+ const firstMissing = path.join(resolved, rest[0]);
789
+ const isLink = await fs
790
+ .lstat(firstMissing)
791
+ .then((stat) => stat.isSymbolicLink())
792
+ .catch((error) => {
793
+ if (isMissingPathError(error))
794
+ return false;
795
+ throw error;
796
+ });
797
+ if (!isLink)
798
+ return path.join(resolved, ...rest);
799
+ pending = path.resolve(resolved, await fs.readlink(firstMissing), ...rest.slice(1));
800
+ }
801
+ throw new Error(`Too many levels of symbolic links resolving ${target}`);
802
+ }
803
+ function isInsideDirectory(child, parent) {
804
+ const relative = path.relative(parent, child);
805
+ if (relative === '')
806
+ return true;
807
+ // Only a whole `..` segment steps out; `..shared` is a directory name.
808
+ const leavesParent = relative === '..' || relative.startsWith(`..${path.sep}`);
809
+ return !leavesParent && !path.isAbsolute(relative);
810
+ }
811
+ /**
812
+ * Checks a prepared update against the project boundary before anything is
813
+ * written. A capability directory (or `tospec/specs` itself) linked outside the
814
+ * project made archive write — and on retire, `fs.rm` — a file the project does
815
+ * not own.
816
+ *
817
+ * A write is allowed and named, since a shared specs directory is a legitimate
818
+ * setup; a delete is refused, since removing a file outside the project is
819
+ * never what archiving a change means. Throws so the caller's all-or-nothing
820
+ * prepare pass aborts with nothing changed.
821
+ */
822
+ export async function checkTargetWithinProject(update, projectRoot, retired) {
823
+ const realTarget = await resolveRealPath(update.target);
824
+ const realRoot = await resolveRealPath(projectRoot);
825
+ if (isInsideDirectory(realTarget, realRoot))
826
+ return null;
827
+ const specName = path.basename(path.dirname(update.target));
828
+ if (retired) {
829
+ throw new Error(`${specName}: removing every requirement would delete tospec/specs/${specName}/spec.md, which resolves to ${realTarget}, outside the project (a symlink or junction). Refusing to delete a file outside the project; remove it there yourself if that is intended.`);
830
+ }
831
+ return {
832
+ code: 'archive_spec_outside_project',
833
+ message: `${specName} - tospec/specs/${specName}/spec.md resolves to ${realTarget}, outside the project (a symlink or junction); the merge was written there.`,
834
+ };
835
+ }
476
836
  /**
477
837
  * Delete an emptied capability's `spec.md` and prune the directories that
478
838
  * leaves empty. Pruning stops at `specsRoot` (a project with no capabilities
@@ -525,7 +885,10 @@ export function extractDeltaPurpose(content) {
525
885
  for (let i = 0; i < lines.length; i++) {
526
886
  if (mask[i])
527
887
  continue;
528
- if (/^##\s+Purpose\s*$/i.test(lines[i])) {
888
+ // A closing `#` run is peeled as `parseDeltaSpec` peels it: that parser
889
+ // reads `## Purpose ##` as Purpose and leaves it off the unmerged list, so
890
+ // missing it here dropped the text with no notice at all.
891
+ if (sectionHeaderKey(lines[i]) === 'purpose') {
529
892
  start = i + 1;
530
893
  break;
531
894
  }