diff --git a/src/analyzers/circularDetector.ts b/src/analyzers/circularDetector.ts new file mode 100644 index 0000000..3bc24c6 --- /dev/null +++ b/src/analyzers/circularDetector.ts @@ -0,0 +1,141 @@ +import { DependencyGraph, GraphEdge, CircularDependency } from '../types'; + +export class CircularDependencyDetector { + private graph: DependencyGraph; + private visited: Set; + private recursionStack: Set; + private cycles: CircularDependency[]; + + constructor(graph: DependencyGraph) { + this.graph = graph; + this.visited = new Set(); + this.recursionStack = new Set(); + this.cycles = []; + } + + detect(): CircularDependency[] { + this.visited.clear(); + this.recursionStack.clear(); + this.cycles = []; + + const nodes = Array.from(this.graph.nodes.keys()); + + for (const nodeId of nodes) { + if (!this.visited.has(nodeId)) { + this.detectCycle(nodeId, []); + } + } + + return this.cycles; + } + + private detectCycle(nodeId: string, path: string[]): void { + this.visited.add(nodeId); + this.recursionStack.add(nodeId); + path.push(nodeId); + + const outgoingEdges = this.getOutgoingEdges(nodeId); + + for (const edge of outgoingEdges) { + if (!this.visited.has(edge.target)) { + this.detectCycle(edge.target, [...path]); + } else if (this.recursionStack.has(edge.target)) { + const cycleStartIndex = path.indexOf(edge.target); + const cycle = path.slice(cycleStartIndex); + + const existingCycle = this.findEquivalentCycle(cycle); + if (!existingCycle) { + this.cycles.push({ + cycle: [...cycle, edge.target], + nodes: cycle, + edges: this.getEdgesForCycle(cycle), + length: cycle.length + }); + } + } + } + + this.recursionStack.delete(nodeId); + } + + private getOutgoingEdges(nodeId: string): GraphEdge[] { + return this.graph.edges.filter((edge) => edge.source === nodeId); + } + + private findEquivalentCycle(cycle: string[]): CircularDependency | undefined { + const normalizedCycle = this.normalizeCycle(cycle); + + return this.cycles.find((existing) => { + const normalizedExisting = this.normalizeCycle(existing.cycle); + return this.cyclesEqual(normalizedCycle, normalizedExisting); + }); + } + + private normalizeCycle(cycle: string[]): string[] { + if (cycle.length <= 1) return cycle; + const minIndex = cycle.findIndex((node) => { + const nodeNum = parseInt(node.split('#').pop() || '0', 10); + const minVal = Math.min(...cycle.map((n) => parseInt(n.split('#').pop() || '0', 10))); + return nodeNum === minVal; + }); + const normalized = cycle.slice(minIndex).concat(cycle.slice(0, minIndex)); + return normalized.length > 1 && normalized[0] > normalized[normalized.length - 1] + ? normalized.reverse() + : normalized; + } + + private cyclesEqual(cycle1: string[], cycle2: string[]): boolean { + if (cycle1.length !== cycle2.length) { + return false; + } + return cycle1.every((node, index) => node === cycle2[index]); + } + + private getEdgesForCycle(cycle: string[]): GraphEdge[] { + const edges: GraphEdge[] = []; + for (let i = 0; i < cycle.length - 1; i++) { + const edge = this.graph.edges.find( + (e) => e.source === cycle[i] && e.target === cycle[i + 1] + ); + if (edge) { + edges.push(edge); + } + } + return edges; + } + + getShortestCycles(): CircularDependency[] { + return this.cycles.sort((a, b) => a.length - b.length); + } + + getLongestCycles(): CircularDependency[] { + return this.cycles.sort((a, b) => b.length - a.length); + } + + getCyclesByNode(nodeId: string): CircularDependency[] { + return this.cycles.filter((cycle) => cycle.nodes.includes(nodeId)); + } + + getCycleCount(): number { + return this.cycles.length; + } + + getAffectedNodeCount(): number { + const affectedNodes = new Set(); + for (const cycle of this.cycles) { + for (const node of cycle.nodes) { + affectedNodes.add(node); + } + } + return affectedNodes.size; + } + + hasCircularDependencies(): boolean { + return this.cycles.length > 0; + } +} + +export function detectCircularDependencies(graph: DependencyGraph): CircularDependency[] { + const detector = new CircularDependencyDetector(graph); + return detector.detect(); +}