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,832 @@
|
|
|
1
|
+
// DEFINE stage gates D1..D8 (REQ-174; proposal 2026-09-22 §2.4, §3.2,
|
|
2
|
+
// §5.2 v3; Baz 2026-09-24 renumbering: warn-with-ack D3/D5/D6 ships in
|
|
3
|
+
// 0.29.0, the fuller template parser / catalogue / probe runner in
|
|
4
|
+
// 0.30).
|
|
5
|
+
//
|
|
6
|
+
// One exported check function per stage. Each is pure: no filesystem
|
|
7
|
+
// I/O, no state, no time. The caller (computeReadiness) assembles the
|
|
8
|
+
// context: the walker tree, the four ledgers, the freeze record, the
|
|
9
|
+
// delta, the scope (changed union added union fan-out), validate
|
|
10
|
+
// errors, probe-ledger open count, tree-wide coverage rollup, and the
|
|
11
|
+
// operator profile string.
|
|
12
|
+
//
|
|
13
|
+
// Every function returns:
|
|
14
|
+
//
|
|
15
|
+
// {
|
|
16
|
+
// stage: 'D1'..'D8',
|
|
17
|
+
// gate: 'define.brief' | 'define.skeleton' | ...,
|
|
18
|
+
// state: 'passed' | 'failing' | 'acknowledged' | 'notApplicable',
|
|
19
|
+
// checks: [
|
|
20
|
+
// {
|
|
21
|
+
// name,
|
|
22
|
+
// ok, // boolean
|
|
23
|
+
// over, // 'delta' | 'tree'
|
|
24
|
+
// pass, total, // integers
|
|
25
|
+
// failing: [ { id, why } ]
|
|
26
|
+
// }, ...
|
|
27
|
+
// ]
|
|
28
|
+
// }
|
|
29
|
+
//
|
|
30
|
+
// State fold (ADR-4122):
|
|
31
|
+
// - Blocking stages (D1, D2, D4, D7, D8): 'passed' when every
|
|
32
|
+
// check.ok is true; 'failing' otherwise. 'notApplicable' is
|
|
33
|
+
// decided by the stage before checks run.
|
|
34
|
+
// - Warn-with-ack stages (D3, D5, D6): 'passed' when every check.ok
|
|
35
|
+
// is true; 'acknowledged' when any check is failing AND
|
|
36
|
+
// freeze.gates[<gate>].state === 'acknowledged' AND
|
|
37
|
+
// freeze.gates[<gate>].at.hash === currentTreeHash; 'failing'
|
|
38
|
+
// otherwise.
|
|
39
|
+
//
|
|
40
|
+
// Warn-with-ack stages in 0.29.0 run only the cheap data-backed
|
|
41
|
+
// checks proposal §3.2 lists; the template parser, entity join,
|
|
42
|
+
// catalogue walk, contradiction / unsatisfiable scans and probe
|
|
43
|
+
// runner are 0.30 and left as named seams (`SEAM 0.30:` comments
|
|
44
|
+
// mark them).
|
|
45
|
+
|
|
46
|
+
import { computeQueue } from '../build/queue.js';
|
|
47
|
+
import { isOptedOut } from '../req-baseline/opt-out.js';
|
|
48
|
+
import { BRIEF_KINDS } from '../define/ledgers.js';
|
|
49
|
+
|
|
50
|
+
/** Canonical stage order (proposal §3.2). */
|
|
51
|
+
export const STAGE_ORDER = /** @type {const} */ (['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7', 'D8']);
|
|
52
|
+
|
|
53
|
+
/** Stage -> gate id map (used by the freeze verb and the CLI --check flag). */
|
|
54
|
+
export const STAGE_GATES = /** @type {const} */ ({
|
|
55
|
+
D1: 'define.brief',
|
|
56
|
+
D2: 'define.skeleton',
|
|
57
|
+
D3: 'define.shapes',
|
|
58
|
+
D4: 'define.stories',
|
|
59
|
+
D5: 'define.crosscut',
|
|
60
|
+
D6: 'define.consistency',
|
|
61
|
+
D7: 'define.decisions',
|
|
62
|
+
D8: 'define.freeze',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** Stage -> operator-friendly short name (--check <short> equivalent to --check <D>). */
|
|
66
|
+
export const STAGE_SHORT_NAMES = /** @type {const} */ ({
|
|
67
|
+
D1: 'brief',
|
|
68
|
+
D2: 'skeleton',
|
|
69
|
+
D3: 'shapes',
|
|
70
|
+
D4: 'stories',
|
|
71
|
+
D5: 'crosscut',
|
|
72
|
+
D6: 'consistency',
|
|
73
|
+
D7: 'decisions',
|
|
74
|
+
D8: 'freeze',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** Reverse: short name / D-name -> D-stage. Consumers accept either. */
|
|
78
|
+
export const STAGE_ALIASES = /** @type {const} */ (() => {
|
|
79
|
+
/** @type {Record<string, string>} */
|
|
80
|
+
const out = {};
|
|
81
|
+
for (const stage of STAGE_ORDER) {
|
|
82
|
+
out[stage] = stage;
|
|
83
|
+
out[STAGE_SHORT_NAMES[stage]] = stage;
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
})();
|
|
87
|
+
|
|
88
|
+
/** Closed vocabulary for TAC.interfaces[].kind (proposal §5.2 v1). */
|
|
89
|
+
export const INTERFACE_KINDS = /** @type {const} */ ([
|
|
90
|
+
'recordShape', 'httpRoute', 'event', 'cliCommand', 'uiRoute',
|
|
91
|
+
'port', 'fileFormat', 'fixture', 'other',
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
/** Warn-with-ack stages in 0.29.0 (ADR-4122). */
|
|
95
|
+
const WARN_WITH_ACK = new Set(['D3', 'D5', 'D6']);
|
|
96
|
+
|
|
97
|
+
/** Blocking stages in 0.29.0 (ADR-4122). */
|
|
98
|
+
const BLOCKING = new Set(['D1', 'D2', 'D4', 'D7', 'D8']);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Signals a stage exports so consumers can render policy in the pill
|
|
102
|
+
* vocabulary without touching internals. Kept as a helper so the CLI
|
|
103
|
+
* text output and the viewer share one source.
|
|
104
|
+
* @param {string} stage
|
|
105
|
+
* @returns {'blocking' | 'warnWithAck'}
|
|
106
|
+
*/
|
|
107
|
+
export function stagePolicy(stage) {
|
|
108
|
+
return WARN_WITH_ACK.has(stage) ? 'warnWithAck' : 'blocking';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Scaffold TODO placeholder marker: uppercase "TODO" followed by a
|
|
113
|
+
* colon, matching every scaffold string `rcf init` and `rcf define
|
|
114
|
+
* create` write (see src/core/store/init.js and src/core/store/writer.js:
|
|
115
|
+
* every scaffold field is 'TODO: <text>'). Word-bounded and
|
|
116
|
+
* case-sensitive so incidental prose like "the scaffold TODO
|
|
117
|
+
* placeholder" or "no TODO markers" does not trip the gate. The
|
|
118
|
+
* validate CLI (src/cli/validate.js) keeps its own broader
|
|
119
|
+
* `\btodo\b/i` matcher for the informational tree-wide notice, so
|
|
120
|
+
* incidental prose still surfaces there without failing D2 / D4.
|
|
121
|
+
*/
|
|
122
|
+
const TODO_RE = /\bTODO:/;
|
|
123
|
+
|
|
124
|
+
/** Bracketed AC-class prefix (Baz decision 6, proposal §3.2 D4). */
|
|
125
|
+
const AC_CLASS_RE = /^\[(happy|edge|failure|must-not|non-functional)\]/;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Parse the leading bracketed class marker on `ac.description`.
|
|
129
|
+
* Returns the class token, or null when no marker is present.
|
|
130
|
+
*
|
|
131
|
+
* @param {unknown} ac
|
|
132
|
+
* @returns {string | null}
|
|
133
|
+
*/
|
|
134
|
+
export function parseAcClass(ac) {
|
|
135
|
+
const desc = ac && typeof ac === 'object' ? /** @type {any} */ (ac).description : null;
|
|
136
|
+
if (typeof desc !== 'string') return null;
|
|
137
|
+
const m = desc.trim().match(AC_CLASS_RE);
|
|
138
|
+
return m ? m[1] : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Build a helper `{ name, ok, over, pass, total, failing }` in one
|
|
143
|
+
* call. `failing` is normalised to the { id, why } shape and omitted
|
|
144
|
+
* when ok is true (the surface is stable either way; the array is
|
|
145
|
+
* always present for consumers that fold on length).
|
|
146
|
+
*
|
|
147
|
+
* @param {string} name
|
|
148
|
+
* @param {'delta' | 'tree'} over
|
|
149
|
+
* @param {number} total
|
|
150
|
+
* @param {Array<{ id: string, why: string }>} failing
|
|
151
|
+
* @param {number} [totalFailingCount] Untruncated failing count. Defaults to
|
|
152
|
+
* `failing.length`. Callers that truncate the failing array for display
|
|
153
|
+
* (e.g. `.slice(0, 20)` on a large validate-error set) pass the full
|
|
154
|
+
* count here so `pass = total - untruncatedFailing`; without this the
|
|
155
|
+
* fold `pass = total - failing.length` over-counts passes beyond the
|
|
156
|
+
* twentieth failure. `ok` is likewise derived from the untruncated
|
|
157
|
+
* count. The section 2.4 shape stays intact -- `failing` remains the
|
|
158
|
+
* display array; no new field is exported.
|
|
159
|
+
* @returns {{ name: string, ok: boolean, over: 'delta'|'tree', pass: number, total: number, failing: Array<{ id: string, why: string }> }}
|
|
160
|
+
*/
|
|
161
|
+
function makeCheck(name, over, total, failing, totalFailingCount) {
|
|
162
|
+
const nFailing = typeof totalFailingCount === 'number' ? totalFailingCount : failing.length;
|
|
163
|
+
const pass = Math.max(0, total - nFailing);
|
|
164
|
+
return { name, ok: nFailing === 0, over, pass, total, failing: [...failing] };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Fold a checks[] into a stage envelope with the ADR-4122 state rules
|
|
169
|
+
* applied. Blocking stages ignore acknowledgement; warn-with-ack
|
|
170
|
+
* stages honour a freeze.gates[<gate>] entry at the current tree hash.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} stage
|
|
173
|
+
* @param {string} gate
|
|
174
|
+
* @param {Array<ReturnType<typeof makeCheck>>} checks
|
|
175
|
+
* @param {object} [freezeCtx]
|
|
176
|
+
* @param {string | null} [freezeCtx.currentTreeHash]
|
|
177
|
+
* @param {Record<string, { state?: string, at?: { hash?: string } }> | undefined} [freezeCtx.freezeGates]
|
|
178
|
+
* @returns {{ stage: string, gate: string, state: 'passed'|'failing'|'acknowledged', checks: Array<ReturnType<typeof makeCheck>> }}
|
|
179
|
+
*/
|
|
180
|
+
export function foldState(stage, gate, checks, freezeCtx = {}) {
|
|
181
|
+
const anyFailing = checks.some((c) => !c.ok);
|
|
182
|
+
if (!anyFailing) return { stage, gate, state: 'passed', checks };
|
|
183
|
+
if (WARN_WITH_ACK.has(stage)) {
|
|
184
|
+
const gates = freezeCtx.freezeGates ?? {};
|
|
185
|
+
const record = gates[gate];
|
|
186
|
+
const ackedAtHash = record?.state === 'acknowledged'
|
|
187
|
+
&& typeof record?.at?.hash === 'string'
|
|
188
|
+
&& record.at.hash === freezeCtx.currentTreeHash;
|
|
189
|
+
if (ackedAtHash) return { stage, gate, state: 'acknowledged', checks };
|
|
190
|
+
}
|
|
191
|
+
return { stage, gate, state: 'failing', checks };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* `notApplicable` envelope helper.
|
|
196
|
+
* @param {string} stage
|
|
197
|
+
* @param {string} gate
|
|
198
|
+
* @param {string} reason
|
|
199
|
+
*/
|
|
200
|
+
function notApplicable(stage, gate, reason) {
|
|
201
|
+
return {
|
|
202
|
+
stage, gate, state: 'notApplicable', checks: [
|
|
203
|
+
makeCheck(`stage:${stage}:scope`, 'delta', 0, []),
|
|
204
|
+
], reason,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The set of ids in scope, as a Set for O(1) hit checks.
|
|
210
|
+
* @param {Iterable<string> | undefined} scope
|
|
211
|
+
* @returns {Set<string>}
|
|
212
|
+
*/
|
|
213
|
+
function asSet(scope) {
|
|
214
|
+
return scope instanceof Set ? scope : new Set(scope ?? []);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// D1 -- Brief intake and ledger (blocking in 0.29.0).
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* @typedef {object} StageContext
|
|
223
|
+
* @property {import('#core/store/walker.js').TreeModel} tree
|
|
224
|
+
* @property {import('./delta.js').LedgerBundle | undefined} ledgers
|
|
225
|
+
* @property {import('./delta.js').DeltaResult | undefined} delta
|
|
226
|
+
* @property {import('./delta.js').FreezeRecord | null | undefined} freeze
|
|
227
|
+
* @property {Iterable<string> | undefined} scope
|
|
228
|
+
* @property {Array<unknown> | undefined} validateErrors tree-wide walker + validate errors
|
|
229
|
+
* @property {number | undefined} probeOpenCount for D6 (probe ledger open entries)
|
|
230
|
+
* @property {import('./coverage.js').CoverageResult | undefined} coverageTree optional, unused by gates in 0.29.0
|
|
231
|
+
* @property {string | null | undefined} profileText rcf/.identity/profile.md contents (null when absent)
|
|
232
|
+
* @property {{ skipReviewFor?: string } | undefined} profile parsed profile switches (light shape)
|
|
233
|
+
* @property {string | null | undefined} currentTreeHash for warn-with-ack acknowledgement match
|
|
234
|
+
* @property {Array<{ state: string }> | undefined} priorStages D8 reads earlier stage states from this
|
|
235
|
+
*/
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* D1 -- Brief intake and ledger.
|
|
239
|
+
*
|
|
240
|
+
* @param {StageContext} ctx
|
|
241
|
+
*/
|
|
242
|
+
export function checkD1Brief(ctx) {
|
|
243
|
+
const gate = STAGE_GATES.D1;
|
|
244
|
+
const checks = [];
|
|
245
|
+
const brief = /** @type {any} */ (ctx.ledgers?.brief);
|
|
246
|
+
const statements = Array.isArray(brief?.statements) ? brief.statements : [];
|
|
247
|
+
const decisions = /** @type {any} */ (ctx.ledgers?.decisions);
|
|
248
|
+
const decisionEntries = Array.isArray(decisions?.decisions) ? decisions.decisions : [];
|
|
249
|
+
|
|
250
|
+
// Check 1: at least one statement since the freeze (proposal §3.2 D1
|
|
251
|
+
// "at least one statement since the freeze"). briefSince is the
|
|
252
|
+
// delta's own high-water-mark projection; on an unfrozen tree the
|
|
253
|
+
// rule is "the brief itself" so every statement counts.
|
|
254
|
+
const highWater = Number.isFinite(ctx.freeze?.briefStatements) ? Number(ctx.freeze?.briefStatements) : 0;
|
|
255
|
+
const since = statements.filter((s) => Number(s?.id) > highWater);
|
|
256
|
+
checks.push(makeCheck(
|
|
257
|
+
'brief:sinceFreeze',
|
|
258
|
+
'delta',
|
|
259
|
+
Math.max(since.length, 1),
|
|
260
|
+
since.length === 0 && (ctx.freeze ? true : statements.length === 0)
|
|
261
|
+
? [{ id: 'brief-ledger', why: ctx.freeze
|
|
262
|
+
? 'no new brief statement since the freeze'
|
|
263
|
+
: 'brief ledger holds no statements' }]
|
|
264
|
+
: [],
|
|
265
|
+
));
|
|
266
|
+
|
|
267
|
+
// Check 2: every statement kinded from the closed set.
|
|
268
|
+
const badKind = [];
|
|
269
|
+
for (const s of statements) {
|
|
270
|
+
if (!s || typeof s.kind !== 'string' || !BRIEF_KINDS.includes(/** @type {any} */ (s.kind))) {
|
|
271
|
+
badKind.push({ id: `brief:${s?.id ?? '?'}`, why: `unknown kind ${s?.kind ?? '(missing)'}` });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
checks.push(makeCheck('brief:kinds', 'tree', statements.length, badKind));
|
|
275
|
+
|
|
276
|
+
// Check 3: every openQuestion resolved or promoted to a decision.
|
|
277
|
+
const openQuestions = statements.filter((s) => s?.kind === 'openQuestion' && s?.status !== 'resolved');
|
|
278
|
+
// A promoted openQuestion is one whose text is quoted or referenced in
|
|
279
|
+
// a decisions-ledger entry's question field; a cheap presence check.
|
|
280
|
+
const promoted = new Set();
|
|
281
|
+
for (const q of openQuestions) {
|
|
282
|
+
const text = typeof q?.text === 'string' ? q.text.trim() : '';
|
|
283
|
+
if (!text) continue;
|
|
284
|
+
if (decisionEntries.some((d) => typeof d?.question === 'string' && d.question.includes(text))) {
|
|
285
|
+
promoted.add(q.id);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const unresolvedOpen = openQuestions.filter((q) => !promoted.has(q.id));
|
|
289
|
+
checks.push(makeCheck(
|
|
290
|
+
'brief:openQuestions',
|
|
291
|
+
'tree',
|
|
292
|
+
openQuestions.length,
|
|
293
|
+
unresolvedOpen.map((q) => ({ id: `brief:${q.id}`, why: 'openQuestion still open (not resolved and not promoted to a decision)' })),
|
|
294
|
+
));
|
|
295
|
+
|
|
296
|
+
// Check 4: profile.md carries surface + register markers (light
|
|
297
|
+
// presence check; the fuller elicitation prompt lives in slice 6).
|
|
298
|
+
const surfaceMarkers = ['viewer', 'runningApp', 'prDiff'];
|
|
299
|
+
const registerMarkers = ['productOwner', 'engineer', 'unstated'];
|
|
300
|
+
const profileText = ctx.profileText ?? '';
|
|
301
|
+
const surfaceHit = surfaceMarkers.some((m) => profileText.includes(m));
|
|
302
|
+
const registerHit = registerMarkers.some((m) => profileText.includes(m));
|
|
303
|
+
const profileFail = [];
|
|
304
|
+
if (!surfaceHit) profileFail.push({ id: 'profile:surface', why: 'profile.md is missing a review-surface marker (viewer | runningApp | prDiff)' });
|
|
305
|
+
if (!registerHit) profileFail.push({ id: 'profile:register', why: 'profile.md is missing a register marker (productOwner | engineer | unstated)' });
|
|
306
|
+
checks.push(makeCheck('brief:profile', 'tree', 2, profileFail));
|
|
307
|
+
|
|
308
|
+
return foldState('D1', gate, checks, {
|
|
309
|
+
currentTreeHash: ctx.currentTreeHash ?? null,
|
|
310
|
+
freezeGates: /** @type {any} */ (ctx.freeze?.gates),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// D2 -- Requirements and architecture skeleton (blocking in 0.29.0).
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
/** Kinds whose resolution D2 checks: capability / constraint / entity / actor / externalSystem / surface. */
|
|
319
|
+
const D2_RESOLVING_KINDS = new Set(['capability', 'constraint', 'entity', 'actor', 'externalSystem', 'surface']);
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* D2 -- Requirements and architecture skeleton.
|
|
323
|
+
*
|
|
324
|
+
* @param {StageContext} ctx
|
|
325
|
+
*/
|
|
326
|
+
export function checkD2Skeleton(ctx) {
|
|
327
|
+
const gate = STAGE_GATES.D2;
|
|
328
|
+
const scope = asSet(ctx.scope);
|
|
329
|
+
const tree = ctx.tree;
|
|
330
|
+
const brief = /** @type {any} */ (ctx.ledgers?.brief);
|
|
331
|
+
const statements = Array.isArray(brief?.statements) ? brief.statements : [];
|
|
332
|
+
const highWater = Number.isFinite(ctx.freeze?.briefStatements) ? Number(ctx.freeze?.briefStatements) : 0;
|
|
333
|
+
const stmtsInScope = statements.filter((s) => Number(s?.id) > highWater);
|
|
334
|
+
const resolvingStmts = stmtsInScope.filter((s) => D2_RESOLVING_KINDS.has(String(s?.kind)));
|
|
335
|
+
const reqInScope = (tree.requirements ?? []).filter((r) => scope.has(r.reqId));
|
|
336
|
+
const prdInScope = tree.prd && scope.has(tree.prd.prdId ?? '');
|
|
337
|
+
const tadInScope = tree.tad && scope.has(tree.tad.tadId ?? '');
|
|
338
|
+
|
|
339
|
+
if (resolvingStmts.length === 0 && reqInScope.length === 0 && !prdInScope && !tadInScope) {
|
|
340
|
+
return notApplicable('D2', gate, 'no resolving brief statements and no REQ / PRD / TAD in scope');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const checks = [];
|
|
344
|
+
|
|
345
|
+
// Check 1: every capability/constraint/entity/... statement resolves
|
|
346
|
+
// (the statement carries `resolvedBy` naming a REQ / TAD entity / TAC
|
|
347
|
+
// / recorded omission id, OR its text matches an existing REQ title).
|
|
348
|
+
// Cheap check: a `resolvedBy` string or a name-hit on titles.
|
|
349
|
+
const resolvedByStmt = (s) => {
|
|
350
|
+
const rb = s?.resolvedBy;
|
|
351
|
+
if (typeof rb === 'string' && rb.length > 0) return true;
|
|
352
|
+
if (rb && typeof rb === 'object' && Object.keys(rb).length > 0) return true;
|
|
353
|
+
const text = typeof s?.text === 'string' ? s.text.trim().toLowerCase() : '';
|
|
354
|
+
if (!text) return false;
|
|
355
|
+
for (const req of tree.requirements ?? []) {
|
|
356
|
+
const title = typeof req?.title === 'string' ? req.title.toLowerCase() : '';
|
|
357
|
+
if (title && text.includes(title)) return true;
|
|
358
|
+
}
|
|
359
|
+
return false;
|
|
360
|
+
};
|
|
361
|
+
const unresolvedStmts = resolvingStmts.filter((s) => !resolvedByStmt(s));
|
|
362
|
+
checks.push(makeCheck(
|
|
363
|
+
'skeleton:resolvedBy',
|
|
364
|
+
'delta',
|
|
365
|
+
resolvingStmts.length,
|
|
366
|
+
unresolvedStmts.map((s) => ({ id: `brief:${s.id}`, why: `${s.kind} statement has no resolvedBy and no REQ-title hit` })),
|
|
367
|
+
));
|
|
368
|
+
|
|
369
|
+
// Check 2: every REQ in scope has description, shapeClassification, domain.
|
|
370
|
+
const reqShapeFail = [];
|
|
371
|
+
for (const req of reqInScope) {
|
|
372
|
+
if (typeof req.description !== 'string' || req.description.length === 0 || TODO_RE.test(req.description)) {
|
|
373
|
+
reqShapeFail.push({ id: req.reqId, why: 'description missing or contains TODO placeholder' });
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (!req.shapeClassification || !Array.isArray(req.shapeClassification.shapes)) {
|
|
377
|
+
reqShapeFail.push({ id: req.reqId, why: 'shapeClassification.shapes missing' });
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (typeof req.domain !== 'string' || req.domain.length === 0) {
|
|
381
|
+
reqShapeFail.push({ id: req.reqId, why: 'domain missing' });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
checks.push(makeCheck('skeleton:reqFields', 'delta', reqInScope.length, reqShapeFail));
|
|
385
|
+
|
|
386
|
+
// Check 3: TAD.dataStores + coreEntities present when any persistence
|
|
387
|
+
// REQ exists tree-wide.
|
|
388
|
+
const persistenceReq = (tree.requirements ?? []).some((r) => Array.isArray(r?.shapeClassification?.shapes) && r.shapeClassification.shapes.includes('persistence'));
|
|
389
|
+
const tadFail = [];
|
|
390
|
+
if (persistenceReq) {
|
|
391
|
+
const tad = tree.tad ?? {};
|
|
392
|
+
const dataStores = Array.isArray(tad.dataStores) ? tad.dataStores : [];
|
|
393
|
+
const coreEntities = Array.isArray(tad.coreEntities) ? tad.coreEntities : [];
|
|
394
|
+
if (dataStores.length === 0) tadFail.push({ id: 'TAD.dataStores', why: 'TAD.dataStores empty while persistence REQ exists' });
|
|
395
|
+
if (coreEntities.length === 0) tadFail.push({ id: 'TAD.coreEntities', why: 'TAD.coreEntities empty while persistence REQ exists' });
|
|
396
|
+
}
|
|
397
|
+
checks.push(makeCheck('skeleton:tadPersistence', 'tree', persistenceReq ? 2 : 0, tadFail));
|
|
398
|
+
|
|
399
|
+
// Check 4: exactly one deploy or deferral ADR tree-wide. Cheap
|
|
400
|
+
// detection by title prefix (proposal §11 assumption).
|
|
401
|
+
const deployAdrs = (tree.adrs ?? []).filter((a) => {
|
|
402
|
+
const t = typeof a?.title === 'string' ? a.title : '';
|
|
403
|
+
return t.startsWith('Deploy target:') || t.startsWith('Deploy deferral:');
|
|
404
|
+
});
|
|
405
|
+
const deployFail = [];
|
|
406
|
+
if (deployAdrs.length === 0) deployFail.push({ id: 'ADR:deploy', why: 'no Deploy target or Deploy deferral ADR found (title-prefix scan)' });
|
|
407
|
+
else if (deployAdrs.length > 1) deployFail.push({ id: 'ADR:deploy', why: `${deployAdrs.length} Deploy ADRs found; expected exactly one` });
|
|
408
|
+
checks.push(makeCheck('skeleton:deployAdr', 'tree', 1, deployFail));
|
|
409
|
+
|
|
410
|
+
return foldState('D2', gate, checks, {
|
|
411
|
+
currentTreeHash: ctx.currentTreeHash ?? null,
|
|
412
|
+
freezeGates: /** @type {any} */ (ctx.freeze?.gates),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// D3 -- Interface contracts and shapes (warn-with-ack in 0.29.0).
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
/** Set form of INTERFACE_KINDS for O(1) checks. */
|
|
421
|
+
const INTERFACE_KINDS_SET = new Set(INTERFACE_KINDS);
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* D3 -- Interface contracts and shapes. 0.29.0 runs only the cheap
|
|
425
|
+
* presence + closed-vocabulary checks.
|
|
426
|
+
*
|
|
427
|
+
* SEAM 0.30: template markers per kind (recordShape.fields:, httpRoute
|
|
428
|
+
* method/path/request/response/errors, fixture.instances:); entity-name
|
|
429
|
+
* join across TAD.coreEntities and TAC.interfaces[]; ownerRef and
|
|
430
|
+
* `authoredAt` path resolution. Those add checks to this stage; the
|
|
431
|
+
* fold shape stays the same.
|
|
432
|
+
*
|
|
433
|
+
* @param {StageContext} ctx
|
|
434
|
+
*/
|
|
435
|
+
export function checkD3Shapes(ctx) {
|
|
436
|
+
const gate = STAGE_GATES.D3;
|
|
437
|
+
const scope = asSet(ctx.scope);
|
|
438
|
+
const tree = ctx.tree;
|
|
439
|
+
const tacsInScope = (tree.tacs ?? []).filter((t) => scope.has(t.tacId));
|
|
440
|
+
const shapedReqInScope = (tree.requirements ?? []).filter((r) => {
|
|
441
|
+
if (!scope.has(r.reqId)) return false;
|
|
442
|
+
const shapes = r.shapeClassification?.shapes ?? [];
|
|
443
|
+
return shapes.includes('httpApi') || shapes.includes('persistence') || shapes.includes('auth');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
if (tacsInScope.length === 0 && shapedReqInScope.length === 0) {
|
|
447
|
+
return notApplicable('D3', gate, 'no TAC and no shaped REQ (httpApi/persistence/auth) in scope');
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const checks = [];
|
|
451
|
+
|
|
452
|
+
// Check 1: every TAC in scope has at least one interface.
|
|
453
|
+
const noIfaceTacs = tacsInScope.filter((t) => !Array.isArray(t.interfaces) || t.interfaces.length === 0);
|
|
454
|
+
checks.push(makeCheck(
|
|
455
|
+
'shapes:tacHasInterface',
|
|
456
|
+
'delta',
|
|
457
|
+
tacsInScope.length,
|
|
458
|
+
noIfaceTacs.map((t) => ({ id: t.tacId, why: 'no interfaces authored' })),
|
|
459
|
+
));
|
|
460
|
+
|
|
461
|
+
// Check 2: every interface kind is in the closed vocabulary.
|
|
462
|
+
let totalInterfaces = 0;
|
|
463
|
+
const badKinds = [];
|
|
464
|
+
for (const tac of tacsInScope) {
|
|
465
|
+
for (const iface of tac.interfaces ?? []) {
|
|
466
|
+
totalInterfaces += 1;
|
|
467
|
+
const kind = iface?.kind;
|
|
468
|
+
if (typeof kind !== 'string' || !INTERFACE_KINDS_SET.has(kind)) {
|
|
469
|
+
badKinds.push({ id: `${tac.tacId}:${iface?.name ?? '(unnamed)'}`, why: `unknown interface kind ${JSON.stringify(kind)}` });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
checks.push(makeCheck('shapes:kindVocabulary', 'delta', totalInterfaces, badKinds));
|
|
474
|
+
|
|
475
|
+
return foldState('D3', gate, checks, {
|
|
476
|
+
currentTreeHash: ctx.currentTreeHash ?? null,
|
|
477
|
+
freezeGates: /** @type {any} */ (ctx.freeze?.gates),
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
// D4 -- Stories and criteria (blocking floors in 0.29.0).
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* D4 -- Stories and criteria floors. 0.29.0 enforces the four floors
|
|
487
|
+
* proposal §3.2 blocks on: every REQ has a US, every US has a testable
|
|
488
|
+
* AC, a failure AC (or opt-out per class), a must-not AC (or opt-out),
|
|
489
|
+
* tacIds non-empty and resolving, no scaffold TODO placeholder.
|
|
490
|
+
*
|
|
491
|
+
* SEAM 0.30: closed-set scan across enumerations; ownerRef resolution
|
|
492
|
+
* to TAC.interfaces[<name>]; template-marker parse. Warn-only notices
|
|
493
|
+
* in 0.29.0 (see D3).
|
|
494
|
+
*
|
|
495
|
+
* @param {StageContext} ctx
|
|
496
|
+
*/
|
|
497
|
+
export function checkD4Stories(ctx) {
|
|
498
|
+
const gate = STAGE_GATES.D4;
|
|
499
|
+
const scope = asSet(ctx.scope);
|
|
500
|
+
const tree = ctx.tree;
|
|
501
|
+
const manifest = /** @type {any} */ (tree.manifest);
|
|
502
|
+
const reqInScope = (tree.requirements ?? []).filter((r) => scope.has(r.reqId));
|
|
503
|
+
const usInScope = (tree.userStories ?? []).filter((us) => scope.has(us.usId));
|
|
504
|
+
|
|
505
|
+
if (reqInScope.length === 0 && usInScope.length === 0) {
|
|
506
|
+
return notApplicable('D4', gate, 'no REQ and no US in scope');
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const checks = [];
|
|
510
|
+
|
|
511
|
+
// Check 1: every REQ in scope has at least one US.
|
|
512
|
+
const reqWithoutUs = [];
|
|
513
|
+
for (const req of reqInScope) {
|
|
514
|
+
const hasUs = (tree.userStories ?? []).some((us) => us.reqId === req.reqId);
|
|
515
|
+
if (!hasUs) reqWithoutUs.push({ id: req.reqId, why: 'no owning user story' });
|
|
516
|
+
}
|
|
517
|
+
checks.push(makeCheck('stories:reqHasUs', 'delta', reqInScope.length, reqWithoutUs));
|
|
518
|
+
|
|
519
|
+
// Check 2: every US in scope carries the class floors + tacIds + testable AC + no TODO.
|
|
520
|
+
const usFail = [];
|
|
521
|
+
for (const us of usInScope) {
|
|
522
|
+
const acs = Array.isArray(us.acceptanceCriteria) ? us.acceptanceCriteria : [];
|
|
523
|
+
// testable
|
|
524
|
+
const hasTestable = acs.some((ac) => ac?.testable !== false && typeof ac?.description === 'string' && ac.description.length > 0);
|
|
525
|
+
if (!hasTestable) usFail.push({ id: us.usId, why: 'no testable acceptance criterion' });
|
|
526
|
+
// failure class
|
|
527
|
+
const hasFailure = acs.some((ac) => parseAcClass(ac) === 'failure');
|
|
528
|
+
const failureOpted = isOptedOut(manifest, us.reqId, 'defineD4:failure');
|
|
529
|
+
if (!hasFailure && !failureOpted) {
|
|
530
|
+
usFail.push({ id: us.usId, why: 'no [failure] class AC and no defineD4:failure opt-out on this REQ' });
|
|
531
|
+
}
|
|
532
|
+
// must-not class
|
|
533
|
+
const hasMustNot = acs.some((ac) => parseAcClass(ac) === 'must-not');
|
|
534
|
+
const mustNotOpted = isOptedOut(manifest, us.reqId, 'defineD4:mustNot');
|
|
535
|
+
if (!hasMustNot && !mustNotOpted) {
|
|
536
|
+
usFail.push({ id: us.usId, why: 'no [must-not] class AC and no defineD4:mustNot opt-out on this REQ' });
|
|
537
|
+
}
|
|
538
|
+
// tacIds non-empty and resolving
|
|
539
|
+
const tacIds = Array.isArray(us.tacIds) ? us.tacIds : [];
|
|
540
|
+
if (tacIds.length === 0) {
|
|
541
|
+
usFail.push({ id: us.usId, why: 'tacIds is empty' });
|
|
542
|
+
} else {
|
|
543
|
+
const unresolved = tacIds.filter((id) => tree.kindById?.get(id) !== 'tac');
|
|
544
|
+
if (unresolved.length > 0) {
|
|
545
|
+
usFail.push({ id: us.usId, why: `tacIds names non-TAC ids: ${unresolved.join(', ')}` });
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// no TODO placeholder in any AC description
|
|
549
|
+
const todoAc = acs.find((ac) => typeof ac?.description === 'string' && TODO_RE.test(ac.description));
|
|
550
|
+
if (todoAc) usFail.push({ id: us.usId, why: `scaffold TODO placeholder in ${todoAc.id ?? '(ac)'}` });
|
|
551
|
+
}
|
|
552
|
+
checks.push(makeCheck('stories:usFloors', 'delta', usInScope.length, usFail));
|
|
553
|
+
|
|
554
|
+
return foldState('D4', gate, checks, {
|
|
555
|
+
currentTreeHash: ctx.currentTreeHash ?? null,
|
|
556
|
+
freezeGates: /** @type {any} */ (ctx.freeze?.gates),
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
// D5 -- Cross-cutting weave (warn-with-ack in 0.29.0).
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* D5 -- Cross-cutting weave. 0.29.0 runs cheap presence checks.
|
|
566
|
+
*
|
|
567
|
+
* SEAM 0.30: full concern catalogue with per-shape applicability;
|
|
568
|
+
* per-(concern, REQ) pair enumeration; TAD securityArchitecture and
|
|
569
|
+
* operationalConcerns structure checks beyond presence.
|
|
570
|
+
*
|
|
571
|
+
* @param {StageContext} ctx
|
|
572
|
+
*/
|
|
573
|
+
export function checkD5Crosscut(ctx) {
|
|
574
|
+
const gate = STAGE_GATES.D5;
|
|
575
|
+
const scope = asSet(ctx.scope);
|
|
576
|
+
const tree = ctx.tree;
|
|
577
|
+
const reqInScope = (tree.requirements ?? []).filter((r) => scope.has(r.reqId));
|
|
578
|
+
|
|
579
|
+
if (reqInScope.length === 0) {
|
|
580
|
+
return notApplicable('D5', gate, 'no REQ in scope');
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const checks = [];
|
|
584
|
+
|
|
585
|
+
// Check 1: TAD.securityArchitecture non-empty when any REQ carries shape 'auth' or 'httpApi'.
|
|
586
|
+
const authApi = (tree.requirements ?? []).some((r) => {
|
|
587
|
+
const s = r.shapeClassification?.shapes ?? [];
|
|
588
|
+
return s.includes('auth') || s.includes('httpApi');
|
|
589
|
+
});
|
|
590
|
+
const tad = /** @type {any} */ (tree.tad ?? {});
|
|
591
|
+
const secFail = [];
|
|
592
|
+
if (authApi) {
|
|
593
|
+
const sa = tad.securityArchitecture;
|
|
594
|
+
if (!sa || (typeof sa === 'string' ? sa.length === 0 : Object.keys(sa).length === 0)) {
|
|
595
|
+
secFail.push({ id: 'TAD.securityArchitecture', why: 'empty while auth or httpApi REQ exists' });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
checks.push(makeCheck('crosscut:securityArchitecture', 'tree', authApi ? 1 : 0, secFail));
|
|
599
|
+
|
|
600
|
+
// Check 2: TAD.operationalConcerns non-empty when any AC description
|
|
601
|
+
// carries the '[deployed]' marker (a cheap presence signal that
|
|
602
|
+
// proposal §3.2 D5 lists as 'deployed-scope AC').
|
|
603
|
+
const hasDeployedAc = (tree.userStories ?? []).some((us) => (us.acceptanceCriteria ?? []).some((ac) => typeof ac?.description === 'string' && ac.description.includes('[deployed]')));
|
|
604
|
+
const opFail = [];
|
|
605
|
+
if (hasDeployedAc) {
|
|
606
|
+
const oc = tad.operationalConcerns;
|
|
607
|
+
if (!oc || (typeof oc === 'string' ? oc.length === 0 : Object.keys(oc).length === 0)) {
|
|
608
|
+
opFail.push({ id: 'TAD.operationalConcerns', why: 'empty while deployed-scope AC exists' });
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
checks.push(makeCheck('crosscut:operationalConcerns', 'tree', hasDeployedAc ? 1 : 0, opFail));
|
|
612
|
+
|
|
613
|
+
// Check 3: no open concern-ledger entry on a REQ in scope.
|
|
614
|
+
const concerns = /** @type {any} */ (ctx.ledgers?.concerns);
|
|
615
|
+
const concernEntries = Array.isArray(concerns?.concerns) ? concerns.concerns : [];
|
|
616
|
+
const openInScope = concernEntries.filter((c) => c?.status === 'open' && scope.has(c?.reqId));
|
|
617
|
+
checks.push(makeCheck(
|
|
618
|
+
'crosscut:concernsResolved',
|
|
619
|
+
'delta',
|
|
620
|
+
concernEntries.filter((c) => scope.has(c?.reqId)).length,
|
|
621
|
+
openInScope.map((c) => ({ id: `concern:${c.id}`, why: `open concern on ${c.reqId}: ${c.concern}` })),
|
|
622
|
+
));
|
|
623
|
+
|
|
624
|
+
return foldState('D5', gate, checks, {
|
|
625
|
+
currentTreeHash: ctx.currentTreeHash ?? null,
|
|
626
|
+
freezeGates: /** @type {any} */ (ctx.freeze?.gates),
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
// D6 -- Consistency and satisfiability probe (warn-with-ack in 0.29.0).
|
|
632
|
+
// ---------------------------------------------------------------------------
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* D6 -- Consistency and satisfiability probe. 0.29.0 runs only the
|
|
636
|
+
* validate-clean tree-wide check and the probe-ledger open count.
|
|
637
|
+
*
|
|
638
|
+
* SEAM 0.30: contradiction-lite across criteria on one story and
|
|
639
|
+
* across their TACs' ADRs; unsatisfiable-lite (a `then` naming
|
|
640
|
+
* undefined state); duplicate scan; orphan-interface scan;
|
|
641
|
+
* regeneration probe runner injectable like `mutationRunner` in
|
|
642
|
+
* src/review/index.js.
|
|
643
|
+
*
|
|
644
|
+
* @param {StageContext} ctx
|
|
645
|
+
*/
|
|
646
|
+
export function checkD6Consistency(ctx) {
|
|
647
|
+
const gate = STAGE_GATES.D6;
|
|
648
|
+
const checks = [];
|
|
649
|
+
|
|
650
|
+
const validateErrors = Array.isArray(ctx.validateErrors) ? ctx.validateErrors : [];
|
|
651
|
+
checks.push(makeCheck(
|
|
652
|
+
'consistency:validateClean',
|
|
653
|
+
'tree',
|
|
654
|
+
// "Expected zero" pattern: total stays at Math.max(N, 1) so the
|
|
655
|
+
// reported ratio reads sensibly on a clean tree ("1/1") and on a
|
|
656
|
+
// dirty one ("0/N"). Truncate the failing array for display but
|
|
657
|
+
// fold `pass` from the untruncated count so beyond-20 failures do
|
|
658
|
+
// not turn into false passes.
|
|
659
|
+
Math.max(validateErrors.length, 1),
|
|
660
|
+
validateErrors.slice(0, 20).map((e, i) => ({
|
|
661
|
+
id: /** @type {any} */ (e)?.documentId ?? `validate:${i}`,
|
|
662
|
+
why: /** @type {any} */ (e)?.message ?? 'validate error',
|
|
663
|
+
})),
|
|
664
|
+
validateErrors.length,
|
|
665
|
+
));
|
|
666
|
+
|
|
667
|
+
const probeOpenCount = Number.isFinite(ctx.probeOpenCount) ? Number(ctx.probeOpenCount) : 0;
|
|
668
|
+
const probes = /** @type {any} */ (ctx.ledgers?.probes);
|
|
669
|
+
const probeEntries = Array.isArray(probes?.probes) ? probes.probes : [];
|
|
670
|
+
const openProbes = probeEntries.filter((p) => p?.status === 'open');
|
|
671
|
+
// Prefer caller-supplied probeOpenCount when set; fall back to bundle scan.
|
|
672
|
+
const effectiveOpen = ctx.probeOpenCount !== undefined ? probeOpenCount : openProbes.length;
|
|
673
|
+
const failing = [];
|
|
674
|
+
if (effectiveOpen > 0) {
|
|
675
|
+
const list = ctx.probeOpenCount !== undefined
|
|
676
|
+
? [{ id: 'probe-ledger', why: `${effectiveOpen} open probe finding(s)` }]
|
|
677
|
+
: openProbes.slice(0, 20).map((p) => ({ id: `probe:${p.id}`, why: `open probe on ${p.reqId}: ${p.finding}` }));
|
|
678
|
+
failing.push(...list);
|
|
679
|
+
}
|
|
680
|
+
// Fold `pass` from the untruncated open-probe count so a 25-open-
|
|
681
|
+
// probes ledger reports 0/25, not 5/25. The failing array itself
|
|
682
|
+
// stays truncated to 20 for display; the caller-supplied count path
|
|
683
|
+
// uses a single representative entry regardless.
|
|
684
|
+
checks.push(makeCheck('consistency:probeCount', 'tree', Math.max(effectiveOpen, 1), failing, effectiveOpen));
|
|
685
|
+
|
|
686
|
+
return foldState('D6', gate, checks, {
|
|
687
|
+
currentTreeHash: ctx.currentTreeHash ?? null,
|
|
688
|
+
freezeGates: /** @type {any} */ (ctx.freeze?.gates),
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ---------------------------------------------------------------------------
|
|
693
|
+
// D7 -- Decisions and review (blocking in 0.29.0).
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* D7 -- Decisions and review.
|
|
698
|
+
*
|
|
699
|
+
* @param {StageContext} ctx
|
|
700
|
+
*/
|
|
701
|
+
export function checkD7Decisions(ctx) {
|
|
702
|
+
const gate = STAGE_GATES.D7;
|
|
703
|
+
const decisions = /** @type {any} */ (ctx.ledgers?.decisions);
|
|
704
|
+
const decisionEntries = Array.isArray(decisions?.decisions) ? decisions.decisions : [];
|
|
705
|
+
const openDecisions = decisionEntries.filter((d) => d?.status === 'open');
|
|
706
|
+
|
|
707
|
+
const delta = ctx.delta ?? { changed: [], added: [] };
|
|
708
|
+
const deltaDocs = new Set([...(delta.changed ?? []), ...(delta.added ?? [])]);
|
|
709
|
+
const deltaSize = [...deltaDocs].filter((id) => !id.startsWith('ledger:')).length;
|
|
710
|
+
const skipSingle = ctx.profile?.skipReviewFor === 'singleDocument' && deltaSize === 1;
|
|
711
|
+
if (openDecisions.length === 0 && skipSingle) {
|
|
712
|
+
return notApplicable('D7', gate, 'no open decisions and profile skipReviewFor:singleDocument on a one-document delta');
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const checks = [];
|
|
716
|
+
checks.push(makeCheck(
|
|
717
|
+
'decisions:allAnswered',
|
|
718
|
+
'tree',
|
|
719
|
+
decisionEntries.length,
|
|
720
|
+
openDecisions.map((d) => ({ id: `decision:${d.id}`, why: `open: ${d.question ?? '(no question)'}` })),
|
|
721
|
+
));
|
|
722
|
+
|
|
723
|
+
return foldState('D7', gate, checks, {
|
|
724
|
+
currentTreeHash: ctx.currentTreeHash ?? null,
|
|
725
|
+
freezeGates: /** @type {any} */ (ctx.freeze?.gates),
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ---------------------------------------------------------------------------
|
|
730
|
+
// D8 -- Freeze (blocking in 0.29.0).
|
|
731
|
+
// ---------------------------------------------------------------------------
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* D8 -- Freeze. Tree-wide, mechanical: prior gates passed or
|
|
735
|
+
* acknowledged, every AC owned by exactly one FBS, queue head
|
|
736
|
+
* actionable, validate clean.
|
|
737
|
+
*
|
|
738
|
+
* @param {StageContext} ctx
|
|
739
|
+
*/
|
|
740
|
+
export function checkD8Freeze(ctx) {
|
|
741
|
+
const gate = STAGE_GATES.D8;
|
|
742
|
+
const tree = ctx.tree;
|
|
743
|
+
const checks = [];
|
|
744
|
+
|
|
745
|
+
// Check 1: prior stages passed or acknowledged.
|
|
746
|
+
const priorStages = Array.isArray(ctx.priorStages) ? ctx.priorStages : [];
|
|
747
|
+
const priorBad = priorStages
|
|
748
|
+
.filter((s) => s.state !== 'passed' && s.state !== 'acknowledged' && s.state !== 'notApplicable')
|
|
749
|
+
.map((s) => ({ id: /** @type {any} */ (s).stage ?? '(?)', why: `state ${s.state}` }));
|
|
750
|
+
checks.push(makeCheck('freeze:priorGates', 'tree', priorStages.length, priorBad));
|
|
751
|
+
|
|
752
|
+
// Check 2: every AC owned by exactly one FBS. Walker gives us
|
|
753
|
+
// fbsByAcId inversion; we assert length === 1 for every known AC.
|
|
754
|
+
const acIds = collectAllAcIds(tree);
|
|
755
|
+
const acFail = [];
|
|
756
|
+
for (const acId of acIds) {
|
|
757
|
+
const owners = tree.fbsByAcId?.get(acId) ?? [];
|
|
758
|
+
if (owners.length === 0) acFail.push({ id: acId, why: 'no owning FBS' });
|
|
759
|
+
else if (owners.length > 1) acFail.push({ id: acId, why: `${owners.length} FBS own this AC: ${owners.join(', ')}` });
|
|
760
|
+
}
|
|
761
|
+
checks.push(makeCheck('freeze:acFbsOwnership', 'tree', acIds.size, acFail));
|
|
762
|
+
|
|
763
|
+
// Check 3: queue head actionable and not a placeholder.
|
|
764
|
+
const queue = computeQueue(tree);
|
|
765
|
+
const queueFail = [];
|
|
766
|
+
if (!queue.nextActionable) {
|
|
767
|
+
queueFail.push({ id: 'queue:head', why: 'no actionable FBS at the queue head' });
|
|
768
|
+
} else {
|
|
769
|
+
const head = (tree.fbsItems ?? []).find((f) => f.fbsId === queue.nextActionable);
|
|
770
|
+
const title = typeof head?.title === 'string' ? head.title.trim() : '';
|
|
771
|
+
if (!title || /^todo\b/i.test(title)) {
|
|
772
|
+
queueFail.push({ id: queue.nextActionable, why: 'queue head title is empty or a TODO placeholder' });
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
checks.push(makeCheck('freeze:queueHead', 'tree', 1, queueFail));
|
|
776
|
+
|
|
777
|
+
// Check 4: validate clean tree-wide (same signal as D6 but blocking here).
|
|
778
|
+
// Truncate the failing array for display; fold `pass` from the
|
|
779
|
+
// untruncated count so beyond-20 failures do not turn into false passes.
|
|
780
|
+
const validateErrors = Array.isArray(ctx.validateErrors) ? ctx.validateErrors : [];
|
|
781
|
+
const validateFail = validateErrors.slice(0, 20).map((e, i) => ({
|
|
782
|
+
id: /** @type {any} */ (e)?.documentId ?? `validate:${i}`,
|
|
783
|
+
why: /** @type {any} */ (e)?.message ?? 'validate error',
|
|
784
|
+
}));
|
|
785
|
+
checks.push(makeCheck('freeze:validateClean', 'tree', Math.max(validateErrors.length, 1), validateFail, validateErrors.length));
|
|
786
|
+
|
|
787
|
+
return foldState('D8', gate, checks, {
|
|
788
|
+
currentTreeHash: ctx.currentTreeHash ?? null,
|
|
789
|
+
freezeGates: /** @type {any} */ (ctx.freeze?.gates),
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Collect every AC id in the tree (walker keeps them inline on the
|
|
795
|
+
* parent US). Kept here to avoid depending on the private helper in
|
|
796
|
+
* `walker.js`.
|
|
797
|
+
*
|
|
798
|
+
* @param {import('#core/store/walker.js').TreeModel} tree
|
|
799
|
+
* @returns {Set<string>}
|
|
800
|
+
*/
|
|
801
|
+
function collectAllAcIds(tree) {
|
|
802
|
+
const out = new Set();
|
|
803
|
+
for (const us of tree.userStories ?? []) {
|
|
804
|
+
for (const ac of us.acceptanceCriteria ?? []) {
|
|
805
|
+
if (typeof ac?.id === 'string') out.add(ac.id);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return out;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Dispatch by stage id (D1..D8) or by short name (brief..freeze).
|
|
813
|
+
* Consumers call `runStage('D4', ctx)` or `runStage('stories', ctx)`.
|
|
814
|
+
*
|
|
815
|
+
* @param {string} stageOrShort
|
|
816
|
+
* @param {StageContext} ctx
|
|
817
|
+
*/
|
|
818
|
+
export function runStage(stageOrShort, ctx) {
|
|
819
|
+
const stage = STAGE_ALIASES[stageOrShort];
|
|
820
|
+
if (!stage) throw new TypeError(`Unknown stage '${stageOrShort}'`);
|
|
821
|
+
switch (stage) {
|
|
822
|
+
case 'D1': return checkD1Brief(ctx);
|
|
823
|
+
case 'D2': return checkD2Skeleton(ctx);
|
|
824
|
+
case 'D3': return checkD3Shapes(ctx);
|
|
825
|
+
case 'D4': return checkD4Stories(ctx);
|
|
826
|
+
case 'D5': return checkD5Crosscut(ctx);
|
|
827
|
+
case 'D6': return checkD6Consistency(ctx);
|
|
828
|
+
case 'D7': return checkD7Decisions(ctx);
|
|
829
|
+
case 'D8': return checkD8Freeze(ctx);
|
|
830
|
+
default: throw new TypeError(`Unhandled stage '${stage}'`);
|
|
831
|
+
}
|
|
832
|
+
}
|