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,191 @@
|
|
|
1
|
+
"""Raw SQL adapter — detects database operations from raw SQL execution calls.
|
|
2
|
+
|
|
3
|
+
Detects patterns like:
|
|
4
|
+
Python: cursor.execute("SELECT * FROM users"), db.execute(text("INSERT INTO ..."))
|
|
5
|
+
JS/TS: pool.query("SELECT * FROM orders"), knex.raw("DELETE FROM sessions")
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from commiter.adapters.base import DBAdapter, DBOpMatch
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from tree_sitter import Tree
|
|
17
|
+
|
|
18
|
+
# Regex for SQL execution method calls across Python and JS
|
|
19
|
+
# Matches: cursor.execute(, db.execute(, pool.query(, client.query(, knex.raw(, etc.
|
|
20
|
+
_SQL_EXEC_RE = re.compile(
|
|
21
|
+
r'(?:cursor|stmt|db|conn|connection|pool|client|session|knex)'
|
|
22
|
+
r'\s*\.\s*'
|
|
23
|
+
r'(?:execute|query|raw|all|run|get|fetchall|fetchone|fetchmany)'
|
|
24
|
+
r'\s*\(',
|
|
25
|
+
re.IGNORECASE,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# SQL operation classification
|
|
29
|
+
_SQL_OP_RE = re.compile(
|
|
30
|
+
r'^\s*(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|TRUNCATE)',
|
|
31
|
+
re.IGNORECASE,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Table name extraction patterns
|
|
35
|
+
_TABLE_FROM_RE = re.compile(r'\bFROM\s+[`"\']?(\w+)[`"\']?', re.IGNORECASE)
|
|
36
|
+
_TABLE_INTO_RE = re.compile(r'\bINTO\s+[`"\']?(\w+)[`"\']?', re.IGNORECASE)
|
|
37
|
+
_TABLE_UPDATE_RE = re.compile(r'\bUPDATE\s+[`"\']?(\w+)[`"\']?', re.IGNORECASE)
|
|
38
|
+
_TABLE_JOIN_RE = re.compile(r'\bJOIN\s+[`"\']?(\w+)[`"\']?', re.IGNORECASE)
|
|
39
|
+
_TABLE_TRUNCATE_RE = re.compile(r'\bTRUNCATE\s+(?:TABLE\s+)?[`"\']?(\w+)[`"\']?', re.IGNORECASE)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RawSQLAdapter(DBAdapter):
|
|
43
|
+
"""Detects database operations from raw SQL strings in execute/query calls."""
|
|
44
|
+
|
|
45
|
+
orm_name = "raw_sql"
|
|
46
|
+
language = "python" # works for both Python and JS via regex on source text
|
|
47
|
+
|
|
48
|
+
def find_db_operations(self, tree: "Tree", source: bytes) -> list[DBOpMatch]:
|
|
49
|
+
ops = []
|
|
50
|
+
text = source.decode("utf-8", errors="replace")
|
|
51
|
+
|
|
52
|
+
for match in _SQL_EXEC_RE.finditer(text):
|
|
53
|
+
line = text[:match.start()].count("\n") + 1
|
|
54
|
+
|
|
55
|
+
# Extract the SQL string from the first argument
|
|
56
|
+
sql = self._extract_sql_string(text, match.end())
|
|
57
|
+
if not sql:
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
# Classify the operation
|
|
61
|
+
op_type = self._classify_sql(sql)
|
|
62
|
+
if not op_type:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
# Extract table names
|
|
66
|
+
tables = self._extract_table_names(sql)
|
|
67
|
+
if not tables:
|
|
68
|
+
# We found SQL but couldn't parse a table name
|
|
69
|
+
ops.append(DBOpMatch(
|
|
70
|
+
operation_type=op_type,
|
|
71
|
+
table_name="<dynamic>",
|
|
72
|
+
call_node=tree.root_node,
|
|
73
|
+
line=line,
|
|
74
|
+
))
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
# Create an operation for each table found
|
|
78
|
+
for table in tables:
|
|
79
|
+
filters = self._extract_where_fields(sql)
|
|
80
|
+
ops.append(DBOpMatch(
|
|
81
|
+
operation_type=op_type,
|
|
82
|
+
table_name=table,
|
|
83
|
+
call_node=tree.root_node,
|
|
84
|
+
line=line,
|
|
85
|
+
filters=filters,
|
|
86
|
+
))
|
|
87
|
+
|
|
88
|
+
return ops
|
|
89
|
+
|
|
90
|
+
def _extract_sql_string(self, text: str, start: int) -> str | None:
|
|
91
|
+
"""Extract the SQL string from the first argument after an opening paren.
|
|
92
|
+
|
|
93
|
+
Handles:
|
|
94
|
+
execute("SELECT ...")
|
|
95
|
+
execute(text("SELECT ..."))
|
|
96
|
+
execute(`SELECT ...`)
|
|
97
|
+
query('SELECT ...')
|
|
98
|
+
"""
|
|
99
|
+
remaining = text[start:start + 1000] # look ahead
|
|
100
|
+
|
|
101
|
+
# Skip whitespace
|
|
102
|
+
remaining = remaining.lstrip()
|
|
103
|
+
|
|
104
|
+
# Handle text() wrapper: execute(text("SELECT ..."))
|
|
105
|
+
if remaining.startswith("text("):
|
|
106
|
+
remaining = remaining[5:].lstrip()
|
|
107
|
+
|
|
108
|
+
# Find the string delimiter
|
|
109
|
+
if not remaining:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
delimiter = None
|
|
113
|
+
if remaining[0] in ("'", '"', "`"):
|
|
114
|
+
delimiter = remaining[0]
|
|
115
|
+
elif remaining[0] == 'f' and len(remaining) > 1 and remaining[1] in ("'", '"'):
|
|
116
|
+
# Python f-string: f"SELECT ..."
|
|
117
|
+
delimiter = remaining[1]
|
|
118
|
+
remaining = remaining[1:]
|
|
119
|
+
else:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
# Handle triple quotes
|
|
123
|
+
if remaining[:3] in ('"""', "'''"):
|
|
124
|
+
end_delim = remaining[:3]
|
|
125
|
+
content_start = 3
|
|
126
|
+
end_idx = remaining.find(end_delim, content_start)
|
|
127
|
+
if end_idx != -1:
|
|
128
|
+
return remaining[content_start:end_idx].strip()
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
# Single delimiter
|
|
132
|
+
content_start = 1
|
|
133
|
+
i = content_start
|
|
134
|
+
while i < len(remaining):
|
|
135
|
+
if remaining[i] == "\\" :
|
|
136
|
+
i += 2 # skip escaped char
|
|
137
|
+
continue
|
|
138
|
+
if remaining[i] == delimiter:
|
|
139
|
+
return remaining[content_start:i].strip()
|
|
140
|
+
i += 1
|
|
141
|
+
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def _classify_sql(self, sql: str) -> str | None:
|
|
145
|
+
"""Classify SQL statement type."""
|
|
146
|
+
match = _SQL_OP_RE.match(sql)
|
|
147
|
+
if not match:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
op = match.group(1).upper()
|
|
151
|
+
if op == "SELECT":
|
|
152
|
+
return "select"
|
|
153
|
+
elif op == "INSERT":
|
|
154
|
+
return "insert"
|
|
155
|
+
elif op == "UPDATE":
|
|
156
|
+
return "update"
|
|
157
|
+
elif op in ("DELETE", "TRUNCATE"):
|
|
158
|
+
return "delete"
|
|
159
|
+
elif op in ("CREATE", "ALTER", "DROP"):
|
|
160
|
+
return "ddl"
|
|
161
|
+
return "query"
|
|
162
|
+
|
|
163
|
+
def _extract_table_names(self, sql: str) -> list[str]:
|
|
164
|
+
"""Extract table names from a SQL statement."""
|
|
165
|
+
tables = []
|
|
166
|
+
seen = set()
|
|
167
|
+
|
|
168
|
+
for pattern in (_TABLE_FROM_RE, _TABLE_INTO_RE, _TABLE_UPDATE_RE, _TABLE_JOIN_RE, _TABLE_TRUNCATE_RE):
|
|
169
|
+
for match in pattern.finditer(sql):
|
|
170
|
+
table = match.group(1)
|
|
171
|
+
# Filter out SQL keywords that might match
|
|
172
|
+
if table.upper() not in ("SELECT", "WHERE", "SET", "VALUES", "AND", "OR",
|
|
173
|
+
"NOT", "NULL", "TABLE", "INDEX", "VIEW", "AS",
|
|
174
|
+
"ON", "IN", "EXISTS", "LIKE", "BETWEEN"):
|
|
175
|
+
if table not in seen:
|
|
176
|
+
seen.add(table)
|
|
177
|
+
tables.append(table)
|
|
178
|
+
|
|
179
|
+
return tables
|
|
180
|
+
|
|
181
|
+
def _extract_where_fields(self, sql: str) -> list[str]:
|
|
182
|
+
"""Extract column names from WHERE clause."""
|
|
183
|
+
filters = []
|
|
184
|
+
where_match = re.search(r'\bWHERE\b(.+?)(?:ORDER|GROUP|LIMIT|HAVING|$)', sql, re.IGNORECASE)
|
|
185
|
+
if where_match:
|
|
186
|
+
where_clause = where_match.group(1)
|
|
187
|
+
for col_match in re.finditer(r'(\w+)\s*(?:=|!=|<>|>|<|>=|<=|LIKE|IN|IS)\s', where_clause, re.IGNORECASE):
|
|
188
|
+
col = col_match.group(1)
|
|
189
|
+
if col.upper() not in ("AND", "OR", "NOT"):
|
|
190
|
+
filters.append(col)
|
|
191
|
+
return filters
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""React frontend adapter — detects fetch/axios API calls in 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 tree_sitter import Node
|
|
10
|
+
|
|
11
|
+
from commiter.adapters.base import FrontendAdapter, APICallMatch
|
|
12
|
+
from commiter.parser import node_text, find_nodes_by_type, resolve_url_from_node
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from tree_sitter import Tree
|
|
16
|
+
|
|
17
|
+
# HTTP methods for axios
|
|
18
|
+
AXIOS_METHODS = {"get", "post", "put", "delete", "patch", "request"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ReactAdapter(FrontendAdapter):
|
|
22
|
+
framework_name = "react"
|
|
23
|
+
language = "javascript" # handles JS/TS/JSX/TSX
|
|
24
|
+
|
|
25
|
+
def find_api_calls(self, tree: Tree, source: bytes, file_path: str = "",
|
|
26
|
+
constants: dict[str, str] | None = None) -> list[APICallMatch]:
|
|
27
|
+
calls: list[APICallMatch] = []
|
|
28
|
+
calls.extend(self._find_fetch_calls(tree.root_node, source, constants))
|
|
29
|
+
calls.extend(self._find_axios_calls(tree.root_node, source, constants))
|
|
30
|
+
return calls
|
|
31
|
+
|
|
32
|
+
def _find_fetch_calls(self, root_node: Node, source: bytes,
|
|
33
|
+
constants: dict[str, str] | None = None) -> list[APICallMatch]:
|
|
34
|
+
"""Find fetch() calls with static, template, and concatenated URLs."""
|
|
35
|
+
results = []
|
|
36
|
+
call_nodes = find_nodes_by_type(root_node, "call_expression")
|
|
37
|
+
|
|
38
|
+
for call in call_nodes:
|
|
39
|
+
func = call.child_by_field_name("function")
|
|
40
|
+
if not func:
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
func_text = node_text(func, source)
|
|
44
|
+
if func_text != "fetch":
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
args = call.child_by_field_name("arguments")
|
|
48
|
+
if not args:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
url = None
|
|
52
|
+
method = "GET"
|
|
53
|
+
|
|
54
|
+
for arg in args.children:
|
|
55
|
+
if arg.type in ("string", "template_string", "binary_expression", "identifier", "member_expression"):
|
|
56
|
+
if url is None: # first matching arg is the URL
|
|
57
|
+
url = resolve_url_from_node(arg, source, constants)
|
|
58
|
+
elif arg.type == "object":
|
|
59
|
+
obj_text = node_text(arg, source)
|
|
60
|
+
method_match = re.search(r'method\s*:\s*["\'](\w+)["\']', obj_text)
|
|
61
|
+
if method_match:
|
|
62
|
+
method = method_match.group(1).upper()
|
|
63
|
+
|
|
64
|
+
if url:
|
|
65
|
+
results.append(APICallMatch(
|
|
66
|
+
http_method=method,
|
|
67
|
+
url_pattern=url,
|
|
68
|
+
call_node=call,
|
|
69
|
+
line=call.start_point[0] + 1,
|
|
70
|
+
))
|
|
71
|
+
|
|
72
|
+
return results
|
|
73
|
+
|
|
74
|
+
def _find_axios_calls(self, root_node: Node, source: bytes,
|
|
75
|
+
constants: dict[str, str] | None = None) -> list[APICallMatch]:
|
|
76
|
+
"""Find axios.get(), axios.post(), etc. with dynamic URL resolution."""
|
|
77
|
+
results = []
|
|
78
|
+
call_nodes = find_nodes_by_type(root_node, "call_expression")
|
|
79
|
+
|
|
80
|
+
for call in call_nodes:
|
|
81
|
+
func = call.child_by_field_name("function")
|
|
82
|
+
if not func or func.type != "member_expression":
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
func_text = node_text(func, source)
|
|
86
|
+
|
|
87
|
+
match = re.match(r"(\w+)\.(get|post|put|delete|patch|request)", func_text)
|
|
88
|
+
if not match:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
obj_name = match.group(1)
|
|
92
|
+
if obj_name not in ("axios", "api", "client", "http", "axiosInstance", "apiClient"):
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
method = match.group(2).upper()
|
|
96
|
+
if method == "REQUEST":
|
|
97
|
+
method = "GET"
|
|
98
|
+
|
|
99
|
+
args = call.child_by_field_name("arguments")
|
|
100
|
+
if not args:
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
url = None
|
|
104
|
+
for arg in args.children:
|
|
105
|
+
if arg.type in ("string", "template_string", "binary_expression", "identifier", "member_expression"):
|
|
106
|
+
url = resolve_url_from_node(arg, source, constants)
|
|
107
|
+
if url:
|
|
108
|
+
break
|
|
109
|
+
|
|
110
|
+
if url:
|
|
111
|
+
response_type = self._extract_generic_type(call, source)
|
|
112
|
+
results.append(APICallMatch(
|
|
113
|
+
http_method=method,
|
|
114
|
+
url_pattern=url,
|
|
115
|
+
call_node=call,
|
|
116
|
+
line=call.start_point[0] + 1,
|
|
117
|
+
response_type=response_type,
|
|
118
|
+
))
|
|
119
|
+
|
|
120
|
+
return results
|
|
121
|
+
|
|
122
|
+
def _extract_generic_type(self, call_node: Node, source: bytes) -> str | None:
|
|
123
|
+
"""Extract generic type parameter from calls like axios.post<UserResponse>(...)."""
|
|
124
|
+
for child in call_node.children:
|
|
125
|
+
if child.type == "type_arguments":
|
|
126
|
+
for sub in child.children:
|
|
127
|
+
if sub.type not in ("<", ">", ","):
|
|
128
|
+
return node_text(sub, source).strip()
|
|
129
|
+
return None
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""SQLAlchemy adapter — detects SQLAlchemy 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
|
+
# SQLAlchemy session methods to operation types
|
|
14
|
+
SA_OP_MAP = {
|
|
15
|
+
"query": "select",
|
|
16
|
+
"add": "insert",
|
|
17
|
+
"add_all": "insert",
|
|
18
|
+
"merge": "upsert",
|
|
19
|
+
"delete": "delete",
|
|
20
|
+
"execute": "query",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SQLAlchemyAdapter(DBAdapter):
|
|
25
|
+
orm_name = "sqlalchemy"
|
|
26
|
+
language = "python"
|
|
27
|
+
|
|
28
|
+
def find_db_operations(self, tree: Tree, source: bytes) -> list[DBOpMatch]:
|
|
29
|
+
ops = []
|
|
30
|
+
text = source.decode("utf-8", errors="replace")
|
|
31
|
+
|
|
32
|
+
# Pattern 1: session.query(Model).filter_by(...) / session.query(Model).filter(...)
|
|
33
|
+
for match in re.finditer(
|
|
34
|
+
r'(?:session|db)\s*\.\s*query\s*\(\s*(\w+)\s*\)',
|
|
35
|
+
text,
|
|
36
|
+
):
|
|
37
|
+
model_name = match.group(1)
|
|
38
|
+
line = text[:match.start()].count("\n") + 1
|
|
39
|
+
filters = self._extract_sa_filters(text, match.end())
|
|
40
|
+
|
|
41
|
+
ops.append(DBOpMatch(
|
|
42
|
+
operation_type="select",
|
|
43
|
+
table_name=model_name,
|
|
44
|
+
call_node=tree.root_node,
|
|
45
|
+
line=line,
|
|
46
|
+
filters=filters,
|
|
47
|
+
))
|
|
48
|
+
|
|
49
|
+
# Pattern 2: session.add(instance), session.delete(instance)
|
|
50
|
+
for match in re.finditer(
|
|
51
|
+
r'(?:session|db)\s*\.\s*(add|add_all|delete|merge)\s*\(',
|
|
52
|
+
text,
|
|
53
|
+
):
|
|
54
|
+
method = match.group(1)
|
|
55
|
+
op_type = SA_OP_MAP.get(method, "unknown")
|
|
56
|
+
line = text[:match.start()].count("\n") + 1
|
|
57
|
+
|
|
58
|
+
ops.append(DBOpMatch(
|
|
59
|
+
operation_type=op_type,
|
|
60
|
+
table_name="<from_instance>",
|
|
61
|
+
call_node=tree.root_node,
|
|
62
|
+
line=line,
|
|
63
|
+
))
|
|
64
|
+
|
|
65
|
+
# Pattern 3: Model.query.filter_by(...) (Flask-SQLAlchemy style)
|
|
66
|
+
for match in re.finditer(
|
|
67
|
+
r'(\b[A-Z]\w+)\s*\.\s*query\s*\.',
|
|
68
|
+
text,
|
|
69
|
+
):
|
|
70
|
+
model_name = match.group(1)
|
|
71
|
+
line = text[:match.start()].count("\n") + 1
|
|
72
|
+
filters = self._extract_sa_filters(text, match.end())
|
|
73
|
+
|
|
74
|
+
ops.append(DBOpMatch(
|
|
75
|
+
operation_type="select",
|
|
76
|
+
table_name=model_name,
|
|
77
|
+
call_node=tree.root_node,
|
|
78
|
+
line=line,
|
|
79
|
+
filters=filters,
|
|
80
|
+
))
|
|
81
|
+
|
|
82
|
+
return ops
|
|
83
|
+
|
|
84
|
+
def _extract_sa_filters(self, text: str, start: int) -> list[str]:
|
|
85
|
+
"""Extract filter field names from .filter_by(field=...) or .filter(Model.field == ...)."""
|
|
86
|
+
filters = []
|
|
87
|
+
remaining = text[start:start + 500]
|
|
88
|
+
|
|
89
|
+
# filter_by(field=value)
|
|
90
|
+
fb_match = re.search(r'\.filter_by\s*\(([^)]+)\)', remaining)
|
|
91
|
+
if fb_match:
|
|
92
|
+
for field in re.finditer(r'(\w+)\s*=', fb_match.group(1)):
|
|
93
|
+
filters.append(field.group(1))
|
|
94
|
+
|
|
95
|
+
# filter(Model.field == value)
|
|
96
|
+
for match in re.finditer(r'\.filter\s*\([^)]*?\.(\w+)\s*[=!<>]', remaining):
|
|
97
|
+
filters.append(match.group(1))
|
|
98
|
+
|
|
99
|
+
return filters
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Supabase adapter — detects Supabase client DB operations (Python + JS)."""
|
|
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
|
+
from commiter.parser import node_text, find_nodes_by_type
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from tree_sitter import Tree
|
|
13
|
+
|
|
14
|
+
# Operation method names in Supabase client chain
|
|
15
|
+
OPERATION_MAP = {
|
|
16
|
+
"select": "select",
|
|
17
|
+
"insert": "insert",
|
|
18
|
+
"update": "update",
|
|
19
|
+
"delete": "delete",
|
|
20
|
+
"upsert": "upsert",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SupabaseAdapter(DBAdapter):
|
|
25
|
+
orm_name = "supabase"
|
|
26
|
+
language = "python" # works for both Python and JS
|
|
27
|
+
|
|
28
|
+
def find_db_operations(self, tree: Tree, source: bytes) -> list[DBOpMatch]:
|
|
29
|
+
ops = []
|
|
30
|
+
call_nodes = find_nodes_by_type(tree.root_node, "call_expression" if b"supabase" in source else "call")
|
|
31
|
+
|
|
32
|
+
# Use regex on source for reliability across both Python and JS ASTs
|
|
33
|
+
text = source.decode("utf-8", errors="replace")
|
|
34
|
+
|
|
35
|
+
# Match patterns like:
|
|
36
|
+
# supabase.table("users").select(...)
|
|
37
|
+
# supabase.from("users").select(...)
|
|
38
|
+
# supabase.from_("users").select(...) (Python client)
|
|
39
|
+
for match in re.finditer(
|
|
40
|
+
r'(?:supabase|db|client)\s*\.\s*(?:table|from_?)\s*\(\s*["\'](\w+)["\']\s*\)\s*\.\s*(\w+)',
|
|
41
|
+
text,
|
|
42
|
+
):
|
|
43
|
+
table_name = match.group(1)
|
|
44
|
+
method = match.group(2)
|
|
45
|
+
op_type = OPERATION_MAP.get(method)
|
|
46
|
+
if not op_type:
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
line = text[:match.start()].count("\n") + 1
|
|
50
|
+
filters = self._extract_filters(text, match.end())
|
|
51
|
+
|
|
52
|
+
ops.append(DBOpMatch(
|
|
53
|
+
operation_type=op_type,
|
|
54
|
+
table_name=table_name,
|
|
55
|
+
call_node=tree.root_node, # placeholder
|
|
56
|
+
line=line,
|
|
57
|
+
filters=filters,
|
|
58
|
+
))
|
|
59
|
+
|
|
60
|
+
return ops
|
|
61
|
+
|
|
62
|
+
def _extract_filters(self, text: str, start: int) -> list[str]:
|
|
63
|
+
"""Extract .eq(), .neq(), .gt() etc. filter calls after the operation."""
|
|
64
|
+
filters = []
|
|
65
|
+
remaining = text[start:start + 500] # look ahead
|
|
66
|
+
for match in re.finditer(r'\.\s*(eq|neq|gt|gte|lt|lte|in_|like|ilike|is_|contains)\s*\(\s*["\'](\w+)["\']', remaining):
|
|
67
|
+
filters.append(f"{match.group(1)}({match.group(2)})")
|
|
68
|
+
return filters
|
commiter/auth.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Browser-based CLI authentication for Commiter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import socket
|
|
7
|
+
import threading
|
|
8
|
+
import webbrowser
|
|
9
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from urllib.parse import urlparse, parse_qs
|
|
12
|
+
|
|
13
|
+
FRONTEND_URL = "https://commiter-three.vercel.app"
|
|
14
|
+
CREDENTIALS_DIR = Path.home() / ".config" / "commiter"
|
|
15
|
+
CREDENTIALS_FILE = CREDENTIALS_DIR / "credentials.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _find_free_port() -> int:
|
|
19
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
20
|
+
s.bind(("127.0.0.1", 0))
|
|
21
|
+
return s.getsockname()[1]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def login() -> bool:
|
|
25
|
+
"""Run the browser-based login flow.
|
|
26
|
+
|
|
27
|
+
Opens the browser to the Commiter consent page. A temporary local
|
|
28
|
+
HTTP server receives the callback with the CLI token.
|
|
29
|
+
|
|
30
|
+
Returns True on success, False on failure/timeout.
|
|
31
|
+
"""
|
|
32
|
+
# Check if already logged in
|
|
33
|
+
existing = get_saved_token()
|
|
34
|
+
if existing:
|
|
35
|
+
creds = _read_credentials()
|
|
36
|
+
email = creds.get("email", "unknown")
|
|
37
|
+
print(f"Already logged in as {email}")
|
|
38
|
+
print("Run commiter --logout first to switch accounts.")
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
port = _find_free_port()
|
|
42
|
+
result: dict = {}
|
|
43
|
+
|
|
44
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
45
|
+
def do_GET(self):
|
|
46
|
+
parsed = urlparse(self.path)
|
|
47
|
+
if parsed.path == "/callback":
|
|
48
|
+
params = parse_qs(parsed.query)
|
|
49
|
+
token = params.get("token", [None])[0]
|
|
50
|
+
email = params.get("email", [""])[0]
|
|
51
|
+
if token:
|
|
52
|
+
result["token"] = token
|
|
53
|
+
result["email"] = email
|
|
54
|
+
self.send_response(200)
|
|
55
|
+
self.send_header("Content-Type", "text/html")
|
|
56
|
+
self.end_headers()
|
|
57
|
+
self.wfile.write(
|
|
58
|
+
b"<html><body><h2>Login successful!</h2>"
|
|
59
|
+
b"<p>You can close this window and return to your terminal.</p>"
|
|
60
|
+
b"</body></html>"
|
|
61
|
+
)
|
|
62
|
+
else:
|
|
63
|
+
self.send_response(400)
|
|
64
|
+
self.send_header("Content-Type", "text/html")
|
|
65
|
+
self.end_headers()
|
|
66
|
+
self.wfile.write(b"<html><body><h2>Login failed</h2><p>No token received.</p></body></html>")
|
|
67
|
+
else:
|
|
68
|
+
self.send_response(404)
|
|
69
|
+
self.end_headers()
|
|
70
|
+
|
|
71
|
+
def log_message(self, format, *args):
|
|
72
|
+
pass # Suppress request logs
|
|
73
|
+
|
|
74
|
+
server = HTTPServer(("127.0.0.1", port), CallbackHandler)
|
|
75
|
+
server.timeout = 120 # 2 minute timeout
|
|
76
|
+
|
|
77
|
+
authorize_url = f"{FRONTEND_URL}/cli/authorize?port={port}"
|
|
78
|
+
print(f"Opening browser to log in...")
|
|
79
|
+
print(f"If the browser doesn't open, visit: {authorize_url}")
|
|
80
|
+
webbrowser.open(authorize_url)
|
|
81
|
+
|
|
82
|
+
# Wait for the callback
|
|
83
|
+
def serve():
|
|
84
|
+
while not result and server.timeout > 0:
|
|
85
|
+
server.handle_request()
|
|
86
|
+
|
|
87
|
+
thread = threading.Thread(target=serve, daemon=True)
|
|
88
|
+
thread.start()
|
|
89
|
+
thread.join(timeout=120)
|
|
90
|
+
server.server_close()
|
|
91
|
+
|
|
92
|
+
if result.get("token"):
|
|
93
|
+
_save_credentials(result["token"], result.get("email", ""))
|
|
94
|
+
print(f"Logged in as {result.get('email', 'unknown')}")
|
|
95
|
+
return True
|
|
96
|
+
else:
|
|
97
|
+
print("Login timed out or was denied.")
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def logout() -> None:
|
|
102
|
+
"""Remove saved credentials."""
|
|
103
|
+
if CREDENTIALS_FILE.exists():
|
|
104
|
+
CREDENTIALS_FILE.unlink()
|
|
105
|
+
print("Logged out successfully.")
|
|
106
|
+
else:
|
|
107
|
+
print("Not currently logged in.")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_saved_token() -> str | None:
|
|
111
|
+
"""Read the saved CLI token. Returns None if missing or expired."""
|
|
112
|
+
creds = _read_credentials()
|
|
113
|
+
return creds.get("token") if creds else None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _read_credentials() -> dict | None:
|
|
117
|
+
if not CREDENTIALS_FILE.exists():
|
|
118
|
+
return None
|
|
119
|
+
try:
|
|
120
|
+
return json.loads(CREDENTIALS_FILE.read_text())
|
|
121
|
+
except (json.JSONDecodeError, OSError):
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _save_credentials(token: str, email: str) -> None:
|
|
126
|
+
CREDENTIALS_DIR.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
CREDENTIALS_FILE.write_text(json.dumps({
|
|
128
|
+
"token": token,
|
|
129
|
+
"email": email,
|
|
130
|
+
}, indent=2))
|