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,143 @@
// Framework detection for Go projects
package main
import (
"golang.org/x/tools/go/ssa"
"strings"
)
// FrameworkPattern defines detection patterns for a framework
type FrameworkPattern struct {
Name string
Packages []string
EntrypointFns []string
HandlerType string
}
// Known Go web frameworks
var frameworkPatterns = []FrameworkPattern{
{
Name: "net/http",
Packages: []string{"net/http"},
EntrypointFns: []string{"HandleFunc", "Handle", "ListenAndServe"},
HandlerType: "http_handler",
},
{
Name: "gin",
Packages: []string{"github.com/gin-gonic/gin"},
EntrypointFns: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "Run"},
HandlerType: "http_handler",
},
{
Name: "echo",
Packages: []string{"github.com/labstack/echo"},
EntrypointFns: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "Start"},
HandlerType: "http_handler",
},
{
Name: "fiber",
Packages: []string{"github.com/gofiber/fiber"},
EntrypointFns: []string{"Get", "Post", "Put", "Delete", "Patch", "Listen"},
HandlerType: "http_handler",
},
{
Name: "chi",
Packages: []string{"github.com/go-chi/chi"},
EntrypointFns: []string{"Get", "Post", "Put", "Delete", "Patch", "Route"},
HandlerType: "http_handler",
},
{
Name: "mux",
Packages: []string{"github.com/gorilla/mux"},
EntrypointFns: []string{"HandleFunc", "Handle", "NewRouter"},
HandlerType: "http_handler",
},
{
Name: "grpc",
Packages: []string{"google.golang.org/grpc"},
EntrypointFns: []string{"RegisterServer", "NewServer"},
HandlerType: "grpc_method",
},
{
Name: "cobra",
Packages: []string{"github.com/spf13/cobra"},
EntrypointFns: []string{"Execute", "AddCommand", "Run"},
HandlerType: "cli_command",
},
}
// DetectFramework checks if a function is related to a known framework
func DetectFramework(fn *ssa.Function) *FrameworkPattern {
if fn.Pkg == nil {
return nil
}
pkgPath := fn.Pkg.Pkg.Path()
for _, pattern := range frameworkPatterns {
for _, pkg := range pattern.Packages {
if strings.Contains(pkgPath, pkg) {
return &pattern
}
}
}
return nil
}
// DetectFrameworkEntrypoint checks if a call is a framework route registration
func DetectFrameworkEntrypoint(call *ssa.Call) *Entrypoint {
callee := call.Call.StaticCallee()
if callee == nil || callee.Pkg == nil {
return nil
}
pkgPath := callee.Pkg.Pkg.Path()
fnName := callee.Name()
for _, pattern := range frameworkPatterns {
for _, pkg := range pattern.Packages {
if strings.Contains(pkgPath, pkg) {
for _, epFn := range pattern.EntrypointFns {
if fnName == epFn {
nodeID := makeSymbolID(callee)
return &Entrypoint{
ID: nodeID,
Type: pattern.HandlerType,
}
}
}
}
}
}
return nil
}
// IsHTTPHandler checks if a function signature matches http.Handler
func IsHTTPHandler(fn *ssa.Function) bool {
sig := fn.Signature
// Check for (http.ResponseWriter, *http.Request) signature
if sig.Params().Len() == 2 {
p0 := sig.Params().At(0).Type().String()
p1 := sig.Params().At(1).Type().String()
if strings.Contains(p0, "ResponseWriter") && strings.Contains(p1, "Request") {
return true
}
}
// Check for gin.Context, echo.Context, fiber.Ctx, etc.
if sig.Params().Len() >= 1 {
p0 := sig.Params().At(0).Type().String()
if strings.Contains(p0, "gin.Context") ||
strings.Contains(p0, "echo.Context") ||
strings.Contains(p0, "fiber.Ctx") ||
strings.Contains(p0, "chi.") {
return true
}
}
return false
}

View File

@@ -0,0 +1,12 @@
module github.com/stella-ops/stella-callgraph-go
go 1.21
require (
golang.org/x/tools v0.16.0
)
require (
golang.org/x/mod v0.14.0 // indirect
golang.org/x/sys v0.15.0 // indirect
)

View File

