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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""FastAPI adapter — recognizes FastAPI 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 FastAPI route decorators: @app.get, @router.post, etc.
|
|
15
|
+
ROUTE_DECORATOR_RE = re.compile(
|
|
16
|
+
r"^(\w+)\.(get|post|put|delete|patch)\s*\("
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Path parameters: {item_id} or {item_id:int}
|
|
20
|
+
PATH_PARAM_RE = re.compile(r"\{(\w+)(?::\w+)?\}")
|
|
21
|
+
|
|
22
|
+
# Common FastAPI auth dependency patterns
|
|
23
|
+
AUTH_PATTERNS = {
|
|
24
|
+
"Depends(get_current_user", "Depends(require_auth",
|
|
25
|
+
"Depends(authenticate", "Security(",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FastAPIAdapter(BaseAdapter):
|
|
30
|
+
framework_name = "fastapi"
|
|
31
|
+
language = "python"
|
|
32
|
+
|
|
33
|
+
def find_route_definitions(self, tree: Tree, source: bytes) -> list[RouteMatch]:
|
|
34
|
+
routes = []
|
|
35
|
+
root = tree.root_node
|
|
36
|
+
|
|
37
|
+
for child in root.children:
|
|
38
|
+
if child.type != "decorated_definition":
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
decorators = []
|
|
42
|
+
func_node = None
|
|
43
|
+
for sub in child.children:
|
|
44
|
+
if sub.type == "decorator":
|
|
45
|
+
decorators.append(sub)
|
|
46
|
+
elif sub.type == "function_definition":
|
|
47
|
+
func_node = sub
|
|
48
|
+
|
|
49
|
+
if func_node is None:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
for dec_node in decorators:
|
|
53
|
+
dec_text = node_text(dec_node, source).lstrip("@").strip()
|
|
54
|
+
match = ROUTE_DECORATOR_RE.match(dec_text)
|
|
55
|
+
if not match:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
router_var_name = match.group(1)
|
|
59
|
+
method = match.group(2).upper()
|
|
60
|
+
route_pattern = self._extract_route_string(dec_node, source)
|
|
61
|
+
if not route_pattern:
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
handler_name = self._get_func_name(func_node, source)
|
|
65
|
+
|
|
66
|
+
routes.append(RouteMatch(
|
|
67
|
+
http_method=method,
|
|
68
|
+
route_pattern=route_pattern,
|
|
69
|
+
handler_name=handler_name,
|
|
70
|
+
handler_node=func_node,
|
|
71
|
+
decorator_nodes=decorators,
|
|
72
|
+
line=func_node.start_point[0] + 1,
|
|
73
|
+
router_var=router_var_name,
|
|
74
|
+
))
|
|
75
|
+
|
|
76
|
+
return routes
|
|
77
|
+
|
|
78
|
+
def extract_auth_info(self, route: RouteMatch, source: bytes) -> list[str]:
|
|
79
|
+
"""Detect Depends() auth patterns in function parameters."""
|
|
80
|
+
auth = []
|
|
81
|
+
# Check decorators
|
|
82
|
+
for dec_node in route.decorator_nodes:
|
|
83
|
+
dec_text = node_text(dec_node, source)
|
|
84
|
+
for pattern in AUTH_PATTERNS:
|
|
85
|
+
if pattern in dec_text:
|
|
86
|
+
auth.append(dec_text.lstrip("@").strip())
|
|
87
|
+
|
|
88
|
+
# Check function parameters for Depends(get_current_user)
|
|
89
|
+
params_node = route.handler_node.child_by_field_name("parameters")
|
|
90
|
+
if params_node:
|
|
91
|
+
params_text = node_text(params_node, source)
|
|
92
|
+
for pattern in AUTH_PATTERNS:
|
|
93
|
+
if pattern in params_text:
|
|
94
|
+
auth.append(pattern.rstrip("("))
|
|
95
|
+
|
|
96
|
+
return auth
|
|
97
|
+
|
|
98
|
+
def extract_request_body_fields(self, route: RouteMatch, source: bytes,
|
|
99
|
+
type_definitions: dict | None = None) -> list[str]:
|
|
100
|
+
"""Extract field names from Pydantic model type hints in parameters.
|
|
101
|
+
|
|
102
|
+
If type_definitions is provided (same-file type info), resolves model fields.
|
|
103
|
+
"""
|
|
104
|
+
fields = []
|
|
105
|
+
body_type = None
|
|
106
|
+
params_node = route.handler_node.child_by_field_name("parameters")
|
|
107
|
+
if not params_node:
|
|
108
|
+
return fields
|
|
109
|
+
|
|
110
|
+
params_text = node_text(params_node, source)
|
|
111
|
+
|
|
112
|
+
for match in re.finditer(r'(\w+)\s*:\s*(\w+)', params_text):
|
|
113
|
+
param_name = match.group(1)
|
|
114
|
+
type_name = match.group(2)
|
|
115
|
+
if type_name in ("str", "int", "float", "bool", "list", "dict", "Optional", "Request", "Response"):
|
|
116
|
+
continue
|
|
117
|
+
if param_name in ("self", "request", "response", "db", "session"):
|
|
118
|
+
continue
|
|
119
|
+
if type_name[0].isupper():
|
|
120
|
+
body_type = type_name
|
|
121
|
+
# Try to resolve the model's fields from same-file definitions
|
|
122
|
+
if type_definitions and type_name in type_definitions:
|
|
123
|
+
for tf in type_definitions[type_name]:
|
|
124
|
+
opt = "?" if tf.optional else ""
|
|
125
|
+
fields.append(f"{tf.name}: {tf.type_str}{opt}")
|
|
126
|
+
else:
|
|
127
|
+
fields.append(f"{param_name}: {type_name}")
|
|
128
|
+
|
|
129
|
+
# Store the body type name on the route for the extractor to pick up
|
|
130
|
+
if body_type:
|
|
131
|
+
route.param_types["_request_body_type"] = body_type
|
|
132
|
+
|
|
133
|
+
return fields
|
|
134
|
+
|
|
135
|
+
def extract_response_fields(self, route: RouteMatch, source: bytes) -> list[str]:
|
|
136
|
+
"""Extract response fields from return dicts or response_model."""
|
|
137
|
+
fields = []
|
|
138
|
+
body_text = node_text(route.handler_node, source)
|
|
139
|
+
|
|
140
|
+
# Match dict literal keys in return statements
|
|
141
|
+
for match in re.finditer(r'["\'](\w+)["\']\s*:', body_text):
|
|
142
|
+
key = match.group(1)
|
|
143
|
+
if key not in fields:
|
|
144
|
+
fields.append(key)
|
|
145
|
+
|
|
146
|
+
# Check decorator for response_model
|
|
147
|
+
for dec_node in route.decorator_nodes:
|
|
148
|
+
dec_text = node_text(dec_node, source)
|
|
149
|
+
rm_match = re.search(r'response_model\s*=\s*(\w+)', dec_text)
|
|
150
|
+
if rm_match:
|
|
151
|
+
fields.insert(0, f"response_model: {rm_match.group(1)}")
|
|
152
|
+
|
|
153
|
+
return fields
|
|
154
|
+
|
|
155
|
+
def extract_path_params(self, route_pattern: str) -> list[str]:
|
|
156
|
+
return PATH_PARAM_RE.findall(route_pattern)
|
|
157
|
+
|
|
158
|
+
def _extract_route_string(self, dec_node: Node, source: bytes) -> str | None:
|
|
159
|
+
call_nodes = find_nodes_by_type(dec_node, "string")
|
|
160
|
+
if call_nodes:
|
|
161
|
+
text = node_text(call_nodes[0], source)
|
|
162
|
+
if len(text) >= 2:
|
|
163
|
+
return text[1:-1]
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
def _get_func_name(self, func_node: Node, source: bytes) -> str:
|
|
167
|
+
name_node = func_node.child_by_field_name("name")
|
|
168
|
+
if name_node:
|
|
169
|
+
return node_text(name_node, source)
|
|
170
|
+
return "<anonymous>"
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Flask adapter — recognizes Flask 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 Flask route decorators: @app.route, @bp.route, @blueprint.route
|
|
15
|
+
ROUTE_DECORATOR_RE = re.compile(
|
|
16
|
+
r"^(\w+)\.(route|get|post|put|delete|patch)\s*\("
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Extracts path parameters like <item_id> or <int:item_id>
|
|
20
|
+
PATH_PARAM_RE = re.compile(r"<(?:\w+:)?(\w+)>")
|
|
21
|
+
|
|
22
|
+
# HTTP method names
|
|
23
|
+
METHOD_MAP = {
|
|
24
|
+
"get": "GET",
|
|
25
|
+
"post": "POST",
|
|
26
|
+
"put": "PUT",
|
|
27
|
+
"delete": "DELETE",
|
|
28
|
+
"patch": "PATCH",
|
|
29
|
+
"route": None, # determined by methods= kwarg
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Common Flask auth decorator patterns
|
|
33
|
+
AUTH_DECORATORS = {
|
|
34
|
+
"login_required", "require_auth", "auth_required",
|
|
35
|
+
"jwt_required", "token_required", "requires_auth",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FlaskAdapter(BaseAdapter):
|
|
40
|
+
framework_name = "flask"
|
|
41
|
+
language = "python"
|
|
42
|
+
|
|
43
|
+
def find_route_definitions(self, tree: Tree, source: bytes) -> list[RouteMatch]:
|
|
44
|
+
routes = []
|
|
45
|
+
root = tree.root_node
|
|
46
|
+
|
|
47
|
+
for child in root.children:
|
|
48
|
+
if child.type != "decorated_definition":
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
decorators = []
|
|
52
|
+
func_node = None
|
|
53
|
+
for sub in child.children:
|
|
54
|
+
if sub.type == "decorator":
|
|
55
|
+
decorators.append(sub)
|
|
56
|
+
elif sub.type == "function_definition":
|
|
57
|
+
func_node = sub
|
|
58
|
+
|
|
59
|
+
if func_node is None:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
for dec_node in decorators:
|
|
63
|
+
dec_text = node_text(dec_node, source).lstrip("@").strip()
|
|
64
|
+
match = ROUTE_DECORATOR_RE.match(dec_text)
|
|
65
|
+
if not match:
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
router_var_name = match.group(1)
|
|
69
|
+
method_shorthand = match.group(2)
|
|
70
|
+
route_pattern = self._extract_route_string(dec_node, source)
|
|
71
|
+
if not route_pattern:
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
methods = self._extract_methods(dec_node, source, method_shorthand)
|
|
75
|
+
handler_name = self._get_func_name(func_node, source)
|
|
76
|
+
|
|
77
|
+
for method in methods:
|
|
78
|
+
routes.append(RouteMatch(
|
|
79
|
+
http_method=method,
|
|
80
|
+
route_pattern=route_pattern,
|
|
81
|
+
handler_name=handler_name,
|
|
82
|
+
handler_node=func_node,
|
|
83
|
+
decorator_nodes=decorators,
|
|
84
|
+
line=func_node.start_point[0] + 1,
|
|
85
|
+
router_var=router_var_name,
|
|
86
|
+
))
|
|
87
|
+
|
|
88
|
+
return routes
|
|
89
|
+
|
|
90
|
+
def extract_auth_info(self, route: RouteMatch, source: bytes) -> list[str]:
|
|
91
|
+
auth = []
|
|
92
|
+
for dec_node in route.decorator_nodes:
|
|
93
|
+
dec_text = node_text(dec_node, source).lstrip("@").strip()
|
|
94
|
+
# Check if any known auth decorator name appears
|
|
95
|
+
for auth_name in AUTH_DECORATORS:
|
|
96
|
+
if auth_name in dec_text:
|
|
97
|
+
auth.append(dec_text)
|
|
98
|
+
break
|
|
99
|
+
return auth
|
|
100
|
+
|
|
101
|
+
def extract_request_body_fields(self, route: RouteMatch, source: bytes) -> list[str]:
|
|
102
|
+
"""Find request.json["field"] and request.form["field"] accesses."""
|
|
103
|
+
fields = []
|
|
104
|
+
body = route.handler_node
|
|
105
|
+
body_text = node_text(body, source)
|
|
106
|
+
|
|
107
|
+
# Match request.json["field"], request.json.get("field"), request.form["field"]
|
|
108
|
+
for match in re.finditer(r'request\.(?:json|form)(?:\[(["\'])(\w+)\1\]|\.get\(["\'](\w+)["\'])', body_text):
|
|
109
|
+
field_name = match.group(2) or match.group(3)
|
|
110
|
+
if field_name and field_name not in fields:
|
|
111
|
+
fields.append(field_name)
|
|
112
|
+
|
|
113
|
+
return fields
|
|
114
|
+
|
|
115
|
+
def extract_response_fields(self, route: RouteMatch, source: bytes) -> list[str]:
|
|
116
|
+
"""Find keys in jsonify({...}) or return {...} dicts."""
|
|
117
|
+
fields = []
|
|
118
|
+
body_text = node_text(route.handler_node, source)
|
|
119
|
+
|
|
120
|
+
# Match jsonify(key=value) pattern
|
|
121
|
+
for match in re.finditer(r'jsonify\s*\(([^)]+)\)', body_text):
|
|
122
|
+
args = match.group(1)
|
|
123
|
+
for kv in re.finditer(r'(\w+)\s*=', args):
|
|
124
|
+
key = kv.group(1)
|
|
125
|
+
if key not in fields:
|
|
126
|
+
fields.append(key)
|
|
127
|
+
|
|
128
|
+
# Match jsonify({"key": ...}) pattern
|
|
129
|
+
for match in re.finditer(r'["\'](\w+)["\']\s*:', body_text):
|
|
130
|
+
key = match.group(1)
|
|
131
|
+
if key not in fields:
|
|
132
|
+
fields.append(key)
|
|
133
|
+
|
|
134
|
+
return fields
|
|
135
|
+
|
|
136
|
+
def extract_path_params(self, route_pattern: str) -> list[str]:
|
|
137
|
+
return PATH_PARAM_RE.findall(route_pattern)
|
|
138
|
+
|
|
139
|
+
def _extract_route_string(self, dec_node: Node, source: bytes) -> str | None:
|
|
140
|
+
"""Extract the route pattern string from a decorator."""
|
|
141
|
+
# Find the first string argument in the decorator call
|
|
142
|
+
call_nodes = find_nodes_by_type(dec_node, "string")
|
|
143
|
+
if call_nodes:
|
|
144
|
+
text = node_text(call_nodes[0], source)
|
|
145
|
+
# Strip quotes
|
|
146
|
+
if len(text) >= 2:
|
|
147
|
+
return text[1:-1]
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def _extract_methods(self, dec_node: Node, source: bytes, method_shorthand: str) -> list[str]:
|
|
151
|
+
"""Extract HTTP methods from the decorator."""
|
|
152
|
+
if method_shorthand != "route":
|
|
153
|
+
mapped = METHOD_MAP.get(method_shorthand)
|
|
154
|
+
return [mapped] if mapped else ["GET"]
|
|
155
|
+
|
|
156
|
+
# For @app.route(..., methods=["GET", "POST"])
|
|
157
|
+
dec_text = node_text(dec_node, source)
|
|
158
|
+
methods_match = re.search(r'methods\s*=\s*\[([^\]]+)\]', dec_text)
|
|
159
|
+
if methods_match:
|
|
160
|
+
raw = methods_match.group(1)
|
|
161
|
+
return [m.strip().strip("\"'").upper() for m in raw.split(",")]
|
|
162
|
+
|
|
163
|
+
return ["GET"]
|
|
164
|
+
|
|
165
|
+
def _get_func_name(self, func_node: Node, source: bytes) -> str:
|
|
166
|
+
name_node = func_node.child_by_field_name("name")
|
|
167
|
+
if name_node:
|
|
168
|
+
return node_text(name_node, source)
|
|
169
|
+
return "<anonymous>"
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Next.js adapter — recognizes API routes and page components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from commiter.adapters.base import BaseAdapter, RouteMatch
|
|
10
|
+
from commiter.parser import node_text, find_nodes_by_type
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from tree_sitter import Node, Tree
|
|
14
|
+
|
|
15
|
+
# HTTP method export names in App Router route.ts files
|
|
16
|
+
APP_ROUTER_METHODS = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NextJSAdapter(BaseAdapter):
|
|
20
|
+
framework_name = "nextjs"
|
|
21
|
+
language = "typescript" # also handles tsx, js, jsx via can_handle_language
|
|
22
|
+
|
|
23
|
+
_file_path: str = ""
|
|
24
|
+
|
|
25
|
+
def can_handle_language(self, language: str) -> bool:
|
|
26
|
+
return language in ("typescript", "tsx", "javascript")
|
|
27
|
+
|
|
28
|
+
def find_route_definitions(self, tree: Tree, source: bytes, file_path: str = "") -> list[RouteMatch]:
|
|
29
|
+
"""Detect Next.js API routes from file conventions."""
|
|
30
|
+
self._file_path = file_path
|
|
31
|
+
|
|
32
|
+
if self._is_app_router_route(file_path):
|
|
33
|
+
return self._find_app_router_routes(tree, source, file_path)
|
|
34
|
+
elif self._is_pages_api_route(file_path):
|
|
35
|
+
return self._find_pages_api_routes(tree, source, file_path)
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
def _is_app_router_route(self, file_path: str) -> bool:
|
|
39
|
+
"""Check if this is an App Router route file (app/**/route.ts)."""
|
|
40
|
+
name = Path(file_path).stem
|
|
41
|
+
return name == "route" and "/app/" in file_path
|
|
42
|
+
|
|
43
|
+
def _is_pages_api_route(self, file_path: str) -> bool:
|
|
44
|
+
"""Check if this is a Pages Router API route (pages/api/**/*.ts)."""
|
|
45
|
+
return "/pages/api/" in file_path
|
|
46
|
+
|
|
47
|
+
def _find_app_router_routes(self, tree: Tree, source: bytes, file_path: str) -> list[RouteMatch]:
|
|
48
|
+
"""Find named exports like `export async function GET(request) {...}`."""
|
|
49
|
+
routes = []
|
|
50
|
+
route_pattern = self._path_to_route(file_path, "app")
|
|
51
|
+
|
|
52
|
+
root = tree.root_node
|
|
53
|
+
for child in root.children:
|
|
54
|
+
if child.type != "export_statement":
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
# Look for function declarations inside exports
|
|
58
|
+
for sub in child.children:
|
|
59
|
+
func_node = None
|
|
60
|
+
if sub.type == "function_declaration":
|
|
61
|
+
func_node = sub
|
|
62
|
+
elif sub.type == "lexical_declaration":
|
|
63
|
+
# const GET = async (...) => {...}
|
|
64
|
+
for decl in sub.children:
|
|
65
|
+
if decl.type == "variable_declarator":
|
|
66
|
+
name_node = decl.child_by_field_name("name")
|
|
67
|
+
if name_node:
|
|
68
|
+
name = node_text(name_node, source)
|
|
69
|
+
if name in APP_ROUTER_METHODS:
|
|
70
|
+
routes.append(RouteMatch(
|
|
71
|
+
http_method=name,
|
|
72
|
+
route_pattern=route_pattern,
|
|
73
|
+
handler_name=name,
|
|
74
|
+
handler_node=decl,
|
|
75
|
+
line=decl.start_point[0] + 1,
|
|
76
|
+
))
|
|
77
|
+
|
|
78
|
+
if func_node:
|
|
79
|
+
name_node = func_node.child_by_field_name("name")
|
|
80
|
+
if name_node:
|
|
81
|
+
name = node_text(name_node, source)
|
|
82
|
+
if name in APP_ROUTER_METHODS:
|
|
83
|
+
routes.append(RouteMatch(
|
|
84
|
+
http_method=name,
|
|
85
|
+
route_pattern=route_pattern,
|
|
86
|
+
handler_name=name,
|
|
87
|
+
handler_node=func_node,
|
|
88
|
+
line=func_node.start_point[0] + 1,
|
|
89
|
+
))
|
|
90
|
+
|
|
91
|
+
return routes
|
|
92
|
+
|
|
93
|
+
def _find_pages_api_routes(self, tree: Tree, source: bytes, file_path: str) -> list[RouteMatch]:
|
|
94
|
+
"""Find default export handler in pages/api/ files."""
|
|
95
|
+
routes = []
|
|
96
|
+
route_pattern = self._path_to_route(file_path, "pages")
|
|
97
|
+
root = tree.root_node
|
|
98
|
+
|
|
99
|
+
# Look for `export default function handler(req, res)` or
|
|
100
|
+
# `export default async (req, res) => {}`
|
|
101
|
+
for child in root.children:
|
|
102
|
+
if child.type != "export_statement":
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
text = node_text(child, source)
|
|
106
|
+
if "default" not in text:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
for sub in child.children:
|
|
110
|
+
if sub.type in ("function_declaration", "arrow_function", "function_expression"):
|
|
111
|
+
handler_name = "handler"
|
|
112
|
+
if sub.type == "function_declaration":
|
|
113
|
+
name_node = sub.child_by_field_name("name")
|
|
114
|
+
if name_node:
|
|
115
|
+
handler_name = node_text(name_node, source)
|
|
116
|
+
|
|
117
|
+
# Pages API routes handle all methods — check for req.method inside
|
|
118
|
+
methods = self._detect_methods_from_body(sub, source)
|
|
119
|
+
if not methods:
|
|
120
|
+
methods = ["ALL"]
|
|
121
|
+
|
|
122
|
+
for method in methods:
|
|
123
|
+
routes.append(RouteMatch(
|
|
124
|
+
http_method=method,
|
|
125
|
+
route_pattern=route_pattern,
|
|
126
|
+
handler_name=handler_name,
|
|
127
|
+
handler_node=sub,
|
|
128
|
+
line=sub.start_point[0] + 1,
|
|
129
|
+
))
|
|
130
|
+
|
|
131
|
+
return routes
|
|
132
|
+
|
|
133
|
+
def _detect_methods_from_body(self, node: Node, source: bytes) -> list[str]:
|
|
134
|
+
"""Check for req.method === 'GET' patterns inside handler."""
|
|
135
|
+
body_text = node_text(node, source)
|
|
136
|
+
methods = []
|
|
137
|
+
for method in ("GET", "POST", "PUT", "DELETE", "PATCH"):
|
|
138
|
+
if f'"{method}"' in body_text or f"'{method}'" in body_text:
|
|
139
|
+
methods.append(method)
|
|
140
|
+
return methods
|
|
141
|
+
|
|
142
|
+
def _path_to_route(self, file_path: str, router_type: str) -> str:
|
|
143
|
+
"""Convert file path to API route pattern.
|
|
144
|
+
|
|
145
|
+
app/api/users/[id]/route.ts -> /api/users/[id]
|
|
146
|
+
pages/api/users/[id].ts -> /api/users/[id]
|
|
147
|
+
"""
|
|
148
|
+
path = Path(file_path)
|
|
149
|
+
parts = path.parts
|
|
150
|
+
|
|
151
|
+
# Find the router root
|
|
152
|
+
try:
|
|
153
|
+
if router_type == "app":
|
|
154
|
+
idx = parts.index("app")
|
|
155
|
+
route_parts = parts[idx + 1:]
|
|
156
|
+
# Remove the "route.ts" file
|
|
157
|
+
route_parts = route_parts[:-1]
|
|
158
|
+
else:
|
|
159
|
+
idx = parts.index("pages")
|
|
160
|
+
route_parts = parts[idx + 1:]
|
|
161
|
+
# Remove file extension from last part
|
|
162
|
+
last = Path(route_parts[-1]).stem
|
|
163
|
+
route_parts = list(route_parts[:-1]) + [last]
|
|
164
|
+
# Remove "index" from the end
|
|
165
|
+
if route_parts and route_parts[-1] == "index":
|
|
166
|
+
route_parts = route_parts[:-1]
|
|
167
|
+
except (ValueError, IndexError):
|
|
168
|
+
return file_path
|
|
169
|
+
|
|
170
|
+
route = "/" + "/".join(route_parts)
|
|
171
|
+
# Convert [param] to :param for display consistency
|
|
172
|
+
route = re.sub(r"\[\.\.\.(\w+)\]", r"*\1", route) # catch-all
|
|
173
|
+
route = re.sub(r"\[(\w+)\]", r":\1", route)
|
|
174
|
+
return route
|
|
175
|
+
|
|
176
|
+
def extract_path_params(self, route_pattern: str) -> list[str]:
|
|
177
|
+
"""Extract params from Next.js patterns like :id or [id]."""
|
|
178
|
+
params = re.findall(r":(\w+)", route_pattern)
|
|
179
|
+
params += re.findall(r"\[(\w+)\]", route_pattern)
|
|
180
|
+
return params
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Prisma adapter — detects Prisma ORM database operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from commiter.adapters.base import DBAdapter, DBOpMatch
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from tree_sitter import Tree
|
|
12
|
+
|
|
13
|
+
# Prisma methods to operation types
|
|
14
|
+
PRISMA_OP_MAP = {
|
|
15
|
+
"findUnique": "select",
|
|
16
|
+
"findFirst": "select",
|
|
17
|
+
"findMany": "select",
|
|
18
|
+
"create": "insert",
|
|
19
|
+
"createMany": "insert",
|
|
20
|
+
"update": "update",
|
|
21
|
+
"updateMany": "update",
|
|
22
|
+
"upsert": "upsert",
|
|
23
|
+
"delete": "delete",
|
|
24
|
+
"deleteMany": "delete",
|
|
25
|
+
"count": "select",
|
|
26
|
+
"aggregate": "select",
|
|
27
|
+
"groupBy": "select",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PrismaAdapter(DBAdapter):
|
|
32
|
+
orm_name = "prisma"
|
|
33
|
+
language = "javascript" # TypeScript too
|
|
34
|
+
|
|
35
|
+
def find_db_operations(self, tree: Tree, source: bytes) -> list[DBOpMatch]:
|
|
36
|
+
ops = []
|
|
37
|
+
text = source.decode("utf-8", errors="replace")
|
|
38
|
+
|
|
39
|
+
# Match: prisma.user.findMany(...), prisma.post.create(...)
|
|
40
|
+
for match in re.finditer(
|
|
41
|
+
r'(?:prisma|db|client)\s*\.\s*(\w+)\s*\.\s*(\w+)\s*\(',
|
|
42
|
+
text,
|
|
43
|
+
):
|
|
44
|
+
model_name = match.group(1)
|
|
45
|
+
method = match.group(2)
|
|
46
|
+
|
|
47
|
+
# Skip non-model methods
|
|
48
|
+
if model_name.startswith("$") or model_name in ("_", "use"):
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
op_type = PRISMA_OP_MAP.get(method)
|
|
52
|
+
if not op_type:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
line = text[:match.start()].count("\n") + 1
|
|
56
|
+
filters = self._extract_where_fields(text, match.end())
|
|
57
|
+
|
|
58
|
+
ops.append(DBOpMatch(
|
|
59
|
+
operation_type=op_type,
|
|
60
|
+
table_name=model_name,
|
|
61
|
+
call_node=tree.root_node,
|
|
62
|
+
line=line,
|
|
63
|
+
filters=filters,
|
|
64
|
+
))
|
|
65
|
+
|
|
66
|
+
return ops
|
|
67
|
+
|
|
68
|
+
def _extract_where_fields(self, text: str, start: int) -> list[str]:
|
|
69
|
+
"""Extract field names from where: { field: ... } clauses."""
|
|
70
|
+
filters = []
|
|
71
|
+
remaining = text[start:start + 500]
|
|
72
|
+
where_match = re.search(r'where\s*:\s*\{([^}]+)\}', remaining)
|
|
73
|
+
if where_match:
|
|
74
|
+
for field in re.finditer(r'(\w+)\s*:', where_match.group(1)):
|
|
75
|
+
filters.append(field.group(1))
|
|
76
|
+
return filters
|