/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /** * @angular-modernizer/plugin-angular - Lifecycle Hook Rule * * Detects improper usage of Angular lifecycle hooks and missing cleanup patterns. * * Detection Patterns: * - ngOnInit with async operations without proper error handling * - ngOnDestroy not unsubscribing from all subscriptions * - Missing ngOnDestroy when subscriptions exist * - Improper order of lifecycle hook calls * - ngAfterViewInit/ngAfterContentInit without corresponding ngOnDestroy cleanup * * This rule helps ensure proper lifecycle management and prevent memory leaks. */ import { SyntaxKind, type ClassDeclaration, type MethodDeclaration, } from 'ts-morph'; import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; /** * Lifecycle Hook Rule - detects improper lifecycle hook usage and missing cleanup. */ export class LifecycleHookRule implements AnalysisRule { public readonly id = 'angular:lifecycle-hook-violation'; public readonly name = 'Lifecycle Hook Violation'; public readonly description = 'Detects improper usage of Angular lifecycle hooks and missing cleanup patterns'; public readonly severity = 'warning'; public readonly category = 'angular-lifecycle'; public readonly tags = [ 'angular', 'lifecycle', 'memory-leak', 'cleanup', 'subscription', ]; async analyze(context: AnalysisContext): Promise { const violations: AnalysisResult[] = []; const sourceFile = context.sourceFile; // Find all class declarations const classes = sourceFile.getDescendantsOfKind( SyntaxKind.ClassDeclaration, ); for (const classDecl of classes) { const classViolations = this.analyzeClass( classDecl, sourceFile.getFilePath(), ); violations.push(...classViolations); } return violations; } private analyzeClass( classDecl: ClassDeclaration, filePath: string, ): AnalysisResult[] { const violations: AnalysisResult[] = []; const className = classDecl.getName(); if (!className) { return violations; } // Only analyze Angular components and directives if (!this.isAngularComponentOrDirective(classDecl)) { return violations; } const lifecycleHooks = this.getLifecycleHooks(classDecl); const hasSubscriptions = this.hasSubscriptions(classDecl); // Rule 1: Missing ngOnDestroy when subscriptions exist if (hasSubscriptions && !lifecycleHooks.has('ngOnDestroy')) { violations.push({ ruleId: this.id, message: `Component '${className}' has subscriptions but no ngOnDestroy method. Implement ngOnDestroy to unsubscribe and prevent memory leaks.`, filePath, line: classDecl.getStartLineNumber(), column: classDecl.getStart() - classDecl.getStartLinePos(), suggestedFix: `Add ngOnDestroy method: ngOnDestroy() { this.subscriptions?.unsubscribe(); }`, metadata: { className, violationType: 'missing-ngondestroy', hasSubscriptions: true, framework: 'angular', }, }); } // Rule 2: ngOnDestroy not properly cleaning up subscriptions if (lifecycleHooks.has('ngOnDestroy')) { const ngOnDestroy = lifecycleHooks.get('ngOnDestroy')!; const cleanupIssues = this.analyzeNgOnDestroyCleanup( ngOnDestroy, classDecl, ); for (const issue of cleanupIssues) { violations.push({ ruleId: this.id, message: `ngOnDestroy in '${className}' ${issue.message}`, filePath, line: ngOnDestroy.getStartLineNumber(), column: ngOnDestroy.getStart() - ngOnDestroy.getStartLinePos(), suggestedFix: issue.suggestedFix, metadata: { className, violationType: 'incomplete-ngondestroy-cleanup', issue: issue.type, framework: 'angular', }, }); } } // Rule 4: heavy ngOnInit (Cat 5) const ngOnInitComplexityViolations = this.analyzeNgOnInitComplexity( classDecl, className, filePath, ); violations.push(...ngOnInitComplexityViolations); // Rule 3: ngOnInit with async operations without error handling if (lifecycleHooks.has('ngOnInit')) { const ngOnInit = lifecycleHooks.get('ngOnInit')!; const asyncIssues = this.analyzeAsyncOperations(ngOnInit, 'ngOnInit'); for (const issue of asyncIssues) { violations.push({ ruleId: this.id, message: `ngOnInit in '${className}' ${issue.message}`, filePath, line: issue.line, column: issue.column, suggestedFix: issue.suggestedFix, metadata: { className, violationType: 'async-without-error-handling', hook: 'ngOnInit', framework: 'angular', }, }); } } return violations; } /** * Detects ngOnInit methods making ≥5 service calls — a strong signal that * Route Resolvers or lazy observable initialization should be used instead. * ≥5 → warning, ≥8 → severity escalated to error in metadata. */ private analyzeNgOnInitComplexity( classDecl: ClassDeclaration, className: string, filePath: string, ): AnalysisResult[] { const ngOnInit = classDecl .getMethods() .find((m) => m.getName() === 'ngOnInit'); if (!ngOnInit) { return []; } // Count service call expressions of form: this..( const callExpressions = ngOnInit.getDescendantsOfKind( SyntaxKind.CallExpression, ); const servicePropertyRefs = new Set(); let serviceCalls = 0; for (const call of callExpressions) { const expr = call.getExpression(); if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) { continue; } const propAccess = expr; const obj = propAccess.getExpression(); // Match pattern: this..() if (obj.isKind(SyntaxKind.PropertyAccessExpression)) { const outer = obj; if (outer.getExpression().getText() === 'this') { servicePropertyRefs.add(outer.getName()); serviceCalls++; } } } if (serviceCalls < 5) { return []; } const severity = serviceCalls >= 8 ? 'error' : 'warning'; return [ { ruleId: this.id, message: `ngOnInit in '${className}' makes ${serviceCalls} service calls across ${servicePropertyRefs.size} service(s). Consider Route Resolvers or lazy observable initialization.`, filePath, line: ngOnInit.getStartLineNumber(), column: ngOnInit.getStart() - ngOnInit.getStartLinePos(), suggestedFix: `Move data fetching to Route Resolvers or initialize observables lazily with the async pipe.`, metadata: { className, violationType: 'heavy-ngoninit', serviceCalls, serviceCount: servicePropertyRefs.size, severity, framework: 'angular', }, }, ]; } private isAngularComponentOrDirective(classDecl: ClassDeclaration): boolean { const decorators = classDecl.getDecorators(); const angularDecorators = ['Component', 'Directive']; return decorators.some((decorator) => { const decoratorName = decorator.getName(); return angularDecorators.includes(decoratorName); }); } private getLifecycleHooks( classDecl: ClassDeclaration, ): Map { const hooks = new Map(); const methods = classDecl.getMethods(); const lifecycleHookNames = [ 'ngOnChanges', 'ngOnInit', 'ngDoCheck', 'ngAfterContentInit', 'ngAfterContentChecked', 'ngAfterViewInit', 'ngAfterViewChecked', 'ngOnDestroy', ]; for (const method of methods) { const methodName = method.getName(); if (methodName && lifecycleHookNames.includes(methodName)) { hooks.set(methodName, method); } } return hooks; } private hasSubscriptions(classDecl: ClassDeclaration): boolean { // Check for subscription-related patterns const text = classDecl.getFullText(); return ( text.includes('.subscribe(') || text.includes('Subscription') || text.includes('Subject') || text.includes('BehaviorSubject') || text.includes('ReplaySubject') ); } private analyzeNgOnDestroyCleanup( ngOnDestroy: MethodDeclaration, classDecl: ClassDeclaration, ): { type: string; message: string; suggestedFix: string }[] { const issues: { type: string; message: string; suggestedFix: string; }[] = []; const body = ngOnDestroy.getBody(); if (!body) { return issues; } const bodyText = body.getFullText(); // Check if ngOnDestroy calls unsubscribe const hasUnsubscribe = bodyText.includes('.unsubscribe()') || bodyText.includes('.unsubscribe(') || bodyText.includes('takeUntil'); if (!hasUnsubscribe && this.hasSubscriptions(classDecl)) { issues.push({ type: 'missing-unsubscribe', message: 'does not unsubscribe from subscriptions', suggestedFix: 'Add this.subscriptions?.unsubscribe(); or similar cleanup logic.', }); } // Check for next() and complete() calls on Subjects const hasSubjectCleanup = bodyText.includes('.next()') && bodyText.includes('.complete()'); const hasSubjects = classDecl.getFullText().includes('Subject') || classDecl.getFullText().includes('BehaviorSubject') || classDecl.getFullText().includes('ReplaySubject'); if (hasSubjects && !hasSubjectCleanup) { issues.push({ type: 'missing-subject-cleanup', message: 'does not complete Subject instances', suggestedFix: 'Add this.destroy$.next(); this.destroy$.complete(); for Subject cleanup.', }); } return issues; } private analyzeAsyncOperations( method: MethodDeclaration, _hookName: string, ): { line: number; column: number; message: string; suggestedFix: string; }[] { const issues: { line: number; column: number; message: string; suggestedFix: string; }[] = []; const body = method.getBody(); if (!body) { return issues; } // eslint-disable-next-line @typescript-eslint/no-explicit-any const bodyBlock = body as any; // Type assertion for Block const statements = bodyBlock.getStatements(); for (const statement of statements) { const statementText = statement.getFullText(); // Check for async operations without error handling if ( statementText.includes('.subscribe(') && !statementText.includes('.catch(') && !statementText.includes('catchError') ) { issues.push({ line: statement.getStartLineNumber(), column: statement.getStart() - statement.getStartLinePos(), message: 'has subscribe call without error handling', suggestedFix: 'Add .catch() or catchError operator to handle errors properly.', }); } // Check for Promise operations without catch if ( statementText.includes('.then(') && !statementText.includes('.catch(') ) { issues.push({ line: statement.getStartLineNumber(), column: statement.getStart() - statement.getStartLinePos(), message: 'has Promise operation without error handling', suggestedFix: 'Add .catch() to handle Promise rejections.', }); } } return issues; } }