@@ -0,0 +1,395 @@
// stella-callgraph-go
// Call graph extraction tool for Go projects using SSA analysis.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/tools/go/callgraph"
"golang.org/x/tools/go/callgraph/cha"
"golang.org/x/tools/go/callgraph/rta"
"golang.org/x/tools/go/packages"
"golang.org/x/tools/go/ssa"
"golang.org/x/tools/go/ssa/ssautil"
)
// CallGraphResult is the output structure
type CallGraphResult struct {
Module string `json:"module"`
Nodes []Node `json:"nodes"`
Edges []Edge `json:"edges"`
Entrypoints []Entrypoint `json:"entrypoints"`
}
// Node represents a function in the call graph
type Node struct {
ID string `json:"id"`
Package string `json:"package"`
Name string `json:"name"`
Signature string `json:"signature"`
Position Position `json:"position"`
Visibility string `json:"visibility"`
Annotations []string `json:"annotations"`
}
// Edge represents a call between functions
type Edge struct {
From string `json:"from"`
To string `json:"to"`
Kind string `json:"kind"`
Site Position `json:"site"`
}
// Position in source code
type Position struct {
File string `json:"file"`
Line int `json:"line"`
Column int `json:"column"`
}
// Entrypoint represents an entry point function
type Entrypoint struct {
ID string `json:"id"`
Type string `json:"type"`
Route string `json:"route,omitempty"`
Method string `json:"method,omitempty"`
}
func main() {
var (
projectPath string
algorithm string
jsonFormat bool
)
flag.StringVar(&projectPath, "path", ".", "Path to Go project")
flag.StringVar(&algorithm, "algo", "cha", "Call graph algorithm: cha, rta, or static")
flag.BoolVar(&jsonFormat, "json", false, "Output formatted JSON")
flag.Parse()
if len(flag.Args()) > 0 {
projectPath = flag.Args()[0]
}
result, err := analyzeProject(projectPath, algorithm)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
var output []byte
if jsonFormat {
output, err = json.MarshalIndent(result, "", " ")
} else {
output, err = json.Marshal(result)
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err)
os.Exit(1)
}
fmt.Println(string(output))
}
func analyzeProject(projectPath string, algorithm string) (*CallGraphResult, error) {
absPath, err := filepath.Abs(projectPath)
if err != nil {
return nil, fmt.Errorf("invalid path: %w", err)
}
// Load packages
cfg := &packages.Config{
Mode: packages.LoadAllSyntax,
Dir: absPath,
}
pkgs, err := packages.Load(cfg, "./...")
if err != nil {
return nil, fmt.Errorf("failed to load packages: %w", err)
}
if len(pkgs) == 0 {
return nil, fmt.Errorf("no packages found")
}
// Check for errors
for _, pkg := range pkgs {
if len(pkg.Errors) > 0 {
// Log but continue
for _, e := range pkg.Errors {
fmt.Fprintf(os.Stderr, "Warning: %v\n", e)
}
}
}
// Build SSA
prog, _ := ssautil.AllPackages(pkgs, ssa.SanityCheckFunctions)
prog.Build()
// Extract module name
moduleName := extractModuleName(absPath, pkgs)
// Build call graph using the specified algorithm
var cg *callgraph.Graph
switch algorithm {
case "rta":
// RTA (Rapid Type Analysis) - more precise for programs with main
mains := ssautil.MainPackages(prog.AllPackages())
if len(mains) > 0 {
var roots []*ssa.Function
for _, main := range mains {
if mainFn := main.Func("main"); mainFn != nil {
roots = append(roots, mainFn)
}
if initFn := main.Func("init"); initFn != nil {
roots = append(roots, initFn)
}
}
if len(roots) > 0 {
rtaResult := rta.Analyze(roots, true)
cg = rtaResult.CallGraph
}
}
if cg == nil {
// Fall back to CHA if no main packages
cg = cha.CallGraph(prog)
}
case "cha":
// CHA (Class Hierarchy Analysis) - sound but less precise
cg = cha.CallGraph(prog)
default:
// Default to CHA
cg = cha.CallGraph(prog)
}
// Collect nodes and edges from call graph
nodes := make([]Node, 0)
edges := make([]Edge, 0)
entrypoints := make([]Entrypoint, 0)
seenNodes := make(map[string]bool)
seenEdges := make(map[string]bool)
// If we have a call graph, use it for edges
if cg != nil {
callgraph.GraphVisitEdges(cg, func(edge *callgraph.Edge) error {
if edge.Caller.Func == nil || edge.Callee.Func == nil {
return nil
}
callerID := makeSymbolID(edge.Caller.Func)
calleeID := makeSymbolID(edge.Callee.Func)
// Add caller node if not seen
if !seenNodes[callerID] {
seenNodes[callerID] = true
nodes = append(nodes, makeNodeFromFunction(prog, edge.Caller.Func))
}
// Add callee node if not seen
if !seenNodes[calleeID] {
seenNodes[calleeID] = true
nodes = append(nodes, makeNodeFromFunction(prog, edge.Callee.Func))
}
// Add edge
edgeKey := fmt.Sprintf("%s|%s", callerID, calleeID)
if !seenEdges[edgeKey] {
seenEdges[edgeKey] = true
kind := "direct"
if edge.Site != nil {
if _, ok := edge.Site.(*ssa.Go); ok {
kind = "goroutine"
} else if _, ok := edge.Site.(*ssa.Defer); ok {
kind = "defer"
}
}
var site Position
if edge.Site != nil {
pos := prog.Fset.Position(edge.Site.Pos())
site = Position{
File: pos.Filename,
Line: pos.Line,
}
}
edges = append(edges, Edge{
From: callerID,
To: calleeID,
Kind: kind,
Site: site,
})
}
return nil
})
}
// Also scan all functions to find any missing nodes and entrypoints
for _, pkg := range prog.AllPackages() {
if pkg == nil {
continue
}
for _, member := range pkg.Members {
fn, ok := member.(*ssa.Function)
if !ok {
continue
}
nodeID := makeSymbolID(fn)
if !seenNodes[nodeID] {
seenNodes[nodeID] = true
nodes = append(nodes, makeNodeFromFunction(prog, fn))
}
// Check for entrypoints
if ep := detectEntrypoint(fn); ep != nil {
entrypoints = append(entrypoints, *ep)
}
}
}
return &CallGraphResult{
Module: moduleName,
Nodes: nodes,
Edges: edges,
Entrypoints: entrypoints,
}, nil
}
func makeNodeFromFunction(prog *ssa.Program, fn *ssa.Function) Node {
pos := prog.Fset.Position(fn.Pos())
pkgPath := ""
if fn.Pkg != nil {
pkgPath = fn.Pkg.Pkg.Path()
}
return Node{
ID: makeSymbolID(fn),
Package: pkgPath,
Name: fn.Name(),
Signature: fn.Signature.String(),
Position: Position{
File: pos.Filename,
Line: pos.Line,
Column: pos.Column,
},
Visibility: getVisibility(fn.Name()),
Annotations: detectAnnotations(fn),
}
}
func extractModuleName(projectPath string, pkgs []*packages.Package) string {
// Try to get from go.mod
goModPath := filepath.Join(projectPath, "go.mod")
if data, err := os.ReadFile(goModPath); err == nil {
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "module ") {
return strings.TrimSpace(strings.TrimPrefix(line, "module "))
}
}
}
// Fall back to first package path
if len(pkgs) > 0 {
return pkgs[0].PkgPath
}
return filepath.Base(projectPath)
}
func makeSymbolID(fn *ssa.Function) string {
if fn.Pkg == nil {
return fmt.Sprintf("go:external/%s", fn.Name())
}
pkg := fn.Pkg.Pkg.Path()
if fn.Signature.Recv() != nil {
// Method
recv := fn.Signature.Recv().Type().String()
recv = strings.TrimPrefix(recv, "*")
if idx := strings.LastIndex(recv, "."); idx >= 0 {
recv = recv[idx+1:]
}
return fmt.Sprintf("go:%s.%s.%s", pkg, recv, fn.Name())
}
return fmt.Sprintf("go:%s.%s", pkg, fn.Name())
}
func getVisibility(name string) string {
if len(name) == 0 {
return "private"
}
if name[0] >= 'A' && name[0] <= 'Z' {
return "public"
}
return "private"
}
func detectAnnotations(fn *ssa.Function) []string {
// Go doesn't have annotations, but we can detect patterns
annotations := make([]string, 0)
// Detect handler patterns from naming
if strings.HasSuffix(fn.Name(), "Handler") {
annotations = append(annotations, "handler")
}
if strings.HasSuffix(fn.Name(), "Middleware") {
annotations = append(annotations, "middleware")
}
return annotations
}
func detectEntrypoint(fn *ssa.Function) *Entrypoint {
name := fn.Name()
pkg := ""
if fn.Pkg != nil {
pkg = fn.Pkg.Pkg.Path()
}
nodeID := makeSymbolID(fn)
// main.main
if name == "main" && strings.HasSuffix(pkg, "main") {
return &Entrypoint{
ID: nodeID,
Type: "cli_command",
}
}
// init functions
if name == "init" {
return &Entrypoint{
ID: nodeID,
Type: "background_job",
}
}
// HTTP handler patterns
if strings.HasSuffix(name, "Handler") || strings.Contains(name, "Handle") {
return &Entrypoint{
ID: nodeID,
Type: "http_handler",
}
}
// gRPC patterns
if strings.HasSuffix(name, "Server") && strings.HasPrefix(name, "Register") {
return &Entrypoint{
ID: nodeID,
Type: "grpc_method",
}
}
return nil
}

