commiter-cli 0.3.0__tar.gz

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 (100) hide show
  1. commiter_cli-0.3.0/PKG-INFO +14 -0
  2. commiter_cli-0.3.0/commiter/__init__.py +3 -0
  3. commiter_cli-0.3.0/commiter/adapters/__init__.py +0 -0
  4. commiter_cli-0.3.0/commiter/adapters/base.py +96 -0
  5. commiter_cli-0.3.0/commiter/adapters/django_rest.py +247 -0
  6. commiter_cli-0.3.0/commiter/adapters/express.py +204 -0
  7. commiter_cli-0.3.0/commiter/adapters/fastapi.py +170 -0
  8. commiter_cli-0.3.0/commiter/adapters/flask.py +169 -0
  9. commiter_cli-0.3.0/commiter/adapters/nextjs.py +180 -0
  10. commiter_cli-0.3.0/commiter/adapters/prisma.py +76 -0
  11. commiter_cli-0.3.0/commiter/adapters/raw_sql.py +191 -0
  12. commiter_cli-0.3.0/commiter/adapters/react.py +129 -0
  13. commiter_cli-0.3.0/commiter/adapters/sqlalchemy.py +99 -0
  14. commiter_cli-0.3.0/commiter/adapters/supabase.py +68 -0
  15. commiter_cli-0.3.0/commiter/auth.py +130 -0
  16. commiter_cli-0.3.0/commiter/cli.py +667 -0
  17. commiter_cli-0.3.0/commiter/correlator.py +208 -0
  18. commiter_cli-0.3.0/commiter/extractors/__init__.py +0 -0
  19. commiter_cli-0.3.0/commiter/extractors/api_calls.py +91 -0
  20. commiter_cli-0.3.0/commiter/extractors/api_endpoints.py +354 -0
  21. commiter_cli-0.3.0/commiter/extractors/backend_files.py +33 -0
  22. commiter_cli-0.3.0/commiter/extractors/base.py +40 -0
  23. commiter_cli-0.3.0/commiter/extractors/db_operations.py +69 -0
  24. commiter_cli-0.3.0/commiter/extractors/dependencies.py +219 -0
  25. commiter_cli-0.3.0/commiter/generic_resolver.py +204 -0
  26. commiter_cli-0.3.0/commiter/handler_index.py +97 -0
  27. commiter_cli-0.3.0/commiter/lib.py +63 -0
  28. commiter_cli-0.3.0/commiter/middleware_index.py +350 -0
  29. commiter_cli-0.3.0/commiter/models.py +117 -0
  30. commiter_cli-0.3.0/commiter/parser.py +1283 -0
  31. commiter_cli-0.3.0/commiter/prefix_index.py +211 -0
  32. commiter_cli-0.3.0/commiter/report/__init__.py +0 -0
  33. commiter_cli-0.3.0/commiter/report/ai.py +120 -0
  34. commiter_cli-0.3.0/commiter/report/api_guide.py +217 -0
  35. commiter_cli-0.3.0/commiter/report/architecture.py +930 -0
  36. commiter_cli-0.3.0/commiter/report/console.py +254 -0
  37. commiter_cli-0.3.0/commiter/report/json_output.py +122 -0
  38. commiter_cli-0.3.0/commiter/report/markdown.py +163 -0
  39. commiter_cli-0.3.0/commiter/scanner.py +383 -0
  40. commiter_cli-0.3.0/commiter/type_index.py +304 -0
  41. commiter_cli-0.3.0/commiter/uploader.py +46 -0
  42. commiter_cli-0.3.0/commiter/utils/__init__.py +0 -0
  43. commiter_cli-0.3.0/commiter/utils/env_reader.py +78 -0
  44. commiter_cli-0.3.0/commiter/utils/file_classifier.py +187 -0
  45. commiter_cli-0.3.0/commiter/utils/path_helpers.py +73 -0
  46. commiter_cli-0.3.0/commiter/utils/tsconfig_resolver.py +281 -0
  47. commiter_cli-0.3.0/commiter/wrapper_index.py +288 -0
  48. commiter_cli-0.3.0/commiter_cli.egg-info/PKG-INFO +14 -0
  49. commiter_cli-0.3.0/commiter_cli.egg-info/SOURCES.txt +98 -0
  50. commiter_cli-0.3.0/commiter_cli.egg-info/dependency_links.txt +1 -0
  51. commiter_cli-0.3.0/commiter_cli.egg-info/entry_points.txt +2 -0
  52. commiter_cli-0.3.0/commiter_cli.egg-info/requires.txt +12 -0
  53. commiter_cli-0.3.0/commiter_cli.egg-info/top_level.txt +4 -0
  54. commiter_cli-0.3.0/pyproject.toml +29 -0
  55. commiter_cli-0.3.0/setup.cfg +4 -0
  56. commiter_cli-0.3.0/tests/__init__.py +0 -0
  57. commiter_cli-0.3.0/tests/fixtures/arch_backend/app.py +22 -0
  58. commiter_cli-0.3.0/tests/fixtures/arch_backend/middleware/__init__.py +0 -0
  59. commiter_cli-0.3.0/tests/fixtures/arch_backend/middleware/rate_limit.py +4 -0
  60. commiter_cli-0.3.0/tests/fixtures/arch_backend/routes/__init__.py +0 -0
  61. commiter_cli-0.3.0/tests/fixtures/arch_backend/routes/analytics.py +20 -0
  62. commiter_cli-0.3.0/tests/fixtures/arch_backend/routes/auth.py +29 -0
  63. commiter_cli-0.3.0/tests/fixtures/arch_backend/routes/projects.py +60 -0
  64. commiter_cli-0.3.0/tests/fixtures/arch_backend/routes/users.py +55 -0
  65. commiter_cli-0.3.0/tests/fixtures/arch_monorepo/apps/api/app.py +30 -0
  66. commiter_cli-0.3.0/tests/fixtures/arch_monorepo/apps/api/middleware/__init__.py +0 -0
  67. commiter_cli-0.3.0/tests/fixtures/arch_monorepo/apps/api/middleware/auth.py +17 -0
  68. commiter_cli-0.3.0/tests/fixtures/arch_monorepo/apps/api/middleware/rate_limit.py +10 -0
  69. commiter_cli-0.3.0/tests/fixtures/arch_monorepo/apps/api/routes/__init__.py +0 -0
  70. commiter_cli-0.3.0/tests/fixtures/arch_monorepo/apps/api/routes/auth.py +46 -0
  71. commiter_cli-0.3.0/tests/fixtures/arch_monorepo/apps/api/routes/invites.py +30 -0
  72. commiter_cli-0.3.0/tests/fixtures/arch_monorepo/apps/api/routes/notifications.py +25 -0
  73. commiter_cli-0.3.0/tests/fixtures/arch_monorepo/apps/api/routes/projects.py +80 -0
  74. commiter_cli-0.3.0/tests/fixtures/arch_monorepo/apps/api/routes/tasks.py +91 -0
  75. commiter_cli-0.3.0/tests/fixtures/arch_monorepo/apps/api/routes/users.py +48 -0
  76. commiter_cli-0.3.0/tests/fixtures/arch_monorepo/apps/api/services/__init__.py +0 -0
  77. commiter_cli-0.3.0/tests/fixtures/arch_monorepo/apps/api/services/email.py +11 -0
  78. commiter_cli-0.3.0/tests/fixtures/backend_b/app.py +17 -0
  79. commiter_cli-0.3.0/tests/fixtures/fastapi_app/app.py +48 -0
  80. commiter_cli-0.3.0/tests/fixtures/fastapi_crossfile/routes.py +18 -0
  81. commiter_cli-0.3.0/tests/fixtures/fastapi_crossfile/schemas.py +21 -0
  82. commiter_cli-0.3.0/tests/fixtures/flask_app/app.py +33 -0
  83. commiter_cli-0.3.0/tests/fixtures/flask_blueprint/app.py +7 -0
  84. commiter_cli-0.3.0/tests/fixtures/flask_blueprint/routes/items.py +13 -0
  85. commiter_cli-0.3.0/tests/fixtures/flask_blueprint/routes/users.py +20 -0
  86. commiter_cli-0.3.0/tests/fixtures/middleware_test_flask/routes/public.py +8 -0
  87. commiter_cli-0.3.0/tests/fixtures/middleware_test_flask/routes/users.py +26 -0
  88. commiter_cli-0.3.0/tests/fixtures/python_deep_imports/app/__init__.py +0 -0
  89. commiter_cli-0.3.0/tests/fixtures/python_deep_imports/app/api/__init__.py +0 -0
  90. commiter_cli-0.3.0/tests/fixtures/python_deep_imports/app/api/health.py +11 -0
  91. commiter_cli-0.3.0/tests/fixtures/python_deep_imports/app/api/v1/__init__.py +0 -0
  92. commiter_cli-0.3.0/tests/fixtures/python_deep_imports/app/api/v1/items.py +18 -0
  93. commiter_cli-0.3.0/tests/fixtures/python_deep_imports/app/api/v1/users.py +27 -0
  94. commiter_cli-0.3.0/tests/fixtures/python_deep_imports/app/schemas/__init__.py +0 -0
  95. commiter_cli-0.3.0/tests/fixtures/python_deep_imports/app/schemas/item.py +13 -0
  96. commiter_cli-0.3.0/tests/fixtures/python_deep_imports/app/schemas/user.py +15 -0
  97. commiter_cli-0.3.0/tests/fixtures/python_deep_imports/app/shared/__init__.py +0 -0
  98. commiter_cli-0.3.0/tests/fixtures/python_deep_imports/app/shared/models.py +7 -0
  99. commiter_cli-0.3.0/tests/fixtures/raw_sql_test/app.py +54 -0
  100. commiter_cli-0.3.0/tests/test_architecture.py +757 -0
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: commiter-cli
3
+ Version: 0.3.0
4
+ Summary: Commiter CLI — scan repositories and enrich architecture snapshots on commiter.dev
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: tree-sitter>=0.21.0
7
+ Requires-Dist: tree-sitter-python>=0.21.0
8
+ Requires-Dist: tree-sitter-javascript>=0.21.0
9
+ Requires-Dist: tree-sitter-typescript>=0.21.0
10
+ Requires-Dist: click>=8.0
11
+ Requires-Dist: rich>=13.0
12
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=7.0; extra == "dev"
@@ -0,0 +1,3 @@
1
+ """Repo Documenter - Generate documentation from repository code analysis."""
2
+
3
+ __version__ = "0.1.0"
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()