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