/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /** * @angular-modernizer/plugin-angular - Bundle Size Optimization Rule * * Detects bundle size optimization opportunities for faster initial load times. * * Detection Patterns: * - Large library imports that could be lazy-loaded * - Barrel imports that prevent tree-shaking * - Missing lazy-loading for feature modules */ import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; import { SyntaxKind, type ArrayLiteralExpression, type ObjectLiteralElementLike, type Decorator, type ImportDeclaration, } from 'ts-morph'; export interface BundleSizeOptimizationConfig { maxBundleSizeKB: number; largeLibraries: string[]; allowedBarrelImports: string[]; } export class BundleSizeOptimizationRule implements AnalysisRule { readonly id = 'plugin-angular:bundle-size-optimization'; readonly name = 'Bundle Size Optimization'; readonly description = 'Detects bundle size optimization opportunities for faster initial load times'; readonly severity = 'warning'; readonly category = 'build-time'; readonly tags = ['angular', 'bundle-size', 'performance', 'tree-shaking']; private config: BundleSizeOptimizationConfig = { maxBundleSizeKB: 500, largeLibraries: [ 'lodash', 'moment', 'jquery', 'underscore', 'date-fns', 'ramda', 'immutable', 'axios', 'three', 'd3', 'chart.js', 'gsap', 'leaflet', 'socket.io-client', 'fabric', 'animejs', 'popper.js', 'bluebird', 'mobx', ], allowedBarrelImports: [ '@angular/core', '@angular/common', '@angular/router', ], }; configure(config: Partial): void { this.config = { ...this.config, ...config }; } /** * Analyzes the given context for bundle size optimization violations. * @param context - The analysis context containing the source file to analyze. * @returns An array of analysis results representing bundle size optimization violations. */ async analyze(context: AnalysisContext): Promise { const violations: AnalysisResult[] = []; violations.push(...this.detectMissingLazyLoading(context)); violations.push(...this.detectLargeImports(context)); violations.push(...this.detectIncorrectModuleImports(context)); violations.push(...this.detectBarrelImports(context)); violations.push(...this.detectNonTreeShakeableImports(context)); return violations; } /** * Detects routes that use eager loading instead of lazy loading. * @param context - The analysis context containing the source file to analyze. * @returns An array of analysis results for missing lazy loading violations. */ private detectMissingLazyLoading(context: AnalysisContext): AnalysisResult[] { const violations: AnalysisResult[] = []; const sourceFile = context.sourceFile; // Look for Routes arrays with component property instead of loadChildren const routesArrays = sourceFile .getDescendantsOfKind(SyntaxKind.ArrayLiteralExpression) .filter((array: ArrayLiteralExpression) => { const elements = array.getElements(); // Check if this looks like a routes array (contains objects with path/component properties) return elements.some( ( element: ReturnType[number], ) => { if (element.getKind() === SyntaxKind.ObjectLiteralExpression) { const obj = element.asKind(SyntaxKind.ObjectLiteralExpression); if (!obj) { return false; } const properties = obj.getProperties(); const hasPath = properties.some( (prop: ObjectLiteralElementLike) => prop.getKind() === SyntaxKind.PropertyAssignment && prop .asKind(SyntaxKind.PropertyAssignment) ?.getNameNode() .getText() === 'path', ); const hasComponent = properties.some( (prop: ObjectLiteralElementLike) => prop.getKind() === SyntaxKind.PropertyAssignment && prop .asKind(SyntaxKind.PropertyAssignment) ?.getNameNode() .getText() === 'component', ); return hasPath && hasComponent; } return false; }, ); }); routesArrays.forEach((routesArray: ArrayLiteralExpression) => { const elements = routesArray.getElements(); elements.forEach( ( element: ReturnType[number], ) => { if (element.getKind() === SyntaxKind.ObjectLiteralExpression) { const objLiteral = element.asKind( SyntaxKind.ObjectLiteralExpression, ); if (objLiteral) { const properties = objLiteral.getProperties(); // Check for component property without loadChildren const hasComponent = properties.some( (prop: ObjectLiteralElementLike) => prop.getKind() === SyntaxKind.PropertyAssignment && prop .asKind(SyntaxKind.PropertyAssignment) ?.getNameNode() .getText() === 'component', ); const hasLoadChildren = properties.some( (prop: ObjectLiteralElementLike) => prop.getKind() === SyntaxKind.PropertyAssignment && prop .asKind(SyntaxKind.PropertyAssignment) ?.getNameNode() .getText() === 'loadChildren', ); if (hasComponent && !hasLoadChildren) { // Get the route path for context const pathProp = properties.find( (prop: ObjectLiteralElementLike) => prop.getKind() === SyntaxKind.PropertyAssignment && prop .asKind(SyntaxKind.PropertyAssignment) ?.getNameNode() .getText() === 'path', ); const routePath = pathProp ? pathProp .asKind(SyntaxKind.PropertyAssignment) ?.getInitializer() ?.getText() : 'unknown'; violations.push({ ruleId: this.id, message: `Route '${routePath}' uses eager loading instead of lazy loading. Use loadChildren to improve initial bundle size.`, filePath: sourceFile.getFilePath(), line: objLiteral.getStartLineNumber(), column: objLiteral.getStartLineNumber(), suggestedFix: `Replace 'component: ComponentName' with 'loadChildren: () => import('./path/to/module').then(m => m.ModuleName)'`, metadata: { framework: 'angular', principle: 'lazy-loading', bundleImpact: '~50-200KB reduction', refactoringComplexity: 'medium', }, }); } } } }, ); }); return violations; } /** * Detects namespace imports from large libraries that prevent tree-shaking. * @param context - The analysis context containing the source file to analyze. * @returns An array of analysis results for large import violations. */ private detectLargeImports(context: AnalysisContext): AnalysisResult[] { const { sourceFile } = context; const imports = sourceFile.getImportDeclarations(); return imports .filter((importDecl) => this.isNamespaceImportFromLargeLibrary(importDecl), ) .map((importDecl) => this.createLargeImportViolation(importDecl, sourceFile), ); } /** * Checks if an import declaration is a namespace import from a large library. * @param importDecl - The import declaration to check. * @returns True if it's a namespace import from a large library. */ private isNamespaceImportFromLargeLibrary( importDecl: ImportDeclaration, ): boolean { const moduleSpecifier = importDecl.getModuleSpecifierValue(); const namedImports = importDecl.getNamedImports(); if (namedImports.length > 0) { return false; // Has named imports, not a namespace import } const importClause = importDecl.getImportClause(); if (!importClause) { return false; } const namespaceImport = importClause.getNamespaceImport(); if (!namespaceImport) { return false; } // Check if it's from a large library return this.config.largeLibraries.some((lib) => moduleSpecifier.includes(lib), ); } /** * Creates a violation for a large library namespace import. * @param importDecl - The import declaration that violates the rule. * @param sourceFile - The source file containing the import. * @returns The analysis result violation. */ private createLargeImportViolation( importDecl: ImportDeclaration, // eslint-disable-next-line @typescript-eslint/no-explicit-any sourceFile: any, ): AnalysisResult { const moduleSpecifier = importDecl.getModuleSpecifierValue(); const bundleSize = this.estimateLibrarySize(moduleSpecifier); return { ruleId: this.id, message: `Namespace import from large library '${moduleSpecifier}' (${bundleSize}KB) prevents tree-shaking. Import specific functions instead.`, filePath: sourceFile.getFilePath(), line: importDecl.getStartLineNumber(), column: importDecl.getStartLineNumber(), suggestedFix: `Replace 'import * as name from "${moduleSpecifier}"' with specific imports like 'import { functionName } from "${moduleSpecifier}"'`, metadata: { framework: 'angular', principle: 'tree-shaking', bundleImpact: `~${bundleSize}KB reduction potential`, librarySize: bundleSize, refactoringComplexity: 'low', }, }; } /** * Detects incorrect module imports like BrowserModule in feature modules. * @param context - The analysis context containing the source file to analyze. * @returns An array of analysis results for incorrect module import violations. */ private detectIncorrectModuleImports( context: AnalysisContext, ): AnalysisResult[] { const { sourceFile } = context; const ngModuleDecorators = this.findNgModuleDecorators(sourceFile); return ngModuleDecorators .map((decorator) => this.extractBrowserModuleViolation(decorator, sourceFile), ) .filter((violation): violation is AnalysisResult => violation !== null); } /** * Finds all NgModule decorators in a source file. * @param sourceFile - The source file to search. * @returns Array of NgModule decorators. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any private findNgModuleDecorators(sourceFile: any): Decorator[] { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return sourceFile .getDescendantsOfKind(SyntaxKind.Decorator) .filter((decorator: Decorator) => { const expression = decorator.getExpression(); return ( expression.getKind() === SyntaxKind.CallExpression && expression .asKind(SyntaxKind.CallExpression) ?.getExpression() .getText() === 'NgModule' ); }); } /** * Extracts a BrowserModule violation from an NgModule decorator if it exists. * @param decorator - The NgModule decorator to analyze. * @param sourceFile - The source file containing the decorator. * @returns A violation if BrowserModule is found in imports, null otherwise. */ private extractBrowserModuleViolation( decorator: Decorator, // eslint-disable-next-line @typescript-eslint/no-explicit-any sourceFile: any, ): AnalysisResult | null { const importsArray = this.extractImportsArrayFromNgModule(decorator); if (!importsArray) { return null; } const hasBrowserModule = this.hasBrowserModule(importsArray); if (!hasBrowserModule) { return null; } return this.createBrowserModuleViolation(decorator, sourceFile); } /** * Extracts the imports array from an NgModule decorator. * @param decorator - The NgModule decorator. * @returns The imports array literal expression, or null if not found. */ private extractImportsArrayFromNgModule( decorator: Decorator, ): ArrayLiteralExpression | null { const callExpr = decorator .getExpression() .asKind(SyntaxKind.CallExpression); if (!callExpr || callExpr.getArguments().length === 0) { return null; } const ngModuleConfig = callExpr .getArguments()[0] ?.asKind(SyntaxKind.ObjectLiteralExpression); if (!ngModuleConfig) { return null; } const importsProp = ngModuleConfig.getProperty('imports'); if ( !importsProp || importsProp.getKind() !== SyntaxKind.PropertyAssignment ) { return null; } const importsArray = importsProp .asKind(SyntaxKind.PropertyAssignment) ?.getInitializer() ?.asKind(SyntaxKind.ArrayLiteralExpression); return importsArray ?? null; } /** * Checks if an imports array contains BrowserModule. * @param importsArray - The imports array to check. * @returns True if BrowserModule is found. */ private hasBrowserModule(importsArray: ArrayLiteralExpression): boolean { return importsArray .getElements() .some((element) => element.getText().includes('BrowserModule')); } /** * Creates a violation for BrowserModule usage in a feature module. * @param decorator - The NgModule decorator. * @param sourceFile - The source file containing the decorator. * @returns The analysis result violation. */ private createBrowserModuleViolation( decorator: Decorator, // eslint-disable-next-line @typescript-eslint/no-explicit-any sourceFile: any, ): AnalysisResult { return { ruleId: this.id, message: 'BrowserModule imported in feature module. Use CommonModule instead to avoid bundle duplication.', filePath: sourceFile.getFilePath(), line: decorator.getStartLineNumber(), column: decorator.getStartLineNumber(), suggestedFix: 'Replace BrowserModule with CommonModule in feature module imports', metadata: { framework: 'angular', principle: 'module-optimization', bundleImpact: '~20KB reduction', refactoringComplexity: 'low', }, }; } /** * Detects barrel imports that prevent tree-shaking. * @param context - The analysis context containing the source file to analyze. * @returns An array of analysis results for barrel import violations. */ private detectBarrelImports(context: AnalysisContext): AnalysisResult[] { const { sourceFile } = context; const imports = sourceFile.getImportDeclarations(); return imports .filter((importDecl) => this.isBarrelImport(importDecl)) .map((importDecl) => this.createBarrelImportViolation(importDecl, sourceFile), ); } /** * Checks if an import declaration is a barrel import. * @param importDecl - The import declaration to check. * @returns True if it's a barrel import that should be flagged. */ private isBarrelImport(importDecl: ImportDeclaration): boolean { const moduleSpecifier = importDecl.getModuleSpecifierValue(); // Only check relative imports (barrel imports) if (!this.isRelativeImport(moduleSpecifier)) { return false; } const importClause = importDecl.getImportClause(); if (!importClause) { return false; } const namespaceImport = importClause.getNamespaceImport(); if (!namespaceImport) { return false; } // Check if it's not in the allowed list return !this.config.allowedBarrelImports.includes(moduleSpecifier); } /** * Checks if a module specifier represents a relative import. * @param moduleSpecifier - The module specifier to check. * @returns True if it's a relative import (starts with ./ or ../). */ private isRelativeImport(moduleSpecifier: string): boolean { return ( moduleSpecifier.startsWith('./') || moduleSpecifier.startsWith('../') ); } /** * Creates a violation for a barrel import. * @param importDecl - The import declaration that violates the rule. * @param sourceFile - The source file containing the import. * @returns The analysis result violation. */ private createBarrelImportViolation( importDecl: ImportDeclaration, // eslint-disable-next-line @typescript-eslint/no-explicit-any sourceFile: any, ): AnalysisResult { const moduleSpecifier = importDecl.getModuleSpecifierValue(); return { ruleId: this.id, message: `Barrel import '${moduleSpecifier}' prevents tree-shaking. Import specific exports instead.`, filePath: sourceFile.getFilePath(), line: importDecl.getStartLineNumber(), column: importDecl.getStartLineNumber(), suggestedFix: `Replace 'import * as name from "${moduleSpecifier}"' with specific imports like 'import { ExportName } from "${moduleSpecifier}"'`, metadata: { framework: 'angular', principle: 'tree-shaking', bundleImpact: 'Variable reduction', refactoringComplexity: 'low', }, }; } /** * Detects side-effect imports that may include unused code. * @param context - The analysis context containing the source file to analyze. * @returns An array of analysis results for non-tree-shakeable import violations. */ private detectNonTreeShakeableImports( context: AnalysisContext, ): AnalysisResult[] { const violations: AnalysisResult[] = []; const sourceFile = context.sourceFile; const imports = sourceFile.getImportDeclarations(); imports.forEach((importDecl) => { // Look for side-effect imports if (importDecl.getImportClause() === undefined) { // This is a side-effect import: import 'some-module' const moduleSpecifier = importDecl.getModuleSpecifierValue(); violations.push({ ruleId: this.id, message: `Side-effect import '${moduleSpecifier}' may include unused code. Ensure all imported code is actually used.`, filePath: sourceFile.getFilePath(), line: importDecl.getStartLineNumber(), column: importDecl.getStartLineNumber(), suggestedFix: 'Remove unused side-effect imports or replace with specific imports', metadata: { framework: 'angular', principle: 'tree-shaking', bundleImpact: 'Variable reduction', refactoringComplexity: 'low', }, }); } }); return violations; } /** * Estimates the bundle size of a library based on known library sizes. * @param libraryName - The name of the library to estimate size for. * @returns The estimated size in KB. */ private estimateLibrarySize(libraryName: string): number { const sizeMap: Record = { lodash: 70, moment: 290, jquery: 85, underscore: 15, 'date-fns': 150, rxjs: 25, ramda: 180, immutable: 55, axios: 15, three: 600, d3: 250, 'chart.js': 200, gsap: 150, leaflet: 140, 'socket.io-client': 45, fabric: 400, animejs: 45, 'popper.js': 20, bluebird: 45, mobx: 25, }; for (const [lib, size] of Object.entries(sizeMap)) { if (libraryName.includes(lib)) { return size; } } return 50; // Conservative default } }