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.
@@ -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))