/** * ComponentGraphEngine — query engine for the design-system relationship graph. * * Provides BFS-based traversal, impact analysis, composition trees, * alternative discovery, and health metrics. Operates on an in-memory * adjacency list built from GraphEdge[]. */ import type { ComponentNode, GraphEdge, GraphEdgeType, GraphHealth, ComponentGraph, ImpactResult, PathResult, NeighborResult, CompositionTree, } from './types.js'; export class ComponentGraphEngine { private nodes: Map; private outgoing: Map; private incoming: Map; private edges: GraphEdge[]; private blockIndex: Map; private health: GraphHealth; constructor(graph: ComponentGraph, blocks?: Record) { this.nodes = new Map(); this.outgoing = new Map(); this.incoming = new Map(); this.edges = graph.edges; this.health = graph.health; this.blockIndex = new Map(); for (const node of graph.nodes) { this.nodes.set(node.name, node); this.outgoing.set(node.name, []); this.incoming.set(node.name, []); } for (const edge of graph.edges) { const out = this.outgoing.get(edge.source); if (out) out.push(edge); else this.outgoing.set(edge.source, [edge]); const inc = this.incoming.get(edge.target); if (inc) inc.push(edge); else this.incoming.set(edge.target, [edge]); } // Build block index: component → block names if (blocks) { for (const [blockName, block] of Object.entries(blocks)) { for (const comp of block.components) { const existing = this.blockIndex.get(comp); if (existing) existing.push(blockName); else this.blockIndex.set(comp, [blockName]); } } } } // ------------------------------------------------------------------------- // Core queries // ------------------------------------------------------------------------- /** Get outgoing edges from a component, optionally filtered by edge type */ dependencies(component: string, edgeTypes?: GraphEdgeType[]): GraphEdge[] { const edges = this.outgoing.get(component) ?? []; if (!edgeTypes || edgeTypes.length === 0) return edges; return edges.filter(e => edgeTypes.includes(e.type)); } /** Get incoming edges to a component, optionally filtered by edge type */ dependents(component: string, edgeTypes?: GraphEdgeType[]): GraphEdge[] { const edges = this.incoming.get(component) ?? []; if (!edgeTypes || edgeTypes.length === 0) return edges; return edges.filter(e => edgeTypes.includes(e.type)); } /** BFS transitive closure — what's affected if this component changes */ impact(component: string, maxDepth = 3): ImpactResult { const affected: ImpactResult['affected'] = []; const visited = new Set([component]); const queue: Array<{ name: string; depth: number; path: string[] }> = [ { name: component, depth: 0, path: [component] }, ]; while (queue.length > 0) { const current = queue.shift()!; if (current.depth >= maxDepth) continue; // Components that depend on current (incoming edges = dependents) const deps = this.incoming.get(current.name) ?? []; for (const edge of deps) { if (visited.has(edge.source)) continue; visited.add(edge.source); const newPath = [...current.path, edge.source]; affected.push({ component: edge.source, depth: current.depth + 1, path: newPath, edgeType: edge.type, }); queue.push({ name: edge.source, depth: current.depth + 1, path: newPath }); } } // Find affected blocks const affectedComponents = new Set([component, ...affected.map(a => a.component)]); const affectedBlocks = new Set(); for (const comp of affectedComponents) { const blocks = this.blockIndex.get(comp); if (blocks) { for (const b of blocks) affectedBlocks.add(b); } } return { component, affected, affectedBlocks: [...affectedBlocks], totalAffected: affected.length, }; } /** BFS shortest path between two components (undirected) */ path(from: string, to: string): PathResult { if (from === to) { return { found: true, path: [from], edges: [] }; } const visited = new Set([from]); const queue: Array<{ name: string; path: string[]; edges: GraphEdge[] }> = [ { name: from, path: [from], edges: [] }, ]; while (queue.length > 0) { const current = queue.shift()!; // Check both outgoing and incoming edges (undirected traversal) const allEdges = [ ...(this.outgoing.get(current.name) ?? []), ...(this.incoming.get(current.name) ?? []), ]; for (const edge of allEdges) { const neighbor = edge.source === current.name ? edge.target : edge.source; if (visited.has(neighbor)) continue; visited.add(neighbor); const newPath = [...current.path, neighbor]; const newEdges = [...current.edges, edge]; if (neighbor === to) { return { found: true, path: newPath, edges: newEdges }; } queue.push({ name: neighbor, path: newPath, edges: newEdges }); } } return { found: false, path: [], edges: [] }; } /** Connected components via BFS on undirected projection */ islands(): string[][] { const visited = new Set(); const components: string[][] = []; for (const nodeName of this.nodes.keys()) { if (visited.has(nodeName)) continue; const island: string[] = []; const queue = [nodeName]; visited.add(nodeName); while (queue.length > 0) { const current = queue.shift()!; island.push(current); const allEdges = [ ...(this.outgoing.get(current) ?? []), ...(this.incoming.get(current) ?? []), ]; for (const edge of allEdges) { const neighbor = edge.source === current ? edge.target : edge.source; if (!visited.has(neighbor) && this.nodes.has(neighbor)) { visited.add(neighbor); queue.push(neighbor); } } } components.push(island.sort()); } return components.sort((a, b) => b.length - a.length); } /** All components reachable within N hops (undirected) */ neighbors(component: string, maxHops = 1): NeighborResult { const neighbors: NeighborResult['neighbors'] = []; const visited = new Set([component]); const queue: Array<{ name: string; hops: number }> = [ { name: component, hops: 0 }, ]; while (queue.length > 0) { const current = queue.shift()!; if (current.hops >= maxHops) continue; const allEdges = [ ...(this.outgoing.get(current.name) ?? []), ...(this.incoming.get(current.name) ?? []), ]; for (const edge of allEdges) { const neighbor = edge.source === current.name ? edge.target : edge.source; if (visited.has(neighbor)) continue; visited.add(neighbor); neighbors.push({ component: neighbor, hops: current.hops + 1, edgeType: edge.type, }); queue.push({ name: neighbor, hops: current.hops + 1 }); } } return { component, neighbors }; } // ------------------------------------------------------------------------- // Design-system queries // ------------------------------------------------------------------------- /** Get the composition tree for a compound component */ composition(component: string): CompositionTree { const node = this.nodes.get(component); // Find sub-components from node data const subComponents = node?.subComponents ?? []; // Find children from outgoing parent-of edges const parentEdges = (this.outgoing.get(component) ?? []) .filter(e => e.type === 'parent-of'); const children = parentEdges.map(e => e.target); // Find parent (incoming parent-of edges) const childEdges = (this.incoming.get(component) ?? []) .filter(e => e.type === 'parent-of'); const parent = childEdges.length > 0 ? childEdges[0].source : undefined; // Find siblings (other targets of the same parent) const siblings: string[] = []; if (parent) { const parentOut = (this.outgoing.get(parent) ?? []) .filter(e => e.type === 'parent-of'); for (const edge of parentOut) { if (edge.target !== component) { siblings.push(edge.target); } } } // Also check sibling-of edges const siblingEdges = [ ...(this.outgoing.get(component) ?? []).filter(e => e.type === 'sibling-of'), ...(this.incoming.get(component) ?? []).filter(e => e.type === 'sibling-of'), ]; for (const edge of siblingEdges) { const sib = edge.source === component ? edge.target : edge.source; if (!siblings.includes(sib)) siblings.push(sib); } return { component, compositionPattern: node?.compositionPattern, subComponents, children, parent, siblings, blocks: this.blockIndex.get(component) ?? [], }; } /** Get alternative components (deduplicated for bidirectional edges) */ alternatives(component: string): Array<{ component: string; note?: string }> { const seen = new Set(); const alts: Array<{ component: string; note?: string }> = []; // Outgoing alternative-to edges for (const edge of this.outgoing.get(component) ?? []) { if (edge.type === 'alternative-to' && !seen.has(edge.target)) { seen.add(edge.target); alts.push({ component: edge.target, note: edge.note }); } } // Incoming alternative-to edges (skip if already seen from outgoing) for (const edge of this.incoming.get(component) ?? []) { if (edge.type === 'alternative-to' && !seen.has(edge.source)) { seen.add(edge.source); alts.push({ component: edge.source, note: edge.note }); } } return alts; } /** Get blocks that use a component */ blocksUsing(component: string): string[] { return this.blockIndex.get(component) ?? []; } /** Extract an induced subgraph for a set of components */ subgraph(components: string[]): ComponentGraph { const componentSet = new Set(components); const nodes = components .map(name => this.nodes.get(name)) .filter((n): n is ComponentNode => n !== undefined); const edges = this.edges.filter( e => componentSet.has(e.source) && componentSet.has(e.target) ); return { nodes, edges, health: computeHealthFromData(nodes, edges, this.blockIndex), }; } /** Return precomputed health metrics */ getHealth(): GraphHealth { return this.health; } /** Get a single node by name */ getNode(name: string): ComponentNode | undefined { return this.nodes.get(name); } /** Check if a component exists in the graph */ hasNode(name: string): boolean { return this.nodes.has(name); } } // --------------------------------------------------------------------------- // Health computation helper (also used by graph-extractor) // --------------------------------------------------------------------------- export function computeHealthFromData( nodes: ComponentNode[], edges: GraphEdge[], blockIndex?: Map ): GraphHealth { const nodeNames = new Set(nodes.map(n => n.name)); const degreeMap = new Map(); for (const name of nodeNames) { degreeMap.set(name, 0); } for (const edge of edges) { degreeMap.set(edge.source, (degreeMap.get(edge.source) ?? 0) + 1); degreeMap.set(edge.target, (degreeMap.get(edge.target) ?? 0) + 1); } // Orphans: zero degree const orphans: string[] = []; for (const [name, degree] of degreeMap) { if (degree === 0) orphans.push(name); } // Hubs: top 10 by degree const hubs = [...degreeMap.entries()] .map(([name, degree]) => ({ name, degree })) .sort((a, b) => b.degree - a.degree) .slice(0, 10); // Composition coverage let inBlock = 0; if (blockIndex) { for (const name of nodeNames) { if ((blockIndex.get(name) ?? []).length > 0) inBlock++; } } const compositionCoverage = nodeNames.size > 0 ? Math.round((inBlock / nodeNames.size) * 100) : 0; // Connected components (BFS on undirected projection) const adjacency = new Map>(); for (const name of nodeNames) { adjacency.set(name, new Set()); } for (const edge of edges) { adjacency.get(edge.source)?.add(edge.target); adjacency.get(edge.target)?.add(edge.source); } const visited = new Set(); const connectedComponents: string[][] = []; for (const name of nodeNames) { if (visited.has(name)) continue; const island: string[] = []; const queue = [name]; visited.add(name); while (queue.length > 0) { const current = queue.shift()!; island.push(current); for (const neighbor of adjacency.get(current) ?? []) { if (!visited.has(neighbor)) { visited.add(neighbor); queue.push(neighbor); } } } connectedComponents.push(island.sort()); } connectedComponents.sort((a, b) => b.length - a.length); // Average degree const totalDegree = [...degreeMap.values()].reduce((sum, d) => sum + d, 0); const averageDegree = nodeNames.size > 0 ? Math.round((totalDegree / nodeNames.size) * 100) / 100 : 0; return { orphans: orphans.sort(), hubs, compositionCoverage, connectedComponents, averageDegree, nodeCount: nodeNames.size, edgeCount: edges.length, }; }