/** * @angular-modernizer/plugin-angular - Static Class Detection Rule * * Detects static-only classes that should be refactored to module-level functions and constants. * * Detection Patterns: * - Classes with only static methods and no instance members * - Utility/helper classes that are never instantiated * - Constants-only classes that could be plain exported objects */ import { SyntaxKind } from 'ts-morph'; import type { ClassDeclaration, ConstructorDeclaration } from 'ts-morph'; import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; export interface StaticClassMetadata { violationType: 'static-class-detection'; className: string; classType: 'helper' | 'constants' | 'mixed'; staticMethodCount: number; staticPropertyCount: number; estimatedImpact: 'low' | 'medium' | 'high'; transformable: true; } const ANGULAR_DECORATORS = new Set([ 'Component', 'Injectable', 'Directive', 'Pipe', 'NgModule', ]); export class StaticClassAnalysisRule implements AnalysisRule { public readonly id = 'angular:static-class-detection'; public readonly name = 'Static Class Detection'; public readonly description = 'Detects static-only classes that should be refactored to module-level functions and constants'; public readonly severity = 'warning' as const; public readonly category = 'code-quality'; public readonly tags = [ 'angular', 'refactoring', 'functional', 'tree-shaking', ]; async analyze(context: AnalysisContext): Promise { const { sourceFile } = context; if (sourceFile.getFilePath().endsWith('.d.ts')) { return []; } const violations: AnalysisResult[] = []; for (const classDecl of sourceFile.getClasses()) { const result = this.classify(classDecl); if (!result) { continue; } const metadata: StaticClassMetadata = { violationType: 'static-class-detection', className: classDecl.getName() ?? '(anonymous)', classType: result.classType, staticMethodCount: result.methodCount, staticPropertyCount: result.propCount, estimatedImpact: this.impact(result.methodCount + result.propCount), transformable: true, }; violations.push({ ruleId: this.id, message: this.message(metadata), filePath: sourceFile.getFilePath(), line: classDecl.getStartLineNumber(), column: classDecl.getStart() - classDecl.getStartLinePos(), suggestedFix: this.suggestedFix(metadata), metadata: metadata as unknown as Record, }); } return violations; } private classify(cls: ClassDeclaration): { classType: 'helper' | 'constants' | 'mixed'; methodCount: number; propCount: number; } | null { if (cls.getDecorators().some((d) => ANGULAR_DECORATORS.has(d.getName()))) { return null; } if (cls.getExtends()) { return null; } if (cls.isAbstract()) { return null; } const ctor = cls.getConstructors()[0]; if (ctor && this.hasCtorLogic(ctor)) { return null; } const methods = cls.getMethods(); const props = cls.getProperties(); if (methods.some((m) => !m.isStatic())) { return null; } if (props.some((p) => !p.isStatic())) { return null; } if (props.some((p) => p.isStatic() && !p.isReadonly())) { return null; } const allStatic = [...methods, ...props]; if ( allStatic.some( (m) => m.hasModifier(SyntaxKind.PrivateKeyword) || m.hasModifier(SyntaxKind.ProtectedKeyword), ) ) { return null; } // Numeric / computed names (e.g. LEVEL[0]) cannot be exported as // `export const 0 = …` — skip so analysis and transform stay consistent. if ( allStatic.some((m) => !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/u.test(m.getName())) ) { return null; } const methodCount = methods.length; const propCount = props.length; if (methodCount === 0 && propCount === 0) { return null; } const classType: 'helper' | 'constants' | 'mixed' = methodCount > 0 && propCount === 0 ? 'helper' : propCount > 0 && methodCount === 0 ? 'constants' : 'mixed'; return { classType, methodCount, propCount }; } private hasCtorLogic(ctor: ConstructorDeclaration): boolean { return ctor.getStatements().length > 0; } private impact(total: number): 'low' | 'medium' | 'high' { if (total <= 3) { return 'low'; } if (total <= 7) { return 'medium'; } return 'high'; } private message(m: StaticClassMetadata): string { const counts = `(${m.staticMethodCount} method(s), ${m.staticPropertyCount} constant(s))`; return `Class '${m.className}' ${counts} contains only static members — refactor to module-level functions/constants for better tree-shaking.`; } private suggestedFix(m: StaticClassMetadata): string { if (m.classType === 'helper') { return `Replace static methods in '${m.className}' with exported functions.`; } if (m.classType === 'constants') { return `Replace static constants in '${m.className}' with exported const declarations.`; } return `Replace static members in '${m.className}' with exported functions and const declarations.`; } }