/** * @angular-modernizer/plugin-angular - Service Mutable State Rule * * Detects Angular services that expose public array/Map/Set properties and then * mutate them directly via push/splice/pop/sort/reverse. * * Direct mutation of shared collection state prevents OnPush components from * detecting changes and makes the service hard to test. * * @example * // Violation: public array mutated in place * @Injectable() * class UserService { * users: User[] = []; * addUser(u: User) { this.users.push(u); } // mutation — OnPush won't detect * } * * // Correct pattern: BehaviorSubject + immutable update * @Injectable() * class UserService { * private readonly users$ = new BehaviorSubject([]); * readonly users = this.users$.asObservable(); * addUser(u: User) { this.users$.next([...this.users$.getValue(), u]); } * } */ import { Scope, SyntaxKind, type ClassDeclaration } from 'ts-morph'; import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; /** * ServiceMutableStateRule — detects services that mutate public collection * properties directly, breaking OnPush change detection compatibility. */ export class ServiceMutableStateRule implements AnalysisRule { public readonly id = 'angular:service-mutable-state'; public readonly name = 'Service Mutable State'; public readonly description = 'Detects services that mutate public array/Map/Set properties directly, breaking OnPush components and testability'; public readonly severity = 'warning' as const; public readonly category = 'angular-architecture'; public readonly tags = [ 'angular', 'services', 'immutability', 'state', 'onpush', ]; /** Mutation methods that indicate direct in-place state modification. */ private readonly mutationMethods = [ 'push', 'splice', 'pop', 'sort', 'reverse', ] as const; async analyze(context: AnalysisContext): Promise { const violations: AnalysisResult[] = []; const sourceFile = context.sourceFile; const classes = sourceFile.getDescendantsOfKind( SyntaxKind.ClassDeclaration, ); for (const classDecl of classes) { if (!this.isInjectable(classDecl)) { continue; } violations.push( ...this.analyzeClass(classDecl, sourceFile.getFilePath()), ); } return violations; } private isInjectable(classDecl: ClassDeclaration): boolean { return classDecl.getDecorators().some((d) => d.getName() === 'Injectable'); } private analyzeClass( classDecl: ClassDeclaration, filePath: string, ): AnalysisResult[] { const violations: AnalysisResult[] = []; const className = classDecl.getName() ?? 'AnonymousService'; const classText = classDecl.getFullText(); for (const prop of classDecl.getProperties()) { const propName = prop.getName(); // Skip non-public properties const scope = prop.getScope(); if (scope === Scope.Private || scope === Scope.Protected) { continue; } // Skip readonly properties — they cannot be reassigned if (prop.isReadonly()) { continue; } // Skip BehaviorSubject/Subject — those are the idiomatic reactive pattern const initText = prop.getInitializer()?.getText() ?? ''; if ( initText.includes('BehaviorSubject') || initText.includes('ReplaySubject') || initText.includes('new Subject(') ) { continue; } // Check if property type is a collection (array, Map, Set) const typeText = prop.getType().getText(); const isCollection = typeText.includes('[]') || typeText.includes('Array<') || typeText.includes('Map<') || typeText.includes('Set<'); if (!isCollection) { continue; } // Check whether any class method mutates this property in place const hasMutation = this.mutationMethods.some((mutFn) => classText.includes(`this.${propName}.${mutFn}(`), ); if (!hasMutation) { continue; } violations.push({ ruleId: this.id, message: `Service '${className}' mutates public array '${propName}' directly. Use BehaviorSubject or return new arrays to support OnPush components and testability.`, filePath, line: prop.getStartLineNumber(), column: prop.getStart() - prop.getStartLinePos(), suggestedFix: `Replace 'public ${propName}: T[]' with 'private readonly ${propName}$ = new BehaviorSubject([])' and expose as 'readonly ${propName}$ = this.${propName}$.asObservable()'.`, metadata: { className, propName, violationType: 'service-mutable-public-array', principle: 'Immutability', framework: 'angular', }, }); } return violations; } }