sca-tool
Version:
Universal static code analysis tool for calculating API operation costs across multiple frameworks (NestJS, Express, Fastify)
622 lines • 25.5 kB
JavaScript
;
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