@holmes-lab/holmes-kit 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,68 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  <!-- @implements A-SPEC-209 -->
8
+ ## [0.12.1] - 2026-09-05
9
+
10
+ Finishes the RTM dashboard's CFG/PDG story and fixes fresh-project spec numbering. Additive and
11
+ backward-compatible.
12
+
13
+ ### Added
14
+
15
+ - **CFG/PDG graph-view drilldown** (REQ-545): drilling into a symbol in the graph tab now renders that
16
+ function's **control-flow graph as a layered DAG** with **program-dependence overlays** — control
17
+ flow, data dependence, and control dependence drawn as three distinct edge styles, unreachable
18
+ blocks dimmed, with a legend. `/api/cfg` gains a server-computed `layout` (deterministic
19
+ `layoutCfg`: BFS layers from entry, cycle-safe, unreachable blocks trailing) so the client only
20
+ paints. This completes REQ-545 (2D matrix · auto-launch · CFG endpoint · CFG render).
21
+
22
+ ### Fixed
23
+
24
+ - **Fresh-project spec numbering starts at REQ-100, not REQ-201** (REQ-546): `spec_slice_init` hard-coded
25
+ a `maxId = 200` floor, so a brand-new project's first slice leapt to 201. It now shares the same id
26
+ allocator as the reverse-draft path (`nextIdBase`, floor 100). **Backward-compatible**: the next id
27
+ is always `max(existing)+1`, so a repo already numbering from 201 keeps the identical sequence and
28
+ no existing id is renamed; the change only lowers the *first* number in an empty (or sub-100-max)
29
+ project. Numbering past 999 yields 4-digit ids with no collision.
30
+ - **ADR references recognise four-digit ids** (REQ-546): the citation matcher was `\bADR-\d{3}\b`
31
+ (exactly three digits), so `ADR-1000+` was invisible. Extracted to a pure `adrRefsIn` and widened to
32
+ `\d{3,}` — three-digit matching and ordering are preserved, four-digit ADRs are now cited.
33
+
34
+ ## [0.12.0] - 2026-09-05
35
+
36
+ World-top-tier RTM dashboard, in flight. The `holmes-kit serve` dashboard stops being a table of
37
+ accordions and becomes a real coverage matrix, gains a one-call launcher, and learns to serve CFG/PDG
38
+ for a function. Additive and backward-compatible — every existing `/api/*` payload keeps its shape and
39
+ values; these are new fields, a new endpoint, a new tool, and a rebuilt heatmap view.
40
+
41
+ ### Added
42
+
43
+ - **2D coverage matrix** (REQ-545): the heatmap tab is now a single **requirement × pipeline-stage
44
+ grid** — rows are requirements ordered by completeness (seriation), each cell a sequential-ramp
45
+ colour with the coverage percent printed in it (so magnitude never rides on colour alone). Replaces
46
+ the per-REQ accordion-of-tables. `/api/rtm/heatmap` gains an additive `matrix` field
47
+ (`{reqs, stages, values, order, buckets}`) built by pure, unit-tested `buildStageMatrix` +
48
+ `seriateRows` + `coverageBucket`.
49
+ - **`rtm_dashboard` MCP tool** (REQ-545): ask to *see* the RTM heatmap / coverage matrix and the
50
+ server launches (idempotently — a second call reuses the live one) and returns `{ url, running,
51
+ census }`, where `census` is an honesty summary (`reqCount, pipelineCount, coveredCount,
52
+ coveragePct, retiredCount, findingsScanned`). No more starting `serve` by hand.
53
+ - **`/api/cfg` — control-flow + program-dependence for one function** (REQ-545): `?file=&symbol=`
54
+ returns that function's CFG (blocks with source line/label, control-flow edges) and PDG (block-level
55
+ control deps + reaching-def data deps), consumed from the same `cfgOf`/`pdgOf` the taint lane uses.
56
+ A non-CFG language is named as `unsupported`, never faked as an empty graph. (Graph-view rendering
57
+ of this lands next.)
58
+
59
+ ### Changed
60
+
61
+ - **Dashboard design tokens** (REQ-545): the palette moved off the Tailwind-default + emoji-marker
62
+ look to a tokenised system — a single-hue sequential coverage ramp, reserved status colours, a
63
+ UI/mono type pairing (data in tabular-nums), surfaces and ink, with a light and a dark palette via
64
+ `prefers-color-scheme`. Applied to the new matrix view and the tab bar; structure emoji removed
65
+ (meaning markers only). The rest of the dashboard tokenises incrementally in the follow-on slice.
66
+ - **Upgrade nudges point at one command** (REQ-544): the session banner's drift line and doctor's
67
+ version-drift fix hint now name `holmes-kit upgrade` (the right binary per platform — `npx.cmd` on
68
+ win32) instead of spelling out a manual `npm i` + `init --force`.
69
+
8
70
  ## [0.11.0] - 2026-09-05
9
71
 
10
72
  Zero-config upgrades — preparation is automatic, execution is your explicit choice. Additive and
package/README.md CHANGED
@@ -20,6 +20,8 @@
20
20
  - 🔴 **Inbuilt TDD — RED-first, enforced not asked** *(new in 0.9.0)*: the test-first discipline is a holmes-installed `holmes-tdd-slice` skill **and** a new constitution article **ART-8**. A changed A-SPEC must show a recorded `red-assertion → green` sequence in the ledger; a `red-error` (a test that could not run) is not a valid RED, so "the covering test failed *correctly*" is judged mechanically, not on trust. `test_run` classifies each covered file (`red-assertion`/`red-error`/`green`) and records per-A-SPEC outcomes the Stop hook reads. Ships at `redFirstEvidence: track` (observe-first, non-blocking; `strict`/`off` per repo), evidence-gated and jest-only for now. A T-SPEC may also declare `kills:` mutations and `test_run --mutate` reports which SURVIVED (a coverage gap). Where superpowers *asks* for RED-first and discriminating power, holmes-kit *proves* them.
