/** * @angular-modernizer/plugin-angular - Sequential Await Transform Orchestrator * * Converts sequential independent await calls to parallel forkJoin() execution. * * Philosophy: "Thin Rule, Thick Orchestrator" * * Transformations: * - @Injectable services: sequential `const x = await obs` → forkJoin().pipe(map()) * - @Component methods: sequential `const x = await obs` / `this.x = await obs` → forkJoin().subscribe() * * Independence check: await[n+1] must not reference any variable from await[0..n]. * * Three guarantees: * - Idempotent: detects forkJoin presence before modifying * - Atomic: rollback on any error * - Reversible: stores originalText in metadata */ import type { TransformContext, TransformResult, } from '@angular-modernizer/plugin-system'; import type { PublicApi } from '@angular-modernizer/api'; import type { ClassDeclaration, MethodDeclaration, SourceFile, Statement, } from 'ts-morph'; import { SyntaxKind } from 'ts-morph'; interface AwaitInfo { type: 'const' | 'property'; /** Variable name used in destructuring: `const users` → 'users', `this.users` → 'users' */ varName: string; /** Original property access for re-assignment in subscribe: 'this.users' */ propAccess?: string; /** The observable expression after stripping await and .toPromise() */ observableExpr: string; } interface AwaitGroup { awaits: AwaitInfo[]; /** Statements before the await run (preserved verbatim) */ preStatements: string[]; /** Non-return statements after the await run (go into subscribe callback) */ postStatements: string[]; /** The return expression, if the method ends with `return ` */ returnExpr: string | null; } export class SequentialAwaitTransformOrchestrator { readonly ruleId = 'angular:sequential-await-to-forkjoin'; run(context: TransformContext): TransformResult { const { sourceFile, filePath } = context; const classInfo = this.findTargetClass(sourceFile); if (!classInfo) { return { ruleId: this.ruleId, modified: false, message: 'No @Injectable or @Component class found', filePath, }; } const { classDecl, isComponent } = classInfo; const methods = this.findEligibleMethods(classDecl); if (methods.length === 0) { return { ruleId: this.ruleId, modified: false, message: 'No sequential await methods found', filePath, }; } const originalText = sourceFile.getFullText(); try { let transformed = 0; for (const method of methods) { if (this.transformMethod(method, isComponent)) { transformed++; } } if (transformed === 0) { return { ruleId: this.ruleId, modified: false, message: 'No transformable sequential await groups found', filePath, }; } this.updateImports(sourceFile, isComponent); return { ruleId: this.ruleId, modified: true, message: `Converted ${transformed} method(s) from sequential await to forkJoin`, filePath, metadata: { originalText }, } as TransformResult & { metadata: Record }; } catch (error) { sourceFile.replaceWithText(originalText); return { ruleId: this.ruleId, modified: false, message: `Transformation failed: ${error instanceof Error ? error.message : String(error)}`, filePath, }; } } private transformMethod( method: MethodDeclaration, isComponent: boolean, ): boolean { const group = this.findAwaitGroup(method); if (!group) { return false; } // 1. Remove async method.setIsAsync(false); // 2. Update return type: Promise → Observable (services only) if (!isComponent) { const returnTypeNode = method.getReturnTypeNode(); if (returnTypeNode) { const typeText = returnTypeNode.getText(); if (typeText.startsWith('Promise<') && typeText.endsWith('>')) { const inner = typeText.slice('Promise<'.length, -1); method.setReturnType(`Observable<${inner}>`); } else if (typeText === 'Promise') { method.setReturnType('Observable'); } } } else { // Component void method: just remove the Promise return type const returnTypeNode = method.getReturnTypeNode(); if (returnTypeNode) { const typeText = returnTypeNode.getText(); if (typeText === 'Promise') { method.setReturnType('void'); } } } // 3. Replace body method.setBodyText(this.buildMethodBody(group, isComponent)); return true; } private findAwaitGroup(method: MethodDeclaration): AwaitGroup | null { const statements = method.getStatements(); for (let i = 0; i < statements.length; i++) { const stmt = statements[i]; if (!stmt) { continue; } const info = this.extractAwaitInfo(stmt); if (!info) { continue; } // Scan forward for consecutive independent awaits const awaits: AwaitInfo[] = [info]; let j = i + 1; while (j < statements.length) { const nextStmt = statements[j]; if (!nextStmt) { break; } const nextInfo = this.extractAwaitInfo(nextStmt); if (!nextInfo) { break; } const existingVarNames = awaits.map((a) => a.varName); if (!this.isIndependent(existingVarNames, nextInfo.observableExpr)) { break; } awaits.push(nextInfo); j++; } if (awaits.length < 2) { continue; } // not enough from position i // Collect pre-statements (before this group) const preStatements = statements.slice(0, i).map((s) => s.getText()); // Collect post-statements (after group) let returnExpr: string | null = null; const postStatements: string[] = []; for (const stmt of statements.slice(j)) { const retStmt = stmt.asKind(SyntaxKind.ReturnStatement); if (retStmt) { returnExpr = retStmt.getExpression()?.getText() ?? null; break; } postStatements.push(stmt.getText()); } return { awaits, preStatements, postStatements, returnExpr }; } return null; } private extractAwaitInfo(stmt: Statement): AwaitInfo | null { // Pattern 1: const varName = await const varStmt = stmt.asKind(SyntaxKind.VariableStatement); if (varStmt) { const decls = varStmt.getDeclarationList().getDeclarations(); if (decls.length !== 1) { return null; } const decl = decls[0]!; const init = decl.getInitializer(); if (!init || init.getKind() !== SyntaxKind.AwaitExpression) { return null; } const awaitExpr = init.asKindOrThrow(SyntaxKind.AwaitExpression); return { type: 'const', varName: decl.getName(), observableExpr: this.stripToPromise( awaitExpr.getExpression().getText(), ), }; } // Pattern 2: this.prop = await const exprStmt = stmt.asKind(SyntaxKind.ExpressionStatement); if (exprStmt) { const expr = exprStmt.getExpression(); if (expr.getKind() !== SyntaxKind.BinaryExpression) { return null; } const binExpr = expr.asKindOrThrow(SyntaxKind.BinaryExpression); if (binExpr.getOperatorToken().getKind() !== SyntaxKind.EqualsToken) { return null; } const left = binExpr.getLeft(); const right = binExpr.getRight(); if (left.getKind() !== SyntaxKind.PropertyAccessExpression) { return null; } if (right.getKind() !== SyntaxKind.AwaitExpression) { return null; } const propAccess = left.getText(); if (!propAccess.startsWith('this.')) { return null; } const varName = propAccess.slice('this.'.length); const awaitExpr = right.asKindOrThrow(SyntaxKind.AwaitExpression); return { type: 'property', varName, propAccess, observableExpr: this.stripToPromise( awaitExpr.getExpression().getText(), ), }; } return null; } private isIndependent( existingVarNames: string[], nextExprText: string, ): boolean { return existingVarNames.every( (name) => !new RegExp(String.raw`\b${name}\b`).test(nextExprText), ); } private stripToPromise(exprText: string): string { return exprText.endsWith('.toPromise()') ? exprText.slice(0, -'.toPromise()'.length) : exprText; } private buildMethodBody(group: AwaitGroup, isComponent: boolean): string { const parts: string[] = [...group.preStatements]; parts.push(this.buildForkJoinBlock(group, isComponent)); return parts.join('\n'); } private buildForkJoinBlock(group: AwaitGroup, isComponent: boolean): string { const observables = group.awaits .map((a) => ` ${a.observableExpr}`) .join(',\n'); const vars = group.awaits.map((a) => a.varName).join(', '); // Service with return expression → forkJoin().pipe(map()) if (!isComponent && group.returnExpr !== null) { return [ `return forkJoin([`, `${observables},`, `]).pipe(`, ` map(([${vars}]) => ${group.returnExpr})`, `);`, ].join('\n'); } // Component / void method → forkJoin().subscribe() const subscribeBody: string[] = []; // Re-assignments for property-type awaits for (const a of group.awaits.filter((x) => x.type === 'property')) { subscribeBody.push(` ${a.propAccess} = ${a.varName};`); } // Post-statements go into the callback for (const s of group.postStatements) { subscribeBody.push(` ${s}`); } if (subscribeBody.length === 0) { return [`forkJoin([`, `${observables},`, `]).subscribe();`].join('\n'); } return [ `forkJoin([`, `${observables},`, `]).subscribe(([${vars}]) => {`, subscribeBody.join('\n'), `});`, ].join('\n'); } private updateImports(sourceFile: SourceFile, isComponent: boolean): void { const existing = this.getNamedImports(sourceFile, 'rxjs'); const needed: string[] = []; if (!existing.has('forkJoin')) { needed.push('forkJoin'); } if (!isComponent && !existing.has('map')) { needed.push('map'); } if (!existing.has('Observable')) { needed.push('Observable'); } if (needed.length === 0) { return; } const decl = sourceFile.getImportDeclaration( (d) => d.getModuleSpecifierValue() === 'rxjs', ); if (decl) { for (const name of needed) { decl.addNamedImport(name); } } else { sourceFile.addImportDeclaration({ moduleSpecifier: 'rxjs', namedImports: needed, }); } } private getNamedImports( sourceFile: SourceFile, moduleSpecifier: string, ): Set { const decl = sourceFile.getImportDeclaration( (d) => d.getModuleSpecifierValue() === moduleSpecifier, ); if (!decl) { return new Set(); } return new Set(decl.getNamedImports().map((ni) => ni.getName())); } private findTargetClass( sourceFile: SourceFile, ): { classDecl: ClassDeclaration; isComponent: boolean } | null { for (const cls of sourceFile.getClasses()) { const decoratorNames = cls.getDecorators().map((d) => d.getName()); if (decoratorNames.includes('Injectable')) { return { classDecl: cls, isComponent: false }; } if (decoratorNames.includes('Component')) { return { classDecl: cls, isComponent: true }; } } return null; } private findEligibleMethods( classDecl: ClassDeclaration, ): MethodDeclaration[] { return classDecl.getMethods().filter((method) => { // Already uses forkJoin — skip (idempotency) const body = method.getBodyText() ?? ''; if (body.includes('forkJoin(')) { return false; } // Must be async and have at least one await if (!method.isAsync()) { return false; } return body.includes('await '); }); } }