codegraph-engine 3.0.0__2-py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codegraph/__init__.py +37 -0
- codegraph/agent.py +26 -0
- codegraph/agent_brain.py +2212 -0
- codegraph/agent_capabilities.py +4058 -0
- codegraph/agent_rules.py +531 -0
- codegraph/api_drift.py +455 -0
- codegraph/architecture.py +371 -0
- codegraph/async_queue/__init__.py +50 -0
- codegraph/async_queue/detectors/__init__.py +48 -0
- codegraph/async_queue/detectors/celery.py +160 -0
- codegraph/async_queue/detectors/kafka.py +168 -0
- codegraph/async_queue/detectors/rabbitmq.py +116 -0
- codegraph/async_queue/detectors/redis.py +130 -0
- codegraph/async_queue/detectors/sqs.py +135 -0
- codegraph/async_queue/models.py +161 -0
- codegraph/async_queue/otel_models.py +74 -0
- codegraph/async_queue/otel_parser.py +337 -0
- codegraph/async_queue/reconciler.py +427 -0
- codegraph/async_queue/scanner.py +291 -0
- codegraph/async_queue/traversal.py +447 -0
- codegraph/audit.py +106 -0
- codegraph/binding_resolver.py +616 -0
- codegraph/branch_comparison.py +197 -0
- codegraph/cache.py +95 -0
- codegraph/change_impact.py +547 -0
- codegraph/cli.py +2436 -0
- codegraph/cli_output.py +205 -0
- codegraph/config.py +45 -0
- codegraph/constraints.py +238 -0
- codegraph/context.py +2182 -0
- codegraph/context_freshness.py +351 -0
- codegraph/database/__init__.py +72 -0
- codegraph/database/extractor.py +3651 -0
- codegraph/database/interrogation.py +1494 -0
- codegraph/database/models.py +310 -0
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +200 -0
- codegraph/errors.py +282 -0
- codegraph/evidence/__init__.py +15 -0
- codegraph/evidence/citations.py +418 -0
- codegraph/evidence_contract.py +486 -0
- codegraph/frameworks.py +960 -0
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +335 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +362 -0
- codegraph/git_state.py +545 -0
- codegraph/graph/__init__.py +46 -0
- codegraph/graph/models.py +56 -0
- codegraph/graph/traversal.py +1993 -0
- codegraph/indexing/__init__.py +24 -0
- codegraph/indexing/classifier.py +564 -0
- codegraph/indexing/indexer.py +2578 -0
- codegraph/indexing/models.py +427 -0
- codegraph/indexing/parser.py +3404 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +211 -0
- codegraph/indexing/telemetry.py +326 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/indexing/test_framework.py +116 -0
- codegraph/installer.py +1702 -0
- codegraph/interrogation.py +2355 -0
- codegraph/llm/__init__.py +3 -0
- codegraph/llm/base.py +15 -0
- codegraph/llm/context.py +20 -0
- codegraph/mcp/__init__.py +3 -0
- codegraph/mcp/server.py +1476 -0
- codegraph/mcp_diagnostics.py +882 -0
- codegraph/memory/__init__.py +3 -0
- codegraph/memory/store.py +46 -0
- codegraph/models.py +310 -0
- codegraph/monorepo.py +925 -0
- codegraph/observability.py +262 -0
- codegraph/optimizer.py +1014 -0
- codegraph/planner.py +417 -0
- codegraph/process_lifecycle.py +1153 -0
- codegraph/py.typed +1 -0
- codegraph/query_expansion.py +199 -0
- codegraph/ranking.py +408 -0
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +414 -0
- codegraph/resolver.py +3968 -0
- codegraph/resources/__init__.py +46 -0
- codegraph/resources/cache.py +117 -0
- codegraph/resources/coalescer.py +83 -0
- codegraph/resources/debouncer.py +98 -0
- codegraph/resources/governor.py +219 -0
- codegraph/resources/monitor.py +102 -0
- codegraph/resources/policy.py +123 -0
- codegraph/retrieval_policy.py +320 -0
- codegraph/roblox.py +2174 -0
- codegraph/route_composer.py +363 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +51 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +692 -0
- codegraph/runtime/models.py +130 -0
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/reconciliation.py +492 -0
- codegraph/runtime/runner.py +353 -0
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/__init__.py +25 -0
- codegraph/search/hybrid.py +846 -0
- codegraph/search/semantic.py +28 -0
- codegraph/security/__init__.py +51 -0
- codegraph/security/paths.py +189 -0
- codegraph/security/redaction.py +633 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/semantic_decorators.py +372 -0
- codegraph/structural_diff.py +542 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +277 -0
- codegraph/target_resolver.py +348 -0
- codegraph/task.py +637 -0
- codegraph/tool_selection_eval.py +1158 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +755 -0
- codegraph/watcher.py +323 -0
- codegraph_engine-3.0.0.dist-info/METADATA +868 -0
- codegraph_engine-3.0.0.dist-info/RECORD +130 -0
- codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
- codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
- codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
- codegraph_engine-3.0.0.dist-info/top_level.txt +1 -0
codegraph/frameworks.py
ADDED
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
"""Pluggable, evidence-driven framework analyzers for Python and JavaScript/TypeScript.
|
|
2
|
+
|
|
3
|
+
Supported Frameworks:
|
|
4
|
+
Python: Flask, FastAPI, Django
|
|
5
|
+
JavaScript/TypeScript: Express, Next.js (App Router)
|
|
6
|
+
|
|
7
|
+
Architectural Invariants:
|
|
8
|
+
1. Framework behavior is detected ONLY when concrete source evidence (imports, decorators,
|
|
9
|
+
route registration calls, or verified route handler exports) exists in the file.
|
|
10
|
+
2. Route detection NEVER rescans the full AST; it consumes node visit events during
|
|
11
|
+
the primary single-pass parser traversal.
|
|
12
|
+
3. Every endpoint produces a stable endpoint_id, human-readable route_signature, original
|
|
13
|
+
source route_path, and canonical normalized_route.
|
|
14
|
+
4. Framework endpoints link to handlers via HANDLED_BY semantic edges using the standard
|
|
15
|
+
canonical symbol ID.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import ast
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import asdict, dataclass
|
|
22
|
+
from typing import Protocol
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class FrameworkEvidence:
|
|
27
|
+
framework: str # flask | fastapi | django | express | nextjs
|
|
28
|
+
construct_type: str # ROUTE | CONTROLLER | MIDDLEWARE | DEPENDENCY | MODEL
|
|
29
|
+
file_path: str
|
|
30
|
+
line: int
|
|
31
|
+
column: int | None
|
|
32
|
+
evidence: str
|
|
33
|
+
confidence: str = "HIGH"
|
|
34
|
+
|
|
35
|
+
def as_dict(self) -> dict[str, object]:
|
|
36
|
+
return asdict(self)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def normalize_route_path(route_path: str, framework: str) -> str:
|
|
40
|
+
"""Normalize framework-specific route parameters into canonical `{param}` format.
|
|
41
|
+
|
|
42
|
+
Examples:
|
|
43
|
+
Flask: /users/<id> -> /users/{id}
|
|
44
|
+
Flask: /users/<int:id> -> /users/{id}
|
|
45
|
+
FastAPI: /users/{id} -> /users/{id}
|
|
46
|
+
Express: /users/:id -> /users/{id}
|
|
47
|
+
Next.js: /users/[id] -> /users/{id}
|
|
48
|
+
Next.js: /users/[...slug] -> /users/{slug}
|
|
49
|
+
"""
|
|
50
|
+
raw = "/" + route_path.strip().lstrip("/") if route_path.strip() else "/"
|
|
51
|
+
# Strip trailing slash unless root
|
|
52
|
+
if len(raw) > 1 and raw.endswith("/"):
|
|
53
|
+
raw = raw.rstrip("/")
|
|
54
|
+
|
|
55
|
+
if framework == "flask":
|
|
56
|
+
# Match <[type:]param>
|
|
57
|
+
return re.sub(r"<(?:\w+:)?([A-Za-z_][\w]*)>", r"{\1}", raw)
|
|
58
|
+
elif framework in ("express", "nestjs"):
|
|
59
|
+
# Match :param
|
|
60
|
+
return re.sub(r":([A-Za-z_][\w]*)", r"{\1}", raw)
|
|
61
|
+
elif framework == "nextjs":
|
|
62
|
+
# Match [[...param]] or [...param] or [param]
|
|
63
|
+
return re.sub(r"\[{1,2}(?:\.\.\.)?([A-Za-z_][\w]*)\]{1,2}", r"{\1}", raw)
|
|
64
|
+
return raw
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def join_route_prefixes(*parts: str) -> str:
|
|
68
|
+
"""Join route prefix segments cleanly and canonically.
|
|
69
|
+
|
|
70
|
+
Examples:
|
|
71
|
+
join_route_prefixes("/api/v1", "/users") -> "/api/v1/users"
|
|
72
|
+
join_route_prefixes("api/v1/", "/users/") -> "/api/v1/users"
|
|
73
|
+
join_route_prefixes("", "/items") -> "/items"
|
|
74
|
+
join_route_prefixes("/api", "") -> "/api"
|
|
75
|
+
join_route_prefixes("/", "/") -> "/"
|
|
76
|
+
"""
|
|
77
|
+
cleaned: list[str] = []
|
|
78
|
+
for part in parts:
|
|
79
|
+
p = part.strip()
|
|
80
|
+
if not p or p == "/":
|
|
81
|
+
continue
|
|
82
|
+
p = p.strip("/")
|
|
83
|
+
if p:
|
|
84
|
+
cleaned.append(p)
|
|
85
|
+
if not cleaned:
|
|
86
|
+
return "/"
|
|
87
|
+
return "/" + "/".join(cleaned)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class RouterMountDetection:
|
|
92
|
+
"""Detection of a router mounting or inclusion statement across frameworks.
|
|
93
|
+
|
|
94
|
+
Examples:
|
|
95
|
+
FastAPI: app.include_router(users_router, prefix="/api/v1")
|
|
96
|
+
Flask: app.register_blueprint(auth_bp, url_prefix="/auth")
|
|
97
|
+
Django: path("api/v1/", include("myapp.urls"))
|
|
98
|
+
Express: app.use("/api/v1", apiRouter)
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
framework: str # flask | fastapi | django | express
|
|
102
|
+
parent_router: str # e.g. "app", "api_router", "urlpatterns"
|
|
103
|
+
child_router: str # e.g. "user_router", "auth_bp", "myapp.urls", "usersRouter"
|
|
104
|
+
prefix: str # e.g. "/api/v1", "/users", "api/v1/"
|
|
105
|
+
file_path: str
|
|
106
|
+
line: int
|
|
107
|
+
column: int | None = None
|
|
108
|
+
confidence: str = "HIGH"
|
|
109
|
+
evidence: str = ""
|
|
110
|
+
module: str = ""
|
|
111
|
+
|
|
112
|
+
def as_dict(self) -> dict[str, object]:
|
|
113
|
+
return asdict(self)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class RouterDefinition:
|
|
118
|
+
"""Detection of a router instantiation with an inherent base prefix.
|
|
119
|
+
|
|
120
|
+
Examples:
|
|
121
|
+
FastAPI: router = APIRouter(prefix="/items")
|
|
122
|
+
Flask: bp = Blueprint("auth", __name__, url_prefix="/auth")
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
framework: str # flask | fastapi
|
|
126
|
+
router_name: str # e.g. "router", "auth_bp"
|
|
127
|
+
prefix: str # e.g. "/items", "/auth"
|
|
128
|
+
file_path: str
|
|
129
|
+
line: int
|
|
130
|
+
column: int | None = None
|
|
131
|
+
evidence: str = ""
|
|
132
|
+
module: str = ""
|
|
133
|
+
|
|
134
|
+
def as_dict(self) -> dict[str, object]:
|
|
135
|
+
return asdict(self)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class RouteDetection:
|
|
140
|
+
framework: str # flask | fastapi | django | express | nextjs
|
|
141
|
+
http_method: str # GET | POST | PUT | DELETE | PATCH | OPTIONS | HEAD | ANY
|
|
142
|
+
route_path: str # Original framework route string, e.g. /users/<int:id>
|
|
143
|
+
normalized_route: str # Canonical route string, e.g. /users/{id}
|
|
144
|
+
handler_name: str # e.g. "login_controller"
|
|
145
|
+
handler_canonical_id: str # Standard canonical symbol ID, e.g. "src.auth.routes.login_controller"
|
|
146
|
+
file_path: str
|
|
147
|
+
line: int
|
|
148
|
+
column: int | None = None
|
|
149
|
+
confidence: str = "HIGH"
|
|
150
|
+
resolution_status: str = "RESOLVED" # RESOLVED | UNRESOLVED | AMBIGUOUS
|
|
151
|
+
evidence: str = ""
|
|
152
|
+
module: str = ""
|
|
153
|
+
endpoint_id_override: str | None = None
|
|
154
|
+
router_name: str = ""
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def route_signature(self) -> str:
|
|
158
|
+
"""Human-readable route signature, e.g. 'POST /login'."""
|
|
159
|
+
return f"{self.http_method} {self.route_path}"
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def endpoint_id(self) -> str:
|
|
163
|
+
"""Stable, deterministic, globally unique endpoint identifier for a route declaration."""
|
|
164
|
+
if self.endpoint_id_override:
|
|
165
|
+
return self.endpoint_id_override
|
|
166
|
+
|
|
167
|
+
clean_path = self.file_path.replace("\\", "/").strip()
|
|
168
|
+
while clean_path.startswith("./"):
|
|
169
|
+
clean_path = clean_path[2:]
|
|
170
|
+
clean_path = clean_path.lstrip("/") or "root"
|
|
171
|
+
|
|
172
|
+
handler_part = f"#{self.handler_name}" if self.handler_name else ""
|
|
173
|
+
fw_part = f":{self.framework}" if self.framework else ""
|
|
174
|
+
return f"API_ENDPOINT{fw_part}:{clean_path}:{self.line}:{self.http_method} {self.normalized_route}{handler_part}"
|
|
175
|
+
|
|
176
|
+
def as_dict(self) -> dict[str, object]:
|
|
177
|
+
return {
|
|
178
|
+
"endpoint_id": self.endpoint_id,
|
|
179
|
+
"route_signature": self.route_signature,
|
|
180
|
+
"framework": self.framework,
|
|
181
|
+
"http_method": self.http_method,
|
|
182
|
+
"route_path": self.route_path,
|
|
183
|
+
"normalized_route": self.normalized_route,
|
|
184
|
+
"handler_name": self.handler_name,
|
|
185
|
+
"handler_canonical_id": self.handler_canonical_id,
|
|
186
|
+
"file": self.file_path,
|
|
187
|
+
"file_path": self.file_path,
|
|
188
|
+
"line": self.line,
|
|
189
|
+
"column": self.column,
|
|
190
|
+
"confidence": self.confidence,
|
|
191
|
+
"resolution_status": self.resolution_status,
|
|
192
|
+
"evidence": self.evidence,
|
|
193
|
+
"router_name": self.router_name,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass(frozen=True)
|
|
198
|
+
class AnalysisContext:
|
|
199
|
+
file_path: str
|
|
200
|
+
module: str
|
|
201
|
+
imports_modules: set[str]
|
|
202
|
+
scope_qname: str
|
|
203
|
+
scope_canonical_id: str
|
|
204
|
+
scope_kind: str
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class FrameworkAnalyzer(Protocol):
|
|
208
|
+
framework: str
|
|
209
|
+
|
|
210
|
+
def analyze_python_node(
|
|
211
|
+
self,
|
|
212
|
+
node: ast.AST,
|
|
213
|
+
context: AnalysisContext,
|
|
214
|
+
) -> list[RouteDetection]: ...
|
|
215
|
+
|
|
216
|
+
def analyze_python_mounts(
|
|
217
|
+
self,
|
|
218
|
+
node: ast.AST,
|
|
219
|
+
context: AnalysisContext,
|
|
220
|
+
) -> list[RouterMountDetection]: ...
|
|
221
|
+
|
|
222
|
+
def analyze_python_definitions(
|
|
223
|
+
self,
|
|
224
|
+
node: ast.AST,
|
|
225
|
+
context: AnalysisContext,
|
|
226
|
+
) -> list[RouterDefinition]: ...
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# Python Framework Analyzers
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
_FLASK_METHODS = {"route", "get", "post", "put", "delete", "patch"}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class FlaskAnalyzer:
|
|
237
|
+
framework = "flask"
|
|
238
|
+
|
|
239
|
+
def analyze_python_node(
|
|
240
|
+
self,
|
|
241
|
+
node: ast.AST,
|
|
242
|
+
context: AnalysisContext,
|
|
243
|
+
) -> list[RouteDetection]:
|
|
244
|
+
routes: list[RouteDetection] = []
|
|
245
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
246
|
+
return routes
|
|
247
|
+
|
|
248
|
+
for dec in node.decorator_list:
|
|
249
|
+
if not isinstance(dec, ast.Call) or not isinstance(dec.func, ast.Attribute):
|
|
250
|
+
continue
|
|
251
|
+
attr = dec.func.attr.lower()
|
|
252
|
+
if attr not in _FLASK_METHODS or not dec.args:
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
first_arg = dec.args[0]
|
|
256
|
+
if not isinstance(first_arg, ast.Constant) or not isinstance(first_arg.value, str):
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
route_path = first_arg.value
|
|
260
|
+
methods = [attr.upper()] if attr != "route" else ["GET"]
|
|
261
|
+
|
|
262
|
+
for kw in dec.keywords:
|
|
263
|
+
if kw.arg == "methods" and isinstance(kw.value, (ast.List, ast.Tuple)):
|
|
264
|
+
methods = [
|
|
265
|
+
e.value.upper()
|
|
266
|
+
for e in kw.value.elts
|
|
267
|
+
if isinstance(e, ast.Constant) and isinstance(e.value, str)
|
|
268
|
+
] or ["GET"]
|
|
269
|
+
|
|
270
|
+
col = getattr(dec, "col_offset", None)
|
|
271
|
+
norm = normalize_route_path(route_path, "flask")
|
|
272
|
+
handler_canon = (
|
|
273
|
+
f"{context.scope_canonical_id}.{node.name}"
|
|
274
|
+
if context.scope_qname
|
|
275
|
+
else f"{context.module}.{node.name}"
|
|
276
|
+
)
|
|
277
|
+
router_name = ast.unparse(dec.func.value) if hasattr(ast, "unparse") else "app"
|
|
278
|
+
|
|
279
|
+
for method in methods:
|
|
280
|
+
routes.append(
|
|
281
|
+
RouteDetection(
|
|
282
|
+
framework="flask",
|
|
283
|
+
http_method=method,
|
|
284
|
+
route_path=route_path,
|
|
285
|
+
normalized_route=norm,
|
|
286
|
+
handler_name=node.name,
|
|
287
|
+
handler_canonical_id=handler_canon,
|
|
288
|
+
file_path=context.file_path,
|
|
289
|
+
line=dec.lineno,
|
|
290
|
+
column=col,
|
|
291
|
+
confidence="HIGH",
|
|
292
|
+
resolution_status="RESOLVED",
|
|
293
|
+
evidence=f"Flask decorator @{ast.unparse(dec.func)}('{route_path}') on {node.name}",
|
|
294
|
+
module=context.module,
|
|
295
|
+
router_name=router_name,
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
return routes
|
|
299
|
+
|
|
300
|
+
def analyze_python_mounts(
|
|
301
|
+
self,
|
|
302
|
+
node: ast.AST,
|
|
303
|
+
context: AnalysisContext,
|
|
304
|
+
) -> list[RouterMountDetection]:
|
|
305
|
+
mounts: list[RouterMountDetection] = []
|
|
306
|
+
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
|
307
|
+
return mounts
|
|
308
|
+
|
|
309
|
+
if node.func.attr == "register_blueprint" and node.args:
|
|
310
|
+
parent_expr = ast.unparse(node.func.value) if hasattr(ast, "unparse") else "app"
|
|
311
|
+
child_expr = ast.unparse(node.args[0]) if hasattr(ast, "unparse") else "blueprint"
|
|
312
|
+
prefix = ""
|
|
313
|
+
for kw in node.keywords:
|
|
314
|
+
if kw.arg == "url_prefix" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
|
|
315
|
+
prefix = kw.value.value
|
|
316
|
+
col = getattr(node, "col_offset", None)
|
|
317
|
+
mounts.append(
|
|
318
|
+
RouterMountDetection(
|
|
319
|
+
framework="flask",
|
|
320
|
+
parent_router=parent_expr,
|
|
321
|
+
child_router=child_expr,
|
|
322
|
+
prefix=prefix,
|
|
323
|
+
file_path=context.file_path,
|
|
324
|
+
line=node.lineno,
|
|
325
|
+
column=col,
|
|
326
|
+
confidence="HIGH",
|
|
327
|
+
evidence=f"Flask register_blueprint: {ast.unparse(node) if hasattr(ast, 'unparse') else 'register_blueprint'}",
|
|
328
|
+
module=context.module,
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
return mounts
|
|
332
|
+
|
|
333
|
+
def analyze_python_definitions(
|
|
334
|
+
self,
|
|
335
|
+
node: ast.AST,
|
|
336
|
+
context: AnalysisContext,
|
|
337
|
+
) -> list[RouterDefinition]:
|
|
338
|
+
defs: list[RouterDefinition] = []
|
|
339
|
+
target_name = ""
|
|
340
|
+
call_node: ast.Call | None = None
|
|
341
|
+
|
|
342
|
+
if isinstance(node, ast.Assign) and node.targets and isinstance(node.value, ast.Call):
|
|
343
|
+
if isinstance(node.targets[0], ast.Name):
|
|
344
|
+
target_name = node.targets[0].id
|
|
345
|
+
call_node = node.value
|
|
346
|
+
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and isinstance(node.value, ast.Call):
|
|
347
|
+
target_name = node.target.id
|
|
348
|
+
call_node = node.value
|
|
349
|
+
|
|
350
|
+
if call_node and hasattr(ast, "unparse"):
|
|
351
|
+
func_expr = ast.unparse(call_node.func)
|
|
352
|
+
if func_expr in ("Blueprint", "flask.Blueprint") or func_expr.endswith(".Blueprint"):
|
|
353
|
+
prefix = ""
|
|
354
|
+
for kw in call_node.keywords:
|
|
355
|
+
if kw.arg == "url_prefix" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
|
|
356
|
+
prefix = kw.value.value
|
|
357
|
+
line = getattr(node, "lineno", 1)
|
|
358
|
+
col = getattr(node, "col_offset", None)
|
|
359
|
+
defs.append(
|
|
360
|
+
RouterDefinition(
|
|
361
|
+
framework="flask",
|
|
362
|
+
router_name=target_name or "bp",
|
|
363
|
+
prefix=prefix,
|
|
364
|
+
file_path=context.file_path,
|
|
365
|
+
line=line,
|
|
366
|
+
column=col,
|
|
367
|
+
evidence=f"Flask Blueprint definition: {ast.unparse(node)}",
|
|
368
|
+
module=context.module,
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
return defs
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
_FASTAPI_METHODS = {"get", "post", "put", "delete", "patch", "options", "head", "api_route"}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class FastAPIAnalyzer:
|
|
378
|
+
framework = "fastapi"
|
|
379
|
+
|
|
380
|
+
def analyze_python_node(
|
|
381
|
+
self,
|
|
382
|
+
node: ast.AST,
|
|
383
|
+
context: AnalysisContext,
|
|
384
|
+
) -> list[RouteDetection]:
|
|
385
|
+
routes: list[RouteDetection] = []
|
|
386
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
387
|
+
return routes
|
|
388
|
+
|
|
389
|
+
for dec in node.decorator_list:
|
|
390
|
+
if not isinstance(dec, ast.Call) or not isinstance(dec.func, ast.Attribute):
|
|
391
|
+
continue
|
|
392
|
+
attr = dec.func.attr.lower()
|
|
393
|
+
if attr not in _FASTAPI_METHODS or not dec.args:
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
first_arg = dec.args[0]
|
|
397
|
+
if not isinstance(first_arg, ast.Constant) or not isinstance(first_arg.value, str):
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
route_path = first_arg.value
|
|
401
|
+
methods = [attr.upper()] if attr != "api_route" else ["GET"]
|
|
402
|
+
|
|
403
|
+
for kw in dec.keywords:
|
|
404
|
+
if kw.arg == "methods" and isinstance(kw.value, (ast.List, ast.Tuple)):
|
|
405
|
+
methods = [
|
|
406
|
+
e.value.upper()
|
|
407
|
+
for e in kw.value.elts
|
|
408
|
+
if isinstance(e, ast.Constant) and isinstance(e.value, str)
|
|
409
|
+
] or ["GET"]
|
|
410
|
+
|
|
411
|
+
col = getattr(dec, "col_offset", None)
|
|
412
|
+
norm = normalize_route_path(route_path, "fastapi")
|
|
413
|
+
handler_canon = (
|
|
414
|
+
f"{context.scope_canonical_id}.{node.name}"
|
|
415
|
+
if context.scope_qname
|
|
416
|
+
else f"{context.module}.{node.name}"
|
|
417
|
+
)
|
|
418
|
+
router_name = ast.unparse(dec.func.value) if hasattr(ast, "unparse") else "app"
|
|
419
|
+
|
|
420
|
+
for method in methods:
|
|
421
|
+
routes.append(
|
|
422
|
+
RouteDetection(
|
|
423
|
+
framework="fastapi",
|
|
424
|
+
http_method=method,
|
|
425
|
+
route_path=route_path,
|
|
426
|
+
normalized_route=norm,
|
|
427
|
+
handler_name=node.name,
|
|
428
|
+
handler_canonical_id=handler_canon,
|
|
429
|
+
file_path=context.file_path,
|
|
430
|
+
line=dec.lineno,
|
|
431
|
+
column=col,
|
|
432
|
+
confidence="HIGH",
|
|
433
|
+
resolution_status="RESOLVED",
|
|
434
|
+
evidence=f"FastAPI decorator @{ast.unparse(dec.func)}('{route_path}') on {node.name}",
|
|
435
|
+
module=context.module,
|
|
436
|
+
router_name=router_name,
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
return routes
|
|
440
|
+
|
|
441
|
+
def analyze_python_mounts(
|
|
442
|
+
self,
|
|
443
|
+
node: ast.AST,
|
|
444
|
+
context: AnalysisContext,
|
|
445
|
+
) -> list[RouterMountDetection]:
|
|
446
|
+
mounts: list[RouterMountDetection] = []
|
|
447
|
+
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
|
448
|
+
return mounts
|
|
449
|
+
|
|
450
|
+
if node.func.attr == "include_router" and node.args:
|
|
451
|
+
parent_expr = ast.unparse(node.func.value) if hasattr(ast, "unparse") else "app"
|
|
452
|
+
child_expr = ast.unparse(node.args[0]) if hasattr(ast, "unparse") else "router"
|
|
453
|
+
prefix = ""
|
|
454
|
+
for kw in node.keywords:
|
|
455
|
+
if kw.arg == "prefix" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
|
|
456
|
+
prefix = kw.value.value
|
|
457
|
+
col = getattr(node, "col_offset", None)
|
|
458
|
+
mounts.append(
|
|
459
|
+
RouterMountDetection(
|
|
460
|
+
framework="fastapi",
|
|
461
|
+
parent_router=parent_expr,
|
|
462
|
+
child_router=child_expr,
|
|
463
|
+
prefix=prefix,
|
|
464
|
+
file_path=context.file_path,
|
|
465
|
+
line=node.lineno,
|
|
466
|
+
column=col,
|
|
467
|
+
confidence="HIGH",
|
|
468
|
+
evidence=f"FastAPI include_router: {ast.unparse(node) if hasattr(ast, 'unparse') else 'include_router'}",
|
|
469
|
+
module=context.module,
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
return mounts
|
|
473
|
+
|
|
474
|
+
def analyze_python_definitions(
|
|
475
|
+
self,
|
|
476
|
+
node: ast.AST,
|
|
477
|
+
context: AnalysisContext,
|
|
478
|
+
) -> list[RouterDefinition]:
|
|
479
|
+
defs: list[RouterDefinition] = []
|
|
480
|
+
target_name = ""
|
|
481
|
+
call_node: ast.Call | None = None
|
|
482
|
+
|
|
483
|
+
if isinstance(node, ast.Assign) and node.targets and isinstance(node.value, ast.Call):
|
|
484
|
+
if isinstance(node.targets[0], ast.Name):
|
|
485
|
+
target_name = node.targets[0].id
|
|
486
|
+
call_node = node.value
|
|
487
|
+
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and isinstance(node.value, ast.Call):
|
|
488
|
+
target_name = node.target.id
|
|
489
|
+
call_node = node.value
|
|
490
|
+
|
|
491
|
+
if call_node and hasattr(ast, "unparse"):
|
|
492
|
+
func_expr = ast.unparse(call_node.func)
|
|
493
|
+
if "APIRouter" in func_expr or func_expr.endswith("Router"):
|
|
494
|
+
prefix = ""
|
|
495
|
+
for kw in call_node.keywords:
|
|
496
|
+
if kw.arg == "prefix" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
|
|
497
|
+
prefix = kw.value.value
|
|
498
|
+
line = getattr(node, "lineno", 1)
|
|
499
|
+
col = getattr(node, "col_offset", None)
|
|
500
|
+
defs.append(
|
|
501
|
+
RouterDefinition(
|
|
502
|
+
framework="fastapi",
|
|
503
|
+
router_name=target_name or "router",
|
|
504
|
+
prefix=prefix,
|
|
505
|
+
file_path=context.file_path,
|
|
506
|
+
line=line,
|
|
507
|
+
column=col,
|
|
508
|
+
evidence=f"FastAPI APIRouter definition: {ast.unparse(node)}",
|
|
509
|
+
module=context.module,
|
|
510
|
+
)
|
|
511
|
+
)
|
|
512
|
+
return defs
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class DjangoAnalyzer:
|
|
516
|
+
framework = "django"
|
|
517
|
+
|
|
518
|
+
def analyze_python_node(
|
|
519
|
+
self,
|
|
520
|
+
node: ast.AST,
|
|
521
|
+
context: AnalysisContext,
|
|
522
|
+
) -> list[RouteDetection]:
|
|
523
|
+
routes: list[RouteDetection] = []
|
|
524
|
+
if not isinstance(node, ast.Call):
|
|
525
|
+
return routes
|
|
526
|
+
|
|
527
|
+
func_name = ""
|
|
528
|
+
if isinstance(node.func, ast.Name):
|
|
529
|
+
func_name = node.func.id
|
|
530
|
+
elif isinstance(node.func, ast.Attribute):
|
|
531
|
+
func_name = node.func.attr
|
|
532
|
+
|
|
533
|
+
if func_name in ("path", "re_path") and len(node.args) >= 2:
|
|
534
|
+
arg0, arg1 = node.args[0], node.args[1]
|
|
535
|
+
|
|
536
|
+
# If second argument is an include(...) call, do NOT record as a direct route
|
|
537
|
+
if isinstance(arg1, ast.Call) and hasattr(ast, "unparse"):
|
|
538
|
+
callee = ast.unparse(arg1.func)
|
|
539
|
+
if callee in ("include", "django.urls.include") or callee.endswith(".include"):
|
|
540
|
+
return routes
|
|
541
|
+
|
|
542
|
+
if isinstance(arg0, ast.Constant) and isinstance(arg0.value, str):
|
|
543
|
+
route_path = "/" + arg0.value.lstrip("/")
|
|
544
|
+
handler_expr = ast.unparse(arg1) if hasattr(ast, "unparse") else "handler"
|
|
545
|
+
handler_clean = handler_expr.replace(".as_view()", "").strip()
|
|
546
|
+
handler_name = handler_clean.split(".")[-1]
|
|
547
|
+
handler_canon = (
|
|
548
|
+
f"{context.module}.{handler_clean}"
|
|
549
|
+
if not handler_clean.startswith(context.module)
|
|
550
|
+
else handler_clean
|
|
551
|
+
)
|
|
552
|
+
col = getattr(node, "col_offset", None)
|
|
553
|
+
norm = normalize_route_path(route_path, "django")
|
|
554
|
+
|
|
555
|
+
routes.append(
|
|
556
|
+
RouteDetection(
|
|
557
|
+
framework="django",
|
|
558
|
+
http_method="ANY",
|
|
559
|
+
route_path=route_path,
|
|
560
|
+
normalized_route=norm,
|
|
561
|
+
handler_name=handler_name,
|
|
562
|
+
handler_canonical_id=handler_canon,
|
|
563
|
+
file_path=context.file_path,
|
|
564
|
+
line=node.lineno,
|
|
565
|
+
column=col,
|
|
566
|
+
confidence="HIGH",
|
|
567
|
+
resolution_status="RESOLVED",
|
|
568
|
+
evidence=f"Django {func_name}('{arg0.value}', {handler_expr})",
|
|
569
|
+
module=context.module,
|
|
570
|
+
router_name="urlpatterns",
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
return routes
|
|
574
|
+
|
|
575
|
+
def analyze_python_mounts(
|
|
576
|
+
self,
|
|
577
|
+
node: ast.AST,
|
|
578
|
+
context: AnalysisContext,
|
|
579
|
+
) -> list[RouterMountDetection]:
|
|
580
|
+
mounts: list[RouterMountDetection] = []
|
|
581
|
+
if not isinstance(node, ast.Call):
|
|
582
|
+
return mounts
|
|
583
|
+
|
|
584
|
+
func_name = ""
|
|
585
|
+
if isinstance(node.func, ast.Name):
|
|
586
|
+
func_name = node.func.id
|
|
587
|
+
elif isinstance(node.func, ast.Attribute):
|
|
588
|
+
func_name = node.func.attr
|
|
589
|
+
|
|
590
|
+
if func_name in ("path", "re_path") and len(node.args) >= 2:
|
|
591
|
+
arg0, arg1 = node.args[0], node.args[1]
|
|
592
|
+
if isinstance(arg1, ast.Call) and hasattr(ast, "unparse"):
|
|
593
|
+
callee = ast.unparse(arg1.func)
|
|
594
|
+
if callee in ("include", "django.urls.include") or callee.endswith(".include"):
|
|
595
|
+
prefix = arg0.value if isinstance(arg0, ast.Constant) and isinstance(arg0.value, str) else ""
|
|
596
|
+
child = ""
|
|
597
|
+
if arg1.args:
|
|
598
|
+
first = arg1.args[0]
|
|
599
|
+
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
600
|
+
child = first.value
|
|
601
|
+
elif isinstance(first, (ast.Tuple, ast.List)) and first.elts:
|
|
602
|
+
c0 = first.elts[0]
|
|
603
|
+
child = c0.value if isinstance(c0, ast.Constant) and isinstance(c0.value, str) else ast.unparse(c0)
|
|
604
|
+
else:
|
|
605
|
+
child = ast.unparse(first)
|
|
606
|
+
if child:
|
|
607
|
+
col = getattr(node, "col_offset", None)
|
|
608
|
+
mounts.append(
|
|
609
|
+
RouterMountDetection(
|
|
610
|
+
framework="django",
|
|
611
|
+
parent_router="urlpatterns",
|
|
612
|
+
child_router=child,
|
|
613
|
+
prefix=prefix,
|
|
614
|
+
file_path=context.file_path,
|
|
615
|
+
line=node.lineno,
|
|
616
|
+
column=col,
|
|
617
|
+
confidence="HIGH",
|
|
618
|
+
evidence=f"Django include: {ast.unparse(node)}",
|
|
619
|
+
module=context.module,
|
|
620
|
+
)
|
|
621
|
+
)
|
|
622
|
+
return mounts
|
|
623
|
+
|
|
624
|
+
def analyze_python_definitions(
|
|
625
|
+
self,
|
|
626
|
+
node: ast.AST,
|
|
627
|
+
context: AnalysisContext,
|
|
628
|
+
) -> list[RouterDefinition]:
|
|
629
|
+
return []
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def get_python_analyzers(imports_modules: set[str]) -> list[FrameworkAnalyzer]:
|
|
633
|
+
"""Select relevant Python framework analyzers based on concrete source import evidence."""
|
|
634
|
+
analyzers: list[FrameworkAnalyzer] = []
|
|
635
|
+
has_flask = any("flask" in m for m in imports_modules)
|
|
636
|
+
has_fastapi = any("fastapi" in m for m in imports_modules)
|
|
637
|
+
has_django = any("django" in m for m in imports_modules)
|
|
638
|
+
|
|
639
|
+
if has_flask:
|
|
640
|
+
analyzers.append(FlaskAnalyzer())
|
|
641
|
+
if has_fastapi:
|
|
642
|
+
analyzers.append(FastAPIAnalyzer())
|
|
643
|
+
if has_django:
|
|
644
|
+
analyzers.append(DjangoAnalyzer())
|
|
645
|
+
|
|
646
|
+
# If no framework import is explicitly present, enable all 3 conservatively so route decorators
|
|
647
|
+
# in files where imports are implicit or aliased are still recognized
|
|
648
|
+
if not analyzers:
|
|
649
|
+
analyzers = [FlaskAnalyzer(), FastAPIAnalyzer(), DjangoAnalyzer()]
|
|
650
|
+
|
|
651
|
+
return analyzers
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
# ---------------------------------------------------------------------------
|
|
655
|
+
# ---------------------------------------------------------------------------
|
|
656
|
+
# JavaScript / TypeScript Framework Analyzers
|
|
657
|
+
# ---------------------------------------------------------------------------
|
|
658
|
+
|
|
659
|
+
_EXPRESS_CALL_RE = re.compile(
|
|
660
|
+
r"\b([A-Za-z_$][\w$]*)\.(get|post|put|delete|patch|options|head|all)\s*\(\s*['\"]([^'\"]+)['\"]\s*,\s*(.+)",
|
|
661
|
+
re.IGNORECASE,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
_EXPRESS_USE_RE = re.compile(
|
|
665
|
+
r"\b([A-Za-z_$][\w$]*)\.use\s*\(\s*(?:['\"]([^'\"]+)['\"]\s*,\s*)?([A-Za-z_$][\w$.]*)\s*\)",
|
|
666
|
+
re.IGNORECASE,
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
_EXPRESS_IGNORE_USE = {
|
|
670
|
+
"express.json",
|
|
671
|
+
"express.urlencoded",
|
|
672
|
+
"express.static",
|
|
673
|
+
"cors",
|
|
674
|
+
"helmet",
|
|
675
|
+
"morgan",
|
|
676
|
+
"cookieparser",
|
|
677
|
+
"bodyparser",
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
_NON_HANDLER_CALLS = {
|
|
681
|
+
"async",
|
|
682
|
+
"await",
|
|
683
|
+
"function",
|
|
684
|
+
"if",
|
|
685
|
+
"return",
|
|
686
|
+
"try",
|
|
687
|
+
"catch",
|
|
688
|
+
"switch",
|
|
689
|
+
"while",
|
|
690
|
+
"for",
|
|
691
|
+
"throw",
|
|
692
|
+
"res.send",
|
|
693
|
+
"res.json",
|
|
694
|
+
"res.status",
|
|
695
|
+
"res.sendstatus",
|
|
696
|
+
"res.redirect",
|
|
697
|
+
"res.setheader",
|
|
698
|
+
"res.cookie",
|
|
699
|
+
"res.end",
|
|
700
|
+
"next",
|
|
701
|
+
"console.log",
|
|
702
|
+
"console.error",
|
|
703
|
+
"console.warn",
|
|
704
|
+
"console.info",
|
|
705
|
+
"object.assign",
|
|
706
|
+
"promise.all",
|
|
707
|
+
"promise.resolve",
|
|
708
|
+
"promise.reject",
|
|
709
|
+
"require",
|
|
710
|
+
"typeof",
|
|
711
|
+
"then",
|
|
712
|
+
"settimeout",
|
|
713
|
+
"cleartimeout",
|
|
714
|
+
"setinterval",
|
|
715
|
+
"clearinterval",
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def split_js_arguments(args_str: str) -> list[str]:
|
|
720
|
+
"""Split comma-separated JavaScript arguments respecting nested parens, brackets, braces, and quotes."""
|
|
721
|
+
args: list[str] = []
|
|
722
|
+
current: list[str] = []
|
|
723
|
+
depth_paren = 0
|
|
724
|
+
depth_bracket = 0
|
|
725
|
+
depth_brace = 0
|
|
726
|
+
in_quote = False
|
|
727
|
+
quote_char = ""
|
|
728
|
+
escape = False
|
|
729
|
+
|
|
730
|
+
for ch in args_str:
|
|
731
|
+
if escape:
|
|
732
|
+
current.append(ch)
|
|
733
|
+
escape = False
|
|
734
|
+
continue
|
|
735
|
+
if ch == "\\":
|
|
736
|
+
current.append(ch)
|
|
737
|
+
escape = True
|
|
738
|
+
continue
|
|
739
|
+
if in_quote:
|
|
740
|
+
current.append(ch)
|
|
741
|
+
if ch == quote_char:
|
|
742
|
+
in_quote = False
|
|
743
|
+
continue
|
|
744
|
+
if ch in ('"', "'", "`"):
|
|
745
|
+
in_quote = True
|
|
746
|
+
quote_char = ch
|
|
747
|
+
current.append(ch)
|
|
748
|
+
continue
|
|
749
|
+
|
|
750
|
+
if ch == "(":
|
|
751
|
+
depth_paren += 1
|
|
752
|
+
elif ch == ")":
|
|
753
|
+
if depth_paren == 0 and depth_bracket == 0 and depth_brace == 0:
|
|
754
|
+
# Trailing closing paren of router call reached
|
|
755
|
+
break
|
|
756
|
+
depth_paren = max(0, depth_paren - 1)
|
|
757
|
+
elif ch == "[":
|
|
758
|
+
depth_bracket += 1
|
|
759
|
+
elif ch == "]":
|
|
760
|
+
depth_bracket = max(0, depth_bracket - 1)
|
|
761
|
+
elif ch == "{":
|
|
762
|
+
depth_brace += 1
|
|
763
|
+
elif ch == "}":
|
|
764
|
+
depth_brace = max(0, depth_brace - 1)
|
|
765
|
+
|
|
766
|
+
if ch == "," and depth_paren == 0 and depth_bracket == 0 and depth_brace == 0:
|
|
767
|
+
arg = "".join(current).strip()
|
|
768
|
+
if arg:
|
|
769
|
+
args.append(arg)
|
|
770
|
+
current = []
|
|
771
|
+
else:
|
|
772
|
+
current.append(ch)
|
|
773
|
+
|
|
774
|
+
arg = "".join(current).strip()
|
|
775
|
+
if arg:
|
|
776
|
+
args.append(arg)
|
|
777
|
+
return args
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _clean_middleware_token(raw_tok: str) -> str:
|
|
781
|
+
"""Extract clean identifier from middleware expression, e.g. requireRoles('admin') -> requireRoles."""
|
|
782
|
+
tok = raw_tok.strip()
|
|
783
|
+
if tok.startswith("[") and tok.endswith("]"):
|
|
784
|
+
tok = tok[1:-1].strip()
|
|
785
|
+
m = re.match(r"^([A-Za-z_$][\w$]*)\s*(?:\(|$)", tok)
|
|
786
|
+
if m:
|
|
787
|
+
return m.group(1)
|
|
788
|
+
return tok.split("(")[0].strip()
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _fallback_handler_name(route_path: str) -> str:
|
|
792
|
+
"""Generate a readable fallback handler name from the route path."""
|
|
793
|
+
clean_parts = [p for p in route_path.strip("/").split("/") if p and not p.startswith(":") and not p.startswith("{")]
|
|
794
|
+
if clean_parts:
|
|
795
|
+
return f"{clean_parts[-1]}_handler"
|
|
796
|
+
return "root_handler"
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _resolve_express_handler_and_middleware(
|
|
800
|
+
raw_args: str,
|
|
801
|
+
route_path: str,
|
|
802
|
+
) -> tuple[str, str, list[str]]:
|
|
803
|
+
"""Look past middleware to resolve the terminal route handler and list of middlewares."""
|
|
804
|
+
args = split_js_arguments(raw_args)
|
|
805
|
+
if not args:
|
|
806
|
+
name = _fallback_handler_name(route_path)
|
|
807
|
+
return name, name, []
|
|
808
|
+
|
|
809
|
+
# Intermediate arguments are middleware
|
|
810
|
+
middlewares: list[str] = []
|
|
811
|
+
for mw in args[:-1]:
|
|
812
|
+
clean_mw = _clean_middleware_token(mw)
|
|
813
|
+
if clean_mw:
|
|
814
|
+
middlewares.append(clean_mw)
|
|
815
|
+
|
|
816
|
+
last_arg = args[-1].strip()
|
|
817
|
+
|
|
818
|
+
# Case 1: Simple named identifier or member access, e.g. uploadInvoiceFile or controller.uploadInvoiceFile
|
|
819
|
+
id_match = re.match(r"^([A-Za-z_$][\w$.]*)$", last_arg)
|
|
820
|
+
if id_match:
|
|
821
|
+
expr = id_match.group(1)
|
|
822
|
+
name = expr.split(".")[-1]
|
|
823
|
+
return name, expr, middlewares
|
|
824
|
+
|
|
825
|
+
# Case 2: Arrow function or function expression, e.g. async (req, res) => uploadInvoiceFile(req, res)
|
|
826
|
+
# Search for business function calls inside the function body
|
|
827
|
+
calls = re.findall(r"\b([A-Za-z_$][\w$.]*)\s*\(", last_arg)
|
|
828
|
+
mw_set = set(middlewares)
|
|
829
|
+
for call in calls:
|
|
830
|
+
clean_call = call.split(".")[-1]
|
|
831
|
+
if (
|
|
832
|
+
call.lower() not in _NON_HANDLER_CALLS
|
|
833
|
+
and clean_call.lower() not in _NON_HANDLER_CALLS
|
|
834
|
+
and clean_call not in mw_set
|
|
835
|
+
):
|
|
836
|
+
return clean_call, call, middlewares
|
|
837
|
+
|
|
838
|
+
# Case 3: Anonymous inline handler without distinct call
|
|
839
|
+
name = _fallback_handler_name(route_path)
|
|
840
|
+
return name, name, middlewares
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
_NEXT_ROUTE_EXPORT_RE = re.compile(
|
|
844
|
+
r"export\s+(?:async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)\s*\("
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def analyze_js_ts_line(
|
|
849
|
+
line: str,
|
|
850
|
+
lineno: int,
|
|
851
|
+
file_path: str,
|
|
852
|
+
module: str,
|
|
853
|
+
imports_modules: set[str],
|
|
854
|
+
) -> list[RouteDetection]:
|
|
855
|
+
"""Analyze a single JS/TS line during single-pass scan for Express and Next.js routes."""
|
|
856
|
+
routes: list[RouteDetection] = []
|
|
857
|
+
|
|
858
|
+
# 1. Express route registration
|
|
859
|
+
for m in _EXPRESS_CALL_RE.finditer(line):
|
|
860
|
+
router_obj = m.group(1)
|
|
861
|
+
# Check if caller looks like an Express router or app
|
|
862
|
+
if router_obj.lower() not in ("app", "router") and not router_obj.lower().endswith("router") and not router_obj.lower().endswith("routes"):
|
|
863
|
+
continue
|
|
864
|
+
|
|
865
|
+
method = m.group(2).upper()
|
|
866
|
+
if method == "ALL":
|
|
867
|
+
method = "ANY"
|
|
868
|
+
route_path = m.group(3)
|
|
869
|
+
raw_args = m.group(4)
|
|
870
|
+
handler_name, handler_expr, middlewares = _resolve_express_handler_and_middleware(raw_args, route_path)
|
|
871
|
+
handler_canon = f"{module}.{handler_expr}"
|
|
872
|
+
norm = normalize_route_path(route_path, "express")
|
|
873
|
+
mw_str = f" (middleware: {', '.join(middlewares)})" if middlewares else ""
|
|
874
|
+
|
|
875
|
+
routes.append(
|
|
876
|
+
RouteDetection(
|
|
877
|
+
framework="express",
|
|
878
|
+
http_method=method,
|
|
879
|
+
route_path=route_path,
|
|
880
|
+
normalized_route=norm,
|
|
881
|
+
handler_name=handler_name,
|
|
882
|
+
handler_canonical_id=handler_canon,
|
|
883
|
+
file_path=file_path,
|
|
884
|
+
line=lineno,
|
|
885
|
+
column=m.start(),
|
|
886
|
+
confidence="HIGH",
|
|
887
|
+
resolution_status="RESOLVED",
|
|
888
|
+
evidence=f"Express route registration {router_obj}.{method.lower()}('{route_path}', ...){mw_str}",
|
|
889
|
+
module=module,
|
|
890
|
+
router_name=router_obj,
|
|
891
|
+
)
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
# 2. Next.js App Router route handlers (e.g. app/api/login/route.ts)
|
|
895
|
+
norm_path = file_path.replace("\\", "/")
|
|
896
|
+
is_next_app_route = "/route." in norm_path or norm_path.startswith("route.")
|
|
897
|
+
has_next_import = any("next" in m.lower() for m in imports_modules)
|
|
898
|
+
|
|
899
|
+
if is_next_app_route or has_next_import:
|
|
900
|
+
for m in _NEXT_ROUTE_EXPORT_RE.finditer(line):
|
|
901
|
+
method = m.group(1).upper()
|
|
902
|
+
route_path = "/" + norm_path.rsplit("/", 1)[0] if "/" in norm_path else "/"
|
|
903
|
+
for prefix in ("/src/app", "/app", "/src/pages", "/pages"):
|
|
904
|
+
if route_path.startswith(prefix):
|
|
905
|
+
route_path = route_path[len(prefix) :] or "/"
|
|
906
|
+
break
|
|
907
|
+
norm = normalize_route_path(route_path, "nextjs")
|
|
908
|
+
routes.append(
|
|
909
|
+
RouteDetection(
|
|
910
|
+
framework="nextjs",
|
|
911
|
+
http_method=method,
|
|
912
|
+
route_path=route_path,
|
|
913
|
+
normalized_route=norm,
|
|
914
|
+
handler_name=method,
|
|
915
|
+
handler_canonical_id=f"{module}.{method}",
|
|
916
|
+
file_path=file_path,
|
|
917
|
+
line=lineno,
|
|
918
|
+
column=m.start(),
|
|
919
|
+
confidence="HIGH",
|
|
920
|
+
resolution_status="RESOLVED",
|
|
921
|
+
evidence=f"Next.js App Router HTTP export {method} in {file_path}",
|
|
922
|
+
module=module,
|
|
923
|
+
router_name="export",
|
|
924
|
+
)
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
return routes
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def analyze_js_ts_mounts(
|
|
931
|
+
line: str,
|
|
932
|
+
lineno: int,
|
|
933
|
+
file_path: str,
|
|
934
|
+
module: str,
|
|
935
|
+
) -> list[RouterMountDetection]:
|
|
936
|
+
"""Analyze a single JS/TS line for Express router mounting statements (e.g. app.use('/api', router))."""
|
|
937
|
+
mounts: list[RouterMountDetection] = []
|
|
938
|
+
for m in _EXPRESS_USE_RE.finditer(line):
|
|
939
|
+
parent = m.group(1)
|
|
940
|
+
prefix = m.group(2) or ""
|
|
941
|
+
child = m.group(3)
|
|
942
|
+
|
|
943
|
+
if child.lower() in _EXPRESS_IGNORE_USE or parent.lower() in _EXPRESS_IGNORE_USE:
|
|
944
|
+
continue
|
|
945
|
+
|
|
946
|
+
mounts.append(
|
|
947
|
+
RouterMountDetection(
|
|
948
|
+
framework="express",
|
|
949
|
+
parent_router=parent,
|
|
950
|
+
child_router=child,
|
|
951
|
+
prefix=prefix,
|
|
952
|
+
file_path=file_path,
|
|
953
|
+
line=lineno,
|
|
954
|
+
column=m.start(),
|
|
955
|
+
confidence="HIGH",
|
|
956
|
+
evidence=f"Express mount {m.group(0)}",
|
|
957
|
+
module=module,
|
|
958
|
+
)
|
|
959
|
+
)
|
|
960
|
+
return mounts
|