/** * @angular-modernizer/plugin-angular - Subscription Transform Orchestrator * * Core transformation logic for fixing raw .subscribe() calls in lifecycle hooks. * Converts them to managed subscriptions using takeUntilDestroyed(). */ import type { TransformContext, TransformResult, } from '@angular-modernizer/plugin-system'; import type { PublicApi } from '@angular-modernizer/api'; import type { ClassDeclaration, MethodDeclaration, SourceFile } from 'ts-morph'; import { Scope, SyntaxKind } from 'ts-morph'; /** * Lifecycle hooks where .subscribe() without takeUntilDestroyed() is a violation. * Must match the set in PresentationalComponentViolationRule. */ const LIFECYCLE_HOOKS = new Set([ 'ngOnInit', 'ngAfterViewInit', 'ngAfterContentInit', 'ngOnChanges', 'ngAfterViewChecked', 'ngAfterContentChecked', ]); /** * Subscription Transform Orchestrator * * Three guarantees: * 1. **Idempotent** — skips hooks that already have takeUntilDestroyed, and * detects existing DestroyRef fields before adding a new one. * 2. **Atomic** — on any error, the original source text is restored. * 3. **Reversible** — original text is stored in metadata for rollback. */ export class SubscriptionTransformOrchestrator { readonly ruleId = 'angular:subscription-transform'; run(context: TransformContext): TransformResult { const { sourceFile, filePath } = context; // 1. Find @Component class const classDecl = this.findComponent(sourceFile); if (!classDecl) { return { ruleId: this.ruleId, modified: false, message: 'No @Component class found', filePath, }; } // 2. Find lifecycle hooks with raw .subscribe() (no takeUntilDestroyed) const violatingHooks = this.findViolatingLifecycleHooks(classDecl); if (violatingHooks.length === 0) { return { ruleId: this.ruleId, modified: false, message: 'No subscription violations found', filePath, }; } // 3. Store original text for atomic rollback const originalText = sourceFile.getFullText(); try { // 4. Ensure DestroyRef field is present; get its field name const destroyRefFieldName = this.ensureDestroyRefField(classDecl); // 5. Transform each violating lifecycle hook — AST-based to avoid // matching .subscribe( inside string literals or comments. let transformCount = 0; for (const hook of violatingHooks) { const subscribeCalls = hook .getDescendantsOfKind(SyntaxKind.CallExpression) .filter((call) => { const propAccess = call .getExpression() .asKind(SyntaxKind.PropertyAccessExpression); return propAccess?.getName() === 'subscribe'; }); if (subscribeCalls.length > 0) { // Process in reverse source order so earlier node positions stay valid for (const call of [...subscribeCalls].reverse()) { const propAccess = call .getExpression() .asKindOrThrow(SyntaxKind.PropertyAccessExpression); const observable = propAccess.getExpression(); const args = call .getArguments() .map((a) => a.getText()) .join(', '); call.replaceWithText( `${observable.getText()}.pipe(takeUntilDestroyed(this.${destroyRefFieldName})).subscribe(${args})`, ); } transformCount++; } } if (transformCount === 0) { return { ruleId: this.ruleId, modified: false, message: 'No subscriptions were transformed', filePath, }; } // 6. Ensure required imports are present this.ensureImports(sourceFile); return { ruleId: this.ruleId, modified: true, message: `Added takeUntilDestroyed() to ${transformCount} lifecycle hook(s)`, filePath, changeCount: transformCount, metadata: { hooksTransformed: transformCount, destroyRefField: destroyRefFieldName, originalText, }, }; } catch (err) { // Atomic rollback on failure sourceFile.replaceWithText(originalText); return { ruleId: this.ruleId, modified: false, message: `Transformation failed: ${err instanceof Error ? err.message : String(err)}`, filePath, }; } } // --------------------------------------------------------------------------- // Detection helpers // --------------------------------------------------------------------------- private findComponent(sourceFile: SourceFile): ClassDeclaration | undefined { return sourceFile .getClasses() .find((cls) => cls.getDecorators().some((d) => d.getName() === 'Component'), ); } /** * Find lifecycle hooks that have .subscribe() calls but NOT takeUntilDestroyed. * Hooks that already use takeUntilDestroyed are already correct — skip them. */ private findViolatingLifecycleHooks( classDecl: ClassDeclaration, ): MethodDeclaration[] { return classDecl.getMethods().filter((method) => { if (!LIFECYCLE_HOOKS.has(method.getName())) { return false; } const body = method.getBodyText() ?? ''; return ( body.includes('.subscribe(') && !body.includes('takeUntilDestroyed') ); }); } // --------------------------------------------------------------------------- // DestroyRef injection // --------------------------------------------------------------------------- /** * Ensure the class has a DestroyRef field for use with takeUntilDestroyed. * Returns the field name to reference in the method body. * * Priority: * 1. Existing inject(DestroyRef) field → reuse * 2. Existing constructor param typed DestroyRef → reuse * 3. Neither → insert `private readonly destroyRef = inject(DestroyRef);` */ private ensureDestroyRefField(classDecl: ClassDeclaration): string { const existing = this.findExistingDestroyRefField(classDecl); if (existing) { return existing; } // Insert new field just before the first method (conventional placement) const fieldStructure = { scope: Scope.Private, isReadonly: true, name: 'destroyRef', initializer: 'inject(DestroyRef)', }; const methods = classDecl.getMethods(); if (methods.length > 0) { const firstMethod = methods[0]!; classDecl.insertProperty(firstMethod.getChildIndex(), fieldStructure); } else { classDecl.addProperty(fieldStructure); } return 'destroyRef'; } /** * Find an existing DestroyRef field: * - inject() style: `private x = inject(DestroyRef);` * - Constructor param: `constructor(private x: DestroyRef) {}` */ private findExistingDestroyRefField( classDecl: ClassDeclaration, ): string | null { // inject() style for (const prop of classDecl.getProperties()) { const init = prop.getInitializer()?.getText(); if (init && /inject\s*\(\s*DestroyRef\s*\)/.test(init)) { return prop.getName(); } } // Constructor injection style const ctor = classDecl.getConstructors()[0]; if (ctor) { for (const param of ctor.getParameters()) { // Use type annotation text (more reliable than resolved type in in-memory FS) const typeAnnotation = param.getTypeNode()?.getText(); if (typeAnnotation === 'DestroyRef') { return param.getName(); } } } return null; } // --------------------------------------------------------------------------- // Import management // --------------------------------------------------------------------------- /** * Ensure required imports are present: * - `inject` and `DestroyRef` from `@angular/core` * - `takeUntilDestroyed` from `@angular/core/rxjs-interop` */ private ensureImports(sourceFile: SourceFile): void { this.ensureNamedImports(sourceFile, '@angular/core', [ 'inject', 'DestroyRef', ]); this.ensureNamedImports(sourceFile, '@angular/core/rxjs-interop', [ 'takeUntilDestroyed', ]); } /** * Add named imports to a module specifier if not already present. * Creates a new import declaration if the module is not yet imported. */ private ensureNamedImports( sourceFile: SourceFile, modulePath: string, names: string[], ): void { const importDecl = sourceFile.getImportDeclaration( (decl) => decl.getModuleSpecifierValue() === modulePath, ); if (!importDecl) { sourceFile.addImportDeclaration({ moduleSpecifier: modulePath, namedImports: names, }); return; } const existingNames = new Set( importDecl.getNamedImports().map((ni) => ni.getName()), ); for (const name of names) { if (!existingNames.has(name)) { importDecl.addNamedImport(name); } } } }