rcf-lite 0.28.2 → 0.28.4

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 (196) hide show
  1. package/CHANGELOG.md +211 -0
  2. package/bin/rcf.js +4 -0
  3. package/fixtures/canary-manifest.json +9 -9
  4. package/package.json +1 -1
  5. package/rcf/adrs/adr-4120-canonical-json-hashing.json +30 -0
  6. package/rcf/adrs/adr-4121-define-sidecars.json +30 -0
  7. package/rcf/adrs/adr-4122-define-gates-blocking-and-optout.json +30 -0
  8. package/rcf/adrs/adr-4123-readiness-single-object.json +30 -0
  9. package/rcf/code-nodes/cn-700.json +23 -0
  10. package/rcf/code-nodes/cn-701.json +14 -0
  11. package/rcf/code-nodes/cn-702.json +14 -0
  12. package/rcf/code-nodes/cn-703.json +16 -0
  13. package/rcf/code-nodes/cn-720.json +15 -0
  14. package/rcf/code-nodes/cn-721.json +15 -0
  15. package/rcf/code-nodes/cn-722.json +15 -0
  16. package/rcf/code-nodes/cn-723.json +18 -0
  17. package/rcf/code-nodes/cn-724.json +14 -0
  18. package/rcf/code-nodes/cn-725.json +21 -0
  19. package/rcf/code-nodes/cn-726.json +17 -0
  20. package/rcf/code-nodes/cn-727.json +14 -0
  21. package/rcf/code-nodes/cn-728.json +21 -0
  22. package/rcf/code-nodes/cn-729.json +18 -0
  23. package/rcf/code-nodes/cn-730.json +14 -0
  24. package/rcf/fbs/fbs-190.json +37 -0
  25. package/rcf/fbs/fbs-191.json +34 -0
  26. package/rcf/fbs/fbs-192.json +17 -0
  27. package/rcf/fbs/fbs-193.json +37 -0
  28. package/rcf/fbs/fbs-194.json +35 -0
  29. package/rcf/fbs/fbs-195.json +37 -0
  30. package/rcf/fbs/fbs-196.json +37 -0
  31. package/rcf/fbs/fbs-197.json +41 -0
  32. package/rcf/requirements/req-001.json +2 -1
  33. package/rcf/requirements/req-002.json +2 -1
  34. package/rcf/requirements/req-003.json +2 -1
  35. package/rcf/requirements/req-004.json +2 -1
  36. package/rcf/requirements/req-005.json +2 -1
  37. package/rcf/requirements/req-006.json +2 -1
  38. package/rcf/requirements/req-007.json +2 -1
  39. package/rcf/requirements/req-008.json +2 -1
  40. package/rcf/requirements/req-009.json +2 -1
  41. package/rcf/requirements/req-010.json +5 -2
  42. package/rcf/requirements/req-011.json +2 -1
  43. package/rcf/requirements/req-012.json +2 -1
  44. package/rcf/requirements/req-013.json +2 -1
  45. package/rcf/requirements/req-014.json +2 -1
  46. package/rcf/requirements/req-015.json +2 -1
  47. package/rcf/requirements/req-016.json +5 -2
  48. package/rcf/requirements/req-017.json +6 -2
  49. package/rcf/requirements/req-018.json +5 -2
  50. package/rcf/requirements/req-019.json +5 -2
  51. package/rcf/requirements/req-020.json +5 -2
  52. package/rcf/requirements/req-021.json +5 -2
  53. package/rcf/requirements/req-022.json +5 -2
  54. package/rcf/requirements/req-023.json +6 -4
  55. package/rcf/requirements/req-024.json +5 -2
  56. package/rcf/requirements/req-025.json +5 -2
  57. package/rcf/requirements/req-026.json +5 -2
  58. package/rcf/requirements/req-027.json +5 -2
  59. package/rcf/requirements/req-028.json +5 -2
  60. package/rcf/requirements/req-029.json +5 -2
  61. package/rcf/requirements/req-030.json +5 -2
  62. package/rcf/requirements/req-040.json +5 -2
  63. package/rcf/requirements/req-041.json +5 -2
  64. package/rcf/requirements/req-042.json +5 -2
  65. package/rcf/requirements/req-043.json +5 -2
  66. package/rcf/requirements/req-050.json +5 -2
  67. package/rcf/requirements/req-051.json +5 -2
  68. package/rcf/requirements/req-052.json +5 -2
  69. package/rcf/requirements/req-053.json +5 -2
  70. package/rcf/requirements/req-054.json +5 -2
  71. package/rcf/requirements/req-060.json +5 -2
  72. package/rcf/requirements/req-061.json +5 -2
  73. package/rcf/requirements/req-062.json +5 -2
  74. package/rcf/requirements/req-063.json +5 -2
  75. package/rcf/requirements/req-070.json +5 -2
  76. package/rcf/requirements/req-071.json +5 -2
  77. package/rcf/requirements/req-072.json +5 -2
  78. package/rcf/requirements/req-073.json +5 -2
  79. package/rcf/requirements/req-074.json +5 -2
  80. package/rcf/requirements/req-075.json +5 -2
  81. package/rcf/requirements/req-076.json +5 -2
  82. package/rcf/requirements/req-077.json +5 -2
  83. package/rcf/requirements/req-080.json +5 -2
  84. package/rcf/requirements/req-081.json +5 -2
  85. package/rcf/requirements/req-082.json +5 -2
  86. package/rcf/requirements/req-083.json +5 -2
  87. package/rcf/requirements/req-084.json +5 -2
  88. package/rcf/requirements/req-085.json +5 -2
  89. package/rcf/requirements/req-086.json +5 -2
  90. package/rcf/requirements/req-090.json +5 -2
  91. package/rcf/requirements/req-091.json +5 -2
  92. package/rcf/requirements/req-092.json +5 -2
  93. package/rcf/requirements/req-093.json +5 -2
  94. package/rcf/requirements/req-094.json +5 -2
  95. package/rcf/requirements/req-100.json +5 -2
  96. package/rcf/requirements/req-101.json +5 -2
  97. package/rcf/requirements/req-102.json +5 -2
  98. package/rcf/requirements/req-103.json +5 -2
  99. package/rcf/requirements/req-104.json +5 -2
  100. package/rcf/requirements/req-110.json +5 -2
  101. package/rcf/requirements/req-111.json +5 -2
  102. package/rcf/requirements/req-112.json +5 -2
  103. package/rcf/requirements/req-113.json +5 -2
  104. package/rcf/requirements/req-114.json +5 -2
  105. package/rcf/requirements/req-115.json +5 -2
  106. package/rcf/requirements/req-120.json +5 -2
  107. package/rcf/requirements/req-121.json +5 -2
  108. package/rcf/requirements/req-122.json +5 -2
  109. package/rcf/requirements/req-123.json +5 -2
  110. package/rcf/requirements/req-124.json +5 -2
  111. package/rcf/requirements/req-125.json +5 -2
  112. package/rcf/requirements/req-130.json +6 -2
  113. package/rcf/requirements/req-131.json +6 -2
  114. package/rcf/requirements/req-132.json +6 -2
  115. package/rcf/requirements/req-133.json +6 -2
  116. package/rcf/requirements/req-134.json +6 -2
  117. package/rcf/requirements/req-140.json +5 -2
  118. package/rcf/requirements/req-145.json +5 -2
  119. package/rcf/requirements/req-150.json +6 -2
  120. package/rcf/requirements/req-151.json +6 -2
  121. package/rcf/requirements/req-152.json +6 -2
  122. package/rcf/requirements/req-153.json +6 -2
  123. package/rcf/requirements/req-154.json +5 -2
  124. package/rcf/requirements/req-155.json +4 -1
  125. package/rcf/requirements/req-156.json +4 -1
  126. package/rcf/requirements/req-157.json +4 -1
  127. package/rcf/requirements/req-158.json +4 -1
  128. package/rcf/requirements/req-159.json +4 -1
  129. package/rcf/requirements/req-160.json +4 -1
  130. package/rcf/requirements/req-161.json +4 -1
  131. package/rcf/requirements/req-162.json +4 -1
  132. package/rcf/requirements/req-163.json +5 -2
  133. package/rcf/requirements/req-164.json +5 -2
  134. package/rcf/requirements/req-165.json +5 -2
  135. package/rcf/requirements/req-170.json +33 -0
  136. package/rcf/requirements/req-171.json +44 -0
  137. package/rcf/requirements/req-172.json +25 -0
  138. package/rcf/requirements/req-173.json +25 -0
  139. package/rcf/requirements/req-174.json +25 -0
  140. package/rcf/requirements/req-175.json +25 -0
  141. package/rcf/tacs/tac-4120-define-delta-and-freeze.json +21 -0
  142. package/rcf/tacs/tac-4121-define-ledgers.json +23 -0
  143. package/rcf/tacs/tac-4122-define-gates.json +25 -0
  144. package/rcf/tacs/tac-4123-define-readiness.json +25 -0
  145. package/rcf/test-suites/ts-204.json +30 -0
  146. package/rcf/test-suites/ts-205.json +22 -0
  147. package/rcf/test-suites/ts-206.json +22 -0
  148. package/rcf/test-suites/ts-207.json +22 -0
  149. package/rcf/test-suites/ts-208.json +22 -0
  150. package/rcf/test-suites/ts-209.json +22 -0
  151. package/rcf/test-suites/ts-210.json +22 -0
  152. package/rcf/test-suites/ts-211.json +22 -0
  153. package/rcf/test-suites/ts-212.json +22 -0
  154. package/rcf/test-suites/ts-213.json +50 -0
  155. package/rcf/test-suites/ts-214.json +74 -0
  156. package/rcf/test-suites/ts-215.json +67 -0
  157. package/rcf/test-suites/ts-216.json +78 -0
  158. package/rcf/test-suites/ts-217.json +62 -0
  159. package/rcf/user-stories/us-15601.json +4 -4
  160. package/rcf/user-stories/us-17001.json +27 -0
  161. package/rcf/user-stories/us-17002.json +22 -0
  162. package/rcf/user-stories/us-17003.json +22 -0
  163. package/rcf/user-stories/us-17004.json +22 -0
  164. package/rcf/user-stories/us-17005.json +22 -0
  165. package/rcf/user-stories/us-17006.json +21 -0
  166. package/rcf/user-stories/us-17007.json +21 -0
  167. package/rcf/user-stories/us-17008.json +22 -0
  168. package/rcf/user-stories/us-17101.json +22 -0
  169. package/rcf/user-stories/us-17102.json +22 -0
  170. package/rcf/user-stories/us-17201.json +38 -0
  171. package/rcf/user-stories/us-17301.json +38 -0
  172. package/rcf/user-stories/us-17401.json +58 -0
  173. package/rcf/user-stories/us-17501.json +48 -0
  174. package/releases/releases.yaml +21 -1
  175. package/src/cli/feedback.js +126 -10
  176. package/src/cli/help.js +11 -0
  177. package/src/cli/ledger.js +399 -0
  178. package/src/cli/readiness.js +280 -0
  179. package/src/core/store/writer.js +86 -6
  180. package/src/define/freeze-record.js +232 -0
  181. package/src/define/ledgers.js +513 -0
  182. package/src/feedback/redact.js +105 -19
  183. package/src/query/delta.js +276 -0
  184. package/src/query/gates.js +832 -0
  185. package/src/query/index.js +36 -0
  186. package/src/query/readiness.js +333 -0
  187. package/src/server/index.js +37 -0
  188. package/src/server/routes.js +65 -3
  189. package/src/view/doc-renderers/helpers.js +2 -2
  190. package/src/view/doc-renderers/req.js +10 -2
  191. package/src/view/doc-renderers/user-story.js +10 -5
  192. package/src/view/html-page.js +347 -28
  193. package/src/view/index.js +14 -1
  194. package/src/view/live-client.js +22 -2
  195. package/src/view/product-map.js +751 -0
  196. package/src/view/style.css +378 -0
