devlensio 0.6.1 → 1.0.0
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.
- package/README.md +89 -31
- package/dist/extractors/detectLanguage.d.ts +2 -0
- package/dist/extractors/detectLanguage.js +33 -0
- package/dist/extractors/index.d.ts +6 -0
- package/dist/extractors/index.js +116 -0
- package/dist/extractors/runner.d.ts +7 -0
- package/dist/extractors/runner.js +194 -0
- package/dist/extractors/types.d.ts +37 -0
- package/dist/extractors/types.js +8 -0
- package/dist/graph/buildLookup.d.ts +1 -0
- package/dist/graph/buildLookup.js +2 -1
- package/dist/graph/edges/callEdges.js +19 -5
- package/dist/graph/edges/callEdges.test.d.ts +1 -0
- package/dist/graph/edges/callEdges.test.js +200 -0
- package/dist/graph/edges/importEdges.js +12 -0
- package/dist/graph/edges/inheritanceEdges.d.ts +3 -0
- package/dist/graph/edges/inheritanceEdges.js +73 -0
- package/dist/graph/edges/inheritanceEdges.test.d.ts +1 -0
- package/dist/graph/edges/inheritanceEdges.test.js +140 -0
- package/dist/graph/index.js +4 -0
- package/dist/parser/classes.test.d.ts +1 -0
- package/dist/parser/classes.test.js +360 -0
- package/dist/parser/extractors/classes.d.ts +5 -0
- package/dist/parser/extractors/classes.js +241 -0
- package/dist/parser/index.d.ts +2 -0
- package/dist/parser/index.js +7 -1
- package/dist/pipeline/index.d.ts +2 -1
- package/dist/pipeline/index.js +36 -48
- package/dist/scoring/index.js +8 -0
- package/dist/scoring/index.test.js +43 -0
- package/dist/scoring/nodeScorer.js +2 -0
- package/dist/scoring/pruneDisconnected.d.ts +8 -0
- package/dist/scoring/pruneDisconnected.js +66 -0
- package/dist/scoring/pruneDisconnected.test.d.ts +1 -0
- package/dist/scoring/pruneDisconnected.test.js +123 -0
- package/dist/summarizer/prompts.d.ts +1 -1
- package/dist/types.d.ts +5 -5
- package/extractors/go/bin/darwin-amd64/devlens_go_extractor +0 -0
- package/extractors/go/bin/darwin-arm64/devlens_go_extractor +0 -0
- package/extractors/go/bin/linux-amd64/devlens_go_extractor +0 -0
- package/extractors/go/bin/linux-arm64/devlens_go_extractor +0 -0
- package/extractors/go/bin/windows-amd64/devlens_go_extractor.exe +0 -0
- package/extractors/go/build.mjs +43 -0
- package/extractors/go/calls.go +289 -0
- package/extractors/go/contract.go +140 -0
- package/extractors/go/extractor.go +161 -0
- package/extractors/go/fingerprint.go +167 -0
- package/extractors/go/go.mod +3 -0
- package/extractors/go/imports.go +90 -0
- package/extractors/go/inheritance.go +138 -0
- package/extractors/go/lookup.go +106 -0
- package/extractors/go/main.go +57 -0
- package/extractors/go/nodes.go +177 -0
- package/extractors/go/orm_edges.go +277 -0
- package/extractors/go/parser.go +469 -0
- package/extractors/go/routes.go +521 -0
- package/extractors/go/tests.go +45 -0
- package/extractors/go/thirdparty.go +138 -0
- package/extractors/go/typeload.go +178 -0
- package/extractors/go/walker.go +67 -0
- package/extractors/java/build.mjs +90 -0
- package/extractors/java/devlens_java_extractor.jar +0 -0
- package/extractors/java/src/devlens/extractor/Contract.java +171 -0
- package/extractors/java/src/devlens/extractor/Extractor.java +262 -0
- package/extractors/java/src/devlens/extractor/ExtractorResult.java +12 -0
- package/extractors/java/src/devlens/extractor/Fingerprint.java +239 -0
- package/extractors/java/src/devlens/extractor/LookupMaps.java +123 -0
- package/extractors/java/src/devlens/extractor/Main.java +66 -0
- package/extractors/java/src/devlens/extractor/Parser.java +522 -0
- package/extractors/java/src/devlens/extractor/SourceWalker.java +82 -0
- package/extractors/java/src/devlens/extractor/ThirdParty.java +141 -0
- package/extractors/java/src/devlens/extractor/TypeSolverFactory.java +43 -0
- package/extractors/java/src/devlens/extractor/edges/Calls.java +266 -0
- package/extractors/java/src/devlens/extractor/edges/Enrich.java +54 -0
- package/extractors/java/src/devlens/extractor/edges/Imports.java +154 -0
- package/extractors/java/src/devlens/extractor/edges/Inheritance.java +79 -0
- package/extractors/java/src/devlens/extractor/edges/OrmEdges.java +209 -0
- package/extractors/java/src/devlens/extractor/edges/Routes.java +143 -0
- package/extractors/java/src/devlens/extractor/edges/Tests.java +53 -0
- package/extractors/python/devlens_extractors_python/__init__.py +3 -0
- package/extractors/python/devlens_extractors_python/__main__.py +33 -0
- package/extractors/python/devlens_extractors_python/contract.py +94 -0
- package/extractors/python/devlens_extractors_python/edges/__init__.py +28 -0
- package/extractors/python/devlens_extractors_python/edges/calls.py +112 -0
- package/extractors/python/devlens_extractors_python/edges/enrich.py +44 -0
- package/extractors/python/devlens_extractors_python/edges/imports.py +155 -0
- package/extractors/python/devlens_extractors_python/edges/inheritance.py +97 -0
- package/extractors/python/devlens_extractors_python/edges/orm_edges.py +225 -0
- package/extractors/python/devlens_extractors_python/edges/routes/__init__.py +28 -0
- package/extractors/python/devlens_extractors_python/edges/routes/common.py +79 -0
- package/extractors/python/devlens_extractors_python/edges/routes/decorators.py +212 -0
- package/extractors/python/devlens_extractors_python/edges/routes/django_urls.py +213 -0
- package/extractors/python/devlens_extractors_python/edges/routes/drf.py +128 -0
- package/extractors/python/devlens_extractors_python/edges/tests.py +53 -0
- package/extractors/python/devlens_extractors_python/extractor.py +107 -0
- package/extractors/python/devlens_extractors_python/fingerprint.py +201 -0
- package/extractors/python/devlens_extractors_python/lookup.py +72 -0
- package/extractors/python/devlens_extractors_python/parser/__init__.py +72 -0
- package/extractors/python/devlens_extractors_python/parser/classes.py +109 -0
- package/extractors/python/devlens_extractors_python/parser/functions.py +163 -0
- package/extractors/python/devlens_extractors_python/parser/walker.py +30 -0
- package/extractors/python/devlens_extractors_python/third_party.py +103 -0
- package/extractors/python/pyproject.toml +16 -0
- package/extractors/python/setup.mjs +54 -0
- package/extractors/rust/Cargo.toml +29 -0
- package/extractors/rust/bin/darwin-amd64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/darwin-arm64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/linux-amd64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/linux-arm64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/windows-amd64/devlens_rust_extractor.exe +0 -0
- package/extractors/rust/build.mjs +85 -0
- package/extractors/rust/src/calls.rs +295 -0
- package/extractors/rust/src/contract.rs +236 -0
- package/extractors/rust/src/enrich.rs +107 -0
- package/extractors/rust/src/extractor.rs +199 -0
- package/extractors/rust/src/fingerprint.rs +238 -0
- package/extractors/rust/src/imports.rs +101 -0
- package/extractors/rust/src/inheritance.rs +233 -0
- package/extractors/rust/src/lookup.rs +226 -0
- package/extractors/rust/src/main.rs +53 -0
- package/extractors/rust/src/module_map.rs +131 -0
- package/extractors/rust/src/nodes.rs +174 -0
- package/extractors/rust/src/orm_edges.rs +125 -0
- package/extractors/rust/src/parser.rs +859 -0
- package/extractors/rust/src/routes.rs +1265 -0
- package/extractors/rust/src/tests.rs +109 -0
- package/extractors/rust/src/thirdparty.rs +114 -0
- package/extractors/rust/src/walker.rs +82 -0
- package/package.json +22 -4
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Shared route builders — used by decorators.py / django_urls.py / drf.py.
|
|
2
|
+
|
|
3
|
+
Mirrors routesToCodeNodes in src/pipeline/index.ts: every route emits
|
|
4
|
+
BackendRouteNode dict (routes array), ROUTE code node (graph), and a
|
|
5
|
+
HANDLES edge (ROUTE node → handler node).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from ...lookup import LookupMaps
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RouteResult:
|
|
17
|
+
routes: list[dict] = field(default_factory=list) # BackendRouteNode[]
|
|
18
|
+
route_nodes: list[dict] = field(default_factory=list) # ROUTE code nodes
|
|
19
|
+
handles_edges: list[dict] = field(default_factory=list)
|
|
20
|
+
# (filePath, httpMethod, urlPath) — dedupe guard for repeated includes
|
|
21
|
+
_seen: set = field(default_factory=set)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _route_code_node(route: dict, rel_path: str) -> dict:
|
|
25
|
+
name = f"{route['httpMethod']} {route['urlPath']}"
|
|
26
|
+
return {
|
|
27
|
+
"id": f"{rel_path}::{name}",
|
|
28
|
+
"name": name,
|
|
29
|
+
"type": "ROUTE",
|
|
30
|
+
"filePath": rel_path,
|
|
31
|
+
"startLine": 0,
|
|
32
|
+
"endLine": 0,
|
|
33
|
+
"parentFile": f"file::{rel_path}",
|
|
34
|
+
"metadata": {
|
|
35
|
+
"urlPath": route["urlPath"],
|
|
36
|
+
"httpMethod": route["httpMethod"],
|
|
37
|
+
"isDynamic": route["isDynamic"],
|
|
38
|
+
"params": route.get("params", []),
|
|
39
|
+
"framework": route["framework"],
|
|
40
|
+
"handlerName": route.get("handlerName"),
|
|
41
|
+
"routeKind": "backend",
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def emit_route(route: dict, rel_path: str, handler_id: str | None,
|
|
47
|
+
result: RouteResult) -> None:
|
|
48
|
+
key = (rel_path, route["httpMethod"], route["urlPath"])
|
|
49
|
+
if key in result._seen:
|
|
50
|
+
return # same route emitted twice (dup include) — skip
|
|
51
|
+
result._seen.add(key)
|
|
52
|
+
|
|
53
|
+
node = _route_code_node(route, rel_path)
|
|
54
|
+
result.routes.append(route)
|
|
55
|
+
result.route_nodes.append(node)
|
|
56
|
+
if handler_id:
|
|
57
|
+
result.handles_edges.append({
|
|
58
|
+
"from": node["id"], "to": handler_id, "type": "HANDLES",
|
|
59
|
+
"metadata": {"urlPath": route["urlPath"],
|
|
60
|
+
"httpMethod": route["httpMethod"],
|
|
61
|
+
"routeKind": "backend"},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def handler_node_id(rel_path: str, name: str, lookup: LookupMaps) -> str | None:
|
|
66
|
+
"""Top-level function → exact id; method → rel::Class.name suffix match."""
|
|
67
|
+
names = lookup.nodes_by_file.get(rel_path, {})
|
|
68
|
+
if name in names:
|
|
69
|
+
return names[name]
|
|
70
|
+
for node_id in lookup.node_by_id:
|
|
71
|
+
if node_id.startswith(f"{rel_path}::") and node_id.endswith(f".{name}"):
|
|
72
|
+
return node_id
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def extract_params(path: str, framework: str) -> list[str]:
|
|
77
|
+
if framework == "fastapi":
|
|
78
|
+
return re.findall(r"\{([A-Za-z_]\w*)(?::[^}]*)?\}", path)
|
|
79
|
+
return re.findall(r"<(?:\w+:)?([A-Za-z_]\w*)>", path)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""FastAPI / Flask decorator routes.
|
|
2
|
+
|
|
3
|
+
@app.get("/users/{id}") · @app.route("/users/<int:id>", methods=[...])
|
|
4
|
+
Also handles app.include_router(router, prefix="/api") prefix joining.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
|
|
10
|
+
from ...lookup import LookupMaps
|
|
11
|
+
from .common import RouteResult, emit_route, extract_params, handler_node_id
|
|
12
|
+
|
|
13
|
+
HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head", "options"}
|
|
14
|
+
ROUTE_ATTRS = HTTP_METHODS | {"route", "add_api_route"} # decorator attr names
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _include_router_prefixes(tree: ast.Module) -> dict[str, str]:
|
|
18
|
+
"""Module-level app.include_router(x, prefix='/api') → {x: '/api'}."""
|
|
19
|
+
prefixes: dict[str, str] = {}
|
|
20
|
+
for stmt in tree.body:
|
|
21
|
+
if not (isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call)):
|
|
22
|
+
continue
|
|
23
|
+
call = stmt.value
|
|
24
|
+
if not (isinstance(call.func, ast.Attribute)
|
|
25
|
+
and call.func.attr == "include_router"):
|
|
26
|
+
continue
|
|
27
|
+
if not call.args or not isinstance(call.args[0], ast.Name):
|
|
28
|
+
continue
|
|
29
|
+
prefix = ""
|
|
30
|
+
for kw in call.keywords:
|
|
31
|
+
if kw.arg == "prefix" and isinstance(kw.value, ast.Constant) \
|
|
32
|
+
and isinstance(kw.value.value, str):
|
|
33
|
+
prefix = kw.value.value
|
|
34
|
+
if not prefix and len(call.args) > 1 and isinstance(call.args[1], ast.Constant) \
|
|
35
|
+
and isinstance(call.args[1].value, str):
|
|
36
|
+
prefix = call.args[1].value
|
|
37
|
+
prefixes[call.args[0].id] = prefix
|
|
38
|
+
return prefixes
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _decorator_route(dec: ast.AST, prefixes: dict[str, str],
|
|
42
|
+
fingerprint) -> dict | None:
|
|
43
|
+
"""One decorator → route dict (framework, path, methods, params) or None."""
|
|
44
|
+
if not isinstance(dec, ast.Call) or not isinstance(dec.func, ast.Attribute):
|
|
45
|
+
return None
|
|
46
|
+
method = dec.func.attr.lower()
|
|
47
|
+
if method not in ROUTE_ATTRS:
|
|
48
|
+
return None
|
|
49
|
+
app_var = dec.func.value.id if isinstance(dec.func.value, ast.Name) else None
|
|
50
|
+
if not app_var:
|
|
51
|
+
return None
|
|
52
|
+
if not dec.args or not isinstance(dec.args[0], ast.Constant) \
|
|
53
|
+
or not isinstance(dec.args[0].value, str):
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
path: str = dec.args[0].value # bind narrowed constant (Pyright _ConstantValue fix)
|
|
57
|
+
# framework: fingerprint when known, else infer from syntax
|
|
58
|
+
if fingerprint.framework in ("fastapi", "flask"):
|
|
59
|
+
framework = fingerprint.framework
|
|
60
|
+
elif method == "route" or "<" in path:
|
|
61
|
+
framework = "flask"
|
|
62
|
+
else:
|
|
63
|
+
framework = "fastapi"
|
|
64
|
+
|
|
65
|
+
prefix = prefixes.get(app_var, "")
|
|
66
|
+
full_path = f"{prefix}{path}" if prefix else path
|
|
67
|
+
|
|
68
|
+
params = extract_params(full_path, framework)
|
|
69
|
+
is_dynamic = bool(params) or "{" in full_path or "<" in full_path
|
|
70
|
+
|
|
71
|
+
# Flask: @app.route(path, methods=[...]) → several methods; default GET
|
|
72
|
+
if method == "route":
|
|
73
|
+
http_methods: list[str] = ["GET"]
|
|
74
|
+
for kw in dec.keywords:
|
|
75
|
+
if kw.arg == "methods" and isinstance(kw.value, ast.List):
|
|
76
|
+
http_methods = [elt.value.upper() for elt in kw.value.elts
|
|
77
|
+
if isinstance(elt, ast.Constant)
|
|
78
|
+
and isinstance(elt.value, str)]
|
|
79
|
+
break
|
|
80
|
+
else:
|
|
81
|
+
http_methods = [method.upper()]
|
|
82
|
+
|
|
83
|
+
return {"framework": framework, "path": full_path, "methods": http_methods,
|
|
84
|
+
"params": params, "is_dynamic": is_dynamic}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _router_construction_prefixes(tree: ast.Module) -> dict[str, str]:
|
|
88
|
+
"""router = APIRouter(prefix='/items') → {'router': '/items'}.
|
|
89
|
+
|
|
90
|
+
FastAPI's documented way to scope a router — the prefix applies to every
|
|
91
|
+
decorator on that router, composed with any include_router(prefix=...) later.
|
|
92
|
+
"""
|
|
93
|
+
prefixes: dict[str, str] = {}
|
|
94
|
+
for stmt in tree.body:
|
|
95
|
+
if not (isinstance(stmt, ast.Assign) and isinstance(stmt.value, ast.Call)
|
|
96
|
+
and isinstance(stmt.value.func, ast.Name)
|
|
97
|
+
and stmt.value.func.id == "APIRouter"):
|
|
98
|
+
continue
|
|
99
|
+
for kw in stmt.value.keywords:
|
|
100
|
+
if kw.arg == "prefix" and isinstance(kw.value, ast.Constant) \
|
|
101
|
+
and isinstance(kw.value.value, str):
|
|
102
|
+
for t in stmt.targets:
|
|
103
|
+
if isinstance(t, ast.Name):
|
|
104
|
+
prefixes[t.id] = kw.value.value
|
|
105
|
+
return prefixes
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _blueprint_prefix_maps(parsed_files: list, lookup: LookupMaps) -> dict[str, dict[str, str]]:
|
|
109
|
+
"""Flask blueprints — prefix composition ACROSS files → {rel_path: {var: prefix}}.
|
|
110
|
+
|
|
111
|
+
Flask scatters the pieces: `auth = Blueprint('auth', __name__)` in
|
|
112
|
+
app/auth/__init__.py, `@auth.route(...)` in app/auth/views.py (auth imported
|
|
113
|
+
there), and `app.register_blueprint(auth_blueprint, url_prefix='/auth')`
|
|
114
|
+
inside create_app() in app/__init__.py. Registration vars are linked to
|
|
115
|
+
blueprint NAMES via the single-blueprint-per-module heuristic; decorator
|
|
116
|
+
vars (local or imported) get the composed prefix.
|
|
117
|
+
"""
|
|
118
|
+
construction: dict[tuple[str, str], tuple[str, str]] = {} # (rel, var) → (name, prefix)
|
|
119
|
+
registration: dict[str, dict[str, str]] = {} # rel → {var: prefix}
|
|
120
|
+
|
|
121
|
+
for pf in parsed_files:
|
|
122
|
+
# ast.walk: registrations often live inside create_app()/factory functions
|
|
123
|
+
for stmt in ast.walk(pf.tree):
|
|
124
|
+
if isinstance(stmt, ast.Assign) and isinstance(stmt.value, ast.Call) \
|
|
125
|
+
and isinstance(stmt.value.func, ast.Name) \
|
|
126
|
+
and stmt.value.func.id == "Blueprint" and stmt.value.args \
|
|
127
|
+
and isinstance(stmt.value.args[0], ast.Constant) \
|
|
128
|
+
and isinstance(stmt.value.args[0].value, str):
|
|
129
|
+
name = stmt.value.args[0].value
|
|
130
|
+
cprefix = ""
|
|
131
|
+
for kw in stmt.value.keywords:
|
|
132
|
+
if kw.arg == "url_prefix" and isinstance(kw.value, ast.Constant) \
|
|
133
|
+
and isinstance(kw.value.value, str):
|
|
134
|
+
cprefix = kw.value.value
|
|
135
|
+
for t in stmt.targets:
|
|
136
|
+
if isinstance(t, ast.Name):
|
|
137
|
+
construction[(pf.rel_path, t.id)] = (name, cprefix)
|
|
138
|
+
elif isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call) \
|
|
139
|
+
and isinstance(stmt.value.func, ast.Attribute) \
|
|
140
|
+
and stmt.value.func.attr == "register_blueprint" \
|
|
141
|
+
and stmt.value.args and isinstance(stmt.value.args[0], ast.Name):
|
|
142
|
+
rprefix = ""
|
|
143
|
+
for kw in stmt.value.keywords:
|
|
144
|
+
if kw.arg == "url_prefix" and isinstance(kw.value, ast.Constant) \
|
|
145
|
+
and isinstance(kw.value.value, str):
|
|
146
|
+
rprefix = kw.value.value
|
|
147
|
+
registration.setdefault(pf.rel_path, {})[stmt.value.args[0].id] = rprefix
|
|
148
|
+
|
|
149
|
+
def constructions_in(rel: str) -> list[tuple[str, str, str]]:
|
|
150
|
+
return [(v, n, p) for (r, v), (n, p) in construction.items() if r == rel]
|
|
151
|
+
|
|
152
|
+
# link registration vars → blueprint names (single-construction heuristic)
|
|
153
|
+
by_name: dict[str, str] = {}
|
|
154
|
+
for rel, vars_map in registration.items():
|
|
155
|
+
symbols = lookup.symbol_maps.get(rel, {})
|
|
156
|
+
for var, rprefix in vars_map.items():
|
|
157
|
+
alias = symbols.get(var, "")
|
|
158
|
+
target_rel = alias[len("file::"):] if alias.startswith("file::") else rel
|
|
159
|
+
candidates = constructions_in(target_rel)
|
|
160
|
+
if len(candidates) == 1:
|
|
161
|
+
_, name, cprefix = candidates[0]
|
|
162
|
+
by_name[name] = cprefix + rprefix
|
|
163
|
+
|
|
164
|
+
# per-file var → prefix: local constructions AND imported blueprint refs
|
|
165
|
+
per_file: dict[str, dict[str, str]] = {}
|
|
166
|
+
for (rel, var), (name, cprefix) in construction.items():
|
|
167
|
+
per_file.setdefault(rel, {})[var] = by_name.get(name, cprefix)
|
|
168
|
+
for pf in parsed_files:
|
|
169
|
+
symbols = lookup.symbol_maps.get(pf.rel_path, {})
|
|
170
|
+
for var, alias in symbols.items():
|
|
171
|
+
if not alias.startswith("file::"):
|
|
172
|
+
continue
|
|
173
|
+
candidates = constructions_in(alias[len("file::"):])
|
|
174
|
+
if len(candidates) == 1:
|
|
175
|
+
_, name, cprefix = candidates[0]
|
|
176
|
+
per_file.setdefault(pf.rel_path, {})[var] = by_name.get(name, cprefix)
|
|
177
|
+
return per_file
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def detect_decorator_routes(parsed_files: list, lookup: LookupMaps,
|
|
181
|
+
fingerprint, result: RouteResult) -> None:
|
|
182
|
+
blueprint_maps = _blueprint_prefix_maps(parsed_files, lookup)
|
|
183
|
+
|
|
184
|
+
for pf in parsed_files:
|
|
185
|
+
# effective prefix = construction + include_router/register_blueprint
|
|
186
|
+
router_prefixes = _router_construction_prefixes(pf.tree)
|
|
187
|
+
include_prefixes = _include_router_prefixes(pf.tree)
|
|
188
|
+
prefixes = {
|
|
189
|
+
var: router_prefixes.get(var, "") + include_prefixes.get(var, "")
|
|
190
|
+
for var in set(router_prefixes) | set(include_prefixes)
|
|
191
|
+
}
|
|
192
|
+
prefixes.update(blueprint_maps.get(pf.rel_path, {}))
|
|
193
|
+
|
|
194
|
+
for node in ast.walk(pf.tree):
|
|
195
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
196
|
+
continue
|
|
197
|
+
handler_id = handler_node_id(pf.rel_path, node.name, lookup)
|
|
198
|
+
for dec in node.decorator_list:
|
|
199
|
+
route = _decorator_route(dec, prefixes, fingerprint)
|
|
200
|
+
if not route:
|
|
201
|
+
continue
|
|
202
|
+
for http_method in route["methods"]:
|
|
203
|
+
emit_route({
|
|
204
|
+
"type": "BACKEND_ROUTE",
|
|
205
|
+
"urlPath": route["path"],
|
|
206
|
+
"filePath": pf.rel_path,
|
|
207
|
+
"httpMethod": http_method,
|
|
208
|
+
"handlerName": node.name,
|
|
209
|
+
"framework": route["framework"],
|
|
210
|
+
"isDynamic": route["is_dynamic"],
|
|
211
|
+
"params": route["params"],
|
|
212
|
+
}, pf.rel_path, handler_id, result)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Django urlpatterns — path/re_path/url + include() recursion.
|
|
2
|
+
|
|
3
|
+
urlpatterns = [path("users/<int:id>/", views.get_user), path("api/", include("app.urls"))]
|
|
4
|
+
Views resolved through symbol maps + lookup.nodes_by_file (function or CBV class).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from ...lookup import LookupMaps, module_name
|
|
12
|
+
from .common import RouteResult, emit_route
|
|
13
|
+
|
|
14
|
+
DJANGO_FUNCS = {"path", "re_path", "url"} # urlpatterns callables
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_django_view(view_expr: ast.AST, rel_path: str,
|
|
18
|
+
lookup: LookupMaps) -> str | None:
|
|
19
|
+
"""views.get_user / get_user / UserView.as_view() → handler node id."""
|
|
20
|
+
if isinstance(view_expr, ast.Name):
|
|
21
|
+
name = view_expr.id
|
|
22
|
+
same_file = lookup.nodes_by_file.get(rel_path, {}).get(name)
|
|
23
|
+
if same_file:
|
|
24
|
+
return same_file
|
|
25
|
+
alias = lookup.symbol_maps.get(rel_path, {}).get(name)
|
|
26
|
+
if alias and alias.startswith("file::"):
|
|
27
|
+
return lookup.nodes_by_file.get(alias[len("file::"):], {}).get(name)
|
|
28
|
+
return alias # node id or None
|
|
29
|
+
if isinstance(view_expr, ast.Attribute):
|
|
30
|
+
root = view_expr.value.id if isinstance(view_expr.value, ast.Name) else None
|
|
31
|
+
if root:
|
|
32
|
+
alias = lookup.symbol_maps.get(rel_path, {}).get(root)
|
|
33
|
+
if alias and alias.startswith("file::"):
|
|
34
|
+
return lookup.nodes_by_file.get(alias[len("file::"):], {}).get(view_expr.attr)
|
|
35
|
+
return alias
|
|
36
|
+
return None
|
|
37
|
+
if isinstance(view_expr, ast.Call): # UserView.as_view()
|
|
38
|
+
f = view_expr.func
|
|
39
|
+
if isinstance(f, ast.Attribute) and f.attr == "as_view":
|
|
40
|
+
return resolve_django_view(f.value, rel_path, lookup)
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _view_name(view_expr: ast.AST) -> str | None:
|
|
45
|
+
if isinstance(view_expr, ast.Name):
|
|
46
|
+
return view_expr.id
|
|
47
|
+
if isinstance(view_expr, ast.Attribute):
|
|
48
|
+
return view_expr.attr
|
|
49
|
+
if isinstance(view_expr, ast.Call):
|
|
50
|
+
return _view_name(view_expr.func)
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _list_elements(value: ast.expr) -> list[ast.expr]:
|
|
55
|
+
"""Flatten List and BinOp(Add) chains: `[...] + static(...)` → [...] elements.
|
|
56
|
+
Non-list operands (calls, etc.) contribute nothing."""
|
|
57
|
+
if isinstance(value, ast.List):
|
|
58
|
+
return value.elts
|
|
59
|
+
if isinstance(value, ast.BinOp) and isinstance(value.op, ast.Add):
|
|
60
|
+
return _list_elements(value.left) + _list_elements(value.right)
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _collect_urlpatterns(pf) -> list[ast.expr]:
|
|
65
|
+
"""All urlpatterns elements across every assignment form:
|
|
66
|
+
`urlpatterns = [...]` · `urlpatterns = [...] + other` · `urlpatterns += [...]`."""
|
|
67
|
+
elements: list[ast.expr] = []
|
|
68
|
+
for stmt in pf.tree.body:
|
|
69
|
+
if isinstance(stmt, ast.Assign) and isinstance(stmt.value, (ast.List, ast.BinOp)) \
|
|
70
|
+
and any(isinstance(t, ast.Name) and t.id == "urlpatterns"
|
|
71
|
+
for t in stmt.targets):
|
|
72
|
+
elements.extend(_list_elements(stmt.value))
|
|
73
|
+
elif isinstance(stmt, ast.AugAssign) and isinstance(stmt.op, ast.Add) \
|
|
74
|
+
and isinstance(stmt.target, ast.Name) and stmt.target.id == "urlpatterns" \
|
|
75
|
+
and isinstance(stmt.value, (ast.List, ast.BinOp)):
|
|
76
|
+
elements.extend(_list_elements(stmt.value))
|
|
77
|
+
return elements
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _include_module(inc_arg: ast.AST, rel_path: str, lookup: LookupMaps) -> str | None:
|
|
81
|
+
"""Module name from include() arg: "app.urls" · ("app.urls", "ns") · module_obj."""
|
|
82
|
+
if isinstance(inc_arg, ast.Tuple) and inc_arg.elts:
|
|
83
|
+
inc_arg = inc_arg.elts[0] # include((module, namespace))
|
|
84
|
+
if isinstance(inc_arg, ast.Constant) and isinstance(inc_arg.value, str):
|
|
85
|
+
return inc_arg.value
|
|
86
|
+
if isinstance(inc_arg, ast.Name):
|
|
87
|
+
alias = lookup.symbol_maps.get(rel_path, {}).get(inc_arg.id)
|
|
88
|
+
if alias and alias.startswith("file::"):
|
|
89
|
+
return module_name(alias[len("file::"):])
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _expand_urlpatterns(module: str, rel_path: str, url_list: list[ast.expr],
|
|
94
|
+
url_modules: dict, local_lists: dict,
|
|
95
|
+
lookup: LookupMaps,
|
|
96
|
+
result: RouteResult, visited: set, prefix: str,
|
|
97
|
+
depth: int = 0) -> None:
|
|
98
|
+
# visited is keyed by (module, prefix): the SAME module mounted at two
|
|
99
|
+
# prefixes yields different routes, so each (module, prefix) expands once.
|
|
100
|
+
# depth caps include cycles (a → b → a).
|
|
101
|
+
key = (module, prefix)
|
|
102
|
+
if key in visited or depth > 6:
|
|
103
|
+
return
|
|
104
|
+
visited.add(key)
|
|
105
|
+
|
|
106
|
+
for elt in url_list:
|
|
107
|
+
if not isinstance(elt, ast.Call):
|
|
108
|
+
continue
|
|
109
|
+
fname = elt.func.id if isinstance(elt.func, ast.Name) else None
|
|
110
|
+
if fname not in DJANGO_FUNCS:
|
|
111
|
+
continue
|
|
112
|
+
if len(elt.args) < 2 or not isinstance(elt.args[0], ast.Constant) \
|
|
113
|
+
or not isinstance(elt.args[0].value, str):
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
route: str = elt.args[0].value # bind narrowed constant (Pyright _ConstantValue fix)
|
|
117
|
+
view_expr = elt.args[1]
|
|
118
|
+
full_path = f"{prefix}{route}" if prefix else route
|
|
119
|
+
|
|
120
|
+
# include(...) → recurse with the route as prefix
|
|
121
|
+
if isinstance(view_expr, ast.Call) and isinstance(view_expr.func, ast.Name) \
|
|
122
|
+
and view_expr.func.id == "include" and view_expr.args:
|
|
123
|
+
inc_arg = view_expr.args[0]
|
|
124
|
+
if isinstance(inc_arg, ast.Tuple) and inc_arg.elts:
|
|
125
|
+
inc_arg = inc_arg.elts[0] # include((module, namespace))
|
|
126
|
+
|
|
127
|
+
if isinstance(inc_arg, ast.List):
|
|
128
|
+
# inline urlpatterns: include(([path(...), ...], "ns")) — Django's
|
|
129
|
+
# sub-group nesting. id() makes the visited key unique per list.
|
|
130
|
+
_expand_urlpatterns(f"{module}:inline:{id(inc_arg)}", rel_path,
|
|
131
|
+
inc_arg.elts, url_modules, local_lists, lookup,
|
|
132
|
+
result, visited, full_path, depth + 1)
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
if isinstance(inc_arg, ast.Name):
|
|
136
|
+
# local list variable: login_urlpatterns = [...] → include(login_urlpatterns)
|
|
137
|
+
local = local_lists.get((module, inc_arg.id))
|
|
138
|
+
if local:
|
|
139
|
+
_expand_urlpatterns(f"{module}:{inc_arg.id}", local[0], local[1],
|
|
140
|
+
url_modules, local_lists, lookup, result,
|
|
141
|
+
visited, full_path, depth + 1)
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
target_module = _include_module(inc_arg, rel_path, lookup)
|
|
145
|
+
if target_module and target_module in url_modules:
|
|
146
|
+
t_rel, t_list = url_modules[target_module]
|
|
147
|
+
_expand_urlpatterns(target_module, t_rel, t_list, url_modules,
|
|
148
|
+
local_lists, lookup, result, visited,
|
|
149
|
+
full_path, depth + 1)
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
params = re.findall(r"<(?:\w+:)?([A-Za-z_]\w*)>", full_path)
|
|
153
|
+
if fname == "re_path": # named regex groups count as params
|
|
154
|
+
params = re.findall(r"\(\?P<([A-Za-z_]\w*)>", route) or params
|
|
155
|
+
is_dynamic = bool(params) or "<" in full_path or "(" in full_path
|
|
156
|
+
|
|
157
|
+
emit_route({
|
|
158
|
+
"type": "BACKEND_ROUTE",
|
|
159
|
+
"urlPath": full_path,
|
|
160
|
+
"filePath": rel_path,
|
|
161
|
+
"httpMethod": "GET", # Django has no per-route method
|
|
162
|
+
"handlerName": _view_name(view_expr),
|
|
163
|
+
"framework": "django",
|
|
164
|
+
"isDynamic": is_dynamic,
|
|
165
|
+
"params": params,
|
|
166
|
+
}, rel_path, resolve_django_view(view_expr, rel_path, lookup), result)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def detect_django_urlpatterns(parsed_files: list, lookup: LookupMaps,
|
|
170
|
+
result: RouteResult) -> None:
|
|
171
|
+
url_modules: dict[str, tuple[str, list[ast.expr]]] = {}
|
|
172
|
+
# module-level list variables: login_urlpatterns = [...] → include(login_urlpatterns)
|
|
173
|
+
local_lists: dict[tuple[str, str], tuple[str, list[ast.expr]]] = {}
|
|
174
|
+
|
|
175
|
+
for pf in parsed_files:
|
|
176
|
+
mod = module_name(pf.rel_path)
|
|
177
|
+
elements = _collect_urlpatterns(pf)
|
|
178
|
+
if elements:
|
|
179
|
+
url_modules[mod] = (pf.rel_path, elements)
|
|
180
|
+
for stmt in pf.tree.body:
|
|
181
|
+
if isinstance(stmt, ast.Assign) and isinstance(stmt.value, (ast.List, ast.BinOp)):
|
|
182
|
+
for t in stmt.targets:
|
|
183
|
+
if isinstance(t, ast.Name) and t.id != "urlpatterns":
|
|
184
|
+
local_lists[(mod, t.id)] = (pf.rel_path, _list_elements(stmt.value))
|
|
185
|
+
|
|
186
|
+
# Modules referenced by include(...) are NOT roots — they are mounted
|
|
187
|
+
# somewhere else and only ever reached via include (Django semantics).
|
|
188
|
+
included: set[str] = set()
|
|
189
|
+
|
|
190
|
+
def scan_includes(rel_path: str, elements: list[ast.expr]) -> None:
|
|
191
|
+
for elt in elements:
|
|
192
|
+
if not isinstance(elt, ast.Call) or len(elt.args) < 2:
|
|
193
|
+
continue
|
|
194
|
+
view_expr = elt.args[1]
|
|
195
|
+
if not (isinstance(view_expr, ast.Call)
|
|
196
|
+
and isinstance(view_expr.func, ast.Name)
|
|
197
|
+
and view_expr.func.id == "include" and view_expr.args):
|
|
198
|
+
continue
|
|
199
|
+
target = _include_module(view_expr.args[0], rel_path, lookup)
|
|
200
|
+
if target:
|
|
201
|
+
included.add(target)
|
|
202
|
+
|
|
203
|
+
for _, (rel_path, elements) in url_modules.items():
|
|
204
|
+
scan_includes(rel_path, elements)
|
|
205
|
+
for (mod, name), (rel_path, elements) in local_lists.items():
|
|
206
|
+
scan_includes(rel_path, elements)
|
|
207
|
+
|
|
208
|
+
visited: set[tuple[str, str]] = set()
|
|
209
|
+
for module, (rel_path, elements) in url_modules.items():
|
|
210
|
+
if module in included:
|
|
211
|
+
continue # not a root — only reached via include
|
|
212
|
+
_expand_urlpatterns(module, rel_path, elements, url_modules, local_lists,
|
|
213
|
+
lookup, result, visited, prefix="")
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""DRF routers — rest_framework.routers.
|
|
2
|
+
|
|
3
|
+
router = DefaultRouter(); router.register("users", UserViewSet)
|
|
4
|
+
→ list route (GET/POST /prefix/) + detail route (GET/PUT/PATCH/DELETE /prefix/{pk}/)
|
|
5
|
+
depending on the viewset kind (ModelViewSet / ReadOnlyModelViewSet / ViewSet).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
|
|
11
|
+
from ...lookup import LookupMaps
|
|
12
|
+
from .common import RouteResult, emit_route
|
|
13
|
+
from .django_urls import resolve_django_view
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _drf_viewset_kind(viewset_node: dict, lookup: LookupMaps) -> str | None:
|
|
17
|
+
"""'model' | 'readonly' | 'plain' | None — how to expand a DRF viewset."""
|
|
18
|
+
rel = viewset_node["filePath"]
|
|
19
|
+
symbols = lookup.symbol_maps.get(rel, {})
|
|
20
|
+
for base in viewset_node["metadata"].get("bases", []):
|
|
21
|
+
root = base.split(".")[-1] # viewsets.ModelViewSet → ModelViewSet
|
|
22
|
+
alias = symbols.get(root, "")
|
|
23
|
+
is_drf = "rest_framework" in str(alias) or root in (
|
|
24
|
+
"ModelViewSet", "ReadOnlyModelViewSet", "ViewSet", "GenericViewSet")
|
|
25
|
+
if not is_drf:
|
|
26
|
+
continue
|
|
27
|
+
if root == "ModelViewSet":
|
|
28
|
+
return "model"
|
|
29
|
+
if root == "ReadOnlyModelViewSet":
|
|
30
|
+
return "readonly"
|
|
31
|
+
return "plain"
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _drf_routes_for_viewset(prefix: str, viewset_node: dict, lookup: LookupMaps,
|
|
36
|
+
result: RouteResult, rel_path: str) -> None:
|
|
37
|
+
"""Expand one registered viewset → list + detail routes (HANDLES → CLASS)."""
|
|
38
|
+
kind = _drf_viewset_kind(viewset_node, lookup)
|
|
39
|
+
if kind is None:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
prefix = prefix.strip("/")
|
|
43
|
+
list_path = f"/{prefix}/" if prefix else "/"
|
|
44
|
+
detail_path = f"/{prefix}/{{pk}}/" if prefix else "/{pk}/"
|
|
45
|
+
|
|
46
|
+
if kind == "model":
|
|
47
|
+
specs = [("GET", list_path), ("POST", list_path),
|
|
48
|
+
("GET", detail_path), ("PUT", detail_path),
|
|
49
|
+
("PATCH", detail_path), ("DELETE", detail_path)]
|
|
50
|
+
else: # readonly + plain → read-only list & detail
|
|
51
|
+
specs = [("GET", list_path), ("GET", detail_path)]
|
|
52
|
+
|
|
53
|
+
for method, path in specs:
|
|
54
|
+
emit_route({
|
|
55
|
+
"type": "BACKEND_ROUTE",
|
|
56
|
+
"urlPath": path,
|
|
57
|
+
"filePath": rel_path,
|
|
58
|
+
"httpMethod": method,
|
|
59
|
+
"handlerName": viewset_node["name"],
|
|
60
|
+
"framework": "django",
|
|
61
|
+
"isDynamic": "{pk}" in path,
|
|
62
|
+
"params": ["pk"] if "{pk}" in path else [],
|
|
63
|
+
}, rel_path, viewset_node["id"], result)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def detect_drf_routers(parsed_files: list, lookup: LookupMaps,
|
|
67
|
+
result: RouteResult) -> None:
|
|
68
|
+
"""DefaultRouter().register(prefix, viewset) → expanded CRUD routes."""
|
|
69
|
+
for pf in parsed_files:
|
|
70
|
+
routers: set[str] = set()
|
|
71
|
+
include_prefixes: dict[str, str] = {}
|
|
72
|
+
|
|
73
|
+
# pass 1: router = DefaultRouter() + path("api/", include(router.urls))
|
|
74
|
+
for stmt in pf.tree.body:
|
|
75
|
+
if isinstance(stmt, ast.Assign) and isinstance(stmt.value, ast.Call) \
|
|
76
|
+
and isinstance(stmt.value.func, ast.Name) \
|
|
77
|
+
and stmt.value.func.id in ("DefaultRouter", "SimpleRouter"):
|
|
78
|
+
for t in stmt.targets:
|
|
79
|
+
if isinstance(t, ast.Name):
|
|
80
|
+
routers.add(t.id)
|
|
81
|
+
|
|
82
|
+
if isinstance(stmt, ast.Assign) and isinstance(stmt.value, ast.List):
|
|
83
|
+
for elt in stmt.value.elts:
|
|
84
|
+
if not (isinstance(elt, ast.Call) and len(elt.args) >= 2
|
|
85
|
+
and isinstance(elt.func, ast.Name)
|
|
86
|
+
and elt.func.id == "path"
|
|
87
|
+
and isinstance(elt.args[0], ast.Constant)
|
|
88
|
+
and isinstance(elt.args[0].value, str)):
|
|
89
|
+
continue
|
|
90
|
+
# bind the narrowed constant immediately — Pyright loses
|
|
91
|
+
# _ConstantValue → str narrowing on elt.args[0].value chains
|
|
92
|
+
route_prefix: str = elt.args[0].value
|
|
93
|
+
inc = elt.args[1]
|
|
94
|
+
if isinstance(inc, ast.Call) and isinstance(inc.func, ast.Name) \
|
|
95
|
+
and inc.func.id == "include" and inc.args \
|
|
96
|
+
and isinstance(inc.args[0], ast.Attribute) \
|
|
97
|
+
and isinstance(inc.args[0].value, ast.Name) \
|
|
98
|
+
and inc.args[0].attr == "urls":
|
|
99
|
+
include_prefixes[inc.args[0].value.id] = route_prefix
|
|
100
|
+
|
|
101
|
+
if not routers:
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
# pass 2: router.register("users", UserViewSet)
|
|
105
|
+
for stmt in pf.tree.body:
|
|
106
|
+
if not (isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call)):
|
|
107
|
+
continue
|
|
108
|
+
call = stmt.value
|
|
109
|
+
if not (isinstance(call.func, ast.Attribute)
|
|
110
|
+
and call.func.attr == "register"
|
|
111
|
+
and isinstance(call.func.value, ast.Name)
|
|
112
|
+
and call.func.value.id in routers):
|
|
113
|
+
continue
|
|
114
|
+
if len(call.args) < 2 or not isinstance(call.args[0], ast.Constant) \
|
|
115
|
+
or not isinstance(call.args[0].value, str):
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
prefix = call.args[0].value
|
|
119
|
+
viewset_id = resolve_django_view(call.args[1], pf.rel_path, lookup)
|
|
120
|
+
if not viewset_id:
|
|
121
|
+
continue
|
|
122
|
+
viewset_node = lookup.node_by_id.get(viewset_id)
|
|
123
|
+
if not viewset_node or viewset_node["type"] != "CLASS":
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
base = include_prefixes.get(call.func.value.id, "")
|
|
127
|
+
_drf_routes_for_viewset(f"{base}{prefix}", viewset_node, lookup,
|
|
128
|
+
result, pf.rel_path)
|