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:
143
tools/stella-callgraph-go/framework.go
Normal file
143
tools/stella-callgraph-go/framework.go
Normal 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
|
||||
}
|
||||
12
tools/stella-callgraph-go/go.mod
Normal file
12
tools/stella-callgraph-go/go.mod
Normal 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
|
||||
)
|
||||
395
tools/stella-callgraph-go/main.go
Normal file
395
tools/stella-callgraph-go/main.go
Normal 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
|
||||
}
|
||||
178
tools/stella-callgraph-node/framework-detect.js
Normal file
178
tools/stella-callgraph-node/framework-detect.js
Normal 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';
|
||||
}
|
||||
435
tools/stella-callgraph-node/index.js
Normal file
435
tools/stella-callgraph-node/index.js
Normal 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);
|
||||
33
tools/stella-callgraph-node/package.json
Normal file
33
tools/stella-callgraph-node/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
168
tools/stella-callgraph-python/__main__.py
Normal file
168
tools/stella-callgraph-python/__main__.py
Normal 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())
|
||||
322
tools/stella-callgraph-python/ast_analyzer.py
Normal file
322
tools/stella-callgraph-python/ast_analyzer.py
Normal 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
|
||||
250
tools/stella-callgraph-python/framework_detect.py
Normal file
250
tools/stella-callgraph-python/framework_detect.py
Normal 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")
|
||||
2
tools/stella-callgraph-python/requirements.txt
Normal file
2
tools/stella-callgraph-python/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
# stella-callgraph-python requirements
|
||||
# No external dependencies - uses Python 3.11+ stdlib only
|
||||
Reference in New Issue
Block a user