/** * @angular-modernizer/plugin-angular - Promise vs Observable Anti-Pattern Rule * * Detects anti-patterns where Angular code converts RxJS Observables to Promises, * losing reactive programming benefits (cancellation, retry, composition). * * * Detection targets: * 1. .toPromise() calls in any class * 2. async component methods (async keyword on @Component methods) * 3. @Injectable methods returning Promise wrapping HttpClient * 4. Sequential awaits in the same method (parallelizable with combineLatest) */ import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; import type { ClassDeclaration, MethodDeclaration, SourceFile } from 'ts-morph'; import { SyntaxKind } from 'ts-morph'; /** * Promise vs Observable Anti-Pattern Analysis Rule * * @example * ```typescript * const rule = new PromiseObservableAnalysisRule(); * const violations = await rule.analyze(context); * ``` */ export class PromiseObservableAnalysisRule implements AnalysisRule { public readonly id = 'angular:promise-vs-observable-anti-pattern'; public readonly name = 'Promise vs Observable Anti-Pattern'; public readonly description = 'Detects anti-patterns where Observables are converted to Promises, causing memory leaks and lost cancellation'; public readonly severity = 'warning' as const; public readonly category = 'rxjs'; public readonly tags = [ 'angular', 'rxjs', 'observable', 'promise', 'performance', 'reactive', ]; async analyze(context: AnalysisContext): Promise { const { sourceFile } = context; const results: AnalysisResult[] = []; const classes = sourceFile.getClasses(); if (classes.length === 0) { return results; } for (const classDecl of classes) { results.push(...this.analyzeClass(classDecl, sourceFile)); } return results; } private analyzeClass( classDecl: ClassDeclaration, sourceFile: SourceFile, ): AnalysisResult[] { const results: AnalysisResult[] = []; const filePath = sourceFile.getFilePath(); const isComponent = this.hasDecorator(classDecl, 'Component'); const isInjectable = this.hasDecorator(classDecl, 'Injectable'); if (!isComponent && !isInjectable) { return results; } const methods = classDecl.getMethods(); for (const method of methods) { // 1. Detect .toPromise() calls in method body const toPromiseViolations = this.detectToPromiseCalls(method, filePath); results.push(...toPromiseViolations); // 2. Detect async methods on @Component (async keyword = Promise) if (isComponent && method.isAsync()) { const hasToPromise = this.methodBodyContains(method, '.toPromise()'); const hasAwait = this.methodBodyContains(method, 'await '); if (hasToPromise || hasAwait) { results.push({ ruleId: this.id, message: `Async component method '${method.getName()}' uses Promise pattern. Convert to Observable property with async pipe.`, filePath, line: method.getStartLineNumber(), column: method.getStart(), metadata: { type: 'asyncComponentMethod', methodName: method.getName(), }, }); } } // 3. Detect @Injectable methods returning Promise if (isInjectable) { const isPromiseMethod = method.isAsync() || (method.getReturnTypeNode()?.getText() ?? '').includes('Promise<'); const usesHttpClient = this.methodBodyContains(method, 'this.http.'); const usesToPromise = this.methodBodyContains(method, '.toPromise()'); if (isPromiseMethod && (usesHttpClient || usesToPromise)) { // Only flag if not already flagged by toPromise detection const alreadyFlagged = results.some( (r) => r.line === method.getStartLineNumber() && r.metadata?.['type'] === 'toPromise', ); if (!alreadyFlagged) { results.push({ ruleId: this.id, message: `Service method '${method.getName()}' returns Promise wrapping HttpClient. Convert to Observable.`, filePath, line: method.getStartLineNumber(), column: method.getStart(), metadata: { type: 'promiseWrapperService', methodName: method.getName(), }, }); } } } // 4. Detect sequential awaits (combinable with combineLatest) if (isComponent || isInjectable) { const awaitCount = this.countAwaitExpressions(method); if (awaitCount >= 2) { results.push({ ruleId: this.id, message: `Method '${method.getName()}' has ${awaitCount} sequential awaits. Use combineLatest() for parallel execution.`, filePath, line: method.getStartLineNumber(), column: method.getStart(), metadata: { type: 'sequentialAwait', awaitCount, methodName: method.getName(), }, }); } } // 5. Detect manual ChangeDetectorRef.detectChanges() after async/await if (isComponent && method.isAsync()) { const hasDetectChanges = this.methodBodyContains( method, '.detectChanges()', ); if (hasDetectChanges) { results.push({ ruleId: this.id, message: `Method '${method.getName()}' calls detectChanges() manually after await. Use async pipe with OnPush instead.`, filePath, line: method.getStartLineNumber(), column: method.getStart(), metadata: { type: 'manualChangeDetection', methodName: method.getName(), }, }); } } } return results; } private detectToPromiseCalls( method: MethodDeclaration, filePath: string, ): AnalysisResult[] { const results: AnalysisResult[] = []; const callExpressions = method.getDescendantsOfKind( SyntaxKind.CallExpression, ); for (const call of callExpressions) { const expr = call.getExpression(); if (expr.getKind() === SyntaxKind.PropertyAccessExpression) { const propAccess = expr.asKindOrThrow( SyntaxKind.PropertyAccessExpression, ); if (propAccess.getName() === 'toPromise') { results.push({ ruleId: this.id, message: `'.toPromise()' converts Observable to Promise, losing cancellation and retry support. Use lastValueFrom() or async pipe instead.`, filePath, line: call.getStartLineNumber(), column: call.getStart(), metadata: { type: 'toPromise', methodName: method.getName() }, }); } } } return results; } private methodBodyContains( method: MethodDeclaration, needle: string, ): boolean { return (method.getBodyText() ?? '').includes(needle); } private countAwaitExpressions(method: MethodDeclaration): number { return method.getDescendantsOfKind(SyntaxKind.AwaitExpression).length; } private hasDecorator( classDecl: ClassDeclaration, decoratorName: string, ): boolean { return classDecl.getDecorators().some((d) => d.getName() === decoratorName); } }