rcf-lite 0.32.5 → 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.
Files changed (88) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/fixtures/canary-manifest.json +9 -9
  3. package/guidance/discovery/discovery-viewer-command-page-1-layout.html +191 -0
  4. package/guidance/discovery/discovery-viewer-command-page-2-coverage.html +193 -0
  5. package/guidance/discovery/discovery-viewer-command-page-3-trace-matrix.html +195 -0
  6. package/guidance/discovery/discovery-viewer-command-page-4-tables.html +196 -0
  7. package/guidance/discovery/discovery-viewer-command-page-5-subtabs.html +224 -0
  8. package/guidance/discovery/discovery-viewer-command-page-6-one-page.html +280 -0
  9. package/guidance/discovery/discovery-viewer-command-page.md +301 -0
  10. package/package.json +1 -1
  11. package/rcf/adrs/adr-4139-readiness-tables-read-only-no-commands.json +30 -0
  12. package/rcf/adrs/adr-4140-view-query-routes-per-pivot-matrix-no-mermaid.json +30 -0
  13. package/rcf/adrs/adr-4141-verify-modes.json +25 -0
  14. package/rcf/code-nodes/cn-771.json +3 -2
  15. package/rcf/code-nodes/cn-779.json +19 -0
  16. package/rcf/code-nodes/cn-780.json +14 -0
  17. package/rcf/code-nodes/cn-781.json +14 -0
  18. package/rcf/code-nodes/cn-782.json +15 -0
  19. package/rcf/code-nodes/cn-783.json +17 -0
  20. package/rcf/code-nodes/cn-784.json +20 -0
  21. package/rcf/code-nodes/cn-785.json +14 -0
  22. package/rcf/code-nodes/cn-786.json +14 -0
  23. package/rcf/code-nodes/cn-787.json +18 -0
  24. package/rcf/code-nodes/cn-788.json +14 -0
  25. package/rcf/code-nodes/cn-789.json +14 -0
  26. package/rcf/code-nodes/cn-790.json +15 -0
  27. package/rcf/code-nodes/cn-791.json +14 -0
  28. package/rcf/code-nodes/cn-792.json +17 -0
  29. package/rcf/code-nodes/cn-793.json +17 -0
  30. package/rcf/code-nodes/cn-794.json +18 -0
  31. package/rcf/code-nodes/cn-795.json +15 -0
  32. package/rcf/code-nodes/cn-796.json +14 -0
  33. package/rcf/code-nodes/cn-797.json +14 -0
  34. package/rcf/code-nodes/cn-798.json +14 -0
  35. package/rcf/define/brief-ledger.json +47 -0
  36. package/rcf/define/decisions-ledger.json +113 -0
  37. package/rcf/fbs/fbs-204.json +46 -0
  38. package/rcf/fbs/fbs-205.json +46 -0
  39. package/rcf/fbs/fbs-206.json +49 -0
  40. package/rcf/fbs/fbs-207.json +43 -0
  41. package/rcf/fbs/fbs-208.json +47 -0
  42. package/rcf/fbs/fbs-209.json +22 -0
  43. package/rcf/requirements/req-002.json +5 -2
  44. package/rcf/requirements/req-180.json +6 -3
  45. package/rcf/tacs/tac-4127-readiness-view.json +2 -2
  46. package/rcf/tacs/tac-4133-readiness-po-layer.json +2 -2
  47. package/rcf/tacs/tac-4135-readiness-command-page.json +94 -0
  48. package/rcf/tacs/tac-4136-view-query-routes.json +68 -0
  49. package/rcf/tacs/tac-4141-verify-modes.json +25 -0
  50. package/rcf/test-suites/ts-223.json +7 -7
  51. package/rcf/test-suites/ts-237.json +78 -0
  52. package/rcf/test-suites/ts-238.json +70 -0
  53. package/rcf/test-suites/ts-239.json +78 -0
  54. package/rcf/test-suites/ts-240.json +70 -0
  55. package/rcf/test-suites/ts-241.json +70 -0
  56. package/rcf/test-suites/ts-242.json +46 -0
  57. package/rcf/user-stories/us-16503.json +51 -0
  58. package/rcf/user-stories/us-18001.json +4 -4
  59. package/rcf/user-stories/us-18003.json +79 -0
  60. package/rcf/user-stories/us-18004.json +70 -0
  61. package/rcf/user-stories/us-18005.json +79 -0
  62. package/rcf/user-stories/us-208.json +74 -0
  63. package/rcf/user-stories/us-209.json +70 -0
  64. package/releases/releases.yaml +10 -1
  65. package/src/cli/finalise.js +141 -2
  66. package/src/cli/view.js +13 -1
  67. package/src/finalise/spawn.js +2 -0
  68. package/src/server/index.js +104 -39
  69. package/src/server/routes.js +169 -0
  70. package/src/verify/cli/run.js +40 -0
  71. package/src/verify/engine/deterministic.js +150 -0
  72. package/src/verify/engine/index.js +27 -4
  73. package/src/view/doc-renderers/req.js +11 -9
  74. package/src/view/html-page.js +15 -11
  75. package/src/view/index.js +7 -1
  76. package/src/view/live-client.js +4 -0
  77. package/src/view/page-init.js +842 -1
  78. package/src/view/readiness/composite-id.js +35 -0
  79. package/src/view/readiness/coverage-summary.js +293 -0
  80. package/src/view/readiness/delta-counts.js +156 -0
  81. package/src/view/readiness/grid.js +175 -0
  82. package/src/view/readiness/po-layer.js +110 -220
  83. package/src/view/readiness/tables.js +500 -0
  84. package/src/view/readiness/thin-reqs.js +237 -0
  85. package/src/view/readiness/trace-matrix.js +392 -0
  86. package/src/view/readiness.js +359 -370
  87. package/src/view/style.css +594 -0
  88. package/src/watch/index.js +75 -17
