UNPKG

sca-tool

Version:

Universal static code analysis tool for calculating API operation costs across multiple frameworks (NestJS, Express, Fastify)

622 lines 25.5 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.ApiCostAnalyzer = void 0; exports.analyzeNestJSFile = analyzeNestJSFile; exports.analyzeNestJSProject = analyzeNestJSProject; exports.analyzeExpressProject = analyzeExpressProject; const ts = __importStar(require("typescript")); const fs = __importStar(require("fs")); const path = __importStar(require("path")); const glob = __importStar(require("glob")); const config_1 = require("./config"); const config_loader_1 = require("./config-loader"); /** * Main API Cost Analyzer class */ class ApiCostAnalyzer { config; allPaths = []; currentConditions = []; sourceFile; constructor(config = {}) { this.config = (0, config_1.mergeConfigs)(config_1.DEFAULT_CONFIG, config); } /** * Load configuration from file with fallbacks */ static loadConfigFromFile(configPath) { return config_loader_1.ConfigLoader.loadConfig(configPath); } /** * Create analyzer with config loaded from file */ static fromConfigFile(configPath) { const config = config_loader_1.ConfigLoader.loadConfig(configPath); return new ApiCostAnalyzer(config); } /** * Set framework preset */ setFramework(framework) { const frameworkConfig = (0, config_1.getFrameworkConfig)(framework); if (frameworkConfig) { this.config = (0, config_1.mergeConfigs)(this.config, frameworkConfig); } } /** * Update configuration */ updateConfig(config) { this.config = (0, config_1.mergeConfigs)(this.config, config); } /** * Analyze a single TypeScript/JavaScript file for API operation costs * @param filePath Path to the file to analyze * @returns File analysis results including methods, execution paths, and cost calculations */ analyzeFile(filePath) { if (!fs.existsSync(filePath)) { throw new Error(`File not found: ${filePath}`); } const sourceCode = fs.readFileSync(filePath, 'utf8'); this.sourceFile = ts.createSourceFile(filePath, sourceCode, ts.ScriptTarget.Latest, true); const methods = []; this.visit(this.sourceFile, (node) => { if (this.isMethodDeclaration(node) || this.isArrowFunctionProperty(node)) { const methodName = this.getMethodName(node); const className = this.getClassName(node); if (methodName && this.shouldAnalyzeMethod(methodName)) { const analysis = this.analyzeMethod(node, methodName, filePath); if (analysis.paths.length > 0) { analysis.className = className; methods.push(analysis); } } } }); return { filePath, fileName: path.basename(filePath), methods, summary: this.generateFileSummary(methods) }; } /** * Analyze multiple files */ analyzeFiles(filePaths) { return filePaths.map(filePath => this.analyzeFile(filePath)); } /** * Analyze entire project */ /** * Analyze an entire project directory * @param projectPath Path to the project directory * @param patterns Glob patterns to match files (default: TypeScript and JavaScript files) * @returns Project analysis results */ analyzeProject(projectPath, patterns = ['**/*.ts', '**/*.js']) { const files = []; patterns.forEach(pattern => { // Use glob with proper options for cross-platform compatibility const globPattern = path.posix.join(projectPath.replace(/\\/g, '/'), pattern); const matches = glob.sync(globPattern, { cwd: process.cwd(), absolute: true, windowsPathsNoEscape: true }); // Filter out test files, spec files, and type definition files const filteredFiles = matches.filter(file => { const normalizedFile = path.normalize(file); return !normalizedFile.includes('.spec.') && !normalizedFile.includes('.test.') && !normalizedFile.includes('.d.ts') && fs.existsSync(normalizedFile) && fs.statSync(normalizedFile).isFile(); }); files.push(...filteredFiles); }); const uniqueFiles = [...new Set(files)]; const fileAnalyses = this.analyzeFiles(uniqueFiles); return { projectPath, files: fileAnalyses, summary: this.generateProjectSummary(fileAnalyses, projectPath), generatedAt: new Date().toISOString() }; } generatePathDescription(conditions, operations) { const conditionDesc = conditions.length > 0 ? `When ${conditions.join(' and ')}` : 'Default execution'; const operationDesc = operations.length > 0 ? `performs ${operations.length} operation(s)` : 'no operations'; return `${conditionDesc}, ${operationDesc}`; } generateMethodSummary() { const costs = this.allPaths.map(p => p.totalCost); const minCost = Math.min(...costs); const maxCost = Math.max(...costs); const avgCost = Math.round((costs.reduce((a, b) => a + b, 0) / costs.length) * 100) / 100; // Find most common path (lowest cost as default) const commonPath = this.allPaths.find(p => p.totalCost === minCost) || this.allPaths[0]; return { minCost, maxCost, avgCost, commonPath }; } analyzeMethod(node, methodName, filePath) { this.allPaths = []; this.currentConditions = []; // Get the actual function body to analyze let bodyToAnalyze = node; // For arrow function properties, we need to analyze the arrow function body if (ts.isPropertyDeclaration(node) && node.initializer && ts.isArrowFunction(node.initializer)) { bodyToAnalyze = node.initializer.body; } // For method declarations, analyze the entire method else if (ts.isMethodDeclaration(node) && node.body) { bodyToAnalyze = node.body; } // Analyze the method body this.analyzeNode(bodyToAnalyze, []); if (this.allPaths.length === 0) { // Create default path if no operations found this.allPaths.push({ name: 'Default Path', conditions: [], operations: [], totalCost: 0, description: 'No database operations found', parallelGroups: [] }); } const summary = this.generateMethodSummary(); const recommendations = this.generateRecommendations(); return { methodName, filePath, totalPaths: this.allPaths.length, paths: this.allPaths, summary, recommendations }; } analyzeNode(node, conditions) { // Focus on await expressions as complete statements if (ts.isAwaitExpression(node)) { this.analyzeAwaitStatement(node, conditions); return; // Don't traverse children for await expressions } // Handle variable declarations with await if (ts.isVariableStatement(node)) { this.analyzeVariableStatement(node, conditions); return; } // Handle Promise.all patterns if (ts.isCallExpression(node)) { this.analyzeCallExpression(node, conditions); } // Continue analyzing child nodes for other patterns ts.forEachChild(node, (child) => { this.analyzeNode(child, conditions); }); } analyzeAwaitStatement(awaitNode, conditions) { const fullStatement = awaitNode.getText(this.sourceFile); const operation = this.extractOperationFromStatement(fullStatement, awaitNode); if (operation) { this.allPaths.push({ name: conditions.length > 0 ? `Conditional Path: ${conditions.join(', ')}` : 'Default Path', conditions: [...conditions], operations: [operation], totalCost: operation.cost, description: `Async operation: ${operation.method}`, parallelGroups: [] }); } } analyzeVariableStatement(varNode, conditions) { const statement = varNode.getText(this.sourceFile); // Check if it contains await if (statement.includes('await')) { const operation = this.extractOperationFromStatement(statement, varNode); if (operation) { this.allPaths.push({ name: conditions.length > 0 ? `Conditional Path: ${conditions.join(', ')}` : 'Default Path', conditions: [...conditions], operations: [operation], totalCost: operation.cost, description: `Variable assignment with async operation: ${operation.method}`, parallelGroups: [] }); } } } analyzeCallExpression(callNode, conditions) { const callText = callNode.getText(this.sourceFile); // Check for Promise.all patterns if (callText.includes('Promise.all') || callText.includes('Promise.allSettled')) { const operations = this.extractParallelOperations(callText, callNode); if (operations.length > 0) { // For parallel operations, cost is the maximum, not the sum const maxCost = Math.max(...operations.map(op => op.cost)); this.allPaths.push({ name: conditions.length > 0 ? `Conditional Path: ${conditions.join(', ')}` : 'Parallel Execution', conditions: [...conditions], operations: operations.map(op => ({ ...op, isParallel: true })), totalCost: maxCost, description: `Parallel execution of ${operations.length} operations`, parallelGroups: [operations] }); } } } extractOperationName(matchText) { // Handle Supabase query chains specially if (matchText.includes('supabase') && matchText.includes('.from(')) { // For Supabase queries, the main operation is determined by the action if (matchText.includes('.insert(')) return 'supabaseInsert'; if (matchText.includes('.update(')) return 'supabaseUpdate'; if (matchText.includes('.upsert(')) return 'supabaseUpsert'; if (matchText.includes('.delete(')) return 'supabaseDelete'; if (matchText.includes('.select(') || matchText.includes('.from(')) return 'from'; return 'from'; // Default for Supabase queries } // For Supabase filter operations, return with zero cost if (matchText.match(/\.(eq|neq|gt|gte|lt|lte|like|ilike|is|not|or|and|single|limit|order)\(/)) { const match = matchText.match(/\.(\w+)\(/); return match ? match[1] : 'unknown_operation'; } // Extract operation name for other patterns const operationPatterns = [ /\.(select|insert|update|upsert|delete|findMany|findUnique|findFirst|create|createMany|updateMany|deleteMany|count|aggregate|groupBy|find|findOne|findOneBy|findAndCount|save|remove|signInWithPassword|signUp|signOut|refreshSession|updateUserById)\(/, /\.(from|single|eq|in)\(/ ]; for (const pattern of operationPatterns) { const match = matchText.match(pattern); if (match) { return match[1]; } } // Fallback: try to extract any method call const methodMatch = matchText.match(/\.(\w+)\(/); if (methodMatch) { return methodMatch[1]; } return 'unknown_operation'; } isPartOfMethodChain(node) { // Check if this node is part of a larger method chain const parent = node.parent; if (!parent) return false; // If parent is a property access expression and this is the expression part if (ts.isPropertyAccessExpression(parent) && parent.expression === node) { return true; } // If parent is a call expression and this is the expression part if (ts.isCallExpression(parent) && parent.expression === node) { return true; } return false; } extractOperationFromStatement(statement, node) { // Determine the framework and operation type let operationName = 'unknown_operation'; let framework = 'unknown'; let cost = 1; // Supabase operations if (statement.includes('supabase')) { framework = 'supabase'; if (statement.includes('.from(') && statement.includes('.select(')) { operationName = 'from'; // Select query } else if (statement.includes('.insert(')) { operationName = 'supabaseInsert'; } else if (statement.includes('.update(')) { operationName = 'supabaseUpdate'; } else if (statement.includes('.upsert(')) { operationName = 'supabaseUpsert'; } else if (statement.includes('.delete(')) { operationName = 'supabaseDelete'; } else if (statement.includes('.signInWithPassword(')) { operationName = 'signInWithPassword'; } else if (statement.includes('.signUp(')) { operationName = 'signUp'; } } // Prisma operations else if (statement.includes('prisma.')) { framework = 'prisma'; if (statement.includes('.findUnique(')) operationName = 'findUnique'; else if (statement.includes('.findMany(')) operationName = 'findMany'; else if (statement.includes('.findFirst(')) operationName = 'findFirst'; else if (statement.includes('.create(')) operationName = 'create'; else if (statement.includes('.createMany(')) operationName = 'createMany'; else if (statement.includes('.update(')) operationName = 'update'; else if (statement.includes('.updateMany(')) operationName = 'updateMany'; else if (statement.includes('.upsert(')) operationName = 'upsert'; else if (statement.includes('.delete(')) operationName = 'delete'; else if (statement.includes('.deleteMany(')) operationName = 'deleteMany'; } // TypeORM operations else if (statement.includes('Repository') || statement.includes('.find(') || statement.includes('.save(')) { framework = 'typeorm'; if (statement.includes('.find(')) operationName = 'find'; else if (statement.includes('.findOne(')) operationName = 'findOne'; else if (statement.includes('.save(')) operationName = 'save'; else if (statement.includes('.remove(')) operationName = 'remove'; } // Get cost from configuration cost = this.config.operations[operationName] || 1; // Only return operation if we found a meaningful database operation if (operationName !== 'unknown_operation') { return { method: operationName, cost, line: this.sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1, conditions: [], expression: statement.length > 100 ? statement.substring(0, 100) + '...' : statement, framework, isParallel: false }; } return null; } extractParallelOperations(statement, node) { const operations = []; // Split by common async patterns to find individual operations const awaitMatches = statement.match(/await\s+[^,\]]+/g) || []; awaitMatches.forEach((awaitExpr, index) => { const operation = this.extractOperationFromStatement(awaitExpr, node); if (operation) { operations.push({ ...operation, isParallel: true }); } }); return operations; } generateRecommendations() { const recommendations = []; const summary = this.generateMethodSummary(); // High cost variance if (summary.maxCost > summary.minCost * 1.5) { recommendations.push(`⚠️ High cost variance (${summary.minCost}-${summary.maxCost}) - Consider optimizing expensive paths`); } // High maximum cost if (summary.maxCost > 5) { recommendations.push(`⚠️ High maximum cost (${summary.maxCost}) - Consider caching or batching operations`); } // Sequential operations that could be parallel const hasSequentialOps = this.allPaths.some(path => path.operations.length > 1 && path.operations.every(op => !op.isParallel)); if (hasSequentialOps) { recommendations.push(`💡 Consider using Promise.all() for independent operations`); } if (recommendations.length === 0) { recommendations.push(`✅ Cost structure looks optimal`); } return recommendations; } generateFileSummary(methods) { const allCosts = methods.flatMap(m => m.paths.map(p => p.totalCost)); const totalPaths = methods.reduce((sum, m) => sum + m.totalPaths, 0); return { totalMethods: methods.length, totalPaths, costRange: { min: allCosts.length > 0 ? Math.min(...allCosts) : 0, max: allCosts.length > 0 ? Math.max(...allCosts) : 0, average: allCosts.length > 0 ? Math.round((allCosts.reduce((a, b) => a + b, 0) / allCosts.length) * 100) / 100 : 0 } }; } generateProjectSummary(files, projectPath) { const allMethods = files.flatMap(f => f.methods); const allCosts = allMethods.flatMap(m => m.paths.map(p => p.totalCost)); const totalPaths = allMethods.reduce((sum, m) => sum + m.totalPaths, 0); // Categorize costs const lowCost = allCosts.filter(c => c <= 2).length; const mediumCost = allCosts.filter(c => c > 2 && c <= 5).length; const highCost = allCosts.filter(c => c > 5).length; // Generate optimization opportunities const optimizationOpportunities = []; allMethods.forEach(method => { const variance = method.summary.maxCost - method.summary.minCost; if (variance > 2) { optimizationOpportunities.push({ file: method.filePath, method: method.methodName, type: 'high_variance', description: `Cost variance: ${method.summary.minCost}-${method.summary.maxCost}`, priority: variance > 5 ? 'high' : 'medium' }); } if (method.summary.maxCost > 5) { optimizationOpportunities.push({ file: method.filePath, method: method.methodName, type: 'high_cost', description: `High maximum cost: ${method.summary.maxCost}`, priority: 'high', suggestion: 'Consider caching, batching, or breaking into smaller operations' }); } }); return { totalFiles: files.length, totalMethods: allMethods.length, totalPaths, costDistribution: { low: lowCost, medium: mediumCost, high: highCost }, optimizationOpportunities, generatedAt: new Date().toISOString() }; } // Helper methods for AST traversal visit(node, callback) { callback(node); ts.forEachChild(node, (child) => this.visit(child, callback)); } isMethodDeclaration(node) { return ts.isMethodDeclaration(node) || ts.isFunctionDeclaration(node); } /** * Check if a node is an arrow function property (common in modern NestJS services) * Handles both class property declarations and object literal assignments */ isArrowFunctionProperty(node) { // Check for class property declarations with arrow function initializers // Example: login = async (user: User) => { ... } if (ts.isPropertyDeclaration(node) && node.initializer && ts.isArrowFunction(node.initializer)) { return true; } // Check for object literal property assignments with arrow functions if (ts.isPropertyAssignment(node) && node.initializer && ts.isArrowFunction(node.initializer)) { return true; } return false; } getMethodName(node) { if (ts.isMethodDeclaration(node) || ts.isFunctionDeclaration(node)) { return node.name?.getText(this.sourceFile) || null; } if (ts.isPropertyAssignment(node)) { return node.name?.getText(this.sourceFile) || null; } if (ts.isPropertyDeclaration(node)) { return node.name?.getText(this.sourceFile) || null; } return null; } getClassName(node) { let parent = node.parent; while (parent) { if (ts.isClassDeclaration(parent)) { return parent.name?.getText(this.sourceFile); } parent = parent.parent; } return undefined; } shouldAnalyzeMethod(methodName) { // Skip certain methods const skipPatterns = [ 'constructor', 'ngOnInit', 'ngOnDestroy', 'toString', 'valueOf', 'hasOwnProperty' ]; return !skipPatterns.some(pattern => methodName.includes(pattern)) && !methodName.startsWith('_') && !methodName.startsWith('private'); } } exports.ApiCostAnalyzer = ApiCostAnalyzer; /** * Convenience function to quickly analyze a file with NestJS preset */ function analyzeNestJSFile(filePath, customConfig) { const analyzer = new ApiCostAnalyzer(); analyzer.setFramework('nestjs'); if (customConfig) { analyzer.updateConfig(customConfig); } return analyzer.analyzeFile(filePath); } /** * Convenience function to quickly analyze a NestJS project */ function analyzeNestJSProject(projectPath, patterns, customConfig) { const analyzer = new ApiCostAnalyzer(); analyzer.setFramework('nestjs'); if (customConfig) { analyzer.updateConfig(customConfig); } return analyzer.analyzeProject(projectPath, patterns || ['**/*.service.ts', '**/*.controller.ts', '**/*.gateway.ts']); } /** * Convenience function for Express projects */ function analyzeExpressProject(projectPath, patterns, customConfig) { const analyzer = new ApiCostAnalyzer(); analyzer.setFramework('express'); if (customConfig) { analyzer.updateConfig(customConfig); } return analyzer.analyzeProject(projectPath, patterns || ['**/*.js', '**/*.ts', '**/routes/**/*.js', '**/controllers/**/*.js']); } //# sourceMappingURL=analyzer.js.map