Reference implementation for the Phoenix Architecture. Work in progress.
aicoding.leaflet.pub/
ai
coding
crazy
1/**
2 * Phoenix Inspect — interactive intent pipeline visualisation.
3 *
4 * Collects the full provenance graph and serves it as a single-page
5 * HTML app with an interactive Sankey-style flow:
6 *
7 * Spec Files → Clauses → Canonical Nodes → IUs → Generated Files
8 *
9 * Each node is clickable to expand detail. Edges show the causal chain.
10 */
11
12import { createServer } from 'node:http';
13import { readFileSync, existsSync } from 'node:fs';
14import { join } from 'node:path';
15import type { Clause } from './models/clause.js';
16import type { CanonicalNode } from './models/canonical.js';
17import type { ImplementationUnit } from './models/iu.js';
18import type { DriftReport, DriftEntry, GeneratedManifest, RegenMetadata } from './models/manifest.js';
19import { DriftStatus } from './models/manifest.js';
20
21// ─── Data model passed to the HTML renderer ──────────────────────────────────
22
23export interface InspectData {
24 projectName: string;
25 systemState: string;
26 specFiles: SpecFileInfo[];
27 clauses: ClauseInfo[];
28 canonNodes: CanonNodeInfo[];
29 ius: IUInfo[];
30 generatedFiles: GenFileInfo[];
31 edges: Edge[];
32 stats: PipelineStats;
33}
34
35export interface SpecFileInfo {
36 id: string;
37 path: string;
38 clauseCount: number;
39 /** Raw content lines for the spec-text view */
40 lines?: string[];
41}
42
43export interface ClauseInfo {
44 id: string;
45 docId: string;
46 sectionPath: string;
47 lineRange: string;
48 preview: string;
49 semhash: string;
50}
51
52export interface CanonNodeInfo {
53 id: string;
54 type: string;
55 statement: string;
56 tags: string[];
57 linkCount: number;
58 confidence?: number;
59 anchor?: string;
60 parentId?: string;
61 linkTypes?: Record<string, string>;
62 extractionMethod?: string;
63}
64
65export interface IUInfo {
66 id: string;
67 name: string;
68 kind: string;
69 riskTier: string;
70 canonCount: number;
71 outputFiles: string[];
72 evidenceRequired: string[];
73 description: string;
74 invariants: string[];
75 regenMeta?: RegenMetadata;
76}
77
78export interface GenFileInfo {
79 path: string;
80 iuId: string;
81 iuName: string;
82 contentHash: string;
83 size: number;
84 driftStatus: string;
85}
86
87export interface Edge {
88 from: string;
89 to: string;
90 type: 'spec→clause' | 'clause→canon' | 'canon→iu' | 'iu→file' | 'canon→canon' | 'canon→parent';
91 edgeType?: string; // typed edge for canon→canon
92}
93
94export interface PipelineStats {
95 specFiles: number;
96 clauses: number;
97 canonNodes: number;
98 canonByType: Record<string, number>;
99 ius: number;
100 iusByRisk: Record<string, number>;
101 generatedFiles: number;
102 totalSize: number;
103 driftClean: number;
104 driftDirty: number;
105 edgeCount: number;
106}
107
108// ─── Data collection ─────────────────────────────────────────────────────────
109
110export function collectInspectData(
111 projectName: string,
112 systemState: string,
113 clauses: Clause[],
114 canonNodes: CanonicalNode[],
115 ius: ImplementationUnit[],
116 manifest: GeneratedManifest,
117 driftReport: DriftReport | null,
118 projectRoot?: string,
119): InspectData {
120 const edges: Edge[] = [];
121
122 // Spec files
123 const docMap = new Map<string, Clause[]>();
124 for (const c of clauses) {
125 const list = docMap.get(c.source_doc_id) ?? [];
126 list.push(c);
127 docMap.set(c.source_doc_id, list);
128 }
129 const specFiles: SpecFileInfo[] = [...docMap.entries()].map(([docId, docClauses]) => {
130 let lines: string[] | undefined;
131 if (projectRoot) {
132 const fullPath = join(projectRoot, docId);
133 if (existsSync(fullPath)) {
134 lines = readFileSync(fullPath, 'utf8').split('\n');
135 }
136 }
137 return { id: `spec:${docId}`, path: docId, clauseCount: docClauses.length, lines };
138 });
139
140 // Clauses + spec→clause edges
141 const clauseInfos: ClauseInfo[] = clauses.map(c => {
142 edges.push({ from: `spec:${c.source_doc_id}`, to: `clause:${c.clause_id}`, type: 'spec→clause' });
143 return {
144 id: c.clause_id,
145 docId: c.source_doc_id,
146 sectionPath: c.section_path.join(' > '),
147 lineRange: `L${c.source_line_range[0]}–${c.source_line_range[1]}`,
148 preview: c.normalized_text.slice(0, 120).replace(/\n/g, ' '),
149 semhash: c.clause_semhash.slice(0, 12),
150 };
151 });
152
153 // Canon nodes + clause→canon edges + canon→canon edges
154 const canonInfos: CanonNodeInfo[] = canonNodes.map(n => {
155 for (const clauseId of n.source_clause_ids) {
156 edges.push({ from: `clause:${clauseId}`, to: `canon:${n.canon_id}`, type: 'clause→canon' });
157 }
158 for (const linkedId of n.linked_canon_ids) {
159 const edgeType = n.link_types?.[linkedId];
160 edges.push({ from: `canon:${n.canon_id}`, to: `canon:${linkedId}`, type: 'canon→canon', edgeType });
161 }
162 if (n.parent_canon_id) {
163 edges.push({ from: `canon:${n.parent_canon_id}`, to: `canon:${n.canon_id}`, type: 'canon→parent' });
164 }
165 return {
166 id: n.canon_id,
167 type: n.type,
168 statement: n.statement,
169 tags: n.tags,
170 linkCount: n.linked_canon_ids.length,
171 confidence: n.confidence,
172 anchor: n.canon_anchor?.slice(0, 12),
173 parentId: n.parent_canon_id,
174 linkTypes: n.link_types,
175 extractionMethod: n.extraction_method,
176 };
177 });
178
179 // IUs + canon→iu edges
180 const iuInfos: IUInfo[] = ius.map(iu => {
181 const iuManifest = manifest.iu_manifests[iu.iu_id];
182 for (const canonId of iu.source_canon_ids) {
183 edges.push({ from: `canon:${canonId}`, to: `iu:${iu.iu_id}`, type: 'canon→iu' });
184 }
185 return {
186 id: iu.iu_id,
187 name: iu.name,
188 kind: iu.kind,
189 riskTier: iu.risk_tier,
190 canonCount: iu.source_canon_ids.length,
191 outputFiles: iu.output_files,
192 evidenceRequired: iu.evidence_policy.required,
193 description: iu.contract.description,
194 invariants: iu.contract.invariants,
195 regenMeta: iuManifest?.regen_metadata,
196 };
197 });
198
199 // Generated files + iu→file edges
200 const driftMap = new Map<string, DriftEntry>();
201 if (driftReport) {
202 for (const e of driftReport.entries) driftMap.set(e.file_path, e);
203 }
204 const genFiles: GenFileInfo[] = [];
205 for (const iuM of Object.values(manifest.iu_manifests)) {
206 for (const [fp, entry] of Object.entries(iuM.files)) {
207 edges.push({ from: `iu:${iuM.iu_id}`, to: `file:${fp}`, type: 'iu→file' });
208 const drift = driftMap.get(fp);
209 genFiles.push({
210 path: fp,
211 iuId: iuM.iu_id,
212 iuName: iuM.iu_name,
213 contentHash: entry.content_hash.slice(0, 12),
214 size: entry.size,
215 driftStatus: drift?.status ?? 'UNKNOWN',
216 });
217 }
218 }
219
220 // Stats
221 const canonByType: Record<string, number> = {};
222 for (const n of canonNodes) canonByType[n.type] = (canonByType[n.type] ?? 0) + 1;
223 const iusByRisk: Record<string, number> = {};
224 for (const iu of ius) iusByRisk[iu.risk_tier] = (iusByRisk[iu.risk_tier] ?? 0) + 1;
225
226 return {
227 projectName,
228 systemState,
229 specFiles,
230 clauses: clauseInfos,
231 canonNodes: canonInfos,
232 ius: iuInfos,
233 generatedFiles: genFiles,
234 edges,
235 stats: {
236 specFiles: specFiles.length,
237 clauses: clauses.length,
238 canonNodes: canonNodes.length,
239 canonByType,
240 ius: ius.length,
241 iusByRisk,
242 generatedFiles: genFiles.length,
243 totalSize: genFiles.reduce((s, f) => s + f.size, 0),
244 driftClean: driftReport?.clean_count ?? 0,
245 driftDirty: (driftReport?.drifted_count ?? 0) + (driftReport?.missing_count ?? 0),
246 edgeCount: edges.length,
247 },
248 };
249}
250
251// ─── HTML renderer ───────────────────────────────────────────────────────────
252
253export function renderInspectHTML(data: InspectData): string {
254 const json = JSON.stringify(data);
255 return `<!DOCTYPE html>
256<html lang="en">
257<head>
258<meta charset="utf-8">
259<meta name="viewport" content="width=device-width,initial-scale=1">
260<title>Phoenix · ${esc(data.projectName)}</title>
261<style>
262:root{--bg:#0f1117;--surface:#1a1d27;--surface2:#232730;--border:#2e3345;--text:#e1e4ed;--dim:#7a8194;--blue:#5b9cf4;--green:#4ade80;--yellow:#fbbf24;--orange:#fb923c;--red:#f87171;--purple:#a78bfa;--cyan:#22d3ee;--font:'SF Mono','Fira Code','JetBrains Mono',monospace}
263*{margin:0;padding:0;box-sizing:border-box}
264body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:13px;line-height:1.6;overflow:hidden;height:100vh}
265.header{background:var(--surface);border-bottom:1px solid var(--border);padding:12px 24px;display:flex;align-items:center;gap:16px;z-index:100}
266.header h1{font-size:18px;font-weight:700;color:var(--blue)}
267.header .state{font-size:11px;padding:3px 8px;border-radius:4px;background:var(--surface2);color:var(--yellow);border:1px solid var(--border)}
268.header .stats{margin-left:auto;display:flex;gap:16px;font-size:11px;color:var(--dim)}
269.header .stats b{color:var(--text);font-weight:600}
270.mode-btns{display:flex;gap:4px}
271.mode-btn{background:var(--surface2);border:1px solid var(--border);color:var(--dim);padding:4px 12px;border-radius:4px;cursor:pointer;font:inherit;font-size:11px}
272.mode-btn:hover{border-color:var(--blue);color:var(--text)}
273.mode-btn.active{background:var(--blue);color:#fff;border-color:var(--blue)}
274
275/* ── Pipeline columns ── */
276.pipeline-wrap{display:flex;height:calc(100vh - 52px);position:relative}
277.pipeline{display:flex;flex:1;overflow:hidden}
278.column{flex:1;min-width:0;border-right:1px solid var(--border);display:flex;flex-direction:column}
279.column:last-child{border-right:none}
280.col-header{padding:8px 12px;background:var(--surface);border-bottom:1px solid var(--border);font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:var(--dim);display:flex;justify-content:space-between}
281.col-header .ct{color:var(--blue)}
282.col-body{flex:1;overflow-y:auto;padding:6px}
283.card{background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin-bottom:4px;cursor:pointer;transition:all .15s;position:relative}
284.card:hover{border-color:var(--blue);background:var(--surface2)}
285.card.hl{border-color:var(--cyan);background:#142535;box-shadow:0 0 8px rgba(34,211,238,.2)}
286.card.sel{border-color:var(--cyan);background:#1a3040;box-shadow:0 0 16px rgba(34,211,238,.35);ring:2px solid var(--cyan)}
287.card.hide{display:none}
288.card .t{font-size:11px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
289.card .s{font-size:9px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}
290.badge{display:inline-block;font-size:8px;font-weight:700;padding:1px 5px;border-radius:3px;text-transform:uppercase;letter-spacing:.5px;vertical-align:middle}
291.b-req{background:#1e3a5f;color:var(--blue)}.b-con{background:#3b1e1e;color:var(--red)}.b-inv{background:#2d1e3f;color:var(--purple)}.b-def{background:#1e2d1e;color:var(--green)}.b-ctx{background:#2d2d1e;color:var(--yellow)}
292.b-low{background:#1e2d1e;color:var(--green)}.b-medium{background:#2d2a1e;color:var(--yellow)}.b-high{background:#2d1e1e;color:var(--orange)}.b-critical{background:#3b1e1e;color:var(--red)}
293.b-clean{background:#1e2d1e;color:var(--green)}.b-drifted{background:#3b1e1e;color:var(--red)}.b-missing{background:#2d1e1e;color:var(--orange)}.b-unknown{background:var(--surface2);color:var(--dim)}
294.tag{display:inline-block;font-size:8px;padding:1px 4px;border-radius:2px;background:var(--surface2);color:var(--dim);margin:1px}
295
296/* ── SVG overlay for connection lines ── */
297svg.lines{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:10}
298svg.lines path{fill:none;stroke:var(--cyan);stroke-width:1.5;opacity:.6}
299svg.lines path.strong{stroke-width:2.5;opacity:1;filter:drop-shadow(0 0 4px rgba(34,211,238,.5))}
300
301/* ── Graph overlay ── */
302.graph-overlay{position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:500;display:none;flex-direction:column}
303.graph-overlay.open{display:flex}
304.graph-bar{padding:12px 24px;display:flex;align-items:center;gap:16px;background:var(--surface);border-bottom:1px solid var(--border)}
305.graph-bar h2{font-size:15px;color:var(--cyan)}
306.graph-bar .close{background:none;border:1px solid var(--border);color:var(--dim);padding:4px 12px;border-radius:4px;cursor:pointer;font:inherit;font-size:11px;margin-left:auto}
307.graph-bar .close:hover{border-color:var(--red);color:var(--red)}
308.graph-body{flex:1;overflow:auto;display:flex;justify-content:center;padding:40px}
309.graph-canvas{position:relative}
310.gn{position:absolute;background:var(--surface);border:2px solid var(--border);border-radius:8px;padding:10px 14px;font-size:11px;max-width:220px;cursor:default;z-index:2;transition:border-color .15s}
311.gn:hover{border-color:var(--blue)}
312.gn.gn-sel{border-color:var(--cyan);box-shadow:0 0 16px rgba(34,211,238,.4)}
313.gn .gn-label{font-size:9px;text-transform:uppercase;color:var(--dim);letter-spacing:.5px;margin-bottom:3px}
314.gn .gn-text{font-weight:600;color:var(--text);word-break:break-word}
315svg.graph-edges{position:absolute;top:0;left:0;pointer-events:none;z-index:1}
316svg.graph-edges path{fill:none;stroke:var(--cyan);stroke-width:2;opacity:.5}
317svg.graph-edges path.primary{stroke-width:3;opacity:.9;filter:drop-shadow(0 0 4px rgba(34,211,238,.4))}
318.graph-hint{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);font-size:11px;color:var(--dim);text-align:center}
319
320/* ── Spec Text View ── */
321.spec-view{display:none;height:calc(100vh - 52px);overflow:hidden}
322.spec-view.open{display:flex}
323.spec-left{width:50%;overflow-y:auto;border-right:1px solid var(--border);padding:0}
324.spec-right{width:50%;overflow-y:auto;padding:16px}
325.spec-file-tab{padding:8px 16px;background:var(--surface);border-bottom:1px solid var(--border);font-weight:700;font-size:12px;color:var(--blue);position:sticky;top:0;z-index:5}
326.spec-line{padding:2px 16px 2px 50px;position:relative;font-size:13px;line-height:1.7;cursor:default;border-left:3px solid transparent;transition:all .1s}
327.spec-line:hover{background:var(--surface2)}
328.spec-line .ln{position:absolute;left:0;width:42px;text-align:right;color:var(--dim);font-size:11px;user-select:none}
329.spec-line.has-clause{cursor:pointer;border-left-color:var(--blue)}
330.spec-line.has-clause:hover{border-left-color:var(--cyan);background:#142535}
331.spec-line.active{border-left-color:var(--cyan);background:#1a3040;box-shadow:inset 0 0 20px rgba(34,211,238,.08)}
332.spec-line .heading{color:var(--blue);font-weight:700}
333.spec-line .bullet{color:var(--dim)}
334.trace-panel{background:var(--surface);border-radius:8px;border:1px solid var(--border);margin-bottom:12px;overflow:hidden}
335.trace-header{padding:10px 14px;border-bottom:1px solid var(--border);font-weight:700;font-size:12px;display:flex;justify-content:space-between;align-items:center}
336.trace-header .label{color:var(--dim);font-size:10px;text-transform:uppercase;letter-spacing:.5px}
337.trace-body{padding:10px 14px}
338.trace-item{padding:6px 0;border-bottom:1px solid var(--border);font-size:12px}
339.trace-item:last-child{border-bottom:none}
340.trace-item .ti-type{font-weight:600;margin-right:6px}
341.trace-item .ti-stmt{color:var(--text)}
342.trace-item .ti-tags{margin-top:3px}
343.trace-empty{text-align:center;padding:40px;color:var(--dim)}
344</style>
345</head>
346<body>
347<div class="header">
348 <h1>🔥 Phoenix</h1>
349 <div class="state">${esc(data.systemState)}</div>
350 <div class="mode-btns">
351 <button class="mode-btn" onclick="setMode('spec')" id="btn-spec">📄 Spec</button>
352 <button class="mode-btn active" onclick="setMode('all')" id="btn-all">All</button>
353 <button class="mode-btn" onclick="setMode('focus')" id="btn-focus">Focus</button>
354 <button class="mode-btn" onclick="openGraph()" id="btn-graph">⬡ Graph</button>
355 </div>
356 <div class="stats">
357 <div><b>${data.stats.specFiles}</b> specs</div>
358 <div><b>${data.stats.clauses}</b> clauses</div>
359 <div><b>${data.stats.canonNodes}</b> canon</div>
360 <div><b>${data.stats.ius}</b> IUs</div>
361 <div><b>${data.stats.generatedFiles}</b> files</div>
362 <div>${data.stats.driftDirty>0?`<b style="color:var(--red)">${data.stats.driftDirty} drift</b>`:'<b style="color:var(--green)">clean</b>'}</div>
363 </div>
364</div>
365<div class="pipeline-wrap">
366 <svg class="lines" id="svg-lines"></svg>
367 <div class="pipeline" id="pipeline"></div>
368</div>
369<div class="spec-view" id="spec-view">
370 <div class="spec-left" id="spec-text"></div>
371 <div class="spec-right" id="spec-trace">
372 <div class="trace-empty">Click a highlighted line in the spec to trace its path through the pipeline</div>
373 </div>
374</div>
375<div class="graph-overlay" id="graph-overlay">
376 <div class="graph-bar">
377 <h2 id="graph-title">Provenance Graph</h2>
378 <button class="close" onclick="closeGraph()">✕ Close</button>
379 </div>
380 <div class="graph-body"><div class="graph-canvas" id="graph-canvas"></div></div>
381</div>
382
383<script>
384const D=${json};
385const COL_ORDER=['spec','clause','canon','iu','file'];
386const COL_ICON={spec:'📄',clause:'📋',canon:'📐',iu:'📦',file:'⚡'};
387
388// indices
389const fwd={},bwd={};
390D.edges.forEach(e=>{(fwd[e.from]=fwd[e.from]||[]).push(e.to);(bwd[e.to]=bwd[e.to]||[]).push(e.from)});
391const items={};
392D.specFiles.forEach(s=>items['spec:'+s.path]={col:'spec',d:s});
393D.clauses.forEach(c=>items['clause:'+c.id]={col:'clause',d:c});
394D.canonNodes.forEach(n=>items['canon:'+n.id]={col:'canon',d:n});
395D.ius.forEach(u=>items['iu:'+u.id]={col:'iu',d:u});
396D.generatedFiles.forEach(f=>items['file:'+f.path]={col:'file',d:f});
397
398function E(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
399
400// ── Traversal (skip canon↔canon to keep pipeline linear) ──
401function getConnected(id){
402 const set=new Set([id]);
403 const q=[id];
404 while(q.length){const n=q.shift();
405 for(const t of(fwd[n]||[])){if(!set.has(t)&&!(n.startsWith('canon:')&&t.startsWith('canon:'))){set.add(t);q.push(t)}}
406 for(const t of(bwd[n]||[])){if(!set.has(t)&&!(n.startsWith('canon:')&&t.startsWith('canon:'))){set.add(t);q.push(t)}}
407 }
408 return set;
409}
410
411// ── Card HTML ──
412function nodeTitle(id){const it=items[id];if(!it)return id;const d=it.d;
413 if(it.col==='spec')return E(d.path.split('/').pop());
414 if(it.col==='clause')return E(d.sectionPath);
415 if(it.col==='canon'){const tc=d.type==='CONTEXT'?'ctx':d.type==='CONSTRAINT'?'con':d.type==='REQUIREMENT'?'req':d.type==='INVARIANT'?'inv':'def';return'<span class="badge b-'+tc+'">'+d.type+'</span> '+E(d.statement.slice(0,55));}
416 if(it.col==='iu')return E(d.name)+' <span class="badge b-'+d.riskTier+'">'+d.riskTier+'</span>';
417 if(it.col==='file')return E(d.path.split('/').pop())+' <span class="badge b-'+d.driftStatus.toLowerCase()+'">'+d.driftStatus+'</span>';
418 return id;}
419function nodeSub(id){const it=items[id];if(!it)return'';const d=it.d;
420 if(it.col==='spec')return d.clauseCount+' clauses';
421 if(it.col==='clause')return d.lineRange+' · '+d.semhash+'…';
422 if(it.col==='canon'){let s=d.tags.slice(0,4).map(t=>'<span class="tag">'+E(t)+'</span>').join('');if(d.confidence!=null)s+=' <span class="tag">conf:'+d.confidence.toFixed(2)+'</span>';if(d.linkCount)s+=' · '+d.linkCount+' links';if(d.extractionMethod)s+=' · '+d.extractionMethod;return s;}
423 if(it.col==='iu')return d.canonCount+' nodes · '+d.outputFiles.length+' file(s)';
424 if(it.col==='file')return E(d.iuName)+' · '+(d.size/1024).toFixed(1)+'KB';
425 return'';}
426function crd(id){return'<div class="card" data-id="'+E(id)+'"><div class="t">'+nodeTitle(id)+'</div><div class="s">'+nodeSub(id)+'</div></div>';}
427
428// ── Render pipeline ──
429function render(){
430 const cols=[
431 {title:'Spec Files',col:'spec',ids:D.specFiles.map(s=>'spec:'+s.path)},
432 {title:'Clauses',col:'clause',ids:D.clauses.map(c=>'clause:'+c.id)},
433 {title:'Canonical Nodes',col:'canon',ids:D.canonNodes.map(n=>'canon:'+n.id)},
434 {title:'Implementation Units',col:'iu',ids:D.ius.map(u=>'iu:'+u.id)},
435 {title:'Generated Files',col:'file',ids:D.generatedFiles.map(f=>'file:'+f.path)},
436 ];
437 document.getElementById('pipeline').innerHTML=cols.map(c=>
438 '<div class="column" data-col="'+c.col+'"><div class="col-header"><span>'+c.title+'</span><span class="ct">'+c.ids.length+'</span></div><div class="col-body">'+c.ids.map(crd).join('')+'</div></div>'
439 ).join('');
440 document.querySelectorAll('.card').forEach(el=>{el.addEventListener('click',()=>selectCard(el.dataset.id))});
441}
442
443// ── Mode + selection ──
444let mode='all',selId=null,connected=new Set();
445
446function setMode(m){
447 mode=m;
448 document.getElementById('btn-all').classList.toggle('active',m==='all');
449 document.getElementById('btn-focus').classList.toggle('active',m==='focus');
450 document.getElementById('btn-spec').classList.toggle('active',m==='spec');
451 // Toggle between pipeline and spec views
452 document.querySelector('.pipeline-wrap').style.display=m==='spec'?'none':'flex';
453 document.getElementById('spec-view').classList.toggle('open',m==='spec');
454 if(m==='spec')renderSpecView();
455 else applyView();
456}
457
458function selectCard(id){
459 selId=id;connected=getConnected(id);
460 if(mode==='all')setMode('focus');
461 else applyView();
462}
463
464function applyView(){
465 const cards=document.querySelectorAll('.card');
466 if(!selId||mode==='all'){
467 cards.forEach(el=>{el.classList.remove('hl','sel','hide')});
468 clearLines();return;
469 }
470 cards.forEach(el=>{
471 const cid=el.dataset.id;
472 el.classList.toggle('hl',connected.has(cid)&&cid!==selId);
473 el.classList.toggle('sel',cid===selId);
474 el.classList.toggle('hide',!connected.has(cid));
475 });
476 requestAnimationFrame(drawLines);
477}
478
479function deselect(){selId=null;connected.clear();setMode('all');}
480
481// ── SVG connection lines ──
482function clearLines(){document.getElementById('svg-lines').innerHTML='';}
483function drawLines(){
484 const svg=document.getElementById('svg-lines');svg.innerHTML='';
485 if(!selId)return;
486 const wrap=document.querySelector('.pipeline-wrap');
487 const wr=wrap.getBoundingClientRect();
488 // collect visible card rects
489 const rects={};
490 document.querySelectorAll('.card:not(.hide)').forEach(el=>{
491 const r=el.getBoundingClientRect();
492 rects[el.dataset.id]={x:r.left-wr.left,y:r.top-wr.top,w:r.width,h:r.height,cx:r.left-wr.left+r.width/2,cy:r.top-wr.top+r.height/2};
493 });
494 // draw edges between connected nodes that are both visible
495 const drawn=new Set();
496 for(const nid of connected){
497 for(const t of(fwd[nid]||[])){
498 if(!connected.has(t))continue;
499 if(nid.startsWith('canon:')&&t.startsWith('canon:'))continue;
500 const key=nid+'→'+t;if(drawn.has(key))continue;drawn.add(key);
501 const a=rects[nid],b=rects[t];if(!a||!b)continue;
502 const x1=a.x+a.w,y1=a.cy,x2=b.x,y2=b.cy;
503 const dx=(x2-x1)*0.45;
504 const strong=(nid===selId||t===selId)?'strong':'';
505 const path=document.createElementNS('http://www.w3.org/2000/svg','path');
506 path.setAttribute('d','M'+x1+','+y1+' C'+(x1+dx)+','+y1+' '+(x2-dx)+','+y2+' '+x2+','+y2);
507 path.setAttribute('class',strong);
508 svg.appendChild(path);
509 }
510 }
511}
512
513// ── Graph overlay ──
514function openGraph(){
515 if(!selId)return;
516 const overlay=document.getElementById('graph-overlay');
517 overlay.classList.add('open');
518 document.getElementById('graph-title').textContent=COL_ICON[items[selId]?.col||'spec']+' Provenance Graph — '+describeShort(selId);
519 renderGraph();
520}
521function closeGraph(){document.getElementById('graph-overlay').classList.remove('open');}
522
523function renderGraph(){
524 const canvas=document.getElementById('graph-canvas');
525 if(!selId){canvas.innerHTML='';return;}
526 // Organize connected nodes by column
527 const cols={};
528 for(const nid of connected){const it=items[nid];if(!it)continue;(cols[it.col]=cols[it.col]||[]).push(nid);}
529 // Layout: columns left→right, nodes top→bottom within column
530 const colX={};let cx=0;
531 const nodePos={};
532 const COL_W=240,COL_GAP=100,ROW_H=70,PAD=40;
533 for(const col of COL_ORDER){
534 if(!cols[col])continue;
535 colX[col]=cx;
536 cols[col].forEach((nid,i)=>{nodePos[nid]={x:cx,y:i*ROW_H}});
537 cx+=COL_W+COL_GAP;
538 }
539 const totalW=cx-COL_GAP+PAD*2;
540 const maxRows=Math.max(...COL_ORDER.map(c=>(cols[c]||[]).length));
541 const totalH=maxRows*ROW_H+PAD*2;
542 canvas.style.width=totalW+'px';canvas.style.height=totalH+'px';
543 let html='<svg class="graph-edges" width="'+totalW+'" height="'+totalH+'"></svg>';
544 // Nodes
545 for(const nid of connected){
546 const pos=nodePos[nid];if(!pos)continue;
547 const it=items[nid];
548 const isSel=nid===selId?'gn-sel':'';
549 html+='<div class="gn '+isSel+'" style="left:'+(pos.x+PAD)+'px;top:'+(pos.y+PAD)+'px;width:'+COL_W+'px">'
550 +'<div class="gn-label">'+COL_ICON[it.col]+' '+it.col+'</div>'
551 +'<div class="gn-text">'+nodeTitle(nid)+'</div></div>';
552 }
553 canvas.innerHTML=html;
554 // Draw edges
555 const svgEl=canvas.querySelector('svg.graph-edges');
556 const drawn2=new Set();
557 for(const nid of connected){
558 for(const t of(fwd[nid]||[])){
559 if(!connected.has(t))continue;
560 if(nid.startsWith('canon:')&&t.startsWith('canon:'))continue;
561 const key=nid+'→'+t;if(drawn2.has(key))continue;drawn2.add(key);
562 const a=nodePos[nid],b=nodePos[t];if(!a||!b)continue;
563 const x1=a.x+PAD+COL_W,y1=a.y+PAD+30,x2=b.x+PAD,y2=b.y+PAD+30;
564 const dx=(x2-x1)*0.4;
565 const cls=(nid===selId||t===selId)?'primary':'';
566 const p=document.createElementNS('http://www.w3.org/2000/svg','path');
567 p.setAttribute('d','M'+x1+','+y1+' C'+(x1+dx)+','+y1+' '+(x2-dx)+','+y2+' '+x2+','+y2);
568 p.setAttribute('class',cls);
569 svgEl.appendChild(p);
570 }
571 }
572}
573
574function describeShort(id){const it=items[id];if(!it)return id;const d=it.d;
575 if(it.col==='spec')return d.path;if(it.col==='clause')return d.sectionPath;
576 if(it.col==='canon')return d.statement.slice(0,50)+'…';
577 if(it.col==='iu')return d.name;if(it.col==='file')return d.path.split('/').pop();return id;}
578
579// ── Keys ──
580document.addEventListener('keydown',e=>{
581 if(e.key==='Escape'){if(document.getElementById('graph-overlay').classList.contains('open'))closeGraph();else deselect();}
582 if(e.key==='g'&&selId&&!document.getElementById('graph-overlay').classList.contains('open'))openGraph();
583});
584document.addEventListener('click',e=>{
585 if(!e.target.closest('.card')&&!e.target.closest('.graph-overlay')&&!e.target.closest('.header'))deselect();
586});
587window.addEventListener('resize',()=>{if(selId&&mode==='focus')requestAnimationFrame(drawLines)});
588
589// ── Spec Text View ──
590function renderSpecView(){
591 const container=document.getElementById('spec-text');
592 let html='';
593 // Build line→clause mapping
594 const lineClauseMap={};
595 D.clauses.forEach(cl=>{
596 const match=cl.lineRange.match(/L(\\d+)–(\\d+)/);
597 if(!match)return;
598 const start=parseInt(match[1]),end=parseInt(match[2]);
599 for(let i=start;i<=end;i++){
600 lineClauseMap[cl.docId+'::'+i]=cl;
601 }
602 });
603
604 D.specFiles.forEach(sf=>{
605 if(!sf.lines)return;
606 html+='<div class="spec-file-tab">'+E(sf.path)+'</div>';
607 sf.lines.forEach((line,i)=>{
608 const lineNum=i+1;
609 const cl=lineClauseMap[sf.path+'::'+lineNum];
610 const hasCl=!!cl;
611 const isHeading=/^#{1,6}\\s/.test(line);
612 const isBullet=/^\\s*[-*•]/.test(line);
613 const content=E(line)||' ';
614 const displayContent=isHeading?'<span class="heading">'+content+'</span>'
615 :isBullet?'<span class="bullet">- </span>'+content.replace(/^\\s*[-*•]\\s*/,'')
616 :content;
617 html+='<div class="spec-line'+(hasCl?' has-clause':'')+'" data-line="'+lineNum+'" data-doc="'+E(sf.path)+'"'
618 +(cl?' data-clause="'+cl.id+'"':'')
619 +'><span class="ln">'+lineNum+'</span>'+displayContent+'</div>';
620 });
621 });
622 container.innerHTML=html;
623
624 // Click handler for spec lines
625 container.querySelectorAll('.spec-line.has-clause').forEach(el=>{
626 el.addEventListener('click',()=>{
627 container.querySelectorAll('.spec-line.active').forEach(a=>a.classList.remove('active'));
628 // Highlight all lines in this clause's range
629 const clauseId=el.dataset.clause;
630 const clause=D.clauses.find(c=>c.id===clauseId);
631 if(!clause)return;
632 const match=clause.lineRange.match(/L(\\d+)–(\\d+)/);
633 if(!match)return;
634 const start=parseInt(match[1]),end=parseInt(match[2]);
635 for(let i=start;i<=end;i++){
636 const ln=container.querySelector('[data-line="'+i+'"][data-doc="'+el.dataset.doc+'"]');
637 if(ln)ln.classList.add('active');
638 }
639 showTrace(clauseId);
640 });
641 });
642}
643
644function showTrace(clauseId){
645 const panel=document.getElementById('spec-trace');
646 const clause=D.clauses.find(c=>c.id===clauseId);
647 if(!clause){panel.innerHTML='<div class="trace-empty">No clause data</div>';return;}
648
649 // Find canon nodes from this clause
650 const canonIds=new Set();
651 D.edges.filter(e=>e.from==='clause:'+clauseId&&e.type==='clause→canon').forEach(e=>canonIds.add(e.to.replace('canon:','')));
652 const canonNodes=D.canonNodes.filter(n=>canonIds.has(n.id));
653
654 // Find IUs from these canon nodes
655 const iuIds=new Set();
656 D.edges.filter(e=>e.type==='canon→iu'&&canonIds.has(e.from.replace('canon:',''))).forEach(e=>iuIds.add(e.to.replace('iu:','')));
657 const ius=D.ius.filter(u=>iuIds.has(u.id));
658
659 // Find generated files from these IUs
660 const fileIds=new Set();
661 D.edges.filter(e=>e.type==='iu→file'&&iuIds.has(e.from.replace('iu:',''))).forEach(e=>fileIds.add(e.to.replace('file:','')));
662 const files=D.generatedFiles.filter(f=>fileIds.has(f.path));
663
664 let html='';
665
666 // Clause info
667 html+='<div class="trace-panel"><div class="trace-header"><span>📋 Clause</span><span class="label">'+clause.lineRange+'</span></div>';
668 html+='<div class="trace-body"><div class="trace-item"><span class="ti-type" style="color:var(--blue)">'+E(clause.sectionPath)+'</span></div>';
669 html+='<div class="trace-item" style="color:var(--dim);font-size:11px">'+E(clause.preview)+'</div></div></div>';
670
671 // Canon nodes
672 if(canonNodes.length>0){
673 html+='<div class="trace-panel"><div class="trace-header"><span>📐 Canonical Nodes</span><span class="label">'+canonNodes.length+' nodes</span></div><div class="trace-body">';
674 const typeColors={REQUIREMENT:'var(--blue)',CONSTRAINT:'var(--red)',INVARIANT:'var(--purple)',DEFINITION:'var(--green)',CONTEXT:'var(--yellow)'};
675 canonNodes.forEach(n=>{
676 html+='<div class="trace-item"><span class="ti-type" style="color:'+(typeColors[n.type]||'var(--dim)')+'">'+n.type+'</span>';
677 html+='<span class="ti-stmt">'+E(n.statement.slice(0,100))+'</span>';
678 if(n.tags.length>0)html+='<div class="ti-tags">'+n.tags.slice(0,5).map(t=>'<span class="tag">'+E(t)+'</span>').join('')+'</div>';
679 html+='</div>';
680 });
681 html+='</div></div>';
682 }
683
684 // IUs
685 if(ius.length>0){
686 html+='<div class="trace-panel"><div class="trace-header"><span>📦 Implementation Units</span><span class="label">'+ius.length+' IUs</span></div><div class="trace-body">';
687 ius.forEach(u=>{
688 const riskColor={low:'var(--green)',medium:'var(--yellow)',high:'var(--orange)',critical:'var(--red)'}[u.riskTier]||'var(--dim)';
689 html+='<div class="trace-item"><span class="ti-type">'+E(u.name)+'</span> <span class="badge" style="background:color-mix(in srgb,'+riskColor+' 20%,transparent);color:'+riskColor+'">'+u.riskTier+'</span>';
690 html+='<div style="color:var(--dim);font-size:11px;margin-top:2px">'+u.canonCount+' canon nodes → '+u.outputFiles.length+' file(s)</div></div>';
691 });
692 html+='</div></div>';
693 }
694
695 // Files
696 if(files.length>0){
697 html+='<div class="trace-panel"><div class="trace-header"><span>⚡ Generated Files</span><span class="label">'+files.length+' files</span></div><div class="trace-body">';
698 files.forEach(f=>{
699 const driftColor=f.driftStatus==='CLEAN'?'var(--green)':f.driftStatus==='DRIFTED'?'var(--red)':'var(--dim)';
700 html+='<div class="trace-item"><span class="ti-type">'+E(f.path.split('/').pop())+'</span>';
701 html+=' <span class="badge" style="background:color-mix(in srgb,'+driftColor+' 20%,transparent);color:'+driftColor+'">'+f.driftStatus+'</span>';
702 html+='<div style="color:var(--dim);font-size:11px;margin-top:2px">'+(f.size/1024).toFixed(1)+'KB · '+f.contentHash+'</div></div>';
703 });
704 html+='</div></div>';
705 }
706
707 panel.innerHTML=html||'<div class="trace-empty">No traceability data for this clause</div>';
708}
709
710render();
711</script>
712</body>
713</html>`;
714}
715
716function esc(s: string): string {
717 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
718}
719
720// ─── Server ──────────────────────────────────────────────────────────────────
721
722export function serveInspect(
723 html: string,
724 port: number,
725 dataJson?: string,
726): { server: ReturnType<typeof createServer>; port: number; ready: Promise<void> } {
727 const server = createServer((req, res) => {
728 if (req.url === '/data.json') {
729 res.writeHead(200, { 'Content-Type': 'application/json' });
730 res.end(dataJson ?? '{}');
731 } else {
732 res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
733 res.end(html);
734 }
735 });
736
737 let actualPort = port;
738 const ready = new Promise<void>(resolve => {
739 server.listen(port, () => {
740 const addr = server.address();
741 if (addr && typeof addr === 'object') actualPort = addr.port;
742 result.port = actualPort;
743 resolve();
744 });
745 });
746
747 const result = { server, port: actualPort, ready };
748 return result;
749}