feat: add stella-callgraph-node for JavaScript/TypeScript call graph extraction

- 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.
This commit is contained in:
master
2025-12-19 18:11:59 +02:00
parent 951a38d561
commit 8779e9226f
130 changed files with 19011 additions and 422 deletions

View File

@@ -0,0 +1,435 @@
#!/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);