/** * AnyToInterfaceOrchestrator * * Contains all business logic for the Any to Interface Transform Rule. * Performs two passes over the source file: * * 1. Collect specs — find all `any`-typed parameters and class properties * that have downstream property accesses, build InterfaceSpec for each. * 2. Transform — replace `any` annotations with the interface name, then * insert the generated interface declarations after the last import. * * Idempotency: `replaceAnyAnnotations` runs before `generateInterfaces`. * On the second run, no `any` nodes remain, so no specs are collected and * no modifications are made. */ import type { TransformContext } from '@angular-modernizer/plugin-system'; import type { PublicApi } from '@angular-modernizer/api'; import { SyntaxKind, type SourceFile, type Node, type ParameterDeclaration, type PropertyDeclaration, } from 'ts-morph'; interface InterfaceSpec { interfaceName: string; properties: string[]; targetNodes: (ParameterDeclaration | PropertyDeclaration)[]; } export class AnyToInterfaceOrchestrator { private readonly DOM_EVENT_PROPERTIES = new Set([ 'preventDefault', 'stopPropagation', 'stopImmediatePropagation', 'target', 'currentTarget', 'relatedTarget', 'srcElement', 'bubbles', 'cancelable', 'cancelBubble', 'composed', 'defaultPrevented', 'eventPhase', 'isTrusted', 'timeStamp', 'type', 'returnValue', 'composedPath', 'initEvent', 'clientX', 'clientY', 'screenX', 'screenY', 'pageX', 'pageY', 'offsetX', 'offsetY', 'movementX', 'movementY', 'button', 'buttons', 'altKey', 'ctrlKey', 'metaKey', 'shiftKey', 'detail', 'which', 'key', 'code', 'keyCode', 'charCode', 'repeat', 'isComposing', 'data', 'dataTransfer', 'inputType', 'touches', 'targetTouches', 'changedTouches', ]); private readonly EVENT_PARAM_NAMES = new Set([ 'event', 'e', 'evt', '$event', 'mouseevent', 'keyevent', 'touchevent', 'dragevent', 'focusevent', 'pointerevent', 'inputevent', 'changeevent', 'clickevent', 'submitevent', 'scrollevent', ]); private readonly ARRAY_PROTOTYPE_METHODS = new Set([ 'forEach', 'map', 'filter', 'reduce', 'reduceRight', 'find', 'findIndex', 'findLast', 'findLastIndex', 'some', 'every', 'includes', 'indexOf', 'lastIndexOf', 'slice', 'splice', 'push', 'pop', 'shift', 'unshift', 'sort', 'reverse', 'fill', 'flat', 'flatMap', 'entries', 'keys', 'values', 'at', 'copyWithin', 'join', 'concat', 'length', 'toString', ]); private isKnownDomEvent(paramName: string, accesses: string[]): boolean { return ( this.EVENT_PARAM_NAMES.has(paramName.toLowerCase()) && accesses.length > 0 && accesses.every((a) => this.DOM_EVENT_PROPERTIES.has(a)) ); } private isArrayLikeUsage(accesses: string[]): boolean { return ( accesses.length > 0 && accesses.every((a) => this.ARRAY_PROTOTYPE_METHODS.has(a)) ); } private readonly STRING_PROTOTYPE_METHODS = new Set([ 'charAt', 'charCodeAt', 'codePointAt', 'includes', 'startsWith', 'endsWith', 'indexOf', 'lastIndexOf', 'search', 'match', 'matchAll', 'replace', 'replaceAll', 'slice', 'substring', 'substr', 'split', 'trim', 'trimStart', 'trimEnd', 'padStart', 'padEnd', 'repeat', 'toLowerCase', 'toUpperCase', 'toLocaleLowerCase', 'toLocaleUpperCase', 'normalize', 'localeCompare', 'toString', 'valueOf', 'at', 'length', ]); private isStringLikeUsage(accesses: string[]): boolean { return ( accesses.length > 0 && accesses.every((a) => this.STRING_PROTOTYPE_METHODS.has(a)) ); } async run(context: TransformContext): Promise { const { sourceFile } = context; const paramSpecs = this.processAnyParameters(sourceFile); const propSpecs = this.processAnyProperties(sourceFile); const allSpecs = this.deduplicateSpecs([...paramSpecs, ...propSpecs]); // Replace any annotations first — makes second run naturally idempotent this.replaceAnyAnnotations(allSpecs); // Insert interfaces after the last import this.generateInterfaces(sourceFile, allSpecs); } private processAnyParameters(sourceFile: SourceFile): InterfaceSpec[] { const specs: InterfaceSpec[] = []; const anyNodes = sourceFile.getDescendantsOfKind(SyntaxKind.AnyKeyword); for (const node of anyNodes) { const parent = node.getParent(); if (!parent) { continue; } if (parent.getKind() === SyntaxKind.ArrayType) { continue; } const param = parent.asKind(SyntaxKind.Parameter); if (!param) { continue; } const paramName = param.getName(); const scope = param.getParent(); if (!scope) { continue; } const accesses = this.collectPropertyAccesses(scope, paramName); if (accesses.length === 0) { continue; } if (this.isKnownDomEvent(paramName, accesses)) { continue; } if (this.isArrayLikeUsage(accesses)) { continue; } if (this.isStringLikeUsage(accesses)) { continue; } specs.push({ interfaceName: this.buildInterfaceName(paramName), properties: accesses, targetNodes: [param], }); } return specs; } private processAnyProperties(sourceFile: SourceFile): InterfaceSpec[] { const specs: InterfaceSpec[] = []; const anyNodes = sourceFile.getDescendantsOfKind(SyntaxKind.AnyKeyword); for (const node of anyNodes) { const parent = node.getParent(); if (!parent) { continue; } if (parent.getKind() === SyntaxKind.ArrayType) { continue; } const prop = parent.asKind(SyntaxKind.PropertyDeclaration); if (!prop) { continue; } const propName = prop.getName(); const scope = prop.getParent(); if (!scope) { continue; } const accesses = this.collectThisPropertyAccesses(scope, propName); if (accesses.length === 0) { continue; } specs.push({ interfaceName: this.buildInterfaceName(propName), properties: accesses, targetNodes: [prop], }); } return specs; } private collectPropertyAccesses( scope: Node, identifierName: string, ): string[] { const names = scope .getDescendantsOfKind(SyntaxKind.PropertyAccessExpression) .filter( (pae) => pae.getExpression().getKind() === SyntaxKind.Identifier && pae.getExpression().getText() === identifierName, ) .map((pae) => pae.getName()); return [...new Set(names)]; } private collectThisPropertyAccesses( scope: Node, propertyName: string, ): string[] { const names = scope .getDescendantsOfKind(SyntaxKind.PropertyAccessExpression) .filter((pae) => { const inner = pae.getExpression(); if (inner.getKind() !== SyntaxKind.PropertyAccessExpression) { return false; } const innerPae = inner.asKindOrThrow( SyntaxKind.PropertyAccessExpression, ); return ( innerPae.getExpression().getKind() === SyntaxKind.ThisKeyword && innerPae.getName() === propertyName ); }) .map((pae) => pae.getName()); return [...new Set(names)]; } private deduplicateSpecs(specs: InterfaceSpec[]): InterfaceSpec[] { // Group by interfaceName first const byName = new Map(); for (const spec of specs) { const group = byName.get(spec.interfaceName) ?? []; group.push(spec); byName.set(spec.interfaceName, group); } const result: InterfaceSpec[] = []; for (const [name, group] of byName) { // Sub-group by canonical (sorted) property set — only merge identical shapes const byProps = new Map(); for (const spec of group) { const propKey = [...spec.properties].sort().join('\0'); const existing = byProps.get(propKey); if (existing) { // Identical shape → merge targetNodes only existing.targetNodes = [...existing.targetNodes, ...spec.targetNodes]; } else { byProps.set(propKey, { ...spec, properties: [...spec.properties], targetNodes: [...spec.targetNodes], }); } } // Assign names: first shape = IFoo, subsequent = IFoo2, IFoo3, … let index = 1; for (const spec of byProps.values()) { spec.interfaceName = index === 1 ? name : `${name}${index}`; result.push(spec); index++; } } return result; } private replaceAnyAnnotations(specs: InterfaceSpec[]): void { for (const spec of specs) { for (const node of spec.targetNodes) { node.setType(spec.interfaceName); } } } private generateInterfaces( sourceFile: SourceFile, specs: InterfaceSpec[], ): void { const newSpecs = specs.filter( (spec) => !this.interfaceAlreadyExists(sourceFile, spec.interfaceName), ); if (newSpecs.length === 0) { return; } const insertionIndex = this.findInsertionPoint(sourceFile); for (let i = 0; i < newSpecs.length; i++) { const spec = newSpecs[i]!; sourceFile.insertInterface(insertionIndex + i, { name: spec.interfaceName, properties: spec.properties.map((p) => ({ name: p, type: 'unknown' })), }); } } private findInsertionPoint(sourceFile: SourceFile): number { const imports = sourceFile.getImportDeclarations(); if (imports.length === 0) { return 0; } return imports.at(-1)!.getChildIndex() + 1; } private interfaceAlreadyExists( sourceFile: SourceFile, interfaceName: string, ): boolean { return sourceFile.getInterface(interfaceName) !== undefined; } private buildInterfaceName(name: string): string { return 'I' + name[0]!.toUpperCase() + name.slice(1); } }