384 lines
13 KiB
TypeScript
384 lines
13 KiB
TypeScript
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<typeof createDependencyGraphBuilder>;
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|