commiter-cli 0.3.0__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.
- commiter/__init__.py +3 -0
- commiter/adapters/__init__.py +0 -0
- commiter/adapters/base.py +96 -0
- commiter/adapters/django_rest.py +247 -0
- commiter/adapters/express.py +204 -0
- commiter/adapters/fastapi.py +170 -0
- commiter/adapters/flask.py +169 -0
- commiter/adapters/nextjs.py +180 -0
- commiter/adapters/prisma.py +76 -0
- commiter/adapters/raw_sql.py +191 -0
- commiter/adapters/react.py +129 -0
- commiter/adapters/sqlalchemy.py +99 -0
- commiter/adapters/supabase.py +68 -0
- commiter/auth.py +130 -0
- commiter/cli.py +667 -0
- commiter/correlator.py +208 -0
- commiter/extractors/__init__.py +0 -0
- commiter/extractors/api_calls.py +91 -0
- commiter/extractors/api_endpoints.py +354 -0
- commiter/extractors/backend_files.py +33 -0
- commiter/extractors/base.py +40 -0
- commiter/extractors/db_operations.py +69 -0
- commiter/extractors/dependencies.py +219 -0
- commiter/generic_resolver.py +204 -0
- commiter/handler_index.py +97 -0
- commiter/lib.py +63 -0
- commiter/middleware_index.py +350 -0
- commiter/models.py +117 -0
- commiter/parser.py +1283 -0
- commiter/prefix_index.py +211 -0
- commiter/report/__init__.py +0 -0
- commiter/report/ai.py +120 -0
- commiter/report/api_guide.py +217 -0
- commiter/report/architecture.py +930 -0
- commiter/report/console.py +254 -0
- commiter/report/json_output.py +122 -0
- commiter/report/markdown.py +163 -0
- commiter/scanner.py +383 -0
- commiter/type_index.py +304 -0
- commiter/uploader.py +46 -0
- commiter/utils/__init__.py +0 -0
- commiter/utils/env_reader.py +78 -0
- commiter/utils/file_classifier.py +187 -0
- commiter/utils/path_helpers.py +73 -0
- commiter/utils/tsconfig_resolver.py +281 -0
- commiter/wrapper_index.py +288 -0
- commiter_cli-0.3.0.dist-info/METADATA +14 -0
- commiter_cli-0.3.0.dist-info/RECORD +96 -0
- commiter_cli-0.3.0.dist-info/WHEEL +5 -0
- commiter_cli-0.3.0.dist-info/entry_points.txt +2 -0
- commiter_cli-0.3.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/fixtures/arch_backend/app.py +22 -0
- tests/fixtures/arch_backend/middleware/__init__.py +0 -0
- tests/fixtures/arch_backend/middleware/rate_limit.py +4 -0
- tests/fixtures/arch_backend/routes/__init__.py +0 -0
- tests/fixtures/arch_backend/routes/analytics.py +20 -0
- tests/fixtures/arch_backend/routes/auth.py +29 -0
- tests/fixtures/arch_backend/routes/projects.py +60 -0
- tests/fixtures/arch_backend/routes/users.py +55 -0
- tests/fixtures/arch_monorepo/apps/api/app.py +30 -0
- tests/fixtures/arch_monorepo/apps/api/middleware/__init__.py +0 -0
- tests/fixtures/arch_monorepo/apps/api/middleware/auth.py +17 -0
- tests/fixtures/arch_monorepo/apps/api/middleware/rate_limit.py +10 -0
- tests/fixtures/arch_monorepo/apps/api/routes/__init__.py +0 -0
- tests/fixtures/arch_monorepo/apps/api/routes/auth.py +46 -0
- tests/fixtures/arch_monorepo/apps/api/routes/invites.py +30 -0
- tests/fixtures/arch_monorepo/apps/api/routes/notifications.py +25 -0
- tests/fixtures/arch_monorepo/apps/api/routes/projects.py +80 -0
- tests/fixtures/arch_monorepo/apps/api/routes/tasks.py +91 -0
- tests/fixtures/arch_monorepo/apps/api/routes/users.py +48 -0
- tests/fixtures/arch_monorepo/apps/api/services/__init__.py +0 -0
- tests/fixtures/arch_monorepo/apps/api/services/email.py +11 -0
- tests/fixtures/backend_b/app.py +17 -0
- tests/fixtures/fastapi_app/app.py +48 -0
- tests/fixtures/fastapi_crossfile/routes.py +18 -0
- tests/fixtures/fastapi_crossfile/schemas.py +21 -0
- tests/fixtures/flask_app/app.py +33 -0
- tests/fixtures/flask_blueprint/app.py +7 -0
- tests/fixtures/flask_blueprint/routes/items.py +13 -0
- tests/fixtures/flask_blueprint/routes/users.py +20 -0
- tests/fixtures/middleware_test_flask/routes/public.py +8 -0
- tests/fixtures/middleware_test_flask/routes/users.py +26 -0
- tests/fixtures/python_deep_imports/app/__init__.py +0 -0
- tests/fixtures/python_deep_imports/app/api/__init__.py +0 -0
- tests/fixtures/python_deep_imports/app/api/health.py +11 -0
- tests/fixtures/python_deep_imports/app/api/v1/__init__.py +0 -0
- tests/fixtures/python_deep_imports/app/api/v1/items.py +18 -0
- tests/fixtures/python_deep_imports/app/api/v1/users.py +27 -0
- tests/fixtures/python_deep_imports/app/schemas/__init__.py +0 -0
- tests/fixtures/python_deep_imports/app/schemas/item.py +13 -0
- tests/fixtures/python_deep_imports/app/schemas/user.py +15 -0
- tests/fixtures/python_deep_imports/app/shared/__init__.py +0 -0
- tests/fixtures/python_deep_imports/app/shared/models.py +7 -0
- tests/fixtures/raw_sql_test/app.py +54 -0
- tests/test_architecture.py +757 -0
commiter/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Base adapter interface — teaches extractors what framework patterns look like."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from tree_sitter import Node, Tree
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class RouteMatch:
|
|
15
|
+
"""A matched route definition in source code."""
|
|
16
|
+
http_method: str
|
|
17
|
+
route_pattern: str
|
|
18
|
+
handler_name: str
|
|
19
|
+
handler_node: "Node"
|
|
20
|
+
decorator_nodes: list["Node"] = field(default_factory=list)
|
|
21
|
+
line: int = 0
|
|
22
|
+
param_types: dict[str, str] = field(default_factory=dict)
|
|
23
|
+
router_var: str = "" # variable name the route is defined on (e.g. "bp", "router", "app")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class APICallMatch:
|
|
28
|
+
"""A matched outbound API call in frontend code."""
|
|
29
|
+
http_method: str
|
|
30
|
+
url_pattern: str
|
|
31
|
+
call_node: "Node"
|
|
32
|
+
line: int = 0
|
|
33
|
+
response_type: str | None = None
|
|
34
|
+
body_type: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class DBOpMatch:
|
|
39
|
+
"""A matched database operation."""
|
|
40
|
+
operation_type: str # select, insert, update, delete
|
|
41
|
+
table_name: str
|
|
42
|
+
call_node: "Node"
|
|
43
|
+
line: int = 0
|
|
44
|
+
filters: list[str] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BaseAdapter(ABC):
|
|
48
|
+
"""Abstract adapter that framework-specific adapters inherit from."""
|
|
49
|
+
|
|
50
|
+
framework_name: str = "base"
|
|
51
|
+
language: str = "unknown"
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def find_route_definitions(self, tree: "Tree", source: bytes) -> list[RouteMatch]:
|
|
55
|
+
"""Find all route/endpoint definitions in a parsed file."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
def extract_auth_info(self, route: RouteMatch, source: bytes) -> list[str]:
|
|
59
|
+
"""Find auth decorators/middleware on a handler. Override per framework."""
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
def extract_request_body_fields(self, route: RouteMatch, source: bytes) -> list[str]:
|
|
63
|
+
"""Infer request body field names from handler. Override per framework."""
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
def extract_response_fields(self, route: RouteMatch, source: bytes) -> list[str]:
|
|
67
|
+
"""Infer response field names from return statements. Override per framework."""
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
def extract_path_params(self, route_pattern: str) -> list[str]:
|
|
71
|
+
"""Extract path parameter names from a route pattern. Override per framework."""
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FrontendAdapter(ABC):
|
|
76
|
+
"""Adapter for detecting outbound API calls in frontend code."""
|
|
77
|
+
|
|
78
|
+
framework_name: str = "base-frontend"
|
|
79
|
+
language: str = "unknown"
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def find_api_calls(self, tree: "Tree", source: bytes) -> list[APICallMatch]:
|
|
83
|
+
"""Find outbound HTTP/API calls in frontend code."""
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class DBAdapter(ABC):
|
|
88
|
+
"""Adapter for detecting database operations."""
|
|
89
|
+
|
|
90
|
+
orm_name: str = "base-db"
|
|
91
|
+
language: str = "unknown"
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def find_db_operations(self, tree: "Tree", source: bytes) -> list[DBOpMatch]:
|
|
95
|
+
"""Find database operations."""
|
|
96
|
+
...
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Django REST Framework adapter — recognizes DRF ViewSet and APIView patterns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from commiter.adapters.base import BaseAdapter, RouteMatch
|
|
9
|
+
from commiter.parser import node_text, find_nodes_by_type
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from tree_sitter import Node, Tree
|
|
13
|
+
|
|
14
|
+
# DRF ViewSet action methods
|
|
15
|
+
VIEWSET_ACTIONS = {
|
|
16
|
+
"list": "GET",
|
|
17
|
+
"create": "POST",
|
|
18
|
+
"retrieve": "GET",
|
|
19
|
+
"update": "PUT",
|
|
20
|
+
"partial_update": "PATCH",
|
|
21
|
+
"destroy": "DELETE",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# DRF APIView method mapping
|
|
25
|
+
APIVIEW_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
|
|
26
|
+
|
|
27
|
+
# Common DRF auth/permission classes
|
|
28
|
+
AUTH_CLASSES = {
|
|
29
|
+
"IsAuthenticated", "IsAdminUser", "IsAuthenticatedOrReadOnly",
|
|
30
|
+
"AllowAny", "DjangoModelPermissions", "TokenAuthentication",
|
|
31
|
+
"SessionAuthentication", "JWTAuthentication",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DjangoRESTAdapter(BaseAdapter):
|
|
36
|
+
framework_name = "django-rest"
|
|
37
|
+
language = "python"
|
|
38
|
+
|
|
39
|
+
def find_route_definitions(self, tree: Tree, source: bytes) -> list[RouteMatch]:
|
|
40
|
+
routes = []
|
|
41
|
+
text = source.decode("utf-8", errors="replace")
|
|
42
|
+
|
|
43
|
+
# Strategy 1: Find ViewSet classes and their action methods
|
|
44
|
+
routes.extend(self._find_viewset_routes(tree, source, text))
|
|
45
|
+
|
|
46
|
+
# Strategy 2: Find APIView classes with get/post/put/delete methods
|
|
47
|
+
routes.extend(self._find_apiview_routes(tree, source, text))
|
|
48
|
+
|
|
49
|
+
# Strategy 3: Find @api_view decorated functions
|
|
50
|
+
routes.extend(self._find_api_view_functions(tree, source))
|
|
51
|
+
|
|
52
|
+
return routes
|
|
53
|
+
|
|
54
|
+
def _find_viewset_routes(self, tree: Tree, source: bytes, text: str) -> list[RouteMatch]:
|
|
55
|
+
routes = []
|
|
56
|
+
root = tree.root_node
|
|
57
|
+
|
|
58
|
+
for child in root.children:
|
|
59
|
+
if child.type != "class_definition":
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
class_text = node_text(child, source)
|
|
63
|
+
class_name = ""
|
|
64
|
+
name_node = child.child_by_field_name("name")
|
|
65
|
+
if name_node:
|
|
66
|
+
class_name = node_text(name_node, source)
|
|
67
|
+
|
|
68
|
+
# Check if it inherits from a ViewSet
|
|
69
|
+
superclasses = node_text(child.child_by_field_name("superclasses"), source) if child.child_by_field_name("superclasses") else ""
|
|
70
|
+
if not any(vs in superclasses for vs in ("ViewSet", "ModelViewSet", "GenericViewSet", "ReadOnlyModelViewSet")):
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
# Find action methods in the class body
|
|
74
|
+
body = child.child_by_field_name("body")
|
|
75
|
+
if not body:
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
for member in body.children:
|
|
79
|
+
if member.type == "function_definition":
|
|
80
|
+
method_name_node = member.child_by_field_name("name")
|
|
81
|
+
if not method_name_node:
|
|
82
|
+
continue
|
|
83
|
+
method_name = node_text(method_name_node, source)
|
|
84
|
+
|
|
85
|
+
if method_name in VIEWSET_ACTIONS:
|
|
86
|
+
http_method = VIEWSET_ACTIONS[method_name]
|
|
87
|
+
route = self._infer_viewset_route(class_name, method_name)
|
|
88
|
+
routes.append(RouteMatch(
|
|
89
|
+
http_method=http_method,
|
|
90
|
+
route_pattern=route,
|
|
91
|
+
handler_name=f"{class_name}.{method_name}",
|
|
92
|
+
handler_node=member,
|
|
93
|
+
line=member.start_point[0] + 1,
|
|
94
|
+
))
|
|
95
|
+
|
|
96
|
+
# Also check for decorated definitions (custom actions)
|
|
97
|
+
elif member.type == "decorated_definition":
|
|
98
|
+
decorators = []
|
|
99
|
+
func_node = None
|
|
100
|
+
for sub in member.children:
|
|
101
|
+
if sub.type == "decorator":
|
|
102
|
+
decorators.append(node_text(sub, source))
|
|
103
|
+
elif sub.type == "function_definition":
|
|
104
|
+
func_node = sub
|
|
105
|
+
|
|
106
|
+
if func_node:
|
|
107
|
+
func_name_node = func_node.child_by_field_name("name")
|
|
108
|
+
if func_name_node:
|
|
109
|
+
func_name = node_text(func_name_node, source)
|
|
110
|
+
for dec in decorators:
|
|
111
|
+
if "@action" in dec:
|
|
112
|
+
methods_match = re.search(r'methods\s*=\s*\[([^\]]+)\]', dec)
|
|
113
|
+
methods = ["POST"]
|
|
114
|
+
if methods_match:
|
|
115
|
+
methods = [m.strip().strip("\"'").upper() for m in methods_match.group(1).split(",")]
|
|
116
|
+
for m in methods:
|
|
117
|
+
route = self._infer_viewset_route(class_name, func_name)
|
|
118
|
+
routes.append(RouteMatch(
|
|
119
|
+
http_method=m,
|
|
120
|
+
route_pattern=f"{route}{func_name}/",
|
|
121
|
+
handler_name=f"{class_name}.{func_name}",
|
|
122
|
+
handler_node=func_node,
|
|
123
|
+
line=func_node.start_point[0] + 1,
|
|
124
|
+
))
|
|
125
|
+
|
|
126
|
+
return routes
|
|
127
|
+
|
|
128
|
+
def _find_apiview_routes(self, tree: Tree, source: bytes, text: str) -> list[RouteMatch]:
|
|
129
|
+
routes = []
|
|
130
|
+
root = tree.root_node
|
|
131
|
+
|
|
132
|
+
for child in root.children:
|
|
133
|
+
if child.type != "class_definition":
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
name_node = child.child_by_field_name("name")
|
|
137
|
+
if not name_node:
|
|
138
|
+
continue
|
|
139
|
+
class_name = node_text(name_node, source)
|
|
140
|
+
|
|
141
|
+
superclasses = child.child_by_field_name("superclasses")
|
|
142
|
+
if not superclasses:
|
|
143
|
+
continue
|
|
144
|
+
super_text = node_text(superclasses, source)
|
|
145
|
+
if "APIView" not in super_text:
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
body = child.child_by_field_name("body")
|
|
149
|
+
if not body:
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
for member in body.children:
|
|
153
|
+
if member.type == "function_definition":
|
|
154
|
+
method_name_node = member.child_by_field_name("name")
|
|
155
|
+
if not method_name_node:
|
|
156
|
+
continue
|
|
157
|
+
method_name = node_text(method_name_node, source)
|
|
158
|
+
|
|
159
|
+
if method_name in APIVIEW_METHODS:
|
|
160
|
+
route = self._infer_class_route(class_name)
|
|
161
|
+
routes.append(RouteMatch(
|
|
162
|
+
http_method=method_name.upper(),
|
|
163
|
+
route_pattern=route,
|
|
164
|
+
handler_name=f"{class_name}.{method_name}",
|
|
165
|
+
handler_node=member,
|
|
166
|
+
line=member.start_point[0] + 1,
|
|
167
|
+
))
|
|
168
|
+
|
|
169
|
+
return routes
|
|
170
|
+
|
|
171
|
+
def _find_api_view_functions(self, tree: Tree, source: bytes) -> list[RouteMatch]:
|
|
172
|
+
"""Find @api_view(['GET', 'POST']) decorated functions."""
|
|
173
|
+
routes = []
|
|
174
|
+
root = tree.root_node
|
|
175
|
+
|
|
176
|
+
for child in root.children:
|
|
177
|
+
if child.type != "decorated_definition":
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
decorators = []
|
|
181
|
+
func_node = None
|
|
182
|
+
for sub in child.children:
|
|
183
|
+
if sub.type == "decorator":
|
|
184
|
+
decorators.append(sub)
|
|
185
|
+
elif sub.type == "function_definition":
|
|
186
|
+
func_node = sub
|
|
187
|
+
|
|
188
|
+
if not func_node:
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
for dec in decorators:
|
|
192
|
+
dec_text = node_text(dec, source)
|
|
193
|
+
if "api_view" not in dec_text:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
methods_match = re.search(r'\[([^\]]+)\]', dec_text)
|
|
197
|
+
methods = ["GET"]
|
|
198
|
+
if methods_match:
|
|
199
|
+
methods = [m.strip().strip("\"'").upper() for m in methods_match.group(1).split(",")]
|
|
200
|
+
|
|
201
|
+
func_name_node = func_node.child_by_field_name("name")
|
|
202
|
+
handler_name = node_text(func_name_node, source) if func_name_node else "<anonymous>"
|
|
203
|
+
|
|
204
|
+
for method in methods:
|
|
205
|
+
routes.append(RouteMatch(
|
|
206
|
+
http_method=method,
|
|
207
|
+
route_pattern=f"/{handler_name.replace('_', '-')}/",
|
|
208
|
+
handler_name=handler_name,
|
|
209
|
+
handler_node=func_node,
|
|
210
|
+
decorator_nodes=decorators,
|
|
211
|
+
line=func_node.start_point[0] + 1,
|
|
212
|
+
))
|
|
213
|
+
|
|
214
|
+
return routes
|
|
215
|
+
|
|
216
|
+
def extract_auth_info(self, route: RouteMatch, source: bytes) -> list[str]:
|
|
217
|
+
"""Detect permission_classes and authentication_classes."""
|
|
218
|
+
auth = []
|
|
219
|
+
# Check if parent class has permission_classes
|
|
220
|
+
handler_text = node_text(route.handler_node, source) if route.handler_node else ""
|
|
221
|
+
|
|
222
|
+
# Check class-level attributes (look in parent)
|
|
223
|
+
parent = route.handler_node.parent if route.handler_node else None
|
|
224
|
+
if parent:
|
|
225
|
+
parent_text = node_text(parent, source)
|
|
226
|
+
for cls in AUTH_CLASSES:
|
|
227
|
+
if cls in parent_text:
|
|
228
|
+
auth.append(cls)
|
|
229
|
+
|
|
230
|
+
return auth
|
|
231
|
+
|
|
232
|
+
def extract_path_params(self, route_pattern: str) -> list[str]:
|
|
233
|
+
return re.findall(r"<(?:\w+:)?(\w+)>", route_pattern)
|
|
234
|
+
|
|
235
|
+
def _infer_viewset_route(self, class_name: str, action: str) -> str:
|
|
236
|
+
"""Infer route from ViewSet class name: UserViewSet -> /users/"""
|
|
237
|
+
name = class_name.replace("ViewSet", "").replace("View", "")
|
|
238
|
+
name = re.sub(r"(?<!^)(?=[A-Z])", "-", name).lower()
|
|
239
|
+
if action in ("retrieve", "update", "partial_update", "destroy"):
|
|
240
|
+
return f"/{name}s/<pk>/"
|
|
241
|
+
return f"/{name}s/"
|
|
242
|
+
|
|
243
|
+
def _infer_class_route(self, class_name: str) -> str:
|
|
244
|
+
"""Infer route from APIView class name."""
|
|
245
|
+
name = class_name.replace("APIView", "").replace("View", "")
|
|
246
|
+
name = re.sub(r"(?<!^)(?=[A-Z])", "-", name).lower()
|
|
247
|
+
return f"/{name}/"
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Express.js adapter — recognizes Express route patterns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from commiter.adapters.base import BaseAdapter, RouteMatch
|
|
9
|
+
from commiter.parser import node_text, find_nodes_by_type
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from tree_sitter import Node, Tree
|
|
13
|
+
|
|
14
|
+
# Matches: app.get("/path", ...), router.post("/path", ...)
|
|
15
|
+
ROUTE_CALL_RE = re.compile(
|
|
16
|
+
r"^(\w+)\.(get|post|put|delete|patch|all)\s*$"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Express path params: /users/:id
|
|
20
|
+
PATH_PARAM_RE = re.compile(r":(\w+)")
|
|
21
|
+
|
|
22
|
+
# Common auth middleware names
|
|
23
|
+
AUTH_MIDDLEWARE = {
|
|
24
|
+
"authenticate", "auth", "requireAuth", "isAuthenticated",
|
|
25
|
+
"verifyToken", "requireLogin", "passport.authenticate",
|
|
26
|
+
"authMiddleware", "protect",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ExpressAdapter(BaseAdapter):
|
|
31
|
+
framework_name = "express"
|
|
32
|
+
language = "javascript" # also works for TS via the extractor's language matching
|
|
33
|
+
|
|
34
|
+
def find_route_definitions(self, tree: Tree, source: bytes) -> list[RouteMatch]:
|
|
35
|
+
routes = []
|
|
36
|
+
call_nodes = find_nodes_by_type(tree.root_node, "call_expression")
|
|
37
|
+
|
|
38
|
+
for call in call_nodes:
|
|
39
|
+
func_node = call.child_by_field_name("function")
|
|
40
|
+
if not func_node or func_node.type != "member_expression":
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
func_text = node_text(func_node, source)
|
|
44
|
+
match = ROUTE_CALL_RE.match(func_text)
|
|
45
|
+
if not match:
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
router_var_name = match.group(1)
|
|
49
|
+
method = match.group(2).upper()
|
|
50
|
+
if method == "ALL":
|
|
51
|
+
method = "ALL"
|
|
52
|
+
|
|
53
|
+
args = call.child_by_field_name("arguments")
|
|
54
|
+
if not args:
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
# Skip app.all("*", ...) — that's middleware, not a route
|
|
58
|
+
if method == "ALL":
|
|
59
|
+
first_arg_text = ""
|
|
60
|
+
for a in args.children:
|
|
61
|
+
if a.type in ("string", "template_string"):
|
|
62
|
+
first_arg_text = node_text(a, source).strip("'\"`")
|
|
63
|
+
break
|
|
64
|
+
if first_arg_text == "*":
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# First argument should be the route string
|
|
68
|
+
route_pattern = None
|
|
69
|
+
handler_node = None
|
|
70
|
+
middleware_names: list[str] = []
|
|
71
|
+
|
|
72
|
+
for i, arg in enumerate(args.children):
|
|
73
|
+
if arg.type in ("string", "template_string"):
|
|
74
|
+
text = node_text(arg, source)
|
|
75
|
+
if text.startswith(("'", '"', "`")):
|
|
76
|
+
route_pattern = text[1:-1]
|
|
77
|
+
elif arg.type in ("arrow_function", "function_expression", "function"):
|
|
78
|
+
handler_node = arg
|
|
79
|
+
elif arg.type == "member_expression":
|
|
80
|
+
# Class-based handler: userController.getAll
|
|
81
|
+
handler_node = arg
|
|
82
|
+
elif arg.type == "identifier":
|
|
83
|
+
name = node_text(arg, source)
|
|
84
|
+
if route_pattern is not None and handler_node is None:
|
|
85
|
+
# Could be middleware or the handler
|
|
86
|
+
middleware_names.append(name)
|
|
87
|
+
|
|
88
|
+
if not route_pattern:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# The handler name: try to find from handler node or infer from route
|
|
92
|
+
handler_name = self._infer_handler_name(call, source, route_pattern, method, handler_node)
|
|
93
|
+
|
|
94
|
+
routes.append(RouteMatch(
|
|
95
|
+
http_method=method,
|
|
96
|
+
route_pattern=route_pattern,
|
|
97
|
+
handler_name=handler_name,
|
|
98
|
+
handler_node=handler_node or call,
|
|
99
|
+
decorator_nodes=[],
|
|
100
|
+
line=call.start_point[0] + 1,
|
|
101
|
+
router_var=router_var_name,
|
|
102
|
+
))
|
|
103
|
+
|
|
104
|
+
return routes
|
|
105
|
+
|
|
106
|
+
def extract_auth_info(self, route: RouteMatch, source: bytes) -> list[str]:
|
|
107
|
+
"""Check if any middleware arguments match known auth patterns."""
|
|
108
|
+
auth = []
|
|
109
|
+
# Re-examine the call node's arguments for middleware identifiers
|
|
110
|
+
if route.handler_node and route.handler_node.parent:
|
|
111
|
+
parent = route.handler_node.parent
|
|
112
|
+
for child in parent.children:
|
|
113
|
+
if child.type == "identifier":
|
|
114
|
+
name = node_text(child, source)
|
|
115
|
+
if name in AUTH_MIDDLEWARE:
|
|
116
|
+
auth.append(name)
|
|
117
|
+
return auth
|
|
118
|
+
|
|
119
|
+
def extract_request_body_fields(self, route: RouteMatch, source: bytes,
|
|
120
|
+
type_definitions: dict | None = None) -> list[str]:
|
|
121
|
+
"""Find req.body fields from runtime accesses and TS type annotations."""
|
|
122
|
+
fields = []
|
|
123
|
+
if route.handler_node is None:
|
|
124
|
+
return fields
|
|
125
|
+
body_text = node_text(route.handler_node, source)
|
|
126
|
+
|
|
127
|
+
# Try TS type annotation first: (req: Request<{}, {}, CreateUserBody>)
|
|
128
|
+
body_type = self._extract_request_body_type(route.handler_node, source)
|
|
129
|
+
if body_type:
|
|
130
|
+
route.param_types["_request_body_type"] = body_type
|
|
131
|
+
# Resolve from same-file type definitions if available
|
|
132
|
+
if type_definitions and body_type in type_definitions:
|
|
133
|
+
for tf in type_definitions[body_type]:
|
|
134
|
+
opt = "?" if tf.optional else ""
|
|
135
|
+
fields.append(f"{tf.name}: {tf.type_str}{opt}")
|
|
136
|
+
return fields
|
|
137
|
+
|
|
138
|
+
# Fallback: runtime field accesses
|
|
139
|
+
# req.body.fieldName
|
|
140
|
+
for match in re.finditer(r'req\.body\.(\w+)', body_text):
|
|
141
|
+
field = match.group(1)
|
|
142
|
+
if field not in fields:
|
|
143
|
+
fields.append(field)
|
|
144
|
+
|
|
145
|
+
# req.body["fieldName"] or req.body['fieldName']
|
|
146
|
+
for match in re.finditer(r'req\.body\[(["\'])(\w+)\1\]', body_text):
|
|
147
|
+
field = match.group(2)
|
|
148
|
+
if field not in fields:
|
|
149
|
+
fields.append(field)
|
|
150
|
+
|
|
151
|
+
# Destructuring: const { name, email } = req.body
|
|
152
|
+
for match in re.finditer(r'\{([^}]+)\}\s*=\s*req\.body', body_text):
|
|
153
|
+
for name in match.group(1).split(","):
|
|
154
|
+
field = name.strip().split(":")[0].strip()
|
|
155
|
+
if field and field not in fields:
|
|
156
|
+
fields.append(field)
|
|
157
|
+
|
|
158
|
+
return fields
|
|
159
|
+
|
|
160
|
+
def _extract_request_body_type(self, handler_node: Node, source: bytes) -> str | None:
|
|
161
|
+
"""Extract the body type from Request<Params, ResBody, ReqBody> annotation."""
|
|
162
|
+
handler_text = node_text(handler_node, source)
|
|
163
|
+
# Match: req: Request<any, any, CreateUserBody> or req: Request<{}, {}, CreateUserBody>
|
|
164
|
+
match = re.search(r':\s*Request\s*<[^,]*,[^,]*,\s*(\w+)\s*>', handler_text)
|
|
165
|
+
if match:
|
|
166
|
+
return match.group(1)
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def extract_response_fields(self, route: RouteMatch, source: bytes) -> list[str]:
|
|
170
|
+
"""Find fields in res.json({...}) calls."""
|
|
171
|
+
fields = []
|
|
172
|
+
if route.handler_node is None:
|
|
173
|
+
return fields
|
|
174
|
+
body_text = node_text(route.handler_node, source)
|
|
175
|
+
|
|
176
|
+
for match in re.finditer(r'(\w+)\s*:', body_text):
|
|
177
|
+
key = match.group(1)
|
|
178
|
+
# Filter out common non-response keys
|
|
179
|
+
if key not in ("const", "let", "var", "if", "else", "return", "function", "async", "await"):
|
|
180
|
+
if key not in fields:
|
|
181
|
+
fields.append(key)
|
|
182
|
+
|
|
183
|
+
return fields
|
|
184
|
+
|
|
185
|
+
def extract_path_params(self, route_pattern: str) -> list[str]:
|
|
186
|
+
return PATH_PARAM_RE.findall(route_pattern)
|
|
187
|
+
|
|
188
|
+
def _infer_handler_name(self, call: Node, source: bytes, route: str, method: str,
|
|
189
|
+
handler_node: "Node | None" = None) -> str:
|
|
190
|
+
"""Try to get a handler name from the handler node, assignment, or route."""
|
|
191
|
+
# Class-based handler: userController.getAll -> "UserController.getAll"
|
|
192
|
+
if handler_node and handler_node.type == "member_expression":
|
|
193
|
+
return node_text(handler_node, source)
|
|
194
|
+
|
|
195
|
+
# Check if this call is part of an assignment
|
|
196
|
+
parent = call.parent
|
|
197
|
+
if parent and parent.type in ("variable_declarator", "assignment_expression"):
|
|
198
|
+
name_node = parent.child_by_field_name("name") or parent.child_by_field_name("left")
|
|
199
|
+
if name_node:
|
|
200
|
+
return node_text(name_node, source)
|
|
201
|
+
|
|
202
|
+
# Fallback: method + route
|
|
203
|
+
clean_route = route.replace("/", "_").strip("_")
|
|
204
|
+
return f"{method.lower()}_{clean_route}" if clean_route else method.lower()
|