rcf-lite 0.28.1 → 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 (219) hide show
  1. package/CHANGELOG.md +310 -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-602.json +3 -2
  10. package/rcf/code-nodes/cn-700.json +23 -0
  11. package/rcf/code-nodes/cn-701.json +14 -0
  12. package/rcf/code-nodes/cn-702.json +14 -0
  13. package/rcf/code-nodes/cn-703.json +16 -0
  14. package/rcf/code-nodes/cn-720.json +15 -0
  15. package/rcf/code-nodes/cn-721.json +15 -0
  16. package/rcf/code-nodes/cn-722.json +15 -0
  17. package/rcf/code-nodes/cn-723.json +18 -0
  18. package/rcf/code-nodes/cn-724.json +14 -0
  19. package/rcf/code-nodes/cn-725.json +21 -0
  20. package/rcf/code-nodes/cn-726.json +17 -0
  21. package/rcf/code-nodes/cn-727.json +14 -0
  22. package/rcf/code-nodes/cn-728.json +21 -0
  23. package/rcf/code-nodes/cn-729.json +18 -0
  24. package/rcf/code-nodes/cn-730.json +14 -0
  25. package/rcf/fbs/fbs-181.json +3 -2
  26. package/rcf/fbs/fbs-190.json +37 -0
  27. package/rcf/fbs/fbs-191.json +34 -0
  28. package/rcf/fbs/fbs-192.json +17 -0
  29. package/rcf/fbs/fbs-193.json +37 -0
  30. package/rcf/fbs/fbs-194.json +35 -0
  31. package/rcf/fbs/fbs-195.json +37 -0
  32. package/rcf/fbs/fbs-196.json +37 -0
  33. package/rcf/fbs/fbs-197.json +41 -0
  34. package/rcf/requirements/req-001.json +2 -1
  35. package/rcf/requirements/req-002.json +2 -1
  36. package/rcf/requirements/req-003.json +2 -1
  37. package/rcf/requirements/req-004.json +2 -1
  38. package/rcf/requirements/req-005.json +2 -1
  39. package/rcf/requirements/req-006.json +2 -1
  40. package/rcf/requirements/req-007.json +2 -1
  41. package/rcf/requirements/req-008.json +2 -1
  42. package/rcf/requirements/req-009.json +2 -1
  43. package/rcf/requirements/req-010.json +5 -2
  44. package/rcf/requirements/req-011.json +2 -1
  45. package/rcf/requirements/req-012.json +2 -1
  46. package/rcf/requirements/req-013.json +2 -1
  47. package/rcf/requirements/req-014.json +2 -1
  48. package/rcf/requirements/req-015.json +2 -1
  49. package/rcf/requirements/req-016.json +5 -2
  50. package/rcf/requirements/req-017.json +6 -2
  51. package/rcf/requirements/req-018.json +5 -2
  52. package/rcf/requirements/req-019.json +5 -2
  53. package/rcf/requirements/req-020.json +5 -2
  54. package/rcf/requirements/req-021.json +5 -2
  55. package/rcf/requirements/req-022.json +5 -2
  56. package/rcf/requirements/req-023.json +6 -4
  57. package/rcf/requirements/req-024.json +5 -2
  58. package/rcf/requirements/req-025.json +5 -2
  59. package/rcf/requirements/req-026.json +5 -2
  60. package/rcf/requirements/req-027.json +5 -2
  61. package/rcf/requirements/req-028.json +5 -2
  62. package/rcf/requirements/req-029.json +5 -2
  63. package/rcf/requirements/req-030.json +5 -2
  64. package/rcf/requirements/req-040.json +5 -2
  65. package/rcf/requirements/req-041.json +5 -2
  66. package/rcf/requirements/req-042.json +5 -2
  67. package/rcf/requirements/req-043.json +5 -2
  68. package/rcf/requirements/req-050.json +5 -2
  69. package/rcf/requirements/req-051.json +5 -2
  70. package/rcf/requirements/req-052.json +5 -2
  71. package/rcf/requirements/req-053.json +5 -2
  72. package/rcf/requirements/req-054.json +5 -2
  73. package/rcf/requirements/req-060.json +5 -2
  74. package/rcf/requirements/req-061.json +5 -2
  75. package/rcf/requirements/req-062.json +5 -2
  76. package/rcf/requirements/req-063.json +5 -2
  77. package/rcf/requirements/req-070.json +5 -2
  78. package/rcf/requirements/req-071.json +5 -2
  79. package/rcf/requirements/req-072.json +5 -2
  80. package/rcf/requirements/req-073.json +5 -2
  81. package/rcf/requirements/req-074.json +5 -2
  82. package/rcf/requirements/req-075.json +5 -2
  83. package/rcf/requirements/req-076.json +5 -2
  84. package/rcf/requirements/req-077.json +5 -2
  85. package/rcf/requirements/req-080.json +5 -2
  86. package/rcf/requirements/req-081.json +5 -2
  87. package/rcf/requirements/req-082.json +5 -2
  88. package/rcf/requirements/req-083.json +5 -2
  89. package/rcf/requirements/req-084.json +5 -2
  90. package/rcf/requirements/req-085.json +5 -2
  91. package/rcf/requirements/req-086.json +5 -2
  92. package/rcf/requirements/req-090.json +5 -2
  93. package/rcf/requirements/req-091.json +5 -2
  94. package/rcf/requirements/req-092.json +5 -2
  95. package/rcf/requirements/req-093.json +5 -2
  96. package/rcf/requirements/req-094.json +5 -2
  97. package/rcf/requirements/req-100.json +5 -2
  98. package/rcf/requirements/req-101.json +5 -2
  99. package/rcf/requirements/req-102.json +5 -2
  100. package/rcf/requirements/req-103.json +5 -2
  101. package/rcf/requirements/req-104.json +5 -2
  102. package/rcf/requirements/req-110.json +5 -2
  103. package/rcf/requirements/req-111.json +5 -2
  104. package/rcf/requirements/req-112.json +5 -2
  105. package/rcf/requirements/req-113.json +5 -2
  106. package/rcf/requirements/req-114.json +5 -2
  107. package/rcf/requirements/req-115.json +5 -2
  108. package/rcf/requirements/req-120.json +5 -2
  109. package/rcf/requirements/req-121.json +5 -2
  110. package/rcf/requirements/req-122.json +5 -2
  111. package/rcf/requirements/req-123.json +5 -2
  112. package/rcf/requirements/req-124.json +5 -2
  113. package/rcf/requirements/req-125.json +5 -2
  114. package/rcf/requirements/req-130.json +6 -2
  115. package/rcf/requirements/req-131.json +6 -2
  116. package/rcf/requirements/req-132.json +6 -2
  117. package/rcf/requirements/req-133.json +6 -2
  118. package/rcf/requirements/req-134.json +6 -2
  119. package/rcf/requirements/req-140.json +5 -2
  120. package/rcf/requirements/req-145.json +5 -2
  121. package/rcf/requirements/req-150.json +6 -2
  122. package/rcf/requirements/req-151.json +6 -2
  123. package/rcf/requirements/req-152.json +6 -2
  124. package/rcf/requirements/req-153.json +6 -2
  125. package/rcf/requirements/req-154.json +5 -2
  126. package/rcf/requirements/req-155.json +4 -1
  127. package/rcf/requirements/req-156.json +4 -1
  128. package/rcf/requirements/req-157.json +4 -1
  129. package/rcf/requirements/req-158.json +4 -1
  130. package/rcf/requirements/req-159.json +4 -1
  131. package/rcf/requirements/req-160.json +4 -1
  132. package/rcf/requirements/req-161.json +4 -1
  133. package/rcf/requirements/req-162.json +4 -1
  134. package/rcf/requirements/req-163.json +5 -2
  135. package/rcf/requirements/req-164.json +5 -2
  136. package/rcf/requirements/req-165.json +5 -2
  137. package/rcf/requirements/req-170.json +33 -0
  138. package/rcf/requirements/req-171.json +44 -0
  139. package/rcf/requirements/req-172.json +25 -0
  140. package/rcf/requirements/req-173.json +25 -0
  141. package/rcf/requirements/req-174.json +25 -0
  142. package/rcf/requirements/req-175.json +25 -0
  143. package/rcf/tacs/tac-4103-feedback-redact.json +2 -2
  144. package/rcf/tacs/tac-4120-define-delta-and-freeze.json +21 -0
  145. package/rcf/tacs/tac-4121-define-ledgers.json +23 -0
  146. package/rcf/tacs/tac-4122-define-gates.json +25 -0
  147. package/rcf/tacs/tac-4123-define-readiness.json +25 -0
  148. package/rcf/test-suites/ts-188.json +10 -2
  149. package/rcf/test-suites/ts-191.json +19 -5
  150. package/rcf/test-suites/ts-204.json +30 -0
  151. package/rcf/test-suites/ts-205.json +22 -0
  152. package/rcf/test-suites/ts-206.json +22 -0
  153. package/rcf/test-suites/ts-207.json +22 -0
  154. package/rcf/test-suites/ts-208.json +22 -0
  155. package/rcf/test-suites/ts-209.json +22 -0
  156. package/rcf/test-suites/ts-210.json +22 -0
  157. package/rcf/test-suites/ts-211.json +22 -0
  158. package/rcf/test-suites/ts-212.json +22 -0
  159. package/rcf/test-suites/ts-213.json +50 -0
  160. package/rcf/test-suites/ts-214.json +74 -0
  161. package/rcf/test-suites/ts-215.json +67 -0
  162. package/rcf/test-suites/ts-216.json +78 -0
  163. package/rcf/test-suites/ts-217.json +62 -0
  164. package/rcf/user-stories/us-15601.json +10 -1
  165. package/rcf/user-stories/us-15701.json +4 -4
  166. package/rcf/user-stories/us-17001.json +27 -0
  167. package/rcf/user-stories/us-17002.json +22 -0
  168. package/rcf/user-stories/us-17003.json +22 -0
  169. package/rcf/user-stories/us-17004.json +22 -0
  170. package/rcf/user-stories/us-17005.json +22 -0
  171. package/rcf/user-stories/us-17006.json +21 -0
  172. package/rcf/user-stories/us-17007.json +21 -0
  173. package/rcf/user-stories/us-17008.json +22 -0
  174. package/rcf/user-stories/us-17101.json +22 -0
  175. package/rcf/user-stories/us-17102.json +22 -0
  176. package/rcf/user-stories/us-17201.json +38 -0
  177. package/rcf/user-stories/us-17301.json +38 -0
  178. package/rcf/user-stories/us-17401.json +58 -0
  179. package/rcf/user-stories/us-17501.json +48 -0
  180. package/releases/releases.yaml +31 -1
  181. package/src/browser-verify/manifest-writer.js +9 -5
  182. package/src/cli/browser-verify.js +9 -1
  183. package/src/cli/feedback.js +316 -32
  184. package/src/cli/help.js +11 -0
  185. package/src/cli/intake.js +25 -4
  186. package/src/cli/ledger.js +399 -0
  187. package/src/cli/preflight.js +24 -3
  188. package/src/cli/readiness.js +280 -0
  189. package/src/cli/review.js +9 -1
  190. package/src/cli/ui-baseline.js +14 -1
  191. package/src/core/store/index.js +5 -0
  192. package/src/core/store/validate-composed-record.js +95 -0
  193. package/src/core/store/writer.js +86 -6
  194. package/src/define/freeze-record.js +232 -0
  195. package/src/define/ledgers.js +513 -0
  196. package/src/feedback/gh.js +27 -1
  197. package/src/feedback/redact-allowlist.json +7 -0
  198. package/src/feedback/redact.js +126 -11
  199. package/src/feedback/render.js +34 -12
  200. package/src/intake/manifest-writer.js +6 -3
  201. package/src/intake/orchestrator.js +44 -7
  202. package/src/preflight/manifest-writer.js +11 -11
  203. package/src/query/delta.js +276 -0
  204. package/src/query/gates.js +832 -0
  205. package/src/query/index.js +36 -0
  206. package/src/query/readiness.js +333 -0
  207. package/src/req-baseline/sweep.js +34 -5
  208. package/src/server/index.js +37 -0
  209. package/src/server/routes.js +65 -3
  210. package/src/ui-baseline/manifest-writer.js +11 -10
  211. package/src/view/doc-renderers/helpers.js +39 -2
  212. package/src/view/doc-renderers/req.js +10 -2
  213. package/src/view/doc-renderers/tad.js +67 -6
  214. package/src/view/doc-renderers/user-story.js +10 -5
  215. package/src/view/html-page.js +347 -28
  216. package/src/view/index.js +14 -1
  217. package/src/view/live-client.js +22 -2
  218. package/src/view/product-map.js +751 -0
  219. package/src/view/style.css +383 -0
