/** * @angular-modernizer/plugin-angular - Dependency Graph * * Builds and maintains a complete dependency graph of the Angular codebase. * Tracks all imports, exports, and relationships between modules. * * Key Features: * - Cross-file dependency analysis * - Layer boundary detection * - Circular dependency detection * - Import type classification */ import { type SourceFile, type ImportDeclaration } from 'ts-morph'; import { type DependencyNode, type DependencyEdge, ArchitectureLayer, ModuleType, ImportType, } from './types.js'; export class DependencyGraph { private readonly nodes = new Map(); private readonly edges: DependencyEdge[] = []; constructor() { // No project needed - we'll work with source files directly } /** * Builds the complete dependency graph for the given source files */ build(sourceFiles: SourceFile[]): void { console.info('🏗️ Building dependency graph...'); // First pass: Create nodes for all modules for (const sourceFile of sourceFiles) { this.createNode(sourceFile); } // Second pass: Create edges for all imports for (const sourceFile of sourceFiles) { this.createEdges(sourceFile); } // Third pass: Update dependents this.updateDependents(); console.info( `✅ Dependency graph built: ${this.nodes.size} nodes, ${this.edges.length} edges`, ); } /** * Gets all nodes in the graph */ getNodes(): Map { return this.nodes; } /** * Gets all nodes as an array */ getAllNodes(): DependencyNode[] { return Array.from(this.nodes.values()); } /** * Gets all edges in the graph */ getEdges(): DependencyEdge[] { return this.edges; } /** * Finds all circular dependencies */ findCircularDependencies(): string[][] { const cycles: string[][] = []; const visited = new Set(); const recursionStack = new Set(); const visit = (nodePath: string, path: string[] = []): void => { if (recursionStack.has(nodePath)) { // Found a cycle const cycleStart = path.indexOf(nodePath); const cycle = [...path.slice(cycleStart), nodePath]; cycles.push(cycle); return; } if (visited.has(nodePath)) { return; } visited.add(nodePath); recursionStack.add(nodePath); const node = this.nodes.get(nodePath); if (node) { for (const dep of node.dependencies) { visit(dep.target, [...path, nodePath]); } } recursionStack.delete(nodePath); }; for (const nodePath of Array.from(this.nodes.keys())) { if (!visited.has(nodePath)) { visit(nodePath); } } return cycles; } /** * Gets all layer violations */ getLayerViolations(): { source: string; target: string; sourceLayer: ArchitectureLayer; targetLayer: ArchitectureLayer; }[] { const violations: { source: string; target: string; sourceLayer: ArchitectureLayer; targetLayer: ArchitectureLayer; }[] = []; for (const edge of this.edges) { const sourceNode = this.nodes.get(edge.location.file); // importer const targetNode = this.nodes.get(edge.target); // imported if (sourceNode && targetNode) { if (!this.isLayerImportAllowed(sourceNode.layer, targetNode.layer)) { violations.push({ source: edge.location.file, target: edge.target, sourceLayer: sourceNode.layer, targetLayer: targetNode.layer, }); } } } return violations; } private createNode(sourceFile: SourceFile): void { const filePath = sourceFile.getFilePath(); const layer = this.determineLayer(filePath); const type = this.determineModuleType(sourceFile); const node: DependencyNode = { path: filePath, layer, dependencies: [], dependents: [], type, }; this.nodes.set(filePath, node); } private createEdges(sourceFile: SourceFile): void { const filePath = sourceFile.getFilePath(); // Analyze import declarations const imports = sourceFile.getImportDeclarations(); for (const importDecl of imports) { const edge = this.createEdgeFromImport(importDecl, filePath); if (edge) { this.edges.push(edge); // Add to node's dependencies const node = this.nodes.get(filePath); if (node) { node.dependencies.push(edge); } } } } private createEdgeFromImport( importDecl: ImportDeclaration, sourceFile: string, ): DependencyEdge | null { try { const moduleSpecifier = importDecl.getModuleSpecifierValue(); const resolvedPath = this.resolveModulePath(moduleSpecifier, sourceFile); if (!resolvedPath) { return null; } const importType = this.classifyImportType(moduleSpecifier); const symbols = this.extractImportedSymbols(importDecl); const edge: DependencyEdge = { target: resolvedPath, importType, location: { file: sourceFile, line: importDecl.getStartLineNumber(), column: importDecl.getStart() - importDecl.getStartLinePos(), }, symbols, }; return edge; } catch (error) { console.warn(`Failed to create edge for import in ${sourceFile}:`, error); return null; } } private updateDependents(): void { // Clear existing dependents Array.from(this.nodes.values()).forEach((node) => { node.dependents = []; }); // Build dependents from edges for (const edge of this.edges) { const targetNode = this.nodes.get(edge.target); if (targetNode) { targetNode.dependents.push(edge.location.file); } } } // TODO: think about other Pattern names for more generic behavior private determineLayer(filePath: string): ArchitectureLayer { const path = filePath.toLowerCase(); // Framework-specific mappings (Angular) if (path.includes('/core/') || path.includes('@core/')) { return ArchitectureLayer.CORE; } if (path.includes('/features/') || path.includes('@features/')) { return ArchitectureLayer.FEATURES; } if ( path.includes('/main/') || path.includes('@main/') || path.includes('/offline/') || path.includes('@offline/') ) { return ArchitectureLayer.MAIN; } // Generic architectural patterns if ( path.includes('/shared/') || path.includes('@shared/') || path.includes('@enterprise-shared/') || path.includes('/common/') || path.includes('@common/') || path.includes('/lib/') || path.includes('@lib/') ) { return ArchitectureLayer.SHARED; } // Domain/business logic if ( path.includes('/domain/') || path.includes('/business/') || path.includes('/entities/') || path.includes('/models/') || path.includes('/domain-services/') ) { return ArchitectureLayer.DOMAIN; } // Application/use case layer if ( path.includes('/application/') || path.includes('/use-cases/') || path.includes('/application-services/') || path.includes('/orchestrators/') ) { return ArchitectureLayer.APPLICATION; } // Infrastructure layer if ( path.includes('/infrastructure/') || path.includes('/data/') || path.includes('/repositories/') || path.includes('/api/') || path.includes('/external/') ) { return ArchitectureLayer.INFRASTRUCTURE; } // Presentation/UI layer if ( path.includes('/presentation/') || path.includes('/ui/') || path.includes('/components/') || path.includes('/views/') || path.includes('/pages/') ) { return ArchitectureLayer.PRESENTATION; } // Configuration layer if ( path.includes('/config/') || path.includes('/configuration/') || path.includes('/environments/') || path.includes('/settings/') ) { return ArchitectureLayer.CONFIGURATION; } // Testing layer if ( path.includes('/test/') || path.includes('/testing/') || path.includes('/mocks/') || path.includes('/spec/') || path.includes('.test.') || path.includes('.spec.') ) { return ArchitectureLayer.TESTING; } // Foundation/core fallback if ( path.includes('/foundation/') || path.includes('/base/') || path.includes('/utils/') || path.includes('/helpers/') || path.includes('/constants/') ) { return ArchitectureLayer.FOUNDATION; } return ArchitectureLayer.APPLICATION; // Default fallback for application code } private determineModuleType(sourceFile: SourceFile): ModuleType { const filePath = sourceFile.getFilePath().toLowerCase(); // Check file extension and content if (filePath.endsWith('.service.ts')) { return ModuleType.SERVICE; } if (filePath.endsWith('.component.ts')) { return ModuleType.COMPONENT; } if (filePath.endsWith('.directive.ts')) { return ModuleType.DIRECTIVE; } if (filePath.endsWith('.pipe.ts')) { return ModuleType.PIPE; } if (filePath.endsWith('.module.ts')) { return ModuleType.MODULE; } if (filePath.includes('/models/') || filePath.endsWith('.model.ts')) { return ModuleType.MODEL; } if (filePath.includes('/utils/') || filePath.includes('/helpers/')) { return ModuleType.UTILITY; } if (filePath.includes('/config/') || filePath.endsWith('.config.ts')) { return ModuleType.CONFIG; } // Analyze content for Angular decorators const classes = sourceFile.getClasses(); for (const cls of classes) { const decorators = cls.getDecorators(); for (const decorator of decorators) { const name = decorator.getName(); switch (name) { case 'Component': return ModuleType.COMPONENT; case 'Directive': return ModuleType.DIRECTIVE; case 'Pipe': return ModuleType.PIPE; case 'Injectable': return ModuleType.SERVICE; case 'NgModule': return ModuleType.MODULE; } } } return ModuleType.UTILITY; // Default } private classifyImportType(moduleSpecifier: string): ImportType { if (moduleSpecifier.startsWith('./') || moduleSpecifier.startsWith('../')) { return ImportType.RELATIVE; } if (moduleSpecifier.startsWith('@')) { return ImportType.ABSOLUTE; } if (moduleSpecifier.includes('/index')) { return ImportType.BARREL; } if (moduleSpecifier.startsWith('import(')) { return ImportType.DYNAMIC; } return ImportType.ABSOLUTE; // Default } private extractImportedSymbols(importDecl: ImportDeclaration): string[] { const symbols: string[] = []; // Named imports const namedImports = importDecl.getNamedImports(); for (const namedImport of namedImports) { symbols.push(namedImport.getName()); } // Namespace import const namespaceImport = importDecl.getNamespaceImport(); if (namespaceImport) { symbols.push(`* as ${namespaceImport.getText()}`); } // Default import const defaultImport = importDecl.getDefaultImport(); if (defaultImport) { symbols.push(defaultImport.getText()); } return symbols; } private resolveModulePath( moduleSpecifier: string, sourceFile: string, ): string | null { // This is a simplified resolver - in a real implementation, // you'd use TypeScript's module resolution logic try { if (moduleSpecifier.startsWith('@')) { // Handle path mappings const resolved = this.resolvePathMapping(moduleSpecifier, sourceFile); return resolved; } if ( moduleSpecifier.startsWith('./') || moduleSpecifier.startsWith('../') ) { // Handle relative imports const sourceDir = sourceFile.substring(0, sourceFile.lastIndexOf('/')); const resolved = this.resolveRelativePath(moduleSpecifier, sourceDir); return resolved; } return null; // Skip node_modules and other external imports } catch (_error) { console.warn( `Failed to resolve module path: ${moduleSpecifier} from ${sourceFile}`, ); return null; } } private resolvePathMapping( moduleSpecifier: string, sourceFile: string, ): string | null { // Extended path mapping resolution for generic architecture // Supports both framework-specific and generic architectural patterns const mappings: Record = { // Framework-specific (Angular) // TODO: Write a regex which checks only 'shared' part of the path '@core/': 'src/app/core/', '@shared/': 'src/app/shared/', '@enterprise-shared/': 'src/app/enterprise-shared/', '@features/': 'src/app/features/', '@main/': 'src/app/main/', '@offline/': 'src/app/offline/', // Generic architectural patterns '@foundation/': 'src/app/foundation/', '@domain/': 'src/app/domain/', '@application/': 'src/app/application/', '@infrastructure/': 'src/app/infrastructure/', '@presentation/': 'src/app/presentation/', '@common/': 'src/app/common/', '@lib/': 'src/app/lib/', '@config/': 'src/app/config/', '@configuration/': 'src/app/configuration/', '@testing/': 'src/app/testing/', '@test/': 'src/app/test/', }; for (const [alias, path] of Object.entries(mappings)) { if (moduleSpecifier.startsWith(alias)) { const remaining = moduleSpecifier.substring(alias.length); const baseDir = sourceFile.includes('/src/') ? sourceFile.substring(0, sourceFile.indexOf('/src/') + 5) : sourceFile.substring(0, sourceFile.lastIndexOf('/')); return `${baseDir}${path}${remaining}`; } } return null; } private resolveRelativePath(relativePath: string, sourceDir: string): string { const parts = relativePath.split('/'); let currentDir = sourceDir; for (const part of parts) { if (part === '.' || part === '') { continue; } if (part === '..') { currentDir = currentDir.substring(0, currentDir.lastIndexOf('/')); } else { currentDir += '/' + part; } } // Add .ts extension if not present if (!currentDir.endsWith('.ts')) { currentDir += '.ts'; } return currentDir; } private isLayerImportAllowed( fromLayer: ArchitectureLayer, toLayer: ArchitectureLayer, ): boolean { // Define allowed layer imports using clean architecture principles // Inner layers (more stable) should not depend on outer layers (more volatile) const allowedImports: Record = { // Foundation layer - most stable, should not import anything [ArchitectureLayer.FOUNDATION]: [], [ArchitectureLayer.CORE]: [], // Alias for FOUNDATION // Configuration - can import foundation [ArchitectureLayer.CONFIGURATION]: [ ArchitectureLayer.FOUNDATION, ArchitectureLayer.CORE, ], // Domain - business rules, can import foundation and configuration [ArchitectureLayer.DOMAIN]: [ ArchitectureLayer.FOUNDATION, ArchitectureLayer.CORE, ArchitectureLayer.CONFIGURATION, ], // Infrastructure - external concerns, can import domain and inner layers [ArchitectureLayer.INFRASTRUCTURE]: [ ArchitectureLayer.FOUNDATION, ArchitectureLayer.CORE, ArchitectureLayer.CONFIGURATION, ArchitectureLayer.DOMAIN, ], // Application - use cases, orchestrates domain and infrastructure [ArchitectureLayer.APPLICATION]: [ ArchitectureLayer.FOUNDATION, ArchitectureLayer.CORE, ArchitectureLayer.CONFIGURATION, ArchitectureLayer.DOMAIN, ArchitectureLayer.INFRASTRUCTURE, ], // Presentation - UI layer, can import application and inner layers [ArchitectureLayer.PRESENTATION]: [ ArchitectureLayer.FOUNDATION, ArchitectureLayer.CORE, ArchitectureLayer.CONFIGURATION, ArchitectureLayer.DOMAIN, ArchitectureLayer.APPLICATION, ArchitectureLayer.INFRASTRUCTURE, ], // Shared - cross-cutting concerns, can import most layers [ArchitectureLayer.SHARED]: [ ArchitectureLayer.FOUNDATION, ArchitectureLayer.CORE, ArchitectureLayer.CONFIGURATION, ArchitectureLayer.DOMAIN, ArchitectureLayer.INFRASTRUCTURE, ArchitectureLayer.APPLICATION, ], // Features - feature modules, can import shared and inner layers [ArchitectureLayer.FEATURES]: [ ArchitectureLayer.FOUNDATION, ArchitectureLayer.CORE, ArchitectureLayer.CONFIGURATION, ArchitectureLayer.DOMAIN, ArchitectureLayer.INFRASTRUCTURE, ArchitectureLayer.APPLICATION, ArchitectureLayer.PRESENTATION, ArchitectureLayer.SHARED, ], // Main - app infrastructure, can import everything [ArchitectureLayer.MAIN]: [ ArchitectureLayer.FOUNDATION, ArchitectureLayer.CORE, ArchitectureLayer.CONFIGURATION, ArchitectureLayer.DOMAIN, ArchitectureLayer.INFRASTRUCTURE, ArchitectureLayer.APPLICATION, ArchitectureLayer.PRESENTATION, ArchitectureLayer.SHARED, ArchitectureLayer.FEATURES, ], // Offline - specialized functionality, can import most layers [ArchitectureLayer.OFFLINE]: [ ArchitectureLayer.FOUNDATION, ArchitectureLayer.CORE, ArchitectureLayer.CONFIGURATION, ArchitectureLayer.DOMAIN, ArchitectureLayer.INFRASTRUCTURE, ArchitectureLayer.APPLICATION, ArchitectureLayer.PRESENTATION, ArchitectureLayer.SHARED, ArchitectureLayer.FEATURES, ArchitectureLayer.MAIN, ], // Testing - can import from anywhere for testing purposes [ArchitectureLayer.TESTING]: [ ArchitectureLayer.FOUNDATION, ArchitectureLayer.CORE, ArchitectureLayer.CONFIGURATION, ArchitectureLayer.DOMAIN, ArchitectureLayer.INFRASTRUCTURE, ArchitectureLayer.APPLICATION, ArchitectureLayer.PRESENTATION, ArchitectureLayer.SHARED, ArchitectureLayer.FEATURES, ArchitectureLayer.MAIN, ArchitectureLayer.OFFLINE, ], }; const allowed = allowedImports[fromLayer] || []; return allowed.includes(toLayer); } }