Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 108 additions & 1 deletion codegraph/codegraph/framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ class FrameworkType(Enum):
SVELTEKIT = "sveltekit"
NESTJS = "nestjs"
ODOO = "odoo"
FASTAPI = "fastapi"
FLASK = "flask"
DJANGO = "django"
FASTIFY = "fastify"
UNKNOWN = "unknown"


Expand All @@ -54,6 +58,10 @@ class FrameworkType(Enum):
FrameworkType.SVELTEKIT: "SvelteKit",
FrameworkType.NESTJS: "NestJS",
FrameworkType.ODOO: "Odoo",
FrameworkType.FASTAPI: "FastAPI",
FrameworkType.FLASK: "Flask",
FrameworkType.DJANGO: "Django",
FrameworkType.FASTIFY: "Fastify",
FrameworkType.UNKNOWN: "Unknown",
}

Expand Down Expand Up @@ -128,6 +136,42 @@ class FrameworkDetector:
r"_name\s*=\s*['\"]\w+\.\w+['\"]",
],
},
FrameworkType.FASTAPI: {
"files": [],
"dependencies": ["fastapi", "uvicorn"],
"patterns": [
r"@app\.(get|post|put|delete|patch)\s*\(",
r"from\s+fastapi\s+import",
r"APIRouter",
],
},
FrameworkType.FLASK: {
"files": ["wsgi.py"],
"dependencies": ["flask", "Flask"],
"patterns": [
r"@app\.route\s*\(",
r"from\s+flask\s+import",
r"Flask\s*\(",
],
},
FrameworkType.DJANGO: {
"files": ["manage.py", "urls.py", "wsgi.py", "asgi.py"],
"dependencies": ["django", "Django"],
"patterns": [
r"from\s+django",
r"urlpatterns\s*=",
r"INSTALLED_APPS",
],
},
FrameworkType.FASTIFY: {
"files": [],
"dependencies": ["fastify"],
"patterns": [
r"fastify\.(get|post|put|delete|patch)\s*\(",
r"from\s+['\"]fastify['\"]",
r"import\s+.*Fastify",
],
},
}

STYLING_INDICATORS = {
Expand Down Expand Up @@ -191,6 +235,7 @@ def __init__(self, project_path: Path) -> None:
self._files_cache: Optional[list[Path]] = None
self._workspace_deps_cache: Optional[set[str]] = None
self._workspace_pjs_cache: Optional[list[dict]] = None
self._python_deps_cache: Optional[set[str]] = None

# ── monorepo walk-up ────────────────────────────────────────────────

Expand Down Expand Up @@ -400,6 +445,61 @@ def _detect_package_manager(self) -> Optional[str]:
return "bun"
return None

# ── Python dependency reading ────────────────────────────────────────

@property
def _python_dependencies(self) -> set[str]:
"""Merged dep names from ``pyproject.toml``, ``setup.py``, and ``requirements.txt``."""
if self._python_deps_cache is not None:
return self._python_deps_cache

deps: set[str] = set()

# pyproject.toml — [project.dependencies]
pyproject = self.project_path / "pyproject.toml"
if pyproject.exists():
try:
try:
import tomllib # Python 3.11+
except ModuleNotFoundError:
import tomli as tomllib # type: ignore[no-redef]
with open(pyproject, "rb") as f:
data = tomllib.load(f)
for dep_str in data.get("project", {}).get("dependencies", []):
# Strip version specifiers: "fastapi>=0.100" → "fastapi"
name = re.split(r"[><=!~;\[\s]", dep_str, maxsplit=1)[0].strip()
if name:
deps.add(name.lower())
# Also check optional deps
for group_deps in data.get("project", {}).get("optional-dependencies", {}).values():
for dep_str in group_deps:
name = re.split(r"[><=!~;\[\s]", dep_str, maxsplit=1)[0].strip()
if name:
deps.add(name.lower())
except Exception:
pass

# requirements.txt
for req_name in ("requirements.txt", "requirements-dev.txt", "requirements-test.txt"):
req_file = self.project_path / req_name
if req_file.exists():
try:
for line in req_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or line.startswith("-"):
continue
name = re.split(r"[><=!~;\[\s]", line, maxsplit=1)[0].strip()
if name:
deps.add(name.lower())
except OSError:
pass

self._python_deps_cache = deps
return deps

def _check_python_dependency(self, dep: str) -> bool:
return dep.lower() in self._python_dependencies

# ── Odoo short-circuit ──────────────────────────────────────────────

def _has_odoo_signature(self) -> bool:
Expand Down Expand Up @@ -441,16 +541,21 @@ def detect(self) -> FrameworkInfo:
confidence=0.95,
)

