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.
Files changed (96) hide show
  1. commiter/__init__.py +3 -0
  2. commiter/adapters/__init__.py +0 -0
  3. commiter/adapters/base.py +96 -0
  4. commiter/adapters/django_rest.py +247 -0
  5. commiter/adapters/express.py +204 -0
  6. commiter/adapters/fastapi.py +170 -0
  7. commiter/adapters/flask.py +169 -0
  8. commiter/adapters/nextjs.py +180 -0
  9. commiter/adapters/prisma.py +76 -0
  10. commiter/adapters/raw_sql.py +191 -0
  11. commiter/adapters/react.py +129 -0
  12. commiter/adapters/sqlalchemy.py +99 -0
  13. commiter/adapters/supabase.py +68 -0
  14. commiter/auth.py +130 -0
  15. commiter/cli.py +667 -0
  16. commiter/correlator.py +208 -0
  17. commiter/extractors/__init__.py +0 -0
  18. commiter/extractors/api_calls.py +91 -0
  19. commiter/extractors/api_endpoints.py +354 -0
  20. commiter/extractors/backend_files.py +33 -0
  21. commiter/extractors/base.py +40 -0
  22. commiter/extractors/db_operations.py +69 -0
  23. commiter/extractors/dependencies.py +219 -0
  24. commiter/generic_resolver.py +204 -0
  25. commiter/handler_index.py +97 -0
  26. commiter/lib.py +63 -0
  27. commiter/middleware_index.py +350 -0
  28. commiter/models.py +117 -0
  29. commiter/parser.py +1283 -0
  30. commiter/prefix_index.py +211 -0
  31. commiter/report/__init__.py +0 -0
  32. commiter/report/ai.py +120 -0
  33. commiter/report/api_guide.py +217 -0
  34. commiter/report/architecture.py +930 -0
  35. commiter/report/console.py +254 -0
  36. commiter/report/json_output.py +122 -0
  37. commiter/report/markdown.py +163 -0
  38. commiter/scanner.py +383 -0
  39. commiter/type_index.py +304 -0
  40. commiter/uploader.py +46 -0
  41. commiter/utils/__init__.py +0 -0
  42. commiter/utils/env_reader.py +78 -0
  43. commiter/utils/file_classifier.py +187 -0
  44. commiter/utils/path_helpers.py +73 -0
  45. commiter/utils/tsconfig_resolver.py +281 -0
  46. commiter/wrapper_index.py +288 -0
  47. commiter_cli-0.3.0.dist-info/METADATA +14 -0
  48. commiter_cli-0.3.0.dist-info/RECORD +96 -0
  49. commiter_cli-0.3.0.dist-info/WHEEL +5 -0
  50. commiter_cli-0.3.0.dist-info/entry_points.txt +2 -0
  51. commiter_cli-0.3.0.dist-info/top_level.txt +2 -0
  52. tests/__init__.py +0 -0
  53. tests/fixtures/arch_backend/app.py +22 -0
  54. tests/fixtures/arch_backend/middleware/__init__.py +0 -0
  55. tests/fixtures/arch_backend/middleware/rate_limit.py +4 -0
  56. tests/fixtures/arch_backend/routes/__init__.py +0 -0
  57. tests/fixtures/arch_backend/routes/analytics.py +20 -0
  58. tests/fixtures/arch_backend/routes/auth.py +29 -0
  59. tests/fixtures/arch_backend/routes/projects.py +60 -0
  60. tests/fixtures/arch_backend/routes/users.py +55 -0
  61. tests/fixtures/arch_monorepo/apps/api/app.py +30 -0
  62. tests/fixtures/arch_monorepo/apps/api/middleware/__init__.py +0 -0
  63. tests/fixtures/arch_monorepo/apps/api/middleware/auth.py +17 -0
  64. tests/fixtures/arch_monorepo/apps/api/middleware/rate_limit.py +10 -0
  65. tests/fixtures/arch_monorepo/apps/api/routes/__init__.py +0 -0
  66. tests/fixtures/arch_monorepo/apps/api/routes/auth.py +46 -0
  67. tests/fixtures/arch_monorepo/apps/api/routes/invites.py +30 -0
  68. tests/fixtures/arch_monorepo/apps/api/routes/notifications.py +25 -0
  69. tests/fixtures/arch_monorepo/apps/api/routes/projects.py +80 -0
  70. tests/fixtures/arch_monorepo/apps/api/routes/tasks.py +91 -0
  71. tests/fixtures/arch_monorepo/apps/api/routes/users.py +48 -0
  72. tests/fixtures/arch_monorepo/apps/api/services/__init__.py +0 -0
  73. tests/fixtures/arch_monorepo/apps/api/services/email.py +11 -0
  74. tests/fixtures/backend_b/app.py +17 -0
  75. tests/fixtures/fastapi_app/app.py +48 -0
  76. tests/fixtures/fastapi_crossfile/routes.py +18 -0
  77. tests/fixtures/fastapi_crossfile/schemas.py +21 -0
  78. tests/fixtures/flask_app/app.py +33 -0
  79. tests/fixtures/flask_blueprint/app.py +7 -0
  80. tests/fixtures/flask_blueprint/routes/items.py +13 -0
  81. tests/fixtures/flask_blueprint/routes/users.py +20 -0
  82. tests/fixtures/middleware_test_flask/routes/public.py +8 -0
  83. tests/fixtures/middleware_test_flask/routes/users.py +26 -0
  84. tests/fixtures/python_deep_imports/app/__init__.py +0 -0
  85. tests/fixtures/python_deep_imports/app/api/__init__.py +0 -0
  86. tests/fixtures/python_deep_imports/app/api/health.py +11 -0
  87. tests/fixtures/python_deep_imports/app/api/v1/__init__.py +0 -0
  88. tests/fixtures/python_deep_imports/app/api/v1/items.py +18 -0
  89. tests/fixtures/python_deep_imports/app/api/v1/users.py +27 -0
  90. tests/fixtures/python_deep_imports/app/schemas/__init__.py +0 -0
  91. tests/fixtures/python_deep_imports/app/schemas/item.py +13 -0
  92. tests/fixtures/python_deep_imports/app/schemas/user.py +15 -0
  93. tests/fixtures/python_deep_imports/app/shared/__init__.py +0 -0
  94. tests/fixtures/python_deep_imports/app/shared/models.py +7 -0
  95. tests/fixtures/raw_sql_test/app.py +54 -0
  96. 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