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,237 @@
1
+ // Readiness thin-requirements table (FBS-205, US-18004 under REQ-180,
2
+ // TAC-4135-readiness-command-page, ADR-4139-readiness-tables-read-only-no-commands).
3
+ //
4
+ // Replaces the old `buildReqWorkRows` helper with `buildThinReqRows`,
5
+ // which widens the PO-side filter to the five thin reasons bound by
6
+ // AC-18004-4:
7
+ // 1. no story under the REQ (stories:reqHasUs)
8
+ // 2. zero criteria on a story under the REQ (D4 floors or the walker)
9
+ // 3. a criterion with no resolving test (coverage.tree)
10
+ // 4. a story failing a D4 floor (stories:usFloors)
11
+ // 5. no plain description on the REQ (skeleton:reqIntent)
12
+ // plus the old skeleton:resolvedBy signal, retained so the sweep is
13
+ // strictly wider than before. Every row carries ThinReqRow.stories,
14
+ // ThinReqRow.criteria, ThinReqRow.covered, ThinReqRow.thinReason,
15
+ // ThinReqRow.openHref and ThinReqRow.traceHref.
16
+
17
+ import { escapeHtml } from '../doc-renderers/helpers.js';
18
+
19
+ const REASONS = {
20
+ reqHasUs: 'no story yet: who uses this, and what do they do with it',
21
+ // src/query/gates.js stories:usFloors fires on four floor failures
22
+ // (missing [failure] class / opt-out, missing TAC refs, testability
23
+ // floor, outstanding TODO on an AC): the reason text names them in
24
+ // plain words rather than hard-coding only "[failure] class", which
25
+ // mis-describes the other three floors.
26
+ usFloors: 'a story under this requirement is failing a definition floor (missing a [failure] class or opt-out, missing TAC refs, failing the testability floor, or an outstanding TODO on an acceptance criterion)',
27
+ reqIntent: 'needs a plain description: what must the product do here, and which part does it belong to',
28
+ resolvedBy: 'a statement from the document may belong here; confirm it in the questions table',
29
+ zeroCriteria: 'a story under this requirement has no acceptance criteria',
30
+ uncoveredAc: 'at least one criterion under this requirement has no resolving test case',
31
+ };
32
+
33
+ // Each reason's "resolved" condition: a plain-English statement of
34
+ // what has to change in the chain for the row to clear. digest-review rule
35
+ // (digest-review): every thin row carries id + plain-English ask +
36
+ // chain location + link + what resolved looks like.
37
+ const RESOLVED = {
38
+ reqHasUs: 'resolved by: add a user story under this requirement in the chain',
39
+ usFloors: 'resolved by: fix the story so every acceptance criterion carries a [failure] class or an opt-out, carries its TAC refs, clears the testability floor and has no outstanding TODO',
40
+ reqIntent: 'resolved by: write a plain description on this requirement that names what the product does and which part it belongs to',
41
+ resolvedBy: 'resolved by: confirm or remove the resolvedBy pointer on the matching question in the questions table',
42
+ zeroCriteria: 'resolved by: write at least one acceptance criterion under the story',
43
+ uncoveredAc: 'resolved by: add a test case under a suite whose testPointer resolves the uncovered acceptance criterion',
44
+ };
45
+
46
+ /**
47
+ * @typedef {object} ThinReqRow
48
+ * @property {string} reqId
49
+ * @property {number} stories - count of user stories under the REQ
50
+ * @property {number} criteria - count of acceptance criteria under the REQ
51
+ * @property {boolean} covered - all criteria have resolving TC coverage
52
+ * @property {string[]} thinReason - keys of the triggered reasons, in a stable order
53
+ * @property {string} openHref - requirements tab hash on the REQ
54
+ * @property {string} traceHref - readiness/trace hash on the REQ (FBS-208 fills the sub-view)
55
+ */
56
+
57
+ /**
58
+ * Build the thin-requirement rows from the live readiness result and
59
+ * the walker's tree model. The function is pure: no fs, no fetch.
60
+ * Pass in the shape the view already has on hand (readiness carries
61
+ * stages and coverage.tree; tree carries requirements + userStories +
62
+ * acceptanceCriteria).
63
+ *
64
+ * @param {object | null | undefined} readiness
65
+ * @param {object | null | undefined} tree
66
+ * @returns {ThinReqRow[]}
67
+ */
68
+ export function buildThinReqRows(readiness, tree) {
69
+ /** @type {Map<string, Set<string>>} */
70
+ const reasonsByReq = new Map();
71
+ const addReason = (reqId, key) => {
72
+ if (!reqId || !key) return;
73
+ const bare = reqId.split(':')[0];
74
+ if (!/^REQ-\d+/.test(bare)) return;
75
+ let set = reasonsByReq.get(bare);
76
+ if (!set) {
77
+ set = new Set();
78
+ reasonsByReq.set(bare, set);
79
+ }
80
+ set.add(key);
81
+ };
82
+
83
+ const stages = Array.isArray(readiness && readiness.stages) ? readiness.stages : [];
84
+ for (const s of stages) {
85
+ if (!s || !Array.isArray(s.checks)) continue;
86
+ for (const c of s.checks) {
87
+ if (!c || c.ok) continue;
88
+ const failing = Array.isArray(c.failing) ? c.failing : [];
89
+ if (c.name === 'stories:reqHasUs') {
90
+ for (const f of failing) addReason(idOf(f), 'reqHasUs');
91
+ } else if (c.name === 'skeleton:reqIntent') {
92
+ for (const f of failing) addReason(idOf(f), 'reqIntent');
93
+ } else if (c.name === 'skeleton:resolvedBy') {
94
+ for (const f of failing) addReason(idOf(f), 'resolvedBy');
95
+ } else if (c.name === 'stories:usFloors') {
96
+ for (const f of failing) {
97
+ const id = idOf(f);
98
+ const reqId = reqIdForUs(id, tree);
99
+ if (reqId) addReason(reqId, 'usFloors');
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ // Zero-criteria stories (walked from the tree model so the row
106
+ // lands even when no D4 check named this story explicitly) and
107
+ // uncovered ACs (from coverage.tree).
108
+ if (tree && Array.isArray(tree.userStories)) {
109
+ for (const us of tree.userStories) {
110
+ if (!us || typeof us !== 'object') continue;
111
+ const criteria = Array.isArray(us.acceptanceCriteria) ? us.acceptanceCriteria : [];
112
+ if (criteria.length === 0) {
113
+ const reqId = typeof us.reqId === 'string' && us.reqId.length > 0 ? us.reqId : null;
114
+ if (reqId) addReason(reqId, 'zeroCriteria');
115
+ }
116
+ }
117
+ }
118
+ const coverageReqs = readiness && readiness.coverage && readiness.coverage.tree
119
+ ? (Array.isArray(readiness.coverage.tree.requirements) ? readiness.coverage.tree.requirements : [])
120
+ : [];
121
+ for (const r of coverageReqs) {
122
+ if (!r || typeof r !== 'object') continue;
123
+ const acs = Array.isArray(r.acs) ? r.acs : [];
124
+ const anyUncovered = acs.some((a) => a && a.covered === false);
125
+ if (anyUncovered) addReason(r.id, 'uncoveredAc');
126
+ }
127
+
128
+ const rows = [];
129
+ const stable = Array.from(reasonsByReq.keys()).sort((a, b) => a.localeCompare(b));
130
+ for (const reqId of stable) {
131
+ const reasons = Array.from(reasonsByReq.get(reqId) || []).sort();
132
+ const { stories, criteria, covered } = reqMetrics(reqId, readiness, tree);
133
+ rows.push({
134
+ reqId,
135
+ stories,
136
+ criteria,
137
+ covered,
138
+ thinReason: reasons,
139
+ openHref: `#tab=requirements&entity=${reqId}`,
140
+ traceHref: `#tab=readiness&sub=trace&entity=${reqId}`,
141
+ });
142
+ }
143
+ return rows;
144
+ }
145
+
146
+ function idOf(f) {
147
+ return (f && typeof f.id === 'string') ? f.id : '';
148
+ }
149
+
150
+ function reqIdForUs(id, tree) {
151
+ const base = id ? id.split(':')[0] : '';
152
+ if (!/^US-/.test(base)) return null;
153
+ if (!tree || !Array.isArray(tree.userStories)) return null;
154
+ // Walker tree model: `tree.userStories[].usId` is the id field
155
+ // (src/core/store/walker.js uses sortById on 'usId'). An older
156
+ // handcrafted fixture that only carries `.id` is accepted as a
157
+ // fallback so the sub-view never breaks on a partial shape, but
158
+ // the primary match is on `usId` because that is what the live
159
+ // tree produces.
160
+ const us = tree.userStories.find((u) => u && (u.usId === base || u.id === base));
161
+ if (us && typeof us.reqId === 'string') return us.reqId;
162
+ return null;
163
+ }
164
+
165
+ function reqMetrics(reqId, readiness, tree) {
166
+ let stories = 0;
167
+ let criteria = 0;
168
+ if (tree && Array.isArray(tree.userStories)) {
169
+ for (const us of tree.userStories) {
170
+ if (!us || us.reqId !== reqId) continue;
171
+ stories += 1;
172
+ if (Array.isArray(us.acceptanceCriteria)) criteria += us.acceptanceCriteria.length;
173
+ }
174
+ }
175
+ let covered = true;
176
+ const coverageReqs = readiness && readiness.coverage && readiness.coverage.tree
177
+ ? (Array.isArray(readiness.coverage.tree.requirements) ? readiness.coverage.tree.requirements : [])
178
+ : [];
179
+ const row = coverageReqs.find((r) => r && r.id === reqId);
180
+ if (row) {
181
+ if (row.coverageClass && row.coverageClass !== 'covered') covered = false;
182
+ const acs = Array.isArray(row.acs) ? row.acs : [];
183
+ if (acs.some((a) => a && a.covered === false)) covered = false;
184
+ } else {
185
+ // No coverage row for this REQ at all: the compute omitted it.
186
+ // Treat as uncovered so the row's covered flag matches the
187
+ // thinReason the row carries.
188
+ covered = false;
189
+ }
190
+ return { stories, criteria, covered };
191
+ }
192
+
193
+ /**
194
+ * Render the thin-requirements table or its empty state.
195
+ *
196
+ * @param {object} args
197
+ * @param {ThinReqRow[]} args.rows
198
+ * @returns {string}
199
+ */
200
+ export function renderThinReqsTable(args) {
201
+ const rows = args && Array.isArray(args.rows) ? args.rows : [];
202
+ if (rows.length === 0) {
203
+ return `<section class="rcf-thin-reqs" data-rcf-empty="yes" aria-labelledby="rcf-readiness-thin-heading">
204
+ <header class="rcf-thin-reqs__head"><h3 id="rcf-readiness-thin-heading">Requirements that still need work</h3> <span class="rcf-badge rcf-badge--count">0</span></header>
205
+ <p class="rcf-thin-reqs__empty">Every requirement has a plain description, a story, and resolving test coverage. Nothing is thin here.</p>
206
+ </section>`;
207
+ }
208
+ const body = rows.map((r) => {
209
+ const reasonText = r.thinReason.map((k) => REASONS[k] || k).join('; ');
210
+ const resolvedText = r.thinReason.map((k) => RESOLVED[k] || `resolved by: fix ${k}`).join('; ');
211
+ const coveredLabel = r.covered ? 'yes' : 'no';
212
+ return `<tr data-rcf-req="${escapeHtml(r.reqId)}" data-rcf-covered="${coveredLabel}">
213
+ <td><code class="rcf-thin-reqs__id">${escapeHtml(r.reqId)}</code></td>
214
+ <td>${escapeHtml(String(r.stories))}</td>
215
+ <td>${escapeHtml(String(r.criteria))}</td>
216
+ <td>${escapeHtml(coveredLabel)}</td>
217
+ <td>${escapeHtml(reasonText)}</td>
218
+ <td class="rcf-thin-reqs__resolved">${escapeHtml(resolvedText)}</td>
219
+ <td><a href="${escapeHtml(r.openHref)}">Open</a> <a href="${escapeHtml(r.traceHref)}" class="rcf-thin-reqs__trace">Trace</a></td>
220
+ </tr>`;
221
+ }).join('');
222
+ return `<section class="rcf-thin-reqs" aria-labelledby="rcf-readiness-thin-heading">
223
+ <header class="rcf-thin-reqs__head"><h3 id="rcf-readiness-thin-heading">Requirements that still need work</h3> <span class="rcf-badge rcf-badge--count">${rows.length}</span></header>
224
+ <p class="rcf-thin-reqs__hint muted small">Each row names the reasons in plain words and what resolves them in the chain. Open opens the requirement; Trace lands on its chain.</p>
225
+ <table class="rcf-thin-reqs__table">
226
+ <thead><tr><th>Requirement</th><th>Stories</th><th>Criteria</th><th>Covered</th><th>Reasons</th><th>What resolves it</th><th></th></tr></thead>
227
+ <tbody>${body}</tbody>
228
+ </table>
229
+ </section>`;
230
+ }
231
+
232
+ // Exposed for the test and for other sub-views that want to render
233
+ // the "resolved" phrase alongside their own reason text (unresolved-
234
+ // pointers table in coverage-summary.js uses the same style).
235
+ export const THIN_RESOLVED = RESOLVED;
236
+
237
+ export const THIN_REASONS = REASONS;
@@ -0,0 +1,392 @@
1
+ // Readiness Trace matrix renderer (FBS-208, US-209 under REQ-002,
2
+ // TAC-4136-view-query-routes, ADR-4140-view-query-routes-per-pivot-matrix-no-mermaid).
3
+ //
4
+ // Pure HTML-string renderer. Takes a trace response (as the GET
5
+ // /trace.json route returns) plus a coverage response (as the GET
6
+ // /coverage.json route returns, strict, scoped to the pivot's owning
7
+ // REQ when applicable) and emits a matrix:
8
+ //
9
+ // rows: one TraceMatrixRow per story (US) and per criterion (AC)
10
+ // under the pivot.
11
+ // columns: suites (TS), cases (TC), build specs (FBS), components (CN).
12
+ // cells: TraceMatrixCell with `reached` true where the forward
13
+ // trace reaches the column from the row, and `resolution`
14
+ // one of "resolving" / "unresolved" / "none" from coverage.
15
+ //
16
+ // Colours stay on existing tokens (`--sv-success`, `--sv-warning`,
17
+ // `--sv-muted`); no new palette entry is introduced (AC-209-2). Over
18
+ // 200 rows the body carries a row filter by story id or criterion id;
19
+ // nothing is truncated (AC-209-7). An unknown pivot renders the empty
20
+ // state and offers the lookup modal (AC-209-6).
21
+ //
22
+ // No CLI command text, no mermaid, no write controls (ADR-4140,
23
+ // ADR-4139).
24
+
25
+ import { escapeHtml } from '../doc-renderers/helpers.js';
26
+
27
+ const RESOLUTIONS = ['resolving', 'unresolved', 'none'];
28
+ const ROW_FILTER_THRESHOLD = 200;
29
+
30
+ /**
31
+ * @typedef {object} TraceMatrixCell
32
+ * @property {string} kind 'ts' | 'tc' | 'fbs' | 'cn'
33
+ * @property {boolean} reached the forward trace from the row reaches at least one id in this column
34
+ * @property {'resolving'|'unresolved'|'none'} resolution
35
+ * @property {string[]} ids the specific column entity ids reached from this row
36
+ */
37
+
38
+ /**
39
+ * @typedef {object} TraceMatrixRow
40
+ * @property {string} id US-xxx or AC-xxx-n
41
+ * @property {'us'|'ac'} kind
42
+ * @property {string} title
43
+ * @property {string} parent the parent US id for an AC row; empty for a US row
44
+ * @property {TraceMatrixCell[]} cells
45
+ */
46
+
47
+ /**
48
+ * Build the matrix row and cell data for a pivot's forward trace and
49
+ * coverage.
50
+ *
51
+ * `coverage` must be shaped like the `./coverage.json` route payload:
52
+ * `requirements[]` with `acs[]` carrying `covered` and `testCases`.
53
+ * When `coverage` is null (not fetched yet or absent), every cell's
54
+ * resolution is `"none"`.
55
+ *
56
+ * @param {object} args
57
+ * @param {object} args.trace /trace.json payload
58
+ * @param {object|null} args.coverage /coverage.json payload or null
59
+ * @returns {TraceMatrixRow[]}
60
+ */
61
+ export function buildTraceMatrixRows({ trace, coverage }) {
62
+ if (!trace || !trace.found || !Array.isArray(trace.nodes) || !Array.isArray(trace.edges)) {
63
+ return [];
64
+ }
65
+ const nodes = trace.nodes;
66
+ const edges = trace.edges;
67
+ const byId = new Map();
68
+ for (const n of nodes) {
69
+ if (n && typeof n.id === 'string' && typeof n.kind === 'string') byId.set(n.id, n);
70
+ }
71
+ // Build forward reach from each AC and each US (via its ACs) to TS/TC/FBS/CN.
72
+ const outFromId = new Map();
73
+ for (const e of edges) {
74
+ if (!e || typeof e.from !== 'string' || typeof e.to !== 'string') continue;
75
+ if (!outFromId.has(e.from)) outFromId.set(e.from, []);
76
+ outFromId.get(e.from).push(e.to);
77
+ }
78
+ // Flatten AC's downstream reach into kind buckets.
79
+ // FBS-208 FINALISE (P2-4): stop traversal at a TS boundary so an
80
+ // AC's Cases column only lists the AC's OWN testPointer-reached TCs,
81
+ // not every sibling TC its TS also contains. TS is still recorded
82
+ // in the bucket when a TS edge reaches it; we just do not descend
83
+ // into TS->TC containment from an AC row.
84
+ function reachFrom(id) {
85
+ const bucket = { ts: [], tc: [], fbs: [], cn: [] };
86
+ const seen = new Set([id]);
87
+ const stack = [id];
88
+ while (stack.length > 0) {
89
+ const cur = stack.pop();
90
+ const curKind = byId.get(cur)?.kind;
91
+ if (curKind === 'ts' || curKind === 'testSuite') continue;
92
+ for (const to of outFromId.get(cur) ?? []) {
93
+ if (seen.has(to)) continue;
94
+ seen.add(to);
95
+ const kind = byId.get(to)?.kind;
96
+ if (kind === 'ts' || kind === 'testSuite') bucket.ts.push(to);
97
+ else if (kind === 'tc' || kind === 'testCase') bucket.tc.push(to);
98
+ else if (kind === 'fbs') bucket.fbs.push(to);
99
+ else if (kind === 'cn' || kind === 'codeNode') bucket.cn.push(to);
100
+ stack.push(to);
101
+ }
102
+ }
103
+ for (const k of Object.keys(bucket)) bucket[k] = Array.from(new Set(bucket[k])).sort();
104
+ return bucket;
105
+ }
106
+
107
+ // AC-level coverage index by AC id.
108
+ const acCoverageByAc = new Map();
109
+ if (coverage && Array.isArray(coverage.requirements)) {
110
+ for (const req of coverage.requirements) {
111
+ for (const ac of req.acs ?? []) {
112
+ if (ac && typeof ac.id === 'string') acCoverageByAc.set(ac.id, ac);
113
+ }
114
+ }
115
+ }
116
+
117
+ // Pivot is the first node (depth 0). Children under it: US + AC.
118
+ const pivot = nodes[0] && nodes[0].id;
119
+ if (!pivot) return [];
120
+
121
+ // Group ACs under their parent US for row grouping.
122
+ const usRows = [];
123
+ const acsByUs = new Map();
124
+ const standaloneAcs = [];
125
+ for (const n of nodes) {
126
+ if (n.kind === 'userStory' || n.kind === 'us') {
127
+ usRows.push(n);
128
+ acsByUs.set(n.id, []);
129
+ }
130
+ }
131
+ for (const n of nodes) {
132
+ if (n.kind !== 'ac') continue;
133
+ // Find the US parent: back-walk edges with kind parentChild.
134
+ let parentUs = null;
135
+ for (const e of edges) {
136
+ if (e.to === n.id && e.kind === 'parentChild') {
137
+ const p = byId.get(e.from);
138
+ if (p && (p.kind === 'userStory' || p.kind === 'us')) { parentUs = p.id; break; }
139
+ }
140
+ }
141
+ if (parentUs && acsByUs.has(parentUs)) acsByUs.get(parentUs).push(n);
142
+ else standaloneAcs.push(n);
143
+ }
144
+
145
+ const rows = [];
146
+ function kindOfResolution(acId, kindKey, ids) {
147
+ if (!ids || ids.length === 0) return 'none';
148
+ if (kindKey !== 'tc') {
149
+ // TS/FBS/CN resolution is simply reached-or-not; "resolving" when ids present.
150
+ return 'resolving';
151
+ }
152
+ const cov = acCoverageByAc.get(acId);
153
+ if (!cov) return 'unresolved';
154
+ const resolvedSet = new Set(cov.testCases ?? []);
155
+ const unresolvedSet = new Set(cov.unresolvedTestCases ?? []);
156
+ const resolvedHits = ids.filter((id) => resolvedSet.has(id) && !unresolvedSet.has(id));
157
+ const unresolvedHits = ids.filter((id) => unresolvedSet.has(id));
158
+ if (unresolvedHits.length === 0 && resolvedHits.length > 0) return 'resolving';
159
+ if (unresolvedHits.length > 0 && resolvedHits.length === 0) return 'unresolved';
160
+ if (unresolvedHits.length > 0) return 'unresolved';
161
+ return 'none';
162
+ }
163
+
164
+ function usRowFrom(us, acsUnder) {
165
+ // Union of AC reaches is the US-level reach.
166
+ const u = { ts: new Set(), tc: new Set(), fbs: new Set(), cn: new Set() };
167
+ for (const a of acsUnder) {
168
+ const r = reachFrom(a.id);
169
+ for (const k of ['ts', 'tc', 'fbs', 'cn']) r[k].forEach((v) => u[k].add(v));
170
+ }
171
+ const bucket = {
172
+ ts: [...u.ts].sort(),
173
+ tc: [...u.tc].sort(),
174
+ fbs: [...u.fbs].sort(),
175
+ cn: [...u.cn].sort(),
176
+ };
177
+ // US-level resolution for TC: resolving only if every AC under it resolves.
178
+ const anyAcUnresolved = acsUnder.some((a) => {
179
+ const cov = acCoverageByAc.get(a.id);
180
+ return cov ? cov.covered !== true : bucket.tc.length > 0;
181
+ });
182
+ return {
183
+ id: us.id,
184
+ kind: 'us',
185
+ title: us.title ?? '',
186
+ parent: '',
187
+ cells: ['ts', 'tc', 'fbs', 'cn'].map((kindKey) => ({
188
+ kind: kindKey,
189
+ reached: bucket[kindKey].length > 0,
190
+ resolution: kindKey === 'tc'
191
+ ? (bucket.tc.length === 0 ? 'none' : (anyAcUnresolved ? 'unresolved' : 'resolving'))
192
+ : (bucket[kindKey].length === 0 ? 'none' : 'resolving'),
193
+ ids: bucket[kindKey],
194
+ })),
195
+ };
196
+ }
197
+
198
+ function acRowFrom(ac, parentUsId) {
199
+ const r = reachFrom(ac.id);
200
+ return {
201
+ id: ac.id,
202
+ kind: 'ac',
203
+ title: ac.title ?? '',
204
+ parent: parentUsId ?? '',
205
+ cells: ['ts', 'tc', 'fbs', 'cn'].map((kindKey) => ({
206
+ kind: kindKey,
207
+ reached: r[kindKey].length > 0,
208
+ resolution: kindOfResolution(ac.id, kindKey, r[kindKey]),
209
+ ids: r[kindKey],
210
+ })),
211
+ };
212
+ }
213
+
214
+ for (const us of usRows) {
215
+ const acs = acsByUs.get(us.id) ?? [];
216
+ rows.push(usRowFrom(us, acs));
217
+ for (const ac of acs) rows.push(acRowFrom(ac, us.id));
218
+ }
219
+ for (const ac of standaloneAcs) rows.push(acRowFrom(ac, ''));
220
+
221
+ return rows;
222
+ }
223
+
224
+ const RESOLUTION_LABEL = {
225
+ resolving: 'resolving',
226
+ unresolved: 'unresolved',
227
+ none: 'none',
228
+ };
229
+
230
+ const COLUMN_LABEL = {
231
+ ts: 'Suites',
232
+ tc: 'Cases',
233
+ fbs: 'Build specs',
234
+ cn: 'Components',
235
+ };
236
+
237
+ /**
238
+ * Render the Trace matrix section for the Readiness Trace sub-view.
239
+ * Emits an empty-state block when `pivot` is empty or `trace.found`
240
+ * is false (AC-209-6).
241
+ *
242
+ * @param {object} args
243
+ * @param {string} args.pivot the pivot id in the hash (may be '')
244
+ * @param {object|null} args.trace /trace.json payload (null until first fetch)
245
+ * @param {object|null} args.coverage /coverage.json payload (null until first fetch)
246
+ * @param {boolean} [args.stale] the client cache is marked stale (post-SSE swap)
247
+ * @param {boolean} [args.loading] fetch in flight
248
+ * @returns {string}
249
+ */
250
+ export function renderTraceMatrix(args) {
251
+ const pivot = typeof args?.pivot === 'string' ? args.pivot : '';
252
+ const trace = args?.trace ?? null;
253
+ const coverage = args?.coverage ?? null;
254
+ const stale = Boolean(args?.stale);
255
+ const loading = Boolean(args?.loading);
256
+
257
+ if (!pivot) {
258
+ return renderEmptyState({ reason: 'no-pivot' });
259
+ }
260
+ if (loading && !trace) {
261
+ return renderLoadingState({ pivot });
262
+ }
263
+ if (trace && trace.found === false) {
264
+ return renderEmptyState({ reason: 'unknown-pivot', pivot });
265
+ }
266
+ if (!trace) {
267
+ // Shell before client fetch; renders on first paint.
268
+ return renderShell({ pivot, stale });
269
+ }
270
+ // FBS-208 FINALISE (P2-5): downstream pivots (TS/TC/FBS/CN/TAC/ADR)
271
+ // produce no US/AC rows. Render an explanatory state instead of a
272
+ // silent empty table.
273
+ if (/^(TS-|TC-|FBS-|CN-|TAC-|ADR-)/.test(pivot)) {
274
+ return renderDownstreamState({ pivot });
275
+ }
276
+
277
+ const rows = buildTraceMatrixRows({ trace, coverage });
278
+ const needsFilter = rows.length > ROW_FILTER_THRESHOLD;
279
+ const filterBar = needsFilter ? renderRowFilter() : '';
280
+ const staleBanner = stale
281
+ ? `<p class="rcf-trace-matrix__stale muted small" data-rcf-trace-stale="yes">Tree changed. The matrix below reflects the previous version; a fresh trace is being fetched.</p>`
282
+ : '';
283
+
284
+ const headerCount = `<span class="rcf-badge rcf-badge--count">${rows.length}</span>`;
285
+ const thead = `<thead>
286
+ <tr>
287
+ <th scope="col" data-rcf-col="row">Story / criterion</th>
288
+ <th scope="col" data-rcf-col="ts" data-rcf-trace-col="ts">${escapeHtml(COLUMN_LABEL.ts)}</th>
289
+ <th scope="col" data-rcf-col="tc" data-rcf-trace-col="tc">${escapeHtml(COLUMN_LABEL.tc)}</th>
290
+ <th scope="col" data-rcf-col="fbs" data-rcf-trace-col="fbs">${escapeHtml(COLUMN_LABEL.fbs)}</th>
291
+ <th scope="col" data-rcf-col="cn" data-rcf-trace-col="cn">${escapeHtml(COLUMN_LABEL.cn)}</th>
292
+ </tr>
293
+ </thead>`;
294
+
295
+ const body = rows.map(renderRow).join('');
296
+
297
+ return `<section class="rcf-trace-matrix" data-rcf-trace-matrix="yes" data-rcf-pivot="${escapeHtml(pivot)}" aria-labelledby="rcf-readiness-trace-heading">
298
+ <header class="rcf-trace-matrix__head">
299
+ <h3 id="rcf-readiness-trace-heading">Trace matrix for ${escapeHtml(pivot)}</h3>
300
+ ${headerCount}
301
+ </header>
302
+ ${staleBanner}
303
+ ${filterBar}
304
+ <table class="rcf-trace-matrix__table" aria-describedby="rcf-readiness-trace-heading">
305
+ ${thead}
306
+ <tbody>${body}</tbody>
307
+ </table>
308
+ </section>`;
309
+ }
310
+
311
+ function renderRowFilter() {
312
+ return `<div class="rcf-trace-matrix__filter" data-rcf-filterbar="trace-matrix" role="search">
313
+ <label>
314
+ <span>Filter rows (story id or criterion id)</span>
315
+ <input type="search" data-rcf-filter-key="rowId" placeholder="e.g. US-209 or AC-209-3">
316
+ </label>
317
+ </div>`;
318
+ }
319
+
320
+ function renderRow(row) {
321
+ const kindAttr = row.kind === 'us' ? 'us' : 'ac';
322
+ const parentAttr = row.parent ? ` data-rcf-parent="${escapeHtml(row.parent)}"` : '';
323
+ const titleLabel = row.title
324
+ ? `<span class="rcf-trace-matrix__row-title muted small">${escapeHtml(row.title)}</span>`
325
+ : '';
326
+ const idLink = `<a href="#${escapeHtml(row.id)}" data-rcf-trace-row-id="${escapeHtml(row.id)}">${escapeHtml(row.id)}</a>`;
327
+ const cells = row.cells.map((c) => renderCell(row, c)).join('');
328
+ return `<tr data-rcf-row-id="${escapeHtml(row.id)}" data-rcf-row-kind="${kindAttr}"${parentAttr}>
329
+ <th scope="row" data-rcf-col="row">${idLink} ${titleLabel}</th>
330
+ ${cells}
331
+ </tr>`;
332
+ }
333
+
334
+ function renderCell(row, cell) {
335
+ const kindAttr = escapeHtml(cell.kind);
336
+ const resolutionAttr = RESOLUTIONS.includes(cell.resolution) ? cell.resolution : 'none';
337
+ const label = cell.ids.length === 0
338
+ ? ''
339
+ : `<span class="rcf-trace-matrix__cell-ids" data-rcf-cell-ids="${escapeHtml(cell.ids.join(','))}">${escapeHtml(cell.ids.length === 1 ? cell.ids[0] : `${cell.ids.length} ids`)}</span>`;
340
+ const srReach = cell.reached ? 'reached' : 'not reached';
341
+ const srResolution = `resolution ${RESOLUTION_LABEL[resolutionAttr] ?? resolutionAttr}`;
342
+ return `<td data-rcf-col="${kindAttr}" data-rcf-trace-col="${kindAttr}" data-rcf-cell-reach="${cell.reached ? 'yes' : 'no'}" data-rcf-cell-resolution="${escapeHtml(resolutionAttr)}" title="${escapeHtml(`${COLUMN_LABEL[cell.kind] ?? cell.kind}: ${srReach}, ${srResolution}`)}">${label || '<span class="rcf-sr-only">' + escapeHtml(srReach) + '</span>'}</td>`;
343
+ }
344
+
345
+ function renderShell({ pivot, stale }) {
346
+ const staleBanner = stale
347
+ ? `<p class="rcf-trace-matrix__stale muted small" data-rcf-trace-stale="yes">Tree changed. Fetching a fresh trace.</p>`
348
+ : '';
349
+ return `<section class="rcf-trace-matrix" data-rcf-trace-matrix="shell" data-rcf-pivot="${escapeHtml(pivot)}" aria-labelledby="rcf-readiness-trace-heading">
350
+ <header class="rcf-trace-matrix__head"><h3 id="rcf-readiness-trace-heading">Trace matrix for ${escapeHtml(pivot)}</h3></header>
351
+ ${staleBanner}
352
+ <p class="muted">Loading trace for ${escapeHtml(pivot)}...</p>
353
+ </section>`;
354
+ }
355
+
356
+ function renderLoadingState({ pivot }) {
357
+ return `<section class="rcf-trace-matrix" data-rcf-trace-matrix="loading" data-rcf-pivot="${escapeHtml(pivot)}" aria-labelledby="rcf-readiness-trace-heading">
358
+ <header class="rcf-trace-matrix__head"><h3 id="rcf-readiness-trace-heading">Trace matrix for ${escapeHtml(pivot)}</h3></header>
359
+ <p class="muted">Loading trace...</p>
360
+ </section>`;
361
+ }
362
+
363
+ function renderDownstreamState({ pivot }) {
364
+ // FBS-208 FINALISE (P2-5): the matrix pivots on upstream nodes
365
+ // (REQ/US/AC); a downstream pivot (TS/TC/FBS/CN/TAC/ADR) has no
366
+ // upstream US/AC rows so the body would be empty.
367
+ const safePivot = escapeHtml(pivot);
368
+ return `<section class="rcf-trace-matrix rcf-trace-matrix--empty" data-rcf-trace-matrix="downstream" data-rcf-trace-reason="downstream-pivot" data-rcf-pivot="${safePivot}" aria-labelledby="rcf-readiness-trace-heading">
369
+ <header class="rcf-trace-matrix__head"><h3 id="rcf-readiness-trace-heading">No upstream rows for ${safePivot}</h3></header>
370
+ <p>The matrix pivots on a requirement, story or criterion. Pick an upstream id that reaches <code>${safePivot}</code>.</p>
371
+ <p><button type="button" class="rcf-trace-matrix__lookup" data-rcf-trace-open-lookup="yes">Open the lookup</button></p>
372
+ </section>`;
373
+ }
374
+
375
+ function renderEmptyState({ reason, pivot }) {
376
+ const heading = reason === 'unknown-pivot' && pivot
377
+ ? `No trace for ${escapeHtml(pivot)}`
378
+ : 'Pick an id to trace';
379
+ const body = reason === 'unknown-pivot'
380
+ ? `<p>The id <code>${escapeHtml(pivot ?? '')}</code> is not in the current tree.</p>
381
+ <p>Pick another id below.</p>
382
+ <p><button type="button" class="rcf-trace-matrix__lookup" data-rcf-trace-open-lookup="yes">Open the lookup</button></p>`
383
+ : `<p>Pick a requirement, story or criterion through the Trace action on any readiness row, or open the lookup to search by id.</p>
384
+ <p><button type="button" class="rcf-trace-matrix__lookup" data-rcf-trace-open-lookup="yes">Open the lookup</button></p>`;
385
+ return `<section class="rcf-trace-matrix rcf-trace-matrix--empty" data-rcf-trace-matrix="empty" data-rcf-trace-reason="${escapeHtml(reason)}"${pivot ? ` data-rcf-pivot="${escapeHtml(pivot)}"` : ''} aria-labelledby="rcf-readiness-trace-heading">
386
+ <header class="rcf-trace-matrix__head"><h3 id="rcf-readiness-trace-heading">${heading}</h3></header>
387
+ ${body}
388
+ </section>`;
389
+ }
390
+
391
+ export const TRACE_MATRIX_ROW_FILTER_THRESHOLD = ROW_FILTER_THRESHOLD;
392
+ export const TRACE_MATRIX_RESOLUTIONS = RESOLUTIONS;