/** * @angular-modernizer/plugin-angular - Architecture Analyzer * * Analyzes architectural violations and patterns across the entire codebase. * Uses the dependency graph to detect layer violations, circular dependencies, * and other architectural anti-patterns. */ import type { AnalysisContext } from '@angular-modernizer/plugin-system'; import { type MethodDeclaration, SyntaxKind, type Block, type Statement, type ClassDeclaration, } from 'ts-morph'; import { type DependencyGraph } from './dependency-graph.js'; import { type ArchitectureViolation, ViolationType } from './types.js'; export class ArchitectureAnalyzer { private readonly dependencyGraph: DependencyGraph; constructor(dependencyGraph: DependencyGraph) { this.dependencyGraph = dependencyGraph; } /** * Analyzes layer boundary violations */ analyzeLayerViolations(): ArchitectureViolation[] { const violations: ArchitectureViolation[] = []; const layerViolations = this.dependencyGraph.getLayerViolations(); for (const violation of layerViolations) { violations.push({ type: ViolationType.LAYER_VIOLATION, source: violation.source, target: violation.target, severity: 'error', description: `Layer violation: ${violation.sourceLayer} module imports from ${violation.targetLayer} layer`, suggestedFix: `Move the imported functionality to ${violation.sourceLayer} layer or create an abstraction in shared layer`, location: { file: violation.source, line: 1, // We don't have line info from dependency graph column: 1, }, metadata: { sourceLayer: violation.sourceLayer, targetLayer: violation.targetLayer, violationType: 'layer-boundary', }, }); } return violations; } /** * Analyzes circular dependencies */ analyzeCircularDependencies(): ArchitectureViolation[] { const violations: ArchitectureViolation[] = []; const cycles = this.dependencyGraph.findCircularDependencies(); for (const cycle of cycles) { if (cycle.length === 0) { continue; } const cycleString = cycle.join(' → '); violations.push({ type: ViolationType.CIRCULAR_DEPENDENCY, source: cycle[0]!, target: cycle.at(-1)!, severity: 'error', description: `Circular dependency detected: ${cycleString}`, suggestedFix: 'Break the circular dependency by introducing an abstraction layer or event-driven communication', location: { file: cycle[0]!, line: 1, column: 1, }, metadata: { cycle: cycle, cycleLength: cycle.length, violationType: 'circular-dependency', }, }); } return violations; } /** * Analyzes God objects (classes with too many methods) */ analyzeGodObjects(context: AnalysisContext): ArchitectureViolation[] { const violations: ArchitectureViolation[] = []; const sourceFile = context.sourceFile; const classes = sourceFile.getClasses(); for (const classDecl of classes) { const className = classDecl.getName(); if (!className) { continue; } const methods = classDecl.getMethods(); const properties = classDecl.getProperties(); // God object criteria: too many methods or too many responsibilities const methodCount = methods.length; const propertyCount = properties.length; if (methodCount > 20) { violations.push({ type: ViolationType.GOD_OBJECT, source: sourceFile.getFilePath(), target: className, severity: 'warning', description: `God object detected: Class '${className}' has ${methodCount} methods (threshold: 20)`, suggestedFix: 'Split this class into smaller, single-responsibility classes', location: { file: sourceFile.getFilePath(), line: classDecl.getStartLineNumber(), column: classDecl.getStart() - classDecl.getStartLinePos(), }, metadata: { className, methodCount, propertyCount, violationType: 'god-object', }, }); } } return violations; } /** * Analyzes SOLID principle violations */ analyzeSolidViolations(context: AnalysisContext): ArchitectureViolation[] { const violations: ArchitectureViolation[] = []; // Single Responsibility Principle violations violations.push(...this.analyzeSingleResponsibilityViolations(context)); // Open/Closed Principle violations violations.push(...this.analyzeOpenClosedViolations(context)); return violations; } /** * Analyzes service anti-patterns */ analyzeServicePatterns(context: AnalysisContext): ArchitectureViolation[] { const violations: ArchitectureViolation[] = []; violations.push(...this.analyzeDirectInstantiation(context)); violations.push(...this.analyzeMissingInterfaces(context)); violations.push(...this.analyzeServiceLocatorPattern(context)); return violations; } /** * Analyzes component pattern violations */ analyzeComponentPatterns(context: AnalysisContext): ArchitectureViolation[] { const violations: ArchitectureViolation[] = []; violations.push(...this.analyzeDumbComponentViolations(context)); violations.push(...this.analyzeSmartComponentViolations(context)); return violations; } private analyzeSingleResponsibilityViolations( context: AnalysisContext, ): ArchitectureViolation[] { const violations: ArchitectureViolation[] = []; const sourceFile = context.sourceFile; const classes = sourceFile.getClasses(); for (const classDecl of classes) { const className = classDecl.getName(); if (!className) { continue; } const methods = classDecl.getMethods(); const responsibilities = this.analyzeResponsibilities(methods); if (responsibilities.length > 3) { violations.push({ type: ViolationType.SINGLE_RESPONSIBILITY, source: sourceFile.getFilePath(), target: className, severity: 'warning', description: `Single Responsibility violation: Class '${className}' has ${responsibilities.length} responsibilities: ${responsibilities.join(', ')}`, suggestedFix: 'Split this class into separate classes, each handling one responsibility', location: { file: sourceFile.getFilePath(), line: classDecl.getStartLineNumber(), column: classDecl.getStart() - classDecl.getStartLinePos(), }, metadata: { className, responsibilities, responsibilityCount: responsibilities.length, violationType: 'single-responsibility', }, }); } } return violations; } private analyzeOpenClosedViolations( context: AnalysisContext, ): ArchitectureViolation[] { const violations: ArchitectureViolation[] = []; const sourceFile = context.sourceFile; const classes = sourceFile.getClasses(); for (const classDecl of classes) { const className = classDecl.getName(); if (!className) { continue; } // Look for switch statements or if-else chains that suggest OCP violations const methods = classDecl.getMethods(); for (const method of methods) { const body = method.getBody(); let hasSwitchStatement = false; let hasLongIfElseChain = false; if (body) { const block = body as Block; if (block) { const statements = block.getStatements(); hasSwitchStatement = statements.some( (stmt) => stmt.getKind() === SyntaxKind.SwitchStatement, ); hasLongIfElseChain = this.hasLongIfElseChain(statements); } } // Fallback: check method text for patterns const methodText = method.getText(); if (!hasSwitchStatement && methodText.includes('switch')) { hasSwitchStatement = true; } if ( !hasLongIfElseChain && (methodText.match(/else if/g) ?? []).length > 3 ) { hasLongIfElseChain = true; } if (hasSwitchStatement || hasLongIfElseChain) { violations.push({ type: ViolationType.OPEN_CLOSED, source: sourceFile.getFilePath(), target: `${className}.${method.getName()}`, severity: 'info', description: `Open/Closed Principle violation: Method '${method.getName()}' in '${className}' uses ${hasSwitchStatement ? 'switch statement' : 'long if-else chain'}`, suggestedFix: 'Use polymorphism or strategy pattern instead of conditional logic', location: { file: sourceFile.getFilePath(), line: method.getStartLineNumber(), column: method.getStart() - method.getStartLinePos(), }, metadata: { className, methodName: method.getName(), pattern: hasSwitchStatement ? 'switch-statement' : 'if-else-chain', violationType: 'open-closed', }, }); } } } return violations; } private analyzeDirectInstantiation( context: AnalysisContext, ): ArchitectureViolation[] { const violations: ArchitectureViolation[] = []; const sourceFile = context.sourceFile; // Use text-based detection for new expressions const fileText = sourceFile.getText(); const newRegex = /\bnew\s+(\w+)/g; let match; while ((match = newRegex.exec(fileText)) !== null) { const typeName = match[1]; if (!typeName) { continue; } // Skip primitive types and framework classes if (this.isAllowedInstantiation(typeName)) { continue; } // Get line and column info const lines = fileText.substring(0, match.index).split('\n'); const line = lines.length; const lastLine = lines.at(-1); const column = lastLine ? lastLine.length : 0; violations.push({ type: ViolationType.DIRECT_INSTANTIATION, source: sourceFile.getFilePath(), target: typeName, severity: 'warning', description: `Direct instantiation of '${typeName}' detected. Use dependency injection instead.`, suggestedFix: 'Inject the service through constructor and register it in module providers', location: { file: sourceFile.getFilePath(), line, column, }, metadata: { instantiatedType: typeName, violationType: 'direct-instantiation', }, }); } return violations; } private analyzeMissingInterfaces( context: AnalysisContext, ): ArchitectureViolation[] { const violations: ArchitectureViolation[] = []; const sourceFile = context.sourceFile; // Find Injectable classes without interfaces const classes = sourceFile.getClasses(); for (const classDecl of classes) { const decorators = classDecl.getDecorators(); const hasInjectable = decorators.some( (d) => d.getName() === 'Injectable', ); if (hasInjectable) { const implementsClause = classDecl.getImplements(); if (!implementsClause || implementsClause.length === 0) { const className = classDecl.getName(); if (className) { violations.push({ type: ViolationType.MISSING_INTERFACE, source: sourceFile.getFilePath(), target: className, severity: 'info', description: `Service '${className}' doesn't implement an interface`, suggestedFix: 'Create an interface for the service and implement it', location: { file: sourceFile.getFilePath(), line: classDecl.getStartLineNumber(), column: classDecl.getStart() - classDecl.getStartLinePos(), }, metadata: { className, violationType: 'missing-interface', }, }); } } } } return violations; } private analyzeServiceLocatorPattern( context: AnalysisContext, ): ArchitectureViolation[] { const violations: ArchitectureViolation[] = []; const sourceFile = context.sourceFile; // Use text-based detection for injector usage const fileText = sourceFile.getText(); const injectorRegex = /\binjector\b/g; let match; while ((match = injectorRegex.exec(fileText)) !== null) { // Get line and column info const lines = fileText.substring(0, match.index).split('\n'); const line = lines.length; const lastLine = lines.at(-1); const column = lastLine ? lastLine.length : 0; violations.push({ type: ViolationType.SERVICE_LOCATOR, source: sourceFile.getFilePath(), target: 'Injector', severity: 'warning', description: 'Service Locator pattern detected. Use dependency injection instead.', suggestedFix: 'Inject services directly through constructor parameters', location: { file: sourceFile.getFilePath(), line, column, }, metadata: { violationType: 'service-locator', }, }); } return violations; } private analyzeDumbComponentViolations( context: AnalysisContext, ): ArchitectureViolation[] { const violations: ArchitectureViolation[] = []; const sourceFile = context.sourceFile; const classes = sourceFile.getClasses(); for (const classDecl of classes) { const decorators = classDecl.getDecorators(); const isComponent = decorators.some((d) => d.getName() === 'Component'); if (isComponent) { const className = classDecl.getName(); if (!className) { continue; } const methods = classDecl.getMethods(); const hasBusinessLogic = methods.some((method) => { const methodName = method.getName(); if (!methodName) { return false; } // Business logic indicators return ( methodName.includes('calculate') || methodName.includes('process') || methodName.includes('validate') || methodName.includes('transform') ); }); if (hasBusinessLogic) { violations.push({ type: ViolationType.DUMB_SMART_VIOLATION, source: sourceFile.getFilePath(), target: className, severity: 'warning', description: `Dumb component '${className}' contains business logic methods`, suggestedFix: 'Move business logic to services or container components', location: { file: sourceFile.getFilePath(), line: classDecl.getStartLineNumber(), column: classDecl.getStart() - classDecl.getStartLinePos(), }, metadata: { className, violationType: 'dumb-smart-violation', }, }); } } } return violations; } private analyzeSmartComponentViolations( context: AnalysisContext, ): ArchitectureViolation[] { const violations: ArchitectureViolation[] = []; const sourceFile = context.sourceFile; const classes = sourceFile.getClasses(); for (const classDecl of classes) { const decorators = classDecl.getDecorators(); const isComponent = decorators.some((d) => d.getName() === 'Component'); if (isComponent) { const className = classDecl.getName(); if (!className) { continue; } // Check for complex template logic const templateContent = this.extractTemplateContent(classDecl); if (templateContent) { const hasComplexPresentation = this.hasComplexPresentationLogic(templateContent); if (hasComplexPresentation) { violations.push({ type: ViolationType.SMART_DUMB_VIOLATION, source: sourceFile.getFilePath(), target: className, severity: 'info', description: `Smart component '${className}' contains complex presentation logic in template`, suggestedFix: 'Extract presentation logic to dumb child components', location: { file: sourceFile.getFilePath(), line: classDecl.getStartLineNumber(), column: classDecl.getStart() - classDecl.getStartLinePos(), }, metadata: { className, violationType: 'smart-dumb-violation', }, }); } } } } return violations; } private analyzeResponsibilities(methods: MethodDeclaration[]): string[] { const responsibilities: string[] = []; for (const method of methods) { const methodName = method.getName(); if (!methodName) { continue; } if ( methodName.includes('http') || methodName.includes('api') || methodName.includes('fetch') ) { if (!responsibilities.includes('API Communication')) { responsibilities.push('API Communication'); } } if (methodName.includes('validate') || methodName.includes('check')) { if (!responsibilities.includes('Validation')) { responsibilities.push('Validation'); } } if ( methodName.includes('render') || methodName.includes('display') || methodName.includes('show') ) { if (!responsibilities.includes('Presentation')) { responsibilities.push('Presentation'); } } if (methodName.includes('calculate') || methodName.includes('compute')) { if (!responsibilities.includes('Business Logic')) { responsibilities.push('Business Logic'); } } } return responsibilities; } private hasLongIfElseChain(statements: Statement[]): boolean { let totalConditions = 0; const countIfElseChain = (stmt: Statement): number => { if (stmt.getKind() === SyntaxKind.IfStatement) { let count = 1; // Count this if statement const ifStmt = stmt.asKind(SyntaxKind.IfStatement); if (ifStmt) { const elseStmt = ifStmt.getElseStatement(); if (elseStmt) { if (elseStmt.getKind() === SyntaxKind.IfStatement) { // Else if count += countIfElseChain(elseStmt); } else { count += 1; // Else block } } } return count; } return 0; }; for (const stmt of statements) { totalConditions += countIfElseChain(stmt); } return totalConditions > 4; } private isAllowedInstantiation(typeName: string): boolean { const allowedTypes = [ 'String', 'Number', 'Boolean', 'Array', 'Object', 'Date', 'Map', 'Set', 'Promise', 'Observable', 'Subject', 'BehaviorSubject', 'FormGroup', 'FormControl', 'FormArray', ]; return allowedTypes.includes(typeName); } private extractTemplateContent(classDecl: ClassDeclaration): string | null { const componentDecorator = classDecl .getDecorators() .find((d) => d.getName() === 'Component'); if (!componentDecorator) { return null; } try { const decoratorText = componentDecorator.getFullText(); // Handle template literals with backticks const templateMatch = /template\s*:\s*`([^`]*)`/.exec(decoratorText); if (templateMatch?.[1]) { return templateMatch[1]; } // Handle regular quotes const quotedMatch = /template\s*:\s*['"]([^'"]*)['"]/.exec(decoratorText); return quotedMatch?.[1] ?? null; } catch { return null; } } private hasComplexPresentationLogic(templateContent: string): boolean { // Look for complex expressions, multiple conditions, etc. const complexPatterns = [ /\{\{[^}]{100,}\}\}/g, // Very long expressions /\*ngIf[^}]{50,}/g, // Complex ngIf conditions /\*ngFor[^}]{50,}/g, // Complex ngFor expressions ]; return complexPatterns.some((pattern) => pattern.test(templateContent)); } } export { ViolationType } from './types.js';