/* 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 - Performance Violation Rule * * Detects Angular performance violations that can cause memory leaks and poor performance. * * Detection Patterns: * - Observable subscriptions without proper cleanup * - Missing ngOnDestroy lifecycle hook when subscriptions exist * - Components with too many subscriptions * - Subscriptions in constructors or field initializers * * This rule helps prevent memory leaks and improve Angular application performance. */ import { SyntaxKind, type ClassDeclaration, type Node } from 'ts-morph'; import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; /** * Performance Violation Rule - detects performance issues and memory leaks. */ export class PerformanceViolationRule implements AnalysisRule { public readonly id = 'angular:performance-violation'; public readonly name = 'Performance Violation'; public readonly description = 'Detects Angular performance violations that can cause memory leaks and poor performance'; public readonly severity = 'error'; public readonly category = 'angular-performance'; public readonly tags = [ 'angular', 'performance', 'memory-leak', 'subscription', 'lifecycle', ]; 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, directives, and services if (!this.isAngularClass(classDecl)) { return violations; } const subscriptions = this.findSubscriptions(classDecl); const hasNgOnDestroy = this.hasNgOnDestroy(classDecl); // Rule 1: Subscriptions without ngOnDestroy if (subscriptions.length > 0 && !hasNgOnDestroy) { violations.push({ ruleId: this.id, message: `Component '${className}' has ${subscriptions.length} subscription(s) but no ngOnDestroy method. This will cause memory leaks.`, filePath, line: classDecl.getStartLineNumber(), column: classDecl.getStart() - classDecl.getStartLinePos(), suggestedFix: `Add ngOnDestroy lifecycle hook and unsubscribe from all subscriptions: ngOnDestroy() { this.subscriptions?.unsubscribe(); }`, metadata: { className, violationType: 'missing-ngondestroy', subscriptionCount: subscriptions.length, subscriptions: subscriptions.map((s) => ({ type: s.type, location: s.location, })), framework: 'angular', }, }); } // Rule 2: Too many subscriptions (potential performance issue) if (subscriptions.length > 5) { violations.push({ ruleId: this.id, message: `Component '${className}' has ${subscriptions.length} subscriptions. Consider using a single subscription or takeUntil pattern.`, filePath, line: classDecl.getStartLineNumber(), column: classDecl.getStart() - classDecl.getStartLinePos(), suggestedFix: `Use takeUntil pattern: private destroy$ = new Subject(); and pipe(takeUntil(this.destroy$)) in subscriptions.`, metadata: { className, violationType: 'too-many-subscriptions', subscriptionCount: subscriptions.length, subscriptions: subscriptions.map((s) => ({ type: s.type, location: s.location, })), framework: 'angular', }, }); } // Rule 3: Subscriptions in constructor or field initializers (dangerous) const dangerousSubscriptions = subscriptions.filter( (sub) => sub.location === 'constructor' || sub.location === 'field-initializer', ); for (const sub of dangerousSubscriptions) { violations.push({ ruleId: this.id, message: `Dangerous subscription in ${sub.location} of component '${className}'. Subscriptions should be managed in ngOnInit/ngAfterViewInit.`, filePath, line: sub.line, column: sub.column, suggestedFix: `Move subscription to ngOnInit and ensure proper cleanup in ngOnDestroy.`, metadata: { className, violationType: 'dangerous-subscription-location', subscriptionType: sub.type, location: sub.location, framework: 'angular', }, }); } return violations; } private isAngularClass(classDecl: ClassDeclaration): boolean { const decorators = classDecl.getDecorators(); const angularDecorators = ['Component', 'Directive', 'Injectable', 'Pipe']; return decorators.some((decorator) => { const decoratorName = decorator.getName(); return angularDecorators.includes(decoratorName); }); } private findSubscriptions( classDecl: ClassDeclaration, ): { type: string; location: string; line: number; column: number }[] { const subscriptions: { type: string; location: string; line: number; column: number; }[] = []; // Check field initializers const properties = classDecl.getProperties(); for (const prop of properties) { const initializer = prop.getInitializer(); if (initializer) { const subInfo = this.detectSubscriptionInExpression( initializer, 'field-initializer', ); if (subInfo) { subscriptions.push({ type: subInfo.type, location: 'field-initializer', line: prop.getStartLineNumber(), column: prop.getStart() - prop.getStartLinePos(), }); } } } // Check constructor const constructors = classDecl.getConstructors(); for (const constructor of constructors) { const body = constructor.getBody(); if (body) { // 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 subInfo = this.detectSubscriptionInStatement( statement as Node, 'constructor', ); if (subInfo) { subscriptions.push({ type: subInfo.type, location: 'constructor', line: statement.getStartLineNumber(), column: statement.getStart() - statement.getStartLinePos(), }); } } } } // Check methods (excluding ngOnDestroy) const methods = classDecl.getMethods(); for (const method of methods) { if (method.getName() === 'ngOnDestroy') { continue; } const body = method.getBody(); if (body) { // 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 subInfo = this.detectSubscriptionInStatement( statement as Node, method.getName() || 'method', ); if (subInfo) { subscriptions.push({ type: subInfo.type, location: method.getName() || 'method', line: statement.getStartLineNumber(), column: statement.getStart() - statement.getStartLinePos(), }); } } } } return subscriptions; } /** * Detects subscription patterns in a given text. * @param text - The text to analyze for subscription patterns. * @param allowTypeAnnotations - Whether to allow type annotations (for expressions). * @returns The subscription type if found, null otherwise. */ private detectSubscriptionPattern( text: string, allowTypeAnnotations = false, ): { type: string } | null { // Look for .subscribe() calls if (text.includes('.subscribe(')) { return { type: 'subscribe-call' }; } // Look for subscription variables/assignments const assignmentOperators = allowTypeAnnotations ? ['=', ':'] : ['=']; const hasSubscription = text.includes('Subscription'); const hasOperator = assignmentOperators.some((op) => text.includes(op)); if (hasSubscription && hasOperator) { return { type: allowTypeAnnotations ? 'subscription-variable' : 'subscription-assignment', }; } return null; } private detectSubscriptionInExpression( expression: Node, _location: string, ): { type: string } | null { return this.detectSubscriptionPattern(expression.getFullText(), true); } private detectSubscriptionInStatement( statement: Node, _location: string, ): { type: string } | null { return this.detectSubscriptionPattern(statement.getFullText(), false); } private hasNgOnDestroy(classDecl: ClassDeclaration): boolean { const methods = classDecl.getMethods(); return methods.some((method) => method.getName() === 'ngOnDestroy'); } }