/** * @angular-modernizer/plugin-angular - Any to Interface Analysis Rule * * Detects `any`-typed parameters and class properties that are accessed by * property name, indicating a named shape that can be replaced with a * generated TypeScript interface. * * Detection scope: * - `Parameter` nodes typed as `any` whose identifier is accessed via dot * notation inside the containing function/method body. * - `PropertyDeclaration` nodes typed as `any` whose name is accessed via * `this..` inside the containing class body. * * Skipped: * - `any[]` — ArrayType parent nodes are ignored. * - `.d.ts` declaration files. * - Parameters or properties with no downstream property accesses (no named * shape can be inferred). * * @example * ```typescript * const rule = new AnyToInterfaceAnalysisRule(); * const results = await rule.analyze({ sourceFile }); * // results[0].metadata.accessedProperties → ['userId', 'name'] * // results[0].metadata.suggestedInterfaceName → 'IData' * ``` */ import { SyntaxKind, type SourceFile, type Node } from 'ts-morph'; import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; export class AnyToInterfaceAnalysisRule implements AnalysisRule { public readonly id = 'angular:any-to-interface'; public readonly name = 'Any to Interface'; public readonly description = 'Detects any-typed parameters and properties that are accessed by name and can be replaced with a generated interface'; public readonly severity = 'warning'; public readonly category = 'angular-typescript'; public readonly tags = ['angular', 'typescript', 'any', 'interface']; 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 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 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 isStringLikeUsage(accesses: string[]): boolean { return ( accesses.length > 0 && accesses.every((a) => this.STRING_PROTOTYPE_METHODS.has(a)) ); } async analyze(context: AnalysisContext): Promise { const { sourceFile } = context; if (sourceFile.getFilePath().endsWith('.d.ts')) { return []; } return this.detectAnyNodes(sourceFile, sourceFile.getFilePath()); } private detectAnyNodes( sourceFile: SourceFile, filePath: string, ): AnalysisResult[] { const violations: AnalysisResult[] = []; const anyNodes = sourceFile.getDescendantsOfKind(SyntaxKind.AnyKeyword); for (const node of anyNodes) { const parent = node.getParent(); if (!parent) { continue; } // Skip any[] — parent is ArrayType if (parent.getKind() === SyntaxKind.ArrayType) { continue; } const asParam = parent.asKind(SyntaxKind.Parameter); const asProp = parent.asKind(SyntaxKind.PropertyDeclaration); if (asParam) { const paramName = asParam.getName(); const scope = asParam.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; } violations.push({ ruleId: this.id, filePath, message: `Parameter '${paramName}' uses 'any' — consider replacing with interface ${this.buildInterfaceName(paramName)}`, line: node.getStartLineNumber(), column: node.getStart() - node.getStartLinePos(), suggestedFix: 'Replace any with a generated interface', metadata: { violationType: 'any-parameter', targetName: paramName, accessedProperties: accesses, suggestedInterfaceName: this.buildInterfaceName(paramName), }, }); continue; } if (asProp) { const propName = asProp.getName(); const scope = asProp.getParent(); if (!scope) { continue; } const accesses = this.collectThisPropertyAccesses(scope, propName); if (accesses.length === 0) { continue; } violations.push({ ruleId: this.id, filePath, message: `Property '${propName}' uses 'any' — consider replacing with interface ${this.buildInterfaceName(propName)}`, line: node.getStartLineNumber(), column: node.getStart() - node.getStartLinePos(), suggestedFix: 'Replace any with a generated interface', metadata: { violationType: 'any-property', targetName: propName, accessedProperties: accesses, suggestedInterfaceName: this.buildInterfaceName(propName), }, }); } } return violations; } /** * Collects property names accessed as `identifierName.propName` within the * given scope node. Handles the parameter case. */ 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)]; } /** * Collects property names accessed as `this.propertyName.propName` within the * given scope node. Handles the class property case. */ 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 buildInterfaceName(name: string): string { return 'I' + (name[0]?.toUpperCase() ?? '') + name.slice(1); } }