diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts new file mode 100644 index 0000000..1340c9e --- /dev/null +++ b/test/analyzer.test.ts @@ -0,0 +1,383 @@ +import { DependencyGraphBuilder, createDependencyGraphBuilder } from '../src/analyzers/dependencyGraph'; +import { CircularDependencyDetector, detectCircularDependencies } from '../src/analyzers/circularDetector'; +import { TypeWideningAnalyzer, analyzeTypeWidening } from '../src/analyzers/typeWidening'; +import { TypeNarrowingAnalyzer, analyzeTypeNarrowing } from '../src/analyzers/typeNarrowing'; +import { DOTExporter, exportToDOT } from '../src/exporters/dotExporter'; +import { GraphMLExporter, exportToGraphML } from '../src/exporters/graphmlExporter'; +import { JSONExporter, exportToJSON } from '../src/exporters/jsonExporter'; +import { DependencyGraph, ParsedFile, GraphNode } from '../src/types'; + +describe('DependencyGraphBuilder', () => { + let builder: ReturnType; + + beforeEach(() => { + builder = createDependencyGraphBuilder(); + }); + + describe('build', () => { + it('should create nodes from parsed files', () => { + const files: ParsedFile[] = [ + { + filePath: '/test/a.ts', + types: [ + { name: 'A', filePath: '/test/a.ts', startLine: 1, endLine: 10, kind: 'interface', dependencies: ['B'], rawNode: undefined } + ], + imports: [], + errors: [] + }, + { + filePath: '/test/b.ts', + types: [ + { name: 'B', filePath: '/test/b.ts', startLine: 1, endLine: 5, kind: 'interface', dependencies: [], rawNode: undefined } + ], + imports: [], + errors: [] + } + ]; + + const graph = builder.build(files, '/test'); + + expect(graph.nodes.size).toBe(2); + }); + + it('should create edges for dependencies', () => { + const files: ParsedFile[] = [ + { + filePath: '/test/a.ts', + types: [ + { name: 'A', filePath: '/test/a.ts', startLine: 1, endLine: 10, kind: 'interface', dependencies: ['B'], rawNode: undefined } + ], + imports: [], + errors: [] + }, + { + filePath: '/test/b.ts', + types: [ + { name: 'B', filePath: '/test/b.ts', startLine: 1, endLine: 5, kind: 'interface', dependencies: [], rawNode: undefined } + ], + imports: [], + errors: [] + } + ]; + + const graph = builder.build(files, '/test'); + + expect(graph.edges).toHaveLength(1); + expect(graph.edges[0].source).toContain('A'); + expect(graph.edges[0].target).toContain('B'); + }); + + it('should not create duplicate edges', () => { + const files: ParsedFile[] = [ + { + filePath: '/test/a.ts', + types: [ + { name: 'A', filePath: '/test/a.ts', startLine: 1, endLine: 10, kind: 'interface', dependencies: ['B', 'C'], rawNode: undefined } + ], + imports: [], + errors: [] + }, + { + filePath: '/test/b.ts', + types: [ + { name: 'B', filePath: '/test/b.ts', startLine: 1, endLine: 5, kind: 'interface', dependencies: [], rawNode: undefined }, + { name: 'C', filePath: '/test/b.ts', startLine: 6, endLine: 10, kind: 'interface', dependencies: [], rawNode: undefined } + ], + imports: [], + errors: [] + } + ]; + + const graph = builder.build(files, '/test'); + + expect(graph.edges.length).toBe(2); + }); + + it('should handle self-dependencies gracefully', () => { + const files: ParsedFile[] = [ + { + filePath: '/test/a.ts', + types: [ + { name: 'A', filePath: '/test/a.ts', startLine: 1, endLine: 10, kind: 'interface', dependencies: ['A'], rawNode: undefined } + ], + imports: [], + errors: [] + } + ]; + + const graph = builder.build(files, '/test'); + + expect(graph.edges).toHaveLength(0); + }); + }); + + describe('getNodeDepth', () => { + it('should calculate node depth correctly', () => { + const files: ParsedFile[] = [ + { + filePath: '/test/a.ts', + types: [ + { name: 'A', filePath: '/test/a.ts', startLine: 1, endLine: 10, kind: 'interface', dependencies: ['B'], rawNode: undefined } + ], + imports: [], + errors: [] + }, + { + filePath: '/test/b.ts', + types: [ + { name: 'B', filePath: '/test/b.ts', startLine: 1, endLine: 5, kind: 'interface', dependencies: ['C'], rawNode: undefined } + ], + imports: [], + errors: [] + }, + { + filePath: '/test/c.ts', + types: [ + { name: 'C', filePath: '/test/c.ts', startLine: 1, endLine: 5, kind: 'interface', dependencies: [], rawNode: undefined } + ], + imports: [], + errors: [] + } + ]; + + const graph = builder.build(files, '/test'); + + expect(builder.getNodeDepth('/test/a.ts#A')).toBe(2); + expect(builder.getNodeDepth('/test/b.ts#B')).toBe(1); + expect(builder.getNodeDepth('/test/c.ts#C')).toBe(0); + }); + }); + + describe('findNodesByKind', () => { + it('should filter nodes by kind', () => { + const files: ParsedFile[] = [ + { + filePath: '/test/a.ts', + types: [ + { name: 'A', filePath: '/test/a.ts', startLine: 1, endLine: 10, kind: 'interface', dependencies: [], rawNode: undefined }, + { name: 'TypeA', filePath: '/test/a.ts', startLine: 11, endLine: 15, kind: 'type_alias', dependencies: [], rawNode: undefined } + ], + imports: [], + errors: [] + } + ]; + + const graph = builder.build(files, '/test'); + const interfaces = builder.findNodesByKind('interface'); + const typeAliases = builder.findNodesByKind('type_alias'); + + expect(interfaces).toHaveLength(1); + expect(typeAliases).toHaveLength(1); + }); + }); + + describe('clusterByFile', () => { + it('should create clusters for each file', () => { + const files: ParsedFile[] = [ + { + filePath: '/test/a.ts', + types: [ + { name: 'A', filePath: '/test/a.ts', startLine: 1, endLine: 5, kind: 'interface', dependencies: [], rawNode: undefined } + ], + imports: [], + errors: [] + }, + { + filePath: '/test/b.ts', + types: [ + { name: 'B', filePath: '/test/b.ts', startLine: 1, endLine: 5, kind: 'interface', dependencies: [], rawNode: undefined } + ], + imports: [], + errors: [] + } + ]; + + const graph = builder.build(files, '/test'); + builder.clusterByFile(); + + expect(graph.clusters).toHaveLength(2); + }); + }); +}); + +describe('CircularDependencyDetector', () => { + describe('detect', () => { + it('should detect simple circular dependency', () => { + const graph: DependencyGraph = { + nodes: new Map([ + ['A#A', { id: 'A#A', label: 'A', kind: 'interface', filePath: 'a.ts', lineNumber: 1, metadata: {}, dependencies: ['B'] }], + ['B#B', { id: 'B#B', label: 'B', kind: 'interface', filePath: 'b.ts', lineNumber: 1, metadata: {}, dependencies: ['A'] }] + ]), + edges: [ + { source: 'A#A', target: 'B#B' }, + { source: 'B#B', target: 'A#A' } + ], + clusters: [], + metadata: { createdAt: '', sourceDirectory: '', nodeCount: 2, edgeCount: 2, analysisOptions: {} } + }; + + const cycles = detectCircularDependencies(graph); + + expect(cycles.length).toBeGreaterThan(0); + }); + + it('should not report false positives for non-circular graphs', () => { + const graph: DependencyGraph = { + nodes: new Map([ + ['A#A', { id: 'A#A', label: 'A', kind: 'interface', filePath: 'a.ts', lineNumber: 1, metadata: {}, dependencies: ['B'] }], + ['B#B', { id: 'B#B', label: 'B', kind: 'interface', filePath: 'b.ts', lineNumber: 1, metadata: {}, dependencies: ['C'] }], + ['C#C', { id: 'C#C', label: 'C', kind: 'interface', filePath: 'c.ts', lineNumber: 1, metadata: {}, dependencies: [] }] + ]), + edges: [ + { source: 'A#A', target: 'B#B' }, + { source: 'B#B', target: 'C#C' } + ], + clusters: [], + metadata: { createdAt: '', sourceDirectory: '', nodeCount: 3, edgeCount: 2, analysisOptions: {} } + }; + + const cycles = detectCircularDependencies(graph); + + expect(cycles).toHaveLength(0); + }); + + it('should detect complex circular dependencies', () => { + const graph: DependencyGraph = { + nodes: new Map([ + ['A#A', { id: 'A#A', label: 'A', kind: 'interface', filePath: 'a.ts', lineNumber: 1, metadata: {}, dependencies: ['B'] }], + ['B#B', { id: 'B#B', label: 'B', kind: 'interface', filePath: 'b.ts', lineNumber: 1, metadata: {}, dependencies: ['C'] }], + ['C#C', { id: 'C#C', label: 'C', kind: 'interface', filePath: 'c.ts', lineNumber: 1, metadata: {}, dependencies: ['A'] }] + ]), + edges: [ + { source: 'A#A', target: 'B#B' }, + { source: 'B#B', target: 'C#C' }, + { source: 'C#C', target: 'A#A' } + ], + clusters: [], + metadata: { createdAt: '', sourceDirectory: '', nodeCount: 3, edgeCount: 3, analysisOptions: {} } + }; + + const cycles = detectCircularDependencies(graph); + + expect(cycles.length).toBeGreaterThan(0); + }); + }); +}); + +describe('TypeWideningAnalyzer', () => { + describe('analyze', () => { + it('should detect large union types', () => { + const graph: DependencyGraph = { + nodes: new Map([ + ['test#A', { id: 'test#A', label: 'A', kind: 'type_alias', filePath: 'test.ts', lineNumber: 1, metadata: { typeAnnotation: 'A | B | C | D | E | F' }, dependencies: [] }] + ]), + edges: [], + clusters: [], + metadata: { createdAt: '', sourceDirectory: '', nodeCount: 1, edgeCount: 0, analysisOptions: {} } + }; + + const issues = analyzeTypeWidening(graph); + + expect(issues.length).toBeGreaterThan(0); + }); + + it('should detect any type usage', () => { + const graph: DependencyGraph = { + nodes: new Map([ + ['test#A', { id: 'test#A', label: 'A', kind: 'type_alias', filePath: 'test.ts', lineNumber: 1, metadata: { typeAnnotation: 'any' }, dependencies: [] }] + ]), + edges: [], + clusters: [], + metadata: { createdAt: '', sourceDirectory: '', nodeCount: 1, edgeCount: 0, analysisOptions: {} } + }; + + const issues = analyzeTypeWidening(graph); + + expect(issues.some(i => i.description.includes('any'))).toBe(true); + }); + }); +}); + +describe('TypeNarrowingAnalyzer', () => { + describe('analyze', () => { + it('should detect excessive nesting', () => { + const graph: DependencyGraph = { + nodes: new Map([ + ['test#A', { id: 'test#A', label: 'A', kind: 'type_alias', filePath: 'test.ts', lineNumber: 1, metadata: { typeAnnotation: '(((A)))' }, dependencies: [] }] + ]), + edges: [], + clusters: [], + metadata: { createdAt: '', sourceDirectory: '', nodeCount: 1, edgeCount: 0, analysisOptions: {} } + }; + + const issues = analyzeTypeNarrowing(graph); + + expect(issues.length).toBeGreaterThan(0); + }); + }); +}); + +describe('DOTExporter', () => { + describe('export', () => { + it('should generate valid DOT output', () => { + const graph: DependencyGraph = { + nodes: new Map([ + ['test#A', { id: 'test#A', label: 'A', kind: 'interface', filePath: 'test.ts', lineNumber: 1, metadata: {}, dependencies: ['B'] }], + ['test#B', { id: 'test#B', label: 'B', kind: 'interface', filePath: 'test.ts', lineNumber: 5, metadata: {}, dependencies: [] }] + ]), + edges: [{ source: 'test#A', target: 'test#B' }], + clusters: [], + metadata: { createdAt: '', sourceDirectory: '', nodeCount: 2, edgeCount: 1, analysisOptions: {} } + }; + + const dot = exportToDOT(graph, { format: 'dot', outputPath: 'test.dot' }); + + expect(dot).toContain('digraph'); + expect(dot).toContain('test_A'); + expect(dot).toContain('test_B'); + expect(dot).toContain('->'); + }); + }); +}); + +describe('GraphMLExporter', () => { + describe('export', () => { + it('should generate valid GraphML output', () => { + const graph: DependencyGraph = { + nodes: new Map([ + ['test#A', { id: 'test#A', label: 'A', kind: 'interface', filePath: 'test.ts', lineNumber: 1, metadata: {}, dependencies: [] }] + ]), + edges: [], + clusters: [], + metadata: { createdAt: '', sourceDirectory: '', nodeCount: 1, edgeCount: 0, analysisOptions: {} } + }; + + const graphml = exportToGraphML(graph, { format: 'graphml', outputPath: 'test.graphml' }); + + expect(graphml).toContain('graphml'); + expect(graphml).toContain('node'); + expect(graphml).toContain('edge'); + }); + }); +}); + +describe('JSONExporter', () => { + describe('export', () => { + it('should generate valid JSON output', () => { + const graph: DependencyGraph = { + nodes: new Map([ + ['test#A', { id: 'test#A', label: 'A', kind: 'interface', filePath: 'test.ts', lineNumber: 1, metadata: {}, dependencies: [] }] + ]), + edges: [], + clusters: [], + metadata: { createdAt: '', sourceDirectory: '', nodeCount: 1, edgeCount: 0, analysisOptions: {} } + }; + + const json = exportToJSON(graph, { format: 'json', outputPath: 'test.json' }); + + expect(() => JSON.parse(json)).not.toThrow(); + const parsed = JSON.parse(json); + expect(parsed.nodes).toHaveLength(1); + }); + }); +});