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
@@ -0,0 +1,276 @@
1
+ // Detection model foundation (REQ-172; proposal 2026-09-22 §2.2 v3).
2
+ //
3
+ // Three pure functions:
4
+ //
5
+ // - `hashDocument(doc)` SHA-256 over the canonical JSON of the
6
+ // parsed document. `sha256:<hex>` string.
7
+ // - `computeTreeHash(hashes)` SHA-256 over the sorted `(id, docHash)`
8
+ // pairs, canonically JSON-encoded.
9
+ // - `computeDelta(tree, freeze, ledgers)`
10
+ // the whole change model, returning the
11
+ // shape the proposal contracts in §2.2.
12
+ //
13
+ // Canonical JSON, chosen at proposal §2.1: keys sorted recursively, no
14
+ // whitespace, no trailing separators. A hand reformat that changes
15
+ // nothing is not a change; that is what an operator expects, and it
16
+ // costs one canonicalise per document per read (milliseconds at the
17
+ // backstory-scale tree of 700 criteria). Hashing raw bytes would be
18
+ // one line shorter and would report drift after `rcf define update`
19
+ // rewrote a file it did not semantically touch (ADR-4120).
20
+ //
21
+ // Document granularity (proposal §2.2, ADR-4120): the delta is a set of
22
+ // standalone-document ids -- one entry per document the walker exposes
23
+ // on `tree.byId` (PRD, TAD, BS, REQ, US, TAC, ADR, FBS, TS, CN, EVAL),
24
+ // plus one `ledger:<name>` entry per sidecar ledger the caller passes.
25
+ // Inline AC and TC entries ride inside the parent hash: editing
26
+ // `AC-207-4` shows as `US-207 changed`, and the criterion-level diff is
27
+ // git's job.
28
+ //
29
+ // Pure: no filesystem I/O, no state, no time. Every call takes plain
30
+ // data in and returns plain data out.
31
+
32
+ import { createHash } from 'node:crypto';
33
+
34
+ /**
35
+ * Canonicalise a value to the JSON string the hash is taken over.
36
+ *
37
+ * - Objects: keys sorted lexicographically, then each `<key>:<value>`
38
+ * joined by `,` and wrapped in `{}`.
39
+ * - Arrays: elements in source order, canonicalised, joined by `,`
40
+ * and wrapped in `[]`.
41
+ * - Strings: `JSON.stringify` (RFC 8259 escaping).
42
+ * - Numbers and booleans: `JSON.stringify` (finite numbers only; NaN
43
+ * and +/-Infinity yield `null` in JSON, which is honest for a value
44
+ * the parsed document could not legally hold).
45
+ * - `null` and `undefined`: `undefined` is omitted from objects and
46
+ * arrays (matches `JSON.stringify` semantics); a top-level
47
+ * `undefined` yields the empty string, which the caller treats as a
48
+ * distinct value from `null` (never expected in a parsed document).
49
+ *
50
+ * No whitespace anywhere. Deterministic on the input tree.
51
+ *
52
+ * @param {unknown} value
53
+ * @returns {string}
54
+ */
55
+ export function canonicaliseJson(value) {
56
+ if (value === null) return 'null';
57
+ const t = typeof value;
58
+ if (t === 'string') return JSON.stringify(value);
59
+ if (t === 'number') return Number.isFinite(value) ? JSON.stringify(value) : 'null';
60
+ if (t === 'boolean') return value ? 'true' : 'false';
61
+ if (t === 'undefined') return '';
62
+ if (Array.isArray(value)) {
63
+ const parts = [];
64
+ for (const el of value) {
65
+ // JSON.stringify drops undefined elements to null in arrays; keep
66
+ // that shape so an accidentally-sparse array hashes the same as
67
+ // its JSON.stringify form (never expected in a parsed document,
68
+ // where JSON.parse cannot produce one).
69
+ parts.push(el === undefined ? 'null' : canonicaliseJson(el));
70
+ }
71
+ return `[${parts.join(',')}]`;
72
+ }
73
+ if (t === 'object') {
74
+ const keys = Object.keys(/** @type {object} */ (value)).sort();
75
+ const parts = [];
76
+ for (const k of keys) {
77
+ const v = (/** @type {Record<string, unknown>} */ (value))[k];
78
+ if (v === undefined) continue; // JSON.stringify omits undefined in objects.
79
+ parts.push(`${JSON.stringify(k)}:${canonicaliseJson(v)}`);
80
+ }
81
+ return `{${parts.join(',')}}`;
82
+ }
83
+ // Functions, symbols, bigints: never legally present in a parsed
84
+ // document. Fall back to JSON.stringify's own null coercion.
85
+ return 'null';
86
+ }
87
+
88
+ /**
89
+ * Hash one parsed document. Returns `sha256:<64-hex>` per proposal
90
+ * §2.1. Accepts any value the walker's `tree.byId` map holds (an
91
+ * object) and also `null` / primitives (a ledger sidecar hashed as a
92
+ * whole, for example, may pass a plain array).
93
+ *
94
+ * @param {unknown} doc
95
+ * @returns {string}
96
+ */
97
+ export function hashDocument(doc) {
98
+ const canonical = canonicaliseJson(doc);
99
+ const hex = createHash('sha256').update(canonical, 'utf8').digest('hex');
100
+ return `sha256:${hex}`;
101
+ }
102
+
103
+ /**
104
+ * Hash a `docHashes` map. Sorts the `(id, docHash)` pairs
105
+ * lexicographically by id, canonically encodes the array of pairs,
106
+ * and hashes that string. Matches proposal §2.1 ("the sorted (id,
107
+ * docHash) pairs"). Accepts a plain object or a Map.
108
+ *
109
+ * @param {Record<string, string> | Map<string, string>} docHashes
110
+ * @returns {string}
111
+ */
112
+ export function computeTreeHash(docHashes) {
113
+ /** @type {[string, string][]} */
114
+ const pairs = [];
115
+ if (docHashes instanceof Map) {
116
+ for (const [id, h] of docHashes) pairs.push([String(id), String(h)]);
117
+ } else if (docHashes && typeof docHashes === 'object') {
118
+ for (const [id, h] of Object.entries(docHashes)) pairs.push([id, String(h)]);
119
+ }
120
+ pairs.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
121
+ const hex = createHash('sha256').update(canonicaliseJson(pairs), 'utf8').digest('hex');
122
+ return `sha256:${hex}`;
123
+ }
124
+
125
+ /**
126
+ * Every standalone-document id the walker holds, sorted. Every id in
127
+ * `tree.byId` (PRD, TAD, BS, REQ, US, TAC, ADR, FBS, TS, CN, EVAL).
128
+ * Inline AC / TC ids ride inside their parent's hash and are NOT
129
+ * enumerated here; delta granularity is the standalone document
130
+ * (proposal §2.2, ADR-4120).
131
+ *
132
+ * @param {import('#core/store/walker.js').TreeModel} tree
133
+ * @returns {string[]}
134
+ */
135
+ function standaloneIds(tree) {
136
+ if (!tree || !tree.byId) return [];
137
+ return [...tree.byId.keys()].sort();
138
+ }
139
+
140
+ /**
141
+ * @typedef {object} LedgerBundle
142
+ * @property {unknown} [brief] brief-ledger.json body (null / absent = untouched)
143
+ * @property {unknown} [decisions] decisions-ledger.json body
144
+ * @property {unknown} [concerns] concern-ledger.json body
145
+ * @property {unknown} [probes] probe-ledger.json body
146
+ *
147
+ * The delta hashes only the ledgers the caller passes -- if a project
148
+ * has not authored any decisions, the caller passes no `decisions`
149
+ * key and the delta records no `ledger:decisions` docHash. This
150
+ * keeps the model additive: sidecars appear when they exist, never
151
+ * before.
152
+ */
153
+
154
+ /**
155
+ * @typedef {object} FreezeRecord
156
+ * @property {string} [frozenAt]
157
+ * @property {string} [treeHash]
158
+ * @property {Record<string, string>} [docHashes]
159
+ * @property {number} [briefStatements]
160
+ */
161
+
162
+ /**
163
+ * @typedef {object} DeltaResult
164
+ * @property {boolean} frozen
165
+ * false when the freeze record is absent (a fresh tree; everything
166
+ * is `added`, per proposal §2.2 "before the first freeze the delta
167
+ * is the whole tree").
168
+ * @property {string | null} frozenAt
169
+ * @property {string | null} treeHash
170
+ * The tree hash the freeze record stored, or null when unfrozen.
171
+ * @property {string} currentTreeHash
172
+ * The tree hash recomputed from the live tree.
173
+ * @property {string[]} changed ids whose hash differs
174
+ * @property {string[]} added ids not in `docHashes`
175
+ * @property {string[]} removed ids in `docHashes` with no live document
176
+ * @property {number[]} briefSince
177
+ * Ledger-brief statement ids above the freeze high-water mark.
178
+ * @property {number} unchanged count of ids present in both maps with
179
+ * the same hash (excludes ledgers).
180
+ */
181
+
182
+ /**
183
+ * Compute the delta between the live tree and the freeze record.
184
+ *
185
+ * Pure. No filesystem, no time, no state. Runs on every read (the
186
+ * proposal's whole point: the freeze record and the tree are the only
187
+ * inputs to the change model).
188
+ *
189
+ * @param {import('#core/store/walker.js').TreeModel} tree
190
+ * @param {FreezeRecord | null | undefined} freeze
191
+ * @param {LedgerBundle | null | undefined} [ledgers]
192
+ * @returns {DeltaResult}
193
+ */
194
+ export function computeDelta(tree, freeze, ledgers) {
195
+ /** @type {Record<string, string>} */
196
+ const currentHashes = {};
197
+ for (const id of standaloneIds(tree)) {
198
+ currentHashes[id] = hashDocument(tree.byId.get(id));
199
+ }
200
+ const ledgerBundle = ledgers ?? {};
201
+ for (const [name, body] of Object.entries(ledgerBundle)) {
202
+ if (body === undefined || body === null) continue;
203
+ currentHashes[`ledger:${name}`] = hashDocument(body);
204
+ }
205
+
206
+ const currentTreeHash = computeTreeHash(currentHashes);
207
+
208
+ // Unfrozen path (proposal §2.2): the delta is the whole tree,
209
+ // reported as `added`. frozenAt / treeHash null; briefSince empty
210
+ // (there is no high-water mark to compare against yet, and D1's
211
+ // scoping rule keeps the greenfield brief in the delta by definition
212
+ // -- the ledger itself is a docHash entry when the caller passes one).
213
+ if (!freeze || typeof freeze !== 'object' || !freeze.docHashes) {
214
+ return {
215
+ frozen: false,
216
+ frozenAt: null,
217
+ treeHash: null,
218
+ currentTreeHash,
219
+ changed: [],
220
+ added: Object.keys(currentHashes).sort(),
221
+ removed: [],
222
+ briefSince: [],
223
+ unchanged: 0,
224
+ };
225
+ }
226
+
227
+ const frozenHashes = freeze.docHashes;
228
+ /** @type {string[]} */
229
+ const changed = [];
230
+ /** @type {string[]} */
231
+ const added = [];
232
+ /** @type {string[]} */
233
+ const removed = [];
234
+ let unchanged = 0;
235
+
236
+ const liveIds = Object.keys(currentHashes);
237
+ for (const id of liveIds) {
238
+ if (Object.prototype.hasOwnProperty.call(frozenHashes, id)) {
239
+ if (frozenHashes[id] === currentHashes[id]) unchanged += 1;
240
+ else changed.push(id);
241
+ } else {
242
+ added.push(id);
243
+ }
244
+ }
245
+ for (const id of Object.keys(frozenHashes)) {
246
+ if (!Object.prototype.hasOwnProperty.call(currentHashes, id)) removed.push(id);
247
+ }
248
+ changed.sort();
249
+ added.sort();
250
+ removed.sort();
251
+
252
+ const highWater = Number.isFinite(freeze.briefStatements) ? Number(freeze.briefStatements) : 0;
253
+ /** @type {number[]} */
254
+ const briefSince = [];
255
+ const brief = ledgerBundle.brief;
256
+ if (brief && typeof brief === 'object' && Array.isArray(/** @type {any} */ (brief).statements)) {
257
+ for (const s of /** @type {any} */ (brief).statements) {
258
+ if (!s || typeof s !== 'object') continue;
259
+ const n = Number(s.id);
260
+ if (Number.isFinite(n) && n > highWater) briefSince.push(n);
261
+ }
262
+ briefSince.sort((a, b) => a - b);
263
+ }
264
+
265
+ return {
266
+ frozen: true,
267
+ frozenAt: typeof freeze.frozenAt === 'string' ? freeze.frozenAt : null,
268
+ treeHash: typeof freeze.treeHash === 'string' ? freeze.treeHash : null,
269
+ currentTreeHash,
270
+ changed,
271
+ added,
272
+ removed,
273
+ briefSince,
274
+ unchanged,
275
+ };
276
+ }