rcf-lite 0.32.4 → 0.33.0
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 +42 -0
- package/fixtures/canary-manifest.json +9 -9
- package/guidance/discovery/README.md +22 -0
- package/guidance/discovery/discovery-viewer-command-page-1-layout.html +191 -0
- package/guidance/discovery/discovery-viewer-command-page-2-coverage.html +193 -0
- package/guidance/discovery/discovery-viewer-command-page-3-trace-matrix.html +195 -0
- package/guidance/discovery/discovery-viewer-command-page-4-tables.html +196 -0
- package/guidance/discovery/discovery-viewer-command-page-5-subtabs.html +224 -0
- package/guidance/discovery/discovery-viewer-command-page-6-one-page.html +280 -0
- package/guidance/discovery/discovery-viewer-command-page.md +301 -0
- package/package.json +1 -1
- package/rcf/adrs/adr-4139-readiness-tables-read-only-no-commands.json +30 -0
- package/rcf/adrs/adr-4140-view-query-routes-per-pivot-matrix-no-mermaid.json +30 -0
- package/rcf/adrs/adr-4141-verify-modes.json +25 -0
- package/rcf/code-nodes/cn-771.json +3 -2
- package/rcf/code-nodes/cn-779.json +19 -0
- package/rcf/code-nodes/cn-780.json +14 -0
- package/rcf/code-nodes/cn-781.json +14 -0
- package/rcf/code-nodes/cn-782.json +15 -0
- package/rcf/code-nodes/cn-783.json +17 -0
- package/rcf/code-nodes/cn-784.json +20 -0
- package/rcf/code-nodes/cn-785.json +14 -0
- package/rcf/code-nodes/cn-786.json +14 -0
- package/rcf/code-nodes/cn-787.json +18 -0
- package/rcf/code-nodes/cn-788.json +14 -0
- package/rcf/code-nodes/cn-789.json +14 -0
- package/rcf/code-nodes/cn-790.json +15 -0
- package/rcf/code-nodes/cn-791.json +14 -0
- package/rcf/code-nodes/cn-792.json +17 -0
- package/rcf/code-nodes/cn-793.json +17 -0
- package/rcf/code-nodes/cn-794.json +18 -0
- package/rcf/code-nodes/cn-795.json +15 -0
- package/rcf/code-nodes/cn-796.json +14 -0
- package/rcf/code-nodes/cn-797.json +14 -0
- package/rcf/code-nodes/cn-798.json +14 -0
- package/rcf/define/brief-ledger.json +47 -0
- package/rcf/define/decisions-ledger.json +113 -0
- package/rcf/fbs/fbs-204.json +46 -0
- package/rcf/fbs/fbs-205.json +46 -0
- package/rcf/fbs/fbs-206.json +49 -0
- package/rcf/fbs/fbs-207.json +43 -0
- package/rcf/fbs/fbs-208.json +47 -0
- package/rcf/fbs/fbs-209.json +22 -0
- package/rcf/requirements/req-002.json +5 -2
- package/rcf/requirements/req-180.json +6 -3
- package/rcf/tacs/tac-4127-readiness-view.json +2 -2
- package/rcf/tacs/tac-4133-readiness-po-layer.json +2 -2
- package/rcf/tacs/tac-4135-readiness-command-page.json +94 -0
- package/rcf/tacs/tac-4136-view-query-routes.json +68 -0
- package/rcf/tacs/tac-4141-verify-modes.json +25 -0
- package/rcf/test-suites/ts-223.json +7 -7
- package/rcf/test-suites/ts-237.json +78 -0
- package/rcf/test-suites/ts-238.json +70 -0
- package/rcf/test-suites/ts-239.json +78 -0
- package/rcf/test-suites/ts-240.json +70 -0
- package/rcf/test-suites/ts-241.json +70 -0
- package/rcf/test-suites/ts-242.json +46 -0
- package/rcf/user-stories/us-16503.json +51 -0
- package/rcf/user-stories/us-18001.json +4 -4
- package/rcf/user-stories/us-18003.json +79 -0
- package/rcf/user-stories/us-18004.json +70 -0
- package/rcf/user-stories/us-18005.json +79 -0
- package/rcf/user-stories/us-208.json +74 -0
- package/rcf/user-stories/us-209.json +70 -0
- package/releases/releases.yaml +18 -1
- package/src/cli/finalise.js +141 -2
- package/src/cli/view.js +13 -1
- package/src/define/ledgers.js +9 -1
- package/src/finalise/spawn.js +2 -0
- package/src/server/index.js +104 -39
- package/src/server/routes.js +169 -0
- package/src/verify/cli/run.js +40 -0
- package/src/verify/engine/deterministic.js +150 -0
- package/src/verify/engine/index.js +27 -4
- package/src/view/doc-renderers/req.js +11 -9
- package/src/view/html-page.js +15 -11
- package/src/view/index.js +7 -1
- package/src/view/live-client.js +4 -0
- package/src/view/page-init.js +842 -1
- package/src/view/readiness/composite-id.js +35 -0
- package/src/view/readiness/coverage-summary.js +293 -0
- package/src/view/readiness/delta-counts.js +156 -0
- package/src/view/readiness/grid.js +175 -0
- package/src/view/readiness/po-layer.js +110 -220
- package/src/view/readiness/tables.js +500 -0
- package/src/view/readiness/thin-reqs.js +237 -0
- package/src/view/readiness/trace-matrix.js +392 -0
- package/src/view/readiness.js +359 -370
- package/src/view/style.css +594 -0
- package/src/watch/index.js +75 -17
package/src/view/readiness.js
CHANGED
|
@@ -1,24 +1,33 @@
|
|
|
1
|
-
// Readiness tab renderer (REQ-180, TAC-
|
|
2
|
-
// Pure HTML string builder
|
|
3
|
-
// `computeReadiness` in
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// rewalk (see `renderModelToPage`) so the Readiness tab and
|
|
7
|
-
// `rcf define readiness --json` cannot disagree for the same tree.
|
|
1
|
+
// Readiness tab renderer (REQ-180, TAC-4135-readiness-command-page,
|
|
2
|
+
// TAC-4133, TAC-4127, ADR-4139). Pure HTML string builder; takes the
|
|
3
|
+
// ReadinessResult (as produced by `computeReadiness` in
|
|
4
|
+
// `src/query/readiness.js`) plus the operator profile text and the
|
|
5
|
+
// walker tree model, emits the tabpanel body.
|
|
8
6
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
7
|
+
// FBS-206 reshape: the panel is a SubTabStrip (Overview, Questions,
|
|
8
|
+
// Blocking, Coverage, Trace) with five sub-panels; one is visible at
|
|
9
|
+
// a time. For-engineers DocRow, stage chips and stage-detail retired
|
|
10
|
+
// (viewer-read-only ADR-4139 and Dave's FBS-204/206 binding ruling).
|
|
11
|
+
// "On me" / "On engineers" vocabulary replaces persona pills in the
|
|
12
|
+
// user-facing text. The freeze state renders in plain words with
|
|
13
|
+
// what resolves each failing gate. The Trace sub-view is an empty
|
|
14
|
+
// shell here; FBS-208 fills it.
|
|
15
|
+
//
|
|
16
|
+
// Every id (passing or failing) still lives in the DOM in some
|
|
17
|
+
// sub-panel so AC-18001-5 and AC-18002-3 ("no id leaves the DOM")
|
|
18
|
+
// stay true: a hidden sub-panel is `[hidden]`, not removed.
|
|
14
19
|
|
|
15
20
|
import { escapeHtml } from './doc-renderers/helpers.js';
|
|
16
21
|
import { formatTreeLine, formatVerdictLines, shortHash } from '../query/readiness.js';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
+
import { renderSubTabStrip } from './components/sub-tab-strip.js';
|
|
23
|
+
import { renderVerdictCards } from './readiness/po-layer.js';
|
|
24
|
+
import { renderQuestionsTable, renderBlockingTable, buildQuestionRows, buildBlockingRows } from './readiness/tables.js';
|
|
25
|
+
import { renderCoverageSummary } from './readiness/coverage-summary.js';
|
|
26
|
+
import { buildThinReqRows, renderThinReqsTable } from './readiness/thin-reqs.js';
|
|
27
|
+
import { renderVerdictGrid } from './readiness/grid.js';
|
|
28
|
+
import { renderDeltaCounts } from './readiness/delta-counts.js';
|
|
29
|
+
import { renderStageLegend } from './readiness/stage-legend.js';
|
|
30
|
+
import { toQuestions, preferReadinessQuestions } from './readiness/question-adapter.js';
|
|
22
31
|
|
|
23
32
|
/**
|
|
24
33
|
* @typedef {import('../query/readiness.js').ReadinessResult} ReadinessResult
|
|
@@ -27,11 +36,15 @@ import { renderStageLegend, stageTitle } from './readiness/stage-legend.js';
|
|
|
27
36
|
* @typedef {import('../query/readiness.js').Blocker} Blocker
|
|
28
37
|
*/
|
|
29
38
|
|
|
39
|
+
/** Sub-tab keys in authoring order (first = default). */
|
|
40
|
+
export const READINESS_SUBS = ['overview', 'questions', 'blocking', 'coverage', 'trace'];
|
|
41
|
+
|
|
30
42
|
/**
|
|
31
43
|
* Decide the register ordering from `profile.md` text. Same contract
|
|
32
|
-
* as the CLI's `pickRegister` helper
|
|
33
|
-
*
|
|
34
|
-
*
|
|
44
|
+
* as the CLI's `pickRegister` helper. The register no longer flips
|
|
45
|
+
* the layout: the Overview is the default landing and the sub-tab
|
|
46
|
+
* router restores sub= from the hash. The register is still a
|
|
47
|
+
* content hint (text uses "On me" / "On engineers" based on it).
|
|
35
48
|
*
|
|
36
49
|
* @param {string | null | undefined} text
|
|
37
50
|
* @returns {'productOwner'|'engineer'|'unstated'}
|
|
@@ -55,12 +68,14 @@ export function pickRegister(text) {
|
|
|
55
68
|
/**
|
|
56
69
|
* Build the full Readiness tabpanel inner HTML.
|
|
57
70
|
*
|
|
58
|
-
* @param {ReadinessResult | null} result
|
|
59
|
-
* object, or null when the tree could not be computed
|
|
71
|
+
* @param {ReadinessResult | null} result
|
|
60
72
|
* @param {object} [opts]
|
|
61
|
-
* @param {string | null} [opts.profile]
|
|
62
|
-
* @param {object | null} [opts.freezeRecord]
|
|
63
|
-
*
|
|
73
|
+
* @param {string | null} [opts.profile]
|
|
74
|
+
* @param {object | null} [opts.freezeRecord]
|
|
75
|
+
* @param {object | null} [opts.tree]
|
|
76
|
+
* @param {string} [opts.activeSub] sub-tab key the server should mark active
|
|
77
|
+
* (defaults to 'overview'; the client router
|
|
78
|
+
* takes over after load)
|
|
64
79
|
* @returns {string}
|
|
65
80
|
*/
|
|
66
81
|
export function renderReadinessPanel(result, opts = {}) {
|
|
@@ -69,370 +84,248 @@ export function renderReadinessPanel(result, opts = {}) {
|
|
|
69
84
|
}
|
|
70
85
|
const register = pickRegister(opts.profile ?? null);
|
|
71
86
|
const freezeRecord = opts.freezeRecord ?? null;
|
|
87
|
+
const tree = opts.tree ?? null;
|
|
88
|
+
const activeSub = READINESS_SUBS.includes(opts.activeSub) ? opts.activeSub : 'overview';
|
|
89
|
+
|
|
90
|
+
const subTabs = renderSubTabStrip({
|
|
91
|
+
hashKey: 'readiness',
|
|
92
|
+
items: [
|
|
93
|
+
{ key: 'overview', label: 'Overview', controls: 'rcf-readiness-sub-overview' },
|
|
94
|
+
{ key: 'questions', label: 'Questions', controls: 'rcf-readiness-sub-questions' },
|
|
95
|
+
{ key: 'blocking', label: 'Blocking', controls: 'rcf-readiness-sub-blocking' },
|
|
96
|
+
{ key: 'coverage', label: 'Coverage', controls: 'rcf-readiness-sub-coverage' },
|
|
97
|
+
{ key: 'trace', label: 'Trace', controls: 'rcf-readiness-sub-trace' },
|
|
98
|
+
],
|
|
99
|
+
active: activeSub,
|
|
100
|
+
});
|
|
101
|
+
|
|
72
102
|
const verdictLines = formatVerdictLines(result);
|
|
103
|
+
const q = toQuestions(result);
|
|
104
|
+
const questionsSource = preferReadinessQuestions(result) ? 'readiness.questions' : 'blockers';
|
|
105
|
+
const questionRows = buildQuestionRowsForTable(q);
|
|
106
|
+
const blockingRows = buildBlockingRows(Array.isArray(result.stages) ? result.stages : []);
|
|
107
|
+
const thinRows = buildThinReqRows(result, tree);
|
|
108
|
+
|
|
109
|
+
const overview = renderOverviewSub({
|
|
110
|
+
result,
|
|
111
|
+
verdictLines,
|
|
112
|
+
q,
|
|
113
|
+
freezeRecord,
|
|
114
|
+
register,
|
|
115
|
+
active: activeSub === 'overview',
|
|
116
|
+
});
|
|
117
|
+
const questions = renderQuestionsSub({
|
|
118
|
+
questionRows,
|
|
119
|
+
questionsSource,
|
|
120
|
+
active: activeSub === 'questions',
|
|
121
|
+
});
|
|
122
|
+
const blocking = renderBlockingSub({
|
|
123
|
+
blockingRows,
|
|
124
|
+
active: activeSub === 'blocking',
|
|
125
|
+
});
|
|
126
|
+
const coverage = renderCoverageSub({
|
|
127
|
+
result,
|
|
128
|
+
tree,
|
|
129
|
+
thinRows,
|
|
130
|
+
active: activeSub === 'coverage',
|
|
131
|
+
});
|
|
132
|
+
const trace = renderTraceSub({ active: activeSub === 'trace' });
|
|
133
|
+
|
|
134
|
+
// AC-18003-8: engineer register renders the Blocking sub-panel
|
|
135
|
+
// before the Questions sub-panel in the DOM so the engineer surface
|
|
136
|
+
// takes DOM precedence. Product owner and the default keep Questions
|
|
137
|
+
// first (matches AC-18001-4). The SubTabStrip's visible labels keep
|
|
138
|
+
// their authoring order; the ARIA aria-controls links each tab to
|
|
139
|
+
// its panel by id, so swapping panel order does not break the strip
|
|
140
|
+
// or the router.
|
|
141
|
+
const paintOrder = register === 'engineer'
|
|
142
|
+
? [overview, blocking, questions, coverage, trace]
|
|
143
|
+
: [overview, questions, blocking, coverage, trace];
|
|
73
144
|
|
|
74
|
-
const engineerBody = [
|
|
75
|
-
renderTreeLine(result),
|
|
76
|
-
renderVerdicts(result, verdictLines),
|
|
77
|
-
renderNextActions(result, register),
|
|
78
|
-
renderStageChips(result, freezeRecord),
|
|
79
|
-
renderBlockersByPersona(result, register),
|
|
80
|
-
renderDelta(result, freezeRecord),
|
|
81
|
-
renderStageDetail(result),
|
|
82
|
-
renderCoverage(result),
|
|
83
|
-
renderDecisions(result),
|
|
84
|
-
renderFreezeNow(result),
|
|
85
|
-
renderFreezeRecord(freezeRecord),
|
|
86
|
-
].join('\n');
|
|
87
|
-
|
|
88
|
-
const poLayer = renderReadinessPO(result, { persona: register, engineerBody });
|
|
89
145
|
const legend = renderStageLegend();
|
|
90
|
-
return
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
function renderTreeLine(result) {
|
|
96
|
-
return `<div class="rcf-readiness-tree"><p>${escapeHtml(formatTreeLine(result))}</p></div>`;
|
|
146
|
+
return `<div class="rcf-readiness-panel" data-rcf-register="${escapeHtml(register)}" data-rcf-source="${escapeHtml(questionsSource)}">`
|
|
147
|
+
+ subTabs
|
|
148
|
+
+ paintOrder.join('')
|
|
149
|
+
+ legend
|
|
150
|
+
+ `</div>`;
|
|
97
151
|
}
|
|
98
152
|
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
153
|
+
// ---- Overview sub-view ---------------------------------------------------
|
|
154
|
+
//
|
|
155
|
+
// Carries: tree line, two VerdictCards (count-links into Questions /
|
|
156
|
+
// Blocking sub-tabs), the stage verdict grid, the delta counts (per-
|
|
157
|
+
// document list behind a collapsed <details>), decisions outstanding,
|
|
158
|
+
// freeze state (plain words), freeze record. The CLI verdictLines
|
|
159
|
+
// render inside the Overview too so AC-18002-3 ("every id in the DOM")
|
|
160
|
+
// stays true across sub-views and the --json parity test
|
|
161
|
+
// (AC-18001-7) keeps passing.
|
|
162
|
+
|
|
163
|
+
function renderOverviewSub({ result, verdictLines, q, freezeRecord, register, active }) {
|
|
164
|
+
const cards = renderVerdictCards(result, verdictLines, q);
|
|
165
|
+
const treeLine = renderTreeLine(result);
|
|
166
|
+
const grid = renderVerdictGrid({ stages: result.stages, freezeRecord });
|
|
167
|
+
const deltaCounts = renderDeltaCounts({ delta: result.delta, freezeRecord, expanded: false });
|
|
168
|
+
const decisions = renderDecisions(result);
|
|
169
|
+
const nextActions = renderNextActionBlock(result, register);
|
|
170
|
+
const freezeState = renderFreezeState(result, register);
|
|
171
|
+
const freezeRec = renderFreezeRecord(freezeRecord);
|
|
172
|
+
const verdictParity = renderCliVerdictLines(verdictLines);
|
|
173
|
+
const hidden = active ? '' : ' hidden';
|
|
174
|
+
return `<div class="rcf-readiness-subpanel rcf-readiness-sub-overview" id="rcf-readiness-sub-overview" data-rcf-subpanel="overview" role="tabpanel"${hidden}>`
|
|
175
|
+
+ treeLine
|
|
176
|
+
+ cards
|
|
177
|
+
+ verdictParity
|
|
178
|
+
+ grid
|
|
179
|
+
+ deltaCounts
|
|
180
|
+
+ decisions
|
|
181
|
+
+ nextActions
|
|
182
|
+
+ freezeState
|
|
183
|
+
+ freezeRec
|
|
120
184
|
+ `</div>`;
|
|
121
185
|
}
|
|
122
186
|
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
187
|
+
// ---- Next actions (AC-18005-4: engineer surface carries a named
|
|
188
|
+
// next-action block) ------------------------------------------------
|
|
189
|
+
//
|
|
190
|
+
// Renders personas.productOwner.nextAction and personas.engineer.nextAction
|
|
191
|
+
// as plain words: "resolve the <plainLabel> failing items in the
|
|
192
|
+
// blocking table" with the id list named inline. No command text, no
|
|
193
|
+
// Run button; the chain fragment (stage/check) rides in data- attributes
|
|
194
|
+
// so the engineer or product owner reads what the row says and acts in
|
|
195
|
+
// their own agent session.
|
|
196
|
+
|
|
197
|
+
function renderNextActionBlock(result, register) {
|
|
198
|
+
const po = result.personas && result.personas.productOwner ? result.personas.productOwner.nextAction : null;
|
|
199
|
+
const eng = result.personas && result.personas.engineer ? result.personas.engineer.nextAction : null;
|
|
200
|
+
const poLine = renderNextActionLine('product owner', po, 'productOwner');
|
|
201
|
+
const engLine = renderNextActionLine('engineer', eng, 'engineer');
|
|
202
|
+
const order = register === 'engineer' ? [engLine, poLine] : [poLine, engLine];
|
|
203
|
+
return `<section class="rcf-readiness-next-actions" data-rcf-register="${escapeHtml(register)}">`
|
|
204
|
+
+ `<h3>Next action</h3>`
|
|
132
205
|
+ order.join('')
|
|
133
|
-
+ `</
|
|
206
|
+
+ `</section>`;
|
|
134
207
|
}
|
|
135
208
|
|
|
136
209
|
function renderNextActionLine(label, action, persona) {
|
|
210
|
+
const ownerLabel = persona === 'engineer' ? 'engineer' : 'product owner';
|
|
137
211
|
if (!action) {
|
|
138
|
-
return `<p class="rcf-readiness-next-action rcf-readiness-next-action--${persona}">`
|
|
139
|
-
+ `<strong
|
|
212
|
+
return `<p class="rcf-readiness-next-action rcf-readiness-next-action--${escapeHtml(persona)}" data-rcf-persona="${escapeHtml(persona)}">`
|
|
213
|
+
+ `<strong>${escapeHtml(ownerLabel)}:</strong> none.`
|
|
140
214
|
+ `</p>`;
|
|
141
215
|
}
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
216
|
+
const stage = typeof action.stage === 'string' ? action.stage : '';
|
|
217
|
+
const check = typeof action.check === 'string' ? action.check : '';
|
|
218
|
+
const plain = checkToPlainLabel(check);
|
|
219
|
+
const ids = Array.isArray(action.ids) ? action.ids.filter((x) => typeof x === 'string') : [];
|
|
220
|
+
const idText = ids.length > 0 ? ` on ${ids.join(', ')}` : '';
|
|
221
|
+
const resolver = plain
|
|
222
|
+
? `resolve the ${plain} failing items in the blocking table`
|
|
223
|
+
: `resolve the failing items named in the blocking table`;
|
|
224
|
+
return `<p class="rcf-readiness-next-action rcf-readiness-next-action--${escapeHtml(persona)}" data-rcf-persona="${escapeHtml(persona)}" data-rcf-stage="${escapeHtml(stage)}" data-rcf-check="${escapeHtml(check)}">`
|
|
225
|
+
+ `<strong>${escapeHtml(ownerLabel)}:</strong> ${escapeHtml(resolver)}${escapeHtml(idText)}.`
|
|
149
226
|
+ `</p>`;
|
|
150
227
|
}
|
|
151
228
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
function renderStageChips(result, freezeRecord) {
|
|
155
|
-
const chips = result.stages.map((s) => {
|
|
156
|
-
const stateClass = chipStateClass(s.state);
|
|
157
|
-
const legendTitle = stageTitle(s.stage);
|
|
158
|
-
let title = `${s.stage} ${s.gate}: ${s.state}`;
|
|
159
|
-
if (legendTitle && legendTitle !== s.stage) title = `${legendTitle} (${s.state})`;
|
|
160
|
-
if (s.state === 'notApplicable' && s.reason) title = `${title} - ${s.reason}`;
|
|
161
|
-
if (s.state === 'acknowledged' && freezeRecord && freezeRecord.gates && freezeRecord.gates[s.gate]) {
|
|
162
|
-
const ack = freezeRecord.gates[s.gate];
|
|
163
|
-
if (ack && typeof ack.reason === 'string' && ack.reason.length > 0) {
|
|
164
|
-
title = `${title} - ${ack.reason}`;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
const label = `${s.stage}: ${s.state}`;
|
|
168
|
-
return `<li class="rcf-readiness-chip rcf-readiness-chip--${stateClass}" data-rcf-stage-ref="${s.stage}">`
|
|
169
|
-
+ pill({ value: label, variant: 'gate-state', title })
|
|
170
|
-
+ `</li>`;
|
|
171
|
-
}).join('');
|
|
172
|
-
return `<ul class="rcf-readiness-chips">${chips}</ul>`;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function chipStateClass(state) {
|
|
176
|
-
switch (state) {
|
|
177
|
-
case 'passed':
|
|
178
|
-
return 'green';
|
|
179
|
-
case 'failing':
|
|
180
|
-
return 'red';
|
|
181
|
-
case 'acknowledged':
|
|
182
|
-
return 'amber';
|
|
183
|
-
case 'notApplicable':
|
|
184
|
-
return 'grey';
|
|
185
|
-
default:
|
|
186
|
-
return 'grey';
|
|
187
|
-
}
|
|
229
|
+
function renderTreeLine(result) {
|
|
230
|
+
return `<div class="rcf-readiness-tree"><p>${escapeHtml(formatTreeLine(result))}</p></div>`;
|
|
188
231
|
}
|
|
189
232
|
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const eng = result.personas.engineer.blockers;
|
|
195
|
-
const stages = Array.isArray(result.stages) ? result.stages : [];
|
|
196
|
-
const poBlock = renderPersonaGroup('productOwner', 'Product owner', 'question', po, register, 'open', stages);
|
|
197
|
-
const engBlock = renderPersonaGroup('engineer', 'Engineer', 'check blocking', eng, register, 'closed', stages);
|
|
198
|
-
// When the register is engineer, the engineer group opens first and
|
|
199
|
-
// the PO group collapses; `unstated` and `productOwner` keep PO open.
|
|
200
|
-
const [first, second] = register === 'engineer'
|
|
201
|
-
? [renderPersonaGroup('engineer', 'Engineer', 'check blocking', eng, register, 'open', stages),
|
|
202
|
-
renderPersonaGroup('productOwner', 'Product owner', 'question', po, register, 'closed', stages)]
|
|
203
|
-
: [poBlock, engBlock];
|
|
204
|
-
return `<section class="rcf-readiness-blockers-by-persona" data-rcf-register="${register}">`
|
|
205
|
-
+ first + second
|
|
206
|
-
+ `</section>`;
|
|
207
|
-
}
|
|
233
|
+
// The CLI verdict strings render as a muted parity block inside
|
|
234
|
+
// Overview. AC-18001-7 (every id on the tab appears in the --json for
|
|
235
|
+
// the same tree) and AC-18002-3 (engineer ids never leave the DOM)
|
|
236
|
+
// both rely on these strings being present in the rendered HTML.
|
|
208
237
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
* @param {'productOwner'|'engineer'|'unstated'} register
|
|
215
|
-
* @param {'open'|'closed'} state
|
|
216
|
-
* @param {StageResult[]} stages - threaded so each blocker's ids can
|
|
217
|
-
* carry the matching failing `why` from `stages[].checks[].failing[]`
|
|
218
|
-
* (spec section 5 block 5).
|
|
219
|
-
*/
|
|
220
|
-
function renderPersonaGroup(persona, heading, singular, blockers, register, state, stages) {
|
|
221
|
-
const plural = singular === 'question' ? 'questions' : 'checks blocking';
|
|
222
|
-
const count = blockers.length;
|
|
223
|
-
const noun = count === 1 ? singular : plural;
|
|
224
|
-
const summary = `${heading}: ${count} ${noun}`;
|
|
225
|
-
const openAttr = state === 'open' ? ' open' : '';
|
|
226
|
-
if (count === 0) {
|
|
227
|
-
return `<details class="rcf-readiness-persona rcf-readiness-persona--${persona}" data-rcf-persona-state="${state}"${openAttr}>`
|
|
228
|
-
+ `<summary><strong>${escapeHtml(heading)}:</strong> 0 ${plural}.</summary>`
|
|
229
|
-
+ `</details>`;
|
|
230
|
-
}
|
|
231
|
-
const items = blockers.map((b) => renderBlockerCard(b, stages)).join('');
|
|
232
|
-
return `<details class="rcf-readiness-persona rcf-readiness-persona--${persona}" data-rcf-persona-state="${state}"${openAttr}>`
|
|
233
|
-
+ `<summary><strong>${escapeHtml(summary)}</strong></summary>`
|
|
234
|
-
+ items
|
|
235
|
-
+ `</details>`;
|
|
238
|
+
function renderCliVerdictLines(verdictLines) {
|
|
239
|
+
return `<p class="rcf-readiness-cli-verdict muted small" data-rcf-verdict="parity">`
|
|
240
|
+
+ `<code>${escapeHtml(verdictLines.intentComplete)}</code> `
|
|
241
|
+
+ `<code>${escapeHtml(verdictLines.readyToBuild)}</code>`
|
|
242
|
+
+ `</p>`;
|
|
236
243
|
}
|
|
237
244
|
|
|
238
|
-
|
|
239
|
-
* Build an id→why map for a blocker by looking up the matching
|
|
240
|
-
* `stages[].checks[].failing[]` entries. Ids missing a `why` map to
|
|
241
|
-
* the empty string so the findings-list renders the id alone.
|
|
242
|
-
*
|
|
243
|
-
* @param {Blocker} b
|
|
244
|
-
* @param {StageResult[]} stages
|
|
245
|
-
* @returns {Record<string, string>}
|
|
246
|
-
*/
|
|
247
|
-
function buildIdWhyMap(b, stages) {
|
|
248
|
-
/** @type {Record<string, string>} */
|
|
249
|
-
const map = {};
|
|
250
|
-
const stage = Array.isArray(stages) ? stages.find((s) => s && s.stage === b.stage) : null;
|
|
251
|
-
if (!stage || !Array.isArray(stage.checks)) return map;
|
|
252
|
-
const check = stage.checks.find((c) => c && c.name === b.check);
|
|
253
|
-
if (!check || !Array.isArray(check.failing)) return map;
|
|
254
|
-
for (const f of check.failing) {
|
|
255
|
-
if (!f || typeof f !== 'object') continue;
|
|
256
|
-
const id = typeof f.id === 'string' ? f.id : null;
|
|
257
|
-
if (!id) continue;
|
|
258
|
-
map[id] = typeof f.why === 'string' ? f.why : '';
|
|
259
|
-
}
|
|
260
|
-
return map;
|
|
261
|
-
}
|
|
245
|
+
// ---- Questions sub-view --------------------------------------------------
|
|
262
246
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
// Each id is a document id; the hash routing in html-page.js resolves
|
|
269
|
-
// `#<id>` to the right tab. The `why` per id lives on the matching
|
|
270
|
-
// `stages[].checks[].failing[]` entry — spec section 5 block 5.
|
|
271
|
-
const whyById = buildIdWhyMap(b, stages);
|
|
272
|
-
const items = (b.ids ?? []).slice(0, 20).map((id) => ({
|
|
273
|
-
id,
|
|
274
|
-
why: whyById[id] ?? '',
|
|
275
|
-
href: `#${id}`,
|
|
276
|
-
}));
|
|
277
|
-
return `<div class="rcf-readiness-blocker" data-rcf-stage="${escapeHtml(b.stage)}" data-rcf-check="${escapeHtml(b.check)}" data-rcf-persona="${escapeHtml(b.persona)}">`
|
|
278
|
-
+ `<div class="rcf-readiness-blocker__meta">`
|
|
279
|
-
+ `<span class="rcf-readiness-blocker__stage">${escapeHtml(b.stage)}</span> `
|
|
280
|
-
+ `<span class="rcf-readiness-blocker__check">${escapeHtml(b.check)}</span> `
|
|
281
|
-
+ pill({ value: b.persona === 'productOwner' ? 'PO' : 'eng', variant: 'persona', title: b.persona })
|
|
282
|
-
+ ` <span class="rcf-readiness-blocker__over">over ${escapeHtml(b.over)}</span>`
|
|
283
|
-
+ `</div>`
|
|
284
|
-
+ findingsList({ heading: b.question, count: b.failingCount, items })
|
|
247
|
+
function renderQuestionsSub({ questionRows, questionsSource, active }) {
|
|
248
|
+
const hidden = active ? '' : ' hidden';
|
|
249
|
+
const table = renderQuestionsTable({ rows: questionRows });
|
|
250
|
+
return `<div class="rcf-readiness-subpanel rcf-readiness-sub-questions" id="rcf-readiness-sub-questions" data-rcf-subpanel="questions" data-rcf-source="${escapeHtml(questionsSource)}" role="tabpanel"${hidden}>`
|
|
251
|
+
+ table
|
|
285
252
|
+ `</div>`;
|
|
286
253
|
}
|
|
287
254
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
+ `</section>`
|
|
305
|
-
: '';
|
|
306
|
-
const changedBlock = changedCount > 0
|
|
307
|
-
? `<section class="rcf-readiness-delta__group rcf-readiness-delta__group--documents">`
|
|
308
|
-
+ `<h4>Documents changed <span class="rcf-readiness-delta__count">${changedCount}</span></h4>`
|
|
309
|
-
+ `<ul>`
|
|
310
|
-
+ (d.added ?? []).map((id) => `<li><a href="#${escapeHtml(id)}">${escapeHtml(id)}</a> <em>added</em>${renderDocDiff(id, frozenHashes, currentHashes, 'added')}</li>`).join('')
|
|
311
|
-
+ (d.changed ?? []).map((id) => `<li><a href="#${escapeHtml(id)}">${escapeHtml(id)}</a> <em>changed</em>${renderDocDiff(id, frozenHashes, currentHashes, 'changed')}</li>`).join('')
|
|
312
|
-
+ (d.removed ?? []).map((id) => `<li>${escapeHtml(id)} <em>removed</em>${renderDocDiff(id, frozenHashes, currentHashes, 'removed')}</li>`).join('')
|
|
313
|
-
+ `</ul>`
|
|
314
|
-
+ `</section>`
|
|
315
|
-
: '';
|
|
316
|
-
if (!briefBlock && !changedBlock) {
|
|
317
|
-
return `<section class="rcf-readiness-delta"><p><em>No delta since the last freeze.</em></p></section>`;
|
|
255
|
+
function buildQuestionRowsForTable(adapterResult) {
|
|
256
|
+
const groups = Array.isArray(adapterResult?.groups) ? adapterResult.groups : [];
|
|
257
|
+
const optional = adapterResult?.optional ?? null;
|
|
258
|
+
const synthetic = [];
|
|
259
|
+
for (const g of groups) {
|
|
260
|
+
const items = Array.isArray(g?.items) ? g.items : [];
|
|
261
|
+
for (const it of items) {
|
|
262
|
+
synthetic.push({
|
|
263
|
+
itemId: it.itemId ?? it.id ?? '',
|
|
264
|
+
ask: it.ask,
|
|
265
|
+
hint: it.hint,
|
|
266
|
+
sourceSpanLabel: g.label,
|
|
267
|
+
checkId: it.checkId,
|
|
268
|
+
optional: Boolean(it.optional),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
318
271
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
function renderDocDiff(id, frozenHashes, currentHashes, kind) {
|
|
333
|
-
const frozenHash = typeof frozenHashes?.[id] === 'string' ? frozenHashes[id] : null;
|
|
334
|
-
const currentHash = typeof currentHashes?.[id] === 'string' ? currentHashes[id] : null;
|
|
335
|
-
const beforeNote = 'frozen body not captured in freeze record 0.29.0';
|
|
336
|
-
const before = kind === 'added'
|
|
337
|
-
? { note: 'not in the frozen tree' }
|
|
338
|
-
: { hash: frozenHash ?? '', note: beforeNote };
|
|
339
|
-
const after = kind === 'removed'
|
|
340
|
-
? { note: 'removed from the live tree' }
|
|
341
|
-
: { hash: currentHash ?? '', note: 'current body available on the document tab' };
|
|
342
|
-
return ` ${diff(before, after)}`;
|
|
272
|
+
if (optional && Array.isArray(optional.items)) {
|
|
273
|
+
for (const it of optional.items) {
|
|
274
|
+
synthetic.push({
|
|
275
|
+
itemId: it.itemId ?? '',
|
|
276
|
+
ask: it.ask,
|
|
277
|
+
hint: it.hint,
|
|
278
|
+
sourceSpanLabel: optional.label,
|
|
279
|
+
checkId: it.checkId,
|
|
280
|
+
optional: true,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return buildQuestionRows(synthetic);
|
|
343
285
|
}
|
|
344
286
|
|
|
345
|
-
//
|
|
287
|
+
// ---- Blocking sub-view ---------------------------------------------------
|
|
346
288
|
|
|
347
|
-
function
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
+
|
|
352
|
-
+ `</
|
|
289
|
+
function renderBlockingSub({ blockingRows, active }) {
|
|
290
|
+
const hidden = active ? '' : ' hidden';
|
|
291
|
+
const table = renderBlockingTable({ rows: blockingRows });
|
|
292
|
+
return `<div class="rcf-readiness-subpanel rcf-readiness-sub-blocking" id="rcf-readiness-sub-blocking" data-rcf-subpanel="blocking" role="tabpanel"${hidden}>`
|
|
293
|
+
+ table
|
|
294
|
+
+ `</div>`;
|
|
353
295
|
}
|
|
354
296
|
|
|
355
|
-
|
|
356
|
-
* @param {StageResult} s
|
|
357
|
-
*/
|
|
358
|
-
function renderStage(s) {
|
|
359
|
-
const chip = pill({ value: s.state, variant: 'gate-state', title: `${s.stage} ${s.gate}` });
|
|
360
|
-
const checks = s.checks.map((c) => renderCheck(s, c)).join('');
|
|
361
|
-
return `<section class="rcf-readiness-stage" id="rcf-readiness-stage-${escapeHtml(s.stage)}">`
|
|
362
|
-
+ `<h4>${escapeHtml(s.stage)} <code>${escapeHtml(s.gate)}</code> ${chip}</h4>`
|
|
363
|
-
+ checks
|
|
364
|
-
+ `</section>`;
|
|
365
|
-
}
|
|
297
|
+
// ---- Coverage sub-view ---------------------------------------------------
|
|
366
298
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
value: c.persona === 'productOwner' ? 'PO' : 'eng',
|
|
375
|
-
variant: 'persona',
|
|
376
|
-
title: c.persona,
|
|
377
|
-
});
|
|
378
|
-
const statusLabel = c.ok ? 'ok' : 'fail';
|
|
379
|
-
const items = c.ok
|
|
380
|
-
? ''
|
|
381
|
-
: findingsList({
|
|
382
|
-
heading: c.question,
|
|
383
|
-
count: c.failing.length,
|
|
384
|
-
items: c.failing.map((f) => ({ id: f.id, why: f.why, href: `#${f.id}` })),
|
|
385
|
-
});
|
|
386
|
-
return `<div class="rcf-readiness-check" id="${escapeHtml(checkId)}" data-rcf-check-ok="${c.ok ? 'yes' : 'no'}">`
|
|
387
|
-
+ `<div class="rcf-readiness-check__meta">`
|
|
388
|
-
+ `<strong>${escapeHtml(c.name)}</strong> ${personaPill} `
|
|
389
|
-
+ `<span class="rcf-readiness-check__over">over ${escapeHtml(c.over)}</span> `
|
|
390
|
-
+ `<span class="rcf-readiness-check__counts">${c.pass}/${c.total}</span> `
|
|
391
|
-
+ `<span class="rcf-readiness-check__status rcf-readiness-check__status--${statusLabel}">${statusLabel}</span>`
|
|
392
|
-
+ `</div>`
|
|
393
|
-
+ items
|
|
299
|
+
function renderCoverageSub({ result, tree, thinRows, active }) {
|
|
300
|
+
const hidden = active ? '' : ' hidden';
|
|
301
|
+
const summary = renderCoverageSummary({ coverage: result.coverage, tree });
|
|
302
|
+
const thin = renderThinReqsTable({ rows: thinRows });
|
|
303
|
+
return `<div class="rcf-readiness-subpanel rcf-readiness-sub-coverage" id="rcf-readiness-sub-coverage" data-rcf-subpanel="coverage" role="tabpanel"${hidden}>`
|
|
304
|
+
+ summary
|
|
305
|
+
+ thin
|
|
394
306
|
+ `</div>`;
|
|
395
307
|
}
|
|
396
308
|
|
|
397
|
-
//
|
|
398
|
-
|
|
399
|
-
function
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
? `<ul>${liveDelta.map((cv) => `<li>${escapeHtml(cv.reqId ?? cv.scope ?? 'delta')}: ${escapeHtml(String(cv.pass ?? ''))} / ${escapeHtml(String(cv.total ?? ''))}</li>`).join('')}</ul>`
|
|
413
|
-
: `<p><em>No per-REQ delta coverage.</em></p>`;
|
|
414
|
-
return `<section class="rcf-readiness-coverage">`
|
|
415
|
-
+ `<h3>Coverage</h3>`
|
|
416
|
-
+ `<div class="rcf-readiness-coverage__cols">`
|
|
417
|
-
+ `<div class="rcf-readiness-coverage__col rcf-readiness-coverage__col--tree"><h4>Tree</h4>${treeCol}</div>`
|
|
418
|
-
+ `<div class="rcf-readiness-coverage__col rcf-readiness-coverage__col--delta"><h4>Delta</h4>${deltaRows}</div>`
|
|
309
|
+
// ---- Trace sub-view (FBS-208, US-209, TAC-4136, ADR-4140) ----------------
|
|
310
|
+
|
|
311
|
+
function renderTraceSub({ active }) {
|
|
312
|
+
const hidden = active ? '' : ' hidden';
|
|
313
|
+
// Initial markup: the empty-state renderer (no pivot yet). The
|
|
314
|
+
// page-init.js `wireTraceMatrix` fetches `./trace.json` and
|
|
315
|
+
// `./coverage.json` and re-renders the inner `[data-rcf-trace-root]`
|
|
316
|
+
// when the hash carries `#tab=readiness&sub=trace&entity=<id>`.
|
|
317
|
+
return `<div class="rcf-readiness-subpanel rcf-readiness-sub-trace" id="rcf-readiness-sub-trace" data-rcf-subpanel="trace" role="tabpanel"${hidden}>`
|
|
318
|
+
+ `<div data-rcf-trace-root="yes">`
|
|
319
|
+
+ `<section class="rcf-trace-matrix rcf-trace-matrix--empty" data-rcf-trace-matrix="empty" data-rcf-trace-reason="no-pivot" aria-labelledby="rcf-readiness-trace-heading">`
|
|
320
|
+
+ `<header class="rcf-trace-matrix__head"><h3 id="rcf-readiness-trace-heading">Pick an id to trace</h3></header>`
|
|
321
|
+
+ `<p>Pick a requirement, story or criterion through the Trace action on any readiness row, or open the lookup to search by id.</p>`
|
|
322
|
+
+ `<p><button type="button" class="rcf-trace-matrix__lookup" data-rcf-trace-open-lookup="yes">Open the lookup</button></p>`
|
|
323
|
+
+ `</section>`
|
|
419
324
|
+ `</div>`
|
|
420
|
-
+
|
|
421
|
-
+ `re-verify: ${impactedCount} · re-execute: ${impactedFbsCount}`
|
|
422
|
-
+ `</p>`
|
|
423
|
-
+ `</section>`;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
function isMeaningfulCoverageRow(cv) {
|
|
427
|
-
if (!cv || typeof cv !== 'object') return false;
|
|
428
|
-
const hasLabel = (typeof cv.reqId === 'string' && cv.reqId.length > 0)
|
|
429
|
-
|| (typeof cv.scope === 'string' && cv.scope.length > 0);
|
|
430
|
-
const passSet = cv.pass !== null && cv.pass !== undefined && cv.pass !== '';
|
|
431
|
-
const totalSet = cv.total !== null && cv.total !== undefined && cv.total !== '';
|
|
432
|
-
return hasLabel || passSet || totalSet;
|
|
325
|
+
+ `</div>`;
|
|
433
326
|
}
|
|
434
327
|
|
|
435
|
-
//
|
|
328
|
+
// ---- Decisions outstanding -----------------------------------------------
|
|
436
329
|
|
|
437
330
|
function renderDecisions(result) {
|
|
438
331
|
const items = Array.isArray(result.decisions) ? result.decisions : [];
|
|
@@ -451,31 +344,122 @@ function renderDecisions(result) {
|
|
|
451
344
|
return `<section class="rcf-readiness-decisions"><h3>Decisions outstanding</h3><ol>${list}</ol></section>`;
|
|
452
345
|
}
|
|
453
346
|
|
|
454
|
-
//
|
|
347
|
+
// ---- Freeze state (plain words, no command, no control) -----------------
|
|
348
|
+
//
|
|
349
|
+
// AC-18005-5: readyToBuild=no names each failing gate with what
|
|
350
|
+
// resolves it; readyToBuild=yes states that the tree can be frozen
|
|
351
|
+
// and what a freeze would record. "On me" / "On engineers" vocab
|
|
352
|
+
// replaces persona pills.
|
|
455
353
|
|
|
456
|
-
function
|
|
354
|
+
function renderFreezeState(result, register) {
|
|
457
355
|
const ok = result.levels.readyToBuild.ok;
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
356
|
+
const freezeableAttr = ok ? 'yes' : 'no';
|
|
357
|
+
if (ok) {
|
|
358
|
+
const shortCurrent = escapeHtml(shortHash(result.tree.currentTreeHash));
|
|
359
|
+
return `<section class="rcf-readiness-freeze-state" data-rcf-freezeable="${freezeableAttr}">`
|
|
360
|
+
+ `<h3>Freeze state</h3>`
|
|
361
|
+
+ `<p>Ready to freeze. A freeze would record the current tree hash <code>${shortCurrent}</code>, the timestamp, the note, the counts and the acknowledged gates with their reasons.</p>`
|
|
362
|
+
+ `</section>`;
|
|
363
|
+
}
|
|
364
|
+
const blockers = Array.isArray(result.levels.readyToBuild.blockedBy) ? result.levels.readyToBuild.blockedBy : [];
|
|
365
|
+
const grouped = groupBlockersByGate(blockers);
|
|
366
|
+
if (grouped.length === 0) {
|
|
367
|
+
return `<section class="rcf-readiness-freeze-state" data-rcf-freezeable="${freezeableAttr}">`
|
|
368
|
+
+ `<h3>Freeze state</h3>`
|
|
369
|
+
+ `<p>Not ready to freeze.</p>`
|
|
370
|
+
+ `</section>`;
|
|
371
|
+
}
|
|
372
|
+
const items = grouped.map((g) => {
|
|
373
|
+
const owner = g.ownerLabel;
|
|
374
|
+
const resolver = resolverSentence(g.gate, g.checks);
|
|
375
|
+
const stageWord = gateToPlainStage(g.gate);
|
|
376
|
+
const lead = stageWord
|
|
377
|
+
? `the ${stageWord} stage is held`
|
|
378
|
+
: 'this stage is held';
|
|
379
|
+
// Each item leads with a plain-English sentence naming the stage
|
|
380
|
+
// and what resolves it (AC-18005-5). The chain gate id follows
|
|
381
|
+
// in <code> so an engineer scanning the block can jump into the
|
|
382
|
+
// chain (AC-18001-6 pins this locator).
|
|
383
|
+
return `<li data-rcf-gate="${escapeHtml(g.gate)}" data-rcf-owner="${escapeHtml(g.owner)}">`
|
|
384
|
+
+ `<span class="muted small">(${escapeHtml(owner)})</span> ${escapeHtml(lead)}: ${escapeHtml(resolver)} `
|
|
385
|
+
+ `<code>${escapeHtml(g.gate)}</code>`
|
|
386
|
+
+ `</li>`;
|
|
387
|
+
}).join('');
|
|
388
|
+
// The gate labels are fixed: 'On me' names the product owner's
|
|
389
|
+
// own gates, 'On engineers' names the gates handed over. The lead
|
|
390
|
+
// sentence states that invariant verbatim for both registers so
|
|
391
|
+
// the vocabulary never flips when the engineer surface renders.
|
|
392
|
+
const lead = 'The build is held by these gates. "On me" names the product owner\'s own gates, "On engineers" names the ones handed over.';
|
|
393
|
+
return `<section class="rcf-readiness-freeze-state" data-rcf-freezeable="${freezeableAttr}">`
|
|
394
|
+
+ `<h3>Freeze state</h3>`
|
|
395
|
+
+ `<p>Not ready to freeze. ${escapeHtml(lead)}</p>`
|
|
396
|
+
+ `<ul class="rcf-readiness-freeze-state__gates">${items}</ul>`
|
|
475
397
|
+ `</section>`;
|
|
476
398
|
}
|
|
477
399
|
|
|
478
|
-
|
|
400
|
+
/** Group blockers by gate, folding owner (persona) to the shared label. */
|
|
401
|
+
function groupBlockersByGate(blockers) {
|
|
402
|
+
const byGate = new Map();
|
|
403
|
+
for (const b of blockers) {
|
|
404
|
+
if (!b || typeof b !== 'object') continue;
|
|
405
|
+
const gate = typeof b.gate === 'string' ? b.gate : '';
|
|
406
|
+
if (!gate) continue;
|
|
407
|
+
const slot = byGate.get(gate) || { gate, owners: new Set(), checks: new Set() };
|
|
408
|
+
if (b.persona === 'productOwner' || b.persona === 'engineer') slot.owners.add(b.persona);
|
|
409
|
+
if (typeof b.check === 'string') slot.checks.add(b.check);
|
|
410
|
+
byGate.set(gate, slot);
|
|
411
|
+
}
|
|
412
|
+
const rows = [];
|
|
413
|
+
for (const slot of byGate.values()) {
|
|
414
|
+
const owners = Array.from(slot.owners);
|
|
415
|
+
const owner = owners.length === 1 ? owners[0] : (owners.includes('productOwner') ? 'productOwner' : 'engineer');
|
|
416
|
+
rows.push({
|
|
417
|
+
gate: slot.gate,
|
|
418
|
+
owner,
|
|
419
|
+
ownerLabel: owner === 'productOwner' ? 'On me' : 'On engineers',
|
|
420
|
+
checks: Array.from(slot.checks).sort(),
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
rows.sort((a, b) => a.gate.localeCompare(b.gate));
|
|
424
|
+
return rows;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Plain-English sentence naming what resolves the gate, derived from
|
|
429
|
+
* its failing checks. Keeps the viewer command-free: the engineer or
|
|
430
|
+
* product owner reads what the row says and acts in their own agent
|
|
431
|
+
* session.
|
|
432
|
+
*/
|
|
433
|
+
function resolverSentence(gate, checks) {
|
|
434
|
+
if (checks.length === 0) return `the ${gate} gate still has failing items in the blocking table.`;
|
|
435
|
+
const labels = checks.map(checkToPlainLabel).filter(Boolean);
|
|
436
|
+
const uniq = Array.from(new Set(labels));
|
|
437
|
+
if (uniq.length === 0) return `resolve the failing items for ${gate} named in the blocking table.`;
|
|
438
|
+
if (uniq.length === 1) return `resolve the ${uniq[0]} failing items in the blocking table.`;
|
|
439
|
+
const last = uniq.pop();
|
|
440
|
+
return `resolve the ${uniq.join(', ')} and ${last} failing items in the blocking table.`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function checkToPlainLabel(check) {
|
|
444
|
+
if (typeof check !== 'string' || check.length === 0) return '';
|
|
445
|
+
// e.g. "brief:sinceFreeze" -> "brief", "stories:usFloors" -> "stories",
|
|
446
|
+
// "crosscut:securityArchitecture" -> "crosscut".
|
|
447
|
+
const idx = check.indexOf(':');
|
|
448
|
+
return idx === -1 ? check : check.slice(0, idx);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Map a gate id ("define.brief", "define.stories", "define.crosscut",
|
|
452
|
+
// "define.probe", "define.freeze", ...) to a short plain-English stage
|
|
453
|
+
// word for the freeze-state items. Returns the second segment lowercased
|
|
454
|
+
// (or the full id when there is no separator) so the user-facing text
|
|
455
|
+
// reads "the brief stage is held" rather than citing a chain id.
|
|
456
|
+
function gateToPlainStage(gate) {
|
|
457
|
+
if (typeof gate !== 'string' || gate.length === 0) return '';
|
|
458
|
+
const idx = gate.indexOf('.');
|
|
459
|
+
return idx === -1 ? gate : gate.slice(idx + 1);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ---- Freeze record -------------------------------------------------------
|
|
479
463
|
|
|
480
464
|
function renderFreezeRecord(freezeRecord) {
|
|
481
465
|
if (!freezeRecord) {
|
|
@@ -491,7 +475,12 @@ function renderFreezeRecord(freezeRecord) {
|
|
|
491
475
|
: '';
|
|
492
476
|
const gates = freezeRecord.gates && typeof freezeRecord.gates === 'object'
|
|
493
477
|
? Object.entries(freezeRecord.gates)
|
|
494
|
-
.map(([k, v]) =>
|
|
478
|
+
.map(([k, v]) => {
|
|
479
|
+
const atReason = v && typeof v === 'object' && v.at && typeof v.at === 'object' && typeof v.at.reason === 'string' ? v.at.reason : null;
|
|
480
|
+
const flatReason = v && typeof v === 'object' && typeof v.reason === 'string' ? v.reason : null;
|
|
481
|
+
const reason = atReason !== null && atReason.length > 0 ? atReason : (flatReason !== null && flatReason.length > 0 ? flatReason : String(v ?? ''));
|
|
482
|
+
return `<li><code>${escapeHtml(k)}</code>: ${escapeHtml(reason)}</li>`;
|
|
483
|
+
})
|
|
495
484
|
.join('')
|
|
496
485
|
: '';
|
|
497
486
|
return `<section class="rcf-readiness-freeze-record">`
|