@@ -4,6 +4,42 @@
4
4
  export { computeCoverage, classifyCoverageScope } from './coverage.js';
5
5
  export { computeTrace, kindOf } from './trace.js';
6
6
  export { computeImpact, labelFor } from './impact.js';
7
+ // Detection model (REQ-172, proposal 2026-09-22 §2.2 v3). Pure hashes
8
+ // over the parsed tree; every downstream DEFINE gate reads this delta.
9
+ export {
10
+ canonicaliseJson,
11
+ hashDocument,
12
+ computeTreeHash,
13
+ computeDelta,
14
+ } from './delta.js';
15
+ // DEFINE stage gates (REQ-174; proposal §2.4, §3.2 v3). One check
16
+ // function per stage; foldState + parseAcClass helpers.
17
+ export {
18
+ STAGE_ORDER,
19
+ STAGE_GATES,
20
+ STAGE_SHORT_NAMES,
21
+ STAGE_ALIASES,
22
+ INTERFACE_KINDS,
23
+ stagePolicy,
24
+ parseAcClass,
25
+ foldState,
26
+ runStage,
27
+ checkD1Brief,
28
+ checkD2Skeleton,
29
+ checkD3Shapes,
30
+ checkD4Stories,
31
+ checkD5Crosscut,
32
+ checkD6Consistency,
33
+ checkD7Decisions,
34
+ checkD8Freeze,
35
+ } from './gates.js';
36
+ // Readiness composer (REQ-175; proposal §2.4, §6 v3).
37
+ export {
38
+ computeReadiness,
39
+ deriveNextAction,
40
+ shortHash,
41
+ formatTreeLine,
42
+ } from './readiness.js';
7
43
  export { formatTable } from './formatters/table.js';
