/** * @angular-modernizer/plugin-angular - Immutable Input Violation Rule * * Detects when @Input properties are mutated in Angular components, which breaks * OnPush change detection. OnPush requires immutable input properties - when inputs * are mutated, change detection won't trigger even though the data has changed. * * Detection Patterns: * - Direct mutation of @Input properties (input.prop = value) * - Nested property mutation of @Input objects (input.nested.prop = value) * - Array mutation of @Input arrays (input.push(), input.splice(), etc.) * - Method calls that mutate @Input properties * * This rule is critical for OnPush change detection to work properly. */ import { SyntaxKind, type ClassDeclaration, type PropertyDeclaration, type MethodDeclaration, } from 'ts-morph'; import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; /** * Immutable Input Violation Rule - detects mutation of @Input properties. */ export class ImmutableInputViolationRule implements AnalysisRule { public readonly id = 'angular:immutable-input-violation'; public readonly name = 'Immutable Input Violation'; public readonly description = 'Detects when @Input properties are mutated, breaking OnPush change detection'; public readonly severity = 'error'; public readonly category = 'angular-performance'; public readonly tags = [ 'angular', 'onpush', 'immutability', 'input', 'change-detection', ]; async analyze(context: AnalysisContext): Promise { const violations: AnalysisResult[] = []; const sourceFile = context.sourceFile; // Find all class declarations (Angular components) const classes = sourceFile.getDescendantsOfKind( SyntaxKind.ClassDeclaration, ); for (const classDecl of classes) { const classViolations = this.analyzeComponent( classDecl, sourceFile.getFilePath(), ); violations.push(...classViolations); } return violations; } private analyzeComponent( classDecl: ClassDeclaration, filePath: string, ): AnalysisResult[] { const violations: AnalysisResult[] = []; const className = classDecl.getName(); if (!className) { return violations; } // Only analyze Angular components if (!this.isAngularComponent(classDecl)) { return violations; } const inputProperties = this.getInputProperties(classDecl); if (inputProperties.length === 0) { return violations; } // Analyze all methods for input mutations const methods = classDecl.getMethods(); for (const method of methods) { const methodViolations = this.analyzeMethodForInputMutations( method, inputProperties, className, filePath, ); violations.push(...methodViolations); } // Analyze property initializers for input mutations const properties = classDecl.getProperties(); for (const prop of properties) { const propViolations = this.analyzePropertyForInputMutations( prop, inputProperties, className, filePath, ); violations.push(...propViolations); } return violations; } private isAngularComponent(classDecl: ClassDeclaration): boolean { const decorators = classDecl.getDecorators(); return decorators.some((decorator) => decorator.getName() === 'Component'); } private getInputProperties(classDecl: ClassDeclaration): string[] { const inputProperties: string[] = []; const properties = classDecl.getProperties(); for (const prop of properties) { const decorators = prop.getDecorators(); const hasInputDecorator = decorators.some( (decorator) => decorator.getName() === 'Input', ); if (hasInputDecorator) { const propName = prop.getName(); if (propName) { inputProperties.push(propName); } } } return inputProperties; } private analyzeMethodForInputMutations( method: MethodDeclaration, inputProperties: string[], className: string, filePath: string, ): AnalysisResult[] { const violations: AnalysisResult[] = []; const methodBody = method.getBody(); if (!methodBody) { return violations; } const methodText = methodBody.getFullText(); const methodName = method.getName() || 'anonymous'; // Check for direct property assignment (input.prop = value) for (const inputProp of inputProperties) { const assignmentPatterns = [ new RegExp(String.raw`\bthis\.${inputProp}\.\w+\s*=`), // this.input.nested = value new RegExp(String.raw`\bthis\.${inputProp}\[`), // this.input[index] = value ]; for (const pattern of assignmentPatterns) { if (pattern.test(methodText)) { const matches = new RegExp(pattern).exec(methodText); if (matches) { violations.push({ ruleId: this.id, message: `Method '${methodName}' in component '${className}' mutates @Input property '${inputProp}'. This breaks OnPush change detection.`, filePath, line: method.getStartLineNumber(), column: method.getStart() - method.getStartLinePos(), suggestedFix: `Create a new immutable object: this.${inputProp} = { ...this.${inputProp}, nestedProp: newValue };`, metadata: { className, methodName, inputProperty: inputProp, violationType: 'input-mutation', mutationType: 'property-assignment', framework: 'angular', }, }); } } } // Check for array mutation methods const arrayMutationPatterns = [ `this.${inputProp}.push(`, `this.${inputProp}.pop(`, `this.${inputProp}.shift(`, `this.${inputProp}.unshift(`, `this.${inputProp}.splice(`, `this.${inputProp}.sort(`, `this.${inputProp}.reverse(`, ]; for (const pattern of arrayMutationPatterns) { if (methodText.includes(pattern)) { const mutationMethod = pattern.split('.')[2]?.split('(')[0] ?? 'unknown'; violations.push({ ruleId: this.id, message: `Method '${methodName}' in component '${className}' mutates @Input array '${inputProp}' with ${mutationMethod}(). This breaks OnPush change detection.`, filePath, line: method.getStartLineNumber(), column: method.getStart() - method.getStartLinePos(), suggestedFix: `Create a new array: this.${inputProp} = [...this.${inputProp}]; then modify the copy.`, metadata: { className, methodName, inputProperty: inputProp, violationType: 'input-mutation', mutationType: 'array-mutation', mutationMethod, framework: 'angular', }, }); } } // Check for object mutation methods const objectMutationPatterns = [ `this.${inputProp}.assign(`, `Object.assign(this.${inputProp}`, ]; for (const pattern of objectMutationPatterns) { if (methodText.includes(pattern)) { violations.push({ ruleId: this.id, message: `Method '${methodName}' in component '${className}' mutates @Input object '${inputProp}' with Object.assign(). This breaks OnPush change detection.`, filePath, line: method.getStartLineNumber(), column: method.getStart() - method.getStartLinePos(), suggestedFix: `Use spread operator: this.${inputProp} = { ...this.${inputProp}, ...newValues };`, metadata: { className, methodName, inputProperty: inputProp, violationType: 'input-mutation', mutationType: 'object-assign', framework: 'angular', }, }); } } } return violations; } private analyzePropertyForInputMutations( property: PropertyDeclaration, inputProperties: string[], className: string, filePath: string, ): AnalysisResult[] { const violations: AnalysisResult[] = []; const initializer = property.getInitializer(); if (!initializer) { return violations; } const initializerText = initializer.getFullText(); const propertyName = property.getName(); // Check if property initializer references and potentially mutates input properties for (const inputProp of inputProperties) { if ( initializerText.includes(`this.${inputProp}`) && initializerText.includes('=') ) { violations.push({ ruleId: this.id, message: `Property '${propertyName}' in component '${className}' may mutate @Input property '${inputProp}' in initializer. This breaks OnPush change detection.`, filePath, line: property.getStartLineNumber(), column: property.getStart() - property.getStartLinePos(), suggestedFix: `Move input mutation logic to ngOnChanges or ngOnInit lifecycle hook.`, metadata: { className, propertyName, inputProperty: inputProp, violationType: 'input-mutation', mutationType: 'property-initializer', framework: 'angular', }, }); } } return violations; } }