View File

@@ -0,0 +1,178 @@
// -----------------------------------------------------------------------------
// framework-detect.js
// Framework detection patterns for JavaScript/TypeScript projects.
// -----------------------------------------------------------------------------
/**
* Framework detection patterns
*/
export const frameworkPatterns = {
express: {
packageNames: ['express'],
patterns: [
/const\s+\w+\s*=\s*require\(['"]express['"]\)/,
/import\s+\w+\s+from\s+['"]express['"]/,
/app\.(get|post|put|delete|patch)\s*\(/
],
entrypointType: 'http_handler'
},
fastify: {
packageNames: ['fastify'],
patterns: [
/require\(['"]fastify['"]\)/,
/import\s+\w+\s+from\s+['"]fastify['"]/,
/fastify\.(get|post|put|delete|patch)\s*\(/
],
entrypointType: 'http_handler'
},
koa: {
packageNames: ['koa', '@koa/router'],
patterns: [
/require\(['"]koa['"]\)/,
/import\s+\w+\s+from\s+['"]koa['"]/,
/router\.(get|post|put|delete|patch)\s*\(/
],
entrypointType: 'http_handler'
},
hapi: {
packageNames: ['@hapi/hapi'],
patterns: [
/require\(['"]@hapi\/hapi['"]\)/,
/import\s+\w+\s+from\s+['"]@hapi\/hapi['"]/,
/server\.route\s*\(/
],
entrypointType: 'http_handler'
},
nestjs: {
packageNames: ['@nestjs/core', '@nestjs/common'],
patterns: [
/@Controller\s*\(/,
/@Get\s*\(/,
/@Post\s*\(/,
/@Put\s*\(/,
/@Delete\s*\(/,
/@Patch\s*\(/
],
entrypointType: 'http_handler'
},
socketio: {
packageNames: ['socket.io'],
patterns: [
/require\(['"]socket\.io['"]\)/,
/import\s+\w+\s+from\s+['"]socket\.io['"]/,
/io\.on\s*\(\s*['"]connection['"]/,
/socket\.on\s*\(/
],
entrypointType: 'websocket_handler'
},
awsLambda: {
packageNames: ['aws-lambda', '@types/aws-lambda'],
patterns: [
/exports\.handler\s*=/,
/export\s+(const|async function)\s+handler/,
/module\.exports\.handler/,
/APIGatewayProxyHandler/,
/APIGatewayEvent/
],
entrypointType: 'lambda'
},
azureFunctions: {
packageNames: ['@azure/functions'],
patterns: [
/require\(['"]@azure\/functions['"]\)/,
/import\s+\w+\s+from\s+['"]@azure\/functions['"]/,
/app\.(http|timer|queue|blob)\s*\(/
],
entrypointType: 'cloud_function'
},
gcpFunctions: {
packageNames: ['@google-cloud/functions-framework'],
patterns: [
/require\(['"]@google-cloud\/functions-framework['"]\)/,
/functions\.(http|cloudEvent)\s*\(/
],
entrypointType: 'cloud_function'
},
electron: {
packageNames: ['electron'],
patterns: [
/require\(['"]electron['"]\)/,
/import\s+\{[^}]*\}\s+from\s+['"]electron['"]/,
/ipcMain\.on\s*\(/,
/ipcRenderer\.on\s*\(/
],
entrypointType: 'event_handler'
},
grpc: {
packageNames: ['@grpc/grpc-js', 'grpc'],
patterns: [
/require\(['"]@grpc\/grpc-js['"]\)/,
/addService\s*\(/,
/loadPackageDefinition\s*\(/
],
entrypointType: 'grpc_method'
}
};
/**
* Detect frameworks from package.json dependencies
* @param {object} packageJson
* @returns {string[]}
*/
export function detectFrameworks(packageJson) {
const detected = [];
const allDeps = {
...packageJson.dependencies,
...packageJson.devDependencies
};
for (const [framework, config] of Object.entries(frameworkPatterns)) {
for (const pkgName of config.packageNames) {
if (allDeps[pkgName]) {
detected.push(framework);
break;
}
}
}
return detected;
}
/**
* Detect frameworks from source code patterns
* @param {string} content
* @returns {string[]}
*/
export function detectFrameworksFromCode(content) {
const detected = [];
for (const [framework, config] of Object.entries(frameworkPatterns)) {
for (const pattern of config.patterns) {
if (pattern.test(content)) {
detected.push(framework);
break;
}
}
}
return detected;
}
/**
* Get entrypoint type for a detected framework
* @param {string} framework
* @returns {string}
*/
export function getEntrypointType(framework) {
return frameworkPatterns[framework]?.entrypointType || 'unknown';
}

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);

View File

@@ -0,0 +1,33 @@
{
"name": "stella-callgraph-node",
"version": "1.0.0",
"description": "Call graph extraction tool for JavaScript/TypeScript using Babel AST",
"main": "index.js",
"type": "module",
"bin": {
"stella-callgraph-node": "./index.js"
},
"scripts": {
"start": "node index.js",
"test": "node --test"
},
"keywords": [
"callgraph",
"ast",
"babel",
"static-analysis",
"security"
],
"license": "AGPL-3.0-or-later",
"dependencies": {
"@babel/parser": "^7.23.0",
"@babel/traverse": "^7.23.0",
"@babel/types": "^7.23.0"
},
"devDependencies": {
"@types/node": "^20.0.0"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
stella-callgraph-python
Call graph extraction tool for Python projects using AST analysis.
"""
import argparse
import ast
import json
import os
import sys
from pathlib import Path
from typing import Any
from ast_analyzer import PythonASTAnalyzer
from framework_detect import detect_frameworks
def main() -> int:
parser = argparse.ArgumentParser(
description="Extract call graphs from Python projects"
)
parser.add_argument(
"path",
help="Path to Python project or file"
)
parser.add_argument(
"--json",
action="store_true",
help="Output formatted JSON"
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Verbose output"
)
args = parser.parse_args()
try:
result = analyze_project(Path(args.path), verbose=args.verbose)
if args.json:
print(json.dumps(result, indent=2))
else:
print(json.dumps(result))
return 0
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
def analyze_project(project_path: Path, verbose: bool = False) -> dict[str, Any]:
"""Analyze a Python project and extract its call graph."""
if not project_path.exists():
raise FileNotFoundError(f"Path not found: {project_path}")
# Find project root (look for pyproject.toml, setup.py, etc.)
root = find_project_root(project_path)
package_name = extract_package_name(root)
# Detect frameworks
frameworks = detect_frameworks(root)
# Find Python source files
source_files = find_python_files(root)
if verbose:
print(f"Found {len(source_files)} Python files", file=sys.stderr)
# Analyze all files
analyzer = PythonASTAnalyzer(package_name, root, frameworks)
for source_file in source_files:
try:
with open(source_file, 'r', encoding='utf-8') as f:
content = f.read()
tree = ast.parse(content, filename=str(source_file))
relative_path = source_file.relative_to(root)
analyzer.analyze_file(tree, str(relative_path))
except SyntaxError as e:
if verbose:
print(f"Warning: Syntax error in {source_file}: {e}", file=sys.stderr)
except Exception as e:
if verbose:
print(f"Warning: Failed to parse {source_file}: {e}", file=sys.stderr)
return analyzer.get_result()
def find_project_root(path: Path) -> Path:
"""Find the project root by looking for marker files."""
markers = ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt', '.git']
current = path.resolve()
if current.is_file():
current = current.parent
while current != current.parent:
for marker in markers:
if (current / marker).exists():
return current
current = current.parent
return path.resolve() if path.is_dir() else path.parent.resolve()
def extract_package_name(root: Path) -> str:
"""Extract package name from project metadata."""
# Try pyproject.toml
pyproject = root / 'pyproject.toml'
if pyproject.exists():
try:
import tomllib
with open(pyproject, 'rb') as f:
data = tomllib.load(f)
return data.get('project', {}).get('name', root.name)
except Exception:
pass
# Try setup.py
setup_py = root / 'setup.py'
if setup_py.exists():
try:
with open(setup_py, 'r') as f:
content = f.read()
# Simple regex-based extraction
import re
match = re.search(r"name\s*=\s*['\"]([^'\"]+)['\"]", content)
if match:
return match.group(1)
except Exception:
pass
return root.name
def find_python_files(root: Path) -> list[Path]:
"""Find all Python source files in the project."""
exclude_dirs = {
'__pycache__', '.git', '.tox', '.nox', '.mypy_cache',
'.pytest_cache', 'venv', '.venv', 'env', '.env',
'node_modules', 'dist', 'build', 'eggs', '*.egg-info'
}
files = []
for path in root.rglob('*.py'):
# Skip excluded directories
skip = False
for part in path.parts:
if part in exclude_dirs or part.endswith('.egg-info'):
skip = True
break
if not skip and not path.name.startswith('.'):
files.append(path)
return sorted(files)
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,322 @@
"""
AST analyzer for Python call graph extraction.
"""
import ast
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
@dataclass
class FunctionNode:
"""Represents a function in the call graph."""
id: str
package: str
name: str
qualified_name: str
file: str
line: int
visibility: str
annotations: list[str] = field(default_factory=list)
is_entrypoint: bool = False
entrypoint_type: Optional[str] = None
@dataclass
class CallEdge:
"""Represents a call between functions."""
from_id: str
to_id: str
kind: str
file: str
line: int
@dataclass
class Entrypoint:
"""Represents a detected entrypoint."""
id: str
type: str
route: Optional[str] = None
method: Optional[str] = None
class PythonASTAnalyzer:
"""Analyzes Python AST to extract call graph information."""
def __init__(self, package_name: str, root: Path, frameworks: list[str]):
self.package_name = package_name
self.root = root
self.frameworks = frameworks
self.nodes: dict[str, FunctionNode] = {}
self.edges: list[CallEdge] = []
self.entrypoints: list[Entrypoint] = []
self.current_function: Optional[str] = None
self.current_file: str = ""
self.current_class: Optional[str] = None
def analyze_file(self, tree: ast.AST, relative_path: str) -> None:
"""Analyze a single Python file."""
self.current_file = relative_path
self.current_function = None
self.current_class = None
visitor = FunctionVisitor(self)
visitor.visit(tree)
def get_result(self) -> dict[str, Any]:
"""Get the analysis result as a dictionary."""
return {
"module": self.package_name,
"nodes": [self._node_to_dict(n) for n in self.nodes.values()],
"edges": [self._edge_to_dict(e) for e in self._dedupe_edges()],
"entrypoints": [self._entrypoint_to_dict(e) for e in self.entrypoints]
}
def _node_to_dict(self, node: FunctionNode) -> dict[str, Any]:
return {
"id": node.id,
"package": node.package,
"name": node.name,
"signature": node.qualified_name,
"position": {
"file": node.file,
"line": node.line,
"column": 0
},
"visibility": node.visibility,
"annotations": node.annotations
}
def _edge_to_dict(self, edge: CallEdge) -> dict[str, Any]:
return {
"from": edge.from_id,
"to": edge.to_id,
"kind": edge.kind,
"site": {
"file": edge.file,
"line": edge.line
}
}
def _entrypoint_to_dict(self, ep: Entrypoint) -> dict[str, Any]:
result: dict[str, Any] = {
"id": ep.id,
"type": ep.type
}
if ep.route:
result["route"] = ep.route
if ep.method:
result["method"] = ep.method
return result
def _dedupe_edges(self) -> list[CallEdge]:
seen: set[str] = set()
result: list[CallEdge] = []
for edge in self.edges:
key = f"{edge.from_id}|{edge.to_id}"
if key not in seen:
seen.add(key)
result.append(edge)
return result
def make_symbol_id(self, name: str, class_name: Optional[str] = None) -> str:
"""Create a symbol ID for a function or method."""
module_base = self.current_file.replace('.py', '').replace('/', '.').replace('\\', '.')
if class_name:
return f"py:{self.package_name}/{module_base}.{class_name}.{name}"
return f"py:{self.package_name}/{module_base}.{name}"
def add_function(
self,
name: str,
line: int,
decorators: list[str],
class_name: Optional[str] = None,
is_private: bool = False
) -> str:
"""Add a function node to the graph."""
symbol_id = self.make_symbol_id(name, class_name)
qualified_name = f"{class_name}.{name}" if class_name else name
visibility = "private" if is_private or name.startswith('_') else "public"
node = FunctionNode(
id=symbol_id,
package=self.package_name,
name=name,
qualified_name=qualified_name,
file=self.current_file,
line=line,
visibility=visibility,
annotations=decorators
)
self.nodes[symbol_id] = node
# Detect entrypoints
entrypoint = self._detect_entrypoint(name, decorators, class_name)
if entrypoint:
node.is_entrypoint = True
node.entrypoint_type = entrypoint.type
self.entrypoints.append(entrypoint)
return symbol_id
def add_call(self, target_name: str, line: int) -> None:
"""Add a call edge from the current function."""
if not self.current_function:
return
# Try to resolve the target
target_id = self._resolve_target(target_name)
self.edges.append(CallEdge(
from_id=self.current_function,
to_id=target_id,
kind="direct",
file=self.current_file,
line=line
))
def _resolve_target(self, name: str) -> str:
"""Resolve a call target to a symbol ID."""
# Check if it's a known local function
for node_id, node in self.nodes.items():
if node.name == name or node.qualified_name == name:
return node_id
# External or unresolved
return f"py:external/{name}"
def _detect_entrypoint(
self,
name: str,
decorators: list[str],
class_name: Optional[str]
) -> Optional[Entrypoint]:
"""Detect if a function is an entrypoint based on frameworks and decorators."""
symbol_id = self.make_symbol_id(name, class_name)
for decorator in decorators:
# Flask routes
if 'route' in decorator.lower() or decorator.lower() in ['get', 'post', 'put', 'delete', 'patch']:
route = self._extract_route_from_decorator(decorator)
method = self._extract_method_from_decorator(decorator)
return Entrypoint(id=symbol_id, type="http_handler", route=route, method=method)
# FastAPI routes
if decorator.lower() in ['get', 'post', 'put', 'delete', 'patch', 'api_route']:
route = self._extract_route_from_decorator(decorator)
return Entrypoint(id=symbol_id, type="http_handler", route=route, method=decorator.upper())
# Celery tasks
if 'task' in decorator.lower() or 'shared_task' in decorator.lower():
return Entrypoint(id=symbol_id, type="background_job")
# Click commands
if 'command' in decorator.lower() or 'group' in decorator.lower():
return Entrypoint(id=symbol_id, type="cli_command")
# Django views (class-based)
if class_name and class_name.endswith('View'):
if name in ['get', 'post', 'put', 'delete', 'patch']:
return Entrypoint(id=symbol_id, type="http_handler", method=name.upper())
# main() function
if name == 'main' and not class_name:
return Entrypoint(id=symbol_id, type="cli_command")
return None
def _extract_route_from_decorator(self, decorator: str) -> Optional[str]:
"""Extract route path from decorator string."""
import re
match = re.search(r"['\"]([/\w{}<>:.-]+)['\"]", decorator)
return match.group(1) if match else None
def _extract_method_from_decorator(self, decorator: str) -> Optional[str]:
"""Extract HTTP method from decorator string."""
import re
methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']
for method in methods:
if method.lower() in decorator.lower():
return method
match = re.search(r"methods\s*=\s*\[([^\]]+)\]", decorator)
if match:
return match.group(1).strip("'\"").upper()
return None
class FunctionVisitor(ast.NodeVisitor):
"""AST visitor that extracts function definitions and calls."""
def __init__(self, analyzer: PythonASTAnalyzer):
self.analyzer = analyzer
def visit_ClassDef(self, node: ast.ClassDef) -> None:
"""Visit class definitions."""
old_class = self.analyzer.current_class
self.analyzer.current_class = node.name
self.generic_visit(node)
self.analyzer.current_class = old_class
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
"""Visit function definitions."""
self._visit_function(node)
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
"""Visit async function definitions."""
self._visit_function(node)
def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
"""Common logic for function and async function definitions."""
decorators = [ast.unparse(d) for d in node.decorator_list]
is_private = node.name.startswith('_') and not node.name.startswith('__')
symbol_id = self.analyzer.add_function(
name=node.name,
line=node.lineno,
decorators=decorators,
class_name=self.analyzer.current_class,
is_private=is_private
)
# Visit function body for calls
old_function = self.analyzer.current_function
self.analyzer.current_function = symbol_id
for child in ast.walk(node):
if isinstance(child, ast.Call):
target_name = self._get_call_target(child)
if target_name:
self.analyzer.add_call(target_name, child.lineno)
self.analyzer.current_function = old_function
def _get_call_target(self, node: ast.Call) -> Optional[str]:
"""Extract the target name from a Call node."""
if isinstance(node.func, ast.Name):
return node.func.id
elif isinstance(node.func, ast.Attribute):
parts = self._get_attribute_parts(node.func)
return '.'.join(parts)
return None
def _get_attribute_parts(self, node: ast.Attribute) -> list[str]:
"""Get all parts of an attribute chain."""
parts: list[str] = []
current: ast.expr = node
while isinstance(current, ast.Attribute):
parts.insert(0, current.attr)
current = current.value
if isinstance(current, ast.Name):
parts.insert(0, current.id)
return parts

View File

@@ -0,0 +1,250 @@
"""
Framework detection for Python projects.
"""
from pathlib import Path
from typing import Any
import re
# Framework patterns
FRAMEWORK_PATTERNS = {
"flask": {
"packages": ["flask"],
"imports": [r"from flask import", r"import flask"],
"patterns": [r"@\w+\.route\(", r"Flask\(__name__\)"],
"entrypoint_type": "http_handler"
},
"fastapi": {
"packages": ["fastapi"],
"imports": [r"from fastapi import", r"import fastapi"],
"patterns": [r"@\w+\.(get|post|put|delete|patch)\(", r"FastAPI\("],
"entrypoint_type": "http_handler"
},
"django": {
"packages": ["django"],
"imports": [r"from django", r"import django"],
"patterns": [r"urlpatterns\s*=", r"class \w+View\(", r"@api_view\("],
"entrypoint_type": "http_handler"
},
"click": {
"packages": ["click"],
"imports": [r"from click import", r"import click"],
"patterns": [r"@click\.command\(", r"@click\.group\(", r"@\w+\.command\("],
"entrypoint_type": "cli_command"
},
"typer": {
"packages": ["typer"],
"imports": [r"from typer import", r"import typer"],
"patterns": [r"typer\.Typer\(", r"@\w+\.command\("],
"entrypoint_type": "cli_command"
},
"celery": {
"packages": ["celery"],
"imports": [r"from celery import", r"import celery"],
"patterns": [r"@\w+\.task\(", r"@shared_task\(", r"Celery\("],
"entrypoint_type": "background_job"
},
"dramatiq": {
"packages": ["dramatiq"],
"imports": [r"from dramatiq import", r"import dramatiq"],
"patterns": [r"@dramatiq\.actor\("],
"entrypoint_type": "background_job"
},
"rq": {
"packages": ["rq"],
"imports": [r"from rq import", r"import rq"],
"patterns": [r"@job\(", r"queue\.enqueue\("],
"entrypoint_type": "background_job"
},
"sanic": {
"packages": ["sanic"],
"imports": [r"from sanic import", r"import sanic"],
"patterns": [r"@\w+\.route\(", r"Sanic\("],
"entrypoint_type": "http_handler"
},
"aiohttp": {
"packages": ["aiohttp"],
"imports": [r"from aiohttp import", r"import aiohttp"],
"patterns": [r"web\.Application\(", r"@routes\.(get|post|put|delete)\("],
"entrypoint_type": "http_handler"
},
"tornado": {
"packages": ["tornado"],
"imports": [r"from tornado import", r"import tornado"],
"patterns": [r"class \w+Handler\(", r"tornado\.web\.Application\("],
"entrypoint_type": "http_handler"
},
"aws_lambda": {
"packages": ["aws_lambda_powertools", "boto3"],
"imports": [r"def handler\(event", r"def lambda_handler\("],
"patterns": [r"def handler\(event,\s*context\)", r"@logger\.inject_lambda_context"],
"entrypoint_type": "lambda"
},
"azure_functions": {
"packages": ["azure.functions"],
"imports": [r"import azure\.functions"],
"patterns": [r"@func\.route\(", r"func\.HttpRequest"],
"entrypoint_type": "cloud_function"
},
"grpc": {
"packages": ["grpcio", "grpc"],
"imports": [r"import grpc", r"from grpc import"],
"patterns": [r"_pb2_grpc\.add_\w+Servicer_to_server\("],
"entrypoint_type": "grpc_method"
},
"graphql": {
"packages": ["graphene", "strawberry", "ariadne"],
"imports": [r"import graphene", r"import strawberry", r"import ariadne"],
"patterns": [r"@strawberry\.(type|mutation|query)\(", r"class \w+\(graphene\.ObjectType\)"],
"entrypoint_type": "graphql_resolver"
}
}
def detect_frameworks(project_root: Path) -> list[str]:
"""Detect frameworks used in a Python project."""
detected: set[str] = set()
# Check pyproject.toml
pyproject = project_root / "pyproject.toml"
if pyproject.exists():
detected.update(_detect_from_pyproject(pyproject))
# Check requirements.txt
requirements = project_root / "requirements.txt"
if requirements.exists():
detected.update(_detect_from_requirements(requirements))
# Check setup.py
setup_py = project_root / "setup.py"
if setup_py.exists():
detected.update(_detect_from_setup_py(setup_py))
# Scan source files for import patterns
detected.update(_detect_from_source(project_root))
return sorted(detected)
def _detect_from_pyproject(path: Path) -> set[str]:
"""Detect frameworks from pyproject.toml."""
detected: set[str] = set()
try:
import tomllib
with open(path, 'rb') as f:
data = tomllib.load(f)
# Check dependencies
deps = set()
deps.update(data.get("project", {}).get("dependencies", []))
deps.update(data.get("project", {}).get("optional-dependencies", {}).get("dev", []))
# Poetry format
poetry = data.get("tool", {}).get("poetry", {})
deps.update(poetry.get("dependencies", {}).keys())
deps.update(poetry.get("dev-dependencies", {}).keys())
for dep in deps:
# Extract package name (remove version specifier)
pkg = re.split(r'[<>=!~\[]', dep)[0].strip().lower()
for framework, config in FRAMEWORK_PATTERNS.items():
if pkg in config["packages"]:
detected.add(framework)
except Exception:
pass
return detected
def _detect_from_requirements(path: Path) -> set[str]:
"""Detect frameworks from requirements.txt."""
detected: set[str] = set()
try:
with open(path, 'r') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
# Extract package name
pkg = re.split(r'[<>=!~\[]', line)[0].strip().lower()
for framework, config in FRAMEWORK_PATTERNS.items():
if pkg in config["packages"]:
detected.add(framework)
except Exception:
pass
return detected
def _detect_from_setup_py(path: Path) -> set[str]:
"""Detect frameworks from setup.py."""
detected: set[str] = set()
try:
with open(path, 'r') as f:
content = f.read()
# Look for install_requires
for framework, config in FRAMEWORK_PATTERNS.items():
for pkg in config["packages"]:
if f'"{pkg}"' in content or f"'{pkg}'" in content:
detected.add(framework)
except Exception:
pass
return detected
def _detect_from_source(project_root: Path) -> set[str]:
"""Detect frameworks by scanning Python source files."""
detected: set[str] = set()
exclude_dirs = {
'__pycache__', '.git', '.tox', '.nox', 'venv', '.venv', 'env', '.env',
'node_modules', 'dist', 'build'
}
# Only scan first few files to avoid slow startup
max_files = 50
scanned = 0
for py_file in project_root.rglob('*.py'):
if scanned >= max_files:
break
# Skip excluded directories
skip = False
for part in py_file.parts:
if part in exclude_dirs:
skip = True
break
if skip:
continue
try:
with open(py_file, 'r', encoding='utf-8') as f:
content = f.read(4096) # Only read first 4KB
for framework, config in FRAMEWORK_PATTERNS.items():
if framework in detected:
continue
for pattern in config["imports"] + config["patterns"]:
if re.search(pattern, content):
detected.add(framework)
break
scanned += 1
except Exception:
continue
return detected
def get_entrypoint_type(framework: str) -> str:
"""Get the entrypoint type for a framework."""
return FRAMEWORK_PATTERNS.get(framework, {}).get("entrypoint_type", "unknown")

View File

@@ -0,0 +1,2 @@
# stella-callgraph-python requirements
# No external dependencies - uses Python 3.11+ stdlib only