- Implemented a new tool `stella-callgraph-node` that extracts call graphs from JavaScript/TypeScript projects using Babel AST. - Added command-line interface with options for JSON output and help. - Included functionality to analyze project structure, detect functions, and build call graphs. - Created a package.json file for dependency management. feat: introduce stella-callgraph-python for Python call graph extraction - Developed `stella-callgraph-python` to extract call graphs from Python projects using AST analysis. - Implemented command-line interface with options for JSON output and verbose logging. - Added framework detection to identify popular web frameworks and their entry points. - Created an AST analyzer to traverse Python code and extract function definitions and calls. - Included requirements.txt for project dependencies. chore: add framework detection for Python projects - Implemented framework detection logic to identify frameworks like Flask, FastAPI, Django, and others based on project files and import patterns. - Enhanced the AST analyzer to recognize entry points based on decorators and function definitions.
436 lines
13 KiB
JavaScript
436 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
// -----------------------------------------------------------------------------
|
|
// stella-callgraph-node
|
|
// Call graph extraction tool for JavaScript/TypeScript projects.
|
|
// Uses Babel AST for static analysis.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
|
|
import { join, extname, relative, dirname } from 'path';
|
|
import { parse } from '@babel/parser';
|
|
import traverse from '@babel/traverse';
|
|
|
|
/**
|
|
* Main entry point
|
|
*/
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
|
|
if (args.length === 0 || args.includes('--help')) {
|
|
printUsage();
|
|
process.exit(0);
|
|
}
|
|
|
|
const targetPath = args[0];
|
|
const outputFormat = args.includes('--json') ? 'json' : 'ndjson';
|
|
|
|
try {
|
|
const result = await analyzeProject(targetPath);
|
|
|
|
if (outputFormat === 'json') {
|
|
console.log(JSON.stringify(result, null, 2));
|
|
} else {
|
|
console.log(JSON.stringify(result));
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error: ${error.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
function printUsage() {
|
|
console.log(`
|
|
stella-callgraph-node - JavaScript/TypeScript call graph extractor
|
|
|
|
Usage:
|
|
stella-callgraph-node <project-path> [options]
|
|
|
|
Options:
|
|
--json Output formatted JSON instead of NDJSON
|
|
--help Show this help message
|
|
|
|
Example:
|
|
stella-callgraph-node ./my-express-app --json
|
|
`);
|
|
}
|
|
|
|
/**
|
|
* Analyze a JavaScript/TypeScript project
|
|
* @param {string} projectPath
|
|
* @returns {Promise<CallGraphResult>}
|
|
*/
|
|
async function analyzeProject(projectPath) {
|
|
const packageJsonPath = join(projectPath, 'package.json');
|
|
let packageInfo = { name: 'unknown', version: '0.0.0' };
|
|
|
|
if (existsSync(packageJsonPath)) {
|
|
const content = readFileSync(packageJsonPath, 'utf-8');
|
|
packageInfo = JSON.parse(content);
|
|
}
|
|
|
|
const sourceFiles = findSourceFiles(projectPath);
|
|
const nodes = [];
|
|
const edges = [];
|
|
const entrypoints = [];
|
|
|
|
for (const file of sourceFiles) {
|
|
try {
|
|
const content = readFileSync(file, 'utf-8');
|
|
const relativePath = relative(projectPath, file);
|
|
const result = analyzeFile(content, relativePath, packageInfo.name);
|
|
|
|
nodes.push(...result.nodes);
|
|
edges.push(...result.edges);
|
|
entrypoints.push(...result.entrypoints);
|
|
} catch (error) {
|
|
// Skip files that can't be parsed
|
|
console.error(`Warning: Could not parse ${file}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
module: packageInfo.name,
|
|
version: packageInfo.version,
|
|
nodes: deduplicateNodes(nodes),
|
|
edges: deduplicateEdges(edges),
|
|
entrypoints
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Find all JavaScript/TypeScript source files
|
|
* @param {string} dir
|
|
* @returns {string[]}
|
|
*/
|
|
function findSourceFiles(dir) {
|
|
const files = [];
|
|
const excludeDirs = ['node_modules', 'dist', 'build', '.git', 'coverage', '__tests__'];
|
|
const extensions = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'];
|
|
|
|
function walk(currentDir) {
|
|
const entries = readdirSync(currentDir);
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = join(currentDir, entry);
|
|
const stat = statSync(fullPath);
|
|
|
|
if (stat.isDirectory()) {
|
|
if (!excludeDirs.includes(entry) && !entry.startsWith('.')) {
|
|
walk(fullPath);
|
|
}
|
|
} else if (stat.isFile()) {
|
|
const ext = extname(entry);
|
|
if (extensions.includes(ext) && !entry.includes('.test.') && !entry.includes('.spec.')) {
|
|
files.push(fullPath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
walk(dir);
|
|
return files.sort();
|
|
}
|
|
|
|
/**
|
|
* Analyze a single source file
|
|
* @param {string} content
|
|
* @param {string} relativePath
|
|
* @param {string} packageName
|
|
* @returns {{ nodes: any[], edges: any[], entrypoints: any[] }}
|
|
*/
|
|
function analyzeFile(content, relativePath, packageName) {
|
|
const nodes = [];
|
|
const edges = [];
|
|
const entrypoints = [];
|
|
const moduleBase = relativePath.replace(/\.[^.]+$/, '').replace(/\\/g, '/');
|
|
|
|
// Parse with Babel
|
|
const ast = parse(content, {
|
|
sourceType: 'module',
|
|
plugins: [
|
|
'typescript',
|
|
'jsx',
|
|
'decorators-legacy',
|
|
'classProperties',
|
|
'classPrivateProperties',
|
|
'classPrivateMethods',
|
|
'dynamicImport',
|
|
'optionalChaining',
|
|
'nullishCoalescingOperator'
|
|
],
|
|
errorRecovery: true
|
|
});
|
|
|
|
// Track current function context for edges
|
|
let currentFunction = null;
|
|
|
|
traverse.default(ast, {
|
|
// Function declarations
|
|
FunctionDeclaration(path) {
|
|
const name = path.node.id?.name;
|
|
if (!name) return;
|
|
|
|
const nodeId = `js:${packageName}/${moduleBase}.${name}`;
|
|
const isExported = path.parent.type === 'ExportNamedDeclaration' ||
|
|
path.parent.type === 'ExportDefaultDeclaration';
|
|
|
|
nodes.push({
|
|
id: nodeId,
|
|
package: packageName,
|
|
name,
|
|
signature: getFunctionSignature(path.node),
|
|
position: {
|
|
file: relativePath,
|
|
line: path.node.loc?.start.line || 0,
|
|
column: path.node.loc?.start.column || 0
|
|
},
|
|
visibility: isExported ? 'public' : 'private',
|
|
annotations: []
|
|
});
|
|
|
|
// Check for route handlers
|
|
const routeInfo = detectRouteHandler(path);
|
|
if (routeInfo) {
|
|
entrypoints.push({
|
|
id: nodeId,
|
|
type: routeInfo.type,
|
|
route: routeInfo.route,
|
|
method: routeInfo.method
|
|
});
|
|
}
|
|
|
|
currentFunction = nodeId;
|
|
},
|
|
|
|
// Arrow functions assigned to variables
|
|
VariableDeclarator(path) {
|
|
if (path.node.init?.type === 'ArrowFunctionExpression' ||
|
|
path.node.init?.type === 'FunctionExpression') {
|
|
|
|
const name = path.node.id?.name;
|
|
if (!name) return;
|
|
|
|
const nodeId = `js:${packageName}/${moduleBase}.${name}`;
|
|
const parent = path.parentPath?.parent;
|
|
const isExported = parent?.type === 'ExportNamedDeclaration';
|
|
|
|
nodes.push({
|
|
id: nodeId,
|
|
package: packageName,
|
|
name,
|
|
signature: getFunctionSignature(path.node.init),
|
|
position: {
|
|
file: relativePath,
|
|
line: path.node.loc?.start.line || 0,
|
|
column: path.node.loc?.start.column || 0
|
|
},
|
|
visibility: isExported ? 'public' : 'private',
|
|
annotations: []
|
|
});
|
|
|
|
currentFunction = nodeId;
|
|
}
|
|
},
|
|
|
|
// Class methods
|
|
ClassMethod(path) {
|
|
const className = path.parentPath?.parent?.id?.name;
|
|
const methodName = path.node.key?.name;
|
|
if (!className || !methodName) return;
|
|
|
|
const nodeId = `js:${packageName}/${moduleBase}.${className}.${methodName}`;
|
|
|
|
nodes.push({
|
|
id: nodeId,
|
|
package: packageName,
|
|
name: `${className}.${methodName}`,
|
|
signature: getFunctionSignature(path.node),
|
|
position: {
|
|
file: relativePath,
|
|
line: path.node.loc?.start.line || 0,
|
|
column: path.node.loc?.start.column || 0
|
|
},
|
|
visibility: path.node.accessibility || 'public',
|
|
annotations: getDecorators(path)
|
|
});
|
|
|
|
// Check for controller/handler patterns
|
|
if (className.endsWith('Controller') || className.endsWith('Handler')) {
|
|
entrypoints.push({
|
|
id: nodeId,
|
|
type: 'http_handler',
|
|
route: null,
|
|
method: null
|
|
});
|
|
}
|
|
|
|
currentFunction = nodeId;
|
|
},
|
|
|
|
// Call expressions (edges)
|
|
CallExpression(path) {
|
|
if (!currentFunction) return;
|
|
|
|
const callee = path.node.callee;
|
|
let targetId = null;
|
|
|
|
if (callee.type === 'Identifier') {
|
|
targetId = `js:${packageName}/${moduleBase}.${callee.name}`;
|
|
} else if (callee.type === 'MemberExpression') {
|
|
const objName = callee.object?.name || 'unknown';
|
|
const propName = callee.property?.name || 'unknown';
|
|
targetId = `js:external/${objName}.${propName}`;
|
|
}
|
|
|
|
if (targetId) {
|
|
edges.push({
|
|
from: currentFunction,
|
|
to: targetId,
|
|
kind: 'direct',
|
|
site: {
|
|
file: relativePath,
|
|
line: path.node.loc?.start.line || 0
|
|
}
|
|
});
|
|
}
|
|
|
|
// Detect Express/Fastify route registration
|
|
detectRouteRegistration(path, entrypoints, packageName, moduleBase, relativePath);
|
|
}
|
|
});
|
|
|
|
return { nodes, edges, entrypoints };
|
|
}
|
|
|
|
/**
|
|
* Get function signature string
|
|
* @param {object} node
|
|
* @returns {string}
|
|
*/
|
|
function getFunctionSignature(node) {
|
|
const params = node.params?.map(p => {
|
|
if (p.type === 'Identifier') {
|
|
return p.name;
|
|
} else if (p.type === 'AssignmentPattern') {
|
|
return p.left?.name || 'arg';
|
|
} else if (p.type === 'RestElement') {
|
|
return `...${p.argument?.name || 'args'}`;
|
|
}
|
|
return 'arg';
|
|
}) || [];
|
|
|
|
const isAsync = node.async ? 'async ' : '';
|
|
return `${isAsync}(${params.join(', ')})`;
|
|
}
|
|
|
|
/**
|
|
* Get decorators from a path
|
|
* @param {object} path
|
|
* @returns {string[]}
|
|
*/
|
|
function getDecorators(path) {
|
|
const decorators = path.node.decorators || [];
|
|
return decorators.map(d => {
|
|
if (d.expression?.callee?.name) {
|
|
return `@${d.expression.callee.name}`;
|
|
} else if (d.expression?.name) {
|
|
return `@${d.expression.name}`;
|
|
}
|
|
return '@unknown';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Detect if function is a route handler
|
|
* @param {object} path
|
|
* @returns {{ type: string, route: string | null, method: string | null } | null}
|
|
*/
|
|
function detectRouteHandler(path) {
|
|
const name = path.node.id?.name?.toLowerCase();
|
|
|
|
if (!name) return null;
|
|
|
|
// Common handler naming patterns
|
|
if (name.includes('handler') || name.includes('controller')) {
|
|
return { type: 'http_handler', route: null, method: null };
|
|
}
|
|
|
|
// Lambda handler pattern
|
|
if (name === 'handler' || name === 'main') {
|
|
return { type: 'lambda', route: null, method: null };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Detect Express/Fastify route registration
|
|
* @param {object} path
|
|
* @param {any[]} entrypoints
|
|
* @param {string} packageName
|
|
* @param {string} moduleBase
|
|
* @param {string} relativePath
|
|
*/
|
|
function detectRouteRegistration(path, entrypoints, packageName, moduleBase, relativePath) {
|
|
const callee = path.node.callee;
|
|
|
|
if (callee.type !== 'MemberExpression') return;
|
|
|
|
const methodName = callee.property?.name?.toLowerCase();
|
|
const httpMethods = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head'];
|
|
|
|
if (!httpMethods.includes(methodName)) return;
|
|
|
|
// Get route path from first argument
|
|
const firstArg = path.node.arguments?.[0];
|
|
let routePath = null;
|
|
|
|
if (firstArg?.type === 'StringLiteral') {
|
|
routePath = firstArg.value;
|
|
}
|
|
|
|
if (routePath) {
|
|
const handlerName = `${methodName.toUpperCase()}_${routePath.replace(/[/:{}*?]/g, '_')}`;
|
|
const nodeId = `js:${packageName}/${moduleBase}.${handlerName}`;
|
|
|
|
entrypoints.push({
|
|
id: nodeId,
|
|
type: 'http_handler',
|
|
route: routePath,
|
|
method: methodName.toUpperCase()
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove duplicate nodes
|
|
* @param {any[]} nodes
|
|
* @returns {any[]}
|
|
*/
|
|
function deduplicateNodes(nodes) {
|
|
const seen = new Set();
|
|
return nodes.filter(n => {
|
|
if (seen.has(n.id)) return false;
|
|
seen.add(n.id);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Remove duplicate edges
|
|
* @param {any[]} edges
|
|
* @returns {any[]}
|
|
*/
|
|
function deduplicateEdges(edges) {
|
|
const seen = new Set();
|
|
return edges.filter(e => {
|
|
const key = `${e.from}|${e.to}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
// Run
|
|
main().catch(console.error);
|