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.
- package/CHANGELOG.md +211 -0
- package/bin/rcf.js +4 -0
- package/fixtures/canary-manifest.json +9 -9
- package/package.json +1 -1
- package/rcf/adrs/adr-4120-canonical-json-hashing.json +30 -0
- package/rcf/adrs/adr-4121-define-sidecars.json +30 -0
- package/rcf/adrs/adr-4122-define-gates-blocking-and-optout.json +30 -0
- package/rcf/adrs/adr-4123-readiness-single-object.json +30 -0
- package/rcf/code-nodes/cn-700.json +23 -0
- package/rcf/code-nodes/cn-701.json +14 -0
- package/rcf/code-nodes/cn-702.json +14 -0
- package/rcf/code-nodes/cn-703.json +16 -0
- package/rcf/code-nodes/cn-720.json +15 -0
- package/rcf/code-nodes/cn-721.json +15 -0
- package/rcf/code-nodes/cn-722.json +15 -0
- package/rcf/code-nodes/cn-723.json +18 -0
- package/rcf/code-nodes/cn-724.json +14 -0
- package/rcf/code-nodes/cn-725.json +21 -0
- package/rcf/code-nodes/cn-726.json +17 -0
- package/rcf/code-nodes/cn-727.json +14 -0
- package/rcf/code-nodes/cn-728.json +21 -0
- package/rcf/code-nodes/cn-729.json +18 -0
- package/rcf/code-nodes/cn-730.json +14 -0
- package/rcf/fbs/fbs-190.json +37 -0
- package/rcf/fbs/fbs-191.json +34 -0
- package/rcf/fbs/fbs-192.json +17 -0
- package/rcf/fbs/fbs-193.json +37 -0
- package/rcf/fbs/fbs-194.json +35 -0
- package/rcf/fbs/fbs-195.json +37 -0
- package/rcf/fbs/fbs-196.json +37 -0
- package/rcf/fbs/fbs-197.json +41 -0
- package/rcf/requirements/req-001.json +2 -1
- package/rcf/requirements/req-002.json +2 -1
- package/rcf/requirements/req-003.json +2 -1
- package/rcf/requirements/req-004.json +2 -1
- package/rcf/requirements/req-005.json +2 -1
- package/rcf/requirements/req-006.json +2 -1
- package/rcf/requirements/req-007.json +2 -1
- package/rcf/requirements/req-008.json +2 -1
- package/rcf/requirements/req-009.json +2 -1
- package/rcf/requirements/req-010.json +5 -2
- package/rcf/requirements/req-011.json +2 -1
- package/rcf/requirements/req-012.json +2 -1
- package/rcf/requirements/req-013.json +2 -1
- package/rcf/requirements/req-014.json +2 -1
- package/rcf/requirements/req-015.json +2 -1
- package/rcf/requirements/req-016.json +5 -2
- package/rcf/requirements/req-017.json +6 -2
- package/rcf/requirements/req-018.json +5 -2
- package/rcf/requirements/req-019.json +5 -2
- package/rcf/requirements/req-020.json +5 -2
- package/rcf/requirements/req-021.json +5 -2
- package/rcf/requirements/req-022.json +5 -2
- package/rcf/requirements/req-023.json +6 -4
- package/rcf/requirements/req-024.json +5 -2
- package/rcf/requirements/req-025.json +5 -2
- package/rcf/requirements/req-026.json +5 -2
- package/rcf/requirements/req-027.json +5 -2
- package/rcf/requirements/req-028.json +5 -2
- package/rcf/requirements/req-029.json +5 -2
- package/rcf/requirements/req-030.json +5 -2
- package/rcf/requirements/req-040.json +5 -2
- package/rcf/requirements/req-041.json +5 -2
- package/rcf/requirements/req-042.json +5 -2
- package/rcf/requirements/req-043.json +5 -2
- package/rcf/requirements/req-050.json +5 -2
- package/rcf/requirements/req-051.json +5 -2
- package/rcf/requirements/req-052.json +5 -2
- package/rcf/requirements/req-053.json +5 -2
- package/rcf/requirements/req-054.json +5 -2
- package/rcf/requirements/req-060.json +5 -2
- package/rcf/requirements/req-061.json +5 -2
- package/rcf/requirements/req-062.json +5 -2
- package/rcf/requirements/req-063.json +5 -2
- package/rcf/requirements/req-070.json +5 -2
- package/rcf/requirements/req-071.json +5 -2
- package/rcf/requirements/req-072.json +5 -2
- package/rcf/requirements/req-073.json +5 -2
- package/rcf/requirements/req-074.json +5 -2
- package/rcf/requirements/req-075.json +5 -2
- package/rcf/requirements/req-076.json +5 -2
- package/rcf/requirements/req-077.json +5 -2
- package/rcf/requirements/req-080.json +5 -2
- package/rcf/requirements/req-081.json +5 -2
- package/rcf/requirements/req-082.json +5 -2
- package/rcf/requirements/req-083.json +5 -2
- package/rcf/requirements/req-084.json +5 -2
- package/rcf/requirements/req-085.json +5 -2
- package/rcf/requirements/req-086.json +5 -2
- package/rcf/requirements/req-090.json +5 -2
- package/rcf/requirements/req-091.json +5 -2
- package/rcf/requirements/req-092.json +5 -2
- package/rcf/requirements/req-093.json +5 -2
- package/rcf/requirements/req-094.json +5 -2
- package/rcf/requirements/req-100.json +5 -2
- package/rcf/requirements/req-101.json +5 -2
- package/rcf/requirements/req-102.json +5 -2
- package/rcf/requirements/req-103.json +5 -2
- package/rcf/requirements/req-104.json +5 -2
- package/rcf/requirements/req-110.json +5 -2
- package/rcf/requirements/req-111.json +5 -2
- package/rcf/requirements/req-112.json +5 -2
- package/rcf/requirements/req-113.json +5 -2
- package/rcf/requirements/req-114.json +5 -2
- package/rcf/requirements/req-115.json +5 -2
- package/rcf/requirements/req-120.json +5 -2
- package/rcf/requirements/req-121.json +5 -2
- package/rcf/requirements/req-122.json +5 -2
- package/rcf/requirements/req-123.json +5 -2
- package/rcf/requirements/req-124.json +5 -2
- package/rcf/requirements/req-125.json +5 -2
- package/rcf/requirements/req-130.json +6 -2
- package/rcf/requirements/req-131.json +6 -2
- package/rcf/requirements/req-132.json +6 -2
- package/rcf/requirements/req-133.json +6 -2
- package/rcf/requirements/req-134.json +6 -2
- package/rcf/requirements/req-140.json +5 -2
- package/rcf/requirements/req-145.json +5 -2
- package/rcf/requirements/req-150.json +6 -2
- package/rcf/requirements/req-151.json +6 -2
- package/rcf/requirements/req-152.json +6 -2
- package/rcf/requirements/req-153.json +6 -2
- package/rcf/requirements/req-154.json +5 -2
- package/rcf/requirements/req-155.json +4 -1
- package/rcf/requirements/req-156.json +4 -1
- package/rcf/requirements/req-157.json +4 -1
- package/rcf/requirements/req-158.json +4 -1
- package/rcf/requirements/req-159.json +4 -1
- package/rcf/requirements/req-160.json +4 -1
- package/rcf/requirements/req-161.json +4 -1
- package/rcf/requirements/req-162.json +4 -1
- package/rcf/requirements/req-163.json +5 -2
- package/rcf/requirements/req-164.json +5 -2
- package/rcf/requirements/req-165.json +5 -2
- package/rcf/requirements/req-170.json +33 -0
- package/rcf/requirements/req-171.json +44 -0
- package/rcf/requirements/req-172.json +25 -0
- package/rcf/requirements/req-173.json +25 -0
- package/rcf/requirements/req-174.json +25 -0
- package/rcf/requirements/req-175.json +25 -0
- package/rcf/tacs/tac-4120-define-delta-and-freeze.json +21 -0
- package/rcf/tacs/tac-4121-define-ledgers.json +23 -0
- package/rcf/tacs/tac-4122-define-gates.json +25 -0
- package/rcf/tacs/tac-4123-define-readiness.json +25 -0
- package/rcf/test-suites/ts-204.json +30 -0
- package/rcf/test-suites/ts-205.json +22 -0
- package/rcf/test-suites/ts-206.json +22 -0
- package/rcf/test-suites/ts-207.json +22 -0
- package/rcf/test-suites/ts-208.json +22 -0
- package/rcf/test-suites/ts-209.json +22 -0
- package/rcf/test-suites/ts-210.json +22 -0
- package/rcf/test-suites/ts-211.json +22 -0
- package/rcf/test-suites/ts-212.json +22 -0
- package/rcf/test-suites/ts-213.json +50 -0
- package/rcf/test-suites/ts-214.json +74 -0
- package/rcf/test-suites/ts-215.json +67 -0
- package/rcf/test-suites/ts-216.json +78 -0
- package/rcf/test-suites/ts-217.json +62 -0
- package/rcf/user-stories/us-15601.json +4 -4
- package/rcf/user-stories/us-17001.json +27 -0
- package/rcf/user-stories/us-17002.json +22 -0
- package/rcf/user-stories/us-17003.json +22 -0
- package/rcf/user-stories/us-17004.json +22 -0
- package/rcf/user-stories/us-17005.json +22 -0
- package/rcf/user-stories/us-17006.json +21 -0
- package/rcf/user-stories/us-17007.json +21 -0
- package/rcf/user-stories/us-17008.json +22 -0
- package/rcf/user-stories/us-17101.json +22 -0
- package/rcf/user-stories/us-17102.json +22 -0
- package/rcf/user-stories/us-17201.json +38 -0
- package/rcf/user-stories/us-17301.json +38 -0
- package/rcf/user-stories/us-17401.json +58 -0
- package/rcf/user-stories/us-17501.json +48 -0
- package/releases/releases.yaml +21 -1
- package/src/cli/feedback.js +126 -10
- package/src/cli/help.js +11 -0
- package/src/cli/ledger.js +399 -0
- package/src/cli/readiness.js +280 -0
- package/src/core/store/writer.js +86 -6
- package/src/define/freeze-record.js +232 -0
- package/src/define/ledgers.js +513 -0
- package/src/feedback/redact.js +105 -19
- package/src/query/delta.js +276 -0
- package/src/query/gates.js +832 -0
- package/src/query/index.js +36 -0
- package/src/query/readiness.js +333 -0
- package/src/server/index.js +37 -0
- package/src/server/routes.js +65 -3
- package/src/view/doc-renderers/helpers.js +2 -2
- package/src/view/doc-renderers/req.js +10 -2
- package/src/view/doc-renderers/user-story.js +10 -5
- package/src/view/html-page.js +347 -28
- package/src/view/index.js +14 -1
- package/src/view/live-client.js +22 -2
- package/src/view/product-map.js +751 -0
- package/src/view/style.css +378 -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/core/store/writer.js
CHANGED
|
@@ -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
|
|
211
|
-
|
|
212
|
-
if (
|
|
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')}`;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Freeze record I/O (REQ-172; proposal §2.1 v3).
|
|
2
|
+
//
|
|
3
|
+
// One file: `rcf/define/freeze.json`. Overwritten on every freeze; git
|
|
4
|
+
// holds the history. Owned by rcf-lite, outside the shared rcf-schemas
|
|
5
|
+
// package until 0.7 (ADR-4121). This module is the seam the freeze
|
|
6
|
+
// verb calls in slice 3; the verb itself is not built yet.
|
|
7
|
+
//
|
|
8
|
+
// A missing file returns `null` (a fresh tree; the delta model treats
|
|
9
|
+
// null as unfrozen, proposal §2.2). A malformed file (parse failure,
|
|
10
|
+
// or a body that fails the local schema) raises a clear error
|
|
11
|
+
// naming the file and the failing field. Silence is never the answer
|
|
12
|
+
// to a broken freeze record: the DEFINE gates read this file on every
|
|
13
|
+
// invocation and any downstream check would be lying if it kept going.
|
|
14
|
+
//
|
|
15
|
+
// Local schema (this file, not @stravica-ai/rcf-schemas):
|
|
16
|
+
//
|
|
17
|
+
// {
|
|
18
|
+
// frozenAt: ISO-8601 timestamp string,
|
|
19
|
+
// treeHash: "sha256:<64 hex>",
|
|
20
|
+
// docHashes: { <docId>: "sha256:<64 hex>", ... }, // required
|
|
21
|
+
// briefStatements: integer >= 0, // required
|
|
22
|
+
// gates: { [gate]: { state, at, failing?, reason? } }, // optional
|
|
23
|
+
// counts: { req, us, ac, ... } (open object, integers, optional)
|
|
24
|
+
// litmus: { attestedAt: string[], readers?: number }, // optional
|
|
25
|
+
// versions: { rcfLite, ruleset, schemas }, // optional
|
|
26
|
+
// note: string | null, // optional
|
|
27
|
+
// override: null | { reason, by, at } // required (nullable)
|
|
28
|
+
// }
|
|
29
|
+
//
|
|
30
|
+
// The record is EXTENSIBLE: later slices add fields (2 gates the freeze
|
|
31
|
+
// verb wrote at freeze time, 4 the `bundle --next` refusal read at
|
|
32
|
+
// override time). This slice validates the fields it consumes and
|
|
33
|
+
// tolerates the rest; unknown properties survive a load/save
|
|
34
|
+
// round-trip so a newer rcf-lite writing the file does not lose them
|
|
35
|
+
// to an older reader mid-migration.
|
|
36
|
+
|
|
37
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
38
|
+
import { dirname, join } from 'node:path';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Relative path (under the project root) of the freeze record. Used by
|
|
42
|
+
* the walker-invisibility invariant test and by every caller that
|
|
43
|
+
* wants to log the path in an error message.
|
|
44
|
+
*/
|
|
45
|
+
export const FREEZE_RECORD_REL_PATH = 'rcf/define/freeze.json';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Absolute path of the freeze record for a project root.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} projectRoot
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function freezeRecordPath(projectRoot) {
|
|
54
|
+
return join(projectRoot, FREEZE_RECORD_REL_PATH);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Structured error thrown on a malformed freeze record. Carries the
|
|
59
|
+
* file path and a short reason. `code` fixes the class so callers can
|
|
60
|
+
* distinguish parse failures from schema failures without matching on
|
|
61
|
+
* the message.
|
|
62
|
+
*/
|
|
63
|
+
export class FreezeRecordError extends Error {
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} message
|
|
66
|
+
* @param {object} [opts]
|
|
67
|
+
* @param {string} [opts.filePath]
|
|
68
|
+
* @param {'parseFailure' | 'schemaFailure' | 'ioFailure'} [opts.code]
|
|
69
|
+
* @param {string} [opts.field]
|
|
70
|
+
*/
|
|
71
|
+
constructor(message, opts = {}) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = 'FreezeRecordError';
|
|
74
|
+
this.filePath = opts.filePath;
|
|
75
|
+
this.code = opts.code ?? 'schemaFailure';
|
|
76
|
+
this.field = opts.field;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const SHA256_RE = /^sha256:[0-9a-f]{64}$/;
|
|
81
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Validate the parsed body against the local schema described at the
|
|
85
|
+
* top of this file. Throws `FreezeRecordError` on the first violation
|
|
86
|
+
* (fail fast; the record is one file).
|
|
87
|
+
*
|
|
88
|
+
* @param {unknown} raw
|
|
89
|
+
* @param {string} [filePath]
|
|
90
|
+
* @returns {import('../query/delta.js').FreezeRecord & Record<string, unknown>}
|
|
91
|
+
*/
|
|
92
|
+
export function validateFreezeRecord(raw, filePath) {
|
|
93
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
94
|
+
throw new FreezeRecordError('Freeze record must be a JSON object.', {
|
|
95
|
+
filePath, field: '(root)',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const body = /** @type {Record<string, unknown>} */ (raw);
|
|
99
|
+
const require = /** @type {(field: string) => void} */ ((field) => {
|
|
100
|
+
if (!Object.prototype.hasOwnProperty.call(body, field)) {
|
|
101
|
+
throw new FreezeRecordError(`Freeze record missing required field '${field}'.`, {
|
|
102
|
+
filePath, field,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
require('frozenAt');
|
|
108
|
+
if (typeof body.frozenAt !== 'string' || !ISO_RE.test(body.frozenAt)) {
|
|
109
|
+
throw new FreezeRecordError(
|
|
110
|
+
"Freeze record 'frozenAt' must be an ISO-8601 timestamp string.",
|
|
111
|
+
{ filePath, field: 'frozenAt' },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
require('treeHash');
|
|
116
|
+
if (typeof body.treeHash !== 'string' || !SHA256_RE.test(body.treeHash)) {
|
|
117
|
+
throw new FreezeRecordError(
|
|
118
|
+
"Freeze record 'treeHash' must be a 'sha256:<64-hex>' string.",
|
|
119
|
+
{ filePath, field: 'treeHash' },
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
require('docHashes');
|
|
124
|
+
const docHashes = body.docHashes;
|
|
125
|
+
if (docHashes === null || typeof docHashes !== 'object' || Array.isArray(docHashes)) {
|
|
126
|
+
throw new FreezeRecordError(
|
|
127
|
+
"Freeze record 'docHashes' must be an object mapping ids to sha256:<hex> strings.",
|
|
128
|
+
{ filePath, field: 'docHashes' },
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
for (const [id, hash] of Object.entries(docHashes)) {
|
|
132
|
+
if (typeof hash !== 'string' || !SHA256_RE.test(hash)) {
|
|
133
|
+
throw new FreezeRecordError(
|
|
134
|
+
`Freeze record docHashes['${id}'] must be a 'sha256:<64-hex>' string.`,
|
|
135
|
+
{ filePath, field: `docHashes.${id}` },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
require('briefStatements');
|
|
141
|
+
if (
|
|
142
|
+
typeof body.briefStatements !== 'number'
|
|
143
|
+
|| !Number.isInteger(body.briefStatements)
|
|
144
|
+
|| body.briefStatements < 0
|
|
145
|
+
) {
|
|
146
|
+
throw new FreezeRecordError(
|
|
147
|
+
"Freeze record 'briefStatements' must be a non-negative integer.",
|
|
148
|
+
{ filePath, field: 'briefStatements' },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (Object.prototype.hasOwnProperty.call(body, 'override')) {
|
|
153
|
+
const override = body.override;
|
|
154
|
+
if (override !== null) {
|
|
155
|
+
if (override === undefined || typeof override !== 'object' || Array.isArray(override)) {
|
|
156
|
+
throw new FreezeRecordError(
|
|
157
|
+
"Freeze record 'override' must be null or { reason, by, at }.",
|
|
158
|
+
{ filePath, field: 'override' },
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const o = /** @type {Record<string, unknown>} */ (override);
|
|
162
|
+
for (const f of ['reason', 'by', 'at']) {
|
|
163
|
+
if (typeof o[f] !== 'string' || o[f].length === 0) {
|
|
164
|
+
throw new FreezeRecordError(
|
|
165
|
+
`Freeze record 'override.${f}' must be a non-empty string.`,
|
|
166
|
+
{ filePath, field: `override.${f}` },
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return /** @type {import('../query/delta.js').FreezeRecord & Record<string, unknown>} */ (body);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Load and validate the freeze record for a project. Returns null
|
|
178
|
+
* when the file does not exist; throws `FreezeRecordError` on parse
|
|
179
|
+
* or schema failure.
|
|
180
|
+
*
|
|
181
|
+
* @param {object} args
|
|
182
|
+
* @param {string} args.projectRoot
|
|
183
|
+
* @returns {Promise<(import('../query/delta.js').FreezeRecord & Record<string, unknown>) | null>}
|
|
184
|
+
*/
|
|
185
|
+
export async function loadFreezeRecord({ projectRoot }) {
|
|
186
|
+
const filePath = freezeRecordPath(projectRoot);
|
|
187
|
+
const relPath = FREEZE_RECORD_REL_PATH;
|
|
188
|
+
let raw;
|
|
189
|
+
try {
|
|
190
|
+
raw = await readFile(filePath, 'utf8');
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return null;
|
|
193
|
+
throw new FreezeRecordError(
|
|
194
|
+
`Failed to read ${relPath}: ${/** @type {Error} */ (err).message}`,
|
|
195
|
+
{ filePath: relPath, code: 'ioFailure' },
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
let parsed;
|
|
199
|
+
try {
|
|
200
|
+
parsed = JSON.parse(raw);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
throw new FreezeRecordError(
|
|
203
|
+
`Freeze record parse failed: ${/** @type {Error} */ (err).message}`,
|
|
204
|
+
{ filePath: relPath, code: 'parseFailure' },
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return validateFreezeRecord(parsed, relPath);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Write the freeze record. Validates first (so a bad in-memory record
|
|
212
|
+
* never lands as JSON on disk), creates `rcf/define/` if needed, and
|
|
213
|
+
* writes pretty-printed JSON with a trailing newline (matches the
|
|
214
|
+
* neighbouring rcf/*.json files' shape).
|
|
215
|
+
*
|
|
216
|
+
* The slice-3 `rcf define freeze` verb is the intended caller; this
|
|
217
|
+
* slice exposes the writer so that verb can be built without another
|
|
218
|
+
* refactor. Every direct caller is expected to have run
|
|
219
|
+
* `computeReadiness` first and only write when the tree is freezeable.
|
|
220
|
+
*
|
|
221
|
+
* @param {object} args
|
|
222
|
+
* @param {string} args.projectRoot
|
|
223
|
+
* @param {import('../query/delta.js').FreezeRecord & Record<string, unknown>} args.record
|
|
224
|
+
* @returns {Promise<{ filePath: string }>}
|
|
225
|
+
*/
|
|
226
|
+
export async function saveFreezeRecord({ projectRoot, record }) {
|
|
227
|
+
const validated = validateFreezeRecord(record, FREEZE_RECORD_REL_PATH);
|
|
228
|
+
const filePath = freezeRecordPath(projectRoot);
|
|
229
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
230
|
+
await writeFile(filePath, `${JSON.stringify(validated, null, 2)}\n`, 'utf8');
|
|
231
|
+
return { filePath };
|
|
232
|
+
}
|