@@ -0,0 +1,35 @@
1
+ // Composite id splitter for the Readiness blocking table (TAC-4135
2
+ // splitCompositeId, ADR-4139; FBS-204, US-18003 AC-18003-4).
3
+ //
4
+ // Failing items in `stages[].checks[].failing[]` and in
5
+ // `levels.*.blockedBy[].ids[]` sometimes carry a composite id of the
6
+ // shape `<docId>:<fragment>` (for example "AC-060-3:userID" or
7
+ // "TAC-4122-define-gates:checkPersona"). The blocking table needs the
8
+ // document part on its own so the row's link resolves through
9
+ // findByDocId; the fragment stays visible so the row still names the
10
+ // exact failing nib. Split on the first colon so the fragment keeps
11
+ // its own colons (TAD-001:security:foo splits to docId=TAD-001,
12
+ // fragment=security:foo).
13
+ //
14
+ // Pure, no I/O. Returns { docId, fragment } where `fragment` is '' when
15
+ // no colon is present. A null / non-string input yields { docId: '',
16
+ // fragment: '' } so callers can wrap defensively.
17
+
18
+ /**
19
+ * @typedef {object} SplitId
20
+ * @property {string} docId the resolvable document id (part before the first colon)
21
+ * @property {string} fragment the remainder (empty when there was no colon)
22
+ */
23
+
24
+ /**
25
+ * Split a composite failing id on the FIRST colon.
26
+ *
27
+ * @param {string | null | undefined} id
28
+ * @returns {SplitId}
29
+ */
30
+ export function splitCompositeId(id) {
31
+ if (typeof id !== 'string' || id.length === 0) return { docId: '', fragment: '' };
32
+ const i = id.indexOf(':');
33
+ if (i === -1) return { docId: id, fragment: '' };
34
+ return { docId: id.slice(0, i), fragment: id.slice(i + 1) };
35
+ }
@@ -0,0 +1,293 @@
1
+ // Readiness coverage summary (FBS-205, US-18004 under REQ-180,
2
+ // TAC-4135-readiness-command-page, ADR-4139-readiness-tables-read-only-no-commands).
3
+ //
4
+ // Replaces renderCoverage. The old block read `coverage.tree.pass`
5
+ // and `coverage.tree.total`, which never exist on the real compute
6
+ // (coverage.tree carries `totals.{requirements, covered,
7
+ // coveredUnresolved, uncovered}` and `requirements[].coverageClass`
8
+ // plus `unresolvedTestPointers[]`). The result rendered as "pass /
9
+ // " blanks on every real tree. This module reads the fields that
10
+ // coverage actually produces and prints a stat line, one stacked
11
+ // bar, a family tiles row and (when non-empty) an unresolved-
12
+ // pointers table. Every value renders as either a number or the
13
+ // words "coverage unavailable"; a blank never leaves the page.
14
+ //
15
+ // Pure HTML-string render. No scripts, no commands, no write
16
+ // controls. Tokens stay on --sv-* (contrast-aa.test.js) and no new
17
+ // token is introduced.
18
+
19
+ import { escapeHtml } from '../doc-renderers/helpers.js';
20
+
21
+ const UNAVAILABLE = 'coverage unavailable';
22
+
23
+ /**
24
+ * @typedef {object} CoverageTreeTotals
25
+ * @property {number} requirements
26
+ * @property {number} covered
27
+ * @property {number} coveredUnresolved
28
+ * @property {number} uncovered
29
+ */
30
+
31
+ /**
32
+ * @typedef {object} CoverageSummary
33
+ * @property {object} totals - the four totals and percentCovered as strings or UNAVAILABLE
34
+ * @property {object[]} families - per-family tile data
35
+ */
36
+
37
+ /**
38
+ * Shape the family counts stat tiles from the walker's tree model.
39
+ * The spec names six families: requirements, stories, criteria,
40
+ * suites, cases, build specs. Each count is derived without a
41
+ * separate compute - the walker already carries each list.
42
+ *
43
+ * @param {object | null | undefined} tree
44
+ * @returns {Array<{key: string, label: string, count: number|null}>}
45
+ */
46
+ export function familyCounts(tree) {
47
+ if (!tree || typeof tree !== 'object') {
48
+ return SIX_FAMILIES.map((f) => ({ key: f.key, label: f.label, count: null }));
49
+ }
50
+ const reqs = Array.isArray(tree.requirements) ? tree.requirements.length : null;
51
+ const stories = Array.isArray(tree.userStories) ? tree.userStories.length : null;
52
+ let criteria = 0;
53
+ if (Array.isArray(tree.userStories)) {
54
+ for (const us of tree.userStories) {
55
+ if (Array.isArray(us && us.acceptanceCriteria)) criteria += us.acceptanceCriteria.length;
56
+ }
57
+ } else {
58
+ criteria = null;
59
+ }
60
+ const suites = Array.isArray(tree.testSuites) ? tree.testSuites.length : null;
61
+ let cases = 0;
62
+ if (Array.isArray(tree.testSuites)) {
63
+ for (const ts of tree.testSuites) {
64
+ if (Array.isArray(ts && ts.testCases)) cases += ts.testCases.length;
65
+ }
66
+ } else {
67
+ cases = null;
68
+ }
69
+ const fbsItems = Array.isArray(tree.fbsItems) ? tree.fbsItems.length : null;
70
+ return [
71
+ { key: 'requirements', label: 'Requirements', count: reqs },
72
+ { key: 'stories', label: 'Stories', count: stories },
73
+ { key: 'criteria', label: 'Criteria', count: criteria },
74
+ { key: 'suites', label: 'Suites', count: suites },
75
+ { key: 'cases', label: 'Cases', count: cases },
76
+ { key: 'buildSpecs', label: 'Build specs', count: fbsItems },
77
+ ];
78
+ }
79
+
80
+ const SIX_FAMILIES = [
81
+ { key: 'requirements', label: 'Requirements' },
82
+ { key: 'stories', label: 'Stories' },
83
+ { key: 'criteria', label: 'Criteria' },
84
+ { key: 'suites', label: 'Suites' },
85
+ { key: 'cases', label: 'Cases' },
86
+ { key: 'buildSpecs', label: 'Build specs' },
87
+ ];
88
+
89
+ /**
90
+ * Compute the four numeric slots + percent as either numbers or the
91
+ * UNAVAILABLE sentinel. AC-18004-6 and AC-18004-7 bind: no slot ever
92
+ * renders as a blank; a missing total renders the words
93
+ * "coverage unavailable".
94
+ */
95
+ export function computeCoverageSummary(coverageTree) {
96
+ const t = coverageTree && typeof coverageTree === 'object' ? coverageTree.totals : null;
97
+ if (!t || typeof t !== 'object') {
98
+ return {
99
+ totals: {
100
+ requirements: UNAVAILABLE,
101
+ covered: UNAVAILABLE,
102
+ coveredUnresolved: UNAVAILABLE,
103
+ uncovered: UNAVAILABLE,
104
+ },
105
+ percentCovered: UNAVAILABLE,
106
+ available: false,
107
+ };
108
+ }
109
+ const reqs = numOrUnavail(t.requirements);
110
+ const covered = numOrUnavail(t.covered);
111
+ const coveredUnresolved = numOrUnavail(t.coveredUnresolved);
112
+ const uncovered = numOrUnavail(t.uncovered);
113
+ const reqsN = typeof reqs === 'number' ? reqs : null;
114
+ const coveredN = typeof covered === 'number' ? covered : null;
115
+ const percent = (reqsN === null || coveredN === null || reqsN === 0)
116
+ ? (reqsN === 0 ? '0%' : UNAVAILABLE)
117
+ : `${Math.round((coveredN / reqsN) * 100)}%`;
118
+ const available = reqs !== UNAVAILABLE && covered !== UNAVAILABLE
119
+ && coveredUnresolved !== UNAVAILABLE && uncovered !== UNAVAILABLE;
120
+ return {
121
+ totals: {
122
+ requirements: reqs,
123
+ covered,
124
+ coveredUnresolved,
125
+ uncovered,
126
+ },
127
+ percentCovered: percent,
128
+ available,
129
+ };
130
+ }
131
+
132
+ function numOrUnavail(v) {
133
+ if (typeof v !== 'number' || !Number.isFinite(v)) return UNAVAILABLE;
134
+ return v;
135
+ }
136
+
137
+ /**
138
+ * Render the coverage sub-view: a stat line, a stacked bar on
139
+ * --sv-success / --sv-warning / --sv-danger over --sv-surface, a
140
+ * family tiles row, and (when non-empty) an unresolved-pointers
141
+ * table. AC-18004-1/-2/-3/-6/-7.
142
+ *
143
+ * @param {object} args
144
+ * @param {object | null | undefined} args.coverage - readiness.coverage
145
+ * @param {object | null | undefined} args.tree - the walker tree model
146
+ * @returns {string}
147
+ */
148
+ export function renderCoverageSummary(args) {
149
+ const coverage = args && args.coverage ? args.coverage : null;
150
+ const tree = args && args.tree ? args.tree : null;
151
+ const treeCov = coverage ? coverage.tree : null;
152
+ const summary = computeCoverageSummary(treeCov);
153
+ const tiles = familyCounts(tree);
154
+ const totalsText = renderTotals(summary);
155
+ const bar = renderBar(summary, treeCov);
156
+ const tilesHtml = renderTiles(tiles);
157
+ const unresolved = renderUnresolvedPointers(treeCov);
158
+ const dataAvailable = summary.available ? 'yes' : 'no';
159
+ return `<section class="rcf-cov-summary" data-rcf-coverage-available="${dataAvailable}" aria-labelledby="rcf-readiness-coverage-heading">
160
+ <header class="rcf-cov-summary__head"><h3 id="rcf-readiness-coverage-heading">Coverage</h3></header>
161
+ ${totalsText}
162
+ ${bar}
163
+ ${tilesHtml}
164
+ ${unresolved}
165
+ </section>`;
166
+ }
167
+
168
+ function renderTotals(summary) {
169
+ // AC-18004-7: a value the coverage result does not carry renders as
170
+ // "coverage unavailable", not as a blank. We always render the four
171
+ // slots; each one shows either its number or the unavailable word.
172
+ const t = summary.totals;
173
+ const slot = (name, label) => {
174
+ const v = t[name];
175
+ const text = typeof v === 'number' ? String(v) : UNAVAILABLE;
176
+ const data = typeof v === 'number' ? 'no' : 'yes';
177
+ return `<span class="rcf-cov-summary__slot" data-rcf-slot="${name}" data-rcf-unavailable="${data}">${escapeHtml(label)}: <strong>${escapeHtml(text)}</strong></span>`;
178
+ };
179
+ const unavailAll = summary.available ? '' : ' rcf-cov-summary__totals--unavailable';
180
+ return `<p class="rcf-cov-summary__totals${unavailAll}">
181
+ ${slot('requirements', 'Requirements')}
182
+ ${slot('covered', 'Covered')}
183
+ ${slot('coveredUnresolved', 'Covered-unresolved')}
184
+ ${slot('uncovered', 'Uncovered')}
185
+ <span class="rcf-cov-summary__slot" data-rcf-slot="percentCovered" data-rcf-unavailable="${summary.percentCovered === UNAVAILABLE ? 'yes' : 'no'}">${escapeHtml(summary.percentCovered)}</span>
186
+ </p>`;
187
+ }
188
+
189
+ function renderBar(summary, treeCov) {
190
+ if (!summary.available) {
191
+ return `<div class="rcf-cov-summary__bar" data-rcf-bar-empty="yes" role="img" aria-label="${escapeHtml(UNAVAILABLE)}"></div>`;
192
+ }
193
+ const t = treeCov && treeCov.totals ? treeCov.totals : null;
194
+ const total = t ? (Number(t.requirements) || 0) : 0;
195
+ const covered = t ? (Number(t.covered) || 0) : 0;
196
+ const coveredUnresolved = t ? (Number(t.coveredUnresolved) || 0) : 0;
197
+ const uncovered = t ? (Number(t.uncovered) || 0) : 0;
198
+ // Three widths. Over --sv-surface. --sv-success / --sv-warning / --sv-danger.
199
+ const safeTotal = total > 0 ? total : (covered + coveredUnresolved + uncovered) || 1;
200
+ const w1 = pct(covered, safeTotal);
201
+ const w2 = pct(coveredUnresolved, safeTotal);
202
+ const w3 = pct(uncovered, safeTotal);
203
+ const label = `covered ${covered}, covered-unresolved ${coveredUnresolved}, uncovered ${uncovered} out of ${total}`;
204
+ return `<div class="rcf-cov-summary__bar" role="img" aria-label="${escapeHtml(label)}">
205
+ <span class="rcf-cov-summary__bar-seg rcf-cov-summary__bar-seg--covered" data-rcf-seg="covered" style="width: ${w1};" title="Covered: ${escapeHtml(String(covered))}"></span>
206
+ <span class="rcf-cov-summary__bar-seg rcf-cov-summary__bar-seg--unresolved" data-rcf-seg="unresolved" style="width: ${w2};" title="Covered-unresolved: ${escapeHtml(String(coveredUnresolved))}"></span>
207
+ <span class="rcf-cov-summary__bar-seg rcf-cov-summary__bar-seg--uncovered" data-rcf-seg="uncovered" style="width: ${w3};" title="Uncovered: ${escapeHtml(String(uncovered))}"></span>
208
+ </div>`;
209
+ }
210
+
211
+ function pct(part, total) {
212
+ if (!total) return '0%';
213
+ const n = Math.max(0, Math.min(100, (part / total) * 100));
214
+ return `${n.toFixed(2)}%`;
215
+ }
216
+
217
+ function renderTiles(tiles) {
218
+ const cells = tiles.map((f) => {
219
+ const value = typeof f.count === 'number' ? String(f.count) : UNAVAILABLE;
220
+ const unavail = typeof f.count !== 'number';
221
+ return `<li class="rcf-cov-summary__tile" data-rcf-family="${escapeHtml(f.key)}" data-rcf-unavailable="${unavail ? 'yes' : 'no'}">
222
+ <span class="rcf-cov-summary__tile-count">${escapeHtml(value)}</span>
223
+ <span class="rcf-cov-summary__tile-label">${escapeHtml(f.label)}</span>
224
+ </li>`;
225
+ }).join('');
226
+ return `<ul class="rcf-cov-summary__tiles" aria-label="Document counts per family">${cells}</ul>`;
227
+ }
228
+
229
+ // Each reason word the compute emits for an unresolved pointer has a
230
+ // plain-English resolution: what has to change in the test tree to
231
+ // clear the row. digest-review rule: rows carry id + plain-
232
+ // English ask + chain location + link + what resolved looks like.
233
+ const UNRESOLVED_RESOLVED = {
234
+ 'test-missing': 'resolved by: add a test case in the pointer file whose name matches the testPointer slot',
235
+ 'file-missing': 'resolved by: create the test file at the pointer path and add the matching test case',
236
+ 'pointer-malformed': 'resolved by: fix the testPointer on the TC so it names a file and a case name',
237
+ 'ambiguous': 'resolved by: narrow the testPointer so one and only one test case matches it',
238
+ };
239
+
240
+ function resolvedForUnresolved(reason) {
241
+ if (!reason) return 'resolved by: add a resolving test case for this pointer';
242
+ if (UNRESOLVED_RESOLVED[reason]) return UNRESOLVED_RESOLVED[reason];
243
+ return `resolved by: fix the pointer so the "${reason}" condition clears`;
244
+ }
245
+
246
+ function renderUnresolvedPointers(treeCov) {
247
+ const items = treeCov && Array.isArray(treeCov.unresolvedTestPointers) ? treeCov.unresolvedTestPointers : [];
248
+ if (items.length === 0) {
249
+ return '';
250
+ }
251
+ const rows = items.map((p) => {
252
+ const tsId = typeof p.tsId === 'string' ? p.tsId : '';
253
+ const tcId = typeof p.tcId === 'string' ? p.tcId : '';
254
+ const pointer = typeof p.testPointer === 'string' && p.testPointer.length > 0 ? p.testPointer : UNAVAILABLE;
255
+ const reason = typeof p.reason === 'string' && p.reason.length > 0 ? p.reason : UNAVAILABLE;
256
+ const resolved = resolvedForUnresolved(typeof p.reason === 'string' ? p.reason : '');
257
+ // `#entity=<TS-id>` without a tab key resolves through the viewer's
258
+ // bare-entity path (page-init.js resolveHash): it finds the TS node,
259
+ // activates its owning tab (Architecture) and scrolls to it. The
260
+ // former `#tab=testing&entity=...` href pointed at a tab that does
261
+ // not exist (TABS: readiness, overview, requirements, architecture,
262
+ // build, product-map) and fell through to Readiness, so clicking a
263
+ // row did not land on its suite.
264
+ const tsCell = tsId
265
+ ? `<a href="#entity=${escapeHtml(tsId)}" data-rcf-ts="${escapeHtml(tsId)}">${escapeHtml(tsId)}</a>`
266
+ : `<em>${escapeHtml(UNAVAILABLE)}</em>`;
267
+ const tcCell = tcId ? `<code>${escapeHtml(tcId)}</code>` : `<em>${escapeHtml(UNAVAILABLE)}</em>`;
268
+ // FBS-208 (AC-209-3): the suite id in the row carries a Trace
269
+ // action that lands on the Trace sub-tab for the id.
270
+ const traceCell = tsId
271
+ ? `<a class="rcf-trace-action" data-rcf-trace-for="${escapeHtml(tsId)}" href="#tab=readiness&amp;sub=trace&amp;entity=${encodeURIComponent(tsId)}">Trace</a>`
272
+ : `<span class="rcf-trace-action rcf-trace-action--na" data-rcf-trace="no" aria-label="No trace available for this item">-</span>`;
273
+ return `<tr>
274
+ <td>${tsCell}</td>
275
+ <td>${tcCell}</td>
276
+ <td><code>${escapeHtml(pointer)}</code></td>
277
+ <td>${escapeHtml(reason)}</td>
278
+ <td class="rcf-cov-summary__unresolved-resolved">${escapeHtml(resolved)}</td>
279
+ <td>${traceCell}</td>
280
+ </tr>`;
281
+ }).join('');
282
+ return `<section class="rcf-cov-summary__unresolved" aria-labelledby="rcf-readiness-coverage-unresolved-heading">
283
+ <h4 id="rcf-readiness-coverage-unresolved-heading">Unresolved test pointers <span class="rcf-badge rcf-badge--count">${items.length}</span></h4>
284
+ <table class="rcf-cov-summary__unresolved-table">
285
+ <thead><tr><th>Suite</th><th>Case</th><th>Pointer</th><th>Reason</th><th>What resolves it</th><th><span class="rcf-sr-only">Trace this id</span></th></tr></thead>
286
+ <tbody>${rows}</tbody>
287
+ </table>
288
+ </section>`;
289
+ }
290
+
291
+ export const UNRESOLVED_POINTER_RESOLVED = UNRESOLVED_RESOLVED;
292
+
293
+ export const COVERAGE_UNAVAILABLE_TEXT = UNAVAILABLE;
@@ -0,0 +1,156 @@
1
+ // Readiness delta counts (FBS-206, TAC-4135-readiness-command-page,
2
+ // ADR-4139). Pure HTML string renderer: emits six count numbers
3
+ // (changed, added, removed, briefSince, impacted, impactedFbs) and
4
+ // hides the per-document list and its diff widgets inside a
5
+ // collapsed <details> so a thousand-document delta no longer buries
6
+ // the failing ids.
7
+ //
8
+ // AC-18005-3 pins the expanded contract: when the reader opens the
9
+ // <details>, the per-document list renders (changed with the diff
10
+ // component, added, removed, impacted ACs and FBS as links); when
11
+ // closed, only the six counts are visible.
12
+ //
13
+ // No command text, no write affordance.
14
+
15
+ import { escapeHtml } from '../doc-renderers/helpers.js';
16
+ import { diff } from '../components/diff.js';
17
+
18
+ /**
19
+ * @typedef {object} DeltaCounts
20
+ * @property {number} changed
21
+ * @property {number} added
22
+ * @property {number} removed
23
+ * @property {number} briefSince
24
+ * @property {number} impacted
25
+ * @property {number} impactedFbs
26
+ * @property {boolean} expanded renderer-side flag mirroring the <details open> state
27
+ */
28
+
29
+ /**
30
+ * Fold the six counts from a ReadinessDelta.
31
+ *
32
+ * @param {import('../../query/readiness.js').ReadinessDelta | null | undefined} d
33
+ * @returns {Omit<DeltaCounts, 'expanded'>}
34
+ */
35
+ export function foldCounts(d) {
36
+ if (!d || typeof d !== 'object') {
37
+ return { changed: 0, added: 0, removed: 0, briefSince: 0, impacted: 0, impactedFbs: 0 };
38
+ }
39
+ return {
40
+ changed: Array.isArray(d.changed) ? d.changed.length : 0,
41
+ added: Array.isArray(d.added) ? d.added.length : 0,
42
+ removed: Array.isArray(d.removed) ? d.removed.length : 0,
43
+ briefSince: Array.isArray(d.briefSince) ? d.briefSince.length : 0,
44
+ impacted: Array.isArray(d.impacted) ? d.impacted.length : 0,
45
+ impactedFbs: Array.isArray(d.impactedFbs) ? d.impactedFbs.length : 0,
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Build the delta-counts block (overview sub-view). The six count
51
+ * tiles are always visible; the per-document list sits inside a
52
+ * collapsed <details>.
53
+ *
54
+ * @param {object} args
55
+ * @param {import('../../query/readiness.js').ReadinessDelta | null | undefined} args.delta
56
+ * @param {object | null | undefined} [args.freezeRecord]
57
+ * @param {boolean} [args.expanded] default false
58
+ * @returns {string}
59
+ */
60
+ export function renderDeltaCounts({ delta, freezeRecord, expanded }) {
61
+ const counts = foldCounts(delta);
62
+ const totalTouched = counts.changed + counts.added + counts.removed;
63
+ const empty = totalTouched === 0 && counts.briefSince === 0 && counts.impacted === 0 && counts.impactedFbs === 0;
64
+ if (empty) {
65
+ return `<section class="rcf-readiness-delta-counts" data-rcf-expanded="${expanded ? 'yes' : 'no'}">`
66
+ + `<h3>Delta since freeze</h3>`
67
+ + `<p><em>No delta since the last freeze.</em></p>`
68
+ + `</section>`;
69
+ }
70
+ const tiles = [
71
+ tile('changed', counts.changed, 'Documents changed'),
72
+ tile('added', counts.added, 'Documents added'),
73
+ tile('removed', counts.removed, 'Documents removed'),
74
+ tile('briefSince', counts.briefSince, 'Brief statements since freeze'),
75
+ tile('impacted', counts.impacted, 'ACs to re-verify'),
76
+ tile('impactedFbs', counts.impactedFbs, 'FBS to re-execute'),
77
+ ].join('');
78
+ const open = expanded ? ' open' : '';
79
+ const docsList = renderDocsList(delta, freezeRecord);
80
+ return `<section class="rcf-readiness-delta-counts" data-rcf-expanded="${expanded ? 'yes' : 'no'}">`
81
+ + `<h3>Delta since freeze</h3>`
82
+ + `<div class="rcf-readiness-delta-counts__tiles" role="group" aria-label="Delta counts">${tiles}</div>`
83
+ + `<details class="rcf-readiness-delta-counts__details"${open}>`
84
+ + `<summary>Show per-document changes</summary>`
85
+ + docsList
86
+ + `</details>`
87
+ + `</section>`;
88
+ }
89
+
90
+ function tile(key, n, label) {
91
+ const empty = n === 0 ? ' rcf-readiness-delta-counts__tile--empty' : '';
92
+ return `<div class="rcf-readiness-delta-counts__tile${empty}" data-rcf-count="${escapeHtml(key)}" data-rcf-count-value="${n}">`
93
+ + `<span class="rcf-readiness-delta-counts__n">${n}</span>`
94
+ + `<span class="rcf-readiness-delta-counts__label">${escapeHtml(label)}</span>`
95
+ + `</div>`;
96
+ }
97
+
98
+ function renderDocsList(delta, freezeRecord) {
99
+ const d = delta && typeof delta === 'object' ? delta : null;
100
+ if (!d) return '';
101
+ const frozenHashes = (freezeRecord && typeof freezeRecord === 'object' && freezeRecord.docHashes && typeof freezeRecord.docHashes === 'object')
102
+ ? freezeRecord.docHashes
103
+ : {};
104
+ const currentHashes = (d && typeof d.currentDocHashes === 'object' && d.currentDocHashes !== null)
105
+ ? d.currentDocHashes
106
+ : {};
107
+ const added = Array.isArray(d.added) ? d.added : [];
108
+ const changed = Array.isArray(d.changed) ? d.changed : [];
109
+ const removed = Array.isArray(d.removed) ? d.removed : [];
110
+ const brief = Array.isArray(d.briefSince) ? d.briefSince : [];
111
+ const impactedAc = Array.isArray(d.impacted) ? d.impacted : [];
112
+ const impactedFbs = Array.isArray(d.impactedFbs) ? d.impactedFbs : [];
113
+ const docsBlock = (added.length + changed.length + removed.length) > 0
114
+ ? `<section class="rcf-readiness-delta-counts__docs">`
115
+ + `<h4>Documents (${added.length + changed.length + removed.length})</h4>`
116
+ + `<ul>`
117
+ + added.map((id) => `<li><a href="#${escapeHtml(String(id))}">${escapeHtml(String(id))}</a> <em>added</em>${renderDocDiff(id, frozenHashes, currentHashes, 'added')}</li>`).join('')
118
+ + changed.map((id) => `<li><a href="#${escapeHtml(String(id))}">${escapeHtml(String(id))}</a> <em>changed</em>${renderDocDiff(id, frozenHashes, currentHashes, 'changed')}</li>`).join('')
119
+ + removed.map((id) => `<li>${escapeHtml(String(id))} <em>removed</em>${renderDocDiff(id, frozenHashes, currentHashes, 'removed')}</li>`).join('')
120
+ + `</ul>`
121
+ + `</section>`
122
+ : '';
123
+ const briefBlock = brief.length > 0
124
+ ? `<section class="rcf-readiness-delta-counts__brief">`
125
+ + `<h4>Brief statements since freeze (${brief.length})</h4>`
126
+ + `<ul>${brief.map((n) => `<li>#${escapeHtml(String(n))}</li>`).join('')}</ul>`
127
+ + `</section>`
128
+ : '';
129
+ const impactedBlock = (impactedAc.length + impactedFbs.length) > 0
130
+ ? `<section class="rcf-readiness-delta-counts__impacted" data-rcf-impacted-ac="${impactedAc.length}" data-rcf-impacted-fbs="${impactedFbs.length}">`
131
+ + `<h4>Impacted by delta (${impactedAc.length} AC, ${impactedFbs.length} FBS)</h4>`
132
+ + (impactedAc.length > 0
133
+ ? `<p class="muted small">To re-verify (${impactedAc.length}):</p>`
134
+ + `<ul class="rcf-readiness-delta-counts__impacted-ac">${impactedAc.map((n) => `<li><a href="#${escapeHtml(String(n))}">${escapeHtml(String(n))}</a></li>`).join('')}</ul>`
135
+ : '')
136
+ + (impactedFbs.length > 0
137
+ ? `<p class="muted small">To re-execute (${impactedFbs.length}):</p>`
138
+ + `<ul class="rcf-readiness-delta-counts__impacted-fbs">${impactedFbs.map((id) => `<li><a href="#${escapeHtml(String(id))}">${escapeHtml(String(id))}</a></li>`).join('')}</ul>`
139
+ : '')
140
+ + `</section>`
141
+ : '';
142
+ return docsBlock + briefBlock + impactedBlock;
143
+ }
144
+
145
+ function renderDocDiff(id, frozenHashes, currentHashes, kind) {
146
+ const frozenHash = typeof frozenHashes?.[id] === 'string' ? frozenHashes[id] : null;
147
+ const currentHash = typeof currentHashes?.[id] === 'string' ? currentHashes[id] : null;
148
+ const beforeNote = 'frozen body not captured in freeze record';
149
+ const before = kind === 'added'
150
+ ? { note: 'not in the frozen tree' }
151
+ : { hash: frozenHash ?? '', note: beforeNote };
152
+ const after = kind === 'removed'
153
+ ? { note: 'removed from the live tree' }
154
+ : { hash: currentHash ?? '', note: 'current body available on the document tab' };
155
+ return ` ${diff(before, after)}`;
156
+ }
@@ -0,0 +1,175 @@
1
+ // Readiness stage grid (FBS-206, TAC-4135-readiness-command-page,
2
+ // ADR-4139). Pure HTML string renderer: emits a two-column table
3
+ // (tree, delta) with one row per stage. Each VerdictGridCell carries
4
+ // pass/total/state and a failing-count link into the blocking table
5
+ // filtered to that stage.
6
+ //
7
+ // Viewer read-only (Dave constraint F7, ADR-4139): no command text,
8
+ // no write affordance. The failing count is a hash link
9
+ // (#tab=readiness&sub=blocking&stage=<D>) picked up by the blocking
10
+ // FilterBar wire (page-init.js).
11
+ //
12
+ // Acknowledgement source (AC-18005-6): stages[].state is the single
13
+ // source for the cell state ("acknowledged" when the pass-fold set
14
+ // it via a D8 freeze match). The reason reads from stages[].reason
15
+ // (notApplicable) or, for acknowledgements, from freezeRecord.gates
16
+ // via one helper (reasonForCell) so the grid never reads
17
+ // freezeRecord.gates directly from its row renderer.
18
+ //
19
+ // Node 24 ESM; no dependencies.
20
+
21
+ import { escapeHtml } from '../doc-renderers/helpers.js';
22
+ import { stageTitle } from './stage-legend.js';
23
+
24
+ /**
25
+ * @typedef {object} VerdictGridCell
26
+ * @property {number} pass
27
+ * @property {number} total
28
+ * @property {'passed'|'failing'|'acknowledged'|'notApplicable'} state
29
+ * @property {number} failing count of failing ids in that stage x scope
30
+ * @property {string} [reason] hover title (notApplicable or acknowledged)
31
+ * @property {string} [href] link target when failing>0
32
+ */
33
+
34
+ /**
35
+ * Build the cell shape for one (stage, scope) pair.
36
+ *
37
+ * @param {import('../../query/readiness.js').StageResult} s
38
+ * @param {'tree'|'delta'} scope
39
+ * @param {object | null | undefined} freezeRecord
40
+ * @returns {VerdictGridCell}
41
+ */
42
+ export function buildCell(s, scope, freezeRecord) {
43
+ const checks = Array.isArray(s.checks) ? s.checks.filter((c) => c && c.over === scope) : [];
44
+ let pass = 0;
45
+ let total = 0;
46
+ let failing = 0;
47
+ for (const c of checks) {
48
+ pass += Number.isFinite(c.pass) ? c.pass : 0;
49
+ total += Number.isFinite(c.total) ? c.total : 0;
50
+ if (!c.ok && Array.isArray(c.failing)) failing += c.failing.length;
51
+ }
52
+ const state = cellStateFromStage(s, checks);
53
+ const reason = reasonForCell(s, state, freezeRecord);
54
+ const cell = { pass, total, state, failing };
55
+ if (reason) cell.reason = reason;
56
+ if (failing > 0) {
57
+ cell.href = `#tab=readiness&sub=blocking&stage=${encodeURIComponent(s.stage)}`;
58
+ }
59
+ return cell;
60
+ }
61
+
62
+ /**
63
+ * Fold the per-scope cell state from stages[].state and the scope's
64
+ * own checks. The stage-level state is authoritative for passed /
65
+ * acknowledged / notApplicable (D8 and the acknowledge-sweep set it);
66
+ * if the scope has no checks at all the cell is notApplicable; a
67
+ * failing check in the scope flips the cell to failing even when the
68
+ * other scope passed (so delta failures surface on their own column).
69
+ *
70
+ * @param {import('../../query/readiness.js').StageResult} s
71
+ * @param {import('../../query/readiness.js').CheckResult[]} scopeChecks
72
+ */
73
+ function cellStateFromStage(s, scopeChecks) {
74
+ if (scopeChecks.length === 0) return 'notApplicable';
75
+ // Stage-level acknowledgement wins over scope-specific failures:
76
+ // foldState only sets state='acknowledged' when there is a failing
77
+ // check and the gate is acked at the current tree hash, so the
78
+ // acknowledged envelope is the authoritative verdict for both the
79
+ // tree and delta columns.
80
+ if (s.state === 'acknowledged') return 'acknowledged';
81
+ if (scopeChecks.some((c) => !c.ok)) return 'failing';
82
+ if (s.state === 'notApplicable') return 'notApplicable';
83
+ return 'passed';
84
+ }
85
+
86
+ /**
87
+ * Reason-helper: the hover title for a cell. notApplicable rows read
88
+ * from stages[].reason; acknowledged rows read the ack-reason from
89
+ * freezeRecord.gates[gate] (via this one function, not from the row
90
+ * renderer); failing / passed rows return undefined (the stage-title
91
+ * tooltip still fires at the <th> level).
92
+ *
93
+ * @param {import('../../query/readiness.js').StageResult} s
94
+ * @param {VerdictGridCell['state']} state
95
+ * @param {object | null | undefined} freezeRecord
96
+ * @returns {string | undefined}
97
+ */
98
+ export function reasonForCell(s, state, freezeRecord) {
99
+ if (state === 'notApplicable') {
100
+ return typeof s.reason === 'string' && s.reason.length > 0 ? s.reason : undefined;
101
+ }
102
+ if (state === 'acknowledged') {
103
+ const gates = freezeRecord && typeof freezeRecord === 'object' ? freezeRecord.gates : null;
104
+ const ack = gates && typeof gates === 'object' ? gates[s.gate] : null;
105
+ // The freeze record writes the reason at gates[g].at.reason (see
106
+ // src/cli/freeze.js buildGatesEntry / mergeAckedGates); the legacy
107
+ // flat gates[g].reason is accepted as a fallback for older files.
108
+ const at = ack && typeof ack === 'object' ? ack.at : null;
109
+ if (at && typeof at === 'object' && typeof at.reason === 'string' && at.reason.length > 0) {
110
+ return at.reason;
111
+ }
112
+ if (ack && typeof ack === 'object' && typeof ack.reason === 'string' && ack.reason.length > 0) {
113
+ return ack.reason;
114
+ }
115
+ return undefined;
116
+ }
117
+ return undefined;
118
+ }
119
+
120
+ /**
121
+ * Build the full stage-grid HTML (overview sub-view block).
122
+ *
123
+ * @param {object} args
124
+ * @param {import('../../query/readiness.js').StageResult[]} args.stages
125
+ * @param {object | null | undefined} [args.freezeRecord]
126
+ * @returns {string}
127
+ */
128
+ export function renderVerdictGrid({ stages, freezeRecord }) {
129
+ const rows = Array.isArray(stages) ? stages : [];
130
+ if (rows.length === 0) {
131
+ return `<section class="rcf-readiness-grid" data-rcf-grid="verdict">`
132
+ + `<p><em>No stages to render.</em></p>`
133
+ + `</section>`;
134
+ }
135
+ const header = `<thead><tr>`
136
+ + `<th scope="col">Stage</th>`
137
+ + `<th scope="col" data-scope="tree">Tree</th>`
138
+ + `<th scope="col" data-scope="delta">Delta</th>`
139
+ + `</tr></thead>`;
140
+ const body = rows.map((s) => {
141
+ const title = stageTitle(s.stage) || s.stage;
142
+ const tree = buildCell(s, 'tree', freezeRecord);
143
+ const delta = buildCell(s, 'delta', freezeRecord);
144
+ const stageCell = `<th scope="row" data-rcf-stage-ref="${escapeHtml(s.stage)}" title="${escapeHtml(title)}">`
145
+ + `<span class="rcf-readiness-grid__stage-id">${escapeHtml(s.stage)}</span> `
146
+ + `<code class="muted">${escapeHtml(s.gate)}</code>`
147
+ + `</th>`;
148
+ return `<tr data-rcf-stage="${escapeHtml(s.stage)}">`
149
+ + stageCell
150
+ + renderCellTd(s, 'tree', tree)
151
+ + renderCellTd(s, 'delta', delta)
152
+ + `</tr>`;
153
+ }).join('\n');
154
+ return `<section class="rcf-readiness-grid" data-rcf-grid="verdict">`
155
+ + `<table class="rcf-readiness-grid__table rcf-cmd-table" data-rcf-table="verdict-grid">`
156
+ + header
157
+ + `<tbody>${body}</tbody>`
158
+ + `</table>`
159
+ + `</section>`;
160
+ }
161
+
162
+ function renderCellTd(s, scope, cell) {
163
+ const stateClass = `rcf-readiness-grid__cell--${escapeHtml(cell.state)}`;
164
+ const title = cell.reason ? ` title="${escapeHtml(cell.reason)}"` : '';
165
+ const countsText = `${cell.pass}/${cell.total}`;
166
+ const failingText = cell.failing > 0
167
+ ? (cell.href
168
+ ? ` <a class="rcf-readiness-grid__failing" href="${escapeHtml(cell.href)}" data-rcf-link="blocking-filtered" data-rcf-filter-stage="${escapeHtml(s.stage)}">${cell.failing} failing</a>`
169
+ : ` <span class="rcf-readiness-grid__failing">${cell.failing} failing</span>`)
170
+ : '';
171
+ return `<td class="rcf-readiness-grid__cell ${stateClass}" data-rcf-cell-state="${escapeHtml(cell.state)}" data-rcf-cell-scope="${escapeHtml(scope)}" data-rcf-cell-pass="${cell.pass}" data-rcf-cell-total="${cell.total}" data-rcf-cell-failing="${cell.failing}"${title}>`
172
+ + `<span class="rcf-readiness-grid__counts">${countsText}</span>`
173
+ + failingText
174
+ + `</td>`;
175
+ }