Reference implementation for the Phoenix Architecture. Work in progress. aicoding.leaflet.pub/
ai coding crazy
at main 749 lines 34 kB view raw
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')} 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)||'&nbsp;'; 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); 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}