8
44
  export { formatJson } from './formatters/json.js';
9
45
  export { formatMermaid } from './formatters/mermaid.js';
@@ -0,0 +1,333 @@
1
+ // Readiness compute (REQ-175; proposal 2026-09-22 §2.4, §6 v3).
2
+ //
3
+ // One pure composer over the DEFINE detection primitives:
4
+ // 1. `computeDelta` (slice 1) -> changed / added / removed / briefSince
5
+ // 2. `computeImpact` per id in changed union added -> fan-out
6
+ // 3. Eight gate check functions from `./gates.js` -> stage[]
7
+ // 4. `computeCoverage` tree-wide plus per REQ ancestor of the delta
8
+ // 5. Open decisions from the decisions ledger
9
+ // 6. Fold to `freezeable` (every stage passed / acknowledged /
10
+ // notApplicable) and `nextAction` (first failing stage + check)
11
+ //
12
+ // Return shape mirrors proposal §2.4 exactly. The CLI (rcf define
13
+ // readiness) and the viewer's Readiness tab (slice 5) render the same
14
+ // object; the freeze verb (slice 3) refuses on any failing stage.
15
+
16
+ import { computeQueue } from '../build/queue.js';
17
+ import { computeCoverage } from './coverage.js';
18
+ import { computeDelta } from './delta.js';
19
+ import { computeImpact } from './impact.js';
20
+ import {
21
+ STAGE_GATES,
22
+ STAGE_ORDER,
23
+ STAGE_SHORT_NAMES,
24
+ checkD1Brief,
25
+ checkD2Skeleton,
26
+ checkD3Shapes,
27
+ checkD4Stories,
28
+ checkD5Crosscut,
29
+ checkD6Consistency,
30
+ checkD7Decisions,
31
+ checkD8Freeze,
32
+ } from './gates.js';
33
+
34
+ /**
35
+ * @typedef {object} ReadinessTree
36
+ * @property {boolean} frozen
37
+ * @property {string | null} frozenAt
38
+ * @property {string | null} treeHash
39
+ * @property {string} currentTreeHash
40
+ * @property {string | null} buildAt fbsId at the queue head, or null
41
+ * @property {number} fbsTotal
42
+ */
43
+
44
+ /**
45
+ * @typedef {object} ReadinessDelta
46
+ * @property {string[]} changed
47
+ * @property {string[]} added
48
+ * @property {string[]} removed
49
+ * @property {number[]} briefSince
50
+ * @property {import('./impact.js').ImpactNode[]} impacted
51
+ * @property {string[]} impactedFbs
52
+ */
53
+
54
+ /**
55
+ * @typedef {object} StageResult
56
+ * @property {string} stage
57
+ * @property {string} gate
58
+ * @property {'passed'|'failing'|'acknowledged'|'notApplicable'} state
59
+ * @property {Array<{ name: string, ok: boolean, over: 'delta'|'tree', pass: number, total: number, failing: Array<{ id: string, why: string }> }>} checks
60
+ * @property {string} [reason]
61
+ */
62
+
63
+ /**
64
+ * @typedef {object} NextAction
65
+ * @property {string} stage
66
+ * @property {string} check
67
+ * @property {string[]} ids
68
+ * @property {string} command
69
+ */
70
+
71
+ /**
72
+ * @typedef {object} ReadinessResult
73
+ * @property {ReadinessTree} tree
74
+ * @property {ReadinessDelta} delta
75
+ * @property {StageResult[]} stages
76
+ * @property {NextAction | null} nextAction
77
+ * @property {{ tree: import('./coverage.js').CoverageResult, delta: import('./coverage.js').CoverageResult[] }} coverage
78
+ * @property {Array<Record<string, unknown>>} decisions
79
+ * @property {boolean} freezeable
80
+ */
81
+
82
+ /**
83
+ * Compute the readiness object.
84
+ *
85
+ * Pure over its inputs. Callers supply the ledgers bundle, freeze
86
+ * record, testPointers map (resolveTestPointers output) and optional
87
+ * profile switches; nothing else touches the filesystem.
88
+ *
89
+ * @param {import('#core/store/walker.js').TreeModel} tree
90
+ * @param {object} args
91
+ * @param {import('./delta.js').FreezeRecord | null | undefined} [args.freeze]
92
+ * @param {import('./delta.js').LedgerBundle | undefined} [args.ledgers]
93
+ * @param {{ skipReviewFor?: string } | undefined} [args.profile]
94
+ * @param {string | null | undefined} [args.profileText] rcf/.identity/profile.md contents
95
+ * @param {Map<string, unknown> | undefined} [args.testPointers]
96
+ * @param {Array<unknown> | undefined} [args.validateErrors] tree-wide validate + walker errors
97
+ * @returns {ReadinessResult}
98
+ */
99
+ export function computeReadiness(tree, args = {}) {
100
+ const {
101
+ freeze = null,
102
+ ledgers = {},
103
+ profile = undefined,
104
+ profileText = null,
105
+ testPointers = undefined,
106
+ validateErrors = [],
107
+ } = args;
108
+
109
+ // 1. Delta.
110
+ const delta = computeDelta(tree, freeze, ledgers);
111
+
112
+ // 2. Fan-out via computeImpact per id in changed union added.
113
+ // Standalone-document ids only; ledger:<name> entries have no fan-out.
114
+ const pivotIds = [
115
+ ...delta.changed.filter((id) => !id.startsWith('ledger:')),
116
+ ...delta.added.filter((id) => !id.startsWith('ledger:')),
117
+ ];
118
+ const pivotSet = new Set(pivotIds);
119
+ /** @type {import('./impact.js').ImpactNode[]} */
120
+ const impactedNodes = [];
121
+ const impactedSeen = new Set();
122
+ const impactedFbsSeen = new Set();
123
+ // Skip fan-out when the tree is unfrozen: the delta IS the whole tree
124
+ // (proposal §2.2 "before the first freeze the delta is the whole
125
+ // tree"), so a per-id fan-out is quadratic for no signal.
126
+ const runFanOut = delta.frozen === true;
127
+ if (runFanOut) {
128
+ for (const id of pivotIds) {
129
+ const impact = computeImpact(tree, { id });
130
+ if (!impact.found || !Array.isArray(impact.nodes)) continue;
131
+ for (const node of impact.nodes) {
132
+ if (node.role === 'pivot') continue;
133
+ if (pivotSet.has(node.id)) continue;
134
+ if (!impactedSeen.has(node.id)) {
135
+ impactedSeen.add(node.id);
136
+ impactedNodes.push(node);
137
+ }
138
+ if (node.kind === 'fbs' && node.actionNeeded === 're-execute') {
139
+ impactedFbsSeen.add(node.id);
140
+ }
141
+ }
142
+ }
143
+ }
144
+ const impactedFbs = [...impactedFbsSeen].sort();
145
+
146
+ // 3. Compose scope = changed union added union impacted (dedup, sort).
147
+ const scopeSet = new Set([
148
+ ...delta.changed.filter((id) => !id.startsWith('ledger:')),
149
+ ...delta.added.filter((id) => !id.startsWith('ledger:')),
150
+ ...impactedSeen,
151
+ ]);
152
+
153
+ // 4. Coverage: tree-wide plus per REQ ancestor of the delta.
154
+ const coverageTree = computeCoverage(tree, { testPointers, strict: true });
155
+ const deltaReqIds = collectDeltaReqAncestors(tree, pivotIds);
156
+ const coverageDelta = [];
157
+ for (const reqId of deltaReqIds) {
158
+ coverageDelta.push(computeCoverage(tree, { testPointers, strict: true, scopeId: reqId }));
159
+ }
160
+
161
+ // 5. Run the eight gate check functions in order. D8 reads the
162
+ // prior stage states, so compose sequentially.
163
+ /** @type {StageResult[]} */
164
+ const stages = [];
165
+ const currentTreeHash = delta.currentTreeHash;
166
+ /** @type {import('./gates.js').StageContext} */
167
+ const ctx = {
168
+ tree,
169
+ ledgers,
170
+ delta,
171
+ freeze,
172
+ scope: scopeSet,
173
+ validateErrors,
174
+ // Prefer the caller-supplied probe count when it is passed; falls
175
+ // back to the ledger scan inside checkD6Consistency.
176
+ probeOpenCount: undefined,
177
+ coverageTree,
178
+ profileText,
179
+ profile,
180
+ currentTreeHash,
181
+ priorStages: undefined,
182
+ };
183
+ stages.push(checkD1Brief(ctx));
184
+ stages.push(checkD2Skeleton(ctx));
185
+ stages.push(checkD3Shapes(ctx));
186
+ stages.push(checkD4Stories(ctx));
187
+ stages.push(checkD5Crosscut(ctx));
188
+ stages.push(checkD6Consistency(ctx));
189
+ stages.push(checkD7Decisions(ctx));
190
+ stages.push(checkD8Freeze({ ...ctx, priorStages: stages.map((s) => ({ stage: s.stage, state: s.state })) }));
191
+
192
+ // 6. Open decisions in the #241 format for the surface consumer.
193
+ const decisions = /** @type {any} */ (ledgers?.decisions);
194
+ const decisionEntries = Array.isArray(decisions?.decisions) ? decisions.decisions : [];
195
+ const openDecisions = decisionEntries.filter((d) => d?.status === 'open');
196
+
197
+ // 7. Fold freezeable + nextAction.
198
+ const freezeable = stages.every((s) => s.state === 'passed' || s.state === 'acknowledged' || s.state === 'notApplicable');
199
+ const nextAction = freezeable ? null : deriveNextAction(stages);
200
+
201
+ // 8. Build the tree summary line inputs.
202
+ const queue = computeQueue(tree);
203
+ const fbsTotal = (tree.fbsItems ?? []).length;
204
+
205
+ /** @type {ReadinessResult} */
206
+ return {
207
+ tree: {
208
+ frozen: delta.frozen,
209
+ frozenAt: delta.frozenAt,
210
+ treeHash: delta.treeHash,
211
+ currentTreeHash: delta.currentTreeHash,
212
+ buildAt: queue.nextActionable ?? null,
213
+ fbsTotal,
214
+ },
215
+ delta: {
216
+ changed: delta.changed,
217
+ added: delta.added,
218
+ removed: delta.removed,
219
+ briefSince: delta.briefSince,
220
+ impacted: impactedNodes,
221
+ impactedFbs,
222
+ },
223
+ stages,
224
+ nextAction,
225
+ coverage: { tree: coverageTree, delta: coverageDelta },
226
+ decisions: openDecisions,
227
+ freezeable,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Walk back from every delta pivot to its REQ ancestor and collect
233
+ * the unique REQ ids. The walker records parent-child edges under
234
+ * `parentByChild`; the walk terminates at the first REQ hit.
235
+ *
236
+ * @param {import('#core/store/walker.js').TreeModel} tree
237
+ * @param {string[]} pivotIds
238
+ * @returns {string[]}
239
+ */
240
+ function collectDeltaReqAncestors(tree, pivotIds) {
241
+ const seen = new Set();
242
+ for (const id of pivotIds) {
243
+ let cursor = id;
244
+ let hops = 0;
245
+ while (cursor && hops < 32) {
246
+ hops += 1;
247
+ const kind = tree.kindById?.get(cursor);
248
+ if (kind === 'req') {
249
+ seen.add(cursor);
250
+ break;
251
+ }
252
+ // Some kinds carry an explicit reqId; use that when the parent
253
+ // chain skips the REQ (e.g. a TC leaf whose parent is a TS).
254
+ const doc = tree.byId?.get(cursor);
255
+ if (doc && typeof doc === 'object' && typeof /** @type {any} */ (doc).reqId === 'string') {
256
+ seen.add(/** @type {any} */ (doc).reqId);
257
+ break;
258
+ }
259
+ const parent = tree.parentByChild?.get(cursor);
260
+ if (!parent) break;
261
+ cursor = parent;
262
+ }
263
+ }
264
+ return [...seen].sort();
265
+ }
266
+
267
+ /**
268
+ * Pick the first failing stage in D1..D8 order and the first failing
269
+ * check inside it, then compose a `NextAction`.
270
+ *
271
+ * @param {StageResult[]} stages
272
+ * @returns {NextAction | null}
273
+ */
274
+ export function deriveNextAction(stages) {
275
+ const byStage = new Map(stages.map((s) => [s.stage, s]));
276
+ for (const stageId of STAGE_ORDER) {
277
+ const s = byStage.get(stageId);
278
+ if (!s || s.state !== 'failing') continue;
279
+ // First failing check; tie-break by the largest failing list so
280
+ // the operator sees the most impactful gap first.
281
+ const failing = s.checks.filter((c) => !c.ok);
282
+ if (failing.length === 0) continue;
283
+ const check = failing.reduce((a, b) => (b.failing.length > a.failing.length ? b : a), failing[0]);
284
+ const ids = [...new Set(check.failing.map((f) => f.id))].slice(0, 20);
285
+ const shortName = STAGE_SHORT_NAMES[stageId] ?? stageId;
286
+ return {
287
+ stage: stageId,
288
+ check: check.name,
289
+ ids,
290
+ command: `rcf define readiness --check ${shortName}`,
291
+ };
292
+ }
293
+ return null;
294
+ }
295
+
296
+ /**
297
+ * Short 8-character prefix of a `sha256:<hex>` string, or '(none)' when
298
+ * absent. Kept exported so text formatters share one convention.
299
+ *
300
+ * @param {string | null | undefined} hash
301
+ * @returns {string}
302
+ */
303
+ export function shortHash(hash) {
304
+ if (typeof hash !== 'string' || !hash.startsWith('sha256:')) return '(none)';
305
+ return hash.slice('sha256:'.length, 'sha256:'.length + 8);
306
+ }
307
+
308
+ /**
309
+ * One-line tree summary matching proposal §6.2:
310
+ * "Frozen at 9f3a2c on 2026-09-20. 4 documents changed since, 11
311
+ * impacted. Build at FBS-031 of 41; 2 FBS impacted."
312
+ * "Unfrozen. 30 documents; the whole tree is the delta."
313
+ *
314
+ * @param {ReadinessResult} result
315
+ * @returns {string}
316
+ */
317
+ export function formatTreeLine(result) {
318
+ const t = result.tree;
319
+ const d = result.delta;
320
+ if (!t.frozen) {
321
+ const total = d.added.length + d.changed.length;
322
+ return `Unfrozen. ${total} document${total === 1 ? '' : 's'}; the whole tree is the delta.`;
323
+ }
324
+ const changedCount = d.changed.length + d.added.length + d.removed.length;
325
+ const impactedCount = d.impacted.length;
326
+ const buildAt = t.buildAt ?? '(none)';
327
+ const impactedFbs = d.impactedFbs.length;
328
+ const at = t.frozenAt ? t.frozenAt.split('T')[0] : '(no timestamp)';
329
+ return `Frozen at ${shortHash(t.treeHash)} on ${at}. ${changedCount} document${changedCount === 1 ? '' : 's'} changed since, ${impactedCount} impacted. Build at ${buildAt} of ${t.fbsTotal}; ${impactedFbs} FBS impacted.`;
330
+ }
331
+
332
+ // Re-export for CLI convenience.
333
+ export { STAGE_GATES, STAGE_ORDER, STAGE_SHORT_NAMES };
@@ -11,6 +11,7 @@
11
11
  // on top; the server itself does not `process.exit`.
12
12
 
13
13
  import { createServer } from 'node:http';
14
+ import { readFile } from 'node:fs/promises';
14
15
  import { join } from 'node:path';
15
16
 
16
17
  import { LIVE_CLIENT_PATH, STYLE_CSS_PATH, VENDORED_MERMAID_PATH, renderModelToPage } from '../view/index.js';
@@ -42,6 +43,25 @@ import { createSseHub } from './sse.js';
42
43
  * rewalk: () => Promise<void>,
43
44
  * }>}
