/** * @angular-modernizer/plugin-angular - Missing Output Transform Orchestrator * * Transforms presentational component methods that call services directly into * event-emitting methods by adding modern output() signals. * * Philosophy: "Thin Rule, Thick Orchestrator" * * Detection supports both Angular writing styles: * - Constructor injection: constructor(private svc: MyService) {} * - inject() function: private svc = inject(MyService); * * Existing output detection supports both styles: * - Decorator: @Output() click = new EventEmitter() * - Signal: click = output() * * Three guarantees: * - Idempotent: skips methods that already have a corresponding output signal * - 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 } from 'ts-morph'; import { SyntaxKind } from 'ts-morph'; /** Infrastructure services allowed in presentational components — never flagged. */ const ALLOWED_SERVICES = new Set([ 'ChangeDetectorRef', 'ElementRef', 'Renderer2', 'ViewContainerRef', 'TemplateRef', 'NgZone', 'DestroyRef', ]); /** Suffixes identifying a dependency as a business service. */ const SERVICE_SUFFIXES = [ 'Service', 'Store', 'Repository', 'Api', 'HttpClient', 'Client', 'Provider', 'Manager', 'Facade', 'Gateway', ]; /** Lifecycle hooks — never transformed. */ const LIFECYCLE_HOOKS = new Set([ 'ngOnInit', 'ngOnDestroy', 'ngOnChanges', 'ngAfterViewInit', 'ngAfterViewChecked', 'ngAfterContentInit', 'ngAfterContentChecked', 'ngDoCheck', ]); interface OutputTransformInfo { method: MethodDeclaration; /** Generated output property name, e.g. 'deleteClick' */ outputName: string; /** TypeScript type for the output generic, e.g. 'void' | 'unknown' | 'string' */ outputType: string; /** Expression passed to .emit(), empty string for void */ payloadArg: string; } export class MissingOutputTransformOrchestrator { readonly ruleId = 'angular:missing-output-transform'; run(context: TransformContext): TransformResult { const { sourceFile, filePath } = context; const classDecl = this.findComponent(sourceFile); if (!classDecl) { return { ruleId: this.ruleId, modified: false, message: 'No @Component class found', filePath, }; } const serviceFieldNames = this.collectServiceFieldNames(classDecl); if (serviceFieldNames.size === 0) { return { ruleId: this.ruleId, modified: false, message: 'No service dependencies found', filePath, }; } const existingOutputNames = this.collectOutputNames(classDecl); const transforms = this.findMethodsToTransform( classDecl, serviceFieldNames, existingOutputNames, ); if (transforms.length === 0) { return { ruleId: this.ruleId, modified: false, message: 'No methods require output signals', filePath, }; } const originalText = sourceFile.getFullText(); try { // Apply in reverse source order so AST positions remain valid during insertion for (const t of [...transforms].reverse()) { this.addOutputProperty(classDecl, t); this.replaceMethodBody(t); } this.ensureOutputImport(sourceFile); return { ruleId: this.ruleId, modified: true, message: `Added ${transforms.length} output signal(s): ${transforms.map((t) => t.outputName).join(', ')}`, filePath, changeCount: transforms.length, metadata: { outputsAdded: transforms.map((t) => t.outputName), methodsTransformed: transforms.map((t) => t.method.getName()), originalText, }, }; } catch (err) { 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((c) => c.getDecorators().some((d) => d.getName() === 'Component')); } /** * Collect service field names from BOTH injection styles: * 1. Constructor injection: constructor(private svc: MyService) {} * 2. inject() function: private svc = inject(MyService); */ private collectServiceFieldNames(classDecl: ClassDeclaration): Set { const names = new Set(); // Constructor injection const ctor = classDecl.getConstructors()[0]; if (ctor) { for (const param of ctor.getParameters()) { if (this.isServiceType(param.getType().getText())) { names.add(param.getName()); } } } // inject() field injection: private svc = inject(MyService) for (const prop of classDecl.getProperties()) { const init = prop.getInitializer(); if (!init) { continue; } const match = /^inject\s*\(\s*(\w+)\s*\)/.exec(init.getText()); if (match && this.isServiceType(match[1]!)) { names.add(prop.getName()); } } return names; } private isServiceType(typeName: string): boolean { if (ALLOWED_SERVICES.has(typeName)) { return false; } return SERVICE_SUFFIXES.some((suffix) => typeName.includes(suffix)); } /** * Collect existing output names from BOTH Angular writing styles: * 1. Decorator: @Output() clickEvent = new EventEmitter() * 2. Signal: clickEvent = output() * * Returns lowercased names for convention-based matching. */ private collectOutputNames(classDecl: ClassDeclaration): Set { const names = new Set(); for (const prop of classDecl.getProperties()) { const hasOutputDecorator = prop .getDecorators() .some((d) => d.getName() === 'Output'); const initText = prop.getInitializer()?.getText() ?? ''; const isOutputSignal = initText.startsWith('output(') || initText.startsWith('output<'); if (hasOutputDecorator || isOutputSignal) { names.add(prop.getName().toLowerCase()); } } return names; } /** * Find methods that call service fields and lack a corresponding output signal. * * Convention-based matching (same as PresentationalComponentViolationRule): * - `delete` → looks for: deleteClick, deleteEvent, deleteChange, onDelete, etc. */ private findMethodsToTransform( classDecl: ClassDeclaration, serviceFieldNames: Set, existingOutputNames: Set, ): OutputTransformInfo[] { const results: OutputTransformInfo[] = []; const fieldPattern = new RegExp( String.raw`this\.(${Array.from(serviceFieldNames).join('|')})\.\w+\(`, ); for (const method of classDecl.getMethods()) { const methodName = method.getName(); if (LIFECYCLE_HOOKS.has(methodName)) { continue; } // Skip private/protected methods — they aren't called from templates if ( method.hasModifier(SyntaxKind.PrivateKeyword) || method.hasModifier(SyntaxKind.ProtectedKeyword) ) { continue; } const body = method.getBodyText() ?? ''; if (!fieldPattern.test(body)) { continue; } // Skip if a corresponding output already exists (idempotency) if (this.hasCorrespondingOutput(methodName, existingOutputNames)) { continue; } const outputName = `${methodName}Click`; const { payloadArg, outputType } = this.inferPayload( method, serviceFieldNames, body, ); results.push({ method, outputName, outputType, payloadArg }); } return results; } /** * Convention-based: `delete` matches deleteClick, deleteEvent, deleteChange, onDelete. * Same logic as PresentationalComponentViolationRule to stay consistent. */ private hasCorrespondingOutput( methodName: string, outputNames: Set, ): boolean { const lower = methodName.toLowerCase(); const conventions = [ 'click', 'event', 'change', 'submit', 'emit', 'action', ]; return ( conventions.some((suffix) => outputNames.has(lower + suffix)) || outputNames.has('on' + lower) ); } /** * Infer the event payload from the method: * 1. If method has exactly 1 parameter → use its type as the output type * 2. Otherwise extract the first arg to the service call: * - `this.svc.method()` → void * - `this.svc.method(this.user.id)` → unknown (type not resolvable without full project) */ private inferPayload( method: MethodDeclaration, serviceFieldNames: Set, bodyText: string, ): { payloadArg: string; outputType: string } { // Use method parameter type if exactly one param const params = method.getParameters(); if (params.length === 1) { const paramName = params[0]!.getName(); const paramType = params[0]!.getType().getText(); return { payloadArg: paramName, outputType: paramType }; } // Extract first argument from the service call const serviceCallRegex = new RegExp( String.raw`this\.(?:${Array.from(serviceFieldNames).join('|')})\.\w+\s*\(([^)]*)\)`, ); const match = bodyText.match(serviceCallRegex); const firstArg = match?.[1]?.split(',')[0]?.trim() ?? ''; if (!firstArg) { return { payloadArg: '', outputType: 'void' }; } return { payloadArg: firstArg, outputType: 'unknown' }; } // --------------------------------------------------------------------------- // AST modification helpers // --------------------------------------------------------------------------- /** * Insert the output signal property before the first method in the class. * Handles both styles by always generating modern output() signal. */ private addOutputProperty( classDecl: ClassDeclaration, t: OutputTransformInfo, ): void { const structure = { name: t.outputName, initializer: `output<${t.outputType}>()`, }; // Find insertion index: just before the first method const methods = classDecl.getMethods(); if (methods.length > 0) { const firstMethod = methods[0]!; const methodIdx = firstMethod.getChildIndex(); classDecl.insertProperty(methodIdx, structure); } else { classDecl.addProperty(structure); } } /** * Replace the method body with a single emit call. * The original service interaction is fully delegated to the parent container. */ private replaceMethodBody(t: OutputTransformInfo): void { const emitCall = t.payloadArg ? `this.${t.outputName}.emit(${t.payloadArg});` : `this.${t.outputName}.emit();`; t.method.setBodyText(emitCall); } /** * Ensure `output` is imported from `@angular/core`. * Adds to existing `@angular/core` import if present, otherwise creates one. */ private ensureOutputImport(sourceFile: SourceFile): void { const angularCoreImport = sourceFile .getImportDeclarations() .find((imp) => imp.getModuleSpecifierValue() === '@angular/core'); if (!angularCoreImport) { sourceFile.addImportDeclaration({ moduleSpecifier: '@angular/core', namedImports: ['output'], }); return; } const alreadyImported = angularCoreImport .getNamedImports() .some((n) => n.getName() === 'output'); if (!alreadyImported) { angularCoreImport.addNamedImport('output'); } } }