@holmes-lab/holmes-kit 0.1.8 → 0.1.10

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 (51) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +48 -4
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/test-platform.d.ts +25 -0
  5. package/dist/holmes/cli/test-platform.js +38 -0
  6. package/dist/holmes/cpg/cpg-scanner.d.ts +41 -0
  7. package/dist/holmes/cpg/cpg-scanner.js +53 -1
  8. package/dist/holmes/cpg/forbidden-edges.d.ts +73 -0
  9. package/dist/holmes/cpg/forbidden-edges.js +140 -0
  10. package/dist/holmes/cpg/hash-cache.js +13 -5
  11. package/dist/holmes/cpg/language-parser-walk.js +70 -4
  12. package/dist/holmes/cpg/proposed-content.d.ts +51 -0
  13. package/dist/holmes/cpg/proposed-content.js +72 -0
  14. package/dist/holmes/cpg/required-calls.d.ts +62 -0
  15. package/dist/holmes/cpg/required-calls.js +93 -0
  16. package/dist/holmes/guardrail/cspec-change.d.ts +23 -0
  17. package/dist/holmes/guardrail/cspec-change.js +70 -0
  18. package/dist/holmes/guardrail/risk-classifier.js +122 -0
  19. package/dist/holmes/guardrail/write-target.d.ts +42 -0
  20. package/dist/holmes/guardrail/write-target.js +69 -18
  21. package/dist/holmes/hooks/pre-tool-use.js +90 -5
  22. package/dist/holmes/hooks/stop.d.ts +17 -0
  23. package/dist/holmes/hooks/stop.js +39 -2
  24. package/dist/holmes/mcp/handlers.d.ts +41 -0
  25. package/dist/holmes/mcp/handlers.js +173 -3
  26. package/dist/holmes/mcp/tool-schemas.js +12 -0
  27. package/dist/holmes/project/dependencies.d.ts +15 -0
  28. package/dist/holmes/project/dependencies.js +58 -0
  29. package/dist/holmes/project/json-state.d.ts +24 -0
  30. package/dist/holmes/project/json-state.js +30 -0
  31. package/dist/holmes/reverse/scan.js +8 -1
  32. package/dist/holmes/review/scope.d.ts +29 -0
  33. package/dist/holmes/review/scope.js +44 -0
  34. package/dist/holmes/rtm/test-scope.d.ts +44 -0
  35. package/dist/holmes/rtm/test-scope.js +92 -2
  36. package/dist/holmes/server/dashboard.d.ts +77 -0
  37. package/dist/holmes/server/dashboard.js +703 -183
  38. package/dist/holmes/spec/approval-blockers.d.ts +21 -5
  39. package/dist/holmes/spec/approval-blockers.js +49 -6
  40. package/dist/holmes/spec/legacy-format.d.ts +14 -0
  41. package/dist/holmes/spec/legacy-format.js +15 -1
  42. package/dist/holmes/spec/nonfunctional.d.ts +70 -0
  43. package/dist/holmes/spec/nonfunctional.js +119 -0
  44. package/dist/holmes/spec/spec-parser.d.ts +25 -0
  45. package/dist/holmes/spec/spec-parser.js +46 -2
  46. package/dist/holmes/spec/spec-types.d.ts +4 -1
  47. package/dist/holmes/spec/spec-types.js +13 -1
  48. package/dist/holmes/testing/effects.d.ts +54 -0
  49. package/dist/holmes/testing/effects.js +107 -0
  50. package/package.json +3 -2
  51. package/playbooks/promote-slice/PLAYBOOK.md +20 -0
@@ -33,9 +33,16 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.SPEC_KINDS = void 0;
36
37
  exports.startDashboardServer = startDashboardServer;
38
+ exports.isCanonicalStatus = isCanonicalStatus;
39
+ exports.activeSpecs = activeSpecs;
40
+ exports.specKindOf = specKindOf;
41
+ exports.buildPipelineRows = buildPipelineRows;
37
42
  // @implements A-SPEC-215
38
43
  // @implements A-SPEC-219
44
+ // @implements A-SPEC-222.2
45
+ const fs = __importStar(require("node:fs"));
39
46
  const http = __importStar(require("node:http"));
40
47
  const path = __importStar(require("node:path"));
41
48
  const spec_store_1 = require("../spec/spec-store");
@@ -73,7 +80,9 @@ async function startDashboardServer(options) {
73
80
  }
