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,898 @@
1
+ // Tree walker (Phase 3.7 D7). Load-then-invert algorithm: read every
2
+ // on-disk document, schema-validate it, then invert child-borne parent
3
+ // references (`prdId`, `tadId`, `bsId`, `reqId`, `usId`) into computed
4
+ // parent-keyed maps. Zero directory-listing based discovery of tree
5
+ // topology -- topology is derived exclusively from parent-id fields
6
+ // on children. Directory enumeration lives in `loader.js`
7
+ // (`listSubdirJsonFiles`) and is used only to bring files into memory.
8
+ //
9
+ // The child-owned edge is the source of truth; the parent never carries a
10
+ // children list. Broken parent references and broken cross-links surface
11
+ // as structured `brokenReference` errors naming the exact file + field.
12
+ //
13
+ // Returns `{ tree, errors }`. The tree carries the doc arrays, `byId` /
14
+ // `rawById` / `brokenIds` (as before) plus the computed relationship maps
15
+ // consumers rely on: `parentByChild`, `childrenByParent`, `fbsByAcId`,
16
+ // `dependentsByFbsId`, `tsByAcId`, `tcsByAcId`, `usByTacId`.
17
+
18
+ import { rcfError } from '../errors/index.js';
19
+ import { normaliseId } from './ids.js';
20
+ import { listSubdirJsonFiles, loadDocument, loadRootDocument, pathForId, subdirFor } from './loader.js';
21
+ import { validateDocument } from './validator.js';
22
+
23
+ /**
24
+ * @typedef {object} TreeModel
25
+ * @property {object|null} manifest
26
+ * @property {object|null} prd
27
+ * @property {object|null} tad
28
+ * @property {object|null} bs
29
+ * @property {object[]} requirements
30
+ * @property {object[]} userStories
31
+ * @property {object[]} tacs
32
+ * @property {object[]} adrs
33
+ * @property {object[]} fbsItems
34
+ * @property {object[]} testSuites
35
+ * @property {Map<string, object>} byId
36
+ * @property {Map<string, string>} rawById
37
+ * @property {Set<string>} brokenIds
38
+ * @property {Map<string, string>} parentByChild
39
+ * @property {Map<string, string[]>} childrenByParent
40
+ * @property {Map<string, string[]>} fbsByAcId
41
+ * @property {Map<string, string[]>} dependentsByFbsId
42
+ * @property {Map<string, string[]>} tsByAcId
43
+ * @property {Map<string, Array<{ tsId: string, tcId: string }>>} tcsByAcId
44
+ * @property {Map<string, string[]>} usByTacId
45
+ */
46
+
47
+ /**
48
+ * @typedef {object} WalkResult
49
+ * @property {TreeModel} tree
50
+ * @property {import('../errors/index.js').RcfError[]} errors
51
+ */
52
+
53
+ // Phase 10 (X2 CodeNode bridge): 'codeNode' appended. The load-then-invert
54
+ // engine treats it exactly like any other child kind - extending the graph
55
+ // into code is additive, not a rewrite (PoC-proven, poc/codenode-bridge).
56
+ const CHILD_KINDS = ['req', 'userStory', 'tac', 'adr', 'fbs', 'testSuite', 'codeNode'];
57
+
58
+ const ID_FIELD_BY_KIND = {
59
+ prd: 'prdId',
60
+ tad: 'tadId',
61
+ buildSequence: 'bsId',
62
+ req: 'reqId',
63
+ userStory: 'usId',
64
+ tac: 'tacId',
65
+ adr: 'adrId',
66
+ fbs: 'fbsId',
67
+ // Test Suite uses the plain `id` field (see 0.2.0 test-suite schema).
68
+ testSuite: 'id',
69
+ // Phase 10: Code Node.
70
+ codeNode: 'cnId',
71
+ };
72
+
73
+ function idOfDoc(doc, kind) {
74
+ const field = ID_FIELD_BY_KIND[kind];
75
+ if (!field) return null;
76
+ const value = doc?.[field];
77
+ return typeof value === 'string' ? value : null;
78
+ }
79
+
80
+ function newTree() {
81
+ return {
82
+ manifest: null,
83
+ prd: null,
84
+ tad: null,
85
+ bs: null,
86
+ requirements: [],
87
+ userStories: [],
88
+ tacs: [],
89
+ adrs: [],
90
+ fbsItems: [],
91
+ testSuites: [],
92
+ // Phase 10 (X2 CodeNode bridge): Code Nodes.
93
+ codeNodes: [],
94
+ byId: new Map(),
95
+ rawById: new Map(),
96
+ kindById: new Map(),
97
+ brokenIds: new Set(),
98
+ // B5: schema-invalid (but parseable) documents, keyed by id. These
99
+ // are EXCLUDED from byId / the doc arrays / the inversion maps, but
100
+ // stay addressable so the write verbs can repair or delete them
101
+ // (post-write validation semantics; the TS-003 wedge).
102
+ invalidDocs: new Map(),
103
+ parentByChild: new Map(),
104
+ childrenByParent: new Map(),
105
+ fbsByAcId: new Map(),
106
+ dependentsByFbsId: new Map(),
107
+ tsByAcId: new Map(),
108
+ tcsByAcId: new Map(),
109
+ usByTacId: new Map(),
110
+ // Phase 10: cnByAcId (AC -> CNs implementing it); dependentsByCnId
111
+ // (CN -> CNs that declare it in dependencies[]). Mirrors
112
+ // fbsByAcId / dependentsByFbsId for the code layer.
113
+ cnByAcId: new Map(),
114
+ dependentsByCnId: new Map(),
115
+ };
116
+ }
117
+
118
+ function pushToMap(map, key, value) {
119
+ const list = map.get(key) ?? [];
120
+ list.push(value);
121
+ map.set(key, list);
122
+ }
123
+
124
+ function sortById(list, field) {
125
+ return [...list].sort((a, b) => (a[field] ?? '').localeCompare(b[field] ?? ''));
126
+ }
127
+
128
+ function recordDoc(tree, id, doc, raw, kind) {
129
+ if (id) {
130
+ tree.byId.set(id, doc);
131
+ tree.rawById.set(id, raw);
132
+ tree.kindById.set(id, kind);
133
+ }
134
+ switch (kind) {
135
+ case 'req': tree.requirements.push(doc); break;
136
+ case 'userStory': tree.userStories.push(doc); break;
137
+ case 'tac': tree.tacs.push(doc); break;
138
+ case 'adr': tree.adrs.push(doc); break;
139
+ case 'fbs': tree.fbsItems.push(doc); break;
140
+ case 'testSuite': tree.testSuites.push(doc); break;
141
+ // Phase 10: Code Node.
142
+ case 'codeNode': tree.codeNodes.push(doc); break;
143
+ default: break;
144
+ }
145
+ }
146
+
147
+ async function loadRoot(kind, { projectRoot, tree, errors }) {
148
+ const result = await loadRootDocument({ projectRoot, kind });
149
+ if ('kind' in result && result.kind !== kind) {
150
+ // Keep the error payload clean for downstream consumers; the parsed
151
+ // body (present on validation errors) lands in tree.invalidDocs.
152
+ const { doc: invalidDoc, raw: invalidRaw, ...cleanError } = result;
153
+ void invalidRaw;
154
+ errors.push(cleanError);
155
+ if (result.kind === 'validation' && invalidDoc && typeof invalidDoc === 'object') {
156
+ const invalidId = idOfDoc(invalidDoc, kind);
157
+ if (invalidId) {
158
+ tree.invalidDocs.set(invalidId, { kind, doc: invalidDoc });
159
+ tree.brokenIds.add(invalidId);
160
+ }
161
+ }
162
+ return null;
163
+ }
164
+ const id = idOfDoc(result.doc, kind);
165
+ recordDoc(tree, id, result.doc, result.raw, kind);
166
+ switch (kind) {
167
+ case 'prd': tree.prd = result.doc; break;
168
+ case 'tad': tree.tad = result.doc; break;
169
+ case 'buildSequence': tree.bs = result.doc; break;
170
+ default: break;
171
+ }
172
+ return result.doc;
173
+ }
174
+
175
+ async function loadChildKind(kind, { projectRoot, tree, errors }) {
176
+ const subdir = subdirFor(kind);
177
+ if (!subdir) return;
178
+ const listing = await listSubdirJsonFiles({ projectRoot, subdir });
179
+ if ('error' in listing) {
180
+ errors.push(listing.error);
181
+ return;
182
+ }
183
+ for (const entry of listing.files) {
184
+ const stem = entry.replace(/\.json$/, '');
185
+ const id = stem.toUpperCase();
186
+ // Filename-derived ids are not injective: on a case-sensitive
187
+ // filesystem `REQ-001.json` and `req-001.json` are two files that
188
+ // both resolve to `REQ-001`. Recording both silently collapsed the
189
+ // second over the first in `byId` (last-write-wins). Refuse instead:
190
+ // the listing is sorted, so the first file on disk wins and the
191
+ // collision is reported rather than absorbed.
192
+ if (tree.byId.has(id) || tree.invalidDocs.has(id)) {
193
+ errors.push(rcfError({
194
+ kind: 'duplicateId',
195
+ message: `Duplicate document id ${id}: rcf/${subdir}/${entry} resolves to an id already loaded from another file in the same directory. Filenames are case-folded to derive the id, so two files whose names differ only by case name one document.`,
196
+ documentId: id,
197
+ filePath: `rcf/${subdir}/${entry}`,
198
+ field: 'filename',
199
+ rule: 'globallyUniqueIds',
200
+ }));
201
+ tree.brokenIds.add(id);
202
+ continue;
203
+ }
204
+ const loaded = await loadDocument({ projectRoot, id });
205
+ if ('kind' in loaded && loaded.kind !== kind) {
206
+ const { doc: invalidDoc, raw: invalidRaw, ...cleanError } = loaded;
207
+ void invalidRaw;
208
+ errors.push(cleanError);
209
+ tree.brokenIds.add(id);
210
+ // B5: schema-invalid docs stay addressable for repair / delete.
211
+ if (loaded.kind === 'validation' && invalidDoc && typeof invalidDoc === 'object') {
212
+ tree.invalidDocs.set(id, { kind, doc: invalidDoc });
213
+ }
214
+ continue;
215
+ }
216
+ recordDoc(tree, id, loaded.doc, loaded.raw, kind);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Walk the tree starting from the manifest. Loads every document, validates
222
+ * against its schema, then inverts child-borne parent references into the
223
+ * computed relationship maps.
224
+ *
225
+ * @param {object} args
226
+ * @param {string} args.projectRoot - absolute path to project root
227
+ * @returns {Promise<WalkResult>}
228
+ */
229
+ export async function walkTree({ projectRoot }) {
230
+ const tree = newTree();
231
+ /** @type {import('../errors/index.js').RcfError[]} */
232
+ const errors = [];
233
+
234
+ // Manifest first.
235
+ const manifest = await loadRootDocument({ projectRoot, kind: 'manifest' });
236
+ if ('kind' in manifest && manifest.kind === 'missingFile') {
237
+ errors.push(manifest);
238
+ return { tree, errors };
239
+ }
240
+ if ('kind' in manifest && manifest.kind !== 'manifest') {
241
+ errors.push(manifest);
242
+ return { tree, errors };
243
+ }
244
+ tree.manifest = manifest.doc;
245
+
246
+ // Root docs (PRD, TAD, BS). Each is optional: a failure surfaces as a
247
+ // structured error but the walk continues over what loaded.
248
+ await loadRoot('prd', { projectRoot, tree, errors });
249
+ await loadRoot('tad', { projectRoot, tree, errors });
250
+ await loadRoot('buildSequence', { projectRoot, tree, errors });
251
+
252
+ // Child kinds. `listSubdirJsonFiles` returns the sorted `*.json` filenames
253
+ // (or an empty list for a missing subdir); load each via `loadDocument`,
254
+ // which schema-validates in the same pass. No parent-list enumeration.
255
+ for (const kind of CHILD_KINDS) {
256
+ await loadChildKind(kind, { projectRoot, tree, errors });
257
+ }
258
+
259
+ // Sort lists deterministically. The testSuite id field is `id`, all
260
+ // others use their kind-specific field.
261
+ tree.requirements = sortById(tree.requirements, 'reqId');
262
+ tree.userStories = sortById(tree.userStories, 'usId');
263
+ tree.tacs = sortById(tree.tacs, 'tacId');
264
+ tree.adrs = sortById(tree.adrs, 'adrId');
265
+ tree.fbsItems = sortById(tree.fbsItems, 'fbsId');
266
+ tree.testSuites = sortById(tree.testSuites, 'id');
267
+ tree.codeNodes = sortById(tree.codeNodes, 'cnId'); // Phase 10
268
+
269
+ // Referential integrity + graph inversion.
270
+ invertGraph(tree);
271
+ collectBrokenReferences(tree, errors);
272
+ // Uniqueness (w-2026-07-28-017). Runs last so a colliding id is
273
+ // reported alongside, not instead of, the reference breakage it causes.
274
+ collectDuplicateIds(tree, errors);
275
+
276
+ return { tree, errors };
277
+ }
278
+
279
+ /**
280
+ * Invert child-borne parent references and cross-links into the computed
281
+ * relationship maps. Only records edges whose parent id resolves to a
282
+ * loaded document of the expected kind -- broken parent/cross-link ids
283
+ * are logged as errors by `collectBrokenReferences`.
284
+ *
285
+ * @param {TreeModel} tree
286
+ */
287
+ function invertGraph(tree) {
288
+ const isKind = (id, kind) => tree.kindById.get(id) === kind;
289
+ const linkParent = (childId, parentId) => {
290
+ if (!childId || !parentId) return;
291
+ tree.parentByChild.set(childId, parentId);
292
+ pushToMap(tree.childrenByParent, parentId, childId);
293
+ };
294
+
295
+ // REQ.prdId -> PRD.
296
+ for (const req of tree.requirements) {
297
+ if (isKind(req.prdId, 'prd')) linkParent(req.reqId, req.prdId);
298
+ }
299
+ // US.reqId -> REQ. (US also has prdId but the load-bearing parent-child
300
+ // edge in the REQ chain is US -> REQ; PRD -> US is transitive.)
301
+ for (const us of tree.userStories) {
302
+ if (isKind(us.reqId, 'req')) linkParent(us.usId, us.reqId);
303
+ for (const tacId of us.tacIds ?? []) {
304
+ if (isKind(tacId, 'tac')) pushToMap(tree.usByTacId, tacId, us.usId);
305
+ }
306
+ }
307
+ // TAC.tadId -> TAD.
308
+ for (const tac of tree.tacs) {
309
+ if (isKind(tac.tadId, 'tad')) linkParent(tac.tacId, tac.tadId);
310
+ }
311
+ // ADR.tadId -> TAD.
312
+ for (const adr of tree.adrs) {
313
+ if (isKind(adr.tadId, 'tad')) linkParent(adr.adrId, adr.tadId);
314
+ }
315
+ // FBS.bsId -> BS.
316
+ // FBS cross-links: FBS.acIds -> AC (fbsByAcId), FBS.dependsOnFbsIds -> FBS
317
+ // (dependentsByFbsId is the inversion: keyed on the dependency's fbsId).
318
+ const acIds = collectAllAcIds(tree);
319
+ for (const fbs of tree.fbsItems) {
320
+ if (isKind(fbs.bsId, 'buildSequence')) linkParent(fbs.fbsId, fbs.bsId);
321
+ for (const acId of fbs.acIds ?? []) {
322
+ if (acIds.has(acId)) pushToMap(tree.fbsByAcId, acId, fbs.fbsId);
323
+ }
324
+ for (const depId of fbs.dependsOnFbsIds ?? []) {
325
+ if (isKind(depId, 'fbs')) pushToMap(tree.dependentsByFbsId, depId, fbs.fbsId);
326
+ }
327
+ }
328
+ // TS.usId -> US (parent-child); TS.acIds -> AC (cross-link).
329
+ for (const ts of tree.testSuites) {
330
+ if (isKind(ts.usId, 'userStory')) linkParent(ts.id, ts.usId);
331
+ for (const acId of ts.acIds ?? []) {
332
+ if (acIds.has(acId)) pushToMap(tree.tsByAcId, acId, ts.id);
333
+ }
334
+ for (const tc of ts.testCases ?? []) {
335
+ if (tc?.acId && acIds.has(tc.acId)) {
336
+ pushToMap(tree.tcsByAcId, tc.acId, { tsId: ts.id, tcId: tc.id });
337
+ }
338
+ }
339
+ }
340
+
341
+ // Populate parentByChild for inline AC / TC ids so CRUD verbs
342
+ // (Phase 4) can resolve an inline id to its owning US / TS via the
343
+ // same map that resolves standalone child docs. `childrenByParent` is
344
+ // deliberately NOT extended: it still names authored child docs only.
345
+ for (const us of tree.userStories) {
346
+ for (const ac of us.acceptanceCriteria ?? []) {
347
+ if (ac?.id) tree.parentByChild.set(ac.id, us.usId);
348
+ }
349
+ }
350
+ for (const ts of tree.testSuites) {
351
+ for (const tc of ts.testCases ?? []) {
352
+ if (tc?.id) tree.parentByChild.set(tc.id, ts.id);
353
+ }
354
+ }
355
+
356
+ // Phase 10 (X2 CodeNode bridge): invert Code Node edges.
357
+ // CN.implementsAcIds -> cnByAcId (keyed on AC, value = implementing CN)
358
+ // CN.dependencies -> dependentsByCnId (keyed on the dependency CN,
359
+ // value = the CN that declares the dependency)
360
+ for (const cn of tree.codeNodes) {
361
+ for (const acId of cn.implementsAcIds ?? []) {
362
+ if (acIds.has(acId)) pushToMap(tree.cnByAcId, acId, cn.cnId);
363
+ }
364
+ for (const depId of cn.dependencies ?? []) {
365
+ if (isKind(depId, 'codeNode')) pushToMap(tree.dependentsByCnId, depId, cn.cnId);
366
+ }
367
+ }
368
+
369
+ // Sort children lists deterministically.
370
+ for (const [k, list] of tree.childrenByParent) {
371
+ tree.childrenByParent.set(k, [...list].sort());
372
+ }
373
+ for (const map of [tree.fbsByAcId, tree.dependentsByFbsId, tree.tsByAcId, tree.usByTacId, tree.cnByAcId, tree.dependentsByCnId]) {
374
+ for (const [k, list] of map) map.set(k, [...list].sort());
375
+ }
376
+ }
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // B5: post-write tree-state simulation
380
+ // ---------------------------------------------------------------------------
381
+
382
+ const ROOT_REL_BY_KIND = {
383
+ prd: 'prd.json',
384
+ tad: 'tad.json',
385
+ buildSequence: 'build-sequence.json',
386
+ };
387
+
388
+ function relPathForEntry(kind, id) {
389
+ const rootRel = ROOT_REL_BY_KIND[kind];
390
+ if (rootRel) return `rcf/${rootRel}`;
391
+ const sub = subdirFor(kind);
392
+ return `rcf/${sub}/${id.toLowerCase()}.json`;
393
+ }
394
+
395
+ /**
396
+ * Compute the error set the walker WOULD report if the given upserts /
397
+ * deletes were applied to the tree - without touching disk. This is the
398
+ * core of the B5 post-write validation semantics: write verbs no longer
399
+ * refuse because the current tree is invalid; they refuse only when the
400
+ * operation would introduce breakage that was not already present.
401
+ *
402
+ * Schema validation and referential integrity are recomputed in memory
403
+ * with exactly the walker's own logic. Load-level errors that cannot be
404
+ * recomputed without disk (parseFailure / missingFile / ioFailure) are
405
+ * carried forward from `preErrors`, minus any attributable to a file
406
+ * this operation touches (e.g. deleting a parse-broken file removes its
407
+ * parseFailure).
408
+ *
409
+ * @param {object} args
410
+ * @param {TreeModel} args.tree - the pre-write tree (walkTree output)
411
+ * @param {import('../errors/index.js').RcfError[]} [args.preErrors]
412
+ * @param {Array<{ kind: string, id: string, doc: object }>} [args.upserts]
413
+ * @param {string[]} [args.deletes] - document ids removed by the operation
414
+ * @returns {import('../errors/index.js').RcfError[]} post-write error set
415
+ */
416
+ export function simulateWriteErrors({ tree, preErrors = [], upserts = [], deletes = [] }) {
417
+ /** @type {Map<string, { kind: string, doc: object }>} */
418
+ const entries = new Map();
419
+ for (const [id, doc] of tree.byId) {
420
+ const kind = tree.kindById.get(id);
421
+ if (!kind) continue;
422
+ entries.set(id, { kind, doc });
423
+ }
424
+ for (const [id, invalid] of tree.invalidDocs ?? []) {
425
+ if (!entries.has(id)) entries.set(id, { kind: invalid.kind, doc: invalid.doc });
426
+ }
427
+
428
+ const touchedPaths = new Set();
429
+ for (const up of upserts) {
430
+ entries.set(up.id, { kind: up.kind, doc: up.doc });
431
+ touchedPaths.add(relPathForEntry(up.kind, up.id));
432
+ }
433
+ for (const id of deletes) {
434
+ const existing = entries.get(id);
435
+ if (existing) {
436
+ touchedPaths.add(relPathForEntry(existing.kind, id));
437
+ entries.delete(id);
438
+ } else {
439
+ // Not loadable at all (e.g. parse-broken file being unlinked):
440
+ // resolve its conventional path so carried-forward load errors drop.
441
+ const resolved = pathForId(id);
442
+ if (resolved) touchedPaths.add(`rcf/${resolved.relPath}`);
443
+ }
444
+ }
445
+
446
+ // Rebuild the tree model in memory, mirroring walkTree: schema-validate
447
+ // every entry, keep valid ones, then invert and integrity-check.
448
+ const post = newTree();
449
+ post.manifest = tree.manifest;
450
+ /** @type {import('../errors/index.js').RcfError[]} */
451
+ const errors = [];
452
+ for (const [id, entry] of entries) {
453
+ const filePath = relPathForEntry(entry.kind, id);
454
+ const validation = validateDocument({ doc: entry.doc, kind: entry.kind, filePath });
455
+ if (validation) {
456
+ errors.push({ ...validation, documentId: id });
457
+ post.brokenIds.add(id);
458
+ continue;
459
+ }
460
+ recordDoc(post, id, entry.doc, tree.rawById.get(id) ?? '', entry.kind);
461
+ if (entry.kind === 'prd') post.prd = entry.doc;
462
+ else if (entry.kind === 'tad') post.tad = entry.doc;
463
+ else if (entry.kind === 'buildSequence') post.bs = entry.doc;
464
+ }
465
+ post.requirements = sortById(post.requirements, 'reqId');
466
+ post.userStories = sortById(post.userStories, 'usId');
467
+ post.tacs = sortById(post.tacs, 'tacId');
468
+ post.adrs = sortById(post.adrs, 'adrId');
469
+ post.fbsItems = sortById(post.fbsItems, 'fbsId');
470
+ post.testSuites = sortById(post.testSuites, 'id');
471
+ post.codeNodes = sortById(post.codeNodes, 'cnId'); // Phase 10
472
+ invertGraph(post);
473
+ collectBrokenReferences(post, errors);
474
+ // w-2026-07-28-017: uniqueness is part of the post-write gate, so a
475
+ // write verb refuses to INTRODUCE a duplicate id (e.g. an `ac create`
476
+ // that would re-issue a taken AC number) while a tree that already
477
+ // carries duplicates stays repairable.
478
+ collectDuplicateIds(post, errors);
479
+
480
+ for (const err of preErrors) {
481
+ if (err.kind === 'parseFailure' || err.kind === 'missingFile' || err.kind === 'ioFailure') {
482
+ if (err.filePath && touchedPaths.has(err.filePath)) continue;
483
+ errors.push(err);
484
+ }
485
+ }
486
+ return errors;
487
+ }
488
+
489
+ /**
490
+ * The errors present post-write that were not present pre-write.
491
+ * Identity is the full structured tuple; message text is included so two
492
+ * distinct breakages on the same document do not collapse.
493
+ *
494
+ * @param {import('../errors/index.js').RcfError[]} preErrors
495
+ * @param {import('../errors/index.js').RcfError[]} postErrors
496
+ * @returns {import('../errors/index.js').RcfError[]}
497
+ */
498
+ export function netNewErrors(preErrors, postErrors) {
499
+ const keyOf = (e) => JSON.stringify([e.kind, e.documentId ?? null, e.filePath ?? null, e.field ?? null, e.rule ?? null, e.message]);
500
+ const pre = new Set(preErrors.map(keyOf));
501
+ return postErrors.filter((e) => !pre.has(keyOf(e)));
502
+ }
503
+
504
+ // Kind lookups use `tree.kindById` (populated at load time). This helper is
505
+ // retained for the small number of call sites that pass a doc without a
506
+ // resolved id, but the load-time map is authoritative.
507
+
508
+ function collectAllAcIds(tree) {
509
+ const acIds = new Set();
510
+ for (const us of tree.userStories) {
511
+ for (const ac of us.acceptanceCriteria ?? []) acIds.add(ac.id);
512
+ }
513
+ return acIds;
514
+ }
515
+
516
+ // ---------------------------------------------------------------------------
517
+ // globallyUniqueIds (w-2026-07-28-017)
518
+ // ---------------------------------------------------------------------------
519
+ //
520
+ // SCOPE: ids are GLOBAL across the whole tree, not scoped per parent.
521
+ //
522
+ // This is forced by the model, not chosen for strictness. `tree.byId` is
523
+ // one flat map; `pathForId()` resolves any id to a file from its prefix
524
+ // alone; `parentByChild` is a single flat map that already covers
525
+ // standalone docs AND inline ACs AND inline TCs; `rcf read <id>`,
526
+ // `rcf trace <id>` and every MCP tool take a bare id with no parent
527
+ // qualifier. An id IS the address. Two things at one address is a bug
528
+ // whatever their parents.
529
+ //
530
+ // Inline ACs are NAMED after their parent (AC-101-1 lives under US-101)
531
+ // and the separate `idPrefixMatchesParent` rule enforces that naming
532
+ // convention -- but naming is not scoping. `AC-101-1` twice under
533
+ // US-101 is two acceptance criteria sharing one address, and until this
534
+ // rule existed the tree validated clean while `collectAllAcIds()`
535
+ // collapsed them into one Set entry and `parentByChild` kept whichever
536
+ // was written last.
537
+ //
538
+ // Cross-kind collision is impossible in practice (the prefixes differ),
539
+ // so a single global namespace costs nothing and is strictly safer than
540
+ // partitioning per kind.
541
+
542
+ /**
543
+ * Every claim on an id in the tree, keyed by normalised id.
544
+ *
545
+ * A document claims:
546
+ * - the id it is filed under (`byId` key: filename-derived for children,
547
+ * field-derived for roots), and
548
+ * - the id its own id-field declares.
549
+ * Those are normally the same string and collapse to one claim. When they
550
+ * diverge (`req-002.json` declaring `"reqId": "REQ-001"`) BOTH ids are
551
+ * occupied by that one file, and the divergence surfaces as a duplicate
552
+ * against whichever other document holds the other id.
553
+ *
554
+ * Inline ACs and TCs each claim their own id.
555
+ *
556
+ * @param {TreeModel} tree
557
+ * @returns {Map<string, Array<{ id: string, filePath: string, field: string, documentId: string }>>}
558
+ */
559
+ function collectIdClaims(tree) {
560
+ /** @type {Map<string, Array<{ id: string, filePath: string, field: string, documentId: string }>>} */
561
+ const claims = new Map();
562
+ const claim = (id, filePath, field, documentId) => {
563
+ const key = normaliseId(id);
564
+ if (key.length === 0) return;
565
+ const list = claims.get(key) ?? [];
566
+ list.push({ id, filePath, field, documentId });
567
+ claims.set(key, list);
568
+ };
569
+
570
+ for (const [id, doc] of tree.byId) {
571
+ const kind = tree.kindById.get(id);
572
+ if (!kind) continue;
573
+ const filePath = relPathForEntry(kind, id);
574
+ const field = ID_FIELD_BY_KIND[kind] ?? 'id';
575
+ const ownId = idOfDoc(doc, kind);
576
+ // Deduplicate the common case where the filed id and the declared id
577
+ // are the same string: one file, one claim.
578
+ const spellings = new Set([id]);
579
+ if (ownId) spellings.add(ownId);
580
+ for (const spelling of spellings) claim(spelling, filePath, field, id);
581
+ }
582
+
583
+ // Schema-invalid documents are excluded from byId but their files still
584
+ // occupy their ids on disk; a valid twin of a broken doc is still a
585
+ // duplicate.
586
+ for (const [id, entry] of tree.invalidDocs ?? []) {
587
+ if (tree.byId.has(id)) continue;
588
+ claim(id, relPathForEntry(entry.kind, id), ID_FIELD_BY_KIND[entry.kind] ?? 'id', id);
589
+ }
590
+
591
+ for (const us of tree.userStories) {
592
+ const filePath = `rcf/user-stories/${(us.usId ?? '').toLowerCase()}.json`;
593
+ for (const [i, ac] of (us.acceptanceCriteria ?? []).entries()) {
594
+ claim(ac?.id, filePath, `acceptanceCriteria[${i}].id`, us.usId);
595
+ }
596
+ }
597
+ for (const ts of tree.testSuites) {
598
+ const filePath = `rcf/test-suites/${(ts.id ?? '').toLowerCase()}.json`;
599
+ for (const [i, tc] of (ts.testCases ?? []).entries()) {
600
+ claim(tc?.id, filePath, `testCases[${i}].id`, ts.id);
601
+ }
602
+ }
603
+
604
+ return claims;
605
+ }
606
+
607
+ /**
608
+ * Uniqueness pass. Any normalised id claimed by more than one location
609
+ * is a `duplicateId` error -- one per claiming location, so a CI log
610
+ * names every file involved rather than making the reader hunt for the
611
+ * other half of the collision.
612
+ *
613
+ * @param {TreeModel} tree
614
+ * @param {import('../errors/index.js').RcfError[]} errors
615
+ */
616
+ function collectDuplicateIds(tree, errors) {
617
+ const claims = collectIdClaims(tree);
618
+ for (const [normalised, list] of claims) {
619
+ if (list.length <= 1) continue;
620
+ const where = list.map((c) => `${c.id} in ${c.filePath} (${c.field})`).join(', ');
621
+ const spellings = new Set(list.map((c) => c.id));
622
+ // Headline with a real id spelling when every claim agrees; fall back
623
+ // to the normalised form only when the spellings genuinely differ,
624
+ // where naming it is the whole explanation.
625
+ const headline = spellings.size === 1 ? [...spellings][0] : normalised;
626
+ const because = spellings.size > 1
627
+ ? ' These spellings differ only by leading zeros, so they name one id.'
628
+ : '';
629
+ for (const c of list) {
630
+ errors.push(rcfError({
631
+ kind: 'duplicateId',
632
+ message: `Duplicate id ${headline}: claimed by ${list.length} locations: ${where}.${because}`,
633
+ documentId: c.documentId,
634
+ filePath: c.filePath,
635
+ field: c.field,
636
+ rule: 'globallyUniqueIds',
637
+ }));
638
+ tree.brokenIds.add(c.id);
639
+ }
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Referential integrity pass. Every parent field and cross-link id must
645
+ * resolve to a loaded doc of the expected kind. Broken links become
646
+ * `brokenReference` errors naming the exact file + field. Inline AC / TC
647
+ * id patterns are checked against their parent's numbering per D7 step 5.
648
+ *
649
+ * @param {TreeModel} tree
650
+ * @param {import('../errors/index.js').RcfError[]} errors
651
+ */
652
+ function collectBrokenReferences(tree, errors) {
653
+ const acIds = collectAllAcIds(tree);
654
+
655
+ const check = ({ docId, docKind, fromField, targetId, expectedKind, filePath, message }) => {
656
+ if (!targetId) return;
657
+ const targetKind = tree.kindById.get(targetId);
658
+ if (targetKind !== expectedKind) {
659
+ errors.push(rcfError({
660
+ kind: 'brokenReference',
661
+ message,
662
+ documentId: docId,
663
+ filePath,
664
+ field: fromField,
665
+ rule: `resolveTo:${expectedKind}`,
666
+ }));
667
+ tree.brokenIds.add(targetId);
668
+ }
669
+ void docKind;
670
+ };
671
+
672
+ for (const req of tree.requirements) {
673
+ check({
674
+ docId: req.reqId,
675
+ docKind: 'req',
676
+ fromField: 'prdId',
677
+ targetId: req.prdId,
678
+ expectedKind: 'prd',
679
+ filePath: `rcf/requirements/${(req.reqId ?? '').toLowerCase()}.json`,
680
+ message: `REQ ${req.reqId} references unknown PRD ${req.prdId}`,
681
+ });
682
+ }
683
+ for (const us of tree.userStories) {
684
+ check({
685
+ docId: us.usId,
686
+ docKind: 'userStory',
687
+ fromField: 'reqId',
688
+ targetId: us.reqId,
689
+ expectedKind: 'req',
690
+ filePath: `rcf/user-stories/${(us.usId ?? '').toLowerCase()}.json`,
691
+ message: `US ${us.usId} references unknown REQ ${us.reqId}`,
692
+ });
693
+ for (const [i, tacId] of (us.tacIds ?? []).entries()) {
694
+ check({
695
+ docId: us.usId,
696
+ docKind: 'userStory',
697
+ fromField: `tacIds[${i}]`,
698
+ targetId: tacId,
699
+ expectedKind: 'tac',
700
+ filePath: `rcf/user-stories/${(us.usId ?? '').toLowerCase()}.json`,
701
+ message: `US ${us.usId} references unknown TAC ${tacId}`,
702
+ });
703
+ }
704
+ // Inline AC id pattern check.
705
+ const usSuffix = us.usId?.match(/^US-(\d{3,})$/)?.[1];
706
+ if (usSuffix) {
707
+ for (const ac of us.acceptanceCriteria ?? []) {
708
+ const m = String(ac.id ?? '').match(/^AC-(\d{3,})(?:-\d+)?$/);
709
+ if (m && m[1] !== usSuffix) {
710
+ errors.push(rcfError({
711
+ kind: 'brokenReference',
712
+ message: `Inline AC ${ac.id} under US ${us.usId} has a mismatched numeric prefix`,
713
+ documentId: us.usId,
714
+ filePath: `rcf/user-stories/${us.usId.toLowerCase()}.json`,
715
+ field: `acceptanceCriteria.id:${ac.id}`,
716
+ rule: 'idPrefixMatchesParent',
717
+ }));
718
+ }
719
+ }
720
+ }
721
+ }
722
+ for (const tac of tree.tacs) {
723
+ check({
724
+ docId: tac.tacId,
725
+ docKind: 'tac',
726
+ fromField: 'tadId',
727
+ targetId: tac.tadId,
728
+ expectedKind: 'tad',
729
+ filePath: `rcf/tacs/${(tac.tacId ?? '').toLowerCase()}.json`,
730
+ message: `TAC ${tac.tacId} references unknown TAD ${tac.tadId}`,
731
+ });
732
+ }
733
+ for (const adr of tree.adrs) {
734
+ check({
735
+ docId: adr.adrId,
736
+ docKind: 'adr',
737
+ fromField: 'tadId',
738
+ targetId: adr.tadId,
739
+ expectedKind: 'tad',
740
+ filePath: `rcf/adrs/${(adr.adrId ?? '').toLowerCase()}.json`,
741
+ message: `ADR ${adr.adrId} references unknown TAD ${adr.tadId}`,
742
+ });
743
+ }
744
+
745
+ // FBS parent + cross-link + dependency checks. `buildOrder` uniqueness
746
+ // per bsId is enforced structurally: duplicate buildOrder values inside
747
+ // one BS are a validation error (spec §D6).
748
+ const buildOrderCounts = new Map();
749
+ for (const fbs of tree.fbsItems) {
750
+ check({
751
+ docId: fbs.fbsId,
752
+ docKind: 'fbs',
753
+ fromField: 'bsId',
754
+ targetId: fbs.bsId,
755
+ expectedKind: 'buildSequence',
756
+ filePath: `rcf/fbs/${(fbs.fbsId ?? '').toLowerCase()}.json`,
757
+ message: `FBS ${fbs.fbsId} references unknown BS ${fbs.bsId}`,
758
+ });
759
+ for (const [i, acId] of (fbs.acIds ?? []).entries()) {
760
+ if (!acIds.has(acId)) {
761
+ errors.push(rcfError({
762
+ kind: 'brokenReference',
763
+ message: `FBS ${fbs.fbsId} references unknown acceptance criterion ${acId}`,
764
+ documentId: fbs.fbsId,
765
+ filePath: `rcf/fbs/${(fbs.fbsId ?? '').toLowerCase()}.json`,
766
+ field: `acIds[${i}]`,
767
+ rule: 'resolveTo:ac',
768
+ }));
769
+ tree.brokenIds.add(acId);
770
+ }
771
+ }
772
+ for (const [i, depId] of (fbs.dependsOnFbsIds ?? []).entries()) {
773
+ check({
774
+ docId: fbs.fbsId,
775
+ docKind: 'fbs',
776
+ fromField: `dependsOnFbsIds[${i}]`,
777
+ targetId: depId,
778
+ expectedKind: 'fbs',
779
+ filePath: `rcf/fbs/${(fbs.fbsId ?? '').toLowerCase()}.json`,
780
+ message: `FBS ${fbs.fbsId} depends on unknown FBS ${depId}`,
781
+ });
782
+ }
783
+ const ctx = fbs.contextRequirements ?? {};
784
+ for (const [i, tacId] of (ctx.tacIds ?? []).entries()) {
785
+ check({
786
+ docId: fbs.fbsId,
787
+ docKind: 'fbs',
788
+ fromField: `contextRequirements.tacIds[${i}]`,
789
+ targetId: tacId,
790
+ expectedKind: 'tac',
791
+ filePath: `rcf/fbs/${(fbs.fbsId ?? '').toLowerCase()}.json`,
792
+ message: `FBS ${fbs.fbsId} references unknown TAC ${tacId}`,
793
+ });
794
+ }
795
+ for (const [i, adrId] of (ctx.adrIds ?? []).entries()) {
796
+ check({
797
+ docId: fbs.fbsId,
798
+ docKind: 'fbs',
799
+ fromField: `contextRequirements.adrIds[${i}]`,
800
+ targetId: adrId,
801
+ expectedKind: 'adr',
802
+ filePath: `rcf/fbs/${(fbs.fbsId ?? '').toLowerCase()}.json`,
803
+ message: `FBS ${fbs.fbsId} references unknown ADR ${adrId}`,
804
+ });
805
+ }
806
+ if (fbs.bsId && typeof fbs.buildOrder === 'number') {
807
+ const key = `${fbs.bsId}::${fbs.buildOrder}`;
808
+ pushToMap(buildOrderCounts, key, fbs.fbsId);
809
+ }
810
+ }
811
+ for (const [key, ids] of buildOrderCounts) {
812
+ if (ids.length <= 1) continue;
813
+ const [bsId, order] = key.split('::');
814
+ for (const fbsId of ids) {
815
+ errors.push(rcfError({
816
+ kind: 'brokenReference',
817
+ message: `Duplicate buildOrder ${order} within ${bsId} on FBS ${fbsId}`,
818
+ documentId: fbsId,
819
+ filePath: `rcf/fbs/${fbsId.toLowerCase()}.json`,
820
+ field: 'buildOrder',
821
+ rule: 'uniqueBuildOrderPerBs',
822
+ }));
823
+ }
824
+ }
825
+
826
+ // Test Suite parent + cross-link checks.
827
+ for (const ts of tree.testSuites) {
828
+ check({
829
+ docId: ts.id,
830
+ docKind: 'testSuite',
831
+ fromField: 'usId',
832
+ targetId: ts.usId,
833
+ expectedKind: 'userStory',
834
+ filePath: `rcf/test-suites/${(ts.id ?? '').toLowerCase()}.json`,
835
+ message: `TS ${ts.id} references unknown US ${ts.usId}`,
836
+ });
837
+ for (const [i, acId] of (ts.acIds ?? []).entries()) {
838
+ if (!acIds.has(acId)) {
839
+ errors.push(rcfError({
840
+ kind: 'brokenReference',
841
+ message: `TS ${ts.id} references unknown acceptance criterion ${acId}`,
842
+ documentId: ts.id,
843
+ filePath: `rcf/test-suites/${(ts.id ?? '').toLowerCase()}.json`,
844
+ field: `acIds[${i}]`,
845
+ rule: 'resolveTo:ac',
846
+ }));
847
+ tree.brokenIds.add(acId);
848
+ }
849
+ }
850
+ // Inline TC id pattern check: `TC-<TS-suffix>-<slug>`.
851
+ const tsSuffix = ts.id?.match(/^TS-(\d{3})$/)?.[1];
852
+ if (tsSuffix) {
853
+ for (const tc of ts.testCases ?? []) {
854
+ const m = String(tc.id ?? '').match(/^TC-(\d{3})-[a-z0-9-]+$/);
855
+ if (m && m[1] !== tsSuffix) {
856
+ errors.push(rcfError({
857
+ kind: 'brokenReference',
858
+ message: `Inline TC ${tc.id} under TS ${ts.id} has a mismatched numeric prefix`,
859
+ documentId: ts.id,
860
+ filePath: `rcf/test-suites/${ts.id.toLowerCase()}.json`,
861
+ field: `testCases.id:${tc.id}`,
862
+ rule: 'idPrefixMatchesParent',
863
+ }));
864
+ }
865
+ }
866
+ }
867
+ }
868
+
869
+ // Phase 10 (X2 CodeNode bridge): Code Node cross-link integrity.
870
+ // implementsAcIds -> AC (must resolve to a known acceptance criterion)
871
+ // dependencies -> CN (must resolve to a known code node)
872
+ for (const cn of tree.codeNodes) {
873
+ for (const [i, acId] of (cn.implementsAcIds ?? []).entries()) {
874
+ if (!acIds.has(acId)) {
875
+ errors.push(rcfError({
876
+ kind: 'brokenReference',
877
+ message: `CN ${cn.cnId} references unknown acceptance criterion ${acId}`,
878
+ documentId: cn.cnId,
879
+ filePath: `rcf/code-nodes/${(cn.cnId ?? '').toLowerCase()}.json`,
880
+ field: `implementsAcIds[${i}]`,
881
+ rule: 'resolveTo:ac',
882
+ }));
883
+ tree.brokenIds.add(acId);
884
+ }
885
+ }
886
+ for (const [i, depId] of (cn.dependencies ?? []).entries()) {
887
+ check({
888
+ docId: cn.cnId,
889
+ docKind: 'codeNode',
890
+ fromField: `dependencies[${i}]`,
891
+ targetId: depId,
892
+ expectedKind: 'codeNode',
893
+ filePath: `rcf/code-nodes/${(cn.cnId ?? '').toLowerCase()}.json`,
894
+ message: `CN ${cn.cnId} depends on unknown code node ${depId}`,
895
+ });
896
+ }
897
+ }
898
+ }