_PYTHON_FRAMEWORKS = {FrameworkType.FASTAPI, FrameworkType.FLASK, FrameworkType.DJANGO}

scores: dict[FrameworkType, float] = {ft: 0.0 for ft in FrameworkType}
code_extensions = (".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte")
code_extensions = (".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte", ".py")

for framework, indicators in self.FRAMEWORK_INDICATORS.items():
for file_indicator in indicators["files"]:
if self._check_file_exists(file_indicator):
scores[framework] += 30.0
for dep in indicators["dependencies"]:
# Check both JS (package.json) and Python (pyproject.toml/requirements.txt)
if self._check_dependency(dep):
scores[framework] += 25.0
elif framework in _PYTHON_FRAMEWORKS and self._check_python_dependency(dep):
scores[framework] += 25.0
for pattern in indicators["patterns"]:
if self._check_pattern_in_files(pattern, code_extensions):
scores[framework] += 15.0
Expand Down Expand Up @@ -478,6 +583,8 @@ def detect(self) -> FrameworkInfo:
version = self._get_dependency_version("svelte")
elif best_framework == FrameworkType.NESTJS:
version = self._get_dependency_version("@nestjs/core")
elif best_framework == FrameworkType.FASTIFY:
version = self._get_dependency_version("fastify")

return FrameworkInfo(
framework=best_framework,
Expand Down
43 changes: 43 additions & 0 deletions codegraph/codegraph/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,24 @@ def _extract_http_method_from_callee(self, fn: Node) -> str:
return name
return ""

# Fastify/Express-style server objects that register routes
_FASTIFY_OBJECTS = {"fastify", "server", "app", "instance", "fastifyInstance"}

def _is_fastify_route(self, fn: Node) -> bool:
"""Check if a call is a Fastify/Express route registration: ``fastify.get(...)``."""
if fn.type != "member_expression":
return False
obj = fn.child_by_field_name("object")
prop = fn.child_by_field_name("property")
if obj is None or prop is None:
return False
prop_name = self._text(prop)
obj_name = self._text(obj)
return (
prop_name in ("get", "post", "put", "patch", "delete", "head", "options")
and obj_name in self._FASTIFY_OBJECTS
)

def _is_emit_call(self, fn: Node) -> bool:
if fn.type != "member_expression":
return False
Expand Down Expand Up @@ -976,6 +994,31 @@ def _scan_function_body(self, body: Node, fn: FunctionNode) -> None:
http_m = self._extract_http_method_from_callee(callee)
self.result.rest_calls.append((fn.name, http_m, first_str))

# Fastify route registration: fastify.get('/path', handler)
if self._is_fastify_route(callee):
args = d.child_by_field_name("arguments")
first_str = self._first_string_arg(args)
if first_str and first_str.startswith("/"):
# Extract method directly from the property name (already
# validated by _is_fastify_route) — avoids the
# _extract_http_method_from_callee gap for HEAD/OPTIONS.
prop = callee.child_by_field_name("property")
http_m = self._text(prop).upper() if prop else "GET"
ep = EndpointNode(
method=http_m,
path=first_str,
controller_class=f"file:{self.result.file.path}",
file=self.result.file.path,
handler=fn.name,
)
self.result.endpoints.append(ep)
self.result.edges.append(
Edge(kind=EXPOSES, src_id=f"file:{self.result.file.path}", dst_id=ep.id)
)
self.result.edges.append(
Edge(kind=HANDLES, src_id=fn.id, dst_id=ep.id)
)

# ConfigService.get('X')
if self._is_config_get(callee):
args = d.child_by_field_name("arguments")
Expand Down
Loading
Loading