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.
Files changed (129) hide show
  1. package/README.md +89 -31
  2. package/dist/extractors/detectLanguage.d.ts +2 -0
  3. package/dist/extractors/detectLanguage.js +33 -0
  4. package/dist/extractors/index.d.ts +6 -0
  5. package/dist/extractors/index.js +116 -0
  6. package/dist/extractors/runner.d.ts +7 -0
  7. package/dist/extractors/runner.js +194 -0
  8. package/dist/extractors/types.d.ts +37 -0
  9. package/dist/extractors/types.js +8 -0
  10. package/dist/graph/buildLookup.d.ts +1 -0
  11. package/dist/graph/buildLookup.js +2 -1
  12. package/dist/graph/edges/callEdges.js +19 -5
  13. package/dist/graph/edges/callEdges.test.d.ts +1 -0
  14. package/dist/graph/edges/callEdges.test.js +200 -0
  15. package/dist/graph/edges/importEdges.js +12 -0
  16. package/dist/graph/edges/inheritanceEdges.d.ts +3 -0
  17. package/dist/graph/edges/inheritanceEdges.js +73 -0
  18. package/dist/graph/edges/inheritanceEdges.test.d.ts +1 -0
  19. package/dist/graph/edges/inheritanceEdges.test.js +140 -0
  20. package/dist/graph/index.js +4 -0
  21. package/dist/parser/classes.test.d.ts +1 -0
  22. package/dist/parser/classes.test.js +360 -0
  23. package/dist/parser/extractors/classes.d.ts +5 -0
  24. package/dist/parser/extractors/classes.js +241 -0
  25. package/dist/parser/index.d.ts +2 -0
  26. package/dist/parser/index.js +7 -1
  27. package/dist/pipeline/index.d.ts +2 -1
  28. package/dist/pipeline/index.js +36 -48
  29. package/dist/scoring/index.js +8 -0
  30. package/dist/scoring/index.test.js +43 -0
  31. package/dist/scoring/nodeScorer.js +2 -0
  32. package/dist/scoring/pruneDisconnected.d.ts +8 -0
  33. package/dist/scoring/pruneDisconnected.js +66 -0
  34. package/dist/scoring/pruneDisconnected.test.d.ts +1 -0
  35. package/dist/scoring/pruneDisconnected.test.js +123 -0
  36. package/dist/summarizer/prompts.d.ts +1 -1
  37. package/dist/types.d.ts +5 -5
  38. package/extractors/go/bin/darwin-amd64/devlens_go_extractor +0 -0
  39. package/extractors/go/bin/darwin-arm64/devlens_go_extractor +0 -0
  40. package/extractors/go/bin/linux-amd64/devlens_go_extractor +0 -0
  41. package/extractors/go/bin/linux-arm64/devlens_go_extractor +0 -0
  42. package/extractors/go/bin/windows-amd64/devlens_go_extractor.exe +0 -0
  43. package/extractors/go/build.mjs +43 -0
  44. package/extractors/go/calls.go +289 -0
  45. package/extractors/go/contract.go +140 -0
  46. package/extractors/go/extractor.go +161 -0
  47. package/extractors/go/fingerprint.go +167 -0
  48. package/extractors/go/go.mod +3 -0
  49. package/extractors/go/imports.go +90 -0
  50. package/extractors/go/inheritance.go +138 -0
  51. package/extractors/go/lookup.go +106 -0
  52. package/extractors/go/main.go +57 -0
  53. package/extractors/go/nodes.go +177 -0
  54. package/extractors/go/orm_edges.go +277 -0
  55. package/extractors/go/parser.go +469 -0
  56. package/extractors/go/routes.go +521 -0
  57. package/extractors/go/tests.go +45 -0
  58. package/extractors/go/thirdparty.go +138 -0
  59. package/extractors/go/typeload.go +178 -0
  60. package/extractors/go/walker.go +67 -0
  61. package/extractors/java/build.mjs +90 -0
  62. package/extractors/java/devlens_java_extractor.jar +0 -0
  63. package/extractors/java/src/devlens/extractor/Contract.java +171 -0
  64. package/extractors/java/src/devlens/extractor/Extractor.java +262 -0
  65. package/extractors/java/src/devlens/extractor/ExtractorResult.java +12 -0
  66. package/extractors/java/src/devlens/extractor/Fingerprint.java +239 -0
  67. package/extractors/java/src/devlens/extractor/LookupMaps.java +123 -0
  68. package/extractors/java/src/devlens/extractor/Main.java +66 -0
  69. package/extractors/java/src/devlens/extractor/Parser.java +522 -0
  70. package/extractors/java/src/devlens/extractor/SourceWalker.java +82 -0
  71. package/extractors/java/src/devlens/extractor/ThirdParty.java +141 -0
  72. package/extractors/java/src/devlens/extractor/TypeSolverFactory.java +43 -0
  73. package/extractors/java/src/devlens/extractor/edges/Calls.java +266 -0
  74. package/extractors/java/src/devlens/extractor/edges/Enrich.java +54 -0
  75. package/extractors/java/src/devlens/extractor/edges/Imports.java +154 -0
  76. package/extractors/java/src/devlens/extractor/edges/Inheritance.java +79 -0
  77. package/extractors/java/src/devlens/extractor/edges/OrmEdges.java +209 -0
  78. package/extractors/java/src/devlens/extractor/edges/Routes.java +143 -0
  79. package/extractors/java/src/devlens/extractor/edges/Tests.java +53 -0
  80. package/extractors/python/devlens_extractors_python/__init__.py +3 -0
  81. package/extractors/python/devlens_extractors_python/__main__.py +33 -0
  82. package/extractors/python/devlens_extractors_python/contract.py +94 -0
  83. package/extractors/python/devlens_extractors_python/edges/__init__.py +28 -0
  84. package/extractors/python/devlens_extractors_python/edges/calls.py +112 -0
  85. package/extractors/python/devlens_extractors_python/edges/enrich.py +44 -0
  86. package/extractors/python/devlens_extractors_python/edges/imports.py +155 -0
  87. package/extractors/python/devlens_extractors_python/edges/inheritance.py +97 -0
  88. package/extractors/python/devlens_extractors_python/edges/orm_edges.py +225 -0
  89. package/extractors/python/devlens_extractors_python/edges/routes/__init__.py +28 -0
  90. package/extractors/python/devlens_extractors_python/edges/routes/common.py +79 -0
  91. package/extractors/python/devlens_extractors_python/edges/routes/decorators.py +212 -0
  92. package/extractors/python/devlens_extractors_python/edges/routes/django_urls.py +213 -0
  93. package/extractors/python/devlens_extractors_python/edges/routes/drf.py +128 -0
  94. package/extractors/python/devlens_extractors_python/edges/tests.py +53 -0
  95. package/extractors/python/devlens_extractors_python/extractor.py +107 -0
  96. package/extractors/python/devlens_extractors_python/fingerprint.py +201 -0
  97. package/extractors/python/devlens_extractors_python/lookup.py +72 -0
  98. package/extractors/python/devlens_extractors_python/parser/__init__.py +72 -0
  99. package/extractors/python/devlens_extractors_python/parser/classes.py +109 -0
  100. package/extractors/python/devlens_extractors_python/parser/functions.py +163 -0
  101. package/extractors/python/devlens_extractors_python/parser/walker.py +30 -0
  102. package/extractors/python/devlens_extractors_python/third_party.py +103 -0
  103. package/extractors/python/pyproject.toml +16 -0
  104. package/extractors/python/setup.mjs +54 -0
  105. package/extractors/rust/Cargo.toml +29 -0
  106. package/extractors/rust/bin/darwin-amd64/devlens_rust_extractor +0 -0
  107. package/extractors/rust/bin/darwin-arm64/devlens_rust_extractor +0 -0
  108. package/extractors/rust/bin/linux-amd64/devlens_rust_extractor +0 -0
  109. package/extractors/rust/bin/linux-arm64/devlens_rust_extractor +0 -0
  110. package/extractors/rust/bin/windows-amd64/devlens_rust_extractor.exe +0 -0
  111. package/extractors/rust/build.mjs +85 -0
  112. package/extractors/rust/src/calls.rs +295 -0
  113. package/extractors/rust/src/contract.rs +236 -0
  114. package/extractors/rust/src/enrich.rs +107 -0
  115. package/extractors/rust/src/extractor.rs +199 -0
  116. package/extractors/rust/src/fingerprint.rs +238 -0
  117. package/extractors/rust/src/imports.rs +101 -0
  118. package/extractors/rust/src/inheritance.rs +233 -0
  119. package/extractors/rust/src/lookup.rs +226 -0
  120. package/extractors/rust/src/main.rs +53 -0
  121. package/extractors/rust/src/module_map.rs +131 -0
  122. package/extractors/rust/src/nodes.rs +174 -0
  123. package/extractors/rust/src/orm_edges.rs +125 -0
  124. package/extractors/rust/src/parser.rs +859 -0
  125. package/extractors/rust/src/routes.rs +1265 -0
  126. package/extractors/rust/src/tests.rs +109 -0
  127. package/extractors/rust/src/thirdparty.rs +114 -0
  128. package/extractors/rust/src/walker.rs +82 -0
  129. 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)