/** * @angular-modernizer/plugin-angular - Untyped Dynamic Component Rule * * Detects `.instance` accesses on `ComponentRef`-typed expressions that lack * an explicit type contract. Dynamic component hosts that access component * members through an untyped (or `any`-typed) `ComponentRef` create implicit * contracts that the TypeScript compiler cannot verify. * * Detection scope: * - `PropertyAccessExpression` nodes whose name is `instance` and whose * expression resolves to a type that includes `ComponentRef`. * - Only flagged when the `.instance` access is itself chained with a member * access (`.instance.memberName`), indicating an actual contract use. * * Skipped: * - `.d.ts` declaration files. * - `ComponentRef` where `T` is a concrete (non-`any`, non-`unknown`) * named type — the contract is already explicit. * - Accesses preceded by a type assertion (`as IMyInterface` or * ``), which means the developer has opted in to explicit * typing at the call site. * - Event member accesses when `checkEventSubscriptions` config option is * `false`. * * @example * ```typescript * const rule = new UntypedDynamicComponentRule(); * const results = await rule.analyze(createContext(sourceFile, project)); * // results[0].metadata.accessedMember → 'setData' * // results[0].metadata.memberType → 'method' * // results[0].metadata.isFullyUntyped → true * ``` */ import { SyntaxKind, type PropertyAccessExpression, type Node } from 'ts-morph'; import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; export interface UntypedDynamicComponentConfig { checkEventSubscriptions: boolean; } export interface UntypedDynamicComponentMetadata { violationType: 'untyped-dynamic-component'; accessedMember: string; memberType: 'method' | 'property' | 'event'; componentRefExpression: string; componentRefType: string; isFullyUntyped: boolean; hasAnyType: boolean; refactoringComplexity: 'medium' | 'high'; } export class UntypedDynamicComponentRule implements AnalysisRule { public readonly id = 'angular:untyped-dynamic-component'; public readonly name = 'Implicit Contract'; public readonly description = 'Detects untyped .instance access on ComponentRef without explicit type contracts'; public readonly severity = 'warning' as const; public readonly category = 'angular-architecture'; public readonly tags = [ 'angular', 'type-safety', 'dynamic-components', 'contracts', ]; private readonly config: UntypedDynamicComponentConfig; constructor(config?: Partial) { this.config = { checkEventSubscriptions: true, ...config, }; } async analyze(context: AnalysisContext): Promise { const { sourceFile } = context; const filePath = sourceFile.getFilePath(); if (filePath.endsWith('.d.ts')) { return []; } const violations: AnalysisResult[] = []; const nodes = sourceFile.getDescendantsOfKind( SyntaxKind.PropertyAccessExpression, ); for (const node of nodes) { const result = this.detectUntypedDynamicComponent(node, filePath); if (result !== null) { violations.push(result); } } return violations; } private detectUntypedDynamicComponent( propAccess: PropertyAccessExpression, filePath: string, ): AnalysisResult | null { // Step 1 — Guard: only interested in `.instance` accesses if (propAccess.getName() !== 'instance') { return null; } // Step 2 — Resolve the base expression type const baseExpr = propAccess.getExpression(); const typeText = baseExpr.getType().getText(); // Step 3 — Must be a ComponentRef type if (!this.isComponentRefType(typeText)) { return null; } // Step 4 — Must have a parent PropertyAccessExpression (i.e. .instance.memberName) const parent = propAccess.getParent(); if (!parent || parent.getKind() !== SyntaxKind.PropertyAccessExpression) { return null; } // Step 5 — Cast parent and get the accessed member name const memberAccess = parent as PropertyAccessExpression; const memberName = memberAccess.getName(); // Step 6 — Skip type-asserted accesses if (this.hasTypeAssertion(memberAccess)) { return null; } // Step 7 — Evaluate generic argument to determine if problematic const genericArg = this.extractGenericArg(typeText); // `unknown` is semantically equivalent to no type — no specific contract can be expressed const isFullyUntyped = !genericArg || genericArg === 'unknown'; const hasAnyType = genericArg === 'any'; const isProblematic = isFullyUntyped || hasAnyType; if (!isProblematic) { return null; } // Step 8 — Determine member type const memberType = this.determineMemberType(memberAccess); // Step 9 — Optionally skip event subscriptions if (memberType === 'event' && !this.config.checkEventSubscriptions) { return null; } // Step 10 — Build metadata const metadata: UntypedDynamicComponentMetadata = { violationType: 'untyped-dynamic-component', accessedMember: memberName, memberType, componentRefExpression: baseExpr.getText(), componentRefType: typeText, isFullyUntyped, hasAnyType, refactoringComplexity: isFullyUntyped ? 'high' : 'medium', }; // Step 11 — Return the analysis result return { ruleId: this.id, message: this.generateMessage(memberName, memberType, genericArg), filePath, line: propAccess.getStartLineNumber(), column: propAccess.getStart() - propAccess.getStartLinePos(), suggestedFix: this.generateSuggestedFix(memberName, memberType), metadata: metadata as unknown as Record, }; } private isComponentRefType(typeText: string): boolean { return ( typeText.includes('ComponentRef') || typeText.includes('DynamicComponentLoader') ); } private extractGenericArg(typeText: string): string | undefined { const match = /ComponentRef<([^>]+)>/.exec(typeText); return match ? match[1]!.trim() : undefined; } private hasTypeAssertion(node: Node): boolean { const parent = node.getParent(); if (!parent) { return false; } const kind = parent.getKind(); if ( kind === SyntaxKind.AsExpression || kind === SyntaxKind.TypeAssertionExpression ) { return true; } if (kind === SyntaxKind.ParenthesizedExpression) { return this.hasTypeAssertion(parent); } return false; } private determineMemberType( memberAccess: PropertyAccessExpression, ): 'method' | 'property' | 'event' { const memberName = memberAccess.getName(); // Check if called as a function if (memberAccess.getParent()?.getKind() === SyntaxKind.CallExpression) { return 'method'; } // Check naming conventions for event emitters / observables if ( memberName.startsWith('on') || memberName.endsWith('Change') || memberName.endsWith('Event') ) { return 'event'; } // Check if chained with subscribe or pipe const grandParent = memberAccess.getParent(); if (grandParent?.getKind() === SyntaxKind.PropertyAccessExpression) { const grandName = (grandParent as PropertyAccessExpression).getName(); if (grandName === 'subscribe' || grandName === 'pipe') { return 'event'; } } return 'property'; } private generateMessage( memberName: string, memberType: 'method' | 'property' | 'event', genericArg: string | undefined, ): string { const typeIssue = genericArg === 'any' ? 'ComponentRef provides no type safety' : 'ComponentRef has no generic type parameter'; switch (memberType) { case 'method': return `Implicit contract: calling .instance.${memberName}() on untyped ComponentRef. ${typeIssue}. Define an interface for the component contract.`; case 'event': return `Implicit contract: subscribing to .instance.${memberName} on untyped ComponentRef. ${typeIssue}. Type the event emitter via interface.`; case 'property': return `Implicit contract: accessing .instance.${memberName} on untyped ComponentRef. ${typeIssue}. Define explicit type contract.`; default: return `Implicit contract: accessing .instance.${memberName} on untyped ComponentRef. ${typeIssue}. Define explicit type contract.`; } } private generateSuggestedFix( memberName: string, memberType: 'method' | 'property' | 'event', ): string { let interfaceMethod: string; switch (memberType) { case 'method': interfaceMethod = `${memberName}(data: unknown): void;`; break; case 'event': interfaceMethod = `${memberName}: Observable;`; break; case 'property': interfaceMethod = `${memberName}: unknown;`; break; default: interfaceMethod = `${memberName}: unknown;`; } return [ `1. Define interface: interface IDynamicComponent { ${interfaceMethod} }`, `2. Type the ComponentRef: ComponentRef`, `3. Confirm ref.instance.${memberName} is now type-safe`, ].join('\n'); } }