/** * @angular-modernizer/plugin-angular - Magic String Selector Rule * * Detects magic string selector arguments passed to dynamic component loading * methods. Passing a raw string or template literal to methods like * `getComponentBySelector` or `loadComponent` creates fragile implicit * contracts that cannot be verified by the TypeScript compiler and break * silently when component selectors are renamed. * * Detection scope: * - `CallExpression` nodes whose called method name matches a configured * `targetMethodPatterns` list (default: `'getComponentBySelector'`, * `'loadComponent'`, `/get.*Component/i`). * - Argument classification: * - `StringLiteral` → `argumentType: 'string-literal'`, `severity: 'error'` * - `NoSubstitutionTemplateLiteral` / `TemplateExpression` → `argumentType: 'template-literal'`, `severity: 'error'` * - `PropertyAccessExpression` (e.g. `this.config.selector`) → `argumentType: 'config-property'`, `severity: 'warning'` * - All other argument kinds → not flagged (too many false positives). * * Skipped: * - `.d.ts` declaration files. * - Call expressions with no arguments. * - First arguments that are plain identifiers, function calls, or other * non-literal, non-property-access expressions. * * Pattern compilation: * - String entries in `targetMethodPatterns` are compiled to exact-match * regexes (`'^' + p + '$'`), preventing partial matches such as * `'load'` matching `'loadComponentFactory'`. * - `RegExp` entries are passed through unchanged (they express partial * matching explicitly, e.g. `/get.*Component/i`). * * @example * ```typescript * const rule = new MagicStringSelectorRule(); * const results = await rule.analyze(createContext(sourceFile, project)); * // results[0].metadata.violationType → 'magic-string-selector' * // results[0].metadata.methodName → 'getComponentBySelector' * // results[0].metadata.argumentType → 'string-literal' * // results[0].metadata.severity → 'error' * ``` */ import { SyntaxKind, type CallExpression, type PropertyAccessExpression, type Node, } from 'ts-morph'; import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; export interface MagicStringSelectorConfig { /** Method name patterns to check. Strings are compiled to exact-match regexes; RegExps pass through unchanged. */ targetMethodPatterns: (string | RegExp)[]; } export interface MagicStringSelectorMetadata { violationType: 'magic-string-selector'; methodName: string; argumentType: 'string-literal' | 'template-literal' | 'config-property'; selectorValue: string; severity: 'error' | 'warning'; } const DEFAULT_CONFIG: MagicStringSelectorConfig = { targetMethodPatterns: [ 'getComponentBySelector', 'loadComponent', /get.*Component/i, ], }; export class MagicStringSelectorRule implements AnalysisRule { public readonly id = 'angular:magic-string-selector'; public readonly name = 'Magic String Selector'; public readonly description = 'Detects magic string selector arguments passed to dynamic component loading methods'; public readonly severity = 'error' as const; public readonly category = 'angular-architecture'; public readonly tags = [ 'angular', 'dynamic-components', 'type-safety', 'selectors', ]; private readonly config: MagicStringSelectorConfig; private readonly compiledPatterns: RegExp[]; constructor(config?: Partial) { this.config = { ...DEFAULT_CONFIG, ...config }; this.compiledPatterns = this.compilePatterns( this.config.targetMethodPatterns, ); } /** * Compiles the mixed `(string | RegExp)[]` pattern list into `RegExp[]`. * String entries are anchored (`^...$`) to prevent partial method-name matches. * RegExp entries are passed through unchanged. */ private compilePatterns(patterns: (string | RegExp)[]): RegExp[] { return patterns.map((p) => typeof p === 'string' ? new RegExp('^' + p + '$') : p, ); } async analyze(context: AnalysisContext): Promise { const { sourceFile } = context; const filePath = sourceFile.getFilePath(); if (filePath.endsWith('.d.ts')) { return []; } const violations: AnalysisResult[] = []; const callExprs = sourceFile.getDescendantsOfKind( SyntaxKind.CallExpression, ); for (const callExpr of callExprs) { const result = this.detectMagicStringSelector(callExpr, filePath); if (result !== null) { violations.push(result); } } return violations; } private detectMagicStringSelector( callExpr: CallExpression, filePath: string, ): AnalysisResult | null { // Step 1 — Extract method name from the callee const methodName = this.extractMethodName(callExpr); if (methodName === null) { return null; } // Step 2 — Check against compiled patterns if (!this.compiledPatterns.some((p) => p.test(methodName))) { return null; } // Step 3 — Must have at least one argument const args = callExpr.getArguments(); if (args.length === 0) { return null; } // Step 4 — Classify the first argument const firstArg = args[0]!; const classification = this.classifyArgument(firstArg); if (classification === null) { return null; } // Step 5 — Build metadata const metadata: MagicStringSelectorMetadata = { violationType: 'magic-string-selector', methodName, argumentType: classification.argumentType, selectorValue: firstArg.getText(), severity: classification.severity, }; // Step 6 — Generate message based on severity const messagePrefix = classification.severity === 'error' ? `Magic string selector: '${methodName}' called with a hardcoded string selector.` : `Indirect magic string: '${methodName}' called with a property access that may carry a magic string.`; return { ruleId: this.id, message: `${messagePrefix} Replace with a typed component token or direct component class reference.`, filePath, line: callExpr.getStartLineNumber(), column: callExpr.getStart() - callExpr.getStartLinePos(), suggestedFix: 'Replace the string selector with a typed component token or direct component class reference', metadata: metadata as unknown as Record, }; } private extractMethodName(callExpr: CallExpression): string | null { const expr = callExpr.getExpression(); const kind = expr.getKind(); if (kind === SyntaxKind.PropertyAccessExpression) { return (expr as PropertyAccessExpression).getName(); } if (kind === SyntaxKind.Identifier) { return expr.getText(); } return null; } private classifyArgument(arg: Node): { argumentType: 'string-literal' | 'template-literal' | 'config-property'; severity: 'error' | 'warning'; } | null { const kind = arg.getKind(); if (kind === SyntaxKind.StringLiteral) { return { argumentType: 'string-literal', severity: 'error' }; } if ( kind === SyntaxKind.NoSubstitutionTemplateLiteral || kind === SyntaxKind.TemplateExpression ) { return { argumentType: 'template-literal', severity: 'error' }; } if (kind === SyntaxKind.PropertyAccessExpression) { return { argumentType: 'config-property', severity: 'warning' }; } return null; } }