rcf-lite 0.0.1 → 0.7.1

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 (342) hide show
  1. package/CHANGELOG.md +288 -0
  2. package/LICENSE +202 -0
  3. package/README.md +92 -4
  4. package/bin/rcf-verify.js +122 -0
  5. package/bin/rcf.js +174 -0
  6. package/bin/view-supervisor-child.mjs +14 -0
  7. package/fixtures/canary-manifest.json +103 -0
  8. package/guidance/README.md +25 -0
  9. package/guidance/build-cycle-playbook.md +492 -0
  10. package/guidance/build-cycle.md +40 -0
  11. package/guidance/document-model.md +45 -0
  12. package/guidance/elicitation-playbook.md +442 -0
  13. package/guidance/harness-template.md +246 -0
  14. package/guidance/managed/README.md +63 -0
  15. package/guidance/managed/agent-instructions-block.hash +1 -0
  16. package/guidance/managed/agent-instructions-block.md +220 -0
  17. package/guidance/managed/legacy-fragment-hashes.json +22 -0
  18. package/guidance/manifest.json +21 -0
  19. package/guidance/overview.md +48 -0
  20. package/guidance/persona-programme.md +44 -0
  21. package/package.json +69 -8
  22. package/rcf/adrs/adr-001.json +25 -0
  23. package/rcf/adrs/adr-002.json +25 -0
  24. package/rcf/adrs/adr-003.json +25 -0
  25. package/rcf/adrs/adr-004.json +20 -0
  26. package/rcf/adrs/adr-005.json +20 -0
  27. package/rcf/adrs/adr-006.json +25 -0
  28. package/rcf/adrs/adr-007.json +25 -0
  29. package/rcf/adrs/adr-008.json +25 -0
  30. package/rcf/adrs/adr-009.json +25 -0
  31. package/rcf/build-sequence.json +11 -0
  32. package/rcf/code-nodes/cn-001.json +14 -0
  33. package/rcf/code-nodes/cn-002.json +14 -0
  34. package/rcf/code-nodes/cn-003.json +15 -0
  35. package/rcf/code-nodes/cn-004.json +17 -0
  36. package/rcf/code-nodes/cn-005.json +19 -0
  37. package/rcf/code-nodes/cn-006.json +17 -0
  38. package/rcf/code-nodes/cn-007.json +16 -0
  39. package/rcf/code-nodes/cn-008.json +19 -0
  40. package/rcf/code-nodes/cn-009.json +16 -0
  41. package/rcf/code-nodes/cn-010.json +18 -0
  42. package/rcf/code-nodes/cn-011.json +18 -0
  43. package/rcf/code-nodes/cn-012.json +18 -0
  44. package/rcf/code-nodes/cn-013.json +19 -0
  45. package/rcf/code-nodes/cn-014.json +16 -0
  46. package/rcf/code-nodes/cn-015.json +16 -0
  47. package/rcf/code-nodes/cn-016.json +16 -0
  48. package/rcf/code-nodes/cn-017.json +16 -0
  49. package/rcf/code-nodes/cn-018.json +15 -0
  50. package/rcf/code-nodes/cn-019.json +16 -0
  51. package/rcf/code-nodes/cn-020.json +16 -0
  52. package/rcf/code-nodes/cn-021.json +16 -0
  53. package/rcf/code-nodes/cn-022.json +16 -0
  54. package/rcf/code-nodes/cn-023.json +16 -0
  55. package/rcf/code-nodes/cn-024.json +16 -0
  56. package/rcf/code-nodes/cn-025.json +16 -0
  57. package/rcf/code-nodes/cn-026.json +16 -0
  58. package/rcf/code-nodes/cn-027.json +16 -0
  59. package/rcf/code-nodes/cn-028.json +16 -0
  60. package/rcf/code-nodes/cn-029.json +19 -0
  61. package/rcf/code-nodes/cn-030.json +14 -0
  62. package/rcf/code-nodes/cn-031.json +14 -0
  63. package/rcf/code-nodes/cn-032.json +14 -0
  64. package/rcf/code-nodes/cn-033.json +14 -0
  65. package/rcf/code-nodes/cn-034.json +14 -0
  66. package/rcf/code-nodes/cn-035.json +14 -0
  67. package/rcf/code-nodes/cn-036.json +14 -0
  68. package/rcf/code-nodes/cn-037.json +14 -0
  69. package/rcf/code-nodes/cn-038.json +14 -0
  70. package/rcf/code-nodes/cn-039.json +14 -0
  71. package/rcf/code-nodes/cn-040.json +14 -0
  72. package/rcf/code-nodes/cn-041.json +14 -0
  73. package/rcf/code-nodes/cn-042.json +14 -0
  74. package/rcf/code-nodes/cn-043.json +14 -0
  75. package/rcf/code-nodes/cn-044.json +14 -0
  76. package/rcf/code-nodes/cn-045.json +14 -0
  77. package/rcf/code-nodes/cn-046.json +14 -0
  78. package/rcf/code-nodes/cn-047.json +14 -0
  79. package/rcf/code-nodes/cn-048.json +14 -0
  80. package/rcf/code-nodes/cn-049.json +14 -0
  81. package/rcf/code-nodes/cn-050.json +14 -0
  82. package/rcf/code-nodes/cn-051.json +14 -0
  83. package/rcf/code-nodes/cn-052.json +14 -0
  84. package/rcf/code-nodes/cn-053.json +14 -0
  85. package/rcf/code-nodes/cn-054.json +14 -0
  86. package/rcf/code-nodes/cn-055.json +14 -0
  87. package/rcf/code-nodes/cn-056.json +14 -0
  88. package/rcf/code-nodes/cn-057.json +14 -0
  89. package/rcf/fbs/fbs-001.json +49 -0
  90. package/rcf/fbs/fbs-002.json +42 -0
  91. package/rcf/fbs/fbs-003.json +37 -0
  92. package/rcf/fbs/fbs-004.json +39 -0
  93. package/rcf/fbs/fbs-005.json +38 -0
  94. package/rcf/fbs/fbs-006.json +48 -0
  95. package/rcf/fbs/fbs-007.json +39 -0
  96. package/rcf/fbs/fbs-008.json +40 -0
  97. package/rcf/fbs/fbs-009.json +36 -0
  98. package/rcf/fbs/fbs-010.json +41 -0
  99. package/rcf/fbs/fbs-011.json +36 -0
  100. package/rcf/fbs/fbs-012.json +46 -0
  101. package/rcf/fbs/fbs-013.json +42 -0
  102. package/rcf/fbs/fbs-014.json +49 -0
  103. package/rcf/fbs/fbs-015.json +32 -0
  104. package/rcf/manifest.json +17 -0
  105. package/rcf/prd.json +47 -0
  106. package/rcf/requirements/req-001.json +19 -0
  107. package/rcf/requirements/req-002.json +19 -0
  108. package/rcf/requirements/req-003.json +19 -0
  109. package/rcf/requirements/req-004.json +19 -0
  110. package/rcf/requirements/req-005.json +19 -0
  111. package/rcf/requirements/req-006.json +19 -0
  112. package/rcf/requirements/req-007.json +19 -0
  113. package/rcf/requirements/req-008.json +19 -0
  114. package/rcf/requirements/req-009.json +19 -0
  115. package/rcf/tacs/tac-001.json +45 -0
  116. package/rcf/tacs/tac-002.json +109 -0
  117. package/rcf/tacs/tac-003.json +40 -0
  118. package/rcf/tacs/tac-004.json +51 -0
  119. package/rcf/tacs/tac-005.json +52 -0
  120. package/rcf/tacs/tac-006.json +104 -0
  121. package/rcf/tacs/tac-007.json +38 -0
  122. package/rcf/tacs/tac-008.json +51 -0
  123. package/rcf/tad.json +51 -0
  124. package/rcf/test-suites/PENDING.md +23 -0
  125. package/rcf/test-suites/ts-001.json +38 -0
  126. package/rcf/test-suites/ts-002.json +38 -0
  127. package/rcf/test-suites/ts-003.json +43 -0
  128. package/rcf/test-suites/ts-004.json +44 -0
  129. package/rcf/test-suites/ts-005.json +30 -0
  130. package/rcf/test-suites/ts-006.json +36 -0
  131. package/rcf/test-suites/ts-007.json +43 -0
  132. package/rcf/test-suites/ts-008.json +37 -0
  133. package/rcf/test-suites/ts-009.json +38 -0
  134. package/rcf/test-suites/ts-010.json +38 -0
  135. package/rcf/test-suites/ts-011.json +44 -0
  136. package/rcf/test-suites/ts-012.json +36 -0
  137. package/rcf/test-suites/ts-013.json +38 -0
  138. package/rcf/test-suites/ts-014.json +38 -0
  139. package/rcf/test-suites/ts-015.json +38 -0
  140. package/rcf/test-suites/ts-016.json +37 -0
  141. package/rcf/test-suites/ts-017.json +38 -0
  142. package/rcf/test-suites/ts-018.json +38 -0
  143. package/rcf/test-suites/ts-019.json +37 -0
  144. package/rcf/test-suites/ts-020.json +46 -0
  145. package/rcf/test-suites/ts-021.json +46 -0
  146. package/rcf/test-suites/ts-022.json +46 -0
  147. package/rcf/test-suites/ts-023.json +46 -0
  148. package/rcf/test-suites/ts-024.json +46 -0
  149. package/rcf/test-suites/ts-025.json +52 -0
  150. package/rcf/user-stories/us-101.json +40 -0
  151. package/rcf/user-stories/us-102.json +40 -0
  152. package/rcf/user-stories/us-201.json +40 -0
  153. package/rcf/user-stories/us-202.json +40 -0
  154. package/rcf/user-stories/us-203.json +32 -0
  155. package/rcf/user-stories/us-301.json +40 -0
  156. package/rcf/user-stories/us-302.json +40 -0
  157. package/rcf/user-stories/us-303.json +40 -0
  158. package/rcf/user-stories/us-304.json +40 -0
  159. package/rcf/user-stories/us-401.json +40 -0
  160. package/rcf/user-stories/us-402.json +40 -0
  161. package/rcf/user-stories/us-403.json +40 -0
  162. package/rcf/user-stories/us-501.json +40 -0
  163. package/rcf/user-stories/us-502.json +40 -0
  164. package/rcf/user-stories/us-503.json +40 -0
  165. package/rcf/user-stories/us-601.json +40 -0
  166. package/rcf/user-stories/us-602.json +40 -0
  167. package/rcf/user-stories/us-701.json +40 -0
  168. package/rcf/user-stories/us-702.json +40 -0
  169. package/rcf/user-stories/us-801.json +49 -0
  170. package/rcf/user-stories/us-802.json +49 -0
  171. package/rcf/user-stories/us-803.json +49 -0
  172. package/rcf/user-stories/us-804.json +49 -0
  173. package/rcf/user-stories/us-805.json +49 -0
  174. package/rcf/user-stories/us-901.json +40 -0
  175. package/src/.gitkeep +0 -0
  176. package/src/browser-verify/auth-smoke.js +109 -0
  177. package/src/browser-verify/index.js +29 -0
  178. package/src/browser-verify/invariants.js +336 -0
  179. package/src/browser-verify/manifest-writer.js +189 -0
  180. package/src/browser-verify/runner.js +171 -0
  181. package/src/build/bundle.js +198 -0
  182. package/src/build/formatters/json.js +18 -0
  183. package/src/build/formatters/markdown.js +406 -0
  184. package/src/build/index.js +14 -0
  185. package/src/build/mark.js +177 -0
  186. package/src/build/queue.js +285 -0
  187. package/src/cli/browser-verify.js +231 -0
  188. package/src/cli/build.js +584 -0
  189. package/src/cli/coverage.js +219 -0
  190. package/src/cli/create.js +355 -0
  191. package/src/cli/delete.js +127 -0
  192. package/src/cli/design.js +353 -0
  193. package/src/cli/doctor.js +548 -0
  194. package/src/cli/fbs.js +171 -0
  195. package/src/cli/finalise.js +377 -0
  196. package/src/cli/guidance.js +206 -0
  197. package/src/cli/help.js +156 -0
  198. package/src/cli/impact.js +119 -0
  199. package/src/cli/init.js +282 -0
  200. package/src/cli/intake.js +153 -0
  201. package/src/cli/link.js +128 -0
  202. package/src/cli/mcp.js +160 -0
  203. package/src/cli/preflight.js +220 -0
  204. package/src/cli/read.js +162 -0
  205. package/src/cli/req-baseline.js +269 -0
  206. package/src/cli/req-classify.js +135 -0
  207. package/src/cli/review.js +295 -0
  208. package/src/cli/test-suite.js +216 -0
  209. package/src/cli/trace.js +172 -0
  210. package/src/cli/ui-baseline.js +292 -0
  211. package/src/cli/ui-classify.js +108 -0
  212. package/src/cli/update.js +197 -0
  213. package/src/cli/validate.js +168 -0
  214. package/src/cli/view.js +452 -0
  215. package/src/core/baseline-catalog/data/auth.json +42 -0
  216. package/src/core/baseline-catalog/data/http-api.json +42 -0
  217. package/src/core/baseline-catalog/data/notifications.json +33 -0
  218. package/src/core/baseline-catalog/data/persistence.json +33 -0
  219. package/src/core/baseline-catalog/data/web-ui.json +60 -0
  220. package/src/core/baseline-catalog/index.js +121 -0
  221. package/src/core/errors/index.js +167 -0
  222. package/src/core/fixtures/register-canary/canary-prompt-01.json +18 -0
  223. package/src/core/fixtures/register-canary/canary-prompt-02.json +21 -0
  224. package/src/core/fixtures/register-canary/canary-prompt-03.json +17 -0
  225. package/src/core/isolation/index.js +60 -0
  226. package/src/core/mcp/framing.js +103 -0
  227. package/src/core/mcp/index.js +8 -0
  228. package/src/core/mcp/server.js +228 -0
  229. package/src/core/patterns/register-canary.js +209 -0
  230. package/src/core/patterns/req-shapes.js +158 -0
  231. package/src/core/patterns/services.js +358 -0
  232. package/src/core/patterns/ui-shapes.js +166 -0
  233. package/src/core/store/cn-resolve.js +134 -0
  234. package/src/core/store/derive-deps.js +93 -0
  235. package/src/core/store/ids.js +78 -0
  236. package/src/core/store/index.js +20 -0
  237. package/src/core/store/init.js +255 -0
  238. package/src/core/store/loader.js +211 -0
  239. package/src/core/store/tp-resolve.js +176 -0
  240. package/src/core/store/validator.js +191 -0
  241. package/src/core/store/walker.js +898 -0
  242. package/src/core/store/writer.js +1849 -0
  243. package/src/design/index.js +11 -0
  244. package/src/design/writer.js +271 -0
  245. package/src/finalise/detect.js +129 -0
  246. package/src/finalise/index.js +17 -0
  247. package/src/finalise/ingest.js +119 -0
  248. package/src/finalise/install.js +119 -0
  249. package/src/finalise/ship-without-verified.js +131 -0
  250. package/src/finalise/spawn.js +84 -0
  251. package/src/intake/fidelity.js +105 -0
  252. package/src/intake/index.js +6 -0
  253. package/src/intake/manifest-writer.js +100 -0
  254. package/src/intake/orchestrator.js +138 -0
  255. package/src/intake/validate.js +80 -0
  256. package/src/mcp/map-errors.js +131 -0
  257. package/src/mcp/prompts.js +49 -0
  258. package/src/mcp/resources.js +244 -0
  259. package/src/mcp/tools.js +1204 -0
  260. package/src/preflight/design-shapes.js +185 -0
  261. package/src/preflight/index.js +90 -0
  262. package/src/preflight/manifest-writer.js +264 -0
  263. package/src/preflight/scanner.js +206 -0
  264. package/src/preflight/secrets.js +134 -0
  265. package/src/preflight/session.js +246 -0
  266. package/src/query/attestation.js +285 -0
  267. package/src/query/coverage.js +308 -0
  268. package/src/query/formatters/json.js +21 -0
  269. package/src/query/formatters/mermaid.js +209 -0
  270. package/src/query/formatters/table.js +203 -0
  271. package/src/query/impact.js +173 -0
  272. package/src/query/index.js +9 -0
  273. package/src/query/trace.js +345 -0
  274. package/src/register-canary/fixture-loader.js +87 -0
  275. package/src/register-canary/index.js +10 -0
  276. package/src/register-canary/record-writer.js +132 -0
  277. package/src/register-canary/runner.js +156 -0
  278. package/src/req-baseline/gate.js +86 -0
  279. package/src/req-baseline/index.js +27 -0
  280. package/src/req-baseline/open-candidates.js +143 -0
  281. package/src/req-baseline/opt-out.js +195 -0
  282. package/src/req-baseline/sweep.js +230 -0
  283. package/src/req-detection/classifier.js +181 -0
  284. package/src/req-detection/index.js +9 -0
  285. package/src/req-detection/persist.js +55 -0
  286. package/src/review/index.js +325 -0
  287. package/src/review/mutation.js +117 -0
  288. package/src/review/ui-baseline-drift.js +138 -0
  289. package/src/server/index.js +178 -0
  290. package/src/server/routes.js +110 -0
  291. package/src/server/sse.js +118 -0
  292. package/src/setup/agent-setup.js +362 -0
  293. package/src/setup/identity-seed.js +104 -0
  294. package/src/setup/knowledge-seed.js +123 -0
  295. package/src/setup/managed-block.js +193 -0
  296. package/src/setup/managed-gitignore.js +166 -0
  297. package/src/setup/managed-markers.js +49 -0
  298. package/src/ui-baseline/defaults.js +119 -0
  299. package/src/ui-baseline/index.js +25 -0
  300. package/src/ui-baseline/manifest-writer.js +282 -0
  301. package/src/ui-baseline/session.js +178 -0
  302. package/src/ui-detection/classifier.js +192 -0
  303. package/src/verify/chain/index.js +190 -0
  304. package/src/verify/cli/cleanup.js +61 -0
  305. package/src/verify/cli/help.js +56 -0
  306. package/src/verify/cli/mcp.js +98 -0
  307. package/src/verify/cli/provision.js +71 -0
  308. package/src/verify/cli/report.js +71 -0
  309. package/src/verify/cli/run.js +155 -0
  310. package/src/verify/engine/brief.js +87 -0
  311. package/src/verify/engine/index.js +177 -0
  312. package/src/verify/engine/launcher.js +307 -0
  313. package/src/verify/mcp/tools.js +107 -0
  314. package/src/verify/profile/index.js +146 -0
  315. package/src/verify/provision/index.js +256 -0
  316. package/src/verify/report/index.js +139 -0
  317. package/src/verify/report/renderer.js +118 -0
  318. package/src/verify/verdict/index.js +246 -0
  319. package/src/view/doc-renderers/adr.js +44 -0
  320. package/src/view/doc-renderers/build-sequence.js +40 -0
  321. package/src/view/doc-renderers/fbs.js +128 -0
  322. package/src/view/doc-renderers/helpers.js +159 -0
  323. package/src/view/doc-renderers/index.js +12 -0
  324. package/src/view/doc-renderers/prd.js +45 -0
  325. package/src/view/doc-renderers/req.js +43 -0
  326. package/src/view/doc-renderers/tac.js +38 -0
  327. package/src/view/doc-renderers/tad.js +74 -0
  328. package/src/view/doc-renderers/test-suite.js +45 -0
  329. package/src/view/doc-renderers/user-story.js +63 -0
  330. package/src/view/html-page.js +462 -0
  331. package/src/view/index.js +63 -0
  332. package/src/view/live-client.js +338 -0
  333. package/src/view/mermaid-diagram.js +178 -0
  334. package/src/view/style.css +735 -0
  335. package/src/view/tree-model.js +152 -0
  336. package/src/view/vendored/mermaid.min.js +2607 -0
  337. package/src/view-supervisor/index.js +26 -0
  338. package/src/view-supervisor/logs.js +32 -0
  339. package/src/view-supervisor/manifest-writer.js +178 -0
  340. package/src/view-supervisor/persist-until.js +85 -0
  341. package/src/view-supervisor/supervisor.js +276 -0
  342. package/src/watch/index.js +152 -0
