/** * @angular-modernizer/plugin-angular - Data Handling Strategy Rule * * Detects inefficient client-side data processing and recommends server-side alternatives. * * Detection Patterns: * - Large table datasets processed client-side (pagination, filtering, sorting) * - Complex chained data transformations in components * - Components not connected to server-side pagination * * Rule ID: plugin-angular:data-handling-strategy * Severity: warning | Category: runtime-architecture */ import type { AnalysisRule, AnalysisContext, AnalysisResult, } from '@angular-modernizer/plugin-system'; import { SyntaxKind, type Node, type ClassDeclaration, type CallExpression, type SourceFile, type PropertyDeclaration, type VariableDeclaration, type Identifier, type PropertyAccessExpression, type TypeNode, type Expression, type Type, type Decorator, } from 'ts-morph'; /** * Configuration for data handling strategy thresholds. * These thresholds determine when client-side processing should be flagged * as inefficient and moved to server-side processing. */ export interface DataHandlingStrategyConfig { /** Threshold for client-side table processing (default: 1000 records) */ clientSideTableThreshold: number; /** Threshold for client-side filtering (default: 500 records) */ clientSideFilterThreshold: number; /** Threshold for client-side sorting (default: 500 records) */ clientSideSortThreshold: number; /** Threshold for complex transformation chains (default: 2 operations) */ complexTransformationThreshold: number; } /** * Context information about offline capabilities of a component. * Used to determine if client-side processing is acceptable for offline-capable components. */ interface OfflineContext { /** Whether the component is capable of offline operation */ isOfflineCapable: boolean; /** Reason why the component is considered offline-capable */ reason: string; /** List of indicators that suggest offline capability */ indicators: string[]; } /** * Configuration for detecting offline-capable components. * Contains various patterns and indicators that suggest a component can work offline. */ interface OfflineConfig { /** Mode for offline detection: 'auto' or 'manual' */ offlineFirstMode: string; /** Component names that are explicitly marked as offline-capable */ offlineComponents: string[]; /** Decorator names that indicate offline capability */ offlineDecorators: string[]; /** File path patterns that suggest offline capability */ offlinePaths: string[]; /** Library imports that enable offline functionality */ offlineLibraries: string[]; } /** * Static configuration for offline detection patterns. * This defines what constitutes an "offline-capable" component. */ const offlineConfig: OfflineConfig = { offlineFirstMode: 'auto', offlineComponents: [ 'OfflineDataService', 'CacheService', 'SyncService', 'OfflineListComponent', 'PWAComponent', ], offlineDecorators: ['OfflineCapable', 'PWAComponent'], offlinePaths: [ 'src/app/offline/', 'src/app/pwa/', 'src/app/cache/', 'src/app/sync/', ], offlineLibraries: ['@angular/service-worker', 'idb', 'dexie', 'localforage'], }; /** * Rule that detects inefficient data handling strategies in Angular components. * * This rule identifies common performance anti-patterns where large datasets * are processed client-side instead of server-side, which can cause: * - Slow initial page loads * - High memory usage * - Poor user experience on low-end devices * - Excessive network traffic * * The rule suggests moving operations like filtering, sorting, and pagination * to the server when dealing with datasets above configurable thresholds. * * Implementation uses functional programming patterns throughout: * - Method chaining for data transformation pipelines * - Optional chaining (?.) for safe AST navigation * - Array of pure functions for complex logic (e.g., offline detection) * - Strategy pattern for array size estimation * - Pure helper functions for better testability */ export class DataHandlingStrategyRule implements AnalysisRule { readonly id = 'plugin-angular:data-handling-strategy'; readonly name = 'Data Handling Strategy'; readonly description = 'Detects inefficient data processing strategies and suggests server-side alternatives'; readonly severity = 'warning'; readonly category = 'runtime-architecture'; readonly tags = ['angular', 'performance', 'data-handling', 'server-side']; /** * Default configuration thresholds for data processing detection. * These can be overridden via the configure() method. */ private config: DataHandlingStrategyConfig = { clientSideTableThreshold: 1000, // Flag tables with 1000+ records clientSideFilterThreshold: 500, // Flag filtering with 500+ records clientSideSortThreshold: 500, // Flag sorting with 500+ records complexTransformationThreshold: 2, // Flag chains of 2+ operations }; /** * Configures the rule with custom thresholds. * @param config - Partial configuration object to override defaults */ configure(config: Partial): void { this.config = { ...this.config, ...config }; } /** * Main analysis entry point that runs all data handling strategy checks. * * This method orchestrates multiple detection strategies: * 1. Client-side table processing (large datasets in tables) * 2. Client-side filtering (array.filter() on large arrays) * 3. Client-side sorting (array.sort() on large arrays) * 4. Complex transformations (chained array operations) * 5. Missing server pagination (paginators without server-side logic) * * Each detection method uses functional programming patterns for better * testability and maintainability. * * @param context - Analysis context containing the source file to analyze * @returns Array of violations found in the source file */ async analyze(context: AnalysisContext): Promise { const violations: AnalysisResult[] = []; // Run all detection strategies and collect violations violations.push(...this.detectClientSideTableProcessing(context)); violations.push(...this.detectClientSideFiltering(context)); violations.push(...this.detectClientSideSorting(context)); violations.push(...this.detectComplexClientTransformations(context)); violations.push(...this.detectMissingServerPagination(context)); return violations; } /** * Detects client-side table processing that should be moved to server-side. * * This method uses a functional pipeline approach to: * 1. Find all class declarations in the source file * 2. Filter to only Angular components (classes with `` decorator) * 3. Filter to components that have table-like data structures * 4. Analyze HTTP calls to estimate data size * 5. Check offline capability context * 6. Filter to violations above threshold * 7. Create violation objects * * The functional approach makes the data flow clear and each step testable. * * @param context - The analysis context containing the source file to analyze. * @returns An array of analysis results for client-side table processing violations. */ private detectClientSideTableProcessing( context: AnalysisContext, ): AnalysisResult[] { const sourceFile = context.sourceFile; return ( sourceFile // Step 1: Get all class declarations from the source file .getDescendantsOfKind(SyntaxKind.ClassDeclaration) // Step 2: Filter to only Angular components (classes with @Component decorator) .filter((classDecl) => this.isComponentClass(classDecl)) // Step 3: Extract class name for later use .map((classDecl) => ({ classDecl, className: classDecl.getName(), })) // Step 4: Filter out classes without names (edge case) .filter(({ className }) => className) // Step 5: Filter to components that have table data sources or custom tables .filter( ({ classDecl }) => this.hasTableDataSource(classDecl) || this.hasCustomTable(classDecl), ) // Step 6: Analyze HTTP calls to determine data size and presence .map(({ classDecl, className }) => ({ classDecl, className: className!, httpInfo: this.analyzeHttpCalls(classDecl), })) // Step 7: Filter to components that make HTTP calls (data comes from server) .filter(({ httpInfo }) => httpInfo.hasHttpCall) // Step 8: Check if component is offline-capable (client-side processing might be OK) .map(({ classDecl, className, httpInfo }) => ({ classDecl, className, httpInfo, offlineContext: this.detectOfflineContext( classDecl, className, sourceFile.getFilePath(), ), })) // Step 9: Filter to non-offline components with large datasets above threshold .filter( ({ offlineContext, httpInfo }) => !offlineContext.isOfflineCapable && httpInfo.estimatedDataSize >= this.config.clientSideTableThreshold, ) // Step 10: Create violation objects for the remaining cases .map(({ classDecl, className, httpInfo, offlineContext }) => this.createTableProcessingViolation( classDecl, className, httpInfo.estimatedDataSize, offlineContext, sourceFile, ), ) ); } /** * Determines if a class declaration is an Angular component. * Checks for the presence of a `` decorator. * * @param classDecl - The class declaration to check * @returns true if the class has a `` decorator */ private isComponentClass(classDecl: ClassDeclaration): boolean { return classDecl.getDecorators().some(this.isComponentDecorator); } /** * Checks if a component uses Angular Material's MatTableDataSource. * This indicates the component is working with tabular data that might be large. * Uses functional array methods (some) for concise checking. * @param classDecl - The component class to analyze * @returns true if the component has MatTableDataSource properties */ private hasTableDataSource(classDecl: ClassDeclaration): boolean { return classDecl.getProperties().some((prop) => { // Check type annotation const typeText = prop.getTypeNode()?.getText() ?? ''; if (typeText.includes('MatTableDataSource')) { return true; } // Check initializer expression const initializer = prop.getInitializer(); return ( initializer?.getKind() === SyntaxKind.NewExpression && initializer .asKind(SyntaxKind.NewExpression) ?.getExpression() .getText() .includes('MatTableDataSource') ); }); } /** * Checks if a component has custom table-like structures. * Looks for array properties that are used in templates with *ngFor or elements. * * @param classDecl - The component class to analyze * @returns true if the component has custom table patterns */ private hasCustomTable(classDecl: ClassDeclaration): boolean { const template = this.getComponentTemplate(classDecl); if ( !template || (!template.includes('*ngFor') && !template.includes('
')) ) { return false; } return classDecl.getProperties().some((prop) => { const typeNode = prop.getTypeNode(); const typeText = typeNode?.getText() ?? ''; return typeText.includes('Array<') || typeText.includes('[]'); }); } /** * Analyzes HTTP calls in a component to determine data size and presence. * Scans constructor and methods for HTTP calls and estimates data size from context. * Uses functional reduce pattern to accumulate results across all members. * @param classDecl - The component class to analyze * @returns Object with HTTP call presence and estimated data size */ private analyzeHttpCalls(classDecl: ClassDeclaration): { hasHttpCall: boolean; estimatedDataSize: number; } { const methods = classDecl.getMethods(); const constructor = classDecl.getConstructors()[0]; const members = [constructor, ...methods].filter(Boolean); return members.reduce( (acc: { hasHttpCall: boolean; estimatedDataSize: number }, member) => { if (!member) { return acc; } const calls = member.getDescendantsOfKind(SyntaxKind.CallExpression); calls.forEach((call) => { const expression = call.getExpression(); if ( expression.getText().includes('.get(') || expression.getText().includes('.post(') || expression.getText().includes('http.') ) { acc.hasHttpCall = true; // Try to estimate data size from context const args = call.getArguments(); if (args.length > 0 && args[0]) { const argText = args[0].getText(); if (argText.includes('Array<') || argText.includes('[]')) { acc.estimatedDataSize = Math.max( acc.estimatedDataSize, this.config.clientSideTableThreshold, ); } } } }); return acc; }, { hasHttpCall: false, estimatedDataSize: 0 }, ); } /** * Creates a violation object for client-side table processing issues. * * @param classDecl - The component class declaration * @param className - The name of the component class * @param estimatedDataSize - The estimated size of the dataset * @param offlineContext - Context about offline capabilities * @param sourceFile - The source file being analyzed * @returns A structured violation object */ private createTableProcessingViolation( classDecl: ClassDeclaration, className: string, estimatedDataSize: number, offlineContext: OfflineContext, sourceFile: SourceFile, ): AnalysisResult { return { ruleId: this.id, message: `Component '${className}' processes large table data (${estimatedDataSize}+ records) client-side. Consider server-side pagination for better performance.`, filePath: sourceFile.getFilePath(), line: classDecl.getStartLineNumber(), column: classDecl.getStartLineNumber(), suggestedFix: 'Implement server-side pagination with MatPaginator and HTTP parameters (page, pageSize)', metadata: { framework: 'angular', principle: 'server-side-processing', performanceImpact: '~60% faster load times', dataSize: estimatedDataSize, offlineContext: offlineContext, refactoringComplexity: 'medium', }, }; } /** * Detects client-side filtering that should be moved to server-side. * * This method finds array.filter() calls on large datasets and suggests * moving the filtering logic to the server for better performance. * * @param context - The analysis context containing the source file to analyze. * @returns An array of analysis results for client-side filtering violations. */ private detectClientSideFiltering( context: AnalysisContext, ): AnalysisResult[] { const sourceFile = context.sourceFile; return sourceFile .getDescendantsOfKind(SyntaxKind.ClassDeclaration) .map((classDecl) => ({ classDecl, className: classDecl.getName(), })) .filter(({ className }) => className) .flatMap(({ classDecl, className }) => this.findFilterCalls(classDecl).map((filterCall) => ({ classDecl, className: className!, filterCall, })), ) .map(({ classDecl, className, filterCall }) => ({ classDecl, className, filterCall, estimatedSize: this.estimateFilterArraySize(filterCall, classDecl), })) .filter( ({ estimatedSize }) => estimatedSize >= this.config.clientSideFilterThreshold, ) .map(({ classDecl, className, filterCall, estimatedSize }) => ({ classDecl, className, filterCall, estimatedSize, offlineContext: this.detectOfflineContext( classDecl, className, sourceFile.getFilePath(), ), })) .filter(({ offlineContext }) => !offlineContext.isOfflineCapable) .map( ({ classDecl, className, filterCall, estimatedSize, offlineContext }) => this.createFilteringViolation( classDecl, className, filterCall, estimatedSize, offlineContext, sourceFile, ), ); } /** * Finds all array.filter() call expressions in a class. * Used to identify client-side filtering operations. * * @param classDecl - The class declaration to search * @returns Array of filter call expressions found */ private findFilterCalls(classDecl: ClassDeclaration): CallExpression[] { return classDecl .getDescendantsOfKind(SyntaxKind.CallExpression) .filter((call) => call.getExpression().getText().endsWith('.filter')); } /** * Estimates the size of an array being filtered. * Analyzes the property access expression to determine array size. * * @param filterCall - The filter call expression * @param classDecl - The class containing the filter call * @returns Estimated number of elements in the array */ private estimateFilterArraySize( filterCall: CallExpression, classDecl: ClassDeclaration, ): number { const expression = filterCall.getExpression(); const propertyAccess = expression.asKind( SyntaxKind.PropertyAccessExpression, ); if (!propertyAccess) { return 0; } const objectExpr = propertyAccess.getExpression(); return this.estimateArraySize(objectExpr, classDecl); } /** * Creates a violation object for client-side filtering issues. * * @param classDecl - The component class declaration * @param className - The name of the component class * @param filterCall - The filter call expression * @param estimatedSize - The estimated size of the filtered array * @param offlineContext - Context about offline capabilities * @param sourceFile - The source file being analyzed * @returns A structured violation object */ private createFilteringViolation( _classDecl: ClassDeclaration, className: string, filterCall: CallExpression, estimatedSize: number, offlineContext: OfflineContext, sourceFile: SourceFile, ): AnalysisResult { return { ruleId: this.id, message: `Client-side filtering on large dataset (${estimatedSize}+ records) in '${className}'. Consider server-side filtering for better performance.`, filePath: sourceFile.getFilePath(), line: filterCall.getStartLineNumber(), column: filterCall.getStartLineNumber(), suggestedFix: 'Move filtering logic to server with query parameters (filter, search)', metadata: { framework: 'angular', principle: 'server-side-filtering', performanceImpact: '~40% faster filtering', dataSize: estimatedSize, offlineContext: offlineContext, refactoringComplexity: 'medium', }, }; } /** * Detects client-side sorting that should be moved to server-side. * * This method finds array.sort() calls on large datasets and suggests * moving the sorting logic to the server for better performance. * * @param context - The analysis context containing the source file to analyze. * @returns An array of analysis results for client-side sorting violations. */ private detectClientSideSorting(context: AnalysisContext): AnalysisResult[] { const sourceFile = context.sourceFile; return sourceFile .getDescendantsOfKind(SyntaxKind.ClassDeclaration) .map((classDecl) => ({ classDecl, className: classDecl.getName(), })) .filter(({ className }) => className) .flatMap(({ classDecl, className }) => this.findSortCalls(classDecl).map((sortCall) => ({ classDecl, className: className!, sortCall, })), ) .map(({ classDecl, className, sortCall }) => ({ classDecl, className, sortCall, estimatedSize: this.estimateSortArraySize(sortCall, classDecl), })) .filter( ({ estimatedSize }) => estimatedSize >= this.config.clientSideSortThreshold, ) .map(({ classDecl, className, sortCall, estimatedSize }) => ({ classDecl, className, sortCall, estimatedSize, offlineContext: this.detectOfflineContext( classDecl, className, sourceFile.getFilePath(), ), })) .filter(({ offlineContext }) => !offlineContext.isOfflineCapable) .map( ({ classDecl, className, sortCall, estimatedSize, offlineContext }) => this.createSortingViolation( classDecl, className, sortCall, estimatedSize, offlineContext, sourceFile, ), ); } /** * Finds all array.sort() call expressions in a class. * Used to identify client-side sorting operations. * * @param classDecl - The class declaration to search * @returns Array of sort call expressions found */ private findSortCalls(classDecl: ClassDeclaration): CallExpression[] { return classDecl .getDescendantsOfKind(SyntaxKind.CallExpression) .filter((call) => call.getExpression().getText().endsWith('.sort')); } /** * Estimates the size of an array being sorted. * Analyzes the property access expression to determine array size. * * @param sortCall - The sort call expression * @param classDecl - The class containing the sort call * @returns Estimated number of elements in the array */ private estimateSortArraySize( sortCall: CallExpression, classDecl: ClassDeclaration, ): number { const expression = sortCall.getExpression(); const propertyAccess = expression.asKind( SyntaxKind.PropertyAccessExpression, ); if (!propertyAccess) { return 0; } const objectExpr = propertyAccess.getExpression(); return this.estimateArraySize(objectExpr, classDecl); } /** * Creates a violation object for client-side sorting issues. * * @param classDecl - The component class declaration * @param className - The name of the component class * @param sortCall - The sort call expression * @param estimatedSize - The estimated size of the sorted array * @param offlineContext - Context about offline capabilities * @param sourceFile - The source file being analyzed * @returns A structured violation object */ private createSortingViolation( _classDecl: ClassDeclaration, className: string, sortCall: CallExpression, estimatedSize: number, offlineContext: OfflineContext, sourceFile: SourceFile, ): AnalysisResult { return { ruleId: this.id, message: `Client-side sorting on large dataset (${estimatedSize}+ records) in '${className}'. Consider server-side sorting for better performance.`, filePath: sourceFile.getFilePath(), line: sortCall.getStartLineNumber(), column: sortCall.getStartLineNumber(), suggestedFix: 'Move sorting logic to server with query parameters (sort, order)', metadata: { framework: 'angular', principle: 'server-side-sorting', performanceImpact: '~30% faster sorting', dataSize: estimatedSize, offlineContext: offlineContext, refactoringComplexity: 'medium', }, }; } /** * Detects complex client-side data transformations that should be moved to server-side. * * This method finds chained array operations (map, filter, reduce, etc.) that * exceed complexity thresholds and suggests moving them to the server. * * @param context - The analysis context containing the source file to analyze. * @returns An array of analysis results for complex transformation violations. */ private detectComplexClientTransformations( context: AnalysisContext, ): AnalysisResult[] { const sourceFile = context.sourceFile; return sourceFile .getDescendantsOfKind(SyntaxKind.ClassDeclaration) .map((classDecl) => ({ classDecl, className: classDecl.getName(), })) .filter(({ className }) => className) .flatMap(({ classDecl, className }) => this.findComplexOperations(classDecl).map((operation) => ({ classDecl, className: className!, operation, })), ) .map(({ classDecl, className, operation }) => ({ classDecl, className, operation, complexity: this.calculateOperationComplexity(operation), })) .filter( ({ complexity }) => complexity >= this.config.complexTransformationThreshold, ) .map(({ classDecl, className, operation, complexity }) => ({ classDecl, className, operation, complexity, offlineContext: this.detectOfflineContext( classDecl, className, sourceFile.getFilePath(), ), })) .filter(({ offlineContext }) => !offlineContext.isOfflineCapable) .map(({ classDecl, className, operation, complexity, offlineContext }) => this.createTransformationViolation( classDecl, className, operation, complexity, offlineContext, sourceFile, ), ); } /** * Finds complex array operations in a class. * Looks for map, reduce, flatMap, and groupBy operations that might be chained. * * @param classDecl - The class declaration to search * @returns Array of complex operation call expressions */ private findComplexOperations(classDecl: ClassDeclaration): CallExpression[] { return classDecl .getDescendantsOfKind(SyntaxKind.CallExpression) .filter((call) => { const exprText = call.getExpression().getText(); return ( exprText.endsWith('.map') || exprText.endsWith('.reduce') || exprText.endsWith('.flatMap') || exprText.endsWith('.groupBy') ); }); } private calculateOperationComplexity(operation: CallExpression): number { let complexity = 1; let currentNode: Node | undefined = operation.getParent(); // Count chained operations by walking up the chain while (currentNode) { if (currentNode.getKind() === SyntaxKind.CallExpression) { const callExpr = currentNode.asKind(SyntaxKind.CallExpression); if (callExpr) { const expr = callExpr.getExpression(); if (expr.getKind() === SyntaxKind.PropertyAccessExpression) { const propAccess = expr.asKind(SyntaxKind.PropertyAccessExpression); if (propAccess) { const methodName = propAccess.getName(); if ( [ 'map', 'filter', 'reduce', 'flatMap', 'groupBy', 'sort', ].includes(methodName) ) { complexity++; } } } } } currentNode = currentNode.getParent(); } return complexity; } private createTransformationViolation( _classDecl: ClassDeclaration, className: string, operation: CallExpression, complexity: number, offlineContext: OfflineContext, sourceFile: SourceFile, ): AnalysisResult { return { ruleId: this.id, message: `Complex data transformation (${complexity} chained operations) in '${className}' may impact performance. Consider server-side processing.`, filePath: sourceFile.getFilePath(), line: operation.getStartLineNumber(), column: operation.getStartLineNumber(), suggestedFix: 'Move complex transformations to server-side or optimize client-side logic', metadata: { framework: 'angular', principle: 'server-side-transformations', performanceImpact: 'Variable improvement', complexity: complexity, offlineContext: offlineContext, refactoringComplexity: 'high', }, }; } private detectMissingServerPagination( context: AnalysisContext, ): AnalysisResult[] { const sourceFile = context.sourceFile; return sourceFile .getDescendantsOfKind(SyntaxKind.ClassDeclaration) .filter((classDecl) => this.hasPaginatorOrCustomPagination(classDecl)) .map((classDecl) => ({ classDecl, className: classDecl.getName(), })) .filter(({ className }) => className) .map(({ classDecl, className }) => ({ classDecl, className: className!, hasServerPagination: this.hasServerPagination(classDecl), componentType: this.getComponentType(classDecl), })) .filter(({ hasServerPagination }) => !hasServerPagination) .map(({ classDecl, className, componentType }) => ({ classDecl, className, componentType, offlineContext: this.detectOfflineContext( classDecl, className, sourceFile.getFilePath(), ), })) .filter(({ offlineContext }) => !offlineContext.isOfflineCapable) .map(({ classDecl, className, componentType, offlineContext }) => this.createPaginationViolation( classDecl, className, componentType, offlineContext, sourceFile, ), ); } private hasPaginatorOrCustomPagination(classDecl: ClassDeclaration): boolean { const properties = classDecl.getProperties(); const hasPaginator = properties.some((prop) => { const typeNode = prop.getTypeNode(); return typeNode?.getText().includes('MatPaginator'); }); const template = this.getComponentTemplate(classDecl); const hasCustomPagination = template && (template.includes('currentPage') || template.includes('pageSize') || template.includes('nextPage') || template.includes('previousPage')); return hasPaginator || !!hasCustomPagination; } private hasServerPagination(classDecl: ClassDeclaration): boolean { return classDecl .getMethods() .some((method) => method .getDescendantsOfKind(SyntaxKind.CallExpression) .some((call) => this.checkCallForPagination(call)), ); } private checkCallForPagination(call: CallExpression): boolean { const exprText = call.getExpression().getText(); // Check HTTP calls for pagination parameters if (exprText.includes('.get(') || exprText.includes('http.')) { return call.getArguments().some((arg) => { const argText = arg.getText(); return ( argText.includes('page=') || argText.includes('pageSize=') || argText.includes('limit=') || argText.includes('offset=') ); }); } // Check HttpParams usage if ( exprText.includes('HttpParams') || exprText.includes('new HttpParams') ) { return call .getDescendantsOfKind(SyntaxKind.CallExpression) .some((parentCall) => { const parentExpr = parentCall.getExpression(); if (parentExpr.getKind() === SyntaxKind.PropertyAccessExpression) { const propAccess = parentExpr.asKind( SyntaxKind.PropertyAccessExpression, ); if (propAccess?.getName() === 'set') { const args = parentCall.getArguments(); if (args.length >= 1 && args[0]) { const paramName = args[0].getText().replaceAll(/['"]/g, ''); return ['page', 'pageSize', 'limit', 'offset', 'size'].includes( paramName, ); } } } return false; }); } return false; } private getComponentType(classDecl: ClassDeclaration): string { const properties = classDecl.getProperties(); const hasPaginator = properties.some((prop) => { const typeNode = prop.getTypeNode(); return typeNode?.getText().includes('MatPaginator'); }); return hasPaginator ? 'MatPaginator' : 'custom pagination'; } private createPaginationViolation( classDecl: ClassDeclaration, className: string, componentType: string, offlineContext: OfflineContext, sourceFile: SourceFile, ): AnalysisResult { return { ruleId: this.id, message: `${componentType} in '${className}' not connected to server-side pagination. Large datasets may cause performance issues.`, filePath: sourceFile.getFilePath(), line: classDecl.getStartLineNumber(), column: classDecl.getStartLineNumber(), suggestedFix: 'Connect pagination to server-side pagination with HTTP parameters', metadata: { framework: 'angular', principle: 'server-side-pagination', performanceImpact: '~50% memory reduction', offlineContext: offlineContext, refactoringComplexity: 'medium', }, }; } /** * Extracts the template from a component decorator using functional chaining. * Uses optional chaining to safely navigate the AST structure. * @param classDecl - The class declaration to extract the template from. * @returns The template string or null if not found. */ private getComponentTemplate(classDecl: ClassDeclaration): string | null { return ( classDecl .getDecorators() // 1. Get all decorators on the class .find(this.isComponentDecorator) // 2. Find the @Component decorator ?.getArguments() // 3. Get the decorator's arguments array .at(0) // 4. Get the first argument (the config object) ?.asKind(SyntaxKind.ObjectLiteralExpression) // 5. Cast to object literal { selector: '...', template: '...' } ?.getProperty('template') // 6. Get the 'template' property from the object ?.asKind(SyntaxKind.PropertyAssignment) // 7. Cast to property assignment (template: "...") ?.getInitializer() // 8. Get the value part of the assignment ?.asKind(SyntaxKind.StringLiteral) // 9. Cast to string literal ?.getLiteralValue() ?? null ); // 10. Extract the actual string value, fallback to null } /** * Type guard to check if a decorator is a Component decorator. * @param decorator - The decorator to check * @returns True if the decorator is a Component decorator */ private isComponentDecorator(this: void, decorator: Decorator): boolean { return ( decorator .getExpression() .asKind(SyntaxKind.CallExpression) ?.getExpression() .getText() === 'Component' ); } /** * Estimates the size of an array based on variable declarations and assignments. * Uses a functional pipeline approach with an array of strategy objects. * Each strategy has a predicate function to match AST patterns and an estimation function. * The first matching strategy is applied to estimate the array size. * @param node - The AST node to analyze for array size estimation * @param classDecl - The class declaration containing the node * @returns Estimated array size or 0 if cannot determine */ private estimateArraySize(node: Node, classDecl: ClassDeclaration): number { // Define estimation strategies as pure functions const strategies = [ // Strategy 1: Direct array literal (may contain spread elements) { matches: (n: Node) => n.getKind() === SyntaxKind.ArrayLiteralExpression, estimate: (n: Node) => { const arrayLiteral = n.asKind(SyntaxKind.ArrayLiteralExpression); if (!arrayLiteral) { return 0; } const elements = arrayLiteral.getElements(); // If there's a single spread element, estimate the spread expression if ( elements.length === 1 && elements[0]?.getKind() === SyntaxKind.SpreadElement ) { const spreadElement = elements[0].asKind(SyntaxKind.SpreadElement); if (spreadElement) { return this.estimateArraySize( spreadElement.getExpression(), classDecl, ); } } // Otherwise, return the literal element count return elements.length; }, }, // Strategy 2: Property access (like this.data) { matches: (n: Node) => n.getKind() === SyntaxKind.PropertyAccessExpression, estimate: (n: Node) => this.estimateFromPropertyAccess( n.asKind(SyntaxKind.PropertyAccessExpression)!, classDecl, ), }, // Strategy 3: Type annotation on node { matches: (n: Node) => n.getType && this.isArrayType(n.getType()), estimate: () => 1000, // Conservative estimate for typed arrays }, // Strategy 4: Variable declarations { matches: (n: Node) => n.getKind() === SyntaxKind.Identifier, estimate: (n: Node) => { const identifier = n.asKind(SyntaxKind.Identifier); return identifier ? this.estimateFromVariableDeclarations(identifier) : 0; }, }, ]; // Apply first matching strategy const matchingStrategy = strategies.find((strategy) => strategy.matches(node), ); return matchingStrategy ? matchingStrategy.estimate(node) : 0; } /** * Estimates array size from property access expressions. * @param propAccess - The property access expression * @param classDecl - The class declaration to search for the property * @returns Estimated array size */ private estimateFromPropertyAccess( propAccess: PropertyAccessExpression, classDecl: ClassDeclaration, ): number { const propName = propAccess.getName(); const matchingProp = classDecl .getProperties() .find((prop: PropertyDeclaration) => prop.getName() === propName); if (!matchingProp) { return 0; } // Combine estimates from type annotation and initializer const typeEstimate = this.estimateFromTypeAnnotation( matchingProp.getTypeNode(), ); const initEstimate = this.estimateFromInitializer( matchingProp.getInitializer(), ); return Math.max(typeEstimate, initEstimate); } /** * Estimates array size from variable declarations. * @param identifier - The identifier to analyze * @returns Maximum estimated size from all matching declarations */ private estimateFromVariableDeclarations(identifier: Identifier): number { const symbol = identifier.getSymbol(); if (!symbol) { return 0; } return symbol .getDeclarations() .filter((decl: Node) => decl.getKind() === SyntaxKind.VariableDeclaration) .map((decl: Node) => decl.asKind(SyntaxKind.VariableDeclaration)) .filter((varDecl): varDecl is VariableDeclaration => Boolean(varDecl)) .map((varDecl: VariableDeclaration) => this.estimateFromInitializer(varDecl.getInitializer()), ) .reduce((max: number, current: number) => Math.max(max, current), 0); } /** * Checks if a type represents an array type. * @param type - The type or type node to check * @returns True if the type is an array type */ private isArrayType(type: TypeNode | Type | undefined): boolean { if (!type) { return false; } const typeText = type.getText(); return typeText.includes('Array<') || typeText.includes('[]'); } /** * Estimates array size from a type annotation. * @param type - The type or type node to analyze * @returns Estimated size or 0 if not an array type */ private estimateFromTypeAnnotation( type: TypeNode | Type | undefined, ): number { return this.isArrayType(type) ? 1000 : 0; } /** * Estimates array size from an initializer expression. * @param initializer - The initializer expression to analyze * @returns Estimated size or 0 if not an array literal */ private estimateFromInitializer(initializer: Expression | undefined): number { if ( !initializer || initializer.getKind() !== SyntaxKind.ArrayLiteralExpression ) { return 0; } const arrayLiteral = initializer.asKind(SyntaxKind.ArrayLiteralExpression); return arrayLiteral ? arrayLiteral.getElements().length : 0; } /** * Detects if a component is offline-capable based on various indicators. * Uses a functional approach with an array of check functions for better testability and maintainability. * Each check returns an OfflineContext if the component is offline-capable, or null otherwise. * The first positive result determines the offline capability. * @param component - The class declaration of the component. * @param className - The name of the component class. * @param filePath - The file path of the component. * @returns An OfflineContext object indicating offline capability. */ private detectOfflineContext( component: ClassDeclaration, className: string, filePath: string, ): OfflineContext { const checks = [ // Check 1: Manual configuration (highest priority) () => offlineConfig.offlineComponents.includes(className) ? { isOfflineCapable: true, reason: 'Manual configuration', indicators: ['offlineComponents list'], } : null, // Check 2: Custom decorators () => { const decoratorName = component .getDecorators() .map((d) => d.getName()) .find((name) => offlineConfig.offlineDecorators.includes(name)); return decoratorName ? { isOfflineCapable: true, reason: 'Custom offline decorator', indicators: [decoratorName], } : null; }, // Check 3: Path patterns () => { const pattern = offlineConfig.offlinePaths.find((p) => filePath.includes(p), ); return pattern ? { isOfflineCapable: true, reason: 'Offline-capable path', indicators: [pattern], } : null; }, // Check 4: Service Worker imports () => this.hasServiceWorkerImport(component.getSourceFile()) ? { isOfflineCapable: true, reason: 'Service Worker detected', indicators: ['@angular/service-worker'], } : null, // Check 5: IndexedDB usage () => this.hasIndexedDBUsage(component.getSourceFile()) ? { isOfflineCapable: true, reason: 'IndexedDB detected', indicators: ['IndexedDB'], } : null, // Check 6: Local storage usage () => this.hasLocalStorageUsage(component.getSourceFile()) ? { isOfflineCapable: true, reason: 'Local storage detected', indicators: ['localStorage'], } : null, // Check 7: Offline sync patterns () => this.hasOfflineSyncPatterns(component.getSourceFile()) ? { isOfflineCapable: true, reason: 'Offline sync patterns', indicators: ['offline sync'], } : null, ]; // Find the first check that returns a positive result const result = checks .map((check) => check()) .find((result) => result !== null); return ( result ?? { isOfflineCapable: false, reason: '', indicators: [], } ); } /** * Checks if the source file imports Angular Service Worker. * @param sourceFile - The source file to check for service worker imports * @returns True if service worker is imported */ private hasServiceWorkerImport(sourceFile: Node): boolean { const imports = sourceFile.getDescendantsOfKind( SyntaxKind.ImportDeclaration, ); return imports.some((imp) => imp.getModuleSpecifierValue().includes('@angular/service-worker'), ); } /** * Checks if the source file uses IndexedDB for offline storage. * @param sourceFile - The source file to check for IndexedDB usage * @returns True if IndexedDB is used */ private hasIndexedDBUsage(sourceFile: Node): boolean { const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); return calls.some((call) => { const expr = call.getExpression().getText(); return ( expr.includes('indexedDB') || expr.includes('openDB') || expr.includes('IDBDatabase') ); }); } /** * Checks if the source file uses localStorage or sessionStorage. * @param sourceFile - The source file to check for storage usage * @returns True if local/session storage is used */ private hasLocalStorageUsage(sourceFile: Node): boolean { const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); return calls.some((call) => { const expr = call.getExpression().getText(); return expr.includes('localStorage') || expr.includes('sessionStorage'); }); } /** * Checks if the source file contains offline synchronization patterns. * @param sourceFile - The source file to check for offline sync patterns * @returns True if offline sync patterns are detected */ private hasOfflineSyncPatterns(sourceFile: Node): boolean { const identifiers = sourceFile.getDescendantsOfKind(SyntaxKind.Identifier); const offlineTerms = [ 'offlineQueue', 'syncWhenOnline', 'pendingRequests', 'backgroundSync', ]; return identifiers.some((id) => offlineTerms.includes(id.getText())); } }