/** * @angular-modernizer/plugin-angular - Async Pipe Misuse Rule * * Detects incorrect usage of Angular's async pipe in inline templates. * * Detection Patterns: * - `double-async-subscription`: Observable property used with `| async` in * the template AND manually subscribed via `.subscribe()` in a lifecycle * hook — creates two subscriptions, one of which leaks. * - `multiple-async-subscriptions`: The same observable property appears with * `| async` two or more times in the same inline template — each occurrence * creates an independent subscription; use `*ngIf="obs$ | async as val"` or * `shareReplay(1)` instead. * * Only analyses inline `template:` strings — external `templateUrl:` files are * not parsed (no file system access at analysis time). */ import { posix } from 'node:path'; import { SyntaxKind, type ClassDeclaration, type Decorator, type Project, type ObjectLiteralExpression, type PropertyAssignment, type StringLiteral, type NoSubstitutionTemplateLiteral, type TemplateExpression, } from 'ts-morph'; import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; const LIFECYCLE_HOOKS = [ 'ngOnInit', 'ngAfterViewInit', 'ngAfterContentInit', 'ngOnChanges', 'ngAfterViewChecked', 'ngAfterContentChecked', ] as const; /** * Async Pipe Misuse Rule. * * Rule ID: `angular:async-pipe-misuse` */ export class AsyncPipeMisuseRule implements AnalysisRule { public readonly id = 'angular:async-pipe-misuse'; public readonly name = 'Async Pipe Misuse'; public readonly description = "Detects incorrect usage of Angular's async pipe in inline templates"; public readonly severity = 'warning' as const; public readonly category = 'angular-performance'; public readonly tags = [ 'angular', 'async-pipe', 'performance', 'template', 'reactive', ]; async analyze(context: AnalysisContext): Promise { const violations: AnalysisResult[] = []; const sourceFile = context.sourceFile; const filePath = sourceFile.getFilePath(); const classes = sourceFile.getDescendantsOfKind( SyntaxKind.ClassDeclaration, ); for (const classDecl of classes) { violations.push( ...this.analyzeClass(classDecl, filePath, context.project), ); } return violations; } // ── private helpers ──────────────────────────────────────────────────────── private analyzeClass( classDecl: ClassDeclaration, filePath: string, project: Project, ): AnalysisResult[] { const componentDecorator = this.getComponentDecorator(classDecl); if (!componentDecorator) { return []; } // Prefer inline template; fall back to templateUrl const template = this.extractInlineTemplate(componentDecorator) ?? this.readTemplateFile(componentDecorator, filePath, project); if (!template) { return []; } const asyncPipeCounts = this.countAsyncPipeUsages(template); if (asyncPipeCounts.size === 0) { return []; } const violations: AnalysisResult[] = []; const className = classDecl.getName() ?? 'UnknownComponent'; const line = classDecl.getStartLineNumber(); // Pattern 1: multiple-async-subscriptions for (const [propName, count] of asyncPipeCounts) { if (count >= 2) { violations.push({ ruleId: this.id, message: `'${className}' uses '${propName} | async' ${count} times in its template. Each pipe creates a separate subscription — use '*ngIf="${propName} | async as val"' or add shareReplay(1) to share one subscription.`, filePath, line, column: 0, suggestedFix: `Replace repeated '${propName} | async' with a single '*ngIf="${propName} | async as ${this.toCamelCase(propName)}"' wrapper or pipe the observable through shareReplay(1).`, metadata: { violationType: 'multiple-async-subscriptions', className, propertyName: propName, count, }, }); } } // Pattern 2: double-async-subscription for (const [propName] of asyncPipeCounts) { if (this.hasManualSubscribeInLifecycleHook(classDecl, propName)) { violations.push({ ruleId: this.id, message: `'${className}' subscribes manually to '${propName}' in a lifecycle hook AND uses it with the async pipe in the template. This creates two subscriptions — remove the manual subscribe() call and rely solely on the async pipe.`, filePath, line, column: 0, suggestedFix: `Remove the manual 'this.${propName}.subscribe(...)' call from the lifecycle hook. The async pipe handles subscription and unsubscription automatically.`, metadata: { violationType: 'double-async-subscription', className, propertyName: propName, }, }); } } return violations; } /** * Returns the @Component decorator or null if this is not a component class. */ private getComponentDecorator(classDecl: ClassDeclaration): Decorator | null { return ( classDecl.getDecorators().find((d) => d.getName() === 'Component') ?? null ); } /** * Extracts the value of the `template:` property from a @Component decorator. * Returns null when the template is external (templateUrl) or absent. */ private extractInlineTemplate(decorator: Decorator): string | null { const args = decorator.getArguments(); if (args.length === 0) { return null; } const arg = args[0]; if (!arg || arg.getKind() !== SyntaxKind.ObjectLiteralExpression) { return null; } const configObj = arg as ObjectLiteralExpression; const properties = configObj.getProperties(); for (const prop of properties) { if (prop.getKind() !== SyntaxKind.PropertyAssignment) { continue; } const propertyAssignment = prop as PropertyAssignment; if (propertyAssignment.getNameNode().getText() !== 'template') { continue; } const initializer = propertyAssignment.getInitializer(); if (!initializer) { return null; } const kind = initializer.getKind(); if ( kind === SyntaxKind.StringLiteral || kind === SyntaxKind.NoSubstitutionTemplateLiteral ) { return ( initializer as StringLiteral | NoSubstitutionTemplateLiteral ).getLiteralValue(); } if (kind === SyntaxKind.TemplateExpression) { // Template with interpolations — get raw text without backticks const raw = (initializer as TemplateExpression).getFullText(); return raw.slice(1, -1); } } return null; } /** * Scans an inline template string for `propName | async` occurrences. * Returns a map from property name → occurrence count. */ private countAsyncPipeUsages(template: string): Map { const counts = new Map(); // Match: identifier | async (with optional whitespace) // The identifier may contain $, letters, digits, underscores. const regex = /\b([\w$]+)\s*\|\s*async\b/g; let match: RegExpExecArray | null; while ((match = regex.exec(template)) !== null) { const name = match[1]; if (name) { counts.set(name, (counts.get(name) ?? 0) + 1); } } return counts; } /** * Returns true if any lifecycle hook method body contains * `this..subscribe(`. */ private hasManualSubscribeInLifecycleHook( classDecl: ClassDeclaration, propName: string, ): boolean { const needle = `this.${propName}.subscribe(`; for (const hookName of LIFECYCLE_HOOKS) { const method = classDecl.getMethod(hookName); if (!method) { continue; } const body = method.getBody(); if (!body) { continue; } if (body.getText().includes(needle)) { return true; } } return false; } /** * Extracts the `templateUrl:` string value from a @Component decorator. * Returns null when the property is absent or not a simple string literal. */ private extractTemplateUrl(decorator: Decorator): string | null { const args = decorator.getArguments(); if (args.length === 0) { return null; } const arg = args[0]; if (!arg || arg.getKind() !== SyntaxKind.ObjectLiteralExpression) { return null; } const configObj = arg as ObjectLiteralExpression; const properties = configObj.getProperties(); for (const prop of properties) { if (prop.getKind() !== SyntaxKind.PropertyAssignment) { continue; } const propertyAssignment = prop as PropertyAssignment; if (propertyAssignment.getNameNode().getText() !== 'templateUrl') { continue; } const initializer = propertyAssignment.getInitializer(); if (!initializer) { return null; } const kind = initializer.getKind(); if (kind === SyntaxKind.StringLiteral) { return (initializer as StringLiteral).getLiteralValue(); } } return null; } /** * Reads the external template file referenced by `templateUrl:`. * Resolves the URL relative to the source file's directory using posix * semantics (Angular always uses forward-slash paths). * Returns null when the file cannot be read (missing, permission error, etc.). */ private readTemplateFile( decorator: Decorator, sourceFilePath: string, project: Project, ): string | null { const templateUrl = this.extractTemplateUrl(decorator); if (!templateUrl) { return null; } // Resolve relative to the source file directory using posix paths. // ts-morph virtual FS uses posix-style paths internally on all platforms. const sourceDir = posix.dirname(sourceFilePath.replaceAll('\\', '/')); const resolvedPath = posix.resolve(sourceDir, templateUrl); try { return project.getFileSystem().readFileSync(resolvedPath, 'utf-8'); } catch { return null; // File not found or unreadable — silently skip } } /** * Converts an observable property name like `user$` → `user` for use in * suggested fix messages. */ private toCamelCase(propName: string): string { return propName.replace(/\$$/, ''); } }