21
21
  - 🧰 **Governance UX tools** *(new in 0.10.0)*: `spec_unseal` (the inverse of `spec_approve` — return a sealed spec to editable `draft` in one act, out-of-band approval required, refuses approved dependents), `approval_status` and `ledger_timeline` (read-only observability into a spec's seal state and the governance history), and a structured `conflict` on `spec_approve`'s optimistic-concurrency refusal (read vs. current version + retry). See CHANGELOG for details.
22
22
  - ⬆️ **Zero-config upgrades** *(new in 0.11.0)*: `holmes-kit upgrade` moves **every** wired workspace to the latest in one command — plan → confirm → install → re-pin all recorded workspaces (`--dry-run`/`--yes` supported). Preparation is automatic (each `init` records the workspace; a session whose pin is behind nudges you to upgrade); the re-pin **write** stays your explicit choice, never a silent auto-install. Opt out of the nudge with `HOLMES_NO_AUTO_REPIN`.
23
+ - 📊 **World-top-tier RTM dashboard** *(new in 0.12.0–0.12.1)*: `holmes-kit serve` — ask to *see* the RTM heatmap and the `rtm_dashboard` MCP tool launches the server idempotently and hands back the URL plus an honesty **census** (requirement/pipeline counts, coverage %, what's excluded). The heatmap is a real **2D coverage matrix** (requirements × pipeline stages, rows seriated by completeness, sequential-ramp cells with the percent printed in each). Drilling into a symbol renders that function's **CFG as a layered DAG** with **PDG (data/control-dependence) colour overlays**, served by `/api/cfg?file=&symbol=` from the same engine the taint lane uses — a non-CFG language is named, never faked. Tokenised palette (sequential ramp, status colours, UI/mono pairing) with light/dark.
24
+ - 🔢 **Sensible spec numbering** *(new in 0.12.1)*: a brand-new project's first slice is now **REQ-100**, not REQ-201 — `spec_slice_init` shares the same id allocator as the reverse-draft path (`nextIdBase`, floor 100). Existing projects are untouched: the next id is always `max(existing)+1`, so a repo already numbering from 201 keeps the exact same sequence. Numbering past 999 yields 4-digit ids cleanly, and ADR references now recognise 4-digit ADRs (`ADR-1000+`).
23
25
  - 🤖 **Autonomous Approval** *(new in 0.8.0)*: with the out-of-band `HOLMES_AUTONOMOUS_APPROVAL` switch on, an agent seals **low/mid-risk** specs itself (ledgered under an `autonomous:<client>` actor) — while `gate-behavior` changes, architecture/gate/taint files, and every upstream `REQ`/`H-SPEC`/`C-SPEC` still ask a human through the in-session TUI. The switch is env-only; a session cannot set it (blocked like `HOLMES_ROLE`). Off = byte-identical to before. *(new in 0.10.0)* Set **`HOLMES_ELICIT=off`** to skip the in-session prompt entirely and route every decision to the out-of-band `holmes-kit approve` queue instead — one decision surface that behaves identically across Claude Code / Antigravity / Codex.
24
26
  - 🪧 **Session Banner + Update Notice** *(new in 0.8.0)*: every session start emits an English intro (version + governance rule + npm URL) to both the human transcript and the agent context (SessionStart hook + MCP `instructions`); when a newer published version is cached, an install-mode-aware update command is appended. Registry check is detached, fail-silent, and opts out via `HOLMES_NO_UPDATE_CHECK`/`CI`.
25
27
  - 🧱 **Deterministic Gate, Hardened** *(new in 0.8.0)*: shell writes are judged at the segment's **effective working directory** (`cd sub && cat > ../src/x.ts` is sealed, legitimate out-of-tree scratch writes are freed); the governing anchor is the **whole set**, not the first match. Every gate change ships with two consecutive clean adversarial rounds.
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 2fb1cad-mtnmh3yo
1
+ 3b23a9b-mtnx2lfo
@@ -757,7 +757,9 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
757
757
  const launch = (0, mcp_version_1.mcpLaunchVersion)({ command: server.command, args: server.args }, readPkgVersionAt);
758
758
  const verdict = (0, mcp_version_1.versionDriftVerdict)(launch, cli);
759
759
  if (verdict === 'drift') {
760
- add('mcp server version', 'WARN', `이 프로젝트의 MCP 서버는 holmes-kit ${launch} 를 띄우지만 이 CLI 는 ${cli} 입니다 — 재배선 전까지 옛 서버가 계속 뜹니다`, `${(0, npx_bin_1.npxBin)()} @holmes-lab/holmes-kit@latest init 을 다시 실행해 .mcp.json 을 최신 버전으로 재배선하십시오 (전역 -g 업그레이드 불필요).`);
760
+ add('mcp server version', 'WARN', `이 프로젝트의 MCP 서버는 holmes-kit ${launch} 를 띄우지만 이 CLI 는 ${cli} 입니다 — 재배선 전까지 옛 서버가 계속 뜹니다`,
761
+ // @implements A-SPEC-544.1 — one command installs + re-pins every workspace; init --force is the per-workspace path.
762
+ `${(0, npx_bin_1.npxBin)()} holmes-kit upgrade 로 최신 설치 + 전 워크스페이스 재핀 (이 워크스페이스만 고치려면 ${(0, npx_bin_1.npxBin)()} holmes-kit init --force).`);
761
763
  }
762
764
  else if (verdict === 'match') {
763
765
  add('mcp server version', 'PASS', `MCP 서버 버전 ${launch} 가 이 CLI 와 일치합니다`);
@@ -237,6 +237,22 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
237
237
  events: import("../governance/ledger-timeline").TimelineEntry[];
238
238
  reason?: undefined;
239
239
  }>;
240
+ rtm_dashboard(a: {
241
+ root?: string;
242
+ port?: number;
243
+ }): Promise<{
244
+ ok: boolean;
245
+ reason: string;
246
+ url?: undefined;
247
+ running?: undefined;
248
+ census?: undefined;
249
+ } | {
250
+ ok: boolean;
251
+ url: string;
252
+ running: boolean;
253
+ census: import("../server/dashboard-launcher").DashboardCensus;
254
+ reason?: undefined;
255
+ }>;
240
256
  spec_approve(a: {
241
257
  root?: string;
242
258
  id: string;
@@ -45,6 +45,7 @@ exports.makeHandlers = makeHandlers;
45
45
  // @implements A-SPEC-269
46
46
  // @implements A-SPEC-267
47
47
  const fs = __importStar(require("node:fs"));
48
+ const http = __importStar(require("node:http"));
48
49
  const assoc_arm_1 = require("../assoc/assoc-arm");
49
50
  // @implements A-SPEC-478 — the semantic tier runtime, resolved HERE (the async edge) and injected
50
51
  // into the sync analysis; the replay harness injects nothing, keeping every pin untouched.
@@ -213,6 +214,21 @@ class HandlerRefusal extends Error {
213
214
  constructor(message) { super(message); this.name = 'HandlerRefusal'; }
214
215
  }
215
216
  exports.HandlerRefusal = HandlerRefusal;
217
+ /** GET a localhost JSON endpoint (used by rtm_dashboard to read the census off the server it launched). */
218
+ function fetchJson(url) {
219
+ return new Promise((resolve, reject) => {
220
+ http.get(url, (res) => {
221
+ let data = '';
222
+ res.on('data', (c) => (data += c));
223
+ res.on('end', () => { try {
224
+ resolve(JSON.parse(data));
225
+ }
226
+ catch (e) {
227
+ reject(e);
228
+ } });
229
+ }).on('error', reject);
230
+ });
231
+ }
216
232
  /** Marker test that survives realm boundaries (jest's instanceof does not). */
217
233
  function isHandlerRefusal(e) {
218
234
  return !!e && typeof e === 'object' && e.holmesRefusal === true;
@@ -225,6 +241,7 @@ const ignore_1 = require("../project/ignore");
225
241
  // @implements A-SPEC-126
226
242
  const scan_1 = require("../reverse/scan");
227
243
  const draft_1 = require("../reverse/draft");
244
+ const adr_refs_1 = require("../spec/adr-refs");
228
245
  const spec_id_guard_1 = require("./spec-id-guard");
229
246
  const anchor_1 = require("../reverse/anchor");
230
247
  // @implements A-SPEC-128
@@ -677,10 +694,10 @@ function collectDecisions(root, scanned, specs) {
677
694
  return { decisions: [], citations: [] };
678
695
  }
679
696
  const ids = new Set(decisions.map((d) => d.id));
697
+ // @implements A-SPEC-546.1 — recognise ADR-\d{3,} (4-digit ADRs no longer invisible), via a pure fn.
680
698
  const cite = (text, from, out) => {
681
- for (const m of text.matchAll(/\bADR-\d{3}\b/g))
682
- if (ids.has(m[0]))
683
- out.push({ from, adr: m[0] });
699
+ for (const adr of (0, adr_refs_1.adrRefsIn)(text, ids))
700
+ out.push({ from, adr });
684
701
  };
685
702
  const citations = [];
686
703
  for (const f of scanned) {
@@ -1322,6 +1339,24 @@ function makeRawHandlers(store, opts) {
1322
1339
  const events = fs.existsSync(dir) ? new ledger_store_1.FileLedgerStore(dir).loadAll() : [];
1323
1340
  return { ok: true, events: (0, ledger_timeline_1.timelineFrom)(events, a.id) };
1324
1341
  },
1342
+ // @implements A-SPEC-545.3 — "show me the RTM dashboard" launches (idempotently) the server the
1343
+ // agent would otherwise start by hand, and returns its URL plus an honesty census of what it shows.
1344
+ async rtm_dashboard(a) {
1345
+ const dest = resolveLedgerRoot(a.root);
1346
+ if (!dest.ok)
1347
+ return { ok: false, reason: dest.reason };
1348
+ try {
1349
+ const { startDashboardServer } = await Promise.resolve().then(() => __importStar(require('../server/dashboard')));
1350
+ const { ensureDashboard, dashboardCensus } = await Promise.resolve().then(() => __importStar(require('../server/dashboard-launcher')));
1351
+ const launch = await ensureDashboard(dest.root, a.port, (opts) => startDashboardServer(opts));
1352
+ const rtm = await fetchJson(`${launch.url}/api/rtm`);
1353
+ const heatmap = await fetchJson(`${launch.url}/api/rtm/heatmap`);
1354
+ return { ok: true, url: launch.url, running: launch.running, census: dashboardCensus(rtm, heatmap) };
1355
+ }
1356
+ catch (err) {
1357
+ return { ok: false, reason: `대시보드 기동 실패: ${err?.message ?? String(err)}` };
1358
+ }
1359
+ },
1325
1360
  async spec_approve(a) {
1326
1361
  const approvalRaw = process.env.HOLMES_APPROVAL;
1327
1362
  let approval;
@@ -3214,13 +3249,10 @@ function makeRawHandlers(store, opts) {
3214
3249
  const root = a.root ? (0, root_2.resolveProjectRoot)(a.root).root : process.cwd();
3215
3250
  const specsDir = path.join(root, '.ax', 'specs');
3216
3251
  const existing = await store.list();
3217
- let maxId = 200;
3218
- for (const s of existing) {
3219
- const num = parseInt(s.id.split('.')[0].replace(/\D/g, ''), 10);
3220
- if (!isNaN(num) && num > maxId)
3221
- maxId = num;
3222
- }
3223
- const nextId = maxId + 1;
3252
+ // @implements A-SPEC-546.1 — align with the sibling reverse-draft path (nextIdBase, floor 100)
3253
+ // instead of a hard-coded 200 floor, so a fresh project starts at REQ-100, not REQ-201. Existing
3254
+ // projects are unchanged: nextIdBase returns max(existing)+1, which dominates the floor.
3255
+ const nextId = (0, draft_1.nextIdBase)(existing.map((s) => s.id));
3224
3256
  const reqId = `REQ-${nextId}`;
3225
3257
  const hspecId = `H-SPEC-${nextId}`;
3226
3258
  const aspecId = `A-SPEC-${nextId}`;
@@ -133,6 +133,16 @@ exports.TOOL_SCHEMAS = {
133
133
  },
134
134
  },
135
135
  },
136
+ rtm_dashboard: {
137
+ description: "Launch (idempotently) the interactive RTM/CPG dashboard the agent would otherwise start by hand, and return { ok, url, running, census } — use it when a human asks to SEE the RTM heatmap / coverage matrix / dashboard. `url` is the localhost address to open; `running` is true when an already-live server for this project was reused (a second call never starts a second server). `census` is an honesty summary of what the dashboard shows — { reqCount, pipelineCount, coveredCount, coveragePct, retiredCount, findingsScanned } — so the caller can report coverage without scraping the page. No spec writes, no ledger append. Refuses when a supplied `root` points at a different project than the server is bound to.",
138
+ inputSchema: {
139
+ type: 'object',
140
+ properties: {
141
+ root: str('Optional when the server is bound to a file store; if supplied it must resolve to the SAME project.'),
142
+ port: { type: 'number', description: 'Optional port to bind when first launching (default 8080). Ignored if a server for this project is already running.' },
143
+ },
144
+ },
145
+ },
136
146
  spec_approve: {
137
147
  description: 'Approve a spec as a sealing ACT: confirm the ledger destination BEFORE sealing → validate (zero errors) → record approved_digest + parent_digests snapshots → status: approved (written only at the version this act read; a concurrent edit wins and the approval is refused for retry) → append spec-approved to the provenance ledger. Requires a valid out-of-band HOLMES_APPROVAL in the SERVER environment (fail-closed; nothing in the request can substitute). Refuses an unsealed approved parent — seal parents first.',
138
148
  inputSchema: {
@@ -0,0 +1,69 @@
1
+ import type { Cfg, CfgEdge } from '../cpg/foundation/cfg';
2
+ import type { Pdg } from '../cpg/foundation/cdg';
3
+ import type { PersistedAst } from '../cpg/foundation/ast-store';
4
+ export interface CfgViewBlock {
5
+ id: number;
6
+ line: number;
7
+ label: string;
8
+ unreachable: boolean;
9
+ }
10
+ export interface CfgView {
11
+ entry: number;
12
+ exit: number;
13
+ blocks: CfgViewBlock[];
14
+ edges: {
15
+ from: number;
16
+ to: number;
17
+ kind: CfgEdge['kind'];
18
+ }[];
19
+ ctrlDeps: {
20
+ ctrl: number;
21
+ dep: number;
22
+ }[];
23
+ dataDeps: {
24
+ from: number;
25
+ to: number;
26
+ name: string;
27
+ }[];
28
+ }
29
+ /**
30
+ * @implements A-SPEC-545.4
31
+ * Pure: 1-based line number of a byte offset (count of newlines in [0, offset) + 1). Clamped.
32
+ */
33
+ export declare function lineAtByte(source: string, offset: number): number;
34
+ /**
35
+ * @implements A-SPEC-545.4
36
+ * Pure: fold a foundation Cfg + Pdg into a display-ready graph — block labels/lines, control-flow
37
+ * edges, block-level control deps, and reaching defs projected to block pairs (deduped, no self-edge).
38
+ */
39
+ export declare function buildCfgView(cfg: Cfg | {
40
+ unsupported: string;
41
+ }, pdg: Pdg | {
42
+ unsupported: string;
43
+ }, ast: PersistedAst, source: string): CfgView | {
44
+ unsupported: string;
45
+ };
46
+ export declare const CFG_COL_W = 220;
47
+ export declare const CFG_ROW_H = 120;
48
+ export interface CfgLayoutNode {
49
+ id: number;
50
+ layer: number;
51
+ col: number;
52
+ x: number;
53
+ y: number;
54
+ line: number;
55
+ label: string;
56
+ unreachable: boolean;
57
+ }
58
+ export interface CfgLayout {
59
+ nodes: CfgLayoutNode[];
60
+ width: number;
61
+ height: number;
62
+ }
63
+ /**
64
+ * @implements A-SPEC-545.5
65
+ * Pure & deterministic layered layout of a CFG: layer = BFS shortest depth from entry (cycle-safe,
66
+ * blocks visited in id order); unreachable blocks trail after the reachable ones. col = order within
67
+ * a layer. Same input ⇒ same layout.
68
+ */
69
+ export declare function layoutCfg(view: CfgView): CfgLayout;
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CFG_ROW_H = exports.CFG_COL_W = void 0;
4
+ exports.lineAtByte = lineAtByte;
5
+ exports.buildCfgView = buildCfgView;
6
+ exports.layoutCfg = layoutCfg;
7
+ /**
8
+ * @implements A-SPEC-545.4
9
+ * Pure: 1-based line number of a byte offset (count of newlines in [0, offset) + 1). Clamped.
10
+ */
11
+ function lineAtByte(source, offset) {
12
+ const at = Math.max(0, Math.min(offset, source.length));
13
+ let line = 1;
14
+ for (let i = 0; i < at; i++)
15
+ if (source.charCodeAt(i) === 10)
16
+ line++;
17
+ return line;
18
+ }
19
+ /**
20
+ * @implements A-SPEC-545.4
21
+ * Pure: fold a foundation Cfg + Pdg into a display-ready graph — block labels/lines, control-flow
22
+ * edges, block-level control deps, and reaching defs projected to block pairs (deduped, no self-edge).
23
+ */
24
+ function buildCfgView(cfg, pdg, ast, source) {
25
+ if ('unsupported' in cfg)
26
+ return cfg;
27
+ if ('unsupported' in pdg)
28
+ return pdg;
29
+ // stmt node index → owning block id
30
+ const blockOf = new Map();
31
+ for (const b of cfg.blocks)
32
+ for (const s of b.stmts)
33
+ blockOf.set(s, b.id);
34
+ const unreachable = new Set(cfg.unreachable);
35
+ const blocks = cfg.blocks.map((b) => {
36
+ const first = b.stmts.length ? b.stmts[0] : -1;
37
+ const node = first >= 0 ? ast.nodes[first] : undefined;
38
+ const line = node ? lineAtByte(source, node.start) : 0;
39
+ const raw = node ? source.slice(node.start, node.end) : '';
40
+ const label = raw.split('\n', 1)[0].slice(0, 80);
41
+ return { id: b.id, line, label, unreachable: unreachable.has(b.id) };
42
+ });
43
+ const edges = cfg.edges.map((e) => ({ from: e.from, to: e.to, kind: e.kind }));
44
+ const ctrlDeps = pdg.controlDeps.map((c) => ({ ctrl: c.ctrl, dep: c.dep }));
45
+ const seen = new Set();
46
+ const dataDeps = [];
47
+ for (const d of pdg.reachingDefs) {
48
+ const from = blockOf.get(d.defStmt);
49
+ const to = blockOf.get(d.useStmt);
50
+ if (from === undefined || to === undefined || from === to)
51
+ continue; // no self-edge / unresolved
52
+ const key = `${from}>${to}:${d.name}`;
53
+ if (seen.has(key))
54
+ continue;
55
+ seen.add(key);
56
+ dataDeps.push({ from, to, name: d.name });
57
+ }
58
+ return { entry: cfg.entry, exit: cfg.exit, blocks, edges, ctrlDeps, dataDeps };
59
+ }
60
+ exports.CFG_COL_W = 220;
61
+ exports.CFG_ROW_H = 120;
62
+ /**
63
+ * @implements A-SPEC-545.5
64
+ * Pure & deterministic layered layout of a CFG: layer = BFS shortest depth from entry (cycle-safe,
65
+ * blocks visited in id order); unreachable blocks trail after the reachable ones. col = order within
66
+ * a layer. Same input ⇒ same layout.
67
+ */
68
+ function layoutCfg(view) {
69
+ if (view.blocks.length === 0)
70
+ return { nodes: [], width: 0, height: 0 };
71
+ const succ = new Map();
72
+ for (const e of view.edges)
73
+ succ.set(e.from, [...(succ.get(e.from) ?? []), e.to]);
74
+ const layer = new Map();
75
+ const start = view.blocks.some((b) => b.id === view.entry) ? view.entry : Math.min(...view.blocks.map((b) => b.id));
76
+ let queue = [start];
77
+ layer.set(start, 0);
78
+ while (queue.length) {
79
+ const next = [];
80
+ for (const id of queue) {
81
+ const depth = layer.get(id);
82
+ for (const to of (succ.get(id) ?? []).slice().sort((a, b) => a - b)) {
83
+ if (!layer.has(to)) {
84
+ layer.set(to, depth + 1);
85
+ next.push(to);
86
+ } // BFS: cycle-safe (visit once)
87
+ }
88
+ }
89
+ queue = next;
90
+ }
91
+ const reachMax = layer.size ? Math.max(...layer.values()) : 0;
92
+ // Unreachable blocks trail after every reachable block, in id order.
93
+ const unreached = view.blocks.filter((b) => !layer.has(b.id)).sort((a, b) => a.id - b.id);
94
+ unreached.forEach((b, i) => layer.set(b.id, reachMax + 1 + i));
95
+ // col = order within a layer, by ascending block id (deterministic).
96
+ const perLayer = new Map();
97
+ const byId = [...view.blocks].sort((a, b) => a.id - b.id);
98
+ const nodes = byId.map((b) => {
99
+ const ly = layer.get(b.id);
100
+ const col = perLayer.get(ly) ?? 0;
101
+ perLayer.set(ly, col + 1);
102
+ return { id: b.id, layer: ly, col, x: col * exports.CFG_COL_W, y: ly * exports.CFG_ROW_H, line: b.line, label: b.label, unreachable: b.unreachable };
103
+ });
104
+ const maxCol = nodes.reduce((m, n) => Math.max(m, n.col), 0);
105
+ const maxLayer = nodes.reduce((m, n) => Math.max(m, n.layer), 0);
106
+ return { nodes, width: (maxCol + 1) * exports.CFG_COL_W, height: (maxLayer + 1) * exports.CFG_ROW_H };
107
+ }
@@ -0,0 +1,40 @@
1
+ /** Honesty summary of what a launched dashboard shows — derived from the endpoints it serves. */
2
+ export interface DashboardCensus {
3
+ reqCount: number;
4
+ pipelineCount: number;
5
+ coveredCount: number;
6
+ coveragePct: number;
7
+ retiredCount: number;
8
+ findingsScanned: boolean;
9
+ }
10
+ /**
11
+ * @implements A-SPEC-545.3
12
+ * Pure: fold the /api/rtm and /api/rtm/heatmap payloads into a census. Missing fields read as 0/false;
13
+ * coveragePct never divides by zero.
14
+ */
15
+ export declare function dashboardCensus(rtm: any, heatmap: any): DashboardCensus;
16
+ export interface LaunchResult {
17
+ url: string;
18
+ port: number;
19
+ running: boolean;
20
+ }
21
+ export interface StartedHandle {
22
+ url: string;
23
+ port: number;
24
+ stop?: () => Promise<void>;
25
+ }
26
+ type Starter = (opts: {
27
+ root: string;
28
+ port?: number;
29
+ }) => Promise<StartedHandle>;
30
+ /**
31
+ * @implements A-SPEC-545.3
32
+ * Idempotent per-root dashboard registry: start once, reuse thereafter. `start` is injected so the
33
+ * idempotency is testable without a real server. A failing start registers nothing.
34
+ */
35
+ export declare function ensureDashboard(root: string, port: number | undefined, start: Starter): Promise<LaunchResult>;
36
+ /** Test hook: forget all launched servers (without stopping them). */
37
+ export declare function _resetLauncher(): void;
38
+ /** Test/shutdown hook: stop every launched server and clear the registry. */
39
+ export declare function _stopAll(): Promise<void>;
40
+ export {};
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ // @implements A-SPEC-545.3
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.dashboardCensus = dashboardCensus;
5
+ exports.ensureDashboard = ensureDashboard;
6
+ exports._resetLauncher = _resetLauncher;
7
+ exports._stopAll = _stopAll;
8
+ /**
9
+ * @implements A-SPEC-545.3
10
+ * Pure: fold the /api/rtm and /api/rtm/heatmap payloads into a census. Missing fields read as 0/false;
11
+ * coveragePct never divides by zero.
12
+ */
13
+ function dashboardCensus(rtm, heatmap) {
14
+ const reqCount = heatmap?.matrix?.reqs?.length ?? 0;
15
+ const pipelineCount = heatmap?.pipelineCount ?? 0;
16
+ const coveredCount = heatmap?.completeCount ?? 0;
17
+ const coveragePct = pipelineCount > 0 ? Math.round((coveredCount / pipelineCount) * 100) : 0;
18
+ return {
19
+ reqCount,
20
+ pipelineCount,
21
+ coveredCount,
22
+ coveragePct,
23
+ retiredCount: rtm?.retired?.count ?? 0,
24
+ findingsScanned: heatmap?.findingsScanned === true,
25
+ };
26
+ }
27
+ const live = new Map();
28
+ /**
29
+ * @implements A-SPEC-545.3
30
+ * Idempotent per-root dashboard registry: start once, reuse thereafter. `start` is injected so the
31
+ * idempotency is testable without a real server. A failing start registers nothing.
32
+ */
33
+ async function ensureDashboard(root, port, start) {
34
+ const existing = live.get(root);
35
+ if (existing)
36
+ return { url: existing.url, port: existing.port, running: true };
37
+ const h = await start({ root, port }); // a throw here registers nothing and propagates
38
+ live.set(root, h);
39
+ return { url: h.url, port: h.port, running: false };
40
+ }
41
+ /** Test hook: forget all launched servers (without stopping them). */
42
+ function _resetLauncher() {
43
+ live.clear();
44
+ }
45
+ /** Test/shutdown hook: stop every launched server and clear the registry. */
46
+ async function _stopAll() {
47
+ for (const h of live.values()) {
48
+ try {
49
+ await h.stop?.();
50
+ }
51
+ catch { /* best effort */ }
52
+ }
53
+ live.clear();
54
+ }
@@ -50,6 +50,11 @@ const cpg_scanner_1 = require("../cpg/cpg-scanner");
50
50
  const language_parser_1 = require("../cpg/language-parser");
51
51
  const findings_1 = require("../review/findings");
52
52
  const ast_mutation_1 = require("../cpg/ast-mutation");
53
+ const rtm_matrix_1 = require("./rtm-matrix");
54
+ const ast_store_1 = require("../cpg/foundation/ast-store");
55
+ const cfg_1 = require("../cpg/foundation/cfg");
56
+ const cdg_1 = require("../cpg/foundation/cdg");
57
+ const cfg_view_1 = require("./cfg-view");
53
58
  /**
54
59
  * Start a lightweight standalone Node.js HTTP server for interactive dashboard & RTM visualization.
55
60
  *
@@ -183,6 +188,8 @@ async function startDashboardServer(options) {
183
188
  const findingsLedger = new findings_1.FindingsLedger(findingsPath);
184
189
  const allFindings = findingsScanned ? findingsLedger.list() : [];
185
190
  const pipelines = buildPipelineRows(specs, files, allFindings, { findingsScanned });
191
+ // @implements A-SPEC-545.2 — seriated REQ × stage coverage matrix (additive; pipelines unchanged).
192
+ const matrix = (0, rtm_matrix_1.heatmapMatrix)(pipelines);
186
193
  const body = JSON.stringify({
187
194
  ok: true,
188
195
  pipelineCount: pipelines.length,
@@ -190,6 +197,7 @@ async function startDashboardServer(options) {
190
197
  findingsScanned,
191
198
  mutationScoreMeasured: false,
192
199
  pipelines,
200
+ matrix,
193
201
  });
194
202
  res.writeHead(200, { 'Content-Type': 'application/json' });
195
203
  res.end(body);
@@ -202,6 +210,80 @@ async function startDashboardServer(options) {
202
210
  }
203
211
  return;
204
212
  }
213
+ // @implements A-SPEC-545.4 — one function's CFG + PDG (data/control deps), from the same cfgOf/pdgOf
214
+ // the taint lane uses. Honest envelope: a non-CFG language is named, never faked as an empty graph.
215
+ if (parsedUrl === '/api/cfg') {
216
+ try {
217
+ const q = new URL(req.url || '/', 'http://localhost').searchParams;
218
+ const file = q.get('file');
219
+ const symbol = q.get('symbol');
220
+ if (!file || !symbol) {
221
+ res.writeHead(400, { 'Content-Type': 'application/json' });
222
+ res.end(JSON.stringify({ ok: false, reason: 'file and symbol query params are required' }));
223
+ return;
224
+ }
225
+ const files = scanner.scan(root);
226
+ const scanned = files.find((f) => f.sourcePath === file);
227
+ const sym = scanned?.symbols.find((s) => s.name === symbol);
228
+ const lang = (0, ast_store_1.languageFor)(file)?.lang ?? null;
229
+ if (!scanned || !sym) {
230
+ res.writeHead(200, { 'Content-Type': 'application/json' });
231
+ res.end(JSON.stringify({ ok: false, reason: `symbol ${symbol} not found in ${file}`, file, symbol, lang }));
232
+ return;
233
+ }
234
+ if (!lang || !cfg_1.CFG_LANGUAGES.has(lang)) {
235
+ res.writeHead(200, { 'Content-Type': 'application/json' });
236
+ res.end(JSON.stringify({ ok: true, file, symbol, lang, unsupported: lang ?? 'unknown' }));
237
+ return;
238
+ }
239
+ const source = fs.readFileSync(path.join(root, file), 'utf8');
240
+ const ast = await (0, ast_store_1.parseAst)(source, file);
241
+ if (!ast) {
242
+ res.writeHead(200, { 'Content-Type': 'application/json' });
243
+ res.end(JSON.stringify({ ok: false, reason: `could not parse ${file}`, file, symbol, lang }));
244
+ return;
245
+ }
246
+ // Pick the function whose declaration line is closest to the scanned symbol's start line
247
+ // (absorbs any 0/1-based drift between the scanner and the AST byte→line mapping).
248
+ const fns = (0, cfg_1.functionsIn)(ast);
249
+ let best = null;
250
+ let bestDist = Infinity;
251
+ for (const fn of fns) {
252
+ const node = ast.nodes[fn.nodeIndex];
253
+ if (!node)
254
+ continue;
255
+ const dist = Math.abs((0, cfg_view_1.lineAtByte)(source, node.start) - sym.startLine);
256
+ if (dist < bestDist) {
257
+ bestDist = dist;
258
+ best = fn;
259
+ }
260
+ }
261
+ if (!best) {
262
+ res.writeHead(200, { 'Content-Type': 'application/json' });
263
+ res.end(JSON.stringify({ ok: false, reason: `no function body for ${symbol} in ${file}`, file, symbol, lang }));
264
+ return;
265
+ }
266
+ const cfg = (0, cfg_1.cfgOf)(ast, best, source);
267
+ const pdg = (0, cdg_1.pdgOf)(ast, cfg, best, source);
268
+ const view = (0, cfg_view_1.buildCfgView)(cfg, pdg, ast, source);
269
+ if ('unsupported' in view) {
270
+ res.writeHead(200, { 'Content-Type': 'application/json' });
271
+ res.end(JSON.stringify({ ok: true, file, symbol, lang, unsupported: view.unsupported }));
272
+ return;
273
+ }
274
+ // @implements A-SPEC-545.5 — server-computed layered layout so the client only paints (no
275
+ // duplicated layout logic to drift from the tested one).
276
+ res.writeHead(200, { 'Content-Type': 'application/json' });
277
+ res.end(JSON.stringify({ ok: true, file, symbol, lang, cfg: view, layout: (0, cfg_view_1.layoutCfg)(view) }));
278
+ }
279
+ catch (err) {
280
+ if (!res.headersSent) {
281
+ res.writeHead(500, { 'Content-Type': 'application/json' });
282
+ res.end(JSON.stringify({ ok: false, error: 'Internal Server Error' }));
283
+ }
284
+ }
285
+ return;
286
+ }
205
287
  if (parsedUrl === '/api/cpg') {
206
288
  try {
207
289
  const files = scanner.scan(root);
@@ -388,7 +470,57 @@ function renderDashboardHtml() {
388
470
  <title>Holmes-Kit World Top-Tier Quantitative RTM Matrix & AST/CPG Graph Canvas</title>
389
471
  <script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
390
472
  <style>
391
- body { font-family: system-ui, -apple-system, sans-serif; background-color: #0b0f19; color: #f8fafc; margin: 0; padding: 24px; user-select: none; }
473
+ /* @implements A-SPEC-545.2 design tokens: sequential coverage ramp, status, surfaces, 2 fonts.
474
+ Dark-first; light palette under prefers-color-scheme. The green ramp reads "traced". */
475
+ :root {
476
+ --surface-0: #0d1417; --surface-1: #131e22; --surface-2: #1b2b30; --border: #26383d;
477
+ --ink-0: #eaf2f0; --ink-1: #9fb2b0; --ink-2: #6b807e;
478
+ --ramp-0: #17211d; --ramp-1: #1f3a2c; --ramp-2: #26543a; --ramp-3: #2c7449; --ramp-4: #33a35f; --ramp-5: #46d97e;
479
+ --ramp-ink-lo: #8ba39c; --ramp-ink-hi: #06120c;
480
+ --ok: #46d97e; --warn: #e0a83c; --bad: #e5595c; --accent: #46b0d9;
481
+ --font-ui: ui-sans-serif, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
482
+ --font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
483
+ }
484
+ @media (prefers-color-scheme: light) {
485
+ :root {
486
+ --surface-0: #f4f7f6; --surface-1: #ffffff; --surface-2: #eaf0ee; --border: #d3ddda;
487
+ --ink-0: #12201c; --ink-1: #4a5c58; --ink-2: #7b8d89;
488
+ --ramp-0: #e7efe9; --ramp-1: #c3e0cd; --ramp-2: #8fc9a5; --ramp-3: #54ac77; --ramp-4: #2f8a54; --ramp-5: #166b3c;
489
+ --ramp-ink-lo: #5b6f69; --ramp-ink-hi: #f4faf6;
490
+ --ok: #166b3c; --warn: #b7791f; --bad: #c53539; --accent: #1f7fa6;
491
+ }
492
+ }
493
+ body { font-family: var(--font-ui); background-color: var(--surface-0); color: var(--ink-0); margin: 0; padding: 24px; user-select: none; }
494
+
495
+ /* Seriated REQ × stage coverage matrix (2D grid) */
496
+ .hm-toolbar { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; margin-bottom: 12px; color: var(--ink-1); font-size: 12px; }
497
+ .hm-legend { display: inline-flex; align-items: center; gap: 6px; }
498
+ .hm-legend-swatch { width: 16px; height: 12px; border-radius: 2px; display: inline-block; border: 1px solid var(--border); }
499
+ .hm-scroll { max-height: 74vh; overflow: auto; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-1); }
500
+ table.hm-grid { border-collapse: separate; border-spacing: 0; width: 100%; font-family: var(--font-mono); }
501
+ table.hm-grid th, table.hm-grid td { border-bottom: 1px solid var(--surface-0); border-right: 1px solid var(--surface-0); }
502
+ table.hm-grid thead th { position: sticky; top: 0; z-index: 3; background: var(--surface-2); color: var(--ink-1);
503
+ font-family: var(--font-ui); font-size: 10px; font-weight: 700; letter-spacing: 0.6px; text-transform: uppercase;
504
+ padding: 10px 8px; text-align: center; white-space: nowrap; }
505
+ table.hm-grid thead th.hm-corner { left: 0; z-index: 4; text-align: left; }
506
+ table.hm-grid td.hm-rowlabel, table.hm-grid th.hm-corner { position: sticky; left: 0; z-index: 2; background: var(--surface-1);
507
+ min-width: 260px; max-width: 320px; padding: 7px 12px; border-right: 1px solid var(--border); }
508
+ .hm-req-id { font-family: var(--font-mono); font-weight: 700; color: var(--ink-0); font-size: 12px; }
509
+ .hm-req-title { font-family: var(--font-ui); color: var(--ink-2); font-size: 11px; margin-left: 8px;
510
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
511
+ .hm-rowlabel-flex { display: flex; align-items: baseline; overflow: hidden; }
512
+ table.hm-grid td.hm-cell { text-align: center; font-variant-numeric: tabular-nums; font-size: 11px; padding: 7px 6px;
513
+ color: var(--ramp-ink-hi); cursor: default; min-width: 62px; }
514
+ table.hm-grid tbody tr:hover td.hm-cell { outline: 1px solid var(--accent); outline-offset: -2px; }
515
+ .hm-b0 { background: var(--ramp-0); color: var(--ramp-ink-lo); }
516
+ .hm-b1 { background: var(--ramp-1); color: var(--ramp-ink-lo); }
517
+ .hm-b2 { background: var(--ramp-2); color: var(--ramp-ink-hi); }
518
+ .hm-b3 { background: var(--ramp-3); color: var(--ramp-ink-hi); }
519
+ .hm-b4 { background: var(--ramp-4); color: var(--ramp-ink-hi); }
520
+ .hm-b5 { background: var(--ramp-5); color: var(--ramp-ink-hi); }
521
+ .hm-summary-pill { font-family: var(--font-ui); font-weight: 700; font-size: 11px; padding: 2px 8px; border-radius: 999px; }
522
+ .hm-pill-ok { background: color-mix(in srgb, var(--ok) 22%, transparent); color: var(--ok); border: 1px solid var(--ok); }
523
+ .hm-pill-warn { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--warn); border: 1px solid var(--warn); }
392
524
  .header { margin-bottom: 20px; border-bottom: 1px solid rgba(255,255,255,0.12); padding-bottom: 16px; display: flex; justify-content: space-between; align-items: center; }
393
525
  .nav-tabs { display: flex; gap: 12px; margin-bottom: 20px; }
394
526
  .tab-btn { background: #1e293b; color: #94a3b8; border: 1px solid #334155; padding: 10px 20px; border-radius: 8px; cursor: pointer; font-weight: bold; }
@@ -524,6 +656,20 @@ function renderDashboardHtml() {
524
656
  .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; }
525
657
  .tool-btn:hover { background: #3b82f6; border-color: #3b82f6; color: #fff; transform: scale(1.05); }
526
658
  .ux-hint-banner { background: #1e293b; border-left: 4px solid #ec4899; color: #cbd5e1; padding: 10px 14px; border-radius: 6px; font-size: 12px; margin-bottom: 12px; display: flex; align-items: center; justify-content: space-between; }
659
+
660
+ /* @implements A-SPEC-545.5 — CFG/PDG drilldown: control-flow blocks + program-dependence overlays */
661
+ .cfg-block rect { fill: var(--surface-2); stroke: var(--border); stroke-width: 1.5; rx: 8; }
662
+ .cfg-block.entry rect { stroke: var(--ok); stroke-width: 2.5; }
663
+ .cfg-block.exitb rect { stroke: var(--accent); stroke-width: 2.5; }
664
+ .cfg-block.unreachable rect { opacity: 0.4; stroke-dasharray: 4 3; }
665
+ .cfg-block .cfg-line { font-family: var(--font-mono); font-size: 10px; fill: var(--ink-2); }
666
+ .cfg-block .cfg-label { font-family: var(--font-mono); font-size: 12px; fill: var(--ink-0); }
667
+ .cfg-edge-flow { stroke: var(--ink-1); stroke-width: 2; fill: none; marker-end: url(#cfg-arrow-flow); }
668
+ .cfg-edge-data { stroke: var(--accent); stroke-width: 2; fill: none; opacity: 0.85; marker-end: url(#cfg-arrow-data); }
669
+ .cfg-edge-ctrl { stroke: var(--warn); stroke-width: 2; fill: none; stroke-dasharray: 6 4; opacity: 0.85; marker-end: url(#cfg-arrow-ctrl); }
670
+ .cfg-legend { display: flex; gap: 18px; align-items: center; font-family: var(--font-ui); font-size: 12px; color: var(--ink-1); flex-wrap: wrap; }
671
+ .cfg-legend .swatch { display: inline-block; width: 22px; height: 0; border-top-width: 2px; border-top-style: solid; vertical-align: middle; margin-right: 6px; }
672
+ .cfg-banner { font-family: var(--font-ui); color: var(--ink-1); background: var(--surface-1); border: 1px solid var(--border); border-radius: 10px; padding: 16px 18px; }
527
673
  </style>
528
674
  </head>
529
675
  <body>
@@ -541,10 +687,10 @@ function renderDashboardHtml() {
541
687
  </div>
542
688
 
543
689
  <div class="nav-tabs">
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>
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>
690
+ <button id="tabRtmBtn" class="tab-btn active" onclick="switchTab('rtm')">Quantitative REQ RTM</button>
691
+ <button id="tabHeatmapBtn" class="tab-btn" onclick="switchTab('heatmap')">Coverage Matrix</button>
692
+ <button id="tabGraphBtn" class="tab-btn" onclick="switchTab('graph')">Pipeline Canvas</button>
693
+ <button id="tabLegacyBtn" class="tab-btn" onclick="switchTab('legacy')">Unmapped Specs</button>
548
694
  </div>
549
695
 
550
696
  <div id="rtmTab">
@@ -570,30 +716,28 @@ function renderDashboardHtml() {
570
716
  </div>
571
717
 
572
718
  <div id="heatmapTab" style="display: none;">
573
- <div class="ux-hint-banner" style="border-left-color: #f59e0b;">
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>
592
- </div>
593
- </div>
594
- <div class="heatmap-wrapper">
595
- <div id="heatmapContainer">Loading REQ-grouped 6-stage pipeline heatmap...</div>
719
+ <div class="hm-toolbar">
720
+ <span style="font-family: var(--font-ui); font-weight: 700; color: var(--ink-0); font-size: 13px;">Requirement × Pipeline-Stage Coverage</span>
721
+ <span style="color: var(--ink-2);">rows seriated by completeness · cell = share of a REQ's chains reaching that stage</span>
722
+ <span style="display: inline-flex; align-items: center; gap: 8px;">
723
+ <label for="pipelineFilterSelect" style="color: var(--ink-1);">Rows</label>
724
+ <select id="pipelineFilterSelect" class="select-box" onchange="changeHeatmapLens()">
725
+ <option value="all" selected>All requirements</option>
726
+ <option value="uncovered">Incomplete only</option>
727
+ </select>
728
+ </span>
729
+ <span class="hm-legend">
730
+ <span style="color: var(--ink-2);">0%</span>
731
+ <span class="hm-legend-swatch" style="background: var(--ramp-0);"></span>
732
+ <span class="hm-legend-swatch" style="background: var(--ramp-1);"></span>
733
+ <span class="hm-legend-swatch" style="background: var(--ramp-2);"></span>
734
+ <span class="hm-legend-swatch" style="background: var(--ramp-3);"></span>
735
+ <span class="hm-legend-swatch" style="background: var(--ramp-4);"></span>
736
+ <span class="hm-legend-swatch" style="background: var(--ramp-5);"></span>
737
+ <span style="color: var(--ink-2);">100%</span>
738
+ </span>
596
739
  </div>
740
+ <div id="heatmapContainer">Loading coverage matrix…</div>
597
741
  </div>
598
742
 
599
743
  <div id="graphTab" style="display: none;">
@@ -844,144 +988,70 @@ function renderDashboardHtml() {
844
988
  }
845
989
  }
846
990
 
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
991
+ // @implements A-SPEC-545.2 seriated REQ × stage coverage matrix (2D grid), painted from the
992
+ // server-built data.matrix. Rows are ordered by matrix.order (completeness), each cell coloured
993
+ // by its ramp bucket with the coverage percent shown inline (tabular-nums), so magnitude never
994
+ // relies on colour alone.
858
995
  function render6StagePipelineMatrix(data) {
859
996
  const container = document.getElementById('heatmapContainer');
860
- const rawPipelines = data.pipelines || [];
861
- const lens = document.getElementById('lensSelect').value || 'lens1';
862
- const statusFilter = document.getElementById('pipelineFilterSelect').value || 'all';
997
+ const matrix = data.matrix || { reqs: [], stages: [], values: [], order: [], buckets: [] };
998
+ const filterEl = document.getElementById('pipelineFilterSelect');
999
+ const statusFilter = filterEl ? (filterEl.value || 'all') : 'all';
863
1000
 
864
- if (rawPipelines.length === 0) {
865
- container.innerHTML = '<div style="color:#94a3b8; font-size:14px;">No pipeline trace chains found.</div>';
1001
+ if (!matrix.reqs || matrix.reqs.length === 0) {
1002
+ container.innerHTML = '<div style="color: var(--ink-1); font-size: 14px; padding: 16px;">No pipeline trace chains found.</div>';
866
1003
  return;
867
1004
  }
868
1005
 
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
- }
1006
+ const isRowComplete = (i) => (matrix.values[i] || []).every(v => v >= 1);
1007
+ let rowIdx = (matrix.order && matrix.order.length ? matrix.order : matrix.reqs.map((_, i) => i)).slice();
1008
+ if (statusFilter === 'uncovered') rowIdx = rowIdx.filter(i => !isRowComplete(i));
876
1009
 
877
- // Group pipelines by reqId
878
- const groupedMap = new Map();
879
- pipelines.forEach(p => {
880
- if (!groupedMap.has(p.reqId)) {
881
- groupedMap.set(p.reqId, { reqId: p.reqId, reqTitle: p.reqTitle, items: [] });
882
- }
883
- groupedMap.get(p.reqId).items.push(p);
884
- });
1010
+ const fullyTraced = matrix.reqs.filter((_, i) => isRowComplete(i)).length;
1011
+ const summaryCls = fullyTraced === matrix.reqs.length ? 'hm-pill-ok' : 'hm-pill-warn';
885
1012
 
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>';
1013
+ if (rowIdx.length === 0) {
1014
+ container.innerHTML = '<div style="color: var(--ink-1); font-size: 14px; padding: 16px;">Every requirement is fully traced — no incomplete rows to show.</div>';
891
1015
  return;
892
1016
  }
893
1017
 
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}
1018
+ const pct = (v) => Math.round((v || 0) * 100);
1019
+ const headCells = matrix.stages.map(s => \`<th>\${escapeHtml(s)}</th>\`).join('');
1020
+
1021
+ let rows = '';
1022
+ rowIdx.forEach(i => {
1023
+ const req = matrix.reqs[i];
1024
+ const vals = matrix.values[i] || [];
1025
+ const bkts = matrix.buckets[i] || [];
1026
+ const cells = matrix.stages.map((stage, s) => {
1027
+ const v = vals[s] || 0;
1028
+ const b = bkts[s] || 0;
1029
+ const title = escapeHtml(req.id) + ' · ' + escapeHtml(stage) + ': ' + pct(v) + '% of chains reached';
1030
+ return \`<td class="hm-cell hm-b\${b}" title="\${title}">\${pct(v)}</td>\`;
1031
+ }).join('');
1032
+ rows += \`
1033
+ <tr>
1034
+ <td class="hm-rowlabel" onclick="focusSpecInGraph('\${escapeHtml(req.id)}')" title="\${escapeHtml(req.title || '')}">
1035
+ <div class="hm-rowlabel-flex">
1036
+ <span class="hm-req-id">\${escapeHtml(req.id)}</span>
1037
+ <span class="hm-req-title">\${escapeHtml(req.title || '')}</span>
917
1038
  </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
- });
975
-
976
- html += \`
977
- </tbody>
978
- </table>
979
- </div>
980
- </div>
981
- \`;
1039
+ </td>
1040
+ \${cells}
1041
+ </tr>\`;
982
1042
  });
983
1043
 
984
- container.innerHTML = html;
1044
+ container.innerHTML = \`
1045
+ <div style="margin-bottom: 10px;">
1046
+ <span class="hm-summary-pill \${summaryCls}">\${fullyTraced} / \${matrix.reqs.length} requirements fully traced</span>
1047
+ <span style="color: var(--ink-2); font-size: 12px; margin-left: 10px;">showing \${rowIdx.length} row\${rowIdx.length === 1 ? '' : 's'}</span>
1048
+ </div>
1049
+ <div class="hm-scroll">
1050
+ <table class="hm-grid">
1051
+ <thead><tr><th class="hm-corner">Requirement</th>\${headCells}</tr></thead>
1052
+ <tbody>\${rows}</tbody>
1053
+ </table>
1054
+ </div>\`;
985
1055
  }
986
1056
 
987
1057
  // World Top-Tier Quantitative REQ-Centric RTM Traceability Matrix Grid Renderer
@@ -1114,12 +1184,84 @@ function renderDashboardHtml() {
1114
1184
 
1115
1185
  async function focusSpecInGraph(specId) {
1116
1186
  switchTab('graph');
1187
+ // @implements A-SPEC-545.5 — a symbol id (file#name) drills into that function's CFG/PDG;
1188
+ // every other id keeps the existing spec-graph traversal untouched.
1189
+ if (typeof specId === 'string' && specId.indexOf('#') !== -1) {
1190
+ const hash = specId.indexOf('#');
1191
+ const file = specId.slice(0, hash);
1192
+ const symbol = specId.slice(hash + 1);
1193
+ try {
1194
+ const res = await fetch('/api/cfg?file=' + encodeURIComponent(file) + '&symbol=' + encodeURIComponent(symbol));
1195
+ renderCfgDrilldown(await res.json(), file, symbol);
1196
+ } catch (err) {
1197
+ document.getElementById('graphScopeNotice').textContent = 'Failed to load CFG: ' + (err && err.message || err);
1198
+ }
1199
+ return;
1200
+ }
1117
1201
  if (!graphData) {
1118
1202
  await loadGraphCanvas();
1119
1203
  }
1120
1204
  selectAndTraverseNode(specId);
1121
1205
  }
1122
1206
 
1207
+ // @implements A-SPEC-545.5 — paint the server-computed layered CFG + PDG overlays into the canvas.
1208
+ function renderCfgDrilldown(resp, file, symbol) {
1209
+ const svg = document.getElementById('graphSvg');
1210
+ const notice = document.getElementById('graphScopeNotice');
1211
+ if (!resp || resp.ok === false) {
1212
+ svg.innerHTML = '';
1213
+ notice.innerHTML = '<div class="cfg-banner">No CFG for <strong>' + escapeHtml(symbol) + '</strong> in ' + escapeHtml(file) + (resp && resp.reason ? ' — ' + escapeHtml(resp.reason) : '') + '</div>';
1214
+ return;
1215
+ }
1216
+ if (resp.unsupported) {
1217
+ svg.innerHTML = '';
1218
+ notice.innerHTML = '<div class="cfg-banner">CFG is not built for <strong>' + escapeHtml(resp.unsupported) + '</strong> — this view is honest about the languages it cannot analyse.</div>';
1219
+ return;
1220
+ }
1221
+ const cfg = resp.cfg, layout = resp.layout;
1222
+ const pos = {};
1223
+ layout.nodes.forEach(n => { pos[n.id] = n; });
1224
+ const CW = 180, CH = 60;
1225
+ const cx = (id) => pos[id] ? pos[id].x + CW / 2 : 0;
1226
+ const top = (id) => pos[id] ? pos[id].y : 0;
1227
+ const bot = (id) => pos[id] ? pos[id].y + CH : 0;
1228
+ const line = (x1, y1, x2, y2, cls) => '<path class="' + cls + '" d="M' + x1 + ' ' + y1 + ' L' + x2 + ' ' + y2 + '"></path>';
1229
+
1230
+ let edges = '';
1231
+ (cfg.edges || []).forEach(e => { edges += line(cx(e.from), bot(e.from), cx(e.to), top(e.to), 'cfg-edge-flow'); });
1232
+ (cfg.dataDeps || []).forEach(d => { edges += line(cx(d.from) + 12, top(d.from), cx(d.to) + 12, bot(d.to), 'cfg-edge-data'); });
1233
+ (cfg.ctrlDeps || []).forEach(c => { edges += line(cx(c.ctrl) - 12, top(c.ctrl), cx(c.dep) - 12, bot(c.dep), 'cfg-edge-ctrl'); });
1234
+
1235
+ let blocks = '';
1236
+ layout.nodes.forEach(n => {
1237
+ const cls = 'cfg-block' + (n.id === cfg.entry ? ' entry' : '') + (n.id === cfg.exit ? ' exitb' : '') + (n.unreachable ? ' unreachable' : '');
1238
+ const lbl = n.label ? escapeHtml(n.label) : (n.id === cfg.entry ? 'ENTRY' : n.id === cfg.exit ? 'EXIT' : 'B' + n.id);
1239
+ blocks += '<g class="' + cls + '" transform="translate(' + n.x + ',' + n.y + ')">'
1240
+ + '<rect width="' + CW + '" height="' + CH + '" rx="8"></rect>'
1241
+ + '<text class="cfg-line" x="10" y="18">' + (n.line ? ('L' + n.line) : ('#' + n.id)) + '</text>'
1242
+ + '<text class="cfg-label" x="10" y="38">' + lbl.slice(0, 26) + '</text>'
1243
+ + '</g>';
1244
+ });
1245
+
1246
+ const defs = '<defs>'
1247
+ + '<marker id="cfg-arrow-flow" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0 0 L7 3 L0 6 z" fill="var(--ink-1)"></path></marker>'
1248
+ + '<marker id="cfg-arrow-data" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0 0 L7 3 L0 6 z" fill="var(--accent)"></path></marker>'
1249
+ + '<marker id="cfg-arrow-ctrl" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0 0 L7 3 L0 6 z" fill="var(--warn)"></path></marker>'
1250
+ + '</defs>';
1251
+ const W = Math.max(layout.width, CW) + 40, H = Math.max(layout.height, CH) + 40;
1252
+ svg.setAttribute('viewBox', '-20 -20 ' + W + ' ' + H);
1253
+ svg.innerHTML = defs + edges + blocks;
1254
+
1255
+ notice.innerHTML = '<div style="display:flex; justify-content:space-between; align-items:center; gap:16px; flex-wrap:wrap;">'
1256
+ + '<span style="font-family:var(--font-ui); color:var(--ink-0); font-weight:700;">CFG · ' + escapeHtml(symbol) + '() <span style="color:var(--ink-2); font-weight:400;">' + escapeHtml(file) + '</span></span>'
1257
+ + '<span class="cfg-legend">'
1258
+ + '<span><span class="swatch" style="border-top-color:var(--ink-1);"></span>control flow</span>'
1259
+ + '<span><span class="swatch" style="border-top-color:var(--accent);"></span>data dep</span>'
1260
+ + '<span><span class="swatch" style="border-top-color:var(--warn); border-top-style:dashed;"></span>control dep</span>'
1261
+ + '<span style="color:var(--ink-2);">' + layout.nodes.length + ' blocks</span>'
1262
+ + '</span></div>';
1263
+ }
1264
+
1123
1265
  document.getElementById('searchInput').addEventListener('input', (e) => {
1124
1266
  const q = e.target.value.toLowerCase();
1125
1267
  const filtered = allSpecs.filter(s => s.id.toLowerCase().includes(q) || (s.title && s.title.toLowerCase().includes(q)));
@@ -0,0 +1,47 @@
1
+ /** The six pipeline stages, in traversal order — the columns of the matrix. */
2
+ export declare const STAGES: readonly ["REQ", "H-SPEC", "A-SPEC", "T-SPEC", "File", "AST Symbol"];
3
+ /** One pipeline chain row from /api/rtm/heatmap (only the fields the matrix reads). */
4
+ export interface PipelineRow {
5
+ reqId: string;
6
+ reqTitle?: string;
7
+ stagesComplete: number;
8
+ }
9
+ export interface StageMatrix {
10
+ reqs: {
11
+ id: string;
12
+ title: string;
13
+ }[];
14
+ stages: string[];
15
+ /** values[r][s] ∈ [0,1] = fraction of REQ r's chains that reached stage s. */
16
+ values: number[][];
17
+ }
18
+ /**
19
+ * @implements A-SPEC-545.1
20
+ * Pure: turn per-chain pipeline rows into a REQ × stage coverage matrix. Rows are REQs (first-seen
21
+ * order), columns the six stages; each cell is the fraction of that REQ's chains that REACHED the
22
+ * stage (stagesComplete >= s+1). This is the overview the 2D heatmap paints — magnitude per cell.
23
+ */
24
+ export declare function buildStageMatrix(pipelines: PipelineRow[]): StageMatrix;
25
+ /**
26
+ * @implements A-SPEC-545.1
27
+ * Pure & deterministic: a row permutation that clusters similar, high-coverage rows together
28
+ * (Behrisch-style seriation). Key: descending row-mean, then the profile vector descending, then the
29
+ * original index (stable). Same input ⇒ same output.
30
+ */
31
+ /**
32
+ * @implements A-SPEC-545.2
33
+ * Pure: map a coverage fraction v∈[0,1] to a sequential-ramp bucket index 0..5. Monotonic
34
+ * non-decreasing, clamped ([0,1]), NaN→0. The dashboard paints cell background = var(--ramp-<bucket>).
35
+ */
36
+ export declare function coverageBucket(v: number): number;
37
+ export interface HeatmapMatrix extends StageMatrix {
38
+ order: number[];
39
+ buckets: number[][];
40
+ }
41
+ /**
42
+ * @implements A-SPEC-545.2
43
+ * Pure server-side payload builder: buildStageMatrix + seriateRows + coverageBucket in one object,
44
+ * so the client render is a thin paint over data.matrix.
45
+ */
46
+ export declare function heatmapMatrix(pipelines: PipelineRow[]): HeatmapMatrix;
47
+ export declare function seriateRows(values: number[][]): number[];
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ // @implements A-SPEC-545.1
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.STAGES = void 0;
5
+ exports.buildStageMatrix = buildStageMatrix;
6
+ exports.coverageBucket = coverageBucket;
7
+ exports.heatmapMatrix = heatmapMatrix;
8
+ exports.seriateRows = seriateRows;
9
+ /** The six pipeline stages, in traversal order — the columns of the matrix. */
10
+ exports.STAGES = ['REQ', 'H-SPEC', 'A-SPEC', 'T-SPEC', 'File', 'AST Symbol'];
11
+ /**
12
+ * @implements A-SPEC-545.1
13
+ * Pure: turn per-chain pipeline rows into a REQ × stage coverage matrix. Rows are REQs (first-seen
14
+ * order), columns the six stages; each cell is the fraction of that REQ's chains that REACHED the
15
+ * stage (stagesComplete >= s+1). This is the overview the 2D heatmap paints — magnitude per cell.
16
+ */
17
+ function buildStageMatrix(pipelines) {
18
+ const order = [];
19
+ const groups = new Map();
20
+ for (const p of pipelines) {
21
+ let g = groups.get(p.reqId);
22
+ if (!g) {
23
+ g = { title: p.reqTitle ?? '', chains: [] };
24
+ groups.set(p.reqId, g);
25
+ order.push(p.reqId);
26
+ }
27
+ g.chains.push(p.stagesComplete);
28
+ }
29
+ const reqs = order.map((id) => ({ id, title: groups.get(id).title }));
30
+ const values = order.map((id) => {
31
+ const chains = groups.get(id).chains;
32
+ return exports.STAGES.map((_, s) => {
33
+ const reached = chains.filter((c) => c >= s + 1).length;
34
+ return Number((reached / chains.length).toFixed(3));
35
+ });
36
+ });
37
+ return { reqs, stages: [...exports.STAGES], values };
38
+ }
39
+ /**
40
+ * @implements A-SPEC-545.1
41
+ * Pure & deterministic: a row permutation that clusters similar, high-coverage rows together
42
+ * (Behrisch-style seriation). Key: descending row-mean, then the profile vector descending, then the
43
+ * original index (stable). Same input ⇒ same output.
44
+ */
45
+ /**
46
+ * @implements A-SPEC-545.2
47
+ * Pure: map a coverage fraction v∈[0,1] to a sequential-ramp bucket index 0..5. Monotonic
48
+ * non-decreasing, clamped ([0,1]), NaN→0. The dashboard paints cell background = var(--ramp-<bucket>).
49
+ */
50
+ function coverageBucket(v) {
51
+ if (!Number.isFinite(v) || v <= 0)
52
+ return 0; // NaN / -Inf / ≤0 → lowest
53
+ if (v >= 1)
54
+ return 5; // ≥1 → highest (upper clamp)
55
+ return Math.min(5, Math.floor(v * 6));
56
+ }
57
+ /**
58
+ * @implements A-SPEC-545.2
59
+ * Pure server-side payload builder: buildStageMatrix + seriateRows + coverageBucket in one object,
60
+ * so the client render is a thin paint over data.matrix.
61
+ */
62
+ function heatmapMatrix(pipelines) {
63
+ const { reqs, stages, values } = buildStageMatrix(pipelines);
64
+ const order = seriateRows(values);
65
+ const buckets = values.map((row) => row.map(coverageBucket));
66
+ return { reqs, stages, values, order, buckets };
67
+ }
68
+ function seriateRows(values) {
69
+ const mean = (row) => row.length ? row.reduce((a, b) => a + b, 0) / row.length : 0;
70
+ return values
71
+ .map((row, i) => ({ i, m: mean(row), row }))
72
+ .sort((a, b) => {
73
+ if (b.m !== a.m)
74
+ return b.m - a.m; // higher coverage first
75
+ for (let k = 0; k < Math.max(a.row.length, b.row.length); k++) {
76
+ const d = (b.row[k] ?? 0) - (a.row[k] ?? 0); // then profile descending
77
+ if (d !== 0)
78
+ return d;
79
+ }
80
+ return a.i - b.i; // stable on ties
81
+ })
82
+ .map((x) => x.i);
83
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @implements A-SPEC-546.1
3
+ * Pure: the ADR references cited in `text` that exist in `known`, in appearance order (duplicates
4
+ * preserved, matching the prior cite behaviour). Recognises `ADR-` followed by 3-OR-MORE digits at a
5
+ * word boundary, so four-digit ADRs (ADR-1000+) are no longer invisible.
6
+ */
7
+ export declare function adrRefsIn(text: string, known: ReadonlySet<string>): string[];
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ // @implements A-SPEC-546.1
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.adrRefsIn = adrRefsIn;
5
+ /**
6
+ * @implements A-SPEC-546.1
7
+ * Pure: the ADR references cited in `text` that exist in `known`, in appearance order (duplicates
8
+ * preserved, matching the prior cite behaviour). Recognises `ADR-` followed by 3-OR-MORE digits at a
9
+ * word boundary, so four-digit ADRs (ADR-1000+) are no longer invisible.
10
+ */
11
+ function adrRefsIn(text, known) {
12
+ const out = [];
13
+ for (const m of text.matchAll(/\bADR-\d{3,}\b/g))
14
+ if (known.has(m[0]))
15
+ out.push(m[0]);
16
+ return out;
17
+ }
@@ -49,6 +49,7 @@ exports.cacheIsStale = cacheIsStale;
49
49
  // major.minor.patch — a full semver library would carry prerelease/build code this never runs), and
50
50
  // the registry query (A-SPEC-531.2) uses Node's built-in https.
51
51
  const path = __importStar(require("node:path"));
52
+ const npx_bin_1 = require("../project/npx-bin");
52
53
  exports.NPM_URL = 'https://www.npmjs.com/package/@holmes-lab/holmes-kit';
53
54
  const DEFAULT_TTL_MS = 24 * 3600_000;
54
55
  /** Parse `major.minor.patch` to a 3-tuple; a non-numeric field becomes 0 (never throws). */
@@ -97,10 +98,11 @@ function readCache(home, readFile) {
97
98
  function installModeGuide(mode, latest, current) {
98
99
  const head = `[Holmes-Kit] Update available: ${latest} (current ${current}).`;
99
100
  switch (mode) {
101
+ // @implements A-SPEC-544.1 — one command installs the latest AND re-pins every wired workspace,
102
+ // so the nudge no longer spells out `npm i` + `init --force`. win32 → npx.cmd (REQ-542).
100
103
  case 'global-npx':
101
- return `${head} Run: npm i -g @holmes-lab/holmes-kit@latest, then holmes-kit init --force (re-pins wiring; requires HOLMES_APPROVAL).`;
102
104
  case 'local-dep':
103
- return `${head} Run: npm i -D @holmes-lab/holmes-kit@latest`;
105
+ return `${head} Run: ${(0, npx_bin_1.npxBin)()} holmes-kit upgrade`;
104
106
  case 'source':
105
107
  return null;
106
108
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@implements A-SPEC-209",
3
3
  "name": "@holmes-lab/holmes-kit",
4
- "version": "0.11.0",
4
+ "version": "0.12.1",
5
5
  "description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
6
6
  "main": "dist/holmes/mcp/server.js",
7
7
  "types": "dist/holmes/mcp/server.d.ts",
@@ -35,6 +35,9 @@
35
35
  "spec-driven",
36
36
  "rtm",
37
37
  "cpg",
38
+ "cfg",
39
+ "pdg",
40
+ "dashboard",
38
41
  "traceability",
39
42
  "mcp",
40
43
  "guardrail"