/** * @angular-modernizer/plugin-angular - Two Tier Instantiation Detector */ import { type ClassDeclaration, type NewExpression, type Project, SyntaxKind, } from 'ts-morph'; /** * Configuration for TwoTierInstantiationDetector. * * Tier 1 checks are performed first (fast, name-based). * Tier 2 checks are performed only when Tier 1 yields no result (slow, AST-based). */ export interface TwoTierDetectorConfig { /** Tier 1: regex patterns (or strings compiled to RegExp) matched against the class name. */ classNamePatterns: (string | RegExp)[]; /** Tier 1: exact class names for a fast O(1) set lookup. */ explicitClassNames: string[]; /** Tier 2: names in the direct `extends` clause to check after resolving the import. */ baseClassNames: string[]; /** Tier 2: decorator names to check after resolving the import. */ decoratorNames: string[]; } /** * Result returned when an instantiation is detected. */ export interface TwoTierDetectionResult { /** Always `true` when a result is returned (non-null). */ matched: boolean; /** Which tier produced the match. */ tier: 1 | 2; /** The specific mechanism that triggered the match. */ mechanism: | 'classNamePattern' | 'explicitClassName' | 'baseClass' | 'decorator'; /** The extracted class name from the `NewExpression`. */ className: string; } /** * Detects whether a `new X()` expression instantiates a class that matches * a two-tier configuration. * * Tier 1 — name-based (fast, no AST traversal): * - Exact name match via `explicitClassNames` * - Regex match via `classNamePatterns` * * Tier 2 — AST-based (slower, requires import resolution): * - Base class check via `baseClassNames` * - Decorator check via `decoratorNames` * * Short-circuits: if Tier 1 matches, Tier 2 is never invoked. * Fails gracefully: unresolvable imports and barrel re-exports return `null`. * * @example * ```typescript * const detector = new TwoTierInstantiationDetector(); * const result = detector.detect(newExpr, config, project); * if (result) { * console.info(`${result.className} matched via ${result.mechanism} (tier ${result.tier})`); * } * ``` */ export class TwoTierInstantiationDetector { /** * Detects whether the given `NewExpression` matches the supplied config. * * @param newExpr - The ts-morph `NewExpression` node to inspect. * @param config - Detection configuration (Tier 1 + Tier 2 options). * @param project - The ts-morph `Project` used for import resolution in Tier 2. * @returns A `TwoTierDetectionResult` if a match was found, `null` otherwise. */ detect( newExpr: NewExpression, config: TwoTierDetectorConfig, project: Project, ): TwoTierDetectionResult | null { const className = this.extractClassName(newExpr); const tier1Result = this.runTier1(className, config); if (tier1Result !== null) { return tier1Result; } return this.runTier2(newExpr, className, config, project); } // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- /** * Extracts the class name from a `NewExpression`. * * Handles: * - Simple `new Foo()` → `'Foo'` * - Namespace `new ns.Foo()` → `'Foo'` (rightmost identifier) * * @param newExpr - The `NewExpression` node. * @returns The class name string. */ private extractClassName(newExpr: NewExpression): string { const expr = newExpr.getExpression(); if (expr.getKind() === SyntaxKind.PropertyAccessExpression) { // new ns.X() — use the rightmost identifier return expr .asKindOrThrow(SyntaxKind.PropertyAccessExpression) .getNameNode() .getText(); } return expr.getText(); } /** * Compiles an array of string-or-RegExp patterns to `RegExp` instances. * * @param patterns - Mixed array of strings and RegExp objects. * @returns Array of `RegExp` instances. */ private compilePatterns(patterns: (string | RegExp)[]): RegExp[] { return patterns.map((p) => (typeof p === 'string' ? new RegExp(p) : p)); } /** * Runs Tier 1 detection (name-based, no import resolution). * * Checks `explicitClassNames` first (exact), then `classNamePatterns` (regex). * * @param className - The extracted class name. * @param config - Detection configuration. * @returns A result with `tier=1` if matched, `null` otherwise. */ private runTier1( className: string, config: TwoTierDetectorConfig, ): TwoTierDetectionResult | null { // Explicit exact-name match if (config.explicitClassNames.includes(className)) { return { matched: true, tier: 1, mechanism: 'explicitClassName', className, }; } // Regex pattern match const compiled = this.compilePatterns(config.classNamePatterns); for (const pattern of compiled) { if (pattern.test(className)) { return { matched: true, tier: 1, mechanism: 'classNamePattern', className, }; } } return null; } /** * Runs Tier 2 detection (AST-based, requires import resolution). * * Resolves the class declaration via the import graph, then checks the * `extends` clause and decorator list against the config. * * @param newExpr - The `NewExpression` node (needed for source file context). * @param className - The extracted class name. * @param config - Detection configuration. * @param project - The ts-morph `Project` for source-file resolution. * @returns A result with `tier=2` if matched, `null` otherwise. */ private runTier2( newExpr: NewExpression, className: string, config: TwoTierDetectorConfig, project: Project, ): TwoTierDetectionResult | null { if ( config.baseClassNames.length === 0 && config.decoratorNames.length === 0 ) { return null; } const classDecl = this.resolveClassDeclaration(newExpr, className, project); if (classDecl === null) { return null; } // Check direct extends clause const extendsExpr = classDecl.getExtends()?.getExpression().getText(); if ( extendsExpr !== undefined && config.baseClassNames.includes(extendsExpr) ) { return { matched: true, tier: 2, mechanism: 'baseClass', className }; } // Check decorators const decoratorNames = classDecl.getDecorators().map((d) => d.getName()); for (const decoratorName of decoratorNames) { if (config.decoratorNames.includes(decoratorName)) { return { matched: true, tier: 2, mechanism: 'decorator', className }; } } return null; } /** * Resolves the `ClassDeclaration` for the given class name by following the * import declarations in the source file of `newExpr`. * * Returns `null` (silently) when: * - No matching import declaration is found (class may be locally defined — not an error case for Tier 2) * - The import module cannot be resolved (external package) * - The resolved source file does not directly declare the class (barrel re-export) * - Any unexpected ts-morph exception occurs * * @param newExpr - The `NewExpression` node whose source file is searched for imports. * @param className - The class name to resolve. * @param project - The ts-morph `Project` (unused directly — resolution goes via source file). * @returns The `ClassDeclaration` or `null`. */ private resolveClassDeclaration( newExpr: NewExpression, className: string, _project: Project, ): ClassDeclaration | null { try { const sourceFile = newExpr.getSourceFile(); const importDecls = sourceFile.getImportDeclarations(); let matchingImport = null; for (const importDecl of importDecls) { // Check named imports: import { Foo, Bar } from '...' const namedImports = importDecl.getNamedImports(); for (const namedImport of namedImports) { // Use alias if present, otherwise the original name const localName = namedImport.getAliasNode()?.getText() ?? namedImport.getName(); if (localName === className) { matchingImport = importDecl; break; } } if (matchingImport !== null) { break; } // Check default import: import Foo from '...' const defaultImport = importDecl.getDefaultImport(); if (defaultImport?.getText() === className) { matchingImport = importDecl; } } if (matchingImport === null) { // Class may be locally defined — Tier 2 cannot inspect it without an import return null; } const resolvedFile = matchingImport.getModuleSpecifierSourceFile(); if (resolvedFile === undefined) { // External package or unresolvable path return null; } const classDecl = resolvedFile.getClass(className); if (classDecl === undefined) { // Barrel re-export — class not directly declared here return null; } return classDecl; } catch { // Graceful fallback for any unexpected ts-morph exception return null; } } }