/** * @angular-modernizer/plugin-angular - Component Wrapper Instantiation Rule * * Detects direct `new X()` instantiation of wrapper or adapter classes * (identified by name patterns, explicit class names, base class inheritance, * or decorator presence) in Angular codebases. * * Direct instantiation of component-wrapper, DTO-wrapper, or adapter classes * breaks the Dependency Inversion Principle: callers take responsibility for * constructing the dependency rather than receiving it through Angular's DI. * * Detection scope: * - Tier 1 (fast, name-based): `classNamePatterns` and `explicitClassNames` * - Tier 2 (AST-based): `baseClassNames` and `decoratorNames` after import resolution * * Skipped: * - `.d.ts` declaration files. * - Classes in `excludedClassNames` (Angular framework Ref types by default). * * @example * ```typescript * const rule = new ComponentWrapperInstantiationRule(); * const results = await rule.analyze(createContext(sourceFile, project)); * // results[0].metadata.className → 'SomeRef' * // results[0].metadata.detectionTier → 1 * // results[0].metadata.detectionMechanism → 'classNamePattern' * ``` */ import { SyntaxKind, type NewExpression } from 'ts-morph'; import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; import { TwoTierInstantiationDetector, type TwoTierDetectorConfig, } from '../utils/two-tier-instantiation-detector.js'; export interface ComponentWrapperInstantiationConfig extends TwoTierDetectorConfig { /** Class names excluded from detection regardless of pattern match. Defaults to Angular framework Ref types. */ excludedClassNames: string[]; } export interface ComponentWrapperInstantiationMetadata { violationType: 'component-wrapper-instantiation'; className: string; detectionTier: 1 | 2; detectionMechanism: | 'classNamePattern' | 'explicitClassName' | 'baseClass' | 'decorator'; } const DEFAULT_CONFIG: ComponentWrapperInstantiationConfig = { classNamePatterns: [/.*Ref$/, /.*Wrapper$/], decoratorNames: ['Injectable'], baseClassNames: [], explicitClassNames: [], excludedClassNames: [ 'ElementRef', 'ViewContainerRef', 'TemplateRef', 'ChangeDetectorRef', 'ComponentRef', 'EmbeddedViewRef', ], }; export class ComponentWrapperInstantiationRule implements AnalysisRule { public readonly id = 'angular:component-wrapper-instantiation'; public readonly name = 'Component Wrapper Direct Instantiation'; public readonly description = 'Detects direct new X() instantiation of component-wrapper or adapter classes that should be injected via DI'; public readonly severity = 'warning' as const; public readonly category = 'angular-architecture'; public readonly tags = ['angular', 'di', 'dependency-injection', 'wrapper']; private readonly config: ComponentWrapperInstantiationConfig; private readonly detector = new TwoTierInstantiationDetector(); constructor(config?: Partial) { this.config = { ...DEFAULT_CONFIG, ...config }; } async analyze(context: AnalysisContext): Promise { const { sourceFile, project } = context; const filePath = sourceFile.getFilePath(); if (filePath.endsWith('.d.ts')) { return []; } const violations: AnalysisResult[] = []; const newExprs = sourceFile.getDescendantsOfKind(SyntaxKind.NewExpression); for (const newExpr of newExprs) { const className = this.extractClassName(newExpr); if (this.config.excludedClassNames.includes(className)) { continue; } const result = this.detector.detect(newExpr, this.config, project); if (result === null) { continue; } const metadata: ComponentWrapperInstantiationMetadata = { violationType: 'component-wrapper-instantiation', className: result.className, detectionTier: result.tier, detectionMechanism: result.mechanism, }; violations.push({ ruleId: this.id, message: `Direct instantiation of '${className}': use DI or a factory instead of new ${className}()`, filePath, line: newExpr.getStartLineNumber(), column: newExpr.getStart() - newExpr.getStartLinePos(), suggestedFix: 'Inject a factory or use DI instead of direct instantiation', metadata: metadata as unknown as Record, }); } return violations; } /** * Extracts the class name from a `NewExpression`. * * Handles: * - Simple `new Foo()` → `'Foo'` * - Namespace `new ns.Foo()` → `'Foo'` (rightmost identifier) */ private extractClassName(newExpr: NewExpression): string { const expr = newExpr.getExpression(); if (expr.getKind() === SyntaxKind.PropertyAccessExpression) { return expr .asKindOrThrow(SyntaxKind.PropertyAccessExpression) .getNameNode() .getText(); } return expr.getText(); } }