74
81
  if (parsedUrl === '/api/rtm') {
75
82
  try {
76
- const specs = await store.list();
83
+ const allSpecs = await store.list();
84
+ const retiredSpecs = allSpecs.filter((s) => s.status === 'outdated');
85
+ const specs = activeSpecs(allSpecs);
77
86
  const files = scanner.scan(root);
78
87
  const implementedSpecIds = new Set();
79
88
  for (const f of files) {
@@ -83,12 +92,71 @@ async function startDashboardServer(options) {
83
92
  const enrichedSpecs = specs.map((s) => {
84
93
  const isA = s.id.startsWith('A-SPEC');
85
94
  const covered = isA ? implementedSpecIds.has(s.id) : s.status === 'approved';
86
- return { ...s, covered };
95
+ return { ...s, covered, legacyStatus: !isCanonicalStatus(s.status) };
87
96
  });
97
+ // Two axes, never one number. Approval (a spec was signed off) and implementation (code is
98
+ // anchored to it) answer different questions, and a spec whose status is outside the
99
+ // canonical vocabulary answers neither — it is excluded and counted separately instead of
100
+ // being silently scored 0, which read as "47 uncovered specs" when it meant "47 unmapped".
101
+ const canonical = enrichedSpecs.filter((s) => !s.legacyStatus && exports.SPEC_KINDS.has(specKindOf(s.id)));
102
+ const approvedCount = canonical.filter((s) => s.status === 'approved').length;
103
+ const anchorable = enrichedSpecs.filter((s) => s.id.startsWith('A-SPEC') && !s.legacyStatus);
104
+ const anchoredCount = anchorable.filter((s) => s.covered).length;
105
+ // @implements A-SPEC-219.1
106
+ // Two populations sit outside the axes, and merging them into one number misleads: a reader
107
+ // of "legacy 49" concludes 49 documents need cleaning up, when 23 of them will never become
108
+ // specs no matter how long anyone waits. The population that prompted this split was
109
+ // `06_job/JOB-*`, work orders carrying an `assignee`; those were deleted under REQ-223
110
+ // once measurement showed all 23 pointed at retired REQs and at 46 source paths of which
111
+ // none still existed. The split stays because it is the guard: the next stray document
112
+ // lands here instead of in the cleanup backlog.
113
+ const outside = enrichedSpecs.filter((s) => s.legacyStatus || !exports.SPEC_KINDS.has(specKindOf(s.id)));
114
+ const unmappedSpecs = outside.filter((s) => exports.SPEC_KINDS.has(specKindOf(s.id)));
115
+ const nonSpecDocs = outside.filter((s) => !exports.SPEC_KINDS.has(specKindOf(s.id)));
116
+ const tally = (rows) => {
117
+ const out = {};
118
+ for (const s of rows) {
119
+ const kind = specKindOf(s.id);
120
+ out[kind] = (out[kind] || 0) + 1;
121
+ }
122
+ return out;
123
+ };
124
+ const approval = {
125
+ total: canonical.length,
126
+ approved: approvedCount,
127
+ pct: canonical.length > 0 ? Math.round((approvedCount / canonical.length) * 100) : 0,
128
+ };
129
+ const implementation = {
130
+ total: anchorable.length,
131
+ anchored: anchoredCount,
132
+ pct: anchorable.length > 0 ? Math.round((anchoredCount / anchorable.length) * 100) : 0,
133
+ };
134
+ // Reported, never silently dropped: without this the 132 excluded documents vanish and the
135
+ // screen cannot say where they went.
136
+ const retiredByKind = {};
137
+ for (const s of retiredSpecs) {
138
+ const kind = specKindOf(s.id);
139
+ retiredByKind[kind] = (retiredByKind[kind] || 0) + 1;
140
+ }
141
+ // Count and kinds only. Shipping the ids would put retired documents back into the very
142
+ // response this slice removes them from, and nothing on the screen renders them.
143
+ const retired = { count: retiredSpecs.length, byKind: retiredByKind };
144
+ const unmapped = { count: unmappedSpecs.length, byKind: tally(unmappedSpecs) };
145
+ const nonSpec = { count: nonSpecDocs.length, byKind: tally(nonSpecDocs) };
88
146
  const coveredCount = enrichedSpecs.filter((s) => s.covered).length;
89
147
  const totalCount = enrichedSpecs.length;
90
- const coveragePct = totalCount > 0 ? Math.round((coveredCount / totalCount) * 100) : 100;
91
- const body = JSON.stringify({ ok: true, totalCount, coveredCount, coveragePct, specs: enrichedSpecs });
148
+ const body = JSON.stringify({
149
+ ok: true,
150
+ totalCount,
151
+ coveredCount,
152
+ approval,
153
+ implementation,
154
+ unmapped,
155
+ nonSpec,
156
+ retired,
157
+ specs: enrichedSpecs,
158
+ files,
159
+ });
92
160
  res.writeHead(200, { 'Content-Type': 'application/json' });
93
161
  res.end(body);
94
162
  }
@@ -103,82 +171,24 @@ async function startDashboardServer(options) {
103
171
  // @implements A-SPEC-219
104
172
  if (parsedUrl === '/api/rtm/heatmap') {
105
173
  try {
106
- const specs = await store.list();
174
+ // @implements A-SPEC-222.2 retired documents never reach a rendered view.
175
+ const specs = activeSpecs(await store.list());
107
176
  const files = scanner.scan(root);
108
- const findingsLedger = new findings_1.FindingsLedger(path.join(root, '.ax', 'ledger', 'findings.jsonl'));
109
- const allFindings = findingsLedger.list();
110
- const reqs = specs.filter((s) => s.id.startsWith('REQ-'));
111
- const pipelines = [];
112
- for (const reqItem of reqs) {
113
- const numPart = reqItem.id.replace('REQ-', '').split('.')[0];
114
- const hspecs = specs.filter((s) => s.id.startsWith(`H-SPEC-${numPart}`));
115
- const aspecs = specs.filter((s) => s.id.startsWith(`A-SPEC-${numPart}`));
116
- const tspecs = specs.filter((s) => s.id.startsWith(`T-SPEC-${numPart}`));
117
- const matchingFiles = files.filter((f) => f.implementsSpecs.some((specId) => specId.includes(numPart) || specId === reqItem.id));
118
- if (matchingFiles.length === 0) {
119
- const specFinding = allFindings.filter((f) => f.specRef?.includes(numPart) && f.status === 'open');
120
- pipelines.push({
121
- reqId: reqItem.id,
122
- reqTitle: reqItem.title,
123
- hspecId: hspecs[0]?.id || `H-SPEC-${numPart}`,
124
- aspecId: aspecs[0]?.id || `A-SPEC-${numPart}`,
125
- tspecId: tspecs[0]?.id || `T-SPEC-${numPart}`,
126
- fileId: 'src/holmes/server/dashboard.ts',
127
- symbolId: 'startDashboardServer',
128
- status: reqItem.status === 'approved' ? 'COVERED' : 'UNCOVERED',
129
- findingsCount: specFinding.length,
130
- criticalCount: specFinding.filter((f) => f.severity === 'critical').length,
131
- mutantCount: 0,
132
- mutationScore: 100,
133
- });
134
- }
135
- else {
136
- for (const f of matchingFiles) {
137
- const fileId = f.sourcePath || f.path;
138
- const syms = f.symbols || [];
139
- const fileMutants = (0, ast_mutation_1.generateAstMutants)([f]);
140
- const fileFindings = allFindings.filter((fd) => (fd.file === fileId || fd.specRef?.includes(numPart)) && fd.status === 'open');
141
- if (syms.length === 0) {
142
- pipelines.push({
143
- reqId: reqItem.id,
144
- reqTitle: reqItem.title,
145
- hspecId: hspecs[0]?.id || `H-SPEC-${numPart}`,
146
- aspecId: aspecs[0]?.id || `A-SPEC-${numPart}`,
147
- tspecId: tspecs[0]?.id || `T-SPEC-${numPart}`,
148
- fileId,
149
- symbolId: '(File Module)',
150
- status: 'COVERED',
151
- findingsCount: fileFindings.length,
152
- criticalCount: fileFindings.filter((fd) => fd.severity === 'critical').length,
153
- mutantCount: fileMutants.length,
154
- mutationScore: 100,
155
- });
156
- }
157
- else {
158
- for (const sym of syms.slice(0, 3)) {
159
- const symMutants = fileMutants.filter((m) => m.symbolName === sym.name);
160
- pipelines.push({
161
- reqId: reqItem.id,
162
- reqTitle: reqItem.title,
163
- hspecId: hspecs[0]?.id || `H-SPEC-${numPart}`,
164
- aspecId: aspecs[0]?.id || `A-SPEC-${numPart}`,
165
- tspecId: tspecs[0]?.id || `T-SPEC-${numPart}`,
166
- fileId,
167
- symbolId: sym.name,
168
- status: 'COVERED',
169
- findingsCount: fileFindings.length,
170
- criticalCount: fileFindings.filter((fd) => fd.severity === 'critical').length,
171
- mutantCount: symMutants.length,
172
- mutationScore: 100,
173
- });
174
- }
175
- }
176
- }
177
- }
178
- }
177
+ // FindingsLedger.list() returns [] for a missing file, which is indistinguishable from a
178
+ // clean scan. The dashboard therefore asks the filesystem directly: an audit lens that
179
+ // renders "0 open findings" over a ledger that was never written is a green wall, not a
180
+ // verdict.
181
+ const findingsPath = path.join(root, '.ax', 'ledger', 'findings.jsonl');
182
+ const findingsScanned = fs.existsSync(findingsPath);
183
+ const findingsLedger = new findings_1.FindingsLedger(findingsPath);
184
+ const allFindings = findingsScanned ? findingsLedger.list() : [];
185
+ const pipelines = buildPipelineRows(specs, files, allFindings, { findingsScanned });
179
186
  const body = JSON.stringify({
180
187
  ok: true,
181
188
  pipelineCount: pipelines.length,
189
+ completeCount: pipelines.filter((p) => p.status === 'COVERED').length,
190
+ findingsScanned,
191
+ mutationScoreMeasured: false,
182
192
  pipelines,
183
193
  });
184
194
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -210,7 +220,8 @@ async function startDashboardServer(options) {
210
220
  // @implements A-SPEC-219
211
221
  if (parsedUrl === '/api/graph') {
212
222
  try {
213
- const specs = await store.list();
223
+ // @implements A-SPEC-222.2 retired documents never reach a rendered view.
224
+ const specs = activeSpecs(await store.list());
214
225
  const files = scanner.scan(root);
215
226
  const nodes = [];
216
227
  const edges = [];
@@ -374,7 +385,7 @@ function renderDashboardHtml() {
374
385
  <html lang="en">
375
386
  <head>
376
387
  <meta charset="UTF-8">
377
- <title>Holmes-Kit World Top-Tier 3-Tier Multi-Lens RTM & Graph Canvas</title>
388
+ <title>Holmes-Kit World Top-Tier Quantitative RTM Matrix & AST/CPG Graph Canvas</title>
378
389
  <script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
379
390
  <style>
380
391
  body { font-family: system-ui, -apple-system, sans-serif; background-color: #0b0f19; color: #f8fafc; margin: 0; padding: 24px; user-select: none; }
@@ -384,18 +395,28 @@ function renderDashboardHtml() {
384
395
  .tab-btn.active { background: #3b82f6; color: #ffffff; border-color: #3b82f6; }
385
396
  .search-box { width: 100%; max-width: 400px; padding: 10px 14px; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #fff; margin-bottom: 16px; font-size: 14px; }
386
397
 
387
- /* REQ Hierarchical Drill-Down Accordion Matrix Styling */
398
+ /* Quantitative REQ SDLC Health Matrix Grid Styling */
388
399
  .rtm-container { display: flex; flex-direction: column; gap: 16px; }
389
- .req-card { background: #1e293b; border: 1.5px solid #334155; border-radius: 10px; overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.4); transition: border-color 0.2s ease; }
400
+ .req-card { background: #1e293b; border: 1.5px solid #334155; border-radius: 10px; overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.4); transition: border-color 0.2s ease; margin-bottom: 16px; }
390
401
  .req-card:hover { border-color: #ec4899; }
391
402
  .req-header { padding: 16px 20px; display: flex; justify-content: space-between; align-items: center; background: rgba(236,72,153,0.06); cursor: pointer; border-bottom: 1px solid rgba(255,255,255,0.05); }
392
403
  .req-title-group { display: flex; align-items: center; gap: 12px; }
393
404
  .req-id-badge { background: #ec4899; color: #fff; font-weight: 800; font-size: 13px; padding: 4px 10px; border-radius: 6px; }
394
405
  .req-title { font-size: 15px; font-weight: bold; color: #f8fafc; }
395
- .req-progress-bar { width: 140px; height: 8px; background: #334155; border-radius: 4px; overflow: hidden; display: inline-block; margin-right: 10px; }
396
- .req-progress-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #34d399); }
406
+
407
+ /* 6-Stage Quantitative Metrics Bar inside REQ Header */
408
+ .quant-grid { display: flex; gap: 8px; align-items: center; background: #0f172a; border: 1px solid #334155; padding: 6px 12px; border-radius: 8px; font-size: 11px; }
409
+ .quant-badge { display: flex; align-items: center; gap: 4px; padding: 2px 6px; border-radius: 4px; font-weight: bold; }
410
+ .quant-hspec { background: rgba(139,92,246,0.2); color: #c084fc; border: 1px solid #8b5cf6; }
411
+ .quant-aspec { background: rgba(59,130,246,0.2); color: #60a5fa; border: 1px solid #3b82f6; }
412
+ .quant-tspec { background: rgba(6,182,212,0.2); color: #67e8f9; border: 1px solid #06b6d4; }
413
+ .quant-file { background: rgba(16,185,129,0.2); color: #34d399; border: 1px solid #10b981; }
414
+ .quant-symbol { background: rgba(245,158,11,0.2); color: #fbbf24; border: 1px solid #f59e0b; }
415
+
397
416
  .expand-btn { background: #334155; color: #38bdf8; border: none; padding: 6px 14px; border-radius: 6px; font-weight: bold; cursor: pointer; font-size: 12px; transition: all 0.15s ease; }
398
417
  .expand-btn:hover { background: #3b82f6; color: #fff; }
418
+ .view-graph-btn { background: #8b5cf6; color: #fff; border: none; padding: 6px 14px; border-radius: 6px; font-weight: bold; cursor: pointer; font-size: 12px; }
419
+ .view-graph-btn:hover { background: #7c3aed; }
399
420
 
400
421
  .accordion-body { padding: 16px 20px; display: none; background: #0f172a; border-top: 1px solid #334155; flex-direction: column; gap: 10px; }
401
422
  .accordion-body.open { display: flex; }
@@ -410,9 +431,9 @@ function renderDashboardHtml() {
410
431
  .badge-uncovered { background-color: #991b1b; color: #f87171; }
411
432
  .stat-badge { font-size: 24px; font-weight: 800; color: #f59e0b; }
412
433
 
413
- /* 6-Stage Sequential Pipeline RTM Heatmap Matrix Styling */
414
- .heatmap-wrapper { width: 100%; overflow-x: auto; background: #0f172a; border-radius: 10px; border: 1.5px solid #334155; padding: 16px; box-sizing: border-box; }
415
- .pipeline-table { border-collapse: collapse; font-size: 12px; width: 100%; min-width: 1000px; }
434
+ /* REQ-Grouped Structured 6-Stage Pipeline Heatmap Matrix Styling */
435
+ .heatmap-wrapper { width: 100%; background: #0f172a; border-radius: 10px; border: 1.5px solid #334155; padding: 20px; box-sizing: border-box; }
436
+ .pipeline-table { border-collapse: collapse; font-size: 12px; width: 100%; }
416
437
  .pipeline-table th, .pipeline-table td { border: 1px solid #334155; padding: 10px 12px; text-align: left; }
417
438
  .pipeline-table th { background: #1e293b; color: #f8fafc; font-weight: bold; text-transform: uppercase; font-size: 11px; letter-spacing: 0.5px; }
418
439
  .pipeline-row:hover { background: #1e293b; }
@@ -479,9 +500,25 @@ function renderDashboardHtml() {
479
500
  .legend-dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
480
501
  .controls { margin-bottom: 12px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
481
502
  .select-box { padding: 8px 12px; border-radius: 6px; border: 1px solid #334155; background: #1e293b; color: #fff; font-size: 13px; }
482
- .popover-card { position: absolute; top: 16px; right: 420px; width: 320px; background: #0f172a; border: 1px solid #3b82f6; border-radius: 8px; padding: 14px; box-shadow: 0 10px 25px rgba(0,0,0,0.5); pointer-events: none; opacity: 0; transition: opacity 0.2s ease; z-index: 100; font-size: 12px; }
503
+
504
+ .axis-stats { display: flex; align-items: center; gap: 18px; font-size: 13px; color: #94a3b8; }
505
+ .axis-stat { display: inline-flex; align-items: center; gap: 6px; }
506
+ .legacy-chip { cursor: pointer; border: 1px dashed #64748b; border-radius: 999px; padding: 4px 10px; }
507
+ .legacy-chip:hover { border-color: #f59e0b; color: #f59e0b; }
508
+ .nonspec-chip { cursor: pointer; border: 1px solid #334155; border-radius: 999px; padding: 4px 10px; color: #64748b; }
509
+ .nonspec-chip:hover { border-color: #94a3b8; color: #94a3b8; }
510
+ .scope-notice { font-size: 12px; color: #94a3b8; background: #0f172a; border: 1px solid #334155; border-radius: 8px; padding: 8px 12px; margin-bottom: 10px; }
511
+ .scope-notice-truncated { color: #fbbf24; border-color: #b45309; border-style: dashed; }
512
+ .stage-missing { color: #64748b; font-style: italic; cursor: default; background: repeating-linear-gradient(45deg, rgba(100,116,139,0.06) 0 6px, transparent 6px 12px); }
513
+ .badge-unmeasured { background: #334155; color: #cbd5e1; border: 1px dashed #64748b; }
514
+ /* World Top-Tier High-Density Rich Tooltip Popover Card Styling */
515
+ .popover-card { position: absolute; top: 16px; right: 420px; width: 360px; background: #0f172a; border: 1.5px solid #3b82f6; border-radius: 10px; padding: 16px; box-shadow: 0 12px 35px rgba(0,0,0,0.8); pointer-events: none; opacity: 0; transition: opacity 0.15s ease; z-index: 100; font-size: 12px; box-sizing: border-box; backdrop-filter: blur(10px); }
483
516
  .popover-card.active { opacity: 1; pointer-events: auto; }
484
- .popover-title { font-weight: bold; font-size: 14px; color: #38bdf8; margin-bottom: 6px; word-break: break-all; }
517
+ .popover-badge { display: inline-block; padding: 3px 8px; border-radius: 4px; font-weight: 800; font-size: 11px; color: #fff; margin-bottom: 8px; }
518
+ .popover-title { font-weight: bold; font-size: 15px; color: #f8fafc; margin-bottom: 6px; word-break: break-all; }
519
+ .popover-section { margin-top: 10px; padding-top: 8px; border-top: 1px solid #334155; display: flex; flex-direction: column; gap: 4px; }
520
+ .popover-label { color: #94a3b8; font-size: 11px; font-weight: bold; text-transform: uppercase; }
521
+ .popover-value { color: #cbd5e1; font-size: 12px; }
485
522
 
486
523
  .canvas-toolbar { position: absolute; bottom: 20px; right: 420px; display: flex; flex-direction: column; gap: 8px; z-index: 90; background: rgba(15, 23, 42, 0.85); backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.15); border-radius: 10px; padding: 8px; box-shadow: 0 8px 32px rgba(0,0,0,0.4); }
487
524
  .tool-btn { background: #1e293b; color: #f8fafc; border: 1px solid #334155; width: 38px; height: 38px; border-radius: 6px; font-weight: bold; font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; }
@@ -492,39 +529,70 @@ function renderDashboardHtml() {
492
529
  <body>
493
530
  <div class="header">
494
531
  <div>
495
- <h1 style="margin:0; font-size: 24px;">🔥 3-Tier Multi-Lens RTM Pipeline & Graph Canvas</h1>
496
- <div style="font-size: 13px; color: #94a3b8; margin-top: 4px;">Layered Multi-Lens ArchitectureTruthful Real-Time Entity Traceability</div>
532
+ <h1 style="margin:0; font-size: 24px;">🔥 REQ-Grouped Pipeline Matrix & AST/CPG Canvas</h1>
533
+ <div style="font-size: 13px; color: #94a3b8; margin-top: 4px;">REQ Master Section Blocks • Multi-Lens Quick Filter Bar Full-collection CPG call graph (canvas shows a bounded subgraph)</div>
534
+ </div>
535
+ <div class="axis-stats">
536
+ <span class="axis-stat" title="Specs signed off, over specs using the canonical status vocabulary">📋 Approval <span id="approvalStat" class="stat-badge">--%</span></span>
537
+ <span class="axis-stat" title="A-SPECs with code anchored to them, over canonical A-SPECs">⚙️ Implementation <span id="implStat" class="stat-badge">--%</span></span>
538
+ <span class="axis-stat legacy-chip" id="unmappedChip" title="Spec documents whose status is outside draft|review|approved|outdated — these are the cleanup backlog" onclick="switchTab('legacy')">🏷️ <span id="unmappedStat">--</span></span>
539
+ <span class="axis-stat nonspec-chip" id="nonSpecChip" title="Documents in the spec store that are not a governed spec kind — never a cleanup target" onclick="switchTab('legacy')">📋 <span id="nonSpecStat">--</span></span>
497
540
  </div>
498
- <div>Specification Coverage: <span id="coverageStat" class="stat-badge">--%</span></div>
499
541
  </div>
500
542
 
501
543
  <div class="nav-tabs">
502
- <button id="tabRtmBtn" class="tab-btn active" onclick="switchTab('rtm')">📋 RTM Traceability Grid (REQ Drill-Down)</button>
503
- <button id="tabHeatmapBtn" class="tab-btn" onclick="switchTab('heatmap')">🔥 6-Stage Pipeline Heatmap (REQ ➔ AST/CPG)</button>
544
+ <button id="tabRtmBtn" class="tab-btn active" onclick="switchTab('rtm')">📊 Quantitative REQ RTM Matrix Grid</button>
545
+ <button id="tabHeatmapBtn" class="tab-btn" onclick="switchTab('heatmap')">🔥 REQ-Grouped Pipeline Heatmap (REQ ➔ AST)</button>
504
546
  <button id="tabGraphBtn" class="tab-btn" onclick="switchTab('graph')">🕸️ End-to-End Human-Insight Pipeline Canvas</button>
547
+ <button id="tabLegacyBtn" class="tab-btn" onclick="switchTab('legacy')">🏷️ Unmapped Specs (C-SPEC · JOB)</button>
505
548
  </div>
506
549
 
507
550
  <div id="rtmTab">
508
551
  <div class="ux-hint-banner">
509
- <span>💡 <strong>REQ Hierarchical Accordion Matrix:</strong> Displaying primary <strong>REQ Business Requirements</strong>. Click <strong>▶ Expand</strong> to drill down into downstream specs, files, and AST symbols!</span>
552
+ <span>💡 <strong>Quantitative REQ SDLC Health Matrix Grid:</strong> Every business requirement displays exact 6-Stage Quantitative Metrics (H/A/T-SPEC, Code Files, AST Symbols, CPG Edges). Click <strong>▶ Expand</strong> for itemized status!</span>
510
553
  </div>
511
554
  <input type="text" id="searchInput" class="search-box" placeholder="Search business REQ requirements by ID or title..." />
512
555
  <div id="specGrid" class="rtm-container">Loading REQ specifications...</div>
513
556
  </div>
514
557
 
558
+ <div id="legacyTab" style="display: none;">
559
+ <div class="ux-hint-banner" style="border-left-color: #f59e0b;">
560
+ <span>🏷️ <strong>Cleanup backlog:</strong> spec documents whose <code>status</code> falls outside <code>draft | review | approved | outdated</code>, so no governance gate has scored them. Each is waiting for <code>spec_upgrade</code> → <code>spec_retire</code>, or a rewrite into the current format. Excluded from both headline axes — an unmapped vocabulary is not an uncovered spec.</span>
561
+ </div>
562
+ <div id="unmappedGrid" class="rtm-container">Loading…</div>
563
+
564
+ <div id="nonSpecSection" style="display: none;">
565
+ <div class="ux-hint-banner" style="border-left-color: #334155; margin-top: 28px;">
566
+ <span>📋 <strong>Not specifications:</strong> documents that live in the spec store but are not a governed spec kind. They are <strong>not</strong> a cleanup target and never will be, and they have always sat outside both axes. Nothing should normally appear here — <code>spec-store-boundary.test.ts</code> fails the build when it does.</span>
567
+ </div>
568
+ <div id="nonSpecGrid" class="rtm-container"></div>
569
+ </div>
570
+ </div>
571
+
515
572
  <div id="heatmapTab" style="display: none;">
516
573
  <div class="ux-hint-banner" style="border-left-color: #f59e0b;">
517
- <div style="display: flex; align-items: center; gap: 12px;">
518
- <span>🔥 <strong>3-Tier Multi-Lens Insight Switcher:</strong> Select Lens to Overlay Truthful Real-Time Entity Data:</span>
519
- <select id="lensSelect" class="select-box" style="border-color: #f59e0b; font-weight: bold; background: #0f172a;" onchange="changeHeatmapLens()">
520
- <option value="lens1" selected>🎯 Lens 1: SDLC Trace Spine (REQ ➔ AST/CPG Core Chain)</option>
521
- <option value="lens2">🛡️ Lens 2: Audit & Security Risk Overlay (Live Findings + Provenance Ledger)</option>
522
- <option value="lens3">🧪 Lens 3: Mutation & Robustness Overlay (Live AST Mutants + Score)</option>
523
- </select>
574
+ <div style="display: flex; align-items: center; gap: 16px; flex-wrap: wrap;">
575
+ <div style="display: flex; align-items: center; gap: 8px;">
576
+ <span>🔥 <strong>3-Tier Multi-Lens:</strong></span>
577
+ <select id="lensSelect" class="select-box" style="border-color: #f59e0b; font-weight: bold; background: #0f172a;" onchange="changeHeatmapLens()">
578
+ <option value="lens1" selected>🎯 Lens 1: SDLC Trace Spine (REQ AST/CPG Core Chain)</option>
579
+ <option value="lens2">🛡️ Lens 2: Audit & Security Risk Overlay (Findings Ledger shows “not scanned” when absent)</option>
580
+ <option value="lens3">🧪 Lens 3: Mutation & Robustness Overlay (Static AST Mutants — score unmeasured)</option>
581
+ </select>
582
+ </div>
583
+
584
+ <div style="display: flex; align-items: center; gap: 8px;">
585
+ <span>🔍 <strong>Filter Status:</strong></span>
586
+ <select id="pipelineFilterSelect" class="select-box" style="border-color: #38bdf8; font-weight: bold; background: #0f172a;" onchange="changeHeatmapLens()">
587
+ <option value="all" selected>All REQ Pipelines</option>
588
+ <option value="uncovered">Only Uncovered / Active Traces</option>
589
+ <option value="findings">Only Traces with Open Findings</option>
590
+ </select>
591
+ </div>
524
592
  </div>
525
593
  </div>
526
594
  <div class="heatmap-wrapper">
527
- <div id="heatmapContainer">Loading 6-stage pipeline heatmap...</div>
595
+ <div id="heatmapContainer">Loading REQ-grouped 6-stage pipeline heatmap...</div>
528
596
  </div>
529
597
  </div>
530
598
 
@@ -565,6 +633,7 @@ function renderDashboardHtml() {
565
633
  </select>
566
634
  </div>
567
635
 
636
+ <div id="graphScopeNotice" class="scope-notice"></div>
568
637
  <div id="graphCanvasContainer">
569
638
  <svg id="graphSvg" class="graph-svg"></svg>
570
639
 
@@ -591,11 +660,12 @@ function renderDashboardHtml() {
591
660
  <button class="tool-btn" onclick="resetZoom()" title="Reset Zoom to 100%">🔄</button>
592
661
  </div>
593
662
 
663
+ <!-- World Top-Tier Rich Hover Popover Card -->
594
664
  <div id="popoverCard" class="popover-card">
665
+ <div id="popoverBadge" class="popover-badge">--</div>
595
666
  <div id="popoverTitle" class="popover-title">--</div>
596
- <div id="popoverType" style="color:#94a3b8; margin-bottom:4px;">--</div>
597
- <div id="popoverStatus" style="font-weight:bold; margin-bottom:8px;">--</div>
598
- <div id="popoverStats" style="color:#cbd5e1; font-size:11px;">--</div>
667
+ <div id="popoverSection1" class="popover-section"></div>
668
+ <div id="popoverSection2" class="popover-section"></div>
599
669
  </div>
600
670
  </div>
601
671
  <div class="legend">
@@ -645,6 +715,7 @@ function renderDashboardHtml() {
645
715
  }
646
716
 
647
717
  let allSpecs = [];
718
+ let allFiles = [];
648
719
  let graphData = null;
649
720
  let heatmapData = null;
650
721
  let currentZoom = null;
@@ -652,14 +723,73 @@ function renderDashboardHtml() {
652
723
  let traversalHistory = [];
653
724
  let selectedRootNodeId = 'A-SPEC-219';
654
725
 
726
+ // Specs excluded from both axes get their own surface. Previously 47 documents were invisible
727
+ // on every tab while still sitting in the headline denominator, which read as 0% coverage.
728
+ // @implements A-SPEC-219.1 — two populations, two destinations. Summing them is what misled:
729
+ // "49" read as "49 to clean up" while 23 of them will never become specs.
730
+ const SPEC_KINDS_UI = ['REQ', 'H-SPEC', 'A-SPEC', 'C-SPEC', 'T-SPEC'];
731
+ const kindOfId = (id) => (String(id).match(/^([A-Za-z-]+?)-\d/) || [null, 'UNKNOWN'])[1];
732
+
733
+ function renderUnmappedGrid(specs) {
734
+ const outside = specs.filter(s => s.legacyStatus || SPEC_KINDS_UI.indexOf(kindOfId(s.id)) === -1);
735
+ renderDocGroup(
736
+ document.getElementById('unmappedGrid'),
737
+ outside.filter(s => SPEC_KINDS_UI.indexOf(kindOfId(s.id)) !== -1),
738
+ 'Every spec uses the canonical status vocabulary.',
739
+ );
740
+ // @implements A-SPEC-223 — an absent category is not explained. T-SPEC-219.1 asked for this
741
+ // as a boundary case and it was never implemented; the store is now empty of such documents,
742
+ // so the banner would describe something that does not exist.
743
+ const nonSpec = outside.filter(s => SPEC_KINDS_UI.indexOf(kindOfId(s.id)) === -1);
744
+ const section = document.getElementById('nonSpecSection');
745
+ if (section) section.style.display = nonSpec.length > 0 ? 'block' : 'none';
746
+ renderDocGroup(document.getElementById('nonSpecGrid'), nonSpec, '');
747
+ }
748
+
749
+ function renderDocGroup(container, rows, emptyMessage) {
750
+ if (!container) return;
751
+ const legacy = rows;
752
+ if (legacy.length === 0) {
753
+ container.innerHTML = '<div style="color:#94a3b8; font-size:14px;">' + emptyMessage + '</div>';
754
+ return;
755
+ }
756
+ const byKind = new Map();
757
+ legacy.forEach(s => {
758
+ const kind = (s.id.match(/^([A-Za-z-]+?)-\d/) || [null, 'UNKNOWN'])[1];
759
+ if (!byKind.has(kind)) byKind.set(kind, []);
760
+ byKind.get(kind).push(s);
761
+ });
762
+ let html = '';
763
+ byKind.forEach((items, kind) => {
764
+ const statuses = {};
765
+ items.forEach(s => { statuses[s.status] = (statuses[s.status] || 0) + 1; });
766
+ const statusSummary = Object.keys(statuses).map(k => escapeHtml(k) + ' × ' + statuses[k]).join(' · ');
767
+ html += '<div class="req-card" style="margin-bottom:20px;">'
768
+ + '<div class="req-header" onclick="toggleAccordion(\'legacy-body-' + escapeHtml(kind) + '\')">'
769
+ + '<div class="req-title-group"><span class="req-id-badge">' + escapeHtml(kind) + '</span>'
770
+ + '<span class="req-title">' + items.length + ' unmapped document' + (items.length === 1 ? '' : 's') + '</span>'
771
+ + '<span class="neighbor-rel badge-unmeasured">' + statusSummary + '</span></div>'
772
+ + '<button class="expand-btn">▶ Expand</button></div>'
773
+ + '<div class="accordion-body open" id="legacy-body-' + escapeHtml(kind) + '">'
774
+ + items.map(s =>
775
+ '<div class="tree-row tree-row-file"><div class="tree-label">🏷️ <strong>' + escapeHtml(s.id) + '</strong>: ' + escapeHtml(s.title || '') + '</div>'
776
+ + '<div><span class="neighbor-rel badge-unmeasured" title="Outside draft|review|approved|outdated">status: ' + escapeHtml(String(s.status)) + '</span></div></div>'
777
+ ).join('')
778
+ + '</div></div>';
779
+ });
780
+ container.innerHTML = html;
781
+ }
782
+
655
783
  function switchTab(tab) {
656
784
  document.getElementById('tabRtmBtn').classList.toggle('active', tab === 'rtm');
657
785
  document.getElementById('tabHeatmapBtn').classList.toggle('active', tab === 'heatmap');
658
786
  document.getElementById('tabGraphBtn').classList.toggle('active', tab === 'graph');
787
+ document.getElementById('tabLegacyBtn').classList.toggle('active', tab === 'legacy');
659
788
 
660
789
  document.getElementById('rtmTab').style.display = tab === 'rtm' ? 'block' : 'none';
661
790
  document.getElementById('heatmapTab').style.display = tab === 'heatmap' ? 'block' : 'none';
662
791
  document.getElementById('graphTab').style.display = tab === 'graph' ? 'block' : 'none';
792
+ document.getElementById('legacyTab').style.display = tab === 'legacy' ? 'block' : 'none';
663
793
 
664
794
  if (tab === 'graph' && !graphData) {
665
795
  loadGraphCanvas();
@@ -674,8 +804,21 @@ function renderDashboardHtml() {
674
804
  const data = await res.json();
675
805
  if (data.ok) {
676
806
  allSpecs = data.specs;
677
- document.getElementById('coverageStat').innerText = data.coveragePct + '% (' + data.coveredCount + '/' + data.totalCount + ')';
678
- renderReqAccordionMatrix(allSpecs);
807
+ allFiles = data.files || [];
808
+ const ap = data.approval || { pct: 0, approved: 0, total: 0 };
809
+ const im = data.implementation || { pct: 0, anchored: 0, total: 0 };
810
+ const um = data.unmapped || { count: 0, byKind: {} };
811
+ const ns = data.nonSpec || { count: 0, byKind: {} };
812
+ document.getElementById('approvalStat').innerText = ap.pct + '% (' + ap.approved + '/' + ap.total + ')';
813
+ document.getElementById('implStat').innerText = im.pct + '% (' + im.anchored + '/' + im.total + ')';
814
+ const umKinds = Object.keys(um.byKind || {}).map(k => k + ' ' + um.byKind[k]).join(' · ');
815
+ const nsKinds = Object.keys(ns.byKind || {}).map(k => k + ' ' + ns.byKind[k]).join(' · ');
816
+ document.getElementById('unmappedStat').innerText = um.count + ' to clean up' + (umKinds ? ' (' + umKinds + ')' : '');
817
+ const nsChip = document.getElementById('nonSpecChip');
818
+ if (nsChip) nsChip.style.display = ns.count > 0 ? 'inline-flex' : 'none';
819
+ document.getElementById('nonSpecStat').innerText = ns.count + ' not specs' + (nsKinds ? ' (' + nsKinds + ')' : '');
820
+ renderUnmappedGrid(data.specs || []);
821
+ renderReqQuantitativeGrid(allSpecs, allFiles);
679
822
  }
680
823
  } catch (err) {
681
824
  document.getElementById('specGrid').innerText = 'Failed to load specs: ' + escapeHtml(err.message || err);
@@ -701,76 +844,148 @@ function renderDashboardHtml() {
701
844
  }
702
845
  }
703
846
 
704
- // World Top-Tier Truthful Real-Time 3-Tier Multi-Lens Pipeline Heatmap Renderer
847
+ // Render one pipeline stage cell. A stage with no artifact renders as an inert “missing” cell
848
+ // instead of a clickable id, so an empty stage can never be mistaken for a traced one.
849
+ function stageCell(cls, icon, label, jumpId, withArrow) {
850
+ const arrow = withArrow ? '<span class="arrow-sep">➔</span> ' : '';
851
+ if (!label) {
852
+ return '<td class="' + cls + ' stage-missing" title="No artifact for this stage">' + arrow + '— missing</td>';
853
+ }
854
+ const onclick = jumpId ? ' onclick="focusSpecInGraph(\'' + escapeHtml(String(jumpId)) + '\')"' : '';
855
+ return '<td class="' + cls + '"' + onclick + '>' + arrow + icon + ' ' + escapeHtml(String(label)) + '</td>';
856
+ }
857
+ // REQ-Grouped Structured 6-Stage Pipeline Heatmap Matrix Renderer
705
858
  function render6StagePipelineMatrix(data) {
706
859
  const container = document.getElementById('heatmapContainer');
707
- const pipelines = data.pipelines || [];
860
+ const rawPipelines = data.pipelines || [];
708
861
  const lens = document.getElementById('lensSelect').value || 'lens1';
862
+ const statusFilter = document.getElementById('pipelineFilterSelect').value || 'all';
709
863
 
710
- if (pipelines.length === 0) {
864
+ if (rawPipelines.length === 0) {
711
865
  container.innerHTML = '<div style="color:#94a3b8; font-size:14px;">No pipeline trace chains found.</div>';
712
866
  return;
713
867
  }
714
868
 
715
- let extraHeader = 'Status';
716
- if (lens === 'lens2') extraHeader = '🛡️ Live Security Findings';
717
- else if (lens === 'lens3') extraHeader = '🧪 Real AST Mutants Score';
718
-
719
- let html = \`
720
- <table class="pipeline-table">
721
- <thead>
722
- <tr>
723
- <th>Stage 1: REQ Business</th>
724
- <th>Stage 2: H-SPEC Functional</th>
725
- <th>Stage 3: A-SPEC Architecture</th>
726
- <th>Stage 4: T-SPEC Verification</th>
727
- <th>Stage 5: Source Code File</th>
728
- <th>Stage 6: AST / CPG Symbol</th>
729
- <th>\${extraHeader}</th>
730
- </tr>
731
- </thead>
732
- <tbody>
733
- \`;
869
+ // Filter pipelines based on user status selection
870
+ let pipelines = rawPipelines;
871
+ if (statusFilter === 'uncovered') {
872
+ pipelines = rawPipelines.filter(p => p.status !== 'COVERED');
873
+ } else if (statusFilter === 'findings') {
874
+ pipelines = rawPipelines.filter(p => p.findingsCount > 0);
875
+ }
734
876
 
877
+ // Group pipelines by reqId
878
+ const groupedMap = new Map();
735
879
  pipelines.forEach(p => {
736
- let statusBadge = \`<span class="neighbor-rel \${p.status === 'COVERED' ? 'badge-covered' : 'badge-uncovered'}">\${p.status}</span>\`;
737
-
738
- if (lens === 'lens2') {
739
- if (p.criticalCount > 0) {
740
- statusBadge = \`<span class="neighbor-rel badge-uncovered">🔴 \${p.criticalCount} Critical Finding\${p.criticalCount > 1 ? 's' : ''}</span>\`;
741
- } else if (p.findingsCount > 0) {
742
- statusBadge = \`<span class="neighbor-rel" style="background:#d97706; color:#fff;">🟡 \${p.findingsCount} Open Finding\${p.findingsCount > 1 ? 's' : ''}</span>\`;
743
- } else {
744
- statusBadge = '<span class="neighbor-rel badge-covered">🟢 0 Open Findings</span>';
745
- }
746
- } else if (lens === 'lens3') {
747
- const mutantCount = p.mutantCount || 0;
748
- if (mutantCount > 0) {
749
- statusBadge = \`<span class="neighbor-rel badge-covered">🧪 100% Score (\${mutantCount} Mutants)</span>\`;
750
- } else {
751
- statusBadge = '<span class="neighbor-rel badge-covered">🧪 100% Score (0 Mutants)</span>';
752
- }
880
+ if (!groupedMap.has(p.reqId)) {
881
+ groupedMap.set(p.reqId, { reqId: p.reqId, reqTitle: p.reqTitle, items: [] });
753
882
  }
883
+ groupedMap.get(p.reqId).items.push(p);
884
+ });
885
+
886
+ if (groupedMap.size === 0) {
887
+ const unscanned = statusFilter === 'findings' && data.findingsScanned === false;
888
+ container.innerHTML = unscanned
889
+ ? '<div style="color:#fbbf24; font-size:14px; padding:16px; border:1px dashed #64748b; border-radius:8px;">⚪ No findings ledger exists (<code>.ax/ledger/findings.jsonl</code>). This is <strong>not</strong> a clean result — no audit has been run.</div>'
890
+ : '<div style="color:#94a3b8; font-size:14px; padding:16px;">No pipelines match the selected filter.</div>';
891
+ return;
892
+ }
893
+
894
+ let extraHeader = 'Status';
895
+ if (lens === 'lens2') extraHeader = heatmapData && heatmapData.findingsScanned === false ? '🛡️ Security Audit (never scanned)' : '🛡️ Live Security Findings';
896
+ else if (lens === 'lens3') extraHeader = '🧪 Static AST Mutants (score unmeasured)';
897
+
898
+ let html = '';
899
+
900
+ groupedMap.forEach((group, reqId) => {
901
+ const items = group.items;
902
+ const totalItems = items.length;
903
+ const coveredItems = items.filter(i => i.status === 'COVERED').length;
904
+ const isReqHealthy = coveredItems === totalItems;
905
+
906
+ const reqStatusBadge = isReqHealthy
907
+ ? '<span class="neighbor-rel badge-covered">🟢 100% HEALTHY TRACE</span>'
908
+ : '<span class="neighbor-rel badge-uncovered">🔴 CONTAINS UNCOVERED TRACES</span>';
909
+
910
+ html += \`
911
+ <div class="req-card" style="margin-bottom: 20px;">
912
+ <div class="req-header" onclick="toggleAccordion('pipe-body-\${escapeHtml(reqId)}')">
913
+ <div class="req-title-group">
914
+ <span class="req-id-badge">\${escapeHtml(reqId)}</span>
915
+ <span class="req-title">\${escapeHtml(group.reqTitle || 'Business Requirement Pipeline')}</span>
916
+ \${reqStatusBadge}
917
+ </div>
918
+ <div style="display: flex; align-items: center; gap: 12px;">
919
+ <span style="font-size: 12px; color: #94a3b8; font-weight: bold;">\${totalItems} End-to-End Trace Chain\${totalItems > 1 ? 's' : ''}</span>
920
+ <button class="expand-btn">▶ Expand Pipeline Matrix</button>
921
+ </div>
922
+ </div>
923
+
924
+ <div class="accordion-body open" id="pipe-body-\${escapeHtml(reqId)}" style="padding:0;">
925
+ <table class="pipeline-table">
926
+ <thead>
927
+ <tr>
928
+ <th>Stage 1: REQ Business</th>
929
+ <th>Stage 2: H-SPEC Functional</th>
930
+ <th>Stage 3: A-SPEC Architecture</th>
931
+ <th>Stage 4: T-SPEC Verification</th>
932
+ <th>Stage 5: Source Code File</th>
933
+ <th>Stage 6: AST / CPG Symbol</th>
934
+ <th>\${extraHeader}</th>
935
+ </tr>
936
+ </thead>
937
+ <tbody>
938
+ \`;
939
+
940
+ items.forEach(p => {
941
+ const missing = p.missingStages || [];
942
+ let statusBadge = missing.length === 0
943
+ ? '<span class="neighbor-rel badge-covered">COVERED (6/6)</span>'
944
+ : \`<span class="neighbor-rel badge-uncovered" title="Missing: \${escapeHtml(missing.join(', '))}">UNCOVERED (\${p.stagesComplete}/6)</span>\`;
945
+
946
+ if (lens === 'lens2') {
947
+ if (!p.findingsScanned) {
948
+ statusBadge = '<span class="neighbor-rel badge-unmeasured" title="No .ax/ledger/findings.jsonl — this chain has never been audited">⚪ Not scanned</span>';
949
+ } else if (p.criticalCount > 0) {
950
+ statusBadge = \`<span class="neighbor-rel badge-uncovered">🔴 \${p.criticalCount} Critical Finding\${p.criticalCount > 1 ? 's' : ''}</span>\`;
951
+ } else if (p.findingsCount > 0) {
952
+ statusBadge = \`<span class="neighbor-rel" style="background:#d97706; color:#fff;">🟡 \${p.findingsCount} Open Finding\${p.findingsCount > 1 ? 's' : ''}</span>\`;
953
+ } else {
954
+ statusBadge = '<span class="neighbor-rel badge-covered">🟢 0 Open Findings</span>';
955
+ }
956
+ } else if (lens === 'lens3') {
957
+ // A score requires running the suite once per mutant; this endpoint only generates them
958
+ // statically, so it reports the mutant count and says the score is unmeasured.
959
+ const mutantCount = p.mutantCount || 0;
960
+ statusBadge = \`<span class="neighbor-rel badge-unmeasured" title="Static generation only — run mutation testing to obtain a score">🧪 \${mutantCount} Mutant\${mutantCount === 1 ? '' : 's'} · Score not measured</span>\`;
961
+ }
962
+
963
+ html += \`
964
+ <tr class="pipeline-row">
965
+ \${stageCell('stage-req', '🟣', p.reqId, p.reqId, false)}
966
+ \${stageCell('stage-hspec', '🟣', p.hspecId, p.hspecId, true)}
967
+ \${stageCell('stage-aspec', '🔵', p.aspecId, p.aspecId, true)}
968
+ \${stageCell('stage-tspec', '🔷', p.tspecId, p.tspecId, true)}
969
+ \${stageCell('stage-file', '📄', p.fileId ? (p.fileId.split('/').pop() || p.fileId) : null, p.fileId, true)}
970
+ \${stageCell('stage-symbol', '⚡', p.symbolId, p.fileId && p.symbolId ? p.fileId + '#' + p.symbolId : null, true)}
971
+ <td>\${statusBadge}</td>
972
+ </tr>
973
+ \`;
974
+ });
754
975
 
755
976
  html += \`
756
- <tr class="pipeline-row">
757
- <td class="stage-req" onclick="focusSpecInGraph('\${escapeHtml(p.reqId)}')">🟣 \${escapeHtml(p.reqId)}</td>
758
- <td class="stage-hspec" onclick="focusSpecInGraph('\${escapeHtml(p.hspecId)}')"><span class="arrow-sep">➔</span> 🟣 \${escapeHtml(p.hspecId)}</td>
759
- <td class="stage-aspec" onclick="focusSpecInGraph('\${escapeHtml(p.aspecId)}')"><span class="arrow-sep">➔</span> 🔵 \${escapeHtml(p.aspecId)}</td>
760
- <td class="stage-tspec" onclick="focusSpecInGraph('\${escapeHtml(p.tspecId)}')"><span class="arrow-sep">➔</span> 🔷 \${escapeHtml(p.tspecId)}</td>
761
- <td class="stage-file" onclick="focusSpecInGraph('\${escapeHtml(p.fileId)}')"><span class="arrow-sep">➔</span> 📄 \${escapeHtml(p.fileId.split('/').pop() || p.fileId)}</td>
762
- <td class="stage-symbol" onclick="focusSpecInGraph('\${escapeHtml(p.fileId + '#' + p.symbolId)}')"><span class="arrow-sep">➔</span> ⚡ \${escapeHtml(p.symbolId)}</td>
763
- <td>\${statusBadge}</td>
764
- </tr>
977
+ </tbody>
978
+ </table>
979
+ </div>
980
+ </div>
765
981
  \`;
766
982
  });
767
983
 
768
- html += '</tbody></table>';
769
984
  container.innerHTML = html;
770
985
  }
771
986
 
772
- // World Top-Tier REQ Hierarchical Drill-Down Accordion Renderer
773
- function renderReqAccordionMatrix(specs) {
987
+ // World Top-Tier Quantitative REQ-Centric RTM Traceability Matrix Grid Renderer
988
+ function renderReqQuantitativeGrid(specs, files) {
774
989
  const container = document.getElementById('specGrid');
775
990
  const reqList = specs.filter(s => s.id.startsWith('REQ-'));
776
991
 
@@ -781,13 +996,33 @@ function renderDashboardHtml() {
781
996
 
782
997
  container.innerHTML = reqList.map(req => {
783
998
  const numPart = req.id.replace('REQ-', '').split('.')[0];
784
- const hspecs = specs.filter(s => s.id.startsWith('H-SPEC-' + numPart));
785
- const aspecs = specs.filter(s => s.id.startsWith('A-SPEC-' + numPart));
786
- const tspecs = specs.filter(s => s.id.startsWith('T-SPEC-' + numPart));
999
+ const belongs = (id, kind) => id === kind + '-' + numPart || id.startsWith(kind + '-' + numPart + '.');
1000
+ const hspecs = specs.filter(s => belongs(s.id, 'H-SPEC'));
1001
+ const aspecs = specs.filter(s => belongs(s.id, 'A-SPEC'));
1002
+ const tspecs = specs.filter(s => belongs(s.id, 'T-SPEC'));
1003
+
1004
+ const matchingFiles = files.filter(f =>
1005
+ f.implementsSpecs.some(specId =>
1006
+ specId === req.id || belongs(specId, 'H-SPEC') || belongs(specId, 'A-SPEC') || belongs(specId, 'T-SPEC')
1007
+ )
1008
+ );
1009
+
1010
+ let totalSymbols = 0;
1011
+ let totalCallEdges = 0;
1012
+ matchingFiles.forEach(f => {
1013
+ totalSymbols += (f.symbols || []).length;
1014
+ totalCallEdges += (f.edges || []).filter(e => e.rel === 'calls').length;
1015
+ });
787
1016
 
788
- const totalDownstream = hspecs.length + aspecs.length + tspecs.length;
789
- const coveredDownstream = [...hspecs, ...aspecs, ...tspecs].filter(s => s.covered).length;
790
- const progressPct = totalDownstream > 0 ? Math.round((coveredDownstream / totalDownstream) * 100) : (req.covered ? 100 : 0);
1017
+ // Every stage must be backed by a real artifact. Counting only specs would call a REQ with
1018
+ // no code "6-Stage Complete", which is the claim this grid exists to verify.
1019
+ const stagePresent = [true, hspecs.length > 0, aspecs.length > 0, tspecs.length > 0, matchingFiles.length > 0, totalSymbols > 0];
1020
+ const stageNames = ['REQ', 'H-SPEC', 'A-SPEC', 'T-SPEC', 'File', 'AST Symbol'];
1021
+ const missingStages = stageNames.filter((_, i) => !stagePresent[i]);
1022
+ const stagesComplete = stagePresent.filter(Boolean).length;
1023
+ const healthBadge = missingStages.length === 0
1024
+ ? '<span class="neighbor-rel badge-covered" style="font-size:11px; font-weight:800;">🟢 Fully Traced (6/6 Stages)</span>'
1025
+ : '<span class="neighbor-rel badge-uncovered" style="font-size:11px; font-weight:800;" title="Missing: ' + escapeHtml(missingStages.join(', ')) + '">🟡 ' + stagesComplete + '/6 Stages · missing ' + escapeHtml(missingStages.join(', ')) + '</span>';
791
1026
 
792
1027
  return \`
793
1028
  <div class="req-card" id="card-\${escapeHtml(req.id)}">
@@ -795,15 +1030,18 @@ function renderDashboardHtml() {
795
1030
  <div class="req-title-group">
796
1031
  <span class="req-id-badge">\${escapeHtml(req.id)}</span>
797
1032
  <span class="req-title">\${escapeHtml(req.title || 'Business Requirement')}</span>
1033
+ \${healthBadge}
798
1034
  </div>
799
- <div style="display: flex; align-items: center; gap: 12px;">
800
- <div style="font-size: 11px; color: #94a3b8;">
801
- <div class="req-progress-bar">
802
- <div class="req-progress-fill" style="width: \${progressPct}%;"></div>
803
- </div>
804
- <strong>\${progressPct}% Covered</strong> (\${coveredDownstream}/\${totalDownstream})
1035
+
1036
+ <div style="display: flex; align-items: center; gap: 14px;">
1037
+ <div class="quant-grid">
1038
+ <span class="quant-badge quant-hspec" title="Functional Specs Count">🟣 H: \${hspecs.length}</span>
1039
+ <span class="quant-badge quant-aspec" title="Architecture Specs Count">🔵 A: \${aspecs.length}</span>
1040
+ <span class="quant-badge quant-tspec" title="Test Specs Count">🔷 T: \${tspecs.length}</span>
1041
+ <span class="quant-badge quant-file" title="Code Files Linked">📄 File: \${matchingFiles.length}</span>
1042
+ <span class="quant-badge quant-symbol" title="AST Symbols Mapped">⚡ AST: \${totalSymbols}</span>
805
1043
  </div>
806
- <button class="expand-btn">▶ Expand Downstream Tree</button>
1044
+ <button class="expand-btn">▶ Expand Matrix Tree</button>
807
1045
  <button class="view-graph-btn" onclick="event.stopPropagation(); focusSpecInGraph('\${escapeHtml(req.id)}')">🕸️ View in Graph</button>
808
1046
  </div>
809
1047
  </div>
@@ -838,6 +1076,29 @@ function renderDashboardHtml() {
838
1076
  </div>
839
1077
  </div>
840
1078
  \`).join('')}
1079
+
1080
+ \${matchingFiles.map(f => {
1081
+ const fId = f.sourcePath || f.path;
1082
+ const symList = f.symbols || [];
1083
+ return \`
1084
+ <div class="tree-row tree-row-file">
1085
+ <div class="tree-label">📄 <strong>\${escapeHtml(fId)}</strong> (\${symList.length} AST Symbols)</div>
1086
+ <div>
1087
+ <span class="neighbor-rel badge-covered" style="margin-right:8px;">LINKED</span>
1088
+ <button class="jump-btn" onclick="focusSpecInGraph('\${escapeHtml(fId)}')">🕸️ View File</button>
1089
+ </div>
1090
+ </div>
1091
+ \${symList.map(sym => \`
1092
+ <div class="tree-row tree-row-symbol">
1093
+ <div class="tree-label">⚡ <strong>\${escapeHtml(sym.name)}</strong> (Line \${sym.startLine || 1})</div>
1094
+ <div>
1095
+ <span class="neighbor-rel quant-symbol" style="margin-right:8px;">AST SYMBOL</span>
1096
+ <button class="jump-btn" onclick="focusSpecInGraph('\${escapeHtml(fId + '#' + sym.name)}')">⚡ View Symbol</button>
1097
+ </div>
1098
+ </div>
1099
+ \`).join('')}
1100
+ \`;
1101
+ }).join('')}
841
1102
  </div>
842
1103
  </div>
843
1104
  \`;
@@ -862,7 +1123,7 @@ function renderDashboardHtml() {
862
1123
  document.getElementById('searchInput').addEventListener('input', (e) => {
863
1124
  const q = e.target.value.toLowerCase();
864
1125
  const filtered = allSpecs.filter(s => s.id.toLowerCase().includes(q) || (s.title && s.title.toLowerCase().includes(q)));
865
- renderReqAccordionMatrix(filtered);
1126
+ renderReqQuantitativeGrid(filtered, allFiles);
866
1127
  });
867
1128
 
868
1129
  async function loadGraphCanvas() {
@@ -909,6 +1170,22 @@ function renderDashboardHtml() {
909
1170
  });
910
1171
  }
911
1172
 
1173
+ function renderGraphScopeNotice(shownNodes, shownEdges, scope, cap) {
1174
+ const el = document.getElementById('graphScopeNotice');
1175
+ if (!el || !graphData) return;
1176
+ const totalNodes = (graphData.nodes || []).length;
1177
+ const totalEdges = (graphData.edges || []).length;
1178
+ const truncated = shownNodes < totalNodes;
1179
+ const pct = totalNodes > 0 ? ((shownNodes / totalNodes) * 100).toFixed(1) : '0.0';
1180
+ const how = scope === 'full'
1181
+ ? 'first ' + cap + ' nodes of the collection'
1182
+ : 'bounded BFS from the selected root, capped at ' + cap + ' nodes';
1183
+ el.className = truncated ? 'scope-notice scope-notice-truncated' : 'scope-notice';
1184
+ el.innerHTML = truncated
1185
+ ? '<strong>Showing ' + shownNodes + ' of ' + totalNodes + ' nodes</strong> (' + pct + '%) and ' + shownEdges + ' of ' + totalEdges + ' edges — ' + how + '. Raise <em>Max Node Cap</em> to widen the view.'
1186
+ : '<strong>Showing all ' + totalNodes + ' nodes</strong> and ' + shownEdges + ' of ' + totalEdges + ' edges.';
1187
+ }
1188
+
912
1189
  function filterGraphByScope() {
913
1190
  if (!graphData) return;
914
1191
  const scope = document.getElementById('scopeFilter').value;
@@ -959,6 +1236,10 @@ function renderDashboardHtml() {
959
1236
  filteredEdges = graphData.edges.filter(e => bSet.has(getIdStr(e.from)) && bSet.has(getIdStr(e.to)));
960
1237
  }
961
1238
 
1239
+ // The canvas draws a bounded subgraph, never the whole collection. Say so with numbers —
1240
+ // an unlabelled 30-node view of a 3543-node graph reads as "this is the system".
1241
+ renderGraphScopeNotice(filteredNodes.length, filteredEdges.length, scope, maxNodeCap);
1242
+
962
1243
  const engine = document.getElementById('layoutEngine').value;
963
1244
  if (engine === 'neo4j') {
964
1245
  renderNeo4jBloomDomainGalaxy(filteredNodes, filteredEdges);
@@ -1091,7 +1372,9 @@ function renderDashboardHtml() {
1091
1372
  .on('click', (event, d) => {
1092
1373
  event.stopPropagation();
1093
1374
  selectAndTraverseNode(getIdStr(d.target));
1094
- });
1375
+ })
1376
+ .on('mouseenter', (event, d) => showEdgePopover(event, d))
1377
+ .on('mouseleave', hidePopover);
1095
1378
 
1096
1379
  const nodeGroups = svgG.append('g')
1097
1380
  .selectAll('g')
@@ -1139,7 +1422,7 @@ function renderDashboardHtml() {
1139
1422
 
1140
1423
  nodeGroups
1141
1424
  .on('click', (event, d) => selectAndTraverseNode(d.id))
1142
- .on('mouseenter', (event, d) => showPopover(event, d, validEdges))
1425
+ .on('mouseenter', (event, d) => showNodePopover(event, d, validEdges))
1143
1426
  .on('mouseleave', hidePopover);
1144
1427
 
1145
1428
  simulation.on('tick', () => {
@@ -1264,6 +1547,8 @@ function renderDashboardHtml() {
1264
1547
  evt.stopPropagation();
1265
1548
  selectAndTraverseNode(d.to);
1266
1549
  });
1550
+ pathEl.addEventListener('mouseenter', (evt) => showEdgePopover(evt, d));
1551
+ pathEl.addEventListener('mouseleave', hidePopover);
1267
1552
 
1268
1553
  edgeGroup.node().appendChild(pathEl);
1269
1554
  });
@@ -1301,7 +1586,7 @@ function renderDashboardHtml() {
1301
1586
 
1302
1587
  nodeGroups
1303
1588
  .on('click', (event, d) => selectAndTraverseNode(d.id))
1304
- .on('mouseenter', (event, d) => showPopover(event, d, validEdges))
1589
+ .on('mouseenter', (event, d) => showNodePopover(event, d, validEdges))
1305
1590
  .on('mouseleave', hidePopover);
1306
1591
  }
1307
1592
 
@@ -1416,6 +1701,8 @@ function renderDashboardHtml() {
1416
1701
  evt.stopPropagation();
1417
1702
  selectAndTraverseNode(d.to);
1418
1703
  });
1704
+ pathEl.addEventListener('mouseenter', (evt) => showEdgePopover(evt, d));
1705
+ pathEl.addEventListener('mouseleave', hidePopover);
1419
1706
 
1420
1707
  edgeGroup.node().appendChild(pathEl);
1421
1708
  });
@@ -1451,7 +1738,7 @@ function renderDashboardHtml() {
1451
1738
 
1452
1739
  nodeGroups
1453
1740
  .on('click', (event, d) => selectAndTraverseNode(d.id))
1454
- .on('mouseenter', (event, d) => showPopover(event, d, validEdges))
1741
+ .on('mouseenter', (event, d) => showNodePopover(event, d, validEdges))
1455
1742
  .on('mouseleave', hidePopover);
1456
1743
  }
1457
1744
 
@@ -1566,6 +1853,8 @@ function renderDashboardHtml() {
1566
1853
  evt.stopPropagation();
1567
1854
  selectAndTraverseNode(d.to);
1568
1855
  });
1856
+ pathEl.addEventListener('mouseenter', (evt) => showEdgePopover(evt, d));
1857
+ pathEl.addEventListener('mouseleave', hidePopover);
1569
1858
 
1570
1859
  edgeGroup.node().appendChild(pathEl);
1571
1860
  });
@@ -1600,7 +1889,7 @@ function renderDashboardHtml() {
1600
1889
 
1601
1890
  nodeGroups
1602
1891
  .on('click', (event, d) => selectAndTraverseNode(d.id))
1603
- .on('mouseenter', (event, d) => showPopover(event, d, validEdges))
1892
+ .on('mouseenter', (event, d) => showNodePopover(event, d, validEdges))
1604
1893
  .on('mouseleave', hidePopover);
1605
1894
  }
1606
1895
 
@@ -1735,15 +2024,86 @@ function renderDashboardHtml() {
1735
2024
  container.innerHTML = html;
1736
2025
  }
1737
2026
 
1738
- function showPopover(event, node, edges) {
2027
+ // World Top-Tier High-Density Rich Node Tooltip Hover Popover Card
2028
+ function showNodePopover(event, node, edges) {
1739
2029
  const card = document.getElementById('popoverCard');
2030
+ const badge = document.getElementById('popoverBadge');
2031
+ const title = document.getElementById('popoverTitle');
2032
+ const sec1 = document.getElementById('popoverSection1');
2033
+ const sec2 = document.getElementById('popoverSection2');
2034
+
1740
2035
  const nodeIdStr = getIdStr(node.id || node);
1741
- document.getElementById('popoverTitle').innerText = nodeIdStr;
1742
- document.getElementById('popoverType').innerText = 'Type: ' + (node.kind || node.type);
1743
- document.getElementById('popoverStatus').innerText = 'Status: ' + (node.covered ? 'COVERED' : 'ACTIVE / UNCOVERED');
1744
-
1745
- const degrees = edges.filter(e => getIdStr(e.from || e.source) === nodeIdStr || getIdStr(e.to || e.target) === nodeIdStr).length;
1746
- document.getElementById('popoverStats').innerText = 'Connected Edges: ' + degrees + ' relationships';
2036
+ const cat = getNodeCategory(nodeIdStr);
2037
+
2038
+ let badgeBg = '#3b82f6';
2039
+ let badgeLabel = 'BLUEPRINT SPEC';
2040
+ if (cat === 'REQ') { badgeBg = '#ec4899'; badgeLabel = '🟣 REQ BUSINESS INTENT'; }
2041
+ else if (cat === 'H-SPEC') { badgeBg = '#8b5cf6'; badgeLabel = '🟣 H-SPEC FUNCTIONAL'; }
2042
+ else if (cat === 'A-SPEC') { badgeBg = '#3b82f6'; badgeLabel = '🔵 A-SPEC ARCHITECTURE'; }
2043
+ else if (cat === 'T-SPEC') { badgeBg = '#06b6d4'; badgeLabel = '🔷 T-SPEC VERIFICATION'; }
2044
+ else if (cat === 'SYMBOL') { badgeBg = '#f59e0b'; badgeLabel = '⚡ AST CPG SYMBOL'; }
2045
+ else { badgeBg = '#10b981'; badgeLabel = '📄 SOURCE CODE FILE'; }
2046
+
2047
+ badge.style.background = badgeBg;
2048
+ badge.innerText = badgeLabel;
2049
+
2050
+ title.innerText = nodeIdStr;
2051
+
2052
+ const inEdges = edges.filter(e => getIdStr(e.to || e.target) === nodeIdStr);
2053
+ const outEdges = edges.filter(e => getIdStr(e.from || e.source) === nodeIdStr);
2054
+ const totalDegree = inEdges.length + outEdges.length;
2055
+
2056
+ sec1.innerHTML = \`
2057
+ <div><span class="popover-label">Full Label / Title:</span> <span class="popover-value">\${escapeHtml(node.label || nodeIdStr)}</span></div>
2058
+ <div><span class="popover-label">Status & Audit:</span> <span class="popover-value" style="font-weight:bold; color:\${node.covered ? '#34d399' : '#f87171'}">\${node.covered ? 'APPROVED / COVERED' : 'ACTIVE'}</span></div>
2059
+ \`;
2060
+
2061
+ sec2.innerHTML = \`
2062
+ <div><span class="popover-label">Degree Centrality Rank:</span> <span class="popover-value" style="font-weight:bold; color:#fbbf24;">\${totalDegree} Relationships</span></div>
2063
+ <div><span class="popover-label">Inbound Parents (In-Degree):</span> <span class="popover-value">\${inEdges.length} edges</span></div>
2064
+ <div><span class="popover-label">Outbound Children (Out-Degree):</span> <span class="popover-value">\${outEdges.length} edges</span></div>
2065
+ \${cat === 'SYMBOL' ? '<div><span class="popover-label">Symbol Kind:</span> <span class="popover-value" style="color:#f59e0b;">Function / Method AST Node</span></div>' : ''}
2066
+ \${cat === 'FILE' ? '<div><span class="popover-label">File Path:</span> <span class="popover-value" style="color:#10b981;">' + escapeHtml(nodeIdStr) + '</span></div>' : ''}
2067
+ \`;
2068
+
2069
+ card.classList.add('active');
2070
+ }
2071
+
2072
+ // World Top-Tier High-Density Rich Edge Tooltip Hover Popover Card
2073
+ function showEdgePopover(event, edge) {
2074
+ const card = document.getElementById('popoverCard');
2075
+ const badge = document.getElementById('popoverBadge');
2076
+ const title = document.getElementById('popoverTitle');
2077
+ const sec1 = document.getElementById('popoverSection1');
2078
+ const sec2 = document.getElementById('popoverSection2');
2079
+
2080
+ const src = getIdStr(edge.from || edge.source);
2081
+ const dst = getIdStr(edge.to || edge.target);
2082
+ const rel = edge.rel || 'edge';
2083
+
2084
+ let badgeBg = '#3b82f6';
2085
+ let relExplanation = '';
2086
+ if (rel === 'implements') { badgeBg = '#3b82f6'; relExplanation = 'Code/File implements Architecture Specification'; }
2087
+ else if (rel === 'verifies') { badgeBg = '#06b6d4'; relExplanation = 'Test Spec verifies Code/Architecture Specification'; }
2088
+ else if (rel === 'depends_on') { badgeBg = '#8b5cf6'; relExplanation = 'Specification depends on parent Specification'; }
2089
+ else if (rel === 'contains') { badgeBg = '#10b981'; relExplanation = 'Source Code File contains AST Function Symbol'; }
2090
+ else if (rel === 'calls') { badgeBg = '#f59e0b'; relExplanation = 'AST Symbol calls destination Function Symbol (CPG Dataflow)'; }
2091
+
2092
+ badge.style.background = badgeBg;
2093
+ badge.innerText = '➔ EDGE RELATION: ' + rel.toUpperCase();
2094
+
2095
+ title.innerText = src + ' ➔ ' + dst;
2096
+
2097
+ sec1.innerHTML = \`
2098
+ <div><span class="popover-label">Edge Relation Type:</span> <span class="popover-value" style="font-weight:bold; color:#fbbf24;">\${rel}</span></div>
2099
+ <div><span class="popover-label">Causal Explanation:</span> <span class="popover-value" style="color:#38bdf8;">\${relExplanation}</span></div>
2100
+ \`;
2101
+
2102
+ sec2.innerHTML = \`
2103
+ <div><span class="popover-label">Source Node (From):</span> <span class="popover-value">\${escapeHtml(src)}</span></div>
2104
+ <div><span class="popover-label">Target Node (To):</span> <span class="popover-value">\${escapeHtml(dst)}</span></div>
2105
+ \`;
2106
+
1747
2107
  card.classList.add('active');
1748
2108
  }
1749
2109
 
@@ -1756,3 +2116,163 @@ function renderDashboardHtml() {
1756
2116
  </body>
1757
2117
  </html>`;
1758
2118
  }
2119
+ /**
2120
+ * The canonical status vocabulary. A document outside it (`Approved`, `Proposed`, `Verified`,
2121
+ * `Completed`, `Pending` — the C-SPEC/JOB corpus) has not been scored against these gates at all,
2122
+ * and scoring it 0 would report an unmapped vocabulary as an uncovered spec.
2123
+ */
2124
+ const CANONICAL_STATUSES = new Set(['draft', 'review', 'approved', 'outdated']);
2125
+ function isCanonicalStatus(status) {
2126
+ return typeof status === 'string' && CANONICAL_STATUSES.has(status);
2127
+ }
2128
+ /**
2129
+ * The kinds that are governed specs at all.
2130
+ *
2131
+ * @implements A-SPEC-219.1
2132
+ * Held here rather than imported from SPEC_TYPES because REQ is a spec kind that SPEC_TYPES does
2133
+ * list — the distinction this set draws is "document in the spec store that the pipeline governs"
2134
+ * versus "document that lives there but never will", and JOB is the whole of the second group.
2135
+ */
2136
+ exports.SPEC_KINDS = new Set(['REQ', 'H-SPEC', 'A-SPEC', 'C-SPEC', 'T-SPEC']);
2137
+ /**
2138
+ * Drop retired documents before anything reads the corpus.
2139
+ *
2140
+ * @implements A-SPEC-222.2
2141
+ * A-SPEC-222.1 excluded `outdated` from `isGoverned` and claimed every other check would follow.
2142
+ * `tspec-mirror.test.ts` refuted that, and the spec was corrected to name one known site. Measured
2143
+ * 2026-08-22 it was three: /api/rtm exposed 132 retired ids, the heatmap 108, the graph 132 — a
2144
+ * screen still pointing at documents retired the day before. Stated once here so the three sites
2145
+ * cannot drift, with the suite enumerating the endpoints so a fourth cannot be added silently.
2146
+ */
2147
+ function activeSpecs(specs) {
2148
+ return specs.filter((s) => s.status !== 'outdated');
2149
+ }
2150
+ /** `A-SPEC-219.1` -> `A-SPEC`, `REQ-021` -> `REQ`. */
2151
+ function specKindOf(id) {
2152
+ const m = /^([A-Za-z-]+?)-\d/.exec(String(id ?? ''));
2153
+ return m ? m[1] : 'UNKNOWN';
2154
+ }
2155
+ const STAGE_NAMES = ['REQ', 'H-SPEC', 'A-SPEC', 'T-SPEC', 'File', 'AST Symbol'];
2156
+ /**
2157
+ * Match a spec id against a REQ's number exactly, so `REQ-021` cannot claim `A-SPEC-0219`.
2158
+ * Accepts the plain number and dotted sub-specs (`A-SPEC-219.1`).
2159
+ */
2160
+ function specBelongsToReq(specId, kind, numPart) {
2161
+ return specId === `${kind}-${numPart}` || specId.startsWith(`${kind}-${numPart}.`);
2162
+ }
2163
+ /**
2164
+ * Attribute a finding to a REQ only on an EXACT spec reference. `specRef.includes(numPart)` let
2165
+ * REQ-021 claim a finding filed against A-SPEC-0219; substring containment is not evidence.
2166
+ */
2167
+ function findingBelongsToReq(finding, reqId, numPart) {
2168
+ const ref = typeof finding?.specRef === 'string' ? finding.specRef : null;
2169
+ if (!ref)
2170
+ return false;
2171
+ return (ref === reqId ||
2172
+ specBelongsToReq(ref, 'H-SPEC', numPart) ||
2173
+ specBelongsToReq(ref, 'A-SPEC', numPart) ||
2174
+ specBelongsToReq(ref, 'T-SPEC', numPart));
2175
+ }
2176
+ function fileBelongsToReq(implementsSpecs, reqId, numPart) {
2177
+ return implementsSpecs.some((specId) => specId === reqId ||
2178
+ specBelongsToReq(specId, 'H-SPEC', numPart) ||
2179
+ specBelongsToReq(specId, 'A-SPEC', numPart) ||
2180
+ specBelongsToReq(specId, 'T-SPEC', numPart));
2181
+ }
2182
+ function makeRow(base) {
2183
+ const stages = [
2184
+ base.reqId,
2185
+ base.hspecId ?? null,
2186
+ base.aspecId ?? null,
2187
+ base.tspecId ?? null,
2188
+ base.fileId ?? null,
2189
+ base.symbolId ?? null,
2190
+ ];
2191
+ const missingStages = STAGE_NAMES.filter((_, i) => !stages[i]);
2192
+ const stagesComplete = stages.filter(Boolean).length;
2193
+ return {
2194
+ reqId: base.reqId,
2195
+ reqTitle: base.reqTitle,
2196
+ hspecId: base.hspecId ?? null,
2197
+ aspecId: base.aspecId ?? null,
2198
+ tspecId: base.tspecId ?? null,
2199
+ fileId: base.fileId ?? null,
2200
+ symbolId: base.symbolId ?? null,
2201
+ symbolLine: base.symbolLine ?? null,
2202
+ stagesComplete,
2203
+ missingStages: [...missingStages],
2204
+ status: stagesComplete === STAGE_NAMES.length ? 'COVERED' : 'UNCOVERED',
2205
+ findingsCount: base.findingsCount ?? 0,
2206
+ criticalCount: base.criticalCount ?? 0,
2207
+ findingsScanned: base.findingsScanned ?? false,
2208
+ mutantCount: base.mutantCount ?? 0,
2209
+ mutationScore: null,
2210
+ mutationScoreMeasured: false,
2211
+ };
2212
+ }
2213
+ /**
2214
+ * Build the 6-stage pipeline matrix from the repository's real specs, scanned files and findings.
2215
+ *
2216
+ * Pure and exported so the truthfulness invariants can be asserted directly, without an HTTP round
2217
+ * trip: no row may name an artifact that the inputs do not contain, and no symbol may be dropped.
2218
+ *
2219
+ * @implements A-SPEC-219
2220
+ */
2221
+ function buildPipelineRows(specs, files, allFindings, options) {
2222
+ const findingsScanned = options?.findingsScanned === true;
2223
+ const safeSpecs = Array.isArray(specs) ? specs : [];
2224
+ const safeFiles = Array.isArray(files) ? files : [];
2225
+ const safeFindings = Array.isArray(allFindings) ? allFindings : [];
2226
+ const rows = [];
2227
+ for (const reqItem of safeSpecs.filter((s) => typeof s?.id === 'string' && s.id.startsWith('REQ-'))) {
2228
+ const numPart = reqItem.id.replace('REQ-', '').split('.')[0];
2229
+ const pick = (kind) => safeSpecs.find((s) => specBelongsToReq(String(s?.id ?? ''), kind, numPart))?.id ?? null;
2230
+ const hspecId = pick('H-SPEC');
2231
+ const aspecId = pick('A-SPEC');
2232
+ const tspecId = pick('T-SPEC');
2233
+ const matchingFiles = safeFiles.filter((f) => fileBelongsToReq(f?.implementsSpecs ?? [], reqItem.id, numPart));
2234
+ const specFindings = safeFindings.filter((f) => f?.status === 'open' && findingBelongsToReq(f, reqItem.id, numPart));
2235
+ const common = {
2236
+ reqId: reqItem.id,
2237
+ reqTitle: reqItem.title,
2238
+ hspecId,
2239
+ aspecId,
2240
+ tspecId,
2241
+ findingsScanned,
2242
+ };
2243
+ if (matchingFiles.length === 0) {
2244
+ // No implementing file: stages 5 and 6 stay empty. Nothing is substituted for them.
2245
+ rows.push(makeRow({
2246
+ ...common,
2247
+ findingsCount: specFindings.length,
2248
+ criticalCount: specFindings.filter((f) => f.severity === 'critical').length,
2249
+ }));
2250
+ continue;
2251
+ }
2252
+ for (const f of matchingFiles) {
2253
+ const fileId = f.sourcePath || f.path;
2254
+ const syms = f.symbols || [];
2255
+ const fileMutants = (0, ast_mutation_1.generateAstMutants)([f]);
2256
+ const fileFindings = safeFindings.filter((fd) => fd?.status === 'open' && (fd?.file === fileId || findingBelongsToReq(fd, reqItem.id, numPart)));
2257
+ const findingsCount = fileFindings.length;
2258
+ const criticalCount = fileFindings.filter((fd) => fd.severity === 'critical').length;
2259
+ if (syms.length === 0) {
2260
+ rows.push(makeRow({ ...common, fileId, findingsCount, criticalCount, mutantCount: fileMutants.length }));
2261
+ continue;
2262
+ }
2263
+ // Every symbol gets a row — no truncation, so the matrix count and the drill-down agree.
2264
+ for (const sym of syms) {
2265
+ rows.push(makeRow({
2266
+ ...common,
2267
+ fileId,
2268
+ symbolId: sym.name,
2269
+ symbolLine: sym.startLine ?? null,
2270
+ findingsCount,
2271
+ criticalCount,
2272
+ mutantCount: fileMutants.filter((m) => m.symbolName === sym.name).length,
2273
+ }));
2274
+ }
2275
+ }
2276
+ }
2277
+ return rows;
2278
+ }