@@ -0,0 +1,280 @@
1
+ // `rcf define readiness` subcommand handler (REQ-175; proposal
2
+ // 2026-09-22 §2.4, §6.2 v3).
3
+ //
4
+ // Prints the readiness object (§2.4) or its JSON envelope. `--check
5
+ // <stage>` narrows the output to one stage; blocking stages exit 4 on
6
+ // failure (matching `coverage`'s per-AC refusal exit); warn-with-ack
7
+ // stages exit 0 with a visible `[warn]` line on unacknowledged
8
+ // failure. The producer is wrapped with `runWithAdmissibilityGate`
9
+ // (NV-BL-SR-03 addendum) so a chain that refuses admissibility short-
10
+ // circuits with the same refusal envelope every other query verb has.
11
+
12
+ import { parseArgs } from 'node:util';
13
+ import { readFile } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+
16
+ import { resolveTestPointers, walkTree } from '#core/store';
17
+ import { checkCodeNodeResolution } from '#core/store';
18
+ import { findProjectRoot } from '../view/index.js';
19
+ import { loadFreezeRecord } from '../define/freeze-record.js';
20
+ import { loadAllLedgers } from '../define/ledgers.js';
21
+ import {
22
+ STAGE_ALIASES,
23
+ STAGE_GATES,
24
+ STAGE_ORDER,
25
+ STAGE_SHORT_NAMES,
26
+ stagePolicy,
27
+ } from '../query/gates.js';
28
+ import { computeReadiness, formatTreeLine } from '../query/readiness.js';
29
+ import { runWithAdmissibilityGate } from '../query/index.js';
30
+
31
+ const OPTION_SPEC = {
32
+ json: { type: 'boolean' },
33
+ check: { type: 'string' },
34
+ help: { type: 'boolean' },
35
+ };
36
+
37
+ export const HELP = `Usage: rcf define readiness [options]
38
+
39
+ Compute and print the DEFINE readiness view: whether the tree is
40
+ freezeable, which stage gate blocks, what the delta and its fan-out
41
+ look like, and what the CLI recommends as the next action.
42
+
43
+ The compute is pure: it composes the freeze delta, per-id impact,
44
+ eight stage-gate checks (D1..D8), tree coverage and open decisions
45
+ into one object. The viewer's Readiness tab (slice 5) will render the
46
+ same object with no schema translation.
47
+
48
+ Options:
49
+ --json Emit the full readiness object as JSON.
50
+ --check <stage> Narrow the output to one stage. Accepts a
51
+ stage id (D1..D8) or its short name:
52
+ brief | skeleton | shapes | stories |
53
+ crosscut | consistency | decisions |
54
+ freeze. Exits 4 on a blocking-stage
55
+ failure (D1 / D2 / D4 / D7 / D8); exits 0
56
+ with a visible '[warn]' line on an
57
+ unacknowledged warn-with-ack failure
58
+ (D3 / D5 / D6). Use --check all to print
59
+ every stage under this exit-code policy.
60
+ --help Print this help.
61
+ `;
62
+
63
+ /** Text label per stage state, for the chip line. */
64
+ const STATE_LABEL = {
65
+ passed: 'passed',
66
+ failing: 'failing',
67
+ acknowledged: 'acknowledged',
68
+ notApplicable: 'notApplicable',
69
+ };
70
+
71
+ /**
72
+ * Read `rcf/.identity/profile.md` when it exists; return null on
73
+ * ENOENT. Any other read error surfaces as null so the profile
74
+ * markers just fail to appear (the D1 check treats absence as a
75
+ * finding, not a runtime error).
76
+ *
77
+ * @param {string} projectRoot
78
+ * @returns {Promise<string|null>}
79
+ */
80
+ async function readProfileText(projectRoot) {
81
+ try {
82
+ return await readFile(join(projectRoot, 'rcf', '.identity', 'profile.md'), 'utf8');
83
+ } catch (err) {
84
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return null;
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * @param {string[]} argv
91
+ * @param {object} [deps]
92
+ * @returns {Promise<number>}
93
+ */
94
+ export async function main(argv, deps = {}) {
95
+ const stdout = deps.stdout ?? process.stdout;
96
+ const stderr = deps.stderr ?? process.stderr;
97
+ const cwd = deps.cwd ?? process.cwd();
98
+
99
+ let parsed;
100
+ try {
101
+ parsed = parseArgs({ args: argv, options: OPTION_SPEC, allowPositionals: false, strict: true });
102
+ } catch (err) {
103
+ stderr.write(`[error] usage ${err.message}\n`);
104
+ stderr.write(HELP);
105
+ return 2;
106
+ }
107
+ const flags = parsed.values;
108
+ if (flags.help) {
109
+ stdout.write(HELP);
110
+ return 0;
111
+ }
112
+
113
+ const checkArg = flags.check;
114
+ let checkStage = null;
115
+ if (typeof checkArg === 'string') {
116
+ if (checkArg === 'all') {
117
+ checkStage = 'all';
118
+ } else if (STAGE_ALIASES[checkArg]) {
119
+ checkStage = STAGE_ALIASES[checkArg];
120
+ } else {
121
+ stderr.write(`[error] usage readiness: unknown --check ${checkArg} (expected D1..D8, one of ${Object.values(STAGE_SHORT_NAMES).join(' | ')}, or 'all')\n`);
122
+ return 2;
123
+ }
124
+ }
125
+
126
+ const projectRoot = await findProjectRoot(cwd);
127
+ if (!projectRoot) {
128
+ stderr.write('[error] usage no project root found (no rcf/manifest.json in this directory or any ancestor). Run `npx rcf init` to create and wire a project.\n');
129
+ return 2;
130
+ }
131
+
132
+ const { tree, errors } = await walkTree({ projectRoot });
133
+ const staleErrors = await checkCodeNodeResolution({ projectRoot, tree });
134
+ const validateErrors = [...errors, ...staleErrors];
135
+
136
+ const [freeze, ledgers, testPointers, profileText] = await Promise.all([
137
+ loadFreezeRecord({ projectRoot }),
138
+ loadAllLedgers({ projectRoot }),
139
+ resolveTestPointers({ projectRoot, tree }),
140
+ readProfileText(projectRoot),
141
+ ]);
142
+
143
+ const startedAt = Date.now();
144
+ const chainRulesetVersion = typeof tree.manifest?.rulesetVersion === 'string'
145
+ ? tree.manifest.rulesetVersion
146
+ : null;
147
+
148
+ // Readiness is a diagnostic: even a chain that refuses admissibility
149
+ // wants to know which stage gate blocks it. The wrap runs first so
150
+ // the ruleset toolScope guard is honoured (NV-BL-SR-03 addendum);
151
+ // on refuse the refusal envelope is threaded through as informational
152
+ // context and the compute still runs (ADR-4123).
153
+ const gated = await runWithAdmissibilityGate({
154
+ tree,
155
+ chainRulesetVersion,
156
+ produce: () => computeReadiness(tree, {
157
+ freeze,
158
+ ledgers,
159
+ profile: undefined,
160
+ profileText,
161
+ testPointers,
162
+ validateErrors,
163
+ }),
164
+ });
165
+
166
+ /** @type {import('../query/readiness.js').ReadinessResult} */
167
+ const result = gated.status === 'refused-admissibility'
168
+ ? computeReadiness(tree, {
169
+ freeze, ledgers, profile: undefined, profileText, testPointers, validateErrors,
170
+ })
171
+ : gated.payload;
172
+ const wallMs = Date.now() - startedAt;
173
+
174
+ if (gated.status === 'refused-admissibility') {
175
+ stderr.write(`[warn] readiness: chain admissibility refused (informational; the readiness compute still ran). ${gated.refusal}\n`);
176
+ }
177
+
178
+ if (flags.json) {
179
+ // JSON emission carries the full object plus the wall-time hint
180
+ // as a stable side-band on `_meta` so consumers can spot
181
+ // regressions without changing shape.
182
+ const envelope = { ...result, _meta: { wallMs } };
183
+ stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
184
+ return checkStage ? decideExitCode(result, checkStage, stderr) : 0;
185
+ }
186
+
187
+ renderText(stdout, result, wallMs, checkStage);
188
+ if (checkStage) {
189
+ return decideExitCode(result, checkStage, stderr);
190
+ }
191
+ return 0;
192
+ }
193
+
194
+ /**
195
+ * Emit the text summary: tree line, next action, chip line, then a
196
+ * per-stage failing-items block. When `--check <stage>` names a
197
+ * stage, only that stage's block is rendered.
198
+ *
199
+ * @param {NodeJS.WritableStream} stdout
200
+ * @param {import('../query/readiness.js').ReadinessResult} result
201
+ * @param {number} wallMs
202
+ * @param {string | null} checkStage
203
+ */
204
+ function renderText(stdout, result, wallMs, checkStage) {
205
+ stdout.write(`${formatTreeLine(result)}\n`);
206
+ if (!checkStage || checkStage === 'all') {
207
+ if (result.nextAction) {
208
+ const ids = result.nextAction.ids.length > 0 ? `; ids: ${result.nextAction.ids.join(', ')}` : '';
209
+ stdout.write(`Next action: ${result.nextAction.stage} / ${result.nextAction.check}${ids}. Run \`${result.nextAction.command}\` after editing.\n`);
210
+ } else {
211
+ stdout.write('Next action: none; every gate passed, acknowledged or notApplicable. Freezeable.\n');
212
+ }
213
+ stdout.write(`Chips: ${formatChipLine(result)}\n`);
214
+ stdout.write(`Delta: changed ${result.delta.changed.length}, added ${result.delta.added.length}, removed ${result.delta.removed.length}, briefSince ${result.delta.briefSince.length}, impacted ${result.delta.impacted.length}, impactedFbs ${result.delta.impactedFbs.length}.\n`);
215
+ stdout.write(`Coverage: tree ${result.coverage.tree.totals.covered}/${result.coverage.tree.totals.requirements} covered (strict), ${result.coverage.delta.length} REQ ancestor(s) scoped from the delta.\n`);
216
+ stdout.write(`Decisions open: ${result.decisions.length}.\n`);
217
+ stdout.write(`Freezeable: ${result.freezeable ? 'yes' : 'no'}. Compute: ${wallMs} ms.\n`);
218
+ }
219
+
220
+ for (const stage of result.stages) {
221
+ if (checkStage && checkStage !== 'all' && stage.stage !== checkStage) continue;
222
+ stdout.write(`\n${stage.stage} (${stage.gate}) -- ${STATE_LABEL[stage.state] ?? stage.state}${stage.reason ? ` (${stage.reason})` : ''}\n`);
223
+ for (const c of stage.checks) {
224
+ const mark = c.ok ? 'ok' : 'FAIL';
225
+ stdout.write(` [${mark}] ${c.name} (${c.over}): ${c.pass}/${c.total}\n`);
226
+ for (const f of c.failing.slice(0, 20)) {
227
+ stdout.write(` - ${f.id}: ${f.why}\n`);
228
+ }
229
+ if (c.failing.length > 20) {
230
+ stdout.write(` ... ${c.failing.length - 20} more suppressed\n`);
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Compose the chip line: "D1:passed D2:failing D3:acknowledged ..."
238
+ *
239
+ * @param {import('../query/readiness.js').ReadinessResult} result
240
+ * @returns {string}
241
+ */
242
+ function formatChipLine(result) {
243
+ return result.stages.map((s) => `${s.stage}:${s.state}`).join(' ');
244
+ }
245
+
246
+ /**
247
+ * Decide the exit code for a `--check <stage>` invocation:
248
+ * - passed / acknowledged / notApplicable -> 0
249
+ * - failing on a blocking stage (D1/D2/D4/D7/D8) -> 4
250
+ * - failing on a warn-with-ack stage (D3/D5/D6) -> 0 with a stderr [warn]
251
+ *
252
+ * The 'all' target folds the individual codes: if any blocking stage
253
+ * is failing, exit 4; otherwise 0 (warn lines print for each
254
+ * warn-with-ack failure).
255
+ *
256
+ * @param {import('../query/readiness.js').ReadinessResult} result
257
+ * @param {string} checkStage
258
+ * @param {NodeJS.WritableStream} stderr
259
+ * @returns {number}
260
+ */
261
+ function decideExitCode(result, checkStage, stderr) {
262
+ const stages = checkStage === 'all'
263
+ ? result.stages
264
+ : result.stages.filter((s) => s.stage === checkStage);
265
+ let exitCode = 0;
266
+ for (const s of stages) {
267
+ if (s.state !== 'failing') continue;
268
+ const policy = stagePolicy(s.stage);
269
+ if (policy === 'blocking') {
270
+ exitCode = 4;
271
+ } else {
272
+ stderr.write(`[warn] readiness: ${s.stage} (${s.gate}) is failing without an acknowledgement at the current tree hash. Run \`rcf define freeze --ack ${s.gate} --reason "<text>"\` to acknowledge, or edit the tree to clear the failure.\n`);
273
+ }
274
+ }
275
+ return exitCode;
276
+ }
277
+
278
+ // Re-export the gate map so consumers importing this module (help,
279
+ // slice 3) share one seam.
280
+ export { STAGE_GATES, STAGE_ORDER, STAGE_SHORT_NAMES };
package/src/cli/review.js CHANGED
@@ -21,7 +21,7 @@ import { parseArgs } from 'node:util';
21
21
  import process from 'node:process';
22
22
 
23
23
  import { rcfError, writeUnexpectedFailure } from '#core/errors';
24
- import { resolveTestPointers, validateDocument, walkTree } from '#core/store';
24
+ import { resolveTestPointers, validateDocument, validateComposedRecord, walkTree } from '#core/store';
25
25
 
26
26
  import { findProjectRoot } from '../view/index.js';
27
27
  import {
@@ -170,6 +170,14 @@ export async function main(argv, deps = {}) {
170
170
  if (flags.json) stdout.write(`${JSON.stringify(record, null, 2)}\n`);
171
171
 
172
172
  if (flags['dry-run']) {
173
+ // 0.28.2 (folded into #230, per Barry ruling 2026-09-21):
174
+ // dry-run runs the same schema pass the writer runs so a preview
175
+ // reports the same validation error the write path would refuse.
176
+ const dryValidation = validateComposedRecord({ tree, record, verb: 'review' });
177
+ if (dryValidation) {
178
+ stderr.write(`[error] ${dryValidation.kind} ${dryValidation.message}\n`);
179
+ return 3;
180
+ }
173
181
  if (!flags.quiet) {
174
182
  stdout.write(`[dry-run] review ${fbsId}: ${findings.length} finding(s); verdict=${record.verdict}\n`);
175
183
  for (const f of findings) {
@@ -21,7 +21,7 @@ import { parseArgs } from 'node:util';
21
21
  import { createInterface } from 'node:readline/promises';
22
22
  import process from 'node:process';
23
23
 
24
- import { walkTree } from '#core/store';
24
+ import { walkTree, validateComposedRecord } from '#core/store';
25
25
  import { writeUnexpectedFailure, rcfError } from '#core/errors';
26
26
 
27
27
  import { findProjectRoot } from '../view/index.js';
@@ -250,6 +250,19 @@ async function runInit({ tree, projectRoot, stdin, stdout, stderr, flags, now })
250
250
  });
251
251
 
252
252
  if (flags['dry-run']) {
253
+ // 0.28.2 (folded into #230): dry-run runs the same schema pass
254
+ // the writer runs so a preview reports the same validation error
255
+ // the write path would refuse.
256
+ const dryValidation = validateComposedRecord({
257
+ tree,
258
+ record,
259
+ verb: 'uiBaselineInit',
260
+ options: { reset: Boolean(flags.reset) },
261
+ });
262
+ if (dryValidation) {
263
+ stderr.write(`[error] ${dryValidation.kind} ${dryValidation.message}\n`);
264
+ return 3;
265
+ }
253
266
  stdout.write(`${JSON.stringify(record, null, 2)}\n`);
254
267
  if (!flags.quiet) stdout.write(`[dry-run] ui-baseline init: would write ${record.id} (${session.optOuts.length} opt-outs).\n`);
255
268
  return 0;
@@ -18,3 +18,8 @@ export { checkCodeNodeResolution, splitCnPath } from './cn-resolve.js';
18
18
  // never counted as covering an AC.
19
19
  export { resolveTestPointers, splitTestPointer, testCaseKey } from './tp-resolve.js';
20
20
  export { nextIdForKind, createDocument, updateDocument, deleteDocument, deriveSlug } from './writer.js';
21
+ // 0.28.2 (issues #230/#232 + review/ui-baseline init/browser-verify):
22
+ // shared "compose then schema-validate without writing" helper the
23
+ // dry-run branches of the five discover verbs run so a preview reports
24
+ // the same schema error the writer would refuse on.
25
+ export { validateComposedRecord, buildNextManifest } from './validate-composed-record.js';
@@ -0,0 +1,95 @@
1
+ // Shared validate-composed-record helper (0.28.2, issues #230 / #232
2
+ // and the three folded verbs review / ui-baseline init / browser-verify,
3
+ // per Barry's 2026-09-21 ruling).
4
+ //
5
+ // The bug: five discover verbs shipped a `--dry-run` branch that
6
+ // composed a manifest record and printed it without ever running the
7
+ // same schema pass the write path runs. A `--dry-run` that passed and
8
+ // a real run that failed on schema made the preview untrustworthy for
9
+ // exactly the non-interactive case it exists to serve.
10
+ //
11
+ // The fix: this helper synthesises the "next manifest" for each verb
12
+ // the same way the writer would, then runs `validateDocument` without
13
+ // touching disk. The dry-run branch of each CLI calls this helper and
14
+ // exits 3 on a validation miss, matching the exit code the write path
15
+ // returns today.
16
+
17
+ import { validateDocument } from './validator.js';
18
+
19
+ /**
20
+ * @typedef {'intake' | 'preflight' | 'review' | 'uiBaselineInit' | 'browserVerify'} DryRunVerb
21
+ */
22
+
23
+ /**
24
+ * @param {object} args
25
+ * @param {object} args.tree the walked tree (carries `manifest`)
26
+ * @param {object} args.record the composed record to be written
27
+ * @param {DryRunVerb} args.verb which verb is composing
28
+ * @param {object[]} [args.extraRecords] preflight-only: the composed opt-outs
29
+ * @param {object} [args.options] per-verb write options (e.g. `{ reset }` for uiBaselineInit)
30
+ * @returns {import('../errors/index.js').RcfError | null}
31
+ * Returns the same shape `validateDocument` returns, so the CLI can
32
+ * forward the error to stderr as it would on the write path.
33
+ */
34
+ export function validateComposedRecord({ tree, record, verb, extraRecords = [], options = {} }) {
35
+ const manifest = tree?.manifest ?? {};
36
+ const nextManifest = buildNextManifest({ manifest, record, verb, extraRecords, options });
37
+ return validateDocument({ doc: nextManifest, kind: 'manifest', filePath: 'rcf/manifest.json' });
38
+ }
39
+
40
+ /**
41
+ * Return the next-manifest shape each writer synthesises before its own
42
+ * `validateDocument` call. Kept in this module so a single test can
43
+ * assert the dry-run branch and the write branch stay in step across
44
+ * every verb.
45
+ *
46
+ * @param {object} args
47
+ * @param {object} args.manifest
48
+ * @param {object} args.record
49
+ * @param {DryRunVerb} args.verb
50
+ * @param {object[]} [args.extraRecords]
51
+ * @param {object} [args.options]
52
+ * @returns {object}
53
+ */
54
+ export function buildNextManifest({ manifest, record, verb, extraRecords = [], options = {} }) {
55
+ const base = { ...(manifest ?? {}) };
56
+ switch (verb) {
57
+ case 'intake': {
58
+ return { ...base, intakeClassification: record };
59
+ }
60
+ case 'preflight': {
61
+ const existing = Array.isArray(base.preFlightConfig) ? base.preFlightConfig : [];
62
+ const next = { ...base, preFlightConfig: [...existing, record] };
63
+ if (Array.isArray(extraRecords) && extraRecords.length > 0) {
64
+ const existingOptOuts = Array.isArray(next.baselineAcOptOuts) ? next.baselineAcOptOuts : [];
65
+ next.baselineAcOptOuts = [...existingOptOuts, ...extraRecords];
66
+ }
67
+ return next;
68
+ }
69
+ case 'review': {
70
+ const existing = Array.isArray(base.reviewAudit) ? base.reviewAudit : [];
71
+ return { ...base, reviewAudit: [...existing, record] };
72
+ }
73
+ case 'uiBaselineInit': {
74
+ // Mirror writeUiBaselineRecord: on `--reset` the prior
75
+ // uiBaseline moves into uiBaselineHistory. Without --reset the
76
+ // prior is replaced in place. The dry-run path calls this
77
+ // helper without options and matches the reset=false branch.
78
+ const next = { ...base };
79
+ const prior = next.uiBaseline ?? null;
80
+ const reset = Boolean(options && options.reset);
81
+ if (prior && reset) {
82
+ const history = Array.isArray(next.uiBaselineHistory) ? next.uiBaselineHistory : [];
83
+ next.uiBaselineHistory = [...history, prior];
84
+ }
85
+ next.uiBaseline = record;
86
+ return next;
87
+ }
88
+ case 'browserVerify': {
89
+ const existing = Array.isArray(base.browserVerification) ? base.browserVerification : [];
90
+ return { ...base, browserVerification: [...existing, record] };
91
+ }
92
+ default:
93
+ throw new Error(`validateComposedRecord: unknown verb ${verb}`);
94
+ }
95
+ }
@@ -200,18 +200,98 @@ export function nextIdForKind(tree, kind, opts = {}) {
200
200
  // one numbering group.
201
201
  const groupNum = Number(normaliseId(match[1]));
202
202
  const groupLabel = String(groupNum);
203
+ // Occupancy is every US id in the tree. Each id is classified as
204
+ // ENCODED (`US-<reqNum><localTwoDigit>` where the reqNum names an
205
+ // existing REQ and local is 01..99) or FLAT (a bare sequential
206
+ // number that carries no parent-encoded suffix, as produced by
207
+ // legacy trees migrated in from WESPA v1). Classification decides
208
+ // the allocation rule:
209
+ // - all-encoded tree (or empty): the historical rule, extend
210
+ // `US-<groupLabel><nextLocal>`.
211
+ // - all-flat tree: extend the flat sequence past the highest
212
+ // existing US number (issue #256 / w-2026-09-29-dave-005).
213
+ // - mixed tree: refuse the mint with an explicit error naming
214
+ // both schemes, so an operator normalises the tree before a
215
+ // new id is issued (Dave disposition 2026-09-30).
216
+ // AC minting is unchanged; #256 was reproduced against USes only.
217
+ // Classification prefers the doc's declared reqId (unambiguous) over
218
+ // the id-only heuristic. Without that, a legacy US-163 filed under
219
+ // REQ-053 would decompose as parent=1/local=63 and look encoded
220
+ // whenever REQ-001 happened to exist, defeating the whole rule.
221
+ const usIds = occupiedIdsOfKind(tree, 'userStory');
222
+ const reqNums = new Set();
223
+ for (const rId of occupiedIdsOfKind(tree, 'req')) {
224
+ const rn = idNumber(rId, 'REQ');
225
+ if (rn !== null) reqNums.add(rn);
226
+ }
227
+ const encoded = [];
228
+ const flat = [];
229
+ const seenUsNums = new Set();
230
+ for (const usId of usIds) {
231
+ const num = idNumber(usId, 'US');
232
+ if (num === null) continue;
233
+ // occupiedIdsOfKind emits both the on-disk id and the doc's
234
+ // declared id field, so the same numeric id appears twice.
235
+ // Dedupe on the numeric value to avoid double-counting.
236
+ if (seenUsNums.has(num)) continue;
237
+ seenUsNums.add(num);
238
+ const localPart = num % 100;
239
+ const parentPart = Math.floor(num / 100);
240
+ const doc = tree.byId.get(usId);
241
+ let declaredReqNum = null;
242
+ if (doc && typeof doc.reqId === 'string') {
243
+ const mReq = /^REQ-(\d+)$/.exec(doc.reqId);
244
+ if (mReq) declaredReqNum = Number(normaliseId(mReq[1]));
245
+ }
246
+ let isEncoded;
247
+ if (declaredReqNum !== null) {
248
+ // Encoded iff the id decomposes as <declaredReqNum><two-digit
249
+ // local in 01..99>. Any other shape is flat.
250
+ isEncoded = (localPart >= 1 && localPart <= 99 && parentPart === declaredReqNum);
251
+ } else {
252
+ // Invalid / unloadable doc: fall back to the id-only heuristic
253
+ // (matches historical behaviour when the parent-part names an
254
+ // existing REQ).
255
+ isEncoded = (localPart >= 1 && localPart <= 99 && reqNums.has(parentPart));
256
+ }
257
+ if (isEncoded) {
258
+ encoded.push({ id: usId, num, parentPart, localPart });
259
+ } else {
260
+ flat.push({ id: usId, num });
261
+ }
262
+ }
263
+
264
+ if (encoded.length > 0 && flat.length > 0) {
265
+ const encodedExample = encoded[0].id;
266
+ const flatExample = flat[0].id;
267
+ throw new TypeError(
268
+ 'nextIdForKind us: refusing to mint - the tree mixes '
269
+ + `encoded US ids (e.g. ${encodedExample}, shape US-<reqNum><localTwoDigit>) `
270
+ + `and flat US ids (e.g. ${flatExample}, shape US-<seq>). `
271
+ + 'Normalise every US id to one scheme (rename the outliers, '
272
+ + 'update every reference) and retry, or ask HQ for a bulk '
273
+ + 're-mint. Auto-mint intentionally refuses on ambiguous trees.',
274
+ );
275
+ }
276
+
277
+ if (flat.length > 0 && encoded.length === 0) {
278
+ let maxFlat = 0;
279
+ for (const f of flat) {
280
+ if (f.num > maxFlat) maxFlat = f.num;
281
+ }
282
+ return `US-${maxFlat + 1}`;
283
+ }
284
+
285
+ // All-encoded (or empty) tree: keep the historical behaviour.
203
286
  // Occupancy is every US id that numerically falls in this group,
204
287
  // whatever `reqId` string it names. Filtering on an exact reqId
205
288
  // string match was the bug: a US filed under `REQ-0001` was
206
289
  // invisible when allocating for `REQ-001`, so US-101 got re-issued
207
290
  // on top of the existing US-101.
208
- const usIds = occupiedIdsOfKind(tree, 'userStory');
209
291
  let maxLocal = 0;
210
- for (const usId of usIds) {
211
- const num = idNumber(usId, 'US');
212
- if (num === null) continue;
213
- const local = num - groupNum * 100;
214
- if (local >= 1 && local <= 99 && local > maxLocal) maxLocal = local;
292
+ for (const e of encoded) {
293
+ if (e.parentPart !== groupNum) continue;
294
+ if (e.localPart > maxLocal) maxLocal = e.localPart;
215
295
  }
216
296
  const nextLocal = maxLocal + 1;
217
297
  return `US-${groupLabel}${String(nextLocal).padStart(2, '0')}`;