/** * @angular-modernizer/plugin-angular - Component Without Selector Rule * * Detects Angular components that are missing the selector property in their @Component decorator. * Components must have a selector to be used in templates. * * Detection Patterns: * - @Component decorators without selector property * - Components with empty or invalid selectors * * This rule ensures components can be properly referenced in templates. */ import { SyntaxKind, type ClassDeclaration, type Decorator, type PropertyAssignment, } from 'ts-morph'; import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; /** * Component Without Selector Rule - detects components missing selector property. */ export class ComponentWithoutSelectorRule implements AnalysisRule { public readonly id = 'angular:component-without-selector'; public readonly name = 'Component Without Selector'; public readonly description = 'Detects Angular components that are missing the selector property'; public readonly severity = 'error'; public readonly category = 'angular-component'; public readonly tags = ['angular', 'component', 'selector', 'template']; async analyze(context: AnalysisContext): Promise { const violations: AnalysisResult[] = []; const sourceFile = context.sourceFile; // Find all class declarations const classes = sourceFile.getDescendantsOfKind( SyntaxKind.ClassDeclaration, ); for (const classDecl of classes) { const violation = this.analyzeClass(classDecl, sourceFile.getFilePath()); if (violation) { violations.push(violation); } } return violations; } private analyzeClass( classDecl: ClassDeclaration, filePath: string, ): AnalysisResult | null { const className = classDecl.getName(); if (!className) { return null; } // Check if this is a component const componentDecorator = this.getComponentDecorator(classDecl); if (!componentDecorator) { return null; } // Check if it has a selector const hasValidSelector = this.hasValidSelector(componentDecorator); if (hasValidSelector) { return null; } const startLine = classDecl.getStartLineNumber(); const startColumn = classDecl.getStart() - classDecl.getStartLinePos(); return { ruleId: this.id, message: `Component '${className}' is missing a selector property. Angular components must have a selector to be used in templates.`, filePath, line: startLine, column: startColumn, suggestedFix: `Add selector property: @Component({ selector: 'app-${className.toLowerCase()}', ... })`, metadata: { className, violationType: 'missing-selector', isComponent: true, framework: 'angular', }, }; } private getComponentDecorator( classDecl: ClassDeclaration, ): Decorator | undefined { const decorators = classDecl.getDecorators(); return decorators.find((decorator) => decorator.getName() === 'Component'); } private hasValidSelector(componentDecorator: Decorator): boolean { const args = componentDecorator.getArguments(); if (args.length === 0) { return false; } const config = args[0]; if (!config || config.getKind() !== SyntaxKind.ObjectLiteralExpression) { return false; } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any const configObj = config as any; // Type assertion for ObjectLiteralExpression // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access const properties = configObj.getProperties(); // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any const selectorProperty = properties.find((prop: any) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access if (prop.getKind() === SyntaxKind.PropertyAssignment) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access const name = prop.getNameNode(); // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access return name.getText() === 'selector'; } return false; }) as PropertyAssignment | undefined; if (!selectorProperty) { return false; } // Check if selector has a non-empty value const initializer = selectorProperty.getInitializer(); if (!initializer) { return false; } const selectorValue = initializer.getText().replaceAll(/['"]/g, ''); return selectorValue.trim().length > 0; } }