44
45
  */
46
+
47
+ /**
48
+ * Issue #248 (0.28.3): read a shipped static asset once and return
49
+ * `{ buffer, size, contentType }`. Any read failure surfaces here so
50
+ * `startServer` can fail fast with an ioFailure before it binds.
51
+ *
52
+ * @param {string} path
53
+ * @param {string} contentType
54
+ */
55
+ async function loadStaticAsset(path, contentType) {
56
+ // Codex P2-1: derive size from the actual captured buffer, not from
57
+ // a parallel stat that could snapshot a different revision if the
58
+ // file is being replaced concurrently. A wrong content-length header
59
+ // would cause truncation or mis-alignment for the entire server
60
+ // lifetime because we cache both values together.
61
+ const buffer = await readFile(path);
62
+ return { buffer, size: buffer.length, contentType };
63
+ }
64
+
45
65
  export async function startServer(args) {
46
66
  const projectRoot = args.projectRoot;
47
67
  const port = typeof args.port === 'number' ? args.port : 4373;
@@ -76,6 +96,7 @@ export async function startServer(args) {
76
96
  fullPageHtml: result.fullPageHtml,
77
97
  contentHtml: result.contentHtml,
78
98
  errors: result.errors,
99
+ pmPartials: result.pmPartials,
79
100
  };
80
101
  sse.broadcast('tree-update', { version, contentHtml: result.contentHtml });
81
102
  if (result.errors && result.errors.length > 0) {
@@ -97,10 +118,26 @@ export async function startServer(args) {
97
118
  // and the first SSE connect gets a real payload.
98
119
  await rewalk();
99
120
 
121
+ // Issue #248 (0.28.3): snapshot the shipped static assets at startup
122
+ // so a checkout branch switch during the server's lifetime cannot
123
+ // change what gets served. The tree walker is the only path that
124
+ // re-reads disk on rcf/ change; every other asset is fixed at boot.
125
+ const [styleAsset, mermaidAsset, liveClientAsset] = await Promise.all([
126
+ loadStaticAsset(STYLE_CSS_PATH, 'text/css; charset=utf-8'),
127
+ loadStaticAsset(VENDORED_MERMAID_PATH, 'application/javascript; charset=utf-8'),
128
+ loadStaticAsset(LIVE_CLIENT_PATH, 'application/javascript; charset=utf-8'),
129
+ ]);
130
+
100
131
  const scopeHandler = createScopeHandler({ projectRoot });
101
132
  const router = createRouter({
102
133
  currentState: () => state,
103
134
  sse,
135
+ // Snapshotted assets take precedence; the legacy path props are
136
+ // still passed so tests that mount the router directly with a
137
+ // path (no asset load) keep working.
138
+ styleAsset,
139
+ mermaidAsset,
140
+ liveClientAsset,
104
141
  stylePath: STYLE_CSS_PATH,
105
142
  mermaidPath: VENDORED_MERMAID_PATH,
106
143
  liveClientPath: LIVE_CLIENT_PATH,
@@ -12,6 +12,22 @@
12
12
 
13
13
  import { readFile, stat } from 'node:fs/promises';
14
14
 
15
+ // Issue #248 (0.28.3): the shipped static assets (style.css,
16
+ // mermaid.min.js, live-client.js) MUST be resolved once at server
17
+ // startup, not read from disk on every HTTP request. If the checkout
18
+ // switches branches or the file mutates while the server runs, per-
19
+ // request reads return the new bytes even though the HTML that
20
+ // references them was rendered from a walk taken at startup. The
21
+ // asymmetry produced the 2026-09-22 Product Map "unstyled preview"
22
+ // regression: HTML from the store walker was old, style.css was
23
+ // current, and the browser combined them.
24
+ //
25
+ // `deps.styleAsset`, `deps.mermaidAsset` and `deps.liveClientAsset`
26
+ // carry `{ buffer, size, contentType }` snapshotted at startup. The
27
+ // legacy `stylePath`/`mermaidPath`/`liveClientPath` props are kept
28
+ // for backwards compatibility with tests that pass a path; the
29
+ // router reads on-demand in that fallback path.
30
+
15
31
  const MIME = {
16
32
  html: 'text/html; charset=utf-8',
17
33
  css: 'text/css; charset=utf-8',
@@ -68,15 +84,33 @@ export function createRouter(deps) {
68
84
  return;
69
85
  }
70
86
  if (path === '/style.css') {
71
- serveFile(res, deps.stylePath, MIME.css).catch((err) => fail(res, err));
87
+ serveAsset(res, deps.styleAsset, deps.stylePath, MIME.css).catch((err) => fail(res, err));
72
88
  return;
73
89
  }
74
90
  if (path === '/mermaid.min.js') {
75
- serveFile(res, deps.mermaidPath, MIME.js).catch((err) => fail(res, err));
91
+ serveAsset(res, deps.mermaidAsset, deps.mermaidPath, MIME.js).catch((err) => fail(res, err));
76
92
  return;
77
93
  }
78
94
  if (path === '/live-client.js') {
79
- serveFile(res, deps.liveClientPath, MIME.js).catch((err) => fail(res, err));
95
+ serveAsset(res, deps.liveClientAsset, deps.liveClientPath, MIME.js).catch((err) => fail(res, err));
96
+ return;
97
+ }
98
+ if (path.startsWith('/product-map/')) {
99
+ const state = deps.currentState();
100
+ if (!state || !state.pmPartials) {
101
+ res.writeHead(503, { 'content-type': MIME.txt });
102
+ res.end('view server initialising\n');
103
+ return;
104
+ }
105
+ const group = path.slice('/product-map/'.length);
106
+ const html = state.pmPartials[group];
107
+ if (typeof html !== 'string') {
108
+ res.writeHead(404, { 'content-type': MIME.txt });
109
+ res.end('unknown grouping\n');
110
+ return;
111
+ }
112
+ res.writeHead(200, { 'content-type': MIME.html, 'cache-control': 'no-store' });
113
+ res.end(html);
80
114
  return;
81
115
  }
82
116
  if (path === '/scope.json') {
@@ -94,6 +128,34 @@ export function createRouter(deps) {
94
128
  };
95
129
  }
96
130
 
131
+ /**
132
+ * Issue #248 (0.28.3): serve a snapshotted asset when the server has
133
+ * one, otherwise fall through to the legacy per-request read. The
134
+ * happy path never touches the filesystem after startup, so a checkout
135
+ * switch during the server's lifetime cannot change what gets served.
136
+ *
137
+ * @param {import('node:http').ServerResponse} res
138
+ * @param {{ buffer: Buffer, size: number, contentType?: string } | undefined | null} asset
139
+ * @param {string | undefined} fallbackPath
140
+ * @param {string} contentType
141
+ */
142
+ async function serveAsset(res, asset, fallbackPath, contentType) {
143
+ if (asset && asset.buffer) {
144
+ res.writeHead(200, {
145
+ 'content-type': asset.contentType ?? contentType,
146
+ 'content-length': String(asset.size),
147
+ });
148
+ res.end(asset.buffer);
149
+ return;
150
+ }
151
+ if (fallbackPath) {
152
+ await serveFile(res, fallbackPath, contentType);
153
+ return;
154
+ }
155
+ res.writeHead(404, { 'content-type': MIME.txt });
156
+ res.end('not found\n');
157
+ }
158
+
97
159
  async function serveFile(res, path, contentType) {
98
160
  let body;
99
161
  let size;
@@ -128,9 +128,9 @@ export function fieldObjectTable(label, rows, columns) {
128
128
  * @param {string} parentDocId - the enclosing doc's display id (e.g. "REQ-002")
129
129
  * @returns {string}
130
130
  */
131
- export function rawJsonDisclosure(raw, doc, parentDocId) {
131
+ export function rawJsonDisclosure(raw, doc, parentDocId, idPrefix) {
132
132
  const json = raw ?? JSON.stringify(doc, null, 2);
133
- const rawId = `${parentDocId ?? 'doc'}::raw`;
133
+ const rawId = `${idPrefix ?? ''}${parentDocId ?? 'doc'}::raw`;
134
134
  return `<details class="raw-json" data-doc-id="${escapeHtml(rawId)}"><summary>Show raw JSON</summary><pre>${escapeHtml(json)}</pre></details>`;
135
135
  }
136
136
 
@@ -18,11 +18,19 @@ import {
18
18
  * @param {string|undefined} ctx.raw
19
19
  * @param {import('#core/errors').RcfError[]} [ctx.errors]
20
20
  * @param {string|undefined} [ctx.subdiagram]
21
+ * @param {string|undefined} [ctx.idPrefix] - prefix prepended to every emitted
22
+ * id/anchor so the same REQ can be rendered under multiple Product Map
23
+ * buckets without duplicating ids. Empty when rendered from the
24
+ * Requirements tab so its anchors stay canonical.
25
+ * @param {boolean} [ctx.suppressRawJson] - when true, the raw-JSON disclosure
26
+ * is not emitted (Product Map compact cards; the disclosure stays on
27
+ * the Requirements tab). AC-17007-1 payload weight.
21
28
  * @returns {string}
22
29
  */
23
30
  export function renderReq(req, ctx) {
24
31
  if (!req) return '';
25
- const anchor = anchorIdFor(req.reqId ?? 'REQ');
32
+ const prefix = ctx.idPrefix ?? '';
33
+ const anchor = `${prefix}${anchorIdFor(req.reqId ?? 'REQ')}`;
26
34
  const broken = ctx.errors?.length ? brokenBanner(ctx.errors) : '';
27
35
  const subdiagram = ctx.subdiagram
28
36
  ? `<section class="subdiagram"><h4>Slice diagram</h4><pre class="mermaid">${escapeHtml(ctx.subdiagram)}</pre></section>`
@@ -38,6 +46,6 @@ export function renderReq(req, ctx) {
38
46
  ${fieldPara('Rationale', req.rationale)}
39
47
  ${fieldList('Tags', req.tags)}
40
48
  ${subdiagram}
41
- ${rawJsonDisclosure(ctx.raw, req, req.reqId)}
49
+ ${ctx.suppressRawJson ? '' : rawJsonDisclosure(ctx.raw, req, req.reqId, prefix)}
42
50
  </article>`.trim();
43
51
  }
@@ -18,13 +18,18 @@ import {
18
18
  * @param {string|undefined} ctx.raw
19
19
  * @param {import('#core/errors').RcfError[]} [ctx.errors]
20
20
  * @param {Map<string, object[]>} [ctx.fbsByAcId]
21
+ * @param {string|undefined} [ctx.idPrefix] - prefix prepended to every emitted
22
+ * id/anchor (article, AC li items, raw-JSON disclosure) so a US rendered
23
+ * under multiple Product Map buckets does not collide with the canonical
24
+ * Requirements-tab anchors. Empty from the Requirements tab.
21
25
  * @returns {string}
22
26
  */
23
27
  export function renderUserStory(us, ctx) {
24
28
  if (!us) return '';
25
- const anchor = anchorIdFor(us.usId ?? 'US');
29
+ const prefix = ctx.idPrefix ?? '';
30
+ const anchor = `${prefix}${anchorIdFor(us.usId ?? 'US')}`;
26
31
  const broken = ctx.errors?.length ? brokenBanner(ctx.errors) : '';
27
- const acItems = (us.acceptanceCriteria ?? []).map((ac) => renderAcItem(ac, ctx)).join('\n');
32
+ const acItems = (us.acceptanceCriteria ?? []).map((ac) => renderAcItem(ac, ctx, prefix)).join('\n');
28
33
  return `
29
34
  <article id="${anchor}" class="doc doc-us">
30
35
  <h3>${escapeHtml(us.usId ?? 'US')} - ${escapeHtml(us.title ?? '')}</h3>
@@ -40,13 +45,13 @@ export function renderUserStory(us, ctx) {
40
45
  ${acItems}
41
46
  </ul>
42
47
  </section>
43
- ${rawJsonDisclosure(ctx.raw, us, us.usId)}
48
+ ${ctx.suppressRawJson ? '' : rawJsonDisclosure(ctx.raw, us, us.usId, prefix)}
44
49
  </article>`.trim();
45
50
  }
46
51
 
47
- function renderAcItem(ac, ctx) {
52
+ function renderAcItem(ac, ctx, prefix) {
48
53
  const acId = ac.id ?? 'AC';
49
- const acAnchor = anchorIdFor(acId);
54
+ const acAnchor = `${prefix ?? ''}${anchorIdFor(acId)}`;
50
55
  const fbsList = ctx.fbsByAcId?.get(acId) ?? [];
51
56
  const coveredBy = fbsList.length === 0
52
57
  ? '<em>not yet delivered by any FBS</em>'