@@ -0,0 +1,1849 @@
1
+ // Persistence module for CRUD verbs. Wraps every disk write with
2
+ // schema validation + referential-integrity checks, gated on the
3
+ // POST-WRITE tree state (B5 amendment, E2E matrix 2026-07-06-003): a
4
+ // verb never INTRODUCES schema or reference breakage, but pre-existing
5
+ // breakage does not wedge the tree - repairing or deleting a broken doc
6
+ // is always possible in-tool.
7
+ //
8
+ // Four public functions per spec Phase 4 §D11:
9
+ // nextIdForKind(tree, kind, opts) -> string
10
+ // createDocument({...}) -> { id, filePath } | RcfError
11
+ // updateDocument({...}) -> { id, filePath } | RcfError
12
+ // deleteDocument({...}) -> { deleted, mutated } | RcfError
13
+ //
14
+ // No internal `updateParentLinkage()` helper: parents do not carry
15
+ // children lists (Phase 3.7 §D2). Create paths write exactly one file
16
+ // (or mutate one file for inline AC/TC kinds). Delete paths delete
17
+ // 1..N files and mutate 0..N cross-link-carrying files, never touching
18
+ // parents.
19
+
20
+ import { mkdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
21
+ import { dirname, join } from 'node:path';
22
+
23
+ import { rcfError } from '../errors/index.js';
24
+ import { idNumber, normaliseId } from './ids.js';
25
+ import { pathForId, subdirFor } from './loader.js';
26
+ import { validateDocument } from './validator.js';
27
+ import { netNewErrors, simulateWriteErrors } from './walker.js';
28
+
29
+ /**
30
+ * @typedef {import('./walker.js').TreeModel} TreeModel
31
+ * @typedef {import('../errors/index.js').RcfError} RcfError
32
+ */
33
+
34
+ // Mapping between CLI-facing kind aliases and the canonical validator kind.
35
+ const KIND_ALIASES = {
36
+ req: 'req',
37
+ us: 'userStory',
38
+ userStory: 'userStory',
39
+ ac: 'ac',
40
+ tac: 'tac',
41
+ adr: 'adr',
42
+ fbs: 'fbs',
43
+ ts: 'testSuite',
44
+ testSuite: 'testSuite',
45
+ tc: 'tc',
46
+ // Phase 10 (X2 CodeNode bridge): Code Node.
47
+ cn: 'codeNode',
48
+ codeNode: 'codeNode',
49
+ };
50
+
51
+ function canonicalKind(kind) {
52
+ return KIND_ALIASES[kind] ?? kind;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // B5: post-write validation gate
57
+ // ---------------------------------------------------------------------------
58
+ //
59
+ // Write verbs validate the POST-WRITE tree state, not the pre-existing
60
+ // state (operator-approved amendment to the Phase-4 refusal semantics,
61
+ // E2E matrix 2026-07-06-003 finding B5). A tree that is already broken
62
+ // no longer wedges every write: repairing a broken doc is allowed,
63
+ // deleting the offending doc is allowed. What stays refused is any
64
+ // operation that would introduce NET-NEW breakage - on a valid tree or
65
+ // a broken one.
66
+
67
+ /**
68
+ * Run the operation's change-set through the walker's in-memory
69
+ * simulation and refuse if any error appears post-write that was not
70
+ * present pre-write.
71
+ *
72
+ * @param {object} args
73
+ * @param {TreeModel} args.tree
74
+ * @param {RcfError[]} [args.walkErrors] - pre-write walk errors
75
+ * @param {Array<{ kind: string, id: string, doc: object }>} [args.upserts]
76
+ * @param {string[]} [args.deletes]
77
+ * @param {string} args.verb - for the refusal message
78
+ * @returns {RcfError | null}
79
+ */
80
+ function postWriteGate({ tree, walkErrors = [], upserts = [], deletes = [], verb }) {
81
+ const postErrors = simulateWriteErrors({ tree, preErrors: walkErrors, upserts, deletes });
82
+ const netNew = netNewErrors(walkErrors, postErrors);
83
+ if (netNew.length === 0) return null;
84
+ return rcfError({
85
+ kind: 'validation',
86
+ message: `${verb}: refused - the post-write tree would carry new breakage: ${netNew.map((e) => e.message).join('; ')}`,
87
+ rule: 'postWriteValidation',
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Ids of schema-invalid (unloadable) docs of a given canonical kind.
93
+ * Considered occupied for id allocation so a repair-pending id is never
94
+ * silently reallocated (and its file never overwritten).
95
+ *
96
+ * @param {TreeModel} tree
97
+ * @param {string} kind - canonical kind
98
+ * @returns {string[]}
99
+ */
100
+ function invalidIdsOfKind(tree, kind) {
101
+ const out = [];
102
+ for (const [id, entry] of tree.invalidDocs ?? []) {
103
+ if (entry.kind === kind) out.push(id);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** Own-id field per canonical kind, mirroring the walker. */
109
+ const OWN_ID_FIELD = {
110
+ req: 'reqId',
111
+ userStory: 'usId',
112
+ tac: 'tacId',
113
+ adr: 'adrId',
114
+ fbs: 'fbsId',
115
+ testSuite: 'id',
116
+ codeNode: 'cnId',
117
+ };
118
+
119
+ /**
120
+ * Every id of a given kind that is occupied, from every source the
121
+ * allocator must respect (w-2026-07-28-017):
122
+ *
123
+ * - the id each loaded doc DECLARES (`d.reqId`), and
124
+ * - the id it is FILED under (its `byId` key, derived from the
125
+ * filename), which can diverge from the declared id, and
126
+ * - ids of schema-invalid docs, whose files still sit on disk.
127
+ *
128
+ * Reading only the declared ids was the allocation half of the
129
+ * duplicate-id bug: with `req-002.json` declaring `"reqId": "REQ-001"`,
130
+ * `nextFlatId` saw only REQ-001 and handed out REQ-002, whose file
131
+ * already existed.
132
+ *
133
+ * @param {TreeModel} tree
134
+ * @param {string} kind - canonical kind
135
+ * @returns {string[]}
136
+ */
137
+ function occupiedIdsOfKind(tree, kind) {
138
+ const field = OWN_ID_FIELD[kind];
139
+ const out = [];
140
+ for (const [id, doc] of tree.byId) {
141
+ if (tree.kindById.get(id) !== kind) continue;
142
+ out.push(id);
143
+ const declared = field ? doc?.[field] : null;
144
+ if (typeof declared === 'string') out.push(declared);
145
+ }
146
+ out.push(...invalidIdsOfKind(tree, kind));
147
+ return out;
148
+ }
149
+
150
+ async function fileExistsOnDisk(absPath) {
151
+ try {
152
+ await stat(absPath);
153
+ return true;
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Compute the next free id for a given kind. Never reuses freed ids:
161
+ * `max+1` reads the current `tree.byId`; a deletion does not lower
162
+ * `max`, so a fresh allocation always exceeds the historical high-water
163
+ * mark for that kind (Phase 4 §D10 amendment).
164
+ *
165
+ * @param {TreeModel} tree
166
+ * @param {string} kind - one of req | us | ac | tac | adr | fbs | ts | tc
167
+ * @param {object} [opts]
168
+ * @param {string} [opts.parentId] - required for us / ac / tc
169
+ * @param {string} [opts.slug] - required for tc
170
+ * @returns {string}
171
+ */
172
+ export function nextIdForKind(tree, kind, opts = {}) {
173
+ const k = canonicalKind(kind);
174
+ // B5: unloadable (schema-invalid) docs are absent from the doc arrays
175
+ // but their ids are still occupied on disk - include them so an
176
+ // allocation never collides with a repair-pending file.
177
+ switch (k) {
178
+ case 'req':
179
+ return nextFlatId('REQ', occupiedIdsOfKind(tree, 'req'));
180
+ case 'tac':
181
+ return nextFlatId('TAC', occupiedIdsOfKind(tree, 'tac'));
182
+ case 'adr':
183
+ return nextFlatId('ADR', occupiedIdsOfKind(tree, 'adr'));
184
+ case 'fbs':
185
+ return nextFlatId('FBS', occupiedIdsOfKind(tree, 'fbs'));
186
+ case 'testSuite':
187
+ return nextFlatId('TS', occupiedIdsOfKind(tree, 'testSuite'));
188
+ // Phase 10 (X2 CodeNode bridge): Code Node. Flat namespace like
189
+ // REQ/TAC/ADR/FBS/TS - no parent required (D13).
190
+ case 'codeNode':
191
+ return nextFlatId('CN', occupiedIdsOfKind(tree, 'codeNode'));
192
+ case 'userStory': {
193
+ const reqId = opts.parentId;
194
+ const match = /^REQ-(\d+)$/.exec(reqId ?? '');
195
+ if (!match) {
196
+ throw new TypeError('nextIdForKind us requires opts.parentId matching REQ-XXX');
197
+ }
198
+ // w-2026-07-28-017: the group is a NUMBER, not a string. `REQ-001`
199
+ // and `REQ-0001` are one requirement, so their user stories share
200
+ // one numbering group.
201
+ const groupNum = Number(normaliseId(match[1]));
202
+ const groupLabel = String(groupNum);
203
+ // Occupancy is every US id that numerically falls in this group,
204
+ // whatever `reqId` string it names. Filtering on an exact reqId
205
+ // string match was the bug: a US filed under `REQ-0001` was
206
+ // invisible when allocating for `REQ-001`, so US-101 got re-issued
207
+ // on top of the existing US-101.
208
+ const usIds = occupiedIdsOfKind(tree, 'userStory');
209
+ let maxLocal = 0;
210
+ for (const usId of usIds) {
211
+ const num = idNumber(usId, 'US');
212
+ if (num === null) continue;
213
+ const local = num - groupNum * 100;
214
+ if (local >= 1 && local <= 99 && local > maxLocal) maxLocal = local;
215
+ }
216
+ const nextLocal = maxLocal + 1;
217
+ return `US-${groupLabel}${String(nextLocal).padStart(2, '0')}`;
218
+ }
219
+ case 'ac': {
220
+ const usId = opts.parentId;
221
+ const us = tree.byId.get(usId ?? '');
222
+ if (!us || tree.kindById.get(usId) !== 'userStory') {
223
+ throw new TypeError('nextIdForKind ac requires opts.parentId=US-XXX (existing)');
224
+ }
225
+ const mUs = /^US-(\d+)$/.exec(usId);
226
+ if (!mUs) throw new TypeError('nextIdForKind ac: unrecognised US id');
227
+ const usSuffix = mUs[1];
228
+ // w-2026-07-28-017: match the AC's group and local number
229
+ // numerically, so `AC-101-01` and `AC-0101-1` both count against
230
+ // `US-101` instead of being read as unrelated ids.
231
+ const normalisedSuffix = normaliseId(usSuffix);
232
+ let maxLocal = 0;
233
+ for (const ac of us.acceptanceCriteria ?? []) {
234
+ const mm = /^AC-(\d+)-(\d+)$/.exec(ac?.id ?? '');
235
+ if (!mm) continue;
236
+ if (normaliseId(mm[1]) !== normalisedSuffix) continue;
237
+ const n = Number(mm[2]);
238
+ if (n > maxLocal) maxLocal = n;
239
+ }
240
+ return `AC-${usSuffix}-${maxLocal + 1}`;
241
+ }
242
+ case 'tc': {
243
+ const tsId = opts.parentId;
244
+ const slug = opts.slug;
245
+ const ts = tree.byId.get(tsId ?? '');
246
+ if (!ts || tree.kindById.get(tsId) !== 'testSuite') {
247
+ throw new TypeError('nextIdForKind tc requires opts.parentId=TS-XXX (existing)');
248
+ }
249
+ if (typeof slug !== 'string' || slug.length === 0) {
250
+ throw new TypeError('nextIdForKind tc requires opts.slug');
251
+ }
252
+ const mTs = /^TS-(\d{3})$/.exec(tsId);
253
+ if (!mTs) throw new TypeError('nextIdForKind tc: unrecognised TS id');
254
+ return `TC-${mTs[1]}-${slug}`;
255
+ }
256
+ default:
257
+ throw new TypeError(`nextIdForKind: unsupported kind ${kind}`);
258
+ }
259
+ }
260
+
261
+ // w-2026-07-28-017: `Number()` already folded leading zeros here, so the
262
+ // high-water mark was never fooled by spelling. What WAS wrong is the id
263
+ // set fed in (see `occupiedIdsOfKind`). Comparison stays numeric and the
264
+ // emitted id is always the canonical three-digit-minimum spelling, so a
265
+ // freshly allocated id is never a leading-zero variant of a taken one.
266
+ function nextFlatId(prefix, ids) {
267
+ let max = 0;
268
+ const re = new RegExp(`^${prefix}-(\\d+)$`);
269
+ for (const id of ids) {
270
+ const m = re.exec(id ?? '');
271
+ if (m) {
272
+ const n = Number(m[1]);
273
+ if (n > max) max = n;
274
+ }
275
+ }
276
+ return `${prefix}-${String(max + 1).padStart(3, '0')}`;
277
+ }
278
+
279
+ /**
280
+ * Parent id field name required on each child kind.
281
+ */
282
+ const PARENT_FIELD_FOR = {
283
+ req: 'prdId',
284
+ userStory: 'reqId',
285
+ tac: 'tadId',
286
+ adr: 'tadId',
287
+ fbs: 'bsId',
288
+ testSuite: 'usId',
289
+ };
290
+
291
+ const EXPECTED_PARENT_KIND_FOR = {
292
+ req: 'prd',
293
+ userStory: 'req',
294
+ tac: 'tad',
295
+ adr: 'tad',
296
+ fbs: 'buildSequence',
297
+ testSuite: 'userStory',
298
+ };
299
+
300
+ /**
301
+ * @returns {string} ISO timestamp for createdAt/updatedAt fields.
302
+ */
303
+ function nowIso() {
304
+ return new Date().toISOString();
305
+ }
306
+
307
+ /**
308
+ * Write a JSON file atomically (write to .tmp, rename). Ensures the
309
+ * parent directory exists.
310
+ * @param {string} absPath
311
+ * @param {object} body
312
+ */
313
+ async function writeJsonAtomic(absPath, body) {
314
+ await mkdir(dirname(absPath), { recursive: true });
315
+ const tmp = `${absPath}.tmp`;
316
+ await writeFile(tmp, `${JSON.stringify(body, null, 2)}\n`, 'utf8');
317
+ try {
318
+ await rename(tmp, absPath);
319
+ } catch (err) {
320
+ try { await unlink(tmp); } catch { /* ignore */ }
321
+ throw err;
322
+ }
323
+ }
324
+
325
+ function pathForKindFile(projectRoot, kind, id) {
326
+ const sub = subdirFor(kind);
327
+ if (!sub) throw new TypeError(`pathForKindFile: not a child kind: ${kind}`);
328
+ return join(projectRoot, 'rcf', sub, `${id.toLowerCase()}.json`);
329
+ }
330
+
331
+ function pathForRootDoc(projectRoot, kind) {
332
+ switch (kind) {
333
+ case 'manifest': return join(projectRoot, 'rcf', 'manifest.json');
334
+ case 'prd': return join(projectRoot, 'rcf', 'prd.json');
335
+ case 'tad': return join(projectRoot, 'rcf', 'tad.json');
336
+ case 'buildSequence': return join(projectRoot, 'rcf', 'build-sequence.json');
337
+ default: throw new TypeError(`pathForRootDoc: not a root kind: ${kind}`);
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Create a new document. Returns `{ id, filePath }` on success or an
343
+ * RcfError. Handles the inline kinds `ac` (mutates parent US) and `tc`
344
+ * (mutates parent TS). For root-child kinds writes exactly one file.
345
+ *
346
+ * `body` supplies the caller-provided fields; parent linkage +
347
+ * timestamps + `--from-file` merging happen inside this function so
348
+ * every path routes through a single validation pass.
349
+ *
350
+ * @param {object} args
351
+ * @param {string} args.projectRoot
352
+ * @param {TreeModel} args.tree
353
+ * @param {string} args.kind - CLI-facing kind alias (req / us / ac / ...)
354
+ * @param {object} args.body - caller-supplied fields
355
+ * @param {object} [args.options]
356
+ * @param {string} [args.options.id] - override auto-id
357
+ * @param {string} [args.options.parentId] - required for all child kinds
358
+ * @param {number} [args.options.buildOrder] - for fbs
359
+ * @param {string} [args.options.slug] - for tc
360
+ * @param {boolean}[args.options.dryRun]
361
+ * @param {RcfError[]} [args.walkErrors] - pre-write walk errors (B5 gate)
362
+ * @returns {Promise<{ id: string, filePath: string, body: object } | RcfError>}
363
+ */
364
+ export async function createDocument({ projectRoot, tree, kind, body, options = {}, walkErrors = [] }) {
365
+ const canonical = canonicalKind(kind);
366
+ const parentId = options.parentId;
367
+ const parentField = PARENT_FIELD_FOR[canonical];
368
+ const expectedParentKind = EXPECTED_PARENT_KIND_FOR[canonical];
369
+
370
+ // Inline kinds route through mutateInline().
371
+ if (canonical === 'ac') {
372
+ return await createInlineAc({ projectRoot, tree, options, body, walkErrors });
373
+ }
374
+ if (canonical === 'tc') {
375
+ return await createInlineTc({ projectRoot, tree, options, body, walkErrors });
376
+ }
377
+ // Phase 10 (X2 CodeNode bridge): CN has no parent field (D13) - its
378
+ // identity is `path`, not a position in the PRD/REQ/US tree.
379
+ if (canonical === 'codeNode') {
380
+ return await createCn({ projectRoot, tree, options, body, walkErrors });
381
+ }
382
+
383
+ if (!parentField) {
384
+ return rcfError({
385
+ kind: 'usage',
386
+ message: `createDocument: unsupported kind ${kind}`,
387
+ });
388
+ }
389
+ if (typeof parentId !== 'string' || parentId.length === 0) {
390
+ return rcfError({
391
+ kind: 'usage',
392
+ message: `create ${kind}: --parent is required`,
393
+ });
394
+ }
395
+ // Referential-integrity pre-check.
396
+ const parentDoc = tree.byId.get(parentId);
397
+ const parentKind = tree.kindById.get(parentId);
398
+ if (!parentDoc || parentKind !== expectedParentKind) {
399
+ return rcfError({
400
+ kind: 'brokenReference',
401
+ message: `create ${kind}: parent ${parentId} not found or not a ${expectedParentKind}`,
402
+ documentId: parentId,
403
+ field: parentField,
404
+ rule: `resolveTo:${expectedParentKind}`,
405
+ });
406
+ }
407
+
408
+ // Allocate id (or take the override).
409
+ let id = options.id;
410
+ if (id) {
411
+ // B5: an unloadable (schema-invalid) doc still occupies its id.
412
+ if (tree.byId.has(id) || tree.invalidDocs?.has(id)) {
413
+ return rcfError({
414
+ kind: 'usage',
415
+ message: `create ${kind}: id ${id} is already taken`,
416
+ documentId: id,
417
+ });
418
+ }
419
+ } else {
420
+ try {
421
+ id = nextIdForKind(tree, canonical, { parentId });
422
+ } catch (err) {
423
+ return rcfError({ kind: 'usage', message: err.message });
424
+ }
425
+ }
426
+
427
+ // FBS-only sibling collision pre-check on buildOrder (§D6 step 4).
428
+ if (canonical === 'fbs') {
429
+ const siblings = childrenOfParent(tree, parentId, 'fbs');
430
+ let providedOrder = options.buildOrder;
431
+ if (providedOrder === undefined) {
432
+ let maxOrder = 0;
433
+ for (const sib of siblings) {
434
+ if (typeof sib.buildOrder === 'number' && sib.buildOrder > maxOrder) {
435
+ maxOrder = sib.buildOrder;
436
+ }
437
+ }
438
+ providedOrder = maxOrder + 1;
439
+ } else if (Number.isInteger(providedOrder)) {
440
+ const collision = siblings.find((sib) => sib.buildOrder === providedOrder);
441
+ if (collision) {
442
+ return rcfError({
443
+ kind: 'usage',
444
+ message: `create fbs: --build-order ${providedOrder} collides with ${collision.fbsId} (existing buildOrder=${collision.buildOrder}) under ${parentId}`,
445
+ documentId: collision.fbsId,
446
+ field: 'buildOrder',
447
+ });
448
+ }
449
+ }
450
+ options = { ...options, buildOrder: providedOrder };
451
+ }
452
+
453
+ // Assemble body with parent linkage + defaults + timestamps.
454
+ const finalBody = assembleBody({ tree, canonical, id, parentId, body, options });
455
+
456
+ // Validate assembled body.
457
+ const relPath = relativePathForChild(canonical, id);
458
+ const validation = validateDocument({
459
+ doc: finalBody,
460
+ kind: canonical,
461
+ filePath: relPath,
462
+ });
463
+ if (validation) return { ...validation, documentId: id };
464
+
465
+ // Cross-link ACs must resolve to existing ACs for fbs / ts.
466
+ if (canonical === 'fbs' || canonical === 'testSuite') {
467
+ const allAcIds = collectAllAcIds(tree);
468
+ for (const acId of finalBody.acIds ?? []) {
469
+ if (!allAcIds.has(acId)) {
470
+ return rcfError({
471
+ kind: 'brokenReference',
472
+ message: `create ${kind}: acId ${acId} does not resolve to a known AC`,
473
+ documentId: acId,
474
+ field: 'acIds',
475
+ rule: 'resolveTo:ac',
476
+ });
477
+ }
478
+ }
479
+ }
480
+ if (canonical === 'fbs') {
481
+ for (const depId of finalBody.dependsOnFbsIds ?? []) {
482
+ if (tree.kindById.get(depId) !== 'fbs') {
483
+ return rcfError({
484
+ kind: 'brokenReference',
485
+ message: `create fbs: dependsOnFbsIds entry ${depId} is not an existing FBS`,
486
+ documentId: depId,
487
+ field: 'dependsOnFbsIds',
488
+ rule: 'resolveTo:fbs',
489
+ });
490
+ }
491
+ }
492
+ }
493
+
494
+ const absPath = pathForKindFile(projectRoot, canonical, id);
495
+ // B5: never silently overwrite an on-disk file that failed to load
496
+ // entirely (e.g. parse-broken) - such files are absent from both byId
497
+ // and invalidDocs, so the id checks above cannot see them.
498
+ if (await fileExistsOnDisk(absPath)) {
499
+ return rcfError({
500
+ kind: 'usage',
501
+ message: `create ${kind}: ${relPath} already exists on disk but did not load; repair or delete it first`,
502
+ documentId: id,
503
+ filePath: relPath,
504
+ });
505
+ }
506
+ const gateErr = postWriteGate({
507
+ tree,
508
+ walkErrors,
509
+ upserts: [{ kind: canonical, id, doc: finalBody }],
510
+ verb: `create ${kind}`,
511
+ });
512
+ if (gateErr) return { ...gateErr, documentId: id };
513
+ if (options.dryRun) {
514
+ return { id, filePath: relPath, body: finalBody, dryRun: true };
515
+ }
516
+ try {
517
+ await writeJsonAtomic(absPath, finalBody);
518
+ } catch (err) {
519
+ return rcfError({
520
+ kind: 'ioFailure',
521
+ message: `create ${kind}: write failed: ${err.message}`,
522
+ filePath: relPath,
523
+ stack: err.stack,
524
+ });
525
+ }
526
+ return { id, filePath: relPath, body: finalBody };
527
+ }
528
+
529
+ /**
530
+ * Create a Code Node (Phase 10, X2 CodeNode bridge, D13). CN has no
531
+ * parent field - identity is `path`, optionally `#symbol`-suffixed
532
+ * (D2). `implementsAcIds` MAY be empty (an orphan CN is legitimate,
533
+ * D3); every non-empty entry must resolve to a known AC.
534
+ * `dependencies` MAY be empty; every entry must resolve to an existing
535
+ * CN and may not self-reference.
536
+ *
537
+ * @param {object} args
538
+ * @param {string} args.projectRoot
539
+ * @param {TreeModel} args.tree
540
+ * @param {object} args.options
541
+ * @param {string} [args.options.id]
542
+ * @param {boolean} [args.options.dryRun]
543
+ * @param {object} args.body
544
+ * @param {string} args.body.path - required
545
+ * @param {string[]} [args.body.implementsAcIds]
546
+ * @param {string[]} [args.body.dependencies]
547
+ * @param {RcfError[]} [args.walkErrors]
548
+ * @returns {Promise<{ id: string, filePath: string, body: object } | RcfError>}
549
+ */
550
+ async function createCn({ projectRoot, tree, options, body, walkErrors = [] }) {
551
+ const path = body?.path;
552
+ if (typeof path !== 'string' || path.length === 0) {
553
+ return rcfError({ kind: 'usage', message: 'create cn: --path is required' });
554
+ }
555
+
556
+ let id = options.id;
557
+ if (id) {
558
+ if (tree.byId.has(id) || tree.invalidDocs?.has(id)) {
559
+ return rcfError({ kind: 'usage', message: `create cn: id ${id} is already taken`, documentId: id });
560
+ }
561
+ } else {
562
+ id = nextIdForKind(tree, 'codeNode');
563
+ }
564
+
565
+ const implementsAcIds = body.implementsAcIds ?? [];
566
+ const allAcIds = collectAllAcIds(tree);
567
+ for (const acId of implementsAcIds) {
568
+ if (!allAcIds.has(acId)) {
569
+ return rcfError({
570
+ kind: 'brokenReference',
571
+ message: `create cn: implementsAcIds entry ${acId} does not resolve to a known AC`,
572
+ documentId: acId,
573
+ field: 'implementsAcIds',
574
+ rule: 'resolveTo:ac',
575
+ });
576
+ }
577
+ }
578
+
579
+ const dependencies = body.dependencies ?? [];
580
+ for (const depId of dependencies) {
581
+ if (depId === id) {
582
+ return rcfError({
583
+ kind: 'usage',
584
+ message: `create cn: dependencies entry ${depId} cannot be the node's own id`,
585
+ documentId: id,
586
+ field: 'dependencies',
587
+ });
588
+ }
589
+ if (tree.kindById.get(depId) !== 'codeNode') {
590
+ return rcfError({
591
+ kind: 'brokenReference',
592
+ message: `create cn: dependencies entry ${depId} does not resolve to a known code node`,
593
+ documentId: depId,
594
+ field: 'dependencies',
595
+ rule: 'resolveTo:codeNode',
596
+ });
597
+ }
598
+ }
599
+
600
+ const now = nowIso();
601
+ const finalBody = {
602
+ cnId: id,
603
+ path,
604
+ ...(body.title !== undefined ? { title: body.title } : {}),
605
+ ...(body.description !== undefined ? { description: body.description } : {}),
606
+ implementsAcIds,
607
+ dependencies,
608
+ version: body.version ?? '0.1.0',
609
+ status: body.status ?? 'draft',
610
+ createdAt: now,
611
+ updatedAt: now,
612
+ };
613
+
614
+ const relPath = relativePathForChild('codeNode', id);
615
+ const validation = validateDocument({ doc: finalBody, kind: 'codeNode', filePath: relPath });
616
+ if (validation) return { ...validation, documentId: id };
617
+
618
+ const absPath = pathForKindFile(projectRoot, 'codeNode', id);
619
+ if (await fileExistsOnDisk(absPath)) {
620
+ return rcfError({
621
+ kind: 'usage',
622
+ message: `create cn: ${relPath} already exists on disk but did not load; repair or delete it first`,
623
+ documentId: id,
624
+ filePath: relPath,
625
+ });
626
+ }
627
+ const gateErr = postWriteGate({
628
+ tree,
629
+ walkErrors,
630
+ upserts: [{ kind: 'codeNode', id, doc: finalBody }],
631
+ verb: 'create cn',
632
+ });
633
+ if (gateErr) return { ...gateErr, documentId: id };
634
+ if (options.dryRun) {
635
+ return { id, filePath: relPath, body: finalBody, dryRun: true };
636
+ }
637
+ try {
638
+ await writeJsonAtomic(absPath, finalBody);
639
+ } catch (err) {
640
+ return rcfError({
641
+ kind: 'ioFailure',
642
+ message: `create cn: write failed: ${err.message}`,
643
+ filePath: relPath,
644
+ stack: err.stack,
645
+ });
646
+ }
647
+ return { id, filePath: relPath, body: finalBody };
648
+ }
649
+
650
+ function relativePathForChild(canonical, id) {
651
+ const sub = subdirFor(canonical);
652
+ if (!sub) throw new TypeError(`relativePathForChild: bad kind ${canonical}`);
653
+ return `rcf/${sub}/${id.toLowerCase()}.json`;
654
+ }
655
+
656
+ function childrenOfParent(tree, parentId, expectedKind) {
657
+ const ids = tree.childrenByParent.get(parentId) ?? [];
658
+ const out = [];
659
+ for (const id of ids) {
660
+ if (tree.kindById.get(id) === expectedKind) out.push(tree.byId.get(id));
661
+ }
662
+ return out;
663
+ }
664
+
665
+ function collectAllAcIds(tree) {
666
+ const set = new Set();
667
+ for (const us of tree.userStories) {
668
+ for (const ac of us.acceptanceCriteria ?? []) {
669
+ if (ac?.id) set.add(ac.id);
670
+ }
671
+ }
672
+ return set;
673
+ }
674
+
675
+ function assembleBody({ tree, canonical, id, parentId, body, options }) {
676
+ const now = nowIso();
677
+ const prdId = tree.prd?.prdId ?? 'PRD-001';
678
+ const base = { ...(body ?? {}) };
679
+ // BUG-002/BUG-003 fix: `--title` is a CLI convenience seed. The writer's
680
+ // job is to place it in the field the kind's schema wants — `title` for
681
+ // req/us/adr/fbs/ts (schema-required) or `name` for tac (which lacks
682
+ // `title` and forbids additional properties). Lift the seed out of the
683
+ // base body so it never leaks via the spread below, then place it
684
+ // explicitly per kind. This also stops the previous title→description /
685
+ // title→summary / title→purpose cross-fallbacks that produced doc bodies
686
+ // where two semantic fields carried the same value.
687
+ const titleSeed = typeof base.title === 'string' ? base.title : undefined;
688
+ delete base.title;
689
+ // B1 fix (E2E matrix 2026-07-06-003): timestamps are writer-owned. The
690
+ // `...base` spread below previously let a caller-supplied createdAt /
691
+ // updatedAt (via --from-file or the MCP body object) override the
692
+ // writer clock - a date-only "today" value serialised as midnight UTC
693
+ // produced updatedAt EARLIER than the same doc's createdAt. Strip both
694
+ // so create mirrors update, which already refuses createdAt and forces
695
+ // updatedAt = nowIso().
696
+ delete base.createdAt;
697
+ delete base.updatedAt;
698
+ const withTimestamps = {
699
+ createdAt: now,
700
+ updatedAt: now,
701
+ ...base,
702
+ };
703
+ // Force id, parent linkage, and prdId on every child that needs them.
704
+ switch (canonical) {
705
+ case 'req':
706
+ return {
707
+ ...withTimestamps,
708
+ reqId: id,
709
+ prdId: parentId,
710
+ title: titleSeed ?? 'TODO: name this requirement',
711
+ description: base.description ?? 'TODO: describe this requirement.',
712
+ category: base.category ?? 'functional',
713
+ domain: base.domain ?? 'todo',
714
+ priority: base.priority ?? 'must',
715
+ version: base.version ?? '0.1.0',
716
+ status: base.status ?? 'draft',
717
+ };
718
+ case 'userStory':
719
+ return {
720
+ ...withTimestamps,
721
+ usId: id,
722
+ prdId,
723
+ reqId: parentId,
724
+ version: base.version ?? '0.1.0',
725
+ status: base.status ?? 'draft',
726
+ title: titleSeed ?? 'TODO: name this user story',
727
+ asA: base.asA ?? 'TODO: name the user',
728
+ iWant: base.iWant ?? 'TODO: state the want',
729
+ soThat: base.soThat ?? 'TODO: state the value',
730
+ acceptanceCriteria: base.acceptanceCriteria ?? [{
731
+ id: `AC-${id.slice(3)}-1`,
732
+ description: 'TODO: first acceptance criterion',
733
+ testable: true,
734
+ }],
735
+ };
736
+ case 'tac':
737
+ return {
738
+ ...withTimestamps,
739
+ tacId: id,
740
+ prdId,
741
+ tadId: parentId,
742
+ version: base.version ?? '0.1.0',
743
+ status: base.status ?? 'draft',
744
+ purpose: base.purpose ?? 'TODO: state the purpose',
745
+ responsibilities: base.responsibilities ?? ['TODO: list at least one responsibility'],
746
+ name: base.name ?? titleSeed ?? 'TODO: name this component',
747
+ };
748
+ case 'adr':
749
+ return {
750
+ ...withTimestamps,
751
+ adrId: id,
752
+ prdId,
753
+ tadId: parentId,
754
+ version: base.version ?? '0.1.0',
755
+ status: base.status ?? 'proposed',
756
+ title: titleSeed ?? 'TODO: name this ADR',
757
+ context: base.context ?? 'TODO: describe the context',
758
+ decision: base.decision ?? 'TODO: describe the decision',
759
+ consequences: base.consequences ?? 'TODO: describe the consequences',
760
+ };
761
+ case 'fbs': {
762
+ const buildOrder = typeof options.buildOrder === 'number' ? options.buildOrder : 1;
763
+ return {
764
+ ...withTimestamps,
765
+ fbsId: id,
766
+ prdId,
767
+ bsId: parentId,
768
+ buildOrder,
769
+ executionStatus: base.executionStatus ?? 'notStarted',
770
+ title: titleSeed ?? 'TODO: name this build session',
771
+ summary: base.summary ?? 'TODO: describe the build session',
772
+ acIds: base.acIds ?? [],
773
+ dependsOnFbsIds: base.dependsOnFbsIds ?? [],
774
+ };
775
+ }
776
+ case 'testSuite': {
777
+ // Parent US -> prdId inherited from tree root; testSuite schema doesn't
778
+ // require prdId, so we omit it unless caller supplied.
779
+ const out = {
780
+ ...withTimestamps,
781
+ id,
782
+ usId: parentId,
783
+ title: titleSeed ?? 'TODO: name this test suite',
784
+ purpose: base.purpose ?? 'TODO: state the purpose',
785
+ testLevel: base.testLevel ?? 'unit',
786
+ acIds: base.acIds ?? [],
787
+ testCases: base.testCases ?? [],
788
+ status: base.status ?? 'draft',
789
+ };
790
+ // testSuite schema forbids additional properties - drop any prdId hint.
791
+ delete out.prdId;
792
+ return out;
793
+ }
794
+ default:
795
+ throw new TypeError(`assembleBody: bad kind ${canonical}`);
796
+ }
797
+ }
798
+
799
+ /**
800
+ * Create an inline AC under a parent US.
801
+ */
802
+ async function createInlineAc({ projectRoot, tree, options, body, walkErrors = [] }) {
803
+ const parentUsId = options.parentId;
804
+ if (typeof parentUsId !== 'string' || parentUsId.length === 0) {
805
+ return rcfError({ kind: 'usage', message: 'create ac: --parent US-XXX is required' });
806
+ }
807
+ const us = tree.byId.get(parentUsId);
808
+ if (!us || tree.kindById.get(parentUsId) !== 'userStory') {
809
+ return rcfError({
810
+ kind: 'brokenReference',
811
+ message: `create ac: parent ${parentUsId} is not an existing US`,
812
+ documentId: parentUsId,
813
+ field: 'parent',
814
+ });
815
+ }
816
+ let acId = options.id;
817
+ if (acId) {
818
+ const existing = (us.acceptanceCriteria ?? []).some((ac) => ac.id === acId);
819
+ if (existing) {
820
+ return rcfError({ kind: 'usage', message: `create ac: id ${acId} already exists on ${parentUsId}`, documentId: acId });
821
+ }
822
+ } else {
823
+ try {
824
+ acId = nextIdForKind(tree, 'ac', { parentId: parentUsId });
825
+ } catch (err) {
826
+ return rcfError({ kind: 'usage', message: err.message });
827
+ }
828
+ }
829
+ const acEntry = {
830
+ id: acId,
831
+ description: body?.description ?? 'TODO: describe the acceptance criterion',
832
+ testable: body?.testable ?? true,
833
+ ...(body?.given !== undefined ? { given: body.given } : {}),
834
+ ...(body?.when !== undefined ? { when: body.when } : {}),
835
+ ...(body?.then !== undefined ? { then: body.then } : {}),
836
+ };
837
+ const nextUs = {
838
+ ...us,
839
+ acceptanceCriteria: [...(us.acceptanceCriteria ?? []), acEntry],
840
+ updatedAt: nowIso(),
841
+ };
842
+ const relPath = `rcf/user-stories/${parentUsId.toLowerCase()}.json`;
843
+ const validation = validateDocument({ doc: nextUs, kind: 'userStory', filePath: relPath });
844
+ if (validation) return { ...validation, documentId: parentUsId };
845
+ const gateErr = postWriteGate({
846
+ tree,
847
+ walkErrors,
848
+ upserts: [{ kind: 'userStory', id: parentUsId, doc: nextUs }],
849
+ verb: 'create ac',
850
+ });
851
+ if (gateErr) return { ...gateErr, documentId: parentUsId };
852
+ if (options.dryRun) {
853
+ return { id: acId, filePath: relPath, parentId: parentUsId, dryRun: true, body: acEntry };
854
+ }
855
+ try {
856
+ await writeJsonAtomic(pathForKindFile(projectRoot, 'userStory', parentUsId), nextUs);
857
+ } catch (err) {
858
+ return rcfError({ kind: 'ioFailure', message: `create ac: write failed: ${err.message}`, filePath: relPath, stack: err.stack });
859
+ }
860
+ return { id: acId, filePath: relPath, parentId: parentUsId, body: acEntry };
861
+ }
862
+
863
+ /**
864
+ * Create an inline TC under a parent TS.
865
+ */
866
+ async function createInlineTc({ projectRoot, tree, options, body, walkErrors = [] }) {
867
+ const parentTsId = options.parentId;
868
+ if (typeof parentTsId !== 'string' || parentTsId.length === 0) {
869
+ return rcfError({ kind: 'usage', message: 'create tc: --parent TS-XXX is required' });
870
+ }
871
+ const ts = tree.byId.get(parentTsId);
872
+ if (!ts || tree.kindById.get(parentTsId) !== 'testSuite') {
873
+ return rcfError({
874
+ kind: 'brokenReference',
875
+ message: `create tc: parent ${parentTsId} is not an existing TS`,
876
+ documentId: parentTsId,
877
+ field: 'parent',
878
+ });
879
+ }
880
+ const acId = body?.acId ?? options.acId;
881
+ if (typeof acId !== 'string' || acId.length === 0) {
882
+ return rcfError({ kind: 'usage', message: 'create tc: --ac AC-XXX is required' });
883
+ }
884
+ const allAcIds = collectAllAcIds(tree);
885
+ if (!allAcIds.has(acId)) {
886
+ return rcfError({
887
+ kind: 'brokenReference',
888
+ message: `create tc: acId ${acId} does not resolve to a known AC`,
889
+ documentId: acId,
890
+ field: 'acId',
891
+ });
892
+ }
893
+ const description = body?.description;
894
+ if (typeof description !== 'string' || description.length === 0) {
895
+ return rcfError({ kind: 'usage', message: 'create tc: --description is required' });
896
+ }
897
+ // w-2026-07-28-005: a TC without a test pointer is a coverage claim with
898
+ // nothing behind it; the schema overlay refuses it anyway, but refuse
899
+ // here first with a usable message.
900
+ const testPointer = options.testPointer ?? body?.testPointer;
901
+ if (typeof testPointer !== 'string' || testPointer.length === 0) {
902
+ return rcfError({
903
+ kind: 'usage',
904
+ message: 'create tc: --test-pointer is required (format filePath::testName; coverage counts a TC only when its pointer resolves to a real test)',
905
+ });
906
+ }
907
+ const slug = options.slug ?? deriveSlug(description);
908
+ const tsSuffix = /^TS-(\d{3})$/.exec(parentTsId)?.[1];
909
+ if (!tsSuffix) {
910
+ return rcfError({ kind: 'usage', message: `create tc: parent ${parentTsId} has an unrecognised id shape` });
911
+ }
912
+ const tcId = options.id ?? `TC-${tsSuffix}-${slug}`;
913
+ // Slug collision fails explicitly (§D10 OQ-P4-R-1).
914
+ const existing = (ts.testCases ?? []).find((tc) => tc.id === tcId);
915
+ if (existing) {
916
+ return rcfError({
917
+ kind: 'usage',
918
+ message: `create tc: slug collision on ${tcId}, supply --slug explicitly`,
919
+ documentId: tcId,
920
+ });
921
+ }
922
+ const tcEntry = {
923
+ id: tcId,
924
+ acId,
925
+ description,
926
+ status: body?.status ?? 'pending',
927
+ testPointer,
928
+ };
929
+ const nextTs = {
930
+ ...ts,
931
+ testCases: [...(ts.testCases ?? []), tcEntry],
932
+ updatedAt: nowIso(),
933
+ };
934
+ const relPath = `rcf/test-suites/${parentTsId.toLowerCase()}.json`;
935
+ const validation = validateDocument({ doc: nextTs, kind: 'testSuite', filePath: relPath });
936
+ if (validation) return { ...validation, documentId: parentTsId };
937
+ const gateErr = postWriteGate({
938
+ tree,
939
+ walkErrors,
940
+ upserts: [{ kind: 'testSuite', id: parentTsId, doc: nextTs }],
941
+ verb: 'create tc',
942
+ });
943
+ if (gateErr) return { ...gateErr, documentId: parentTsId };
944
+ if (options.dryRun) {
945
+ return { id: tcId, filePath: relPath, parentId: parentTsId, dryRun: true, body: tcEntry };
946
+ }
947
+ try {
948
+ await writeJsonAtomic(pathForKindFile(projectRoot, 'testSuite', parentTsId), nextTs);
949
+ } catch (err) {
950
+ return rcfError({ kind: 'ioFailure', message: `create tc: write failed: ${err.message}`, filePath: relPath, stack: err.stack });
951
+ }
952
+ return { id: tcId, filePath: relPath, parentId: parentTsId, body: tcEntry };
953
+ }
954
+
955
+ /**
956
+ * Derive a slug from a description string. Lowercase, alphanumerics
957
+ * plus hyphens, first 40 chars max, single hyphen runs. Truncation
958
+ * lands on a word boundary: if the 40-char cut falls mid-word the
959
+ * partial word is dropped (B2 fix, E2E matrix 2026-07-06-003 - ids
960
+ * like "...-saved-whil" chopped mid-word). A single unbroken word
961
+ * longer than the limit keeps its 40-char prefix (no boundary exists).
962
+ * @param {string} description
963
+ */
964
+ export function deriveSlug(description) {
965
+ const full = String(description)
966
+ .toLowerCase()
967
+ .replace(/[^a-z0-9]+/g, '-')
968
+ .replace(/^-+|-+$/g, '');
969
+ let slug = full.slice(0, 40);
970
+ if (full.length > 40 && full[40] !== '-') {
971
+ // The cut landed inside a word - back off to the last hyphen.
972
+ const boundary = slug.lastIndexOf('-');
973
+ if (boundary > 0) slug = slug.slice(0, boundary);
974
+ }
975
+ slug = slug.replace(/-+$/g, '');
976
+ return slug.length > 0 ? slug : 'tc';
977
+ }
978
+
979
+ /**
980
+ * Apply a set of dot-path assignments and/or a body merge to an
981
+ * existing document. Refuses to touch `id`, `createdAt`, or
982
+ * `schemaVersion`. Returns `{ id, filePath }` or an RcfError.
983
+ *
984
+ * @param {object} args
985
+ * @param {string} args.projectRoot
986
+ * @param {TreeModel} args.tree
987
+ * @param {string} args.id - target document id (supports inline AC / TC ids)
988
+ * @param {object} [args.patch] - deep-merge target (from --from-file)
989
+ * @param {Array<{ path: string, value: unknown }>} [args.sets] - dot-path assignments
990
+ * @param {object} [args.options]
991
+ * @param {boolean} [args.options.dryRun]
992
+ * @param {RcfError[]} [args.walkErrors] - pre-write walk errors (B5 gate)
993
+ * @returns {Promise<{ id: string, filePath: string, body: object } | RcfError>}
994
+ */
995
+ export async function updateDocument({ projectRoot, tree, id, patch, sets = [], options = {}, walkErrors = [] }) {
996
+ // Inline id resolution.
997
+ const inline = resolveInlineId(id);
998
+ if (inline) {
999
+ return await updateInline({
1000
+ projectRoot, tree, inline, id, patch, sets, options, walkErrors,
1001
+ });
1002
+ }
1003
+ // Root or child document.
1004
+ const resolved = pathForId(id);
1005
+ if (!resolved) {
1006
+ return rcfError({ kind: 'usage', message: `update: unrecognised id ${id}`, documentId: id });
1007
+ }
1008
+ const kind = resolved.kind;
1009
+ const rootKinds = new Set(['prd', 'tad', 'buildSequence']);
1010
+ // B5: schema-invalid docs are absent from byId but stay addressable
1011
+ // via invalidDocs - updating one is exactly how a wedged tree gets
1012
+ // repaired (the post-write gate ensures the repair actually repairs).
1013
+ let doc;
1014
+ if (rootKinds.has(kind)) {
1015
+ doc = tree.byId.get(id) ?? tree[rootKindTreeKey(kind)] ?? tree.invalidDocs?.get(id)?.doc;
1016
+ } else if (kind === 'manifest') {
1017
+ doc = tree.manifest;
1018
+ } else {
1019
+ doc = tree.byId.get(id) ?? tree.invalidDocs?.get(id)?.doc;
1020
+ }
1021
+ if (!doc) {
1022
+ return rcfError({ kind: 'usage', message: `update: id ${id} not found`, documentId: id });
1023
+ }
1024
+
1025
+ // Build patched body: start from doc, apply --from-file deep merge,
1026
+ // then apply each --set dot-path assignment.
1027
+ let next = deepClone(doc);
1028
+ if (patch && typeof patch === 'object') {
1029
+ next = deepMergeReplaceArrays(next, patch);
1030
+ }
1031
+ for (const { path, value } of sets) {
1032
+ const err = applyDotPath(next, path, value);
1033
+ if (err) return rcfError({ kind: 'usage', message: err, documentId: id });
1034
+ }
1035
+ // Refuse to update immutable fields.
1036
+ const immutableTouched = ['id', 'createdAt', 'schemaVersion'].filter((f) => {
1037
+ // Fields at top level only.
1038
+ return (patch && Object.prototype.hasOwnProperty.call(patch, f))
1039
+ || sets.some((s) => s.path === f);
1040
+ });
1041
+ if (immutableTouched.length > 0) {
1042
+ return rcfError({
1043
+ kind: 'usage',
1044
+ message: `update: refusing to modify immutable field(s): ${immutableTouched.join(', ')}`,
1045
+ documentId: id,
1046
+ });
1047
+ }
1048
+ next.updatedAt = nowIso();
1049
+ const relPath = kind === 'manifest'
1050
+ ? 'rcf/manifest.json'
1051
+ : rootKinds.has(kind)
1052
+ ? `rcf/${resolved.relPath}`
1053
+ : `rcf/${resolved.relPath}`;
1054
+ const validation = validateDocument({ doc: next, kind, filePath: relPath });
1055
+ if (validation) return { ...validation, documentId: id };
1056
+
1057
+ // Cross-link resolution for update-time reference-integrity:
1058
+ // if the caller touched acIds / tacIds / dependsOnFbsIds, verify each
1059
+ // referenced id is loaded.
1060
+ const refCheck = checkCrossLinks(tree, kind, next, id);
1061
+ if (refCheck) return refCheck;
1062
+
1063
+ // B5 gate: the updated doc must not introduce net-new tree breakage
1064
+ // (e.g. dropping an AC that a surviving FBS / TS still references).
1065
+ if (kind !== 'manifest') {
1066
+ const gateErr = postWriteGate({
1067
+ tree,
1068
+ walkErrors,
1069
+ upserts: [{ kind, id, doc: next }],
1070
+ verb: 'update',
1071
+ });
1072
+ if (gateErr) return { ...gateErr, documentId: id };
1073
+ }
1074
+
1075
+ const absPath = kind === 'manifest'
1076
+ ? pathForRootDoc(projectRoot, 'manifest')
1077
+ : rootKinds.has(kind)
1078
+ ? pathForRootDoc(projectRoot, kind)
1079
+ : pathForKindFile(projectRoot, kind, id);
1080
+ if (options.dryRun) {
1081
+ return { id, filePath: relPath, body: next, dryRun: true };
1082
+ }
1083
+ try {
1084
+ await writeJsonAtomic(absPath, next);
1085
+ } catch (err) {
1086
+ return rcfError({ kind: 'ioFailure', message: `update: write failed: ${err.message}`, filePath: relPath, stack: err.stack });
1087
+ }
1088
+ return { id, filePath: relPath, body: next };
1089
+ }
1090
+
1091
+ function rootKindTreeKey(kind) {
1092
+ if (kind === 'prd') return 'prd';
1093
+ if (kind === 'tad') return 'tad';
1094
+ if (kind === 'buildSequence') return 'bs';
1095
+ return kind;
1096
+ }
1097
+
1098
+ function checkCrossLinks(tree, kind, doc, docId) {
1099
+ const allAcIds = collectAllAcIds(tree);
1100
+ if (kind === 'fbs') {
1101
+ for (const acId of doc.acIds ?? []) {
1102
+ if (!allAcIds.has(acId)) {
1103
+ return rcfError({
1104
+ kind: 'brokenReference', message: `update: acId ${acId} does not resolve to a known AC`,
1105
+ documentId: docId, field: 'acIds', rule: 'resolveTo:ac',
1106
+ });
1107
+ }
1108
+ }
1109
+ for (const depId of doc.dependsOnFbsIds ?? []) {
1110
+ if (tree.kindById.get(depId) !== 'fbs' || depId === docId) {
1111
+ return rcfError({
1112
+ kind: 'brokenReference', message: `update: dependsOnFbsIds entry ${depId} is invalid`,
1113
+ documentId: docId, field: 'dependsOnFbsIds', rule: 'resolveTo:fbs',
1114
+ });
1115
+ }
1116
+ }
1117
+ }
1118
+ if (kind === 'testSuite') {
1119
+ for (const acId of doc.acIds ?? []) {
1120
+ if (!allAcIds.has(acId)) {
1121
+ return rcfError({
1122
+ kind: 'brokenReference', message: `update: acId ${acId} does not resolve to a known AC`,
1123
+ documentId: docId, field: 'acIds', rule: 'resolveTo:ac',
1124
+ });
1125
+ }
1126
+ }
1127
+ }
1128
+ if (kind === 'userStory') {
1129
+ for (const tacId of doc.tacIds ?? []) {
1130
+ if (tree.kindById.get(tacId) !== 'tac') {
1131
+ return rcfError({
1132
+ kind: 'brokenReference', message: `update: tacId ${tacId} does not resolve to a known TAC`,
1133
+ documentId: docId, field: 'tacIds', rule: 'resolveTo:tac',
1134
+ });
1135
+ }
1136
+ }
1137
+ }
1138
+ // Phase 10 (X2 CodeNode bridge): CN cross-link resolution on update.
1139
+ if (kind === 'codeNode') {
1140
+ for (const acId of doc.implementsAcIds ?? []) {
1141
+ if (!allAcIds.has(acId)) {
1142
+ return rcfError({
1143
+ kind: 'brokenReference', message: `update: implementsAcIds entry ${acId} does not resolve to a known AC`,
1144
+ documentId: docId, field: 'implementsAcIds', rule: 'resolveTo:ac',
1145
+ });
1146
+ }
1147
+ }
1148
+ for (const depId of doc.dependencies ?? []) {
1149
+ if (depId === docId || tree.kindById.get(depId) !== 'codeNode') {
1150
+ return rcfError({
1151
+ kind: 'brokenReference', message: `update: dependencies entry ${depId} is invalid`,
1152
+ documentId: docId, field: 'dependencies', rule: 'resolveTo:codeNode',
1153
+ });
1154
+ }
1155
+ }
1156
+ }
1157
+ return null;
1158
+ }
1159
+
1160
+ function resolveInlineId(id) {
1161
+ if (typeof id !== 'string') return null;
1162
+ if (/^AC-\d+(-\d+)?$/.test(id)) return { kind: 'ac' };
1163
+ if (/^TC-\d{3}-[a-z0-9-]+$/.test(id)) return { kind: 'tc' };
1164
+ return null;
1165
+ }
1166
+
1167
+ /**
1168
+ * Update an inline AC or TC by mutating its parent document.
1169
+ */
1170
+ async function updateInline({ projectRoot, tree, inline, id, patch, sets, options, walkErrors = [] }) {
1171
+ const parentId = tree.parentByChild.get(id);
1172
+ if (!parentId) {
1173
+ return rcfError({ kind: 'usage', message: `update: inline id ${id} has no resolvable parent`, documentId: id });
1174
+ }
1175
+ if (inline.kind === 'ac' && tree.kindById.get(parentId) !== 'userStory') {
1176
+ return rcfError({ kind: 'usage', message: `update: inline ${id} parent ${parentId} is not a US`, documentId: id });
1177
+ }
1178
+ if (inline.kind === 'tc' && tree.kindById.get(parentId) !== 'testSuite') {
1179
+ return rcfError({ kind: 'usage', message: `update: inline ${id} parent ${parentId} is not a TS`, documentId: id });
1180
+ }
1181
+ const parent = tree.byId.get(parentId);
1182
+ const arrayField = inline.kind === 'ac' ? 'acceptanceCriteria' : 'testCases';
1183
+ const entries = parent[arrayField] ?? [];
1184
+ const idx = entries.findIndex((e) => e.id === id);
1185
+ if (idx < 0) {
1186
+ return rcfError({ kind: 'usage', message: `update: inline ${id} not found under ${parentId}`, documentId: id });
1187
+ }
1188
+ let entry = deepClone(entries[idx]);
1189
+ if (patch && typeof patch === 'object') {
1190
+ entry = deepMergeReplaceArrays(entry, patch);
1191
+ }
1192
+ for (const { path, value } of sets) {
1193
+ const err = applyDotPath(entry, path, value);
1194
+ if (err) return rcfError({ kind: 'usage', message: err, documentId: id });
1195
+ }
1196
+ if (Object.prototype.hasOwnProperty.call(entry, 'id') && entry.id !== id) {
1197
+ return rcfError({ kind: 'usage', message: 'update: refusing to modify immutable field(s): id', documentId: id });
1198
+ }
1199
+ const nextParent = { ...parent, [arrayField]: [...entries.slice(0, idx), entry, ...entries.slice(idx + 1)], updatedAt: nowIso() };
1200
+ const kind = inline.kind === 'ac' ? 'userStory' : 'testSuite';
1201
+ const relPath = `rcf/${subdirFor(kind)}/${parentId.toLowerCase()}.json`;
1202
+ const validation = validateDocument({ doc: nextParent, kind, filePath: relPath });
1203
+ if (validation) return { ...validation, documentId: parentId };
1204
+ const gateErr = postWriteGate({
1205
+ tree,
1206
+ walkErrors,
1207
+ upserts: [{ kind, id: parentId, doc: nextParent }],
1208
+ verb: 'update',
1209
+ });
1210
+ if (gateErr) return { ...gateErr, documentId: parentId };
1211
+ if (options.dryRun) {
1212
+ return { id, filePath: relPath, parentId, body: entry, dryRun: true };
1213
+ }
1214
+ try {
1215
+ await writeJsonAtomic(pathForKindFile(projectRoot, kind, parentId), nextParent);
1216
+ } catch (err) {
1217
+ return rcfError({ kind: 'ioFailure', message: `update: write failed: ${err.message}`, filePath: relPath, stack: err.stack });
1218
+ }
1219
+ return { id, filePath: relPath, parentId, body: entry };
1220
+ }
1221
+
1222
+ // ---------------------------------------------------------------------------
1223
+ // Delete
1224
+ // ---------------------------------------------------------------------------
1225
+
1226
+ /**
1227
+ * Delete a document. Refuses by default when the doc has dependents;
1228
+ * `options.cascade` opts in. `options.dryRun` prints the plan without
1229
+ * touching disk. Returns:
1230
+ * { deleted: string[], mutated: Array<{ id, filePath }>, plan: string[] }
1231
+ * or an RcfError. Exit-code 4 refusals are surfaced as `usage` errors
1232
+ * with a `rule` field the CLI maps to exit 4 (`dependents` /
1233
+ * `wouldOrphan`).
1234
+ *
1235
+ * @param {object} args
1236
+ * @param {string} args.projectRoot
1237
+ * @param {TreeModel} args.tree
1238
+ * @param {string} args.id
1239
+ * @param {object} [args.options]
1240
+ * @param {boolean}[args.options.cascade]
1241
+ * @param {boolean}[args.options.dryRun]
1242
+ * @param {RcfError[]} [args.walkErrors] - pre-write walk errors (B5 gate)
1243
+ * @returns {Promise<{ deleted: string[], mutated: Array<{ id: string, filePath: string }>, plan: string[] } | RcfError>}
1244
+ */
1245
+ export async function deleteDocument({ projectRoot, tree, id, options = {}, walkErrors = [] }) {
1246
+ const inline = resolveInlineId(id);
1247
+ if (inline) {
1248
+ return await deleteInline({ projectRoot, tree, inline, id, options, walkErrors });
1249
+ }
1250
+ const resolved = pathForId(id);
1251
+ if (!resolved) {
1252
+ return rcfError({ kind: 'usage', message: `delete: unrecognised id ${id}`, documentId: id });
1253
+ }
1254
+ const kind = resolved.kind;
1255
+ const rootKinds = new Set(['prd', 'tad', 'buildSequence', 'manifest']);
1256
+ if (rootKinds.has(kind)) {
1257
+ return rcfError({
1258
+ kind: 'usage',
1259
+ message: `delete: root singleton ${id} cannot be deleted via rcf delete`,
1260
+ documentId: id,
1261
+ });
1262
+ }
1263
+ const doc = tree.byId.get(id);
1264
+ if (!doc) {
1265
+ // B5: the doc may exist on disk but have failed to load (schema-
1266
+ // invalid or parse-broken). Deleting it is the canonical escape from
1267
+ // a wedged tree; a delete is never blocked by validation errors
1268
+ // attributable solely to the doc being deleted. The post-write gate
1269
+ // still refuses if removing the file would introduce NET-NEW
1270
+ // breakage elsewhere.
1271
+ const invalid = tree.invalidDocs?.get(id);
1272
+ const absPath = pathForKindFile(projectRoot, kind, id);
1273
+ if (invalid || await fileExistsOnDisk(absPath)) {
1274
+ const gateErr = postWriteGate({ tree, walkErrors, deletes: [id], verb: 'delete' });
1275
+ if (gateErr) return gateErr;
1276
+ const relPath = relativePathForChild(kind, id);
1277
+ if (!options.dryRun) {
1278
+ try { await unlink(absPath); } catch (err) {
1279
+ return rcfError({ kind: 'ioFailure', message: `delete: unlink failed: ${err.message}`, filePath: relPath, stack: err.stack });
1280
+ }
1281
+ }
1282
+ return { deleted: [id], mutated: [], plan: [`delete ${relPath}`] };
1283
+ }
1284
+ return rcfError({ kind: 'usage', message: `delete: id ${id} not found`, documentId: id });
1285
+ }
1286
+
1287
+ const cascade = Boolean(options.cascade);
1288
+
1289
+ switch (kind) {
1290
+ case 'req': return await deleteReq({ projectRoot, tree, id, cascade, options, walkErrors });
1291
+ case 'userStory': return await deleteUs({ projectRoot, tree, id, cascade, options, walkErrors });
1292
+ case 'tac': return await deleteTac({ projectRoot, tree, id, cascade, options, walkErrors });
1293
+ case 'adr': return await deleteAdr({ projectRoot, tree, id, options, walkErrors });
1294
+ case 'fbs': return await deleteFbs({ projectRoot, tree, id, cascade, options, walkErrors });
1295
+ case 'testSuite': return await deleteTs({ projectRoot, tree, id, options, walkErrors });
1296
+ // Phase 10 (X2 CodeNode bridge, D13): refused while depended-on,
1297
+ // mirroring the FBS dependsOnFbsIds pattern.
1298
+ case 'codeNode': return await deleteCn({ projectRoot, tree, id, cascade, options, walkErrors });
1299
+ default:
1300
+ return rcfError({ kind: 'usage', message: `delete: unsupported kind ${kind}`, documentId: id });
1301
+ }
1302
+ }
1303
+
1304
+ async function deleteReq({ projectRoot, tree, id, cascade, options, walkErrors = [] }) {
1305
+ // Discover: child US ids via childrenByParent.
1306
+ const childUsIds = (tree.childrenByParent.get(id) ?? []).filter((cid) => tree.kindById.get(cid) === 'userStory');
1307
+ // Discover: TS ids under each child US.
1308
+ const tsIds = [];
1309
+ const collectedAcIds = new Set();
1310
+ for (const usId of childUsIds) {
1311
+ const us = tree.byId.get(usId);
1312
+ for (const ac of us.acceptanceCriteria ?? []) collectedAcIds.add(ac.id);
1313
+ for (const cid of tree.childrenByParent.get(usId) ?? []) {
1314
+ if (tree.kindById.get(cid) === 'testSuite') tsIds.push(cid);
1315
+ }
1316
+ }
1317
+ const dependents = childUsIds.length + tsIds.length + collectedAcIds.size;
1318
+ if (!cascade && dependents > 0) {
1319
+ return refuseWithDependents(id, {
1320
+ childUs: childUsIds,
1321
+ childTs: tsIds,
1322
+ collectedAcs: [...collectedAcIds],
1323
+ });
1324
+ }
1325
+ return await executeCascade({
1326
+ projectRoot,
1327
+ tree,
1328
+ toDelete: [id, ...childUsIds, ...tsIds],
1329
+ collectedAcIds,
1330
+ options,
1331
+ walkErrors,
1332
+ });
1333
+ }
1334
+
1335
+ async function deleteUs({ projectRoot, tree, id, cascade, options, walkErrors = [] }) {
1336
+ const us = tree.byId.get(id);
1337
+ const collectedAcIds = new Set((us.acceptanceCriteria ?? []).map((ac) => ac.id));
1338
+ const childTsIds = (tree.childrenByParent.get(id) ?? []).filter((cid) => tree.kindById.get(cid) === 'testSuite');
1339
+ const dependents = childTsIds.length + collectedAcIds.size;
1340
+ if (!cascade && dependents > 0) {
1341
+ return refuseWithDependents(id, { childTs: childTsIds, collectedAcs: [...collectedAcIds] });
1342
+ }
1343
+ return await executeCascade({
1344
+ projectRoot,
1345
+ tree,
1346
+ toDelete: [id, ...childTsIds],
1347
+ collectedAcIds,
1348
+ options,
1349
+ walkErrors,
1350
+ });
1351
+ }
1352
+
1353
+ async function deleteTac({ projectRoot, tree, id, cascade, options, walkErrors = [] }) {
1354
+ const dependents = (tree.usByTacId.get(id) ?? []).slice();
1355
+ if (!cascade && dependents.length > 0) {
1356
+ return refuseWithDependents(id, { usDependents: dependents });
1357
+ }
1358
+ // Two-phase (B5): compute every mutation first, gate the whole
1359
+ // change-set on the post-write tree state, then flush.
1360
+ const pending = [];
1361
+ if (cascade) {
1362
+ for (const usId of dependents) {
1363
+ const us = tree.byId.get(usId);
1364
+ const next = { ...us, tacIds: (us.tacIds ?? []).filter((t) => t !== id), updatedAt: nowIso() };
1365
+ if (next.tacIds.length === 0) delete next.tacIds; // schema allows omission (minItems:0 with optional presence)
1366
+ const relPath = `rcf/user-stories/${usId.toLowerCase()}.json`;
1367
+ const validation = validateDocument({ doc: next, kind: 'userStory', filePath: relPath });
1368
+ if (validation) return { ...validation, documentId: usId };
1369
+ pending.push({ kind: 'userStory', id: usId, doc: next, relPath });
1370
+ }
1371
+ }
1372
+ const gateErr = postWriteGate({
1373
+ tree,
1374
+ walkErrors,
1375
+ upserts: pending.map((p) => ({ kind: p.kind, id: p.id, doc: p.doc })),
1376
+ deletes: [id],
1377
+ verb: 'delete',
1378
+ });
1379
+ if (gateErr) return gateErr;
1380
+ const mutated = [];
1381
+ for (const p of pending) {
1382
+ if (!options.dryRun) await writeJsonAtomic(pathForKindFile(projectRoot, p.kind, p.id), p.doc);
1383
+ mutated.push({ id: p.id, filePath: p.relPath });
1384
+ }
1385
+ // Delete the TAC file.
1386
+ const tacRel = `rcf/tacs/${id.toLowerCase()}.json`;
1387
+ if (!options.dryRun) {
1388
+ try { await unlink(pathForKindFile(projectRoot, 'tac', id)); } catch (err) {
1389
+ return rcfError({ kind: 'ioFailure', message: `delete: unlink failed: ${err.message}`, filePath: tacRel, stack: err.stack });
1390
+ }
1391
+ }
1392
+ return { deleted: [id], mutated, plan: buildPlanLines([id], mutated) };
1393
+ }
1394
+
1395
+ async function deleteAdr({ projectRoot, tree, id, options, walkErrors = [] }) {
1396
+ const gateErr = postWriteGate({ tree, walkErrors, deletes: [id], verb: 'delete' });
1397
+ if (gateErr) return gateErr;
1398
+ const adrRel = `rcf/adrs/${id.toLowerCase()}.json`;
1399
+ if (!options.dryRun) {
1400
+ try { await unlink(pathForKindFile(projectRoot, 'adr', id)); } catch (err) {
1401
+ return rcfError({ kind: 'ioFailure', message: `delete: unlink failed: ${err.message}`, filePath: adrRel, stack: err.stack });
1402
+ }
1403
+ }
1404
+ return { deleted: [id], mutated: [], plan: [`delete ${adrRel}`] };
1405
+ }
1406
+
1407
+ async function deleteFbs({ projectRoot, tree, id, cascade, options, walkErrors = [] }) {
1408
+ const dependents = (tree.dependentsByFbsId.get(id) ?? []).slice();
1409
+ if (!cascade && dependents.length > 0) {
1410
+ return refuseWithDependents(id, { fbsDependents: dependents });
1411
+ }
1412
+ const pending = [];
1413
+ if (cascade) {
1414
+ for (const depFbsId of dependents) {
1415
+ const dep = tree.byId.get(depFbsId);
1416
+ const next = {
1417
+ ...dep,
1418
+ dependsOnFbsIds: (dep.dependsOnFbsIds ?? []).filter((d) => d !== id),
1419
+ updatedAt: nowIso(),
1420
+ };
1421
+ const relPath = `rcf/fbs/${depFbsId.toLowerCase()}.json`;
1422
+ const validation = validateDocument({ doc: next, kind: 'fbs', filePath: relPath });
1423
+ if (validation) return { ...validation, documentId: depFbsId };
1424
+ pending.push({ kind: 'fbs', id: depFbsId, doc: next, relPath });
1425
+ }
1426
+ }
1427
+ const gateErr = postWriteGate({
1428
+ tree,
1429
+ walkErrors,
1430
+ upserts: pending.map((p) => ({ kind: p.kind, id: p.id, doc: p.doc })),
1431
+ deletes: [id],
1432
+ verb: 'delete',
1433
+ });
1434
+ if (gateErr) return gateErr;
1435
+ const mutated = [];
1436
+ for (const p of pending) {
1437
+ if (!options.dryRun) await writeJsonAtomic(pathForKindFile(projectRoot, p.kind, p.id), p.doc);
1438
+ mutated.push({ id: p.id, filePath: p.relPath });
1439
+ }
1440
+ const fbsRel = `rcf/fbs/${id.toLowerCase()}.json`;
1441
+ if (!options.dryRun) {
1442
+ try { await unlink(pathForKindFile(projectRoot, 'fbs', id)); } catch (err) {
1443
+ return rcfError({ kind: 'ioFailure', message: `delete: unlink failed: ${err.message}`, filePath: fbsRel, stack: err.stack });
1444
+ }
1445
+ }
1446
+ return { deleted: [id], mutated, plan: buildPlanLines([id], mutated) };
1447
+ }
1448
+
1449
+ /**
1450
+ * Delete a Code Node (Phase 10, D13). Refused by default while another
1451
+ * CN depends on it (dependentsByCnId); --cascade drops the dependency
1452
+ * edge from every dependent CN's `dependencies[]` before removing the
1453
+ * file, mirroring `deleteFbs`.
1454
+ */
1455
+ async function deleteCn({ projectRoot, tree, id, cascade, options, walkErrors = [] }) {
1456
+ const dependents = (tree.dependentsByCnId.get(id) ?? []).slice();
1457
+ if (!cascade && dependents.length > 0) {
1458
+ return refuseWithDependents(id, { cnDependents: dependents });
1459
+ }
1460
+ const pending = [];
1461
+ if (cascade) {
1462
+ for (const depCnId of dependents) {
1463
+ const dep = tree.byId.get(depCnId);
1464
+ const next = {
1465
+ ...dep,
1466
+ dependencies: (dep.dependencies ?? []).filter((d) => d !== id),
1467
+ updatedAt: nowIso(),
1468
+ };
1469
+ const relPath = `rcf/code-nodes/${depCnId.toLowerCase()}.json`;
1470
+ const validation = validateDocument({ doc: next, kind: 'codeNode', filePath: relPath });
1471
+ if (validation) return { ...validation, documentId: depCnId };
1472
+ pending.push({ kind: 'codeNode', id: depCnId, doc: next, relPath });
1473
+ }
1474
+ }
1475
+ const gateErr = postWriteGate({
1476
+ tree,
1477
+ walkErrors,
1478
+ upserts: pending.map((p) => ({ kind: p.kind, id: p.id, doc: p.doc })),
1479
+ deletes: [id],
1480
+ verb: 'delete',
1481
+ });
1482
+ if (gateErr) return gateErr;
1483
+ const mutated = [];
1484
+ for (const p of pending) {
1485
+ if (!options.dryRun) await writeJsonAtomic(pathForKindFile(projectRoot, p.kind, p.id), p.doc);
1486
+ mutated.push({ id: p.id, filePath: p.relPath });
1487
+ }
1488
+ const cnRel = `rcf/code-nodes/${id.toLowerCase()}.json`;
1489
+ if (!options.dryRun) {
1490
+ try { await unlink(pathForKindFile(projectRoot, 'codeNode', id)); } catch (err) {
1491
+ return rcfError({ kind: 'ioFailure', message: `delete: unlink failed: ${err.message}`, filePath: cnRel, stack: err.stack });
1492
+ }
1493
+ }
1494
+ return { deleted: [id], mutated, plan: buildPlanLines([id], mutated) };
1495
+ }
1496
+
1497
+ async function deleteTs({ projectRoot, tree, id, options, walkErrors = [] }) {
1498
+ const gateErr = postWriteGate({ tree, walkErrors, deletes: [id], verb: 'delete' });
1499
+ if (gateErr) return gateErr;
1500
+ const tsRel = `rcf/test-suites/${id.toLowerCase()}.json`;
1501
+ if (!options.dryRun) {
1502
+ try { await unlink(pathForKindFile(projectRoot, 'testSuite', id)); } catch (err) {
1503
+ return rcfError({ kind: 'ioFailure', message: `delete: unlink failed: ${err.message}`, filePath: tsRel, stack: err.stack });
1504
+ }
1505
+ }
1506
+ return { deleted: [id], mutated: [], plan: [`delete ${tsRel}`] };
1507
+ }
1508
+
1509
+ /**
1510
+ * Delete an inline AC / TC entry. Refuses with exit 4 if the AC has
1511
+ * cross-link dependents; --cascade opts in and mutates FBS/TS acIds
1512
+ * accordingly (with an orphan-refuse pre-plan check per §D9).
1513
+ */
1514
+ async function deleteInline({ projectRoot, tree, inline, id, options, walkErrors = [] }) {
1515
+ const parentId = tree.parentByChild.get(id);
1516
+ if (!parentId) {
1517
+ return rcfError({ kind: 'usage', message: `delete: inline ${id} has no resolvable parent`, documentId: id });
1518
+ }
1519
+ if (inline.kind === 'ac' && tree.kindById.get(parentId) !== 'userStory') {
1520
+ return rcfError({ kind: 'usage', message: `delete: parent ${parentId} for ${id} is not a US`, documentId: id });
1521
+ }
1522
+ if (inline.kind === 'tc' && tree.kindById.get(parentId) !== 'testSuite') {
1523
+ return rcfError({ kind: 'usage', message: `delete: parent ${parentId} for ${id} is not a TS`, documentId: id });
1524
+ }
1525
+ const parent = tree.byId.get(parentId);
1526
+ const arrayField = inline.kind === 'ac' ? 'acceptanceCriteria' : 'testCases';
1527
+ const entries = parent[arrayField] ?? [];
1528
+ const idx = entries.findIndex((e) => e.id === id);
1529
+ if (idx < 0) {
1530
+ return rcfError({ kind: 'usage', message: `delete: inline ${id} not found under ${parentId}`, documentId: id });
1531
+ }
1532
+ if (inline.kind === 'tc') {
1533
+ // No cross-refs to worry about (§D9 TC clause). Just drop the entry.
1534
+ const nextEntries = [...entries.slice(0, idx), ...entries.slice(idx + 1)];
1535
+ return await writeInlineParent({
1536
+ projectRoot, tree, walkErrors, parent, parentId, arrayField, nextEntries, options, deletedInlineIds: [id],
1537
+ });
1538
+ }
1539
+ // AC path: check cross-refs and orphan-refuse.
1540
+ const dependentFbs = tree.fbsByAcId.get(id) ?? [];
1541
+ const dependentTs = tree.tsByAcId.get(id) ?? [];
1542
+ const hasDeps = dependentFbs.length + dependentTs.length > 0;
1543
+ if (!options.cascade && hasDeps) {
1544
+ return refuseWithDependents(id, { fbsAcDependents: dependentFbs, tsAcDependents: dependentTs });
1545
+ }
1546
+ // Would the parent US drop below minItems:1 on acceptanceCriteria?
1547
+ const nextEntries = [...entries.slice(0, idx), ...entries.slice(idx + 1)];
1548
+ if (nextEntries.length === 0) {
1549
+ return rcfError({
1550
+ kind: 'usage',
1551
+ message: `delete: would leave US ${parentId} with no acceptance criteria`,
1552
+ documentId: parentId,
1553
+ rule: 'wouldOrphan',
1554
+ });
1555
+ }
1556
+ // Cascade path: compute FBS/TS acIds mutations (no writes yet - the
1557
+ // whole change-set is gated in writeInlineParent), orphan-refuse.
1558
+ const pending = [];
1559
+ if (options.cascade) {
1560
+ const orphanCheck = checkAcOrphans(tree, [id]);
1561
+ if (orphanCheck) return orphanCheck;
1562
+ for (const fbsId of dependentFbs) {
1563
+ const fbs = tree.byId.get(fbsId);
1564
+ const nextAcIds = (fbs.acIds ?? []).filter((a) => a !== id);
1565
+ const next = { ...fbs, acIds: nextAcIds, updatedAt: nowIso() };
1566
+ const relPath = `rcf/fbs/${fbsId.toLowerCase()}.json`;
1567
+ const validation = validateDocument({ doc: next, kind: 'fbs', filePath: relPath });
1568
+ if (validation) return { ...validation, documentId: fbsId };
1569
+ pending.push({ kind: 'fbs', id: fbsId, doc: next, relPath });
1570
+ }
1571
+ for (const tsId of dependentTs) {
1572
+ const ts = tree.byId.get(tsId);
1573
+ const nextAcIds = (ts.acIds ?? []).filter((a) => a !== id);
1574
+ const next = { ...ts, acIds: nextAcIds, updatedAt: nowIso() };
1575
+ const relPath = `rcf/test-suites/${tsId.toLowerCase()}.json`;
1576
+ const validation = validateDocument({ doc: next, kind: 'testSuite', filePath: relPath });
1577
+ if (validation) return { ...validation, documentId: tsId };
1578
+ pending.push({ kind: 'testSuite', id: tsId, doc: next, relPath });
1579
+ }
1580
+ }
1581
+ return await writeInlineParent({
1582
+ projectRoot, tree, walkErrors, parent, parentId, arrayField, nextEntries, options, pendingMutations: pending, deletedInlineIds: [id],
1583
+ });
1584
+ }
1585
+
1586
+ async function writeInlineParent({
1587
+ projectRoot, tree, walkErrors = [], parent, parentId, arrayField, nextEntries, options,
1588
+ pendingMutations = [], deletedInlineIds = [],
1589
+ }) {
1590
+ const parentKind = tree.kindById.get(parentId);
1591
+ const next = { ...parent, [arrayField]: nextEntries, updatedAt: nowIso() };
1592
+ const relPath = `rcf/${subdirFor(parentKind)}/${parentId.toLowerCase()}.json`;
1593
+ const validation = validateDocument({ doc: next, kind: parentKind, filePath: relPath });
1594
+ if (validation) return { ...validation, documentId: parentId };
1595
+ // B5 gate over the whole change-set (cascade mutations + parent edit).
1596
+ const gateErr = postWriteGate({
1597
+ tree,
1598
+ walkErrors,
1599
+ upserts: [
1600
+ ...pendingMutations.map((m) => ({ kind: m.kind, id: m.id, doc: m.doc })),
1601
+ { kind: parentKind, id: parentId, doc: next },
1602
+ ],
1603
+ verb: 'delete',
1604
+ });
1605
+ if (gateErr) return gateErr;
1606
+ if (!options.dryRun) {
1607
+ try {
1608
+ for (const m of pendingMutations) {
1609
+ await writeJsonAtomic(pathForKindFile(projectRoot, m.kind, m.id), m.doc);
1610
+ }
1611
+ await writeJsonAtomic(pathForKindFile(projectRoot, parentKind, parentId), next);
1612
+ } catch (err) {
1613
+ return rcfError({ kind: 'ioFailure', message: `delete: write failed: ${err.message}`, filePath: relPath, stack: err.stack });
1614
+ }
1615
+ }
1616
+ const mutated = [
1617
+ ...pendingMutations.map((m) => ({ id: m.id, filePath: m.relPath })),
1618
+ { id: parentId, filePath: relPath },
1619
+ ];
1620
+ const plan = [
1621
+ ...pendingMutations.map((m) => `mutate ${m.relPath}`),
1622
+ `mutate ${relPath} (drop inline entries)`,
1623
+ ];
1624
+ return { deleted: [...deletedInlineIds], mutated, plan };
1625
+ }
1626
+
1627
+ /**
1628
+ * Execute a cascade delete plan (REQ/US cascade). Runs the orphan-refuse
1629
+ * pre-plan check first; simulates every FBS/TS `acIds` mutation and
1630
+ * refuses the whole cascade if any surviving FBS/TS would fall below
1631
+ * minItems:1.
1632
+ */
1633
+ async function executeCascade({ projectRoot, tree, toDelete, collectedAcIds, options, walkErrors = [] }) {
1634
+ const deletingSet = new Set(toDelete);
1635
+ const orphanCheck = checkAcOrphans(tree, [...collectedAcIds], deletingSet);
1636
+ if (orphanCheck) return orphanCheck;
1637
+
1638
+ // Two-phase (B5): compute every surviving-doc mutation, gate the whole
1639
+ // change-set on the post-write tree state, then flush writes + unlinks.
1640
+ const affectedFbs = new Map();
1641
+ const affectedTs = new Map();
1642
+ for (const acId of collectedAcIds) {
1643
+ for (const fbsId of tree.fbsByAcId.get(acId) ?? []) {
1644
+ if (deletingSet.has(fbsId)) continue;
1645
+ const fbs = affectedFbs.get(fbsId) ?? { ...tree.byId.get(fbsId) };
1646
+ fbs.acIds = (fbs.acIds ?? []).filter((a) => a !== acId);
1647
+ affectedFbs.set(fbsId, fbs);
1648
+ }
1649
+ for (const tsId of tree.tsByAcId.get(acId) ?? []) {
1650
+ if (deletingSet.has(tsId)) continue;
1651
+ const ts = affectedTs.get(tsId) ?? { ...tree.byId.get(tsId) };
1652
+ ts.acIds = (ts.acIds ?? []).filter((a) => a !== acId);
1653
+ affectedTs.set(tsId, ts);
1654
+ }
1655
+ }
1656
+ const pending = [];
1657
+ for (const [fbsId, fbs] of affectedFbs) {
1658
+ fbs.updatedAt = nowIso();
1659
+ const relPath = `rcf/fbs/${fbsId.toLowerCase()}.json`;
1660
+ const validation = validateDocument({ doc: fbs, kind: 'fbs', filePath: relPath });
1661
+ if (validation) return { ...validation, documentId: fbsId };
1662
+ pending.push({ kind: 'fbs', id: fbsId, doc: fbs, relPath });
1663
+ }
1664
+ for (const [tsId, ts] of affectedTs) {
1665
+ ts.updatedAt = nowIso();
1666
+ const relPath = `rcf/test-suites/${tsId.toLowerCase()}.json`;
1667
+ const validation = validateDocument({ doc: ts, kind: 'testSuite', filePath: relPath });
1668
+ if (validation) return { ...validation, documentId: tsId };
1669
+ pending.push({ kind: 'testSuite', id: tsId, doc: ts, relPath });
1670
+ }
1671
+ const gateErr = postWriteGate({
1672
+ tree,
1673
+ walkErrors,
1674
+ upserts: pending.map((p) => ({ kind: p.kind, id: p.id, doc: p.doc })),
1675
+ deletes: toDelete,
1676
+ verb: 'delete',
1677
+ });
1678
+ if (gateErr) return gateErr;
1679
+ const mutated = [];
1680
+ for (const p of pending) {
1681
+ if (!options.dryRun) await writeJsonAtomic(pathForKindFile(projectRoot, p.kind, p.id), p.doc);
1682
+ mutated.push({ id: p.id, filePath: p.relPath });
1683
+ }
1684
+ // Then unlink every file in the toDelete list (leaves first: reverse order).
1685
+ const deleted = [];
1686
+ for (const delId of [...toDelete].reverse()) {
1687
+ const kind = tree.kindById.get(delId);
1688
+ if (!kind) continue;
1689
+ const relPath = relativePathForChild(kind, delId);
1690
+ if (!options.dryRun) {
1691
+ try { await unlink(pathForKindFile(projectRoot, kind, delId)); } catch (err) {
1692
+ if (err.code !== 'ENOENT') {
1693
+ return rcfError({ kind: 'ioFailure', message: `delete: unlink failed: ${err.message}`, filePath: relPath, stack: err.stack });
1694
+ }
1695
+ }
1696
+ }
1697
+ deleted.push(delId);
1698
+ }
1699
+ return { deleted, mutated, plan: buildPlanLines(deleted, mutated) };
1700
+ }
1701
+
1702
+ /**
1703
+ * Check if deleting the given AC ids would leave any surviving FBS/TS
1704
+ * with an empty `acIds[]` (schema mandates `minItems: 1`). Optionally
1705
+ * skips FBS/TS in `deletingSet` because those are being removed anyway.
1706
+ * Returns an RcfError on refusal or null.
1707
+ */
1708
+ function checkAcOrphans(tree, acIds, deletingSet = new Set()) {
1709
+ const deletingAcSet = new Set(acIds);
1710
+ const orphans = [];
1711
+ for (const [acId, fbsList] of tree.fbsByAcId) {
1712
+ if (!deletingAcSet.has(acId)) continue;
1713
+ for (const fbsId of fbsList) {
1714
+ if (deletingSet.has(fbsId)) continue;
1715
+ const fbs = tree.byId.get(fbsId);
1716
+ const survivorAcIds = (fbs.acIds ?? []).filter((a) => !deletingAcSet.has(a));
1717
+ if (survivorAcIds.length === 0) {
1718
+ orphans.push({ id: fbsId, kind: 'fbs', drivers: (fbs.acIds ?? []).filter((a) => deletingAcSet.has(a)) });
1719
+ }
1720
+ }
1721
+ }
1722
+ for (const [acId, tsList] of tree.tsByAcId) {
1723
+ if (!deletingAcSet.has(acId)) continue;
1724
+ for (const tsId of tsList) {
1725
+ if (deletingSet.has(tsId)) continue;
1726
+ const ts = tree.byId.get(tsId);
1727
+ const survivorAcIds = (ts.acIds ?? []).filter((a) => !deletingAcSet.has(a));
1728
+ if (survivorAcIds.length === 0) {
1729
+ orphans.push({ id: tsId, kind: 'testSuite', drivers: (ts.acIds ?? []).filter((a) => deletingAcSet.has(a)) });
1730
+ }
1731
+ }
1732
+ }
1733
+ // Deduplicate by id.
1734
+ const dedup = new Map();
1735
+ for (const o of orphans) if (!dedup.has(o.id)) dedup.set(o.id, o);
1736
+ if (dedup.size === 0) return null;
1737
+ const lines = [...dedup.values()].map((o) => `${o.id} (${o.kind}) would be orphaned by deletion of ${o.drivers.join(', ')}`);
1738
+ return rcfError({
1739
+ kind: 'usage',
1740
+ message: `delete: cascade would orphan downstream ac lists: ${lines.join('; ')}`,
1741
+ rule: 'wouldOrphan',
1742
+ });
1743
+ }
1744
+
1745
+ function refuseWithDependents(id, dependents) {
1746
+ const parts = [];
1747
+ for (const [key, list] of Object.entries(dependents)) {
1748
+ if (Array.isArray(list) && list.length > 0) parts.push(`${key}=${list.join(',')}`);
1749
+ }
1750
+ return rcfError({
1751
+ kind: 'usage',
1752
+ message: `delete: ${id} has dependents (${parts.join(' ')}); pass --cascade to opt in`,
1753
+ documentId: id,
1754
+ rule: 'dependents',
1755
+ });
1756
+ }
1757
+
1758
+ function buildPlanLines(deleted, mutated) {
1759
+ return [
1760
+ ...mutated.map((m) => `mutate ${m.filePath}`),
1761
+ ...deleted.map((id) => {
1762
+ // BUG-006 fix: resolve the real kind-directory from the id prefix
1763
+ // instead of emitting the `rcf/.../` placeholder. `pathForId`
1764
+ // returns `{ kind, relPath }` where `relPath` already includes the
1765
+ // kind subdirectory (e.g. `requirements/req-002.json`); prepend
1766
+ // the `rcf/` root to line up with the other plan lines.
1767
+ const resolved = pathForId(id);
1768
+ const rel = resolved ? `rcf/${resolved.relPath}` : `rcf/${id.toLowerCase()}.json`;
1769
+ return `delete ${rel}`;
1770
+ }),
1771
+ ];
1772
+ }
1773
+
1774
+ // ---------------------------------------------------------------------------
1775
+ // Utilities: deep-merge and dot-path patch
1776
+ // ---------------------------------------------------------------------------
1777
+
1778
+ function deepClone(value) {
1779
+ if (value === null || typeof value !== 'object') return value;
1780
+ if (Array.isArray(value)) return value.map((v) => deepClone(v));
1781
+ const out = {};
1782
+ for (const k of Object.keys(value)) out[k] = deepClone(value[k]);
1783
+ return out;
1784
+ }
1785
+
1786
+ function deepMergeReplaceArrays(target, source) {
1787
+ if (source === null || typeof source !== 'object' || Array.isArray(source)) {
1788
+ return deepClone(source);
1789
+ }
1790
+ const out = target && typeof target === 'object' && !Array.isArray(target) ? { ...target } : {};
1791
+ for (const k of Object.keys(source)) {
1792
+ const sv = source[k];
1793
+ if (Array.isArray(sv)) {
1794
+ out[k] = deepClone(sv);
1795
+ } else if (sv && typeof sv === 'object') {
1796
+ out[k] = deepMergeReplaceArrays(out[k], sv);
1797
+ } else {
1798
+ out[k] = sv;
1799
+ }
1800
+ }
1801
+ return out;
1802
+ }
1803
+
1804
+ /**
1805
+ * Apply `target.<dotPath> = value`. Supports `a.b.c` and `a.b[0].c`.
1806
+ * Returns null on success or a string describing the failure.
1807
+ */
1808
+ function applyDotPath(target, path, value) {
1809
+ const parts = parseDotPath(path);
1810
+ if (!parts) return `bad path: ${path}`;
1811
+ let cur = target;
1812
+ for (let i = 0; i < parts.length - 1; i += 1) {
1813
+ const seg = parts[i];
1814
+ if (seg.kind === 'index') {
1815
+ if (!Array.isArray(cur)) return `path ${path}: expected array at ${seg.value}`;
1816
+ if (cur[seg.value] === undefined) cur[seg.value] = {};
1817
+ cur = cur[seg.value];
1818
+ } else {
1819
+ if (cur[seg.value] === undefined || cur[seg.value] === null) cur[seg.value] = {};
1820
+ cur = cur[seg.value];
1821
+ }
1822
+ }
1823
+ const last = parts[parts.length - 1];
1824
+ if (last.kind === 'index') {
1825
+ if (!Array.isArray(cur)) return `path ${path}: expected array at ${last.value}`;
1826
+ cur[last.value] = value;
1827
+ } else {
1828
+ cur[last.value] = value;
1829
+ }
1830
+ return null;
1831
+ }
1832
+
1833
+ function parseDotPath(path) {
1834
+ if (typeof path !== 'string' || path.length === 0) return null;
1835
+ const parts = [];
1836
+ const segments = path.split('.');
1837
+ for (const seg of segments) {
1838
+ const re = /^([^\[\]]+)((?:\[\d+\])*)$/;
1839
+ const m = re.exec(seg);
1840
+ if (!m) return null;
1841
+ parts.push({ kind: 'prop', value: m[1] });
1842
+ const bracketed = m[2];
1843
+ if (bracketed) {
1844
+ const indices = bracketed.match(/\d+/g) ?? [];
1845
+ for (const n of indices) parts.push({ kind: 'index', value: Number(n) });
1846
+ }
1847
+ }
1848
+ return parts;
1849
+ }