/* 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 - Unnecessary Change Detection Rule * * Detects Angular components using Default change detection unnecessarily. * Components should use OnPush change detection when possible for better performance. * * Detection Patterns: * - Components with @Input properties using Default change detection * - Components with complex logic using Default change detection * - Components that could benefit from OnPush but don't use it * * This rule promotes better performance by encouraging OnPush change detection. */ import { SyntaxKind, type ClassDeclaration, type Decorator, type PropertyAssignment, } from 'ts-morph'; import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; /** * Unnecessary Change Detection Rule - detects components using Default CD unnecessarily. */ export class UnnecessaryChangeDetectionRule implements AnalysisRule { public readonly id = 'angular:unnecessary-change-detection'; public readonly name = 'Unnecessary Change Detection'; public readonly description = 'Detects Angular components using Default change detection unnecessarily'; public readonly severity = 'warning'; public readonly category = 'angular-performance'; public readonly tags = [ 'angular', 'change-detection', 'performance', 'onpush', ]; 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 violation = this.analyzeClass(classDecl, sourceFile.getFilePath()); if (violation) { violations.push(violation); } // Cat 8: detectChanges() called without OnPush is wasteful and fragile violations.push( ...this.analyzeDetectChangesWithoutOnPush( classDecl, sourceFile.getFilePath(), ), ); } return violations; } /** * Analyzes a single class declaration for unnecessary change detection violations. * @param classDecl - The class declaration to analyze. * @param filePath - The file path of the source file. * @returns An analysis result if a violation is found, null otherwise. */ private analyzeClass( classDecl: ClassDeclaration, filePath: string, ): AnalysisResult | null { const className = classDecl.getName(); if (!className) { return null; } // Check if this is a component const componentDecorator = this.getComponentDecorator(classDecl); if (!componentDecorator) { return null; } // Check if it already uses OnPush if (this.usesOnPush(componentDecorator)) { return null; } // Check if it should use OnPush const shouldUseOnPush = this.shouldUseOnPush(classDecl); if (!shouldUseOnPush) { return null; } const startLine = classDecl.getStartLineNumber(); const startColumn = classDecl.getStart() - classDecl.getStartLinePos(); return { ruleId: this.id, message: `Component '${className}' is using Default change detection but could benefit from OnPush. Consider using ChangeDetectionStrategy.OnPush for better performance.`, filePath, line: startLine, column: startColumn, suggestedFix: `Add changeDetection: ChangeDetectionStrategy.OnPush to @Component decorator`, metadata: { className, violationType: 'unnecessary-default-cd', isComponent: true, framework: 'angular', }, }; } /** * Detects components that call `ChangeDetectorRef.detectChanges()` but do NOT * use `ChangeDetectionStrategy.OnPush`. * * Without OnPush, the whole component tree is already re-rendered on every * event. Calling `detectChanges()` additionally is wasteful and suggests a * misunderstanding of Angular's change detection model. * * violationType: `detectchanges-without-onpush` */ private analyzeDetectChangesWithoutOnPush( classDecl: ClassDeclaration, filePath: string, ): AnalysisResult[] { const className = classDecl.getName(); if (!className) { return []; } const componentDecorator = this.getComponentDecorator(classDecl); if (!componentDecorator) { return []; } // Only flag when OnPush is NOT set if (this.usesOnPush(componentDecorator)) { return []; } // Look for detectChanges() calls anywhere in the class const violations: AnalysisResult[] = []; for (const method of classDecl.getMethods()) { const body = method.getBody(); if (!body) { continue; } const text = body.getFullText(); if (!text.includes('detectChanges()')) { continue; } violations.push({ ruleId: this.id, message: `Component '${className}' calls detectChanges() but does not use OnPush. With Default CD the entire tree is re-rendered automatically — detectChanges() is redundant and indicates a CD strategy mismatch.`, filePath, line: method.getStartLineNumber(), column: method.getStart() - method.getStartLinePos(), suggestedFix: `Either switch to ChangeDetectionStrategy.OnPush (and then detectChanges() becomes meaningful) or remove the detectChanges() call.`, metadata: { className, methodName: method.getName(), violationType: 'detectchanges-without-onpush', isComponent: true, framework: 'angular', }, }); } return violations; } /** * Gets the @Component decorator from a class declaration. * @param classDecl - The class declaration to check. * @returns The Component decorator if found, undefined otherwise. */ private getComponentDecorator( classDecl: ClassDeclaration, ): Decorator | undefined { const decorators = classDecl.getDecorators(); return decorators.find((decorator) => decorator.getName() === 'Component'); } /** * Checks if a component decorator already uses OnPush change detection. * @param componentDecorator - The Component decorator to check. * @returns True if OnPush is already configured, false otherwise. */ private usesOnPush(componentDecorator: Decorator): boolean { const args = componentDecorator.getArguments(); if (args.length === 0) { return false; } const config = args[0]; if (!config || config.getKind() !== SyntaxKind.ObjectLiteralExpression) { return false; } // eslint-disable-next-line @typescript-eslint/no-explicit-any const configObj = config as any; // Type assertion for ObjectLiteralExpression const properties = configObj.getProperties(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const changeDetectionProperty = properties.find((prop: any) => { if (prop.getKind() === SyntaxKind.PropertyAssignment) { const name = prop.getNameNode(); return name.getText() === 'changeDetection'; } return false; }) as PropertyAssignment | undefined; if (!changeDetectionProperty) { return false; } const initializer = changeDetectionProperty.getInitializer(); if (!initializer) { return false; } const changeDetectionValue = initializer.getText(); return changeDetectionValue.includes('OnPush'); } /** * Determines if a component should use OnPush change detection. * @param classDecl - The class declaration to analyze. * @returns True if the component should use OnPush, false otherwise. */ private shouldUseOnPush(classDecl: ClassDeclaration): boolean { // Check for @Input properties const hasInputs = this.hasInputProperties(classDecl); if (hasInputs) { return true; } // Check for complex logic (methods with certain patterns) const hasComplexLogic = this.hasComplexLogic(classDecl); if (hasComplexLogic) { return true; } return false; } /** * Checks if a class has @Input properties. * @param classDecl - The class declaration to check. * @returns True if the class has Input properties, false otherwise. */ private hasInputProperties(classDecl: ClassDeclaration): boolean { const properties = classDecl.getProperties(); return properties.some((prop) => { const decorators = prop.getDecorators(); return decorators.some((decorator) => decorator.getName() === 'Input'); }); } /** * Checks if a class has complex logic that would benefit from OnPush. * @param classDecl - The class declaration to check. * @returns True if the class has complex logic, false otherwise. */ private hasComplexLogic(classDecl: ClassDeclaration): boolean { const methods = classDecl.getMethods(); // Check for methods that suggest complex logic const complexMethodNames = [ 'ngOnChanges', 'ngDoCheck', 'calculate', 'compute', 'process', 'transform', ]; const hasComplexMethods = methods.some((method) => { const methodName = method.getName() || ''; return complexMethodNames.some((complexName) => methodName.toLowerCase().includes(complexName.toLowerCase()), ); }); if (hasComplexMethods) { return true; } // Check for methods with parameters (suggesting data processing) const hasParameterizedMethods = methods.some( (method) => method.getParameters().length > 0, ); if (hasParameterizedMethods) { return true; } return false; } }