codebread 1.0.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.
- codebread/__init__.py +3 -0
- codebread/analyzer.py +84 -0
- codebread/classifier.py +102 -0
- codebread/cli.py +106 -0
- codebread/connections.py +407 -0
- codebread/diff.py +123 -0
- codebread/export.py +45 -0
- codebread/languages.py +110 -0
- codebread/models.py +119 -0
- codebread/parsers/__init__.py +53 -0
- codebread/parsers/generic_parser.py +288 -0
- codebread/parsers/javascript_parser.py +371 -0
- codebread/parsers/python_parser.py +291 -0
- codebread/scanner.py +206 -0
- codebread/server.py +86 -0
- codebread/web/app.js +1716 -0
- codebread/web/index.html +188 -0
- codebread/web/style.css +601 -0
- codebread-1.0.0.dist-info/METADATA +254 -0
- codebread-1.0.0.dist-info/RECORD +24 -0
- codebread-1.0.0.dist-info/WHEEL +5 -0
- codebread-1.0.0.dist-info/entry_points.txt +2 -0
- codebread-1.0.0.dist-info/licenses/LICENSE +21 -0
- codebread-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""JavaScript / TypeScript extractor (regex + brace matching).
|
|
2
|
+
|
|
3
|
+
Handles: function declarations, arrow/const functions, classes + methods,
|
|
4
|
+
imports, Express/Fastify/Koa route definitions, NestJS decorators,
|
|
5
|
+
fetch/axios API calls, and Mongoose/Prisma/Sequelize/Knex/raw-SQL DB usage.
|
|
6
|
+
Also parses the <script> block of .vue / .svelte single-file components.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from ..models import (ApiCall, DbRef, FileInfo, FunctionInfo, Route,
|
|
14
|
+
TableInfo, describe)
|
|
15
|
+
|
|
16
|
+
JS_KEYWORDS = {
|
|
17
|
+
"if", "for", "while", "switch", "catch", "return", "typeof", "new",
|
|
18
|
+
"function", "await", "async", "else", "do", "try", "throw", "delete",
|
|
19
|
+
"in", "of", "instanceof", "void", "yield", "case", "break", "continue",
|
|
20
|
+
"super", "this", "import", "export", "default", "constructor",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
RE_FUNC_DECL = re.compile(
|
|
24
|
+
r"^[ \t]*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*"
|
|
25
|
+
r"([A-Za-z_$][\w$]*)\s*\(([^)]*)\)", re.MULTILINE)
|
|
26
|
+
RE_ARROW = re.compile(
|
|
27
|
+
r"^[ \t]*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*"
|
|
28
|
+
r"(?::[^=]{0,80})?=\s*(?:async\s*)?"
|
|
29
|
+
r"(?:\(([^)]*)\)|([A-Za-z_$][\w$]*))\s*(?::\s*[\w<>\[\]., |]+)?\s*=>",
|
|
30
|
+
re.MULTILINE)
|
|
31
|
+
RE_CLASS = re.compile(
|
|
32
|
+
r"^[ \t]*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+"
|
|
33
|
+
r"([A-Za-z_$][\w$]*)(?:\s+extends\s+([\w$.]+))?", re.MULTILINE)
|
|
34
|
+
RE_METHOD = re.compile(
|
|
35
|
+
r"^[ \t]*(?:public\s+|private\s+|protected\s+|readonly\s+)*"
|
|
36
|
+
r"(?:static\s+)?(?:async\s+)?\*?\s*([A-Za-z_$][\w$]*)\s*\(([^)]*)\)\s*"
|
|
37
|
+
r"(?::\s*[\w<>\[\]., |&{}]+)?\s*\{", re.MULTILINE)
|
|
38
|
+
RE_IMPORT = re.compile(
|
|
39
|
+
r"""(?:import\s+(?:[\w${},*\s]+\s+from\s+)?|require\s*\(\s*)"""
|
|
40
|
+
r"""['"]([^'"]+)['"]""")
|
|
41
|
+
RE_ROUTE = re.compile(
|
|
42
|
+
r"\b(?:app|router|server|api|fastify|routes?)\s*\.\s*"
|
|
43
|
+
r"(get|post|put|delete|patch|all|options|head)\s*\(\s*[`'\"]([^`'\"]+)[`'\"]"
|
|
44
|
+
r"\s*,\s*([A-Za-z_$][\w$.]*)?", re.IGNORECASE)
|
|
45
|
+
RE_NEST_ROUTE = re.compile(
|
|
46
|
+
r"@(Get|Post|Put|Delete|Patch|All)\s*\(\s*(?:[`'\"]([^`'\"]*)[`'\"])?\s*\)")
|
|
47
|
+
RE_FETCH = re.compile(r"\bfetch\s*\(\s*[`'\"]([^`'\"]+)[`'\"]")
|
|
48
|
+
RE_FETCH_METHOD = re.compile(r"method\s*:\s*[`'\"](\w+)[`'\"]", re.IGNORECASE)
|
|
49
|
+
RE_AXIOS = re.compile(
|
|
50
|
+
r"\b(?:axios|http|api|apiClient|client|\$http)\s*\.\s*"
|
|
51
|
+
r"(get|post|put|delete|patch)\s*\(\s*[`'\"]([^`'\"]+)[`'\"]",
|
|
52
|
+
re.IGNORECASE)
|
|
53
|
+
RE_AXIOS_OBJ = re.compile(
|
|
54
|
+
r"\baxios\s*\(\s*\{[^}]*?url\s*:\s*[`'\"]([^`'\"]+)[`'\"]", re.DOTALL)
|
|
55
|
+
RE_MONGOOSE = re.compile(r"mongoose\.model\s*\(\s*[`'\"](\w+)[`'\"]")
|
|
56
|
+
RE_MONGOOSE_SCHEMA = re.compile(
|
|
57
|
+
r"new\s+(?:mongoose\.)?Schema\s*\(\s*\{([^}]*)\}", re.DOTALL)
|
|
58
|
+
RE_PRISMA = re.compile(
|
|
59
|
+
r"\bprisma\.(\w+)\.(findMany|findUnique|findFirst|create|createMany|"
|
|
60
|
+
r"update|updateMany|delete|deleteMany|upsert|count|aggregate)\b")
|
|
61
|
+
RE_SEQUELIZE = re.compile(r"sequelize\.define\s*\(\s*[`'\"](\w+)[`'\"]")
|
|
62
|
+
RE_KNEX = re.compile(r"\bknex\s*\(\s*[`'\"](\w+)[`'\"]")
|
|
63
|
+
RE_TYPEORM_ENTITY = re.compile(r"@Entity\s*\(\s*(?:[`'\"](\w+)[`'\"])?\s*\)")
|
|
64
|
+
RE_SQL = re.compile(
|
|
65
|
+
r"\b(SELECT|INSERT|UPDATE|DELETE)\b[\s\S]{0,200}?"
|
|
66
|
+
r"\b(?:FROM|INTO|UPDATE|JOIN)\s+[`\"\[]?([A-Za-z_][A-Za-z0-9_.]*)",
|
|
67
|
+
re.IGNORECASE)
|
|
68
|
+
RE_MODEL_OP = re.compile(
|
|
69
|
+
r"\b([A-Z][\w$]*)\.(find|findOne|findById|findAll|findMany|create|"
|
|
70
|
+
r"insertMany|updateOne|updateMany|findByIdAndUpdate|findByIdAndDelete|"
|
|
71
|
+
r"deleteOne|deleteMany|destroy|save|aggregate|countDocuments)\b")
|
|
72
|
+
RE_CALL = re.compile(r"(?<![\w$.])([A-Za-z_$][\w$]*)\s*\(")
|
|
73
|
+
|
|
74
|
+
OP_MAP = {"create": "insert", "insertmany": "insert", "save": "insert",
|
|
75
|
+
"update": "update", "updateone": "update", "updatemany": "update",
|
|
76
|
+
"findbyidandupdate": "update", "delete": "delete",
|
|
77
|
+
"deleteone": "delete", "deletemany": "delete", "destroy": "delete",
|
|
78
|
+
"findbyidanddelete": "delete"}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _strip_comments(text: str) -> str:
|
|
82
|
+
"""Blank out comments and (roughly) string contents so structural regexes
|
|
83
|
+
don't fire inside them — but keep offsets/line numbers identical."""
|
|
84
|
+
out = list(text)
|
|
85
|
+
i, n = 0, len(text)
|
|
86
|
+
in_str: Optional[str] = None
|
|
87
|
+
while i < n:
|
|
88
|
+
c = text[i]
|
|
89
|
+
if in_str:
|
|
90
|
+
if c == "\\":
|
|
91
|
+
i += 2
|
|
92
|
+
continue
|
|
93
|
+
if c == in_str:
|
|
94
|
+
in_str = None
|
|
95
|
+
i += 1
|
|
96
|
+
continue
|
|
97
|
+
if c in "'\"`":
|
|
98
|
+
in_str = c
|
|
99
|
+
i += 1
|
|
100
|
+
continue
|
|
101
|
+
if c == "/" and i + 1 < n and text[i + 1] == "/":
|
|
102
|
+
j = text.find("\n", i)
|
|
103
|
+
j = n if j == -1 else j
|
|
104
|
+
for k in range(i, j):
|
|
105
|
+
out[k] = " "
|
|
106
|
+
i = j
|
|
107
|
+
continue
|
|
108
|
+
if c == "/" and i + 1 < n and text[i + 1] == "*":
|
|
109
|
+
j = text.find("*/", i + 2)
|
|
110
|
+
j = n if j == -1 else j + 2
|
|
111
|
+
for k in range(i, j):
|
|
112
|
+
if out[k] != "\n":
|
|
113
|
+
out[k] = " "
|
|
114
|
+
i = j
|
|
115
|
+
continue
|
|
116
|
+
i += 1
|
|
117
|
+
return "".join(out)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _match_brace(text: str, open_idx: int) -> int:
|
|
121
|
+
"""Index of the `}` matching the `{` at open_idx. -1 if unbalanced."""
|
|
122
|
+
depth = 0
|
|
123
|
+
i, n = open_idx, len(text)
|
|
124
|
+
in_str: Optional[str] = None
|
|
125
|
+
while i < n:
|
|
126
|
+
c = text[i]
|
|
127
|
+
if in_str:
|
|
128
|
+
if c == "\\":
|
|
129
|
+
i += 2
|
|
130
|
+
continue
|
|
131
|
+
if c == in_str:
|
|
132
|
+
in_str = None
|
|
133
|
+
elif c in "'\"`":
|
|
134
|
+
in_str = c
|
|
135
|
+
elif c == "/" and i + 1 < n and text[i + 1] == "/":
|
|
136
|
+
nl = text.find("\n", i)
|
|
137
|
+
if nl == -1:
|
|
138
|
+
return -1
|
|
139
|
+
i = nl
|
|
140
|
+
elif c == "/" and i + 1 < n and text[i + 1] == "*":
|
|
141
|
+
end = text.find("*/", i + 2)
|
|
142
|
+
if end == -1:
|
|
143
|
+
return -1
|
|
144
|
+
i = end + 1
|
|
145
|
+
elif c == "{":
|
|
146
|
+
depth += 1
|
|
147
|
+
elif c == "}":
|
|
148
|
+
depth -= 1
|
|
149
|
+
if depth == 0:
|
|
150
|
+
return i
|
|
151
|
+
i += 1
|
|
152
|
+
return -1
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _line_of(text: str, idx: int) -> int:
|
|
156
|
+
return text.count("\n", 0, idx) + 1
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _body_range(clean: str, start_idx: int) -> Tuple[int, int]:
|
|
160
|
+
"""(start, end) offsets of the function body starting at/after start_idx."""
|
|
161
|
+
brace = clean.find("{", start_idx)
|
|
162
|
+
arrow = clean.find("=>", start_idx)
|
|
163
|
+
if arrow != -1 and (brace == -1 or arrow < brace):
|
|
164
|
+
after = arrow + 2
|
|
165
|
+
b2 = clean.find("{", after)
|
|
166
|
+
nl = clean.find("\n", after)
|
|
167
|
+
if b2 != -1 and (nl == -1 or b2 < nl) and clean[after:b2].strip() == "":
|
|
168
|
+
end = _match_brace(clean, b2)
|
|
169
|
+
return (b2, end if end != -1 else min(len(clean), b2 + 400))
|
|
170
|
+
# expression-body arrow fn: body = rest of statement (approx one line)
|
|
171
|
+
end = nl if nl != -1 else len(clean)
|
|
172
|
+
return (after, end)
|
|
173
|
+
if brace == -1:
|
|
174
|
+
return (start_idx, min(len(clean), start_idx + 200))
|
|
175
|
+
end = _match_brace(clean, brace)
|
|
176
|
+
return (brace, end if end != -1 else min(len(clean), brace + 2000))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _extract_script(text: str) -> str:
|
|
180
|
+
"""For .vue/.svelte: keep only <script> content, preserving line offsets."""
|
|
181
|
+
out_lines = [""] * (text.count("\n") + 1)
|
|
182
|
+
for m in re.finditer(r"<script[^>]*>([\s\S]*?)</script>", text,
|
|
183
|
+
re.IGNORECASE):
|
|
184
|
+
start_line = text.count("\n", 0, m.start(1))
|
|
185
|
+
for i, line in enumerate(m.group(1).split("\n")):
|
|
186
|
+
if start_line + i < len(out_lines):
|
|
187
|
+
out_lines[start_line + i] = line
|
|
188
|
+
return "\n".join(out_lines)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _scan_body(raw_body: str, base_line: int, fn: FunctionInfo):
|
|
192
|
+
"""Populate calls / api_calls / db_refs from a function body (raw text)."""
|
|
193
|
+
for m in RE_FETCH.finditer(raw_body):
|
|
194
|
+
window = raw_body[m.end():m.end() + 220]
|
|
195
|
+
mm = RE_FETCH_METHOD.search(window)
|
|
196
|
+
method = mm.group(1).upper() if mm else "GET"
|
|
197
|
+
fn.api_calls.append(ApiCall(method=method, url=m.group(1),
|
|
198
|
+
line=base_line + raw_body.count("\n", 0, m.start())))
|
|
199
|
+
for m in RE_AXIOS.finditer(raw_body):
|
|
200
|
+
fn.api_calls.append(ApiCall(method=m.group(1).upper(), url=m.group(2),
|
|
201
|
+
line=base_line + raw_body.count("\n", 0, m.start())))
|
|
202
|
+
for m in RE_AXIOS_OBJ.finditer(raw_body):
|
|
203
|
+
fn.api_calls.append(ApiCall(method="ANY", url=m.group(1),
|
|
204
|
+
line=base_line + raw_body.count("\n", 0, m.start())))
|
|
205
|
+
for m in RE_PRISMA.finditer(raw_body):
|
|
206
|
+
fn.db_refs.append(DbRef(table=m.group(1),
|
|
207
|
+
op=OP_MAP.get(m.group(2).lower(), "query"),
|
|
208
|
+
via="orm",
|
|
209
|
+
line=base_line + raw_body.count("\n", 0, m.start())))
|
|
210
|
+
for m in RE_MODEL_OP.finditer(raw_body):
|
|
211
|
+
name = m.group(1)
|
|
212
|
+
if name in ("Object", "Array", "JSON", "Math", "Promise", "Date",
|
|
213
|
+
"Number", "String", "Boolean", "Map", "Set", "Symbol",
|
|
214
|
+
"Reflect", "Proxy", "RegExp", "Error", "Intl"):
|
|
215
|
+
continue
|
|
216
|
+
fn.db_refs.append(DbRef(table=name,
|
|
217
|
+
op=OP_MAP.get(m.group(2).lower(), "query"),
|
|
218
|
+
via="orm",
|
|
219
|
+
line=base_line + raw_body.count("\n", 0, m.start())))
|
|
220
|
+
for m in RE_KNEX.finditer(raw_body):
|
|
221
|
+
fn.db_refs.append(DbRef(table=m.group(1), op="query", via="orm",
|
|
222
|
+
line=base_line + raw_body.count("\n", 0, m.start())))
|
|
223
|
+
for m in RE_SQL.finditer(raw_body):
|
|
224
|
+
fn.db_refs.append(DbRef(table=m.group(2), op=m.group(1).lower(),
|
|
225
|
+
via="sql",
|
|
226
|
+
line=base_line + raw_body.count("\n", 0, m.start())))
|
|
227
|
+
clean_body = _strip_comments(raw_body)
|
|
228
|
+
seen = set(fn.calls)
|
|
229
|
+
for m in RE_CALL.finditer(clean_body):
|
|
230
|
+
name = m.group(1)
|
|
231
|
+
if name in JS_KEYWORDS or name == fn.name or name in seen:
|
|
232
|
+
continue
|
|
233
|
+
seen.add(name)
|
|
234
|
+
fn.calls.append(name)
|
|
235
|
+
# de-dup db refs
|
|
236
|
+
seen_db = set()
|
|
237
|
+
fn.db_refs = [d for d in fn.db_refs
|
|
238
|
+
if not ((d.table, d.op) in seen_db or seen_db.add((d.table, d.op)))]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def parse_javascript(info: FileInfo, text: str, language: str) -> None:
|
|
242
|
+
if language in ("vue", "svelte"):
|
|
243
|
+
text = _extract_script(text)
|
|
244
|
+
clean = _strip_comments(text)
|
|
245
|
+
covered: List[Tuple[int, int]] = [] # body ranges already owned by a fn
|
|
246
|
+
|
|
247
|
+
for m in RE_IMPORT.finditer(text):
|
|
248
|
+
info.imports.append(m.group(1))
|
|
249
|
+
info.imports = sorted(set(info.imports))
|
|
250
|
+
|
|
251
|
+
def add_function(name: str, params: str, decl_start: int,
|
|
252
|
+
parent_class: str = ""):
|
|
253
|
+
body_start, body_end = _body_range(clean, decl_start)
|
|
254
|
+
fn = FunctionInfo(
|
|
255
|
+
name=name,
|
|
256
|
+
params=[p.strip() for p in params.split(",") if p.strip()],
|
|
257
|
+
line=_line_of(text, decl_start),
|
|
258
|
+
end_line=_line_of(text, min(body_end, len(text) - 1) if text else 0),
|
|
259
|
+
parent_class=parent_class,
|
|
260
|
+
kind="method" if parent_class else "function",
|
|
261
|
+
)
|
|
262
|
+
raw_body = text[body_start:body_end + 1]
|
|
263
|
+
_scan_body(raw_body, fn.line - 1 + text[decl_start:body_start].count("\n"),
|
|
264
|
+
fn)
|
|
265
|
+
# routes attached via decorator on the preceding lines (NestJS)
|
|
266
|
+
prefix = text[max(0, decl_start - 200):decl_start]
|
|
267
|
+
nm = RE_NEST_ROUTE.search(prefix)
|
|
268
|
+
if nm:
|
|
269
|
+
fn.routes.append(Route(method=nm.group(1).upper(),
|
|
270
|
+
path="/" + (nm.group(2) or "").strip("/"),
|
|
271
|
+
line=fn.line))
|
|
272
|
+
fn.description = describe(fn, raw_body[:600])
|
|
273
|
+
info.functions.append(fn)
|
|
274
|
+
covered.append((decl_start, body_end))
|
|
275
|
+
return fn
|
|
276
|
+
|
|
277
|
+
# classes first (so methods get parent_class and aren't re-matched)
|
|
278
|
+
for cm in RE_CLASS.finditer(clean):
|
|
279
|
+
cls_name = cm.group(1)
|
|
280
|
+
brace = clean.find("{", cm.end())
|
|
281
|
+
if brace == -1:
|
|
282
|
+
continue
|
|
283
|
+
cend = _match_brace(clean, brace)
|
|
284
|
+
if cend == -1:
|
|
285
|
+
continue
|
|
286
|
+
# TypeORM @Entity above the class -> table definition
|
|
287
|
+
prefix = clean[max(0, cm.start() - 160):cm.start()]
|
|
288
|
+
em = RE_TYPEORM_ENTITY.search(prefix)
|
|
289
|
+
if em:
|
|
290
|
+
body_txt = text[brace:cend]
|
|
291
|
+
cols = re.findall(r"@(?:Primary\w*Column|Column|OneToMany|"
|
|
292
|
+
r"ManyToOne|ManyToMany|OneToOne)[^\n]*\n\s*"
|
|
293
|
+
r"([A-Za-z_$][\w$]*)", body_txt)
|
|
294
|
+
info.tables.append(TableInfo(name=em.group(1) or cls_name.lower(),
|
|
295
|
+
model=cls_name, fields=cols,
|
|
296
|
+
line=_line_of(text, cm.start()),
|
|
297
|
+
source=info.path))
|
|
298
|
+
for mm in RE_METHOD.finditer(clean[brace:cend]):
|
|
299
|
+
name = mm.group(1)
|
|
300
|
+
if name in JS_KEYWORDS:
|
|
301
|
+
continue
|
|
302
|
+
add_function(name, mm.group(2), brace + mm.start(), cls_name)
|
|
303
|
+
covered.append((cm.start(), cend))
|
|
304
|
+
|
|
305
|
+
def in_class(idx: int) -> bool:
|
|
306
|
+
return any(s <= idx <= e for s, e in covered)
|
|
307
|
+
|
|
308
|
+
for m in RE_FUNC_DECL.finditer(clean):
|
|
309
|
+
if not in_class(m.start()):
|
|
310
|
+
add_function(m.group(1), m.group(2), m.start())
|
|
311
|
+
for m in RE_ARROW.finditer(clean):
|
|
312
|
+
if not in_class(m.start()):
|
|
313
|
+
add_function(m.group(1), m.group(2) or m.group(3) or "", m.start())
|
|
314
|
+
|
|
315
|
+
# route definitions (Express-style) — attach to enclosing fn or file level
|
|
316
|
+
for m in RE_ROUTE.finditer(clean):
|
|
317
|
+
route = Route(method=m.group(1).upper(),
|
|
318
|
+
path=m.group(2),
|
|
319
|
+
line=_line_of(text, m.start()),
|
|
320
|
+
handler=(m.group(3) or "").split(".")[-1])
|
|
321
|
+
owner = None
|
|
322
|
+
for fn in info.functions:
|
|
323
|
+
if fn.line <= route.line <= max(fn.end_line, fn.line):
|
|
324
|
+
if owner is None or fn.line > owner.line:
|
|
325
|
+
owner = fn
|
|
326
|
+
if owner is not None and not route.handler:
|
|
327
|
+
owner.routes.append(route)
|
|
328
|
+
else:
|
|
329
|
+
info.routes.append(route)
|
|
330
|
+
|
|
331
|
+
# DB model definitions at file level
|
|
332
|
+
for m in RE_MONGOOSE.finditer(clean):
|
|
333
|
+
fields: List[str] = []
|
|
334
|
+
sm = RE_MONGOOSE_SCHEMA.search(clean)
|
|
335
|
+
if sm:
|
|
336
|
+
fields = re.findall(r"([A-Za-z_$][\w$]*)\s*:", sm.group(1))[:20]
|
|
337
|
+
info.tables.append(TableInfo(name=m.group(1).lower(), model=m.group(1),
|
|
338
|
+
fields=fields,
|
|
339
|
+
line=_line_of(text, m.start()),
|
|
340
|
+
source=info.path))
|
|
341
|
+
for m in RE_SEQUELIZE.finditer(clean):
|
|
342
|
+
info.tables.append(TableInfo(name=m.group(1), model=m.group(1),
|
|
343
|
+
line=_line_of(text, m.start()),
|
|
344
|
+
source=info.path))
|
|
345
|
+
|
|
346
|
+
# file-level API calls (outside any extracted function)
|
|
347
|
+
def outside_functions(line: int) -> bool:
|
|
348
|
+
return not any(f.line <= line <= max(f.end_line, f.line)
|
|
349
|
+
for f in info.functions)
|
|
350
|
+
|
|
351
|
+
for m in RE_FETCH.finditer(text):
|
|
352
|
+
line = _line_of(text, m.start())
|
|
353
|
+
if outside_functions(line):
|
|
354
|
+
info.api_calls.append(ApiCall(method="GET", url=m.group(1),
|
|
355
|
+
line=line))
|
|
356
|
+
for m in RE_AXIOS.finditer(text):
|
|
357
|
+
line = _line_of(text, m.start())
|
|
358
|
+
if outside_functions(line):
|
|
359
|
+
info.api_calls.append(ApiCall(method=m.group(1).upper(),
|
|
360
|
+
url=m.group(2), line=line))
|
|
361
|
+
|
|
362
|
+
# module-level calls (top-level script code outside any function)
|
|
363
|
+
seen_top = set()
|
|
364
|
+
for m in RE_CALL.finditer(clean):
|
|
365
|
+
name = m.group(1)
|
|
366
|
+
if name in JS_KEYWORDS or name in seen_top:
|
|
367
|
+
continue
|
|
368
|
+
if outside_functions(_line_of(text, m.start())):
|
|
369
|
+
seen_top.add(name)
|
|
370
|
+
info.calls.append(name)
|
|
371
|
+
info.calls = info.calls[:40]
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Python extractor built on the stdlib `ast` module.
|
|
2
|
+
|
|
3
|
+
Extracts functions/classes/methods with params + return annotations,
|
|
4
|
+
call targets, Flask/FastAPI/Django routes, outgoing HTTP calls
|
|
5
|
+
(requests/httpx/aiohttp/urllib), ORM models (SQLAlchemy/Django/peewee/
|
|
6
|
+
tortoise) and raw SQL usage.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import re
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
from ..models import (ApiCall, DbRef, FileInfo, FunctionInfo, Route,
|
|
15
|
+
TableInfo, describe)
|
|
16
|
+
|
|
17
|
+
HTTP_VERBS = {"get", "post", "put", "delete", "patch", "head", "options"}
|
|
18
|
+
ROUTE_ATTRS = HTTP_VERBS | {"route", "api_route", "websocket"}
|
|
19
|
+
ROUTE_OWNERS = {"app", "router", "api", "bp", "blueprint", "urlpatterns"}
|
|
20
|
+
HTTP_LIBS = {"requests", "httpx", "aiohttp", "session", "client", "urllib3"}
|
|
21
|
+
ORM_BASES = {"model", "base", "document", "declarativebase", "basemodel_db"}
|
|
22
|
+
SQL_RE = re.compile(
|
|
23
|
+
r"\b(SELECT|INSERT|UPDATE|DELETE)\b[\s\S]{0,200}?\b(?:FROM|INTO|UPDATE|JOIN)\s+"
|
|
24
|
+
r"[`\"\[]?([A-Za-z_][A-Za-z0-9_.]*)", re.IGNORECASE)
|
|
25
|
+
FIELD_CALL_RE = re.compile(r"(Column|Field)$", re.IGNORECASE)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _unparse(node) -> str:
|
|
29
|
+
try:
|
|
30
|
+
return ast.unparse(node)
|
|
31
|
+
except Exception:
|
|
32
|
+
return ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _name_of(func) -> str:
|
|
36
|
+
"""Dotted name of a call target, e.g. requests.get -> 'requests.get'."""
|
|
37
|
+
if isinstance(func, ast.Name):
|
|
38
|
+
return func.id
|
|
39
|
+
if isinstance(func, ast.Attribute):
|
|
40
|
+
base = _name_of(func.value)
|
|
41
|
+
return f"{base}.{func.attr}" if base else func.attr
|
|
42
|
+
if isinstance(func, ast.Call):
|
|
43
|
+
return _name_of(func.func)
|
|
44
|
+
return ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _str_arg(call: ast.Call) -> str:
|
|
48
|
+
for a in call.args:
|
|
49
|
+
if isinstance(a, ast.Constant) and isinstance(a.value, str):
|
|
50
|
+
return a.value
|
|
51
|
+
if isinstance(a, ast.JoinedStr): # f-string url
|
|
52
|
+
parts = []
|
|
53
|
+
for v in a.values:
|
|
54
|
+
if isinstance(v, ast.Constant):
|
|
55
|
+
parts.append(str(v.value))
|
|
56
|
+
else:
|
|
57
|
+
parts.append("{param}")
|
|
58
|
+
return "".join(parts)
|
|
59
|
+
return ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _params(node) -> List[str]:
|
|
63
|
+
out = []
|
|
64
|
+
args = node.args
|
|
65
|
+
for a in list(args.posonlyargs) + list(args.args) + list(args.kwonlyargs):
|
|
66
|
+
p = a.arg
|
|
67
|
+
if a.annotation is not None:
|
|
68
|
+
ann = _unparse(a.annotation)
|
|
69
|
+
if ann:
|
|
70
|
+
p += f": {ann}"
|
|
71
|
+
out.append(p)
|
|
72
|
+
if args.vararg:
|
|
73
|
+
out.append("*" + args.vararg.arg)
|
|
74
|
+
if args.kwarg:
|
|
75
|
+
out.append("**" + args.kwarg.arg)
|
|
76
|
+
return [p for p in out if p not in ("self", "cls")]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _route_from_decorator(dec) -> Optional[Route]:
|
|
80
|
+
if not isinstance(dec, ast.Call):
|
|
81
|
+
return None
|
|
82
|
+
name = _name_of(dec.func)
|
|
83
|
+
if "." not in name:
|
|
84
|
+
return None
|
|
85
|
+
owner, attr = name.rsplit(".", 1)
|
|
86
|
+
owner_base = owner.split(".")[-1].lower()
|
|
87
|
+
if attr.lower() not in ROUTE_ATTRS:
|
|
88
|
+
return None
|
|
89
|
+
if owner_base not in ROUTE_OWNERS and not owner_base.endswith("router"):
|
|
90
|
+
return None
|
|
91
|
+
path = _str_arg(dec)
|
|
92
|
+
if not path:
|
|
93
|
+
return None
|
|
94
|
+
method = attr.upper() if attr.lower() in HTTP_VERBS else "ANY"
|
|
95
|
+
# Flask: @app.route('/x', methods=['GET','POST'])
|
|
96
|
+
for kw in dec.keywords:
|
|
97
|
+
if kw.arg == "methods" and isinstance(kw.value, (ast.List, ast.Tuple)):
|
|
98
|
+
ms = [e.value for e in kw.value.elts
|
|
99
|
+
if isinstance(e, ast.Constant) and isinstance(e.value, str)]
|
|
100
|
+
if ms:
|
|
101
|
+
method = "/".join(m.upper() for m in ms)
|
|
102
|
+
return Route(method=method, path=path, line=dec.lineno)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _scan_calls(node, fn: FunctionInfo, source_seg: str):
|
|
106
|
+
"""Collect callee names, HTTP calls, and DB touches inside a function."""
|
|
107
|
+
for sub in ast.walk(node):
|
|
108
|
+
if isinstance(sub, ast.Call):
|
|
109
|
+
dotted = _name_of(sub.func)
|
|
110
|
+
if not dotted:
|
|
111
|
+
continue
|
|
112
|
+
head = dotted.split(".")[0]
|
|
113
|
+
tail = dotted.split(".")[-1]
|
|
114
|
+
low_head, low_tail = head.lower(), tail.lower()
|
|
115
|
+
|
|
116
|
+
# outgoing HTTP call: requests.get(url), httpx.post(...)
|
|
117
|
+
if low_tail in HTTP_VERBS and low_head in HTTP_LIBS:
|
|
118
|
+
url = _str_arg(sub)
|
|
119
|
+
if url:
|
|
120
|
+
fn.api_calls.append(ApiCall(method=tail.upper(), url=url,
|
|
121
|
+
line=sub.lineno))
|
|
122
|
+
continue
|
|
123
|
+
if dotted in ("urllib.request.urlopen", "urlopen"):
|
|
124
|
+
url = _str_arg(sub)
|
|
125
|
+
if url:
|
|
126
|
+
fn.api_calls.append(ApiCall(method="GET", url=url,
|
|
127
|
+
line=sub.lineno))
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
# ORM usage: session.query(User), User.objects.filter(...),
|
|
131
|
+
# db.session.add(user), Model.select() ...
|
|
132
|
+
if low_tail == "query" and sub.args:
|
|
133
|
+
model = _name_of(sub.args[0].func if isinstance(sub.args[0], ast.Call)
|
|
134
|
+
else sub.args[0])
|
|
135
|
+
if model and model[0].isupper():
|
|
136
|
+
fn.db_refs.append(DbRef(table=model, op="query",
|
|
137
|
+
via="orm", line=sub.lineno))
|
|
138
|
+
elif ".objects." in dotted:
|
|
139
|
+
model = dotted.split(".objects.")[0].split(".")[-1]
|
|
140
|
+
op = {"create": "insert", "update": "update",
|
|
141
|
+
"delete": "delete"}.get(low_tail, "query")
|
|
142
|
+
if model and model[0].isupper():
|
|
143
|
+
fn.db_refs.append(DbRef(table=model, op=op,
|
|
144
|
+
via="orm", line=sub.lineno))
|
|
145
|
+
elif low_tail in ("execute", "executemany", "read_sql",
|
|
146
|
+
"read_sql_query"):
|
|
147
|
+
sql = _str_arg(sub)
|
|
148
|
+
m = SQL_RE.search(sql or "")
|
|
149
|
+
if m:
|
|
150
|
+
fn.db_refs.append(DbRef(table=m.group(2),
|
|
151
|
+
op=m.group(1).lower(),
|
|
152
|
+
via="sql", line=sub.lineno))
|
|
153
|
+
|
|
154
|
+
# record the plain callee name for the call graph
|
|
155
|
+
if head and head not in ("self", "cls"):
|
|
156
|
+
fn.calls.append(head if "." not in dotted else tail)
|
|
157
|
+
else:
|
|
158
|
+
fn.calls.append(tail)
|
|
159
|
+
|
|
160
|
+
# raw SQL sitting in a string constant
|
|
161
|
+
elif isinstance(sub, ast.Constant) and isinstance(sub.value, str):
|
|
162
|
+
if len(sub.value) > 12:
|
|
163
|
+
m = SQL_RE.search(sub.value)
|
|
164
|
+
if m:
|
|
165
|
+
fn.db_refs.append(DbRef(table=m.group(2),
|
|
166
|
+
op=m.group(1).lower(),
|
|
167
|
+
via="sql",
|
|
168
|
+
line=getattr(sub, "lineno", fn.line)))
|
|
169
|
+
# de-dup while keeping order
|
|
170
|
+
seen = set()
|
|
171
|
+
fn.calls = [c for c in fn.calls
|
|
172
|
+
if c and not (c in seen or seen.add(c))]
|
|
173
|
+
seen_db = set()
|
|
174
|
+
fn.db_refs = [d for d in fn.db_refs
|
|
175
|
+
if not ((d.table, d.op) in seen_db or seen_db.add((d.table, d.op)))]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _make_function(node, source: str, parent_class: str = "") -> FunctionInfo:
|
|
179
|
+
fn = FunctionInfo(
|
|
180
|
+
name=node.name,
|
|
181
|
+
params=_params(node),
|
|
182
|
+
returns=_unparse(node.returns) if node.returns else "",
|
|
183
|
+
line=node.lineno,
|
|
184
|
+
end_line=getattr(node, "end_lineno", node.lineno) or node.lineno,
|
|
185
|
+
doc=ast.get_docstring(node) or "",
|
|
186
|
+
parent_class=parent_class,
|
|
187
|
+
kind="method" if parent_class else "function",
|
|
188
|
+
)
|
|
189
|
+
for dec in node.decorator_list:
|
|
190
|
+
r = _route_from_decorator(dec)
|
|
191
|
+
if r:
|
|
192
|
+
fn.routes.append(r)
|
|
193
|
+
_scan_calls(node, fn, source)
|
|
194
|
+
try:
|
|
195
|
+
seg = ast.get_source_segment(source, node) or ""
|
|
196
|
+
except Exception:
|
|
197
|
+
seg = ""
|
|
198
|
+
fn.description = describe(fn, seg)
|
|
199
|
+
return fn
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _model_from_class(node: ast.ClassDef) -> Optional[TableInfo]:
|
|
203
|
+
"""SQLAlchemy / Django / peewee / tortoise model -> TableInfo."""
|
|
204
|
+
base_names = []
|
|
205
|
+
for b in node.bases:
|
|
206
|
+
base_names.append(_name_of(b).split(".")[-1].lower())
|
|
207
|
+
is_model = any(b in ORM_BASES or b.endswith("model") for b in base_names)
|
|
208
|
+
if not is_model:
|
|
209
|
+
return None
|
|
210
|
+
table = node.name.lower()
|
|
211
|
+
fields: List[str] = []
|
|
212
|
+
for item in node.body:
|
|
213
|
+
if isinstance(item, ast.Assign):
|
|
214
|
+
targets = [t.id for t in item.targets if isinstance(t, ast.Name)]
|
|
215
|
+
if targets and targets[0] == "__tablename__" and \
|
|
216
|
+
isinstance(item.value, ast.Constant):
|
|
217
|
+
table = str(item.value.value)
|
|
218
|
+
continue
|
|
219
|
+
if targets and isinstance(item.value, ast.Call):
|
|
220
|
+
callee = _name_of(item.value.func).split(".")[-1]
|
|
221
|
+
if FIELD_CALL_RE.search(callee) or callee in (
|
|
222
|
+
"relationship", "ForeignKey", "ManyToManyField"):
|
|
223
|
+
fields.append(targets[0])
|
|
224
|
+
elif isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
|
|
225
|
+
fields.append(item.target.id)
|
|
226
|
+
return TableInfo(name=table, model=node.name, fields=fields,
|
|
227
|
+
line=node.lineno)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def parse_python(info: FileInfo, text: str) -> None:
|
|
231
|
+
try:
|
|
232
|
+
tree = ast.parse(text)
|
|
233
|
+
except SyntaxError as exc:
|
|
234
|
+
info.warnings.append(f"Python syntax error at line {exc.lineno}: "
|
|
235
|
+
f"{exc.msg} — file skipped.")
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
for node in ast.walk(tree):
|
|
239
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
240
|
+
if isinstance(node, ast.Import):
|
|
241
|
+
info.imports.extend(a.name for a in node.names)
|
|
242
|
+
elif node.module:
|
|
243
|
+
info.imports.append(node.module)
|
|
244
|
+
|
|
245
|
+
def visit_body(body, parent_class=""):
|
|
246
|
+
for node in body:
|
|
247
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
248
|
+
info.functions.append(_make_function(node, text, parent_class))
|
|
249
|
+
# nested defs
|
|
250
|
+
inner = [n for n in node.body
|
|
251
|
+
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
|
|
252
|
+
if inner:
|
|
253
|
+
visit_body(inner, parent_class)
|
|
254
|
+
elif isinstance(node, ast.ClassDef):
|
|
255
|
+
model = _model_from_class(node)
|
|
256
|
+
if model:
|
|
257
|
+
model.source = info.path
|
|
258
|
+
info.tables.append(model)
|
|
259
|
+
visit_body(node.body, parent_class=node.name)
|
|
260
|
+
|
|
261
|
+
visit_body(tree.body)
|
|
262
|
+
|
|
263
|
+
# Django urls.py: path('users/', views.user_list) -> route + handler name
|
|
264
|
+
for node in ast.walk(tree):
|
|
265
|
+
if isinstance(node, ast.Call):
|
|
266
|
+
callee = _name_of(node.func).split(".")[-1]
|
|
267
|
+
if callee in ("path", "re_path", "url") and len(node.args) >= 2:
|
|
268
|
+
p = _str_arg(node)
|
|
269
|
+
handler = _name_of(node.args[1]).split(".")[-1]
|
|
270
|
+
if p and handler:
|
|
271
|
+
info.routes.append(Route(method="ANY", path="/" + p.strip("/"),
|
|
272
|
+
line=node.lineno, handler=handler))
|
|
273
|
+
|
|
274
|
+
# module-level calls (script-style code outside any def)
|
|
275
|
+
fn_ranges = [(f.line, max(f.end_line, f.line)) for f in info.functions]
|
|
276
|
+
seen_top = set()
|
|
277
|
+
for node in ast.walk(tree):
|
|
278
|
+
if not isinstance(node, ast.Call):
|
|
279
|
+
continue
|
|
280
|
+
line = getattr(node, "lineno", 0)
|
|
281
|
+
if any(s <= line <= e for s, e in fn_ranges):
|
|
282
|
+
continue
|
|
283
|
+
dotted = _name_of(node.func)
|
|
284
|
+
name = dotted.split(".")[0] if "." not in dotted else \
|
|
285
|
+
dotted.split(".")[-1]
|
|
286
|
+
if name and name not in seen_top:
|
|
287
|
+
seen_top.add(name)
|
|
288
|
+
info.calls.append(name)
|
|
289
|
+
info.calls = info.calls[:40]
|
|
290
|
+
|
|
291
|
+
info.imports = sorted(set(info.imports))
|