/** * @angular-modernizer/plugin-angular - Presentational Component Violation Rule * * Detects Angular components that should be presentational (dumb) but contain * business logic, service dependencies, or state management. * * Presentational components should focus solely on rendering UI and delegate * all business logic to parent (smart/container) components through * @Input/@Output communication. * * Detection targets: * 1. Service dependencies injected via constructor or inject() * 2. .subscribe() calls in lifecycle hooks (excluding takeUntilDestroyed) * 3. Methods calling services without a corresponding @Output / output() signal * 4. Business logic methods (keyword + complexity > 3 branching tokens) * 5. Complex local state (arrays, Maps, Sets of 3+ non-Input properties) */ import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; import { SyntaxKind } from 'ts-morph'; import type { ClassDeclaration, SourceFile, PropertyDeclaration, } from 'ts-morph'; /** * Dialog detection configuration for PresentationalComponentViolationRule. * * Dialog components are container components — they manage service calls and * local state by design. They should not be flagged as presentational violations. * * Configure in `.angular-modernizer.json`: * ```json * { * "rules": { * "angular:presentational-component-violation": { * "dialogDetection": { * "injectedClassNames": ["MatDialogRef", "MAT_DIALOG_DATA"], * "baseClassNames": ["AppDialogBaseComponent", "BaseDialogComponent"] * } * } * } * } * ``` */ export interface DialogDetectionConfig { /** * Constructor-injected or inject()-based class names that mark a component * as a dialog. Any component that has one of these as a dependency is skipped. * Default: `['MatDialogRef']` */ injectedClassNames: string[]; /** * Base class names that mark a component as a dialog. * Any component extending one of these classes is skipped. * Default: `[]` */ baseClassNames: string[]; } export interface PresentationalComponentViolationConfig { dialogDetection: DialogDetectionConfig; /** * Class name patterns that identify "Action Tile" components. * When a component whose name matches any of these patterns also has a * `service-dependency` violation, the violation severity is escalated * to `error` and supplementary action-tile guidance is added to the message. * * Default: `['TileComponent', 'ActionTile', 'ActionTileComponent']` */ actionTilePatterns: string[]; } const DEFAULT_CONFIG: PresentationalComponentViolationConfig = { dialogDetection: { injectedClassNames: ['MatDialogRef'], baseClassNames: [], }, actionTilePatterns: ['TileComponent', 'ActionTile', 'ActionTileComponent'], }; /** Infrastructure services that are allowed inside presentational components. */ const ALLOWED_SERVICES = new Set([ 'ChangeDetectorRef', 'ElementRef', 'Renderer2', 'ViewContainerRef', 'TemplateRef', 'NgZone', 'DestroyRef', ]); /** * Suffixes that identify a dependency as a business service. * Must stay aligned with hasServiceFieldCall() to avoid false missing-output violations. */ const SERVICE_SUFFIXES = [ 'Service', 'Store', 'Repository', 'Api', 'HttpClient', 'Client', 'Provider', 'Manager', 'Facade', 'Gateway', ]; /** * Lifecycle hooks where .subscribe() indicates a violation. * takeUntilDestroyed() subscriptions are excluded — they are idiomatic Angular. */ const LIFECYCLE_HOOKS = new Set([ 'ngOnInit', 'ngAfterViewInit', 'ngAfterContentInit', 'ngOnChanges', 'ngAfterViewChecked', 'ngAfterContentChecked', ]); /** * Method name prefixes that suggest business logic. * 'filter', 'sort', 'transform' excluded — too common in legitimate presentational components. * Must be combined with cyclomatic complexity > 3 to reduce false positives. */ const BUSINESS_LOGIC_KEYWORDS = [ 'calculate', 'compute', 'validate', 'process', 'aggregate', ]; /** * Presentational Component Violation Analysis Rule * * @example * ```typescript * const rule = new PresentationalComponentViolationRule(); * const violations = await rule.analyze(context); * // violations contain service-dependency, subscription-in-lifecycle, * // missing-output, business-logic, complex-state-management entries * ``` */ export class PresentationalComponentViolationRule implements AnalysisRule { public readonly id = 'angular:presentational-component-violation'; public readonly name = 'Presentational Component Violation'; public readonly description = 'Detects components that should be presentational but contain service dependencies, business logic, or complex state management'; public readonly severity = 'warning' as const; public readonly category = 'angular-architecture'; public readonly tags = [ 'angular', 'architecture', 'presentational', 'smart-dumb', 'component', ]; private readonly config: PresentationalComponentViolationConfig; constructor(config?: { dialogDetection?: Partial; actionTilePatterns?: string[]; }) { this.config = { ...DEFAULT_CONFIG, dialogDetection: { ...DEFAULT_CONFIG.dialogDetection, ...config?.dialogDetection, }, actionTilePatterns: config?.actionTilePatterns ?? DEFAULT_CONFIG.actionTilePatterns, }; } async analyze(context: AnalysisContext): Promise { const { sourceFile } = context; const results: AnalysisResult[] = []; const classes = sourceFile.getClasses(); if (classes.length === 0) { return results; } for (const classDecl of classes) { if (!this.isComponent(classDecl)) { continue; } const className = classDecl.getName(); if (!className) { continue; } // Dialog components: analyse getter validation instead of presentational checks if (this.isDialogComponent(classDecl)) { results.push( ...this.analyzeDialogGetterValidation( classDecl, className, sourceFile.getFilePath(), ), ); continue; } // Collect all service field names for consistent service-call detection const serviceFieldNames = this.collectServiceFieldNames(classDecl); // Detect service dependencies and potentially escalate for action tiles const serviceDeps = this.detectServiceDependencies( classDecl, className, sourceFile, serviceFieldNames, ); const isActionTile = this.matchesActionTilePattern(className); results.push( ...this.applyActionTileEscalation(serviceDeps, className, isActionTile), ...this.detectSubscriptionsInLifecycle( classDecl, className, sourceFile, ), ...this.detectMissingOutputs( classDecl, className, sourceFile, serviceFieldNames, ), ...this.detectBusinessLogicMethods(classDecl, className, sourceFile), ...this.detectComplexStateManagement(classDecl, className, sourceFile), ); } return results; } // --------------------------------------------------------------------------- // Detection helpers // --------------------------------------------------------------------------- private isComponent(classDecl: ClassDeclaration): boolean { return classDecl.getDecorators().some((d) => d.getName() === 'Component'); } /** * Returns true when the component is a dialog — i.e. it extends a configured * dialog base class OR injects a configured dialog token/service. * * Checks both injection styles: * - Constructor param: `constructor(private ref: MatDialogRef) {}` * - inject() field: `private ref = inject(MatDialogRef)` */ private isDialogComponent(classDecl: ClassDeclaration): boolean { const { injectedClassNames, baseClassNames } = this.config.dialogDetection; // Base class check if (baseClassNames.length > 0) { const extendsClause = classDecl.getBaseClass(); if (extendsClause) { const baseName = extendsClause.getName(); if (baseName && baseClassNames.includes(baseName)) { return true; } } } if (injectedClassNames.length === 0) { return false; } const injectedSet = new Set(injectedClassNames); // Constructor injection — check type text (e.g. "MatDialogRef") // and @Inject(TOKEN) decorators (e.g. "@Inject(MAT_DIALOG_DATA)") const ctor = classDecl.getConstructors()[0]; if (ctor) { for (const param of ctor.getParameters()) { const typeText = param.getTypeNode()?.getText() ?? ''; // Strip generic parameters: "MatDialogRef" → "MatDialogRef" const baseName = typeText.split('<')[0]?.trim() ?? ''; if (injectedSet.has(baseName) || injectedSet.has(typeText)) { return true; } // Check @Inject(TOKEN) decorators for injection tokens for (const deco of param.getDecorators()) { if (deco.getName() === 'Inject') { const args = deco.getArguments(); if (args.length > 0 && injectedSet.has(args[0]!.getText().trim())) { return true; } } } } } // inject() field injection: `private x = inject(MatDialogRef)` or `inject(MAT_DIALOG_DATA)` for (const prop of classDecl.getProperties()) { const init = prop.getInitializer()?.getText() ?? ''; const match = /^inject\s*\(\s*(\w+)/.exec(init); if (match && injectedSet.has(match[1]!)) { return true; } } return false; } /** * Collect all field names that are service dependencies — covering both * constructor injection and inject() function calls. * Used by detectServiceDependencies AND detectMissingOutputs to keep the * service taxonomy consistent between both detectors. */ private collectServiceFieldNames(classDecl: ClassDeclaration): Set { const names = new Set(); // Constructor parameters const ctor = classDecl.getConstructors()[0]; if (ctor) { for (const param of ctor.getParameters()) { if (this.isServiceType(param.getType().getText())) { names.add(param.getName()); } } } // Field declarations using inject(): private svc = inject(MyService) for (const prop of classDecl.getProperties()) { const init = prop.getInitializer(); if (!init) { continue; } const text = init.getText(); // Match: inject(SomeService) or inject(SomeStore) etc. const match = /^inject\s*\(\s*(\w+)\s*\)/.exec(text); if (match && this.isServiceType(match[1]!)) { names.add(prop.getName()); } } return names; } private isServiceType(typeName: string): boolean { if (ALLOWED_SERVICES.has(typeName)) { return false; } return SERVICE_SUFFIXES.some((suffix) => typeName.includes(suffix)); } // 1. Service dependencies ------------------------------------------------- private detectServiceDependencies( classDecl: ClassDeclaration, className: string, sourceFile: SourceFile, serviceFieldNames: Set, ): AnalysisResult[] { const results: AnalysisResult[] = []; const filePath = sourceFile.getFilePath(); // Constructor injection const ctor = classDecl.getConstructors()[0]; if (ctor) { for (const param of ctor.getParameters()) { if (serviceFieldNames.has(param.getName())) { results.push( this.serviceDependencyViolation( className, param.getName(), param.getType().getText(), filePath, param.getStartLineNumber(), 'constructor', ), ); } } } // inject() field injection for (const prop of classDecl.getProperties()) { if (!serviceFieldNames.has(prop.getName())) { continue; } const init = prop.getInitializer(); if (!init?.getText().startsWith('inject(')) { continue; } results.push( this.serviceDependencyViolation( className, prop.getName(), prop.getType().getText() || init.getText(), filePath, prop.getStartLineNumber(), 'inject()', ), ); } return results; } private serviceDependencyViolation( className: string, fieldName: string, typeName: string, filePath: string, line: number, injectionStyle: string, ): AnalysisResult { return { ruleId: this.id, message: `Presentational component '${className}' has service dependency '${fieldName}' (via ${injectionStyle}). Move to container component and pass data via @Input.`, filePath, line, suggestedFix: `Remove service dependency. Add @Input() / input() property for data and @Output() / output() for actions.`, metadata: { className, violationType: 'service-dependency', serviceName: fieldName, serviceType: typeName, injectionStyle, framework: 'angular', refactoringComplexity: 'medium', }, }; } // 2. Subscriptions in lifecycle hooks ------------------------------------- private detectSubscriptionsInLifecycle( classDecl: ClassDeclaration, className: string, sourceFile: SourceFile, ): AnalysisResult[] { const results: AnalysisResult[] = []; const filePath = sourceFile.getFilePath(); for (const method of classDecl.getMethods()) { if (!LIFECYCLE_HOOKS.has(method.getName())) { continue; } const body = method.getBodyText() ?? ''; if (!body.includes('.subscribe(')) { continue; } // Exclude takeUntilDestroyed() subscriptions — they are idiomatic Angular const hasTakeUntilDestroyed = body.includes('takeUntilDestroyed'); if (hasTakeUntilDestroyed) { continue; } results.push({ ruleId: this.id, message: `Presentational component '${className}' subscribes to observables in ${method.getName()}. Use async pipe or receive data via @Input from container.`, filePath, line: method.getStartLineNumber(), suggestedFix: `Remove subscription. Option 1: Use async pipe in template. Option 2: Receive data via @Input from parent container.`, metadata: { className, methodName: method.getName(), violationType: 'subscription-in-lifecycle', framework: 'angular', refactoringComplexity: 'medium', }, }); } return results; } // 3. Missing @Output for service-calling methods -------------------------- private detectMissingOutputs( classDecl: ClassDeclaration, className: string, sourceFile: SourceFile, serviceFieldNames: Set, ): AnalysisResult[] { const results: AnalysisResult[] = []; const filePath = sourceFile.getFilePath(); if (serviceFieldNames.size === 0) { return results; } const outputNames = this.collectOutputNames(classDecl); // Build regex from actual injected field names for consistent detection const fieldPattern = new RegExp( String.raw`this\.(${Array.from(serviceFieldNames).join('|')})\.\w+\(`, ); for (const method of classDecl.getMethods()) { const methodName = method.getName(); const body = method.getBodyText() ?? ''; if (!fieldPattern.test(body)) { continue; } if (!this.hasCorrespondingOutput(methodName, outputNames)) { results.push({ ruleId: this.id, message: `Method '${methodName}' in '${className}' calls services directly without a corresponding @Output / output(). Emit an event instead.`, filePath, line: method.getStartLineNumber(), suggestedFix: `Add output signal: ${methodName}Event = output() and emit it instead of calling the service.`, metadata: { className, methodName, violationType: 'missing-output', framework: 'angular', refactoringComplexity: 'low', }, }); } } return results; } /** * Collect output names from both @Output() decorators and output() signals. */ private collectOutputNames(classDecl: ClassDeclaration): Set { const names = new Set(); for (const prop of classDecl.getProperties()) { const hasOutputDecorator = prop .getDecorators() .some((d) => d.getName() === 'Output'); const isOutputSignal = prop.getInitializer()?.getText().startsWith('output(') ?? false; if (hasOutputDecorator || isOutputSignal) { names.add(prop.getName().toLowerCase()); } } return names; } /** * Convention-based matching: deleteClick, deleteEvent, deleteChange, onDelete. * Avoids bidirectional substring matching which causes false negatives * ('edit' would suppress 'editable'; 'save' would suppress 'savedItemsList'). */ private hasCorrespondingOutput( methodName: string, outputNames: Set, ): boolean { const lower = methodName.toLowerCase(); const conventions = [ 'click', 'event', 'change', 'submit', 'emit', 'action', ]; return ( conventions.some((suffix) => outputNames.has(lower + suffix)) || outputNames.has('on' + lower) ); } // 4. Business logic methods ----------------------------------------------- private detectBusinessLogicMethods( classDecl: ClassDeclaration, className: string, sourceFile: SourceFile, ): AnalysisResult[] { const results: AnalysisResult[] = []; const filePath = sourceFile.getFilePath(); for (const method of classDecl.getMethods()) { const methodName = method.getName(); const lower = methodName.toLowerCase(); const hasKeyword = BUSINESS_LOGIC_KEYWORDS.some((kw) => lower.includes(kw), ); if (!hasKeyword) { continue; } // Require cyclomatic complexity > 3 to avoid flagging trivial helpers. // Count branching tokens as a lightweight proxy for cyclomatic complexity. // Note: &&, ||, ? are non-word chars so \b does not apply — match them directly. // ?(?!\?)(?!\.) = ternary ? (excludes ?? and ?.) const body = method.getBodyText() ?? ''; const branchCount = ( body.match( /\bif\b|\bfor\b|\bwhile\b|\bswitch\b|&&|\|\||\?(?!\?)(?!\.)|\?\?/g, ) ?? [] ).length; if (branchCount <= 3) { continue; } results.push({ ruleId: this.id, message: `Method '${methodName}' in '${className}' contains business logic (complexity ~${branchCount}). Move to service or compute in container component.`, filePath, line: method.getStartLineNumber(), suggestedFix: `Extract business logic to a service or compute the value in the container and pass via @Input.`, metadata: { className, methodName, violationType: 'business-logic', estimatedComplexity: branchCount, framework: 'angular', refactoringComplexity: 'medium', }, }); } return results; } // 5. Complex state management --------------------------------------------- private detectComplexStateManagement( classDecl: ClassDeclaration, className: string, sourceFile: SourceFile, ): AnalysisResult[] { const filePath = sourceFile.getFilePath(); let complexStateCount = 0; for (const prop of classDecl.getProperties()) { // Skip @Input, @Output, input(), output() — those are intentional contracts if (this.isInputOrOutput(prop)) { continue; } if (prop.isReadonly()) { continue; } const propName = prop.getName(); if (this.isSimpleUIState(propName)) { continue; } if (this.isComplexStateType(prop)) { complexStateCount++; } } if (complexStateCount < 3) { return []; } return [ { ruleId: this.id, message: `Presentational component '${className}' manages ${complexStateCount} complex state properties. Lift state to container component.`, filePath, line: classDecl.getStartLineNumber(), suggestedFix: `Move state to parent container. Pass data via @Input and changes via @Output.`, metadata: { className, violationType: 'complex-state-management', statePropertyCount: complexStateCount, framework: 'angular', refactoringComplexity: 'high', }, }, ]; } private isInputOrOutput(prop: PropertyDeclaration): boolean { const decoratorNames = prop.getDecorators().map((d) => d.getName()); if (decoratorNames.includes('Input') || decoratorNames.includes('Output')) { return true; } const init = prop.getInitializer()?.getText() ?? ''; return ( init.startsWith('input(') || init.startsWith('input.required(') || init.startsWith('output(') ); } /** * Simple UI-toggle state that is legitimate in presentational components. * Uses startsWith/endsWith to avoid matching mid-word (e.g. 'listItems' contains 'is'). */ private isSimpleUIState(propName: string): boolean { const lower = propName.toLowerCase(); const prefixes = ['is', 'show', 'hide', 'has', 'can', 'should']; const suffixes = [ 'visible', 'expanded', 'collapsed', 'open', 'active', 'disabled', 'enabled', 'loading', ]; return ( prefixes.some((p) => lower.startsWith(p)) || suffixes.some((s) => lower.endsWith(s)) ); } /** * Complex state: arrays, Maps, Sets. * Uses ts-morph Type API rather than string comparison to handle union types * (e.g. 'string | null' must not count as complex). */ private isComplexStateType(prop: PropertyDeclaration): boolean { const type = prop.getType(); const typeText = type.getText(); return ( type.isArray() || typeText.includes('[]') || typeText.includes('Map<') || typeText.includes('Set<') || (type.isObject() && !type.isEnum() && !type.isAnonymous()) ); } // --------------------------------------------------------------------------- // B4 helpers: dialog getter validation + action-tile escalation // --------------------------------------------------------------------------- /** * Detects dialog component getters that embed validation logic (≥2 IfStatements). * Validation logic in getters couples the dialog to its data model and makes * testing harder — it belongs in the data model or a dedicated validator service. */ private analyzeDialogGetterValidation( classDecl: ClassDeclaration, className: string, filePath: string, ): AnalysisResult[] { const results: AnalysisResult[] = []; for (const getter of classDecl.getGetAccessors()) { const body = getter.getBody(); if (!body) { continue; } const ifCount = body.getDescendantsOfKind(SyntaxKind.IfStatement).length; if (ifCount < 2) { continue; } results.push({ ruleId: this.id, message: `Dialog '${className}' getter '${getter.getName()}' contains validation logic (${ifCount} if-statements). Extract to the data model or a validator service.`, filePath, line: getter.getStartLineNumber(), suggestedFix: `Move validation to the data model class or a dedicated validator service.`, metadata: { className, getterName: getter.getName(), ifStatementCount: ifCount, violationType: 'dialog-getter-validation', framework: 'angular', }, }); } return results; } /** * Returns true when the component class name matches any of the configured * action-tile patterns (substring check, case-sensitive). */ private matchesActionTilePattern(className: string): boolean { return this.config.actionTilePatterns.some((pattern) => className.includes(pattern), ); } /** * If the component is an action tile, escalate any `service-dependency` * violations: severity promoted to `error` in metadata and guidance appended * to the message. All other violations are returned unchanged. */ private applyActionTileEscalation( violations: AnalysisResult[], _className: string, isActionTile: boolean, ): AnalysisResult[] { if (!isActionTile) { return violations; } return violations.map((v) => { if (v.metadata?.['violationType'] !== 'service-dependency') { return v; } return { ...v, message: v.message + ' (Action Tile: service dependencies are especially harmful here — Action Tiles must be purely presentational to remain reusable across multiple containers.)', metadata: { ...v.metadata, escalatedSeverity: 'error', }, }; }); } }