/** * @angular-modernizer/plugin-angular - DTO Direct Instantiation Rule * * Detects direct `new X()` instantiation of DTO/Response/Result classes that * should be constructed as typed object literals or via factory functions. * Instantiating these classes directly bypasses type-safety checks and makes * property mapping implicit rather than explicit. * * Detection scope: * - `NewExpression` nodes whose class name matches default suffix patterns * (`.*Response`, `.*Dto`, `.*Result`) — Tier 1, name-based. * - `NewExpression` nodes whose resolved class declaration extends a configured * base class or carries a configured decorator — Tier 2, AST-based. * - Explicit class names registered in `explicitClassNames` — Tier 1, O(1). * * Skipped: * - `.d.ts` declaration files. * - Class names that do not match any configured Tier 1 or Tier 2 criterion. * * @example * ```typescript * const rule = new DtoDirectInstantiationRule(); * const results = await rule.analyze(createContext(sourceFile, project)); * // results[0].metadata.violationType → 'dto-direct-instantiation' * // results[0].metadata.detectionMechanism → 'classNamePattern' * // results[0].metadata.typeArguments → ['UserDto'] * ``` */ 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 DtoDirectInstantiationConfig { /** Tier 1: regex patterns matched against the class name. */ classNamePatterns: (string | RegExp)[]; /** Tier 1: exact class names for O(1) set lookup. */ explicitClassNames: string[]; /** Tier 2: names in the direct `extends` clause checked after import resolution. */ baseClassNames: string[]; /** Tier 2: decorator names checked after import resolution. */ decoratorNames: string[]; /** Class names that are always excluded regardless of pattern matches (e.g. Angular built-ins). */ excludedClassNames: string[]; } export interface DtoDirectInstantiationMetadata { violationType: 'dto-direct-instantiation'; className: string; detectionTier: 1 | 2; detectionMechanism: | 'classNamePattern' | 'explicitClassName' | 'baseClass' | 'decorator'; typeArguments: string[]; } const DEFAULT_CONFIG: DtoDirectInstantiationConfig = { classNamePatterns: [/.*Response$/, /.*Dto$/, /.*Result$/], explicitClassNames: [], baseClassNames: [], decoratorNames: [], excludedClassNames: [ 'HttpResponse', 'HttpErrorResponse', 'HttpHeaderResponse', ], }; export class DtoDirectInstantiationRule implements AnalysisRule { public readonly id = 'angular:dto-direct-instantiation'; public readonly name = 'DTO Direct Instantiation'; public readonly description = 'Detects direct new X() instantiation of DTO/Response/Result classes that should use typed object literals'; public readonly severity = 'warning' as const; public readonly category = 'angular-architecture'; public readonly tags = ['angular', 'dto', 'instantiation', 'object-literal']; private readonly config: DtoDirectInstantiationConfig; private readonly detector: TwoTierInstantiationDetector; constructor(config?: Partial) { this.config = { ...DEFAULT_CONFIG, ...config }; this.detector = new TwoTierInstantiationDetector(); } async analyze(context: AnalysisContext): Promise { const { sourceFile, project } = context; const filePath = sourceFile.getFilePath(); if (filePath.endsWith('.d.ts')) { return []; } const violations: AnalysisResult[] = []; const newExpressions = sourceFile.getDescendantsOfKind( SyntaxKind.NewExpression, ); for (const newExpr of newExpressions) { if ( this.config.excludedClassNames.includes(this.extractClassName(newExpr)) ) { continue; } const result = this.detector.detect( newExpr, this.config as TwoTierDetectorConfig, project, ); if (result === null) { continue; } const typeArguments = newExpr .getTypeArguments() .map((ta) => ta.getText()); const metadata: DtoDirectInstantiationMetadata = { violationType: 'dto-direct-instantiation', className: result.className, detectionTier: result.tier, detectionMechanism: result.mechanism, typeArguments, }; violations.push({ ruleId: this.id, message: `Direct instantiation of '${result.className}': Replace with a typed object literal or factory function to make property mapping explicit and type-safe.`, filePath, line: newExpr.getStartLineNumber(), column: newExpr.getStart() - newExpr.getStartLinePos(), suggestedFix: 'Replace with a typed object literal', metadata: metadata as unknown as Record, }); } return violations; } private extractClassName(newExpr: NewExpression): string { const expr = newExpr.getExpression(); if (expr.getKind() === SyntaxKind.PropertyAccessExpression) { return expr .asKindOrThrow(SyntaxKind.PropertyAccessExpression) .getNameNode() .getText(); } return expr.getText(); } }