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,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")