#!/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 [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} */ 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);