/** * Phoenix Inspect — interactive intent pipeline visualisation. * * Collects the full provenance graph and serves it as a single-page * HTML app with an interactive Sankey-style flow: * * Spec Files → Clauses → Canonical Nodes → IUs → Generated Files * * Each node is clickable to expand detail. Edges show the causal chain. */ import { createServer } from 'node:http'; import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import type { Clause } from './models/clause.js'; import type { CanonicalNode } from './models/canonical.js'; import type { ImplementationUnit } from './models/iu.js'; import type { DriftReport, DriftEntry, GeneratedManifest, RegenMetadata } from './models/manifest.js'; import { DriftStatus } from './models/manifest.js'; // ─── Data model passed to the HTML renderer ────────────────────────────────── export interface InspectData { projectName: string; systemState: string; specFiles: SpecFileInfo[]; clauses: ClauseInfo[]; canonNodes: CanonNodeInfo[]; ius: IUInfo[]; generatedFiles: GenFileInfo[]; edges: Edge[]; stats: PipelineStats; } export interface SpecFileInfo { id: string; path: string; clauseCount: number; /** Raw content lines for the spec-text view */ lines?: string[]; } export interface ClauseInfo { id: string; docId: string; sectionPath: string; lineRange: string; preview: string; semhash: string; } export interface CanonNodeInfo { id: string; type: string; statement: string; tags: string[]; linkCount: number; confidence?: number; anchor?: string; parentId?: string; linkTypes?: Record; extractionMethod?: string; } export interface IUInfo { id: string; name: string; kind: string; riskTier: string; canonCount: number; outputFiles: string[]; evidenceRequired: string[]; description: string; invariants: string[]; regenMeta?: RegenMetadata; } export interface GenFileInfo { path: string; iuId: string; iuName: string; contentHash: string; size: number; driftStatus: string; } export interface Edge { from: string; to: string; type: 'spec→clause' | 'clause→canon' | 'canon→iu' | 'iu→file' | 'canon→canon' | 'canon→parent'; edgeType?: string; // typed edge for canon→canon } export interface PipelineStats { specFiles: number; clauses: number; canonNodes: number; canonByType: Record; ius: number; iusByRisk: Record; generatedFiles: number; totalSize: number; driftClean: number; driftDirty: number; edgeCount: number; } // ─── Data collection ───────────────────────────────────────────────────────── export function collectInspectData( projectName: string, systemState: string, clauses: Clause[], canonNodes: CanonicalNode[], ius: ImplementationUnit[], manifest: GeneratedManifest, driftReport: DriftReport | null, projectRoot?: string, ): InspectData { const edges: Edge[] = []; // Spec files const docMap = new Map(); for (const c of clauses) { const list = docMap.get(c.source_doc_id) ?? []; list.push(c); docMap.set(c.source_doc_id, list); } const specFiles: SpecFileInfo[] = [...docMap.entries()].map(([docId, docClauses]) => { let lines: string[] | undefined; if (projectRoot) { const fullPath = join(projectRoot, docId); if (existsSync(fullPath)) { lines = readFileSync(fullPath, 'utf8').split('\n'); } } return { id: `spec:${docId}`, path: docId, clauseCount: docClauses.length, lines }; }); // Clauses + spec→clause edges const clauseInfos: ClauseInfo[] = clauses.map(c => { edges.push({ from: `spec:${c.source_doc_id}`, to: `clause:${c.clause_id}`, type: 'spec→clause' }); return { id: c.clause_id, docId: c.source_doc_id, sectionPath: c.section_path.join(' > '), lineRange: `L${c.source_line_range[0]}–${c.source_line_range[1]}`, preview: c.normalized_text.slice(0, 120).replace(/\n/g, ' '), semhash: c.clause_semhash.slice(0, 12), }; }); // Canon nodes + clause→canon edges + canon→canon edges const canonInfos: CanonNodeInfo[] = canonNodes.map(n => { for (const clauseId of n.source_clause_ids) { edges.push({ from: `clause:${clauseId}`, to: `canon:${n.canon_id}`, type: 'clause→canon' }); } for (const linkedId of n.linked_canon_ids) { const edgeType = n.link_types?.[linkedId]; edges.push({ from: `canon:${n.canon_id}`, to: `canon:${linkedId}`, type: 'canon→canon', edgeType }); } if (n.parent_canon_id) { edges.push({ from: `canon:${n.parent_canon_id}`, to: `canon:${n.canon_id}`, type: 'canon→parent' }); } return { id: n.canon_id, type: n.type, statement: n.statement, tags: n.tags, linkCount: n.linked_canon_ids.length, confidence: n.confidence, anchor: n.canon_anchor?.slice(0, 12), parentId: n.parent_canon_id, linkTypes: n.link_types, extractionMethod: n.extraction_method, }; }); // IUs + canon→iu edges const iuInfos: IUInfo[] = ius.map(iu => { const iuManifest = manifest.iu_manifests[iu.iu_id]; for (const canonId of iu.source_canon_ids) { edges.push({ from: `canon:${canonId}`, to: `iu:${iu.iu_id}`, type: 'canon→iu' }); } return { id: iu.iu_id, name: iu.name, kind: iu.kind, riskTier: iu.risk_tier, canonCount: iu.source_canon_ids.length, outputFiles: iu.output_files, evidenceRequired: iu.evidence_policy.required, description: iu.contract.description, invariants: iu.contract.invariants, regenMeta: iuManifest?.regen_metadata, }; }); // Generated files + iu→file edges const driftMap = new Map(); if (driftReport) { for (const e of driftReport.entries) driftMap.set(e.file_path, e); } const genFiles: GenFileInfo[] = []; for (const iuM of Object.values(manifest.iu_manifests)) { for (const [fp, entry] of Object.entries(iuM.files)) { edges.push({ from: `iu:${iuM.iu_id}`, to: `file:${fp}`, type: 'iu→file' }); const drift = driftMap.get(fp); genFiles.push({ path: fp, iuId: iuM.iu_id, iuName: iuM.iu_name, contentHash: entry.content_hash.slice(0, 12), size: entry.size, driftStatus: drift?.status ?? 'UNKNOWN', }); } } // Stats const canonByType: Record = {}; for (const n of canonNodes) canonByType[n.type] = (canonByType[n.type] ?? 0) + 1; const iusByRisk: Record = {}; for (const iu of ius) iusByRisk[iu.risk_tier] = (iusByRisk[iu.risk_tier] ?? 0) + 1; return { projectName, systemState, specFiles, clauses: clauseInfos, canonNodes: canonInfos, ius: iuInfos, generatedFiles: genFiles, edges, stats: { specFiles: specFiles.length, clauses: clauses.length, canonNodes: canonNodes.length, canonByType, ius: ius.length, iusByRisk, generatedFiles: genFiles.length, totalSize: genFiles.reduce((s, f) => s + f.size, 0), driftClean: driftReport?.clean_count ?? 0, driftDirty: (driftReport?.drifted_count ?? 0) + (driftReport?.missing_count ?? 0), edgeCount: edges.length, }, }; } // ─── HTML renderer ─────────────────────────────────────────────────────────── export function renderInspectHTML(data: InspectData): string { const json = JSON.stringify(data); return ` Phoenix · ${esc(data.projectName)}

🔥 Phoenix

${esc(data.systemState)}
${data.stats.specFiles} specs
${data.stats.clauses} clauses
${data.stats.canonNodes} canon
${data.stats.ius} IUs
${data.stats.generatedFiles} files
${data.stats.driftDirty>0?`${data.stats.driftDirty} drift`:'clean'}
Click a highlighted line in the spec to trace its path through the pipeline

Provenance Graph

`; } function esc(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // ─── Server ────────────────────────────────────────────────────────────────── export function serveInspect( html: string, port: number, dataJson?: string, ): { server: ReturnType; port: number; ready: Promise } { const server = createServer((req, res) => { if (req.url === '/data.json') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(dataJson ?? '{}'); } else { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html); } }); let actualPort = port; const ready = new Promise(resolve => { server.listen(port, () => { const addr = server.address(); if (addr && typeof addr === 'object') actualPort = addr.port; result.port = actualPort; resolve(); }); }); const result = { server, port: actualPort, ready }; return result; }