/** * Phase 18.π — Guard dependency graph visualizer. * * Scans a project's source tree, resolves imports between modules, tags each * module with its architectural layer (per the active guard preset), and * emits: * * analyzeDependencyGraph(config, rootDir) → DependencyGraph * { * nodes: ModuleNode[] // one per source file inside srcDir * edges: ImportEdge[] // one per (source file → resolved * target file) import, with violation * flag + layer tagging * layers: Layer[] // layers active in this project, keyed * by hierarchy order * violations: Violation[] // full guard report violations * summary: { … counts … } * } * * And a single-file, self-contained HTML viewer: * * renderGraphHtml(graph) → string * - Inline SVG, layered layout (deterministic — no force-directed random * seeding, CI-stable). Nodes bucketed per layer, row = layer. * - Dark theme. No external CDN, no d3, no runtime deps. * - Click node → side panel shows importers + importees + file path. * - Filter bar: "show only violations" toggle + per-layer chips. * * Design spirit matches Phase 18.η bundle analyzer (`bundler/analyzer.ts`): * deterministic, zero runtime deps, portable single HTML file. * * @module guard/graph */ import { existsSync } from "fs"; import { relative, resolve, sep, isAbsolute, dirname, extname } from "path"; import type { GuardConfig, LayerDefinition, Violation, FileAnalysis, } from "./types"; import { WATCH_EXTENSIONS, DEFAULT_GUARD_CONFIG } from "./types"; import { analyzeFile, shouldAnalyzeFile, shouldIgnoreImport, } from "./analyzer"; import { validateFileAnalysis, detectCircularDependencies, } from "./validator"; import { getPreset } from "./presets"; // ════════════════════════════════════════════════════════════════════════════ // Public types // ════════════════════════════════════════════════════════════════════════════ /** * Single module (source file) in the dependency graph. */ export interface ModuleNode { /** Stable id — POSIX path relative to rootDir. */ id: string; /** Absolute filesystem path. */ filePath: string; /** Human-friendly label (basename w/o extension + parent dir). */ label: string; /** Resolved guard layer, or `null` if outside any layer. */ layer: string | null; /** FSD slice (second path segment after layer), if any. */ slice?: string; } /** * A single import edge `source → target`. Both endpoints are module ids * present in `nodes`. Unresolvable imports (node_modules, ambient types, * etc.) are dropped. */ export interface ImportEdge { /** Source node id. */ from: string; /** Target node id. */ to: string; /** Layer of source. */ fromLayer: string | null; /** Layer of target. */ toLayer: string | null; /** True if this edge corresponds to a guard violation. */ violation: boolean; /** Import statement line number (1-indexed). */ line: number; } /** * Layer bucket — a visual row in the graph. */ export interface Layer { /** Layer name (matches guard preset). */ name: string; /** Rank in the hierarchy (0 = top / outermost layer). */ rank: number; /** Human-friendly description. */ description?: string; /** Number of nodes in this layer. */ nodeCount: number; } /** * Summary counts displayed in the HTML header. */ export interface GraphSummary { /** Node count. */ nodes: number; /** Edge count (successfully resolved imports). */ edges: number; /** Number of violation edges. */ violationEdges: number; /** Total guard violations (includes circulars not mapped to a single edge). */ violations: number; /** Files analyzed (same as nodes, duplicated for symmetry with guard report). */ filesAnalyzed: number; /** Active guard preset name. */ preset: string; /** Source directory scanned. */ srcDir: string; /** ISO timestamp of generation. */ generatedAt: string; /** Schema version — bump when shape changes. */ version: number; } /** * Full dependency graph. */ export interface DependencyGraph { nodes: ModuleNode[]; edges: ImportEdge[]; layers: Layer[]; violations: Violation[]; summary: GraphSummary; } const GRAPH_SCHEMA_VERSION = 1; // ════════════════════════════════════════════════════════════════════════════ // Graph builder // ════════════════════════════════════════════════════════════════════════════ /** * Scan srcDir, build nodes + edges + layer tagging + violation overlay. * * Deterministic: sorts nodes / edges / layers for stable output across runs. * Pure (does not write to disk). */ export async function analyzeDependencyGraph( config: GuardConfig, rootDir: string ): Promise { const layers = resolveLayerDefinitions(config); const hierarchy = resolveHierarchy(config); const srcDir = config.srcDir ?? DEFAULT_GUARD_CONFIG.srcDir; const exclude = config.exclude ?? DEFAULT_GUARD_CONFIG.exclude; // 1. Discover files. Mirror watcher's scan logic (srcDir + optional app/). const files = await discoverFiles(rootDir, srcDir, config.fsRoutes ? "app" : null, exclude); // 2. Analyze each file — imports + layer. const analyses: FileAnalysis[] = []; for (const file of files) { if (!shouldAnalyzeFile(file, config, rootDir)) continue; try { const analysis = await analyzeFile(file, layers, rootDir); analyses.push(analysis); } catch { // Skip unreadable files — matches watcher behavior. } } // 3. Build node map keyed by POSIX relpath. const nodesById = new Map(); for (const a of analyses) { const id = toPosix(relative(rootDir, a.filePath)); nodesById.set(id, { id, filePath: a.filePath, label: computeLabel(id), layer: a.layer, slice: a.slice, }); } // 4. Resolve edges. For each import in each analysis, find the matching // file on disk (same extension set as WATCH_EXTENSIONS). Drop anything // unresolvable (external deps, ambient types). const edges: ImportEdge[] = []; const violations: Violation[] = []; for (const analysis of analyses) { const fromId = toPosix(relative(rootDir, analysis.filePath)); const fileViolations = validateFileAnalysis(analysis, layers, config); violations.push(...fileViolations); // Index violations by (line, path) so we can tag edges without re-running // the validator's rule logic. const violationKey = (line: number, importPath: string): string => `${line}::${importPath}`; const violationSet = new Set(); for (const v of fileViolations) { violationSet.add(violationKey(v.line, v.importPath)); } for (const imp of analysis.imports) { if (shouldIgnoreImport(imp.path, config)) continue; const targetAbs = resolveImportToFile( imp.path, analysis.filePath, rootDir, config, nodesById ); if (!targetAbs) continue; const toId = toPosix(relative(rootDir, targetAbs)); if (!nodesById.has(toId)) continue; if (toId === fromId) continue; // self-import (barrel re-export from same file) const fromNode = nodesById.get(fromId)!; const toNode = nodesById.get(toId)!; edges.push({ from: fromId, to: toId, fromLayer: fromNode.layer, toLayer: toNode.layer, violation: violationSet.has(violationKey(imp.line, imp.path)), line: imp.line, }); } } // 5. Circular dependencies are detected at the batch level — append to the // violations collection so the HTML counter is accurate. if (analyses.length > 0) { violations.push(...detectCircularDependencies(analyses, layers, config)); } // 6. Compute layer buckets. const layerCounts = new Map(); for (const node of nodesById.values()) { if (!node.layer) continue; layerCounts.set(node.layer, (layerCounts.get(node.layer) ?? 0) + 1); } const layerBuckets: Layer[] = []; const seen = new Set(); // First pass: layers explicitly in hierarchy (preserves order). for (let rank = 0; rank < hierarchy.length; rank++) { const name = hierarchy[rank]; if (layerCounts.has(name)) { const def = layers.find((l) => l.name === name); layerBuckets.push({ name, rank, description: def?.description, nodeCount: layerCounts.get(name) ?? 0, }); seen.add(name); } } // Second pass: layers present in nodes but not in hierarchy (custom / overrides). let extraRank = layerBuckets.length; for (const [name, count] of Array.from(layerCounts.entries()).sort()) { if (seen.has(name)) continue; const def = layers.find((l) => l.name === name); layerBuckets.push({ name, rank: extraRank++, description: def?.description, nodeCount: count, }); } // 7. Deterministic ordering. const sortedNodes = Array.from(nodesById.values()).sort((a, b) => a.id.localeCompare(b.id) ); edges.sort((a, b) => { if (a.from !== b.from) return a.from.localeCompare(b.from); if (a.to !== b.to) return a.to.localeCompare(b.to); return a.line - b.line; }); const violationEdges = edges.filter((e) => e.violation).length; return { nodes: sortedNodes, edges, layers: layerBuckets, violations, summary: { nodes: sortedNodes.length, edges: edges.length, violationEdges, violations: violations.length, filesAnalyzed: sortedNodes.length, preset: config.preset ?? "custom", srcDir, generatedAt: new Date().toISOString(), version: GRAPH_SCHEMA_VERSION, }, }; } // ════════════════════════════════════════════════════════════════════════════ // File discovery // ════════════════════════════════════════════════════════════════════════════ async function discoverFiles( rootDir: string, srcDir: string, extraRoot: string | null, exclude: string[] ): Promise { const { glob } = await import("glob"); const extensions = WATCH_EXTENSIONS.map((e) => e.slice(1)).join(","); const roots = new Set([srcDir]); if (extraRoot) roots.add(extraRoot); const found = new Set(); for (const root of roots) { const pattern = `${root}/**/*.{${extensions}}`; const hits = await glob(pattern, { cwd: rootDir, ignore: exclude, absolute: true, }); for (const hit of hits) found.add(hit); } return Array.from(found); } // ════════════════════════════════════════════════════════════════════════════ // Import → file resolution // ════════════════════════════════════════════════════════════════════════════ /** * Best-effort resolution from an import specifier to an absolute source file * present in `nodesById`. * * Handles: * - Relative imports (./x, ../y) * - `@/`, `~/` alias (resolved against srcDir) * - Bare `srcDir/...` paths (e.g. "src/features/auth") * - Directory imports → index.(ts|tsx|js|jsx) * - Extension-less imports — tries all WATCH_EXTENSIONS * * Returns null for node_modules, URL imports, or unmatched paths. Falls back * to "does a node id exist with a matching POSIX path" to keep CI output * deterministic even when filesystem lookups would miss (e.g. virtual files * in tests). */ function resolveImportToFile( importPath: string, fromFile: string, rootDir: string, config: GuardConfig, nodesById: Map ): string | null { const srcDir = (config.srcDir ?? "src").replace(/\\/g, "/").replace(/\/$/, ""); const normalized = importPath.replace(/\\/g, "/"); const candidates: string[] = []; if (normalized.startsWith("@/") || normalized.startsWith("~/")) { const alias = normalized.slice(2); if (srcDir) candidates.push(`${srcDir}/${alias}`); candidates.push(alias); } else if (normalized.startsWith(".")) { const absoluteFrom = isAbsolute(fromFile) ? fromFile : resolve(rootDir, fromFile); const resolved = resolve(dirname(absoluteFrom), normalized); const rel = toPosix(relative(rootDir, resolved)); if (!rel.startsWith("..")) candidates.push(rel); } else if ( srcDir && (normalized === srcDir || normalized.startsWith(`${srcDir}/`)) ) { candidates.push(normalized); } else { return null; // bare import — node_modules or unknown alias } // Try each candidate with all extension variants + index.* fallback. for (const candidate of candidates) { const abs = resolve(rootDir, candidate); // Exact file (candidate has its own extension already) if (extname(candidate) && existsSync(abs)) return abs; for (const ext of WATCH_EXTENSIONS) { const withExt = `${abs}${ext}`; if (existsSync(withExt)) return withExt; const asIndex = resolve(abs, `index${ext}`); if (existsSync(asIndex)) return asIndex; } // Virtual-fs fallback — for tests where nodes may be registered but // fs.existsSync isn't reachable (never hits in normal runs, but keeps // edge generation deterministic in edge cases). for (const ext of WATCH_EXTENSIONS) { const id = `${toPosix(candidate)}${ext}`; if (nodesById.has(id)) return resolve(rootDir, id); const idIdx = `${toPosix(candidate)}/index${ext}`; if (nodesById.has(idIdx)) return resolve(rootDir, idIdx); } } return null; } // ════════════════════════════════════════════════════════════════════════════ // Utilities // ════════════════════════════════════════════════════════════════════════════ function toPosix(p: string): string { return p.replace(/\\/g, "/"); } function computeLabel(id: string): string { const parts = id.split("/"); const base = parts[parts.length - 1] ?? id; const parent = parts.length > 1 ? parts[parts.length - 2] : ""; const stem = base.replace(/\.[^.]+$/, ""); return parent ? `${parent}/${stem}` : stem; } function resolveLayerDefinitions(config: GuardConfig): LayerDefinition[] { if (config.layers && config.layers.length > 0) { return config.layers; } if (config.preset) { const preset = getPreset(config.preset); let layers = [...preset.layers]; if (config.override?.layers) { layers = layers.map((layer) => { const override = config.override?.layers?.[layer.name]; return override ? { ...layer, ...override } : layer; }); } return layers; } return []; } function resolveHierarchy(config: GuardConfig): string[] { if (config.preset) { return [...getPreset(config.preset).hierarchy]; } return (config.layers ?? []).map((l) => l.name); } // ════════════════════════════════════════════════════════════════════════════ // HTML renderer // ════════════════════════════════════════════════════════════════════════════ const LAYER_PALETTE = [ "#1e3a8a", "#155e75", "#166534", "#854d0e", "#7c2d12", "#581c87", "#831843", "#0f766e", "#3730a3", "#92400e", "#064e3b", "#6b21a8", "#134e4a", "#7f1d1d", "#1e40af", ]; /** * Render a self-contained HTML graph viewer. * * Layout: * - Rows = layers (rank 0 = top). Nodes in each layer packed horizontally, * sorted by id → deterministic across runs. * - Edges drawn as cubic Bezier splines between node centers. Violation * edges rendered in red, dashed, with markerEnd arrow. Normal edges in * muted blue. * * Interactivity: * - Click node → side panel lists importers + importees + file path. * - Top filter bar: "Only violations" toggle + "All layers" / per-layer * toggle chips. * * Portability: * - Zero external JS / CSS. Drag-and-drop into any browser. * - File size stays well under 500 KB for projects up to ~2k modules * (tested on auth-starter: ~30 KB). */ export function renderGraphHtml(graph: DependencyGraph): string { const { nodes, edges, layers, summary } = graph; // ── Layout ──────────────────────────────────────────────────────────────── const MARGIN_X = 24; const MARGIN_TOP = 48; const LAYER_GAP = 120; const NODE_W = 130; const NODE_H = 28; const NODE_GAP = 10; // Bucket nodes per layer. const nodesByLayer = new Map(); for (const n of nodes) { const key = n.layer ?? "__unassigned__"; if (!nodesByLayer.has(key)) nodesByLayer.set(key, []); nodesByLayer.get(key)!.push(n); } // Determine layer ordering — layer array + trailing "__unassigned__". const layerOrder: string[] = layers.map((l) => l.name); if (nodesByLayer.has("__unassigned__")) layerOrder.push("__unassigned__"); // Compute per-node coordinates. const nodePos = new Map(); let maxRowWidth = 0; for (let rowIdx = 0; rowIdx < layerOrder.length; rowIdx++) { const layerName = layerOrder[rowIdx]; const members = nodesByLayer.get(layerName) ?? []; const rowWidth = members.length * NODE_W + Math.max(0, members.length - 1) * NODE_GAP; maxRowWidth = Math.max(maxRowWidth, rowWidth); for (let i = 0; i < members.length; i++) { const x = MARGIN_X + i * (NODE_W + NODE_GAP); const y = MARGIN_TOP + rowIdx * LAYER_GAP; nodePos.set(members[i].id, { x, y, layerIdx: rowIdx }); } } const svgWidth = Math.max(maxRowWidth + MARGIN_X * 2, 960); const svgHeight = MARGIN_TOP + layerOrder.length * LAYER_GAP + 40; // Layer row bands (background stripes). const layerBands = layerOrder .map((name, rowIdx) => { const y = MARGIN_TOP + rowIdx * LAYER_GAP - 18; const fill = rowIdx % 2 === 0 ? "#0f172a" : "#0b1220"; const count = nodesByLayer.get(name)?.length ?? 0; return ` ${escText( name === "__unassigned__" ? "(unassigned)" : name )} · ${count} module${count === 1 ? "" : "s"} `; }) .join(""); // Edges. const edgePaths = edges .map((e, idx) => { const a = nodePos.get(e.from); const b = nodePos.get(e.to); if (!a || !b) return ""; const x1 = a.x + NODE_W / 2; const y1 = a.y + NODE_H / 2; const x2 = b.x + NODE_W / 2; const y2 = b.y + NODE_H / 2; const dy = (y2 - y1) * 0.5; const d = `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2}`; const cls = e.violation ? "edge violation" : "edge"; return ``; }) .join(""); // Node rects. const layerColor = (layerName: string | null): string => { if (!layerName) return "#1f2937"; const idx = layerOrder.indexOf(layerName); if (idx < 0) return "#1f2937"; return LAYER_PALETTE[idx % LAYER_PALETTE.length]; }; const nodeRects = nodes .map((n, idx) => { const p = nodePos.get(n.id); if (!p) return ""; const color = layerColor(n.layer); const label = n.label.length > 16 ? n.label.slice(0, 15) + "…" : n.label; return ` ${escText( label )} ${escText(n.id)}${n.layer ? ` (${n.layer})` : ""} `; }) .join(""); // Layer filter chips. const layerChips = layerOrder .map((name) => { const count = nodesByLayer.get(name)?.length ?? 0; const color = layerColor(name); return ``; }) .join(""); // Sidebar: precomputed importer / importee lookup. const neighbours = buildNeighbourIndex(edges); // Serialize the data the client-side script needs. Keep it minimal — we // don't embed filePath (already in node titles) because the side panel // re-reads it from the DOM. const clientData = { nodes: nodes.map((n) => ({ id: n.id, filePath: n.filePath, layer: n.layer, })), importers: neighbours.importers, importees: neighbours.importees, }; const summaryCards = `
Modules
${summary.nodes}
Edges
${summary.edges}
Violations
${summary.violationEdges}
Preset
${escText( summary.preset )}
Layers
${layers.length}
Src
${escText( summary.srcDir )}
`; return ` Mandu Guard Graph

Mandu Guard Graph

generated ${escText(summary.generatedAt)} · preset ${escText( summary.preset )} · src ${escText(summary.srcDir)} · schema v${summary.version}
${summaryCards}
${layerChips}
importviolation
${layerBands} ${edgePaths} ${nodeRects}
`; } /** * Build adjacency lookup used by the side panel. * - importers[id] = list of ids that import `id` * - importees[id] = list of ids that `id` imports * * Deduped + sorted for stable client-side rendering. */ function buildNeighbourIndex( edges: ImportEdge[] ): { importers: Record; importees: Record } { const importers: Record> = {}; const importees: Record> = {}; for (const e of edges) { (importees[e.from] ??= new Set()).add(e.to); (importers[e.to] ??= new Set()).add(e.from); } const toSorted = (rec: Record>): Record => { const out: Record = {}; for (const key of Object.keys(rec).sort()) { out[key] = Array.from(rec[key]).sort(); } return out; }; return { importers: toSorted(importers), importees: toSorted(importees) }; } /** * JSON embedded in a `