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
commiter/parser.py
ADDED
|
@@ -0,0 +1,1283 @@
|
|
|
1
|
+
"""Tree-sitter setup, grammar loading, and AST query helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import tree_sitter_python as tspython
|
|
10
|
+
import tree_sitter_javascript as tsjavascript
|
|
11
|
+
import tree_sitter_typescript as tstypescript
|
|
12
|
+
from tree_sitter import Language, Parser, Node
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from tree_sitter import Tree
|
|
16
|
+
|
|
17
|
+
# Language singletons — loaded once and cached
|
|
18
|
+
_LANGUAGES: dict[str, Language] = {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_language(lang: str) -> Language:
|
|
22
|
+
"""Get or create a Tree-sitter Language for the given language name."""
|
|
23
|
+
if lang not in _LANGUAGES:
|
|
24
|
+
if lang == "python":
|
|
25
|
+
_LANGUAGES[lang] = Language(tspython.language())
|
|
26
|
+
elif lang == "javascript":
|
|
27
|
+
_LANGUAGES[lang] = Language(tsjavascript.language())
|
|
28
|
+
elif lang == "typescript":
|
|
29
|
+
_LANGUAGES[lang] = Language(tstypescript.language_typescript())
|
|
30
|
+
elif lang == "tsx":
|
|
31
|
+
_LANGUAGES[lang] = Language(tstypescript.language_tsx())
|
|
32
|
+
else:
|
|
33
|
+
raise ValueError(f"Unsupported language: {lang}")
|
|
34
|
+
return _LANGUAGES[lang]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def detect_language(file_path: str) -> str | None:
|
|
38
|
+
"""Detect programming language from file extension."""
|
|
39
|
+
ext = Path(file_path).suffix.lower()
|
|
40
|
+
mapping = {
|
|
41
|
+
".py": "python",
|
|
42
|
+
".js": "javascript",
|
|
43
|
+
".mjs": "javascript",
|
|
44
|
+
".cjs": "javascript",
|
|
45
|
+
".jsx": "javascript",
|
|
46
|
+
".ts": "typescript",
|
|
47
|
+
".tsx": "tsx",
|
|
48
|
+
}
|
|
49
|
+
return mapping.get(ext)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_file(file_path: str, language: str | None = None) -> tuple[Tree, str] | None:
|
|
53
|
+
"""Parse a file and return its Tree-sitter tree and detected language.
|
|
54
|
+
|
|
55
|
+
Returns None if the file cannot be parsed (unsupported language, read error, etc.).
|
|
56
|
+
"""
|
|
57
|
+
if language is None:
|
|
58
|
+
language = detect_language(file_path)
|
|
59
|
+
if language is None:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
source = Path(file_path).read_bytes()
|
|
64
|
+
except (OSError, IOError):
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
lang = _get_language(language)
|
|
68
|
+
parser = Parser(lang)
|
|
69
|
+
tree = parser.parse(source)
|
|
70
|
+
return tree, language
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_source(file_path: str) -> bytes:
|
|
74
|
+
"""Read file source as bytes."""
|
|
75
|
+
return Path(file_path).read_bytes()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def node_text(node: Node, source: bytes) -> str:
|
|
79
|
+
"""Extract the text content of an AST node."""
|
|
80
|
+
return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def find_nodes_by_type(node: Node, type_name: str) -> list[Node]:
|
|
84
|
+
"""Recursively find all descendant nodes of a given type."""
|
|
85
|
+
results = []
|
|
86
|
+
if node.type == type_name:
|
|
87
|
+
results.append(node)
|
|
88
|
+
for child in node.children:
|
|
89
|
+
results.extend(find_nodes_by_type(child, type_name))
|
|
90
|
+
return results
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def find_decorated_functions(node: Node, source: bytes) -> list[tuple[list[str], Node]]:
|
|
94
|
+
"""Find all decorated function definitions.
|
|
95
|
+
|
|
96
|
+
Returns a list of (decorator_names, function_node) tuples.
|
|
97
|
+
"""
|
|
98
|
+
results = []
|
|
99
|
+
for child in node.children:
|
|
100
|
+
if child.type == "decorated_definition":
|
|
101
|
+
decorators = []
|
|
102
|
+
func_node = None
|
|
103
|
+
for sub in child.children:
|
|
104
|
+
if sub.type == "decorator":
|
|
105
|
+
dec_text = node_text(sub, source).lstrip("@").strip()
|
|
106
|
+
decorators.append(dec_text)
|
|
107
|
+
elif sub.type == "function_definition":
|
|
108
|
+
func_node = sub
|
|
109
|
+
if func_node is not None:
|
|
110
|
+
results.append((decorators, func_node))
|
|
111
|
+
elif child.type == "function_definition":
|
|
112
|
+
results.append(([], child))
|
|
113
|
+
# Recurse into class bodies, modules, etc.
|
|
114
|
+
if child.type in ("module", "class_definition", "block"):
|
|
115
|
+
results.extend(find_decorated_functions(child, source))
|
|
116
|
+
return results
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def find_function_calls(node: Node, source: bytes, name: str | None = None) -> list[Node]:
|
|
120
|
+
"""Find all function call nodes, optionally filtered by function name."""
|
|
121
|
+
calls = find_nodes_by_type(node, "call")
|
|
122
|
+
if name is None:
|
|
123
|
+
return calls
|
|
124
|
+
results = []
|
|
125
|
+
for call in calls:
|
|
126
|
+
func = call.child_by_field_name("function")
|
|
127
|
+
if func and node_text(func, source) == name:
|
|
128
|
+
results.append(call)
|
|
129
|
+
return results
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def find_imports(node: Node, source: bytes) -> list[str]:
|
|
133
|
+
"""Extract all import names from a module."""
|
|
134
|
+
imports = []
|
|
135
|
+
for child in node.children:
|
|
136
|
+
if child.type == "import_statement":
|
|
137
|
+
imports.append(node_text(child, source))
|
|
138
|
+
elif child.type == "import_from_statement":
|
|
139
|
+
imports.append(node_text(child, source))
|
|
140
|
+
return imports
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def find_string_literals(node: Node, source: bytes) -> list[tuple[str, Node]]:
|
|
144
|
+
"""Find all string literal values and their nodes."""
|
|
145
|
+
strings = []
|
|
146
|
+
for str_node in find_nodes_by_type(node, "string"):
|
|
147
|
+
text = node_text(str_node, source)
|
|
148
|
+
# Strip quotes
|
|
149
|
+
if len(text) >= 2:
|
|
150
|
+
if text.startswith(('"""', "'''")):
|
|
151
|
+
val = text[3:-3]
|
|
152
|
+
elif text.startswith(('"', "'")):
|
|
153
|
+
val = text[1:-1]
|
|
154
|
+
else:
|
|
155
|
+
val = text
|
|
156
|
+
strings.append((val, str_node))
|
|
157
|
+
return strings
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ──────────────────────────────────────────────
|
|
161
|
+
# JS/TS import and export helpers
|
|
162
|
+
# ──────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class JSImport:
|
|
166
|
+
"""A parsed JS/TS import statement."""
|
|
167
|
+
names: list[str]
|
|
168
|
+
module_path: str
|
|
169
|
+
is_default: bool
|
|
170
|
+
line: int
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class JSExportedFunction:
|
|
175
|
+
"""An exported function or arrow-function const in JS/TS."""
|
|
176
|
+
name: str
|
|
177
|
+
body_node: Node
|
|
178
|
+
file_path: str
|
|
179
|
+
line: int
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def find_js_imports(node: Node, source: bytes) -> list[JSImport]:
|
|
183
|
+
"""Find all import statements in a JS/TS AST.
|
|
184
|
+
|
|
185
|
+
Handles:
|
|
186
|
+
import { getUser, createPost } from "../lib/api";
|
|
187
|
+
import api from "../lib/api";
|
|
188
|
+
import * as api from "../lib/api";
|
|
189
|
+
"""
|
|
190
|
+
imports = []
|
|
191
|
+
for child in node.children:
|
|
192
|
+
if child.type != "import_statement":
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
text = node_text(child, source)
|
|
196
|
+
line = child.start_point[0] + 1
|
|
197
|
+
|
|
198
|
+
# Extract the module path (the string after "from")
|
|
199
|
+
module_path = None
|
|
200
|
+
for sub in child.children:
|
|
201
|
+
if sub.type == "string":
|
|
202
|
+
module_path = node_text(sub, source).strip("'\"")
|
|
203
|
+
break
|
|
204
|
+
|
|
205
|
+
if not module_path:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
# Extract imported names
|
|
209
|
+
names = []
|
|
210
|
+
is_default = False
|
|
211
|
+
|
|
212
|
+
for sub in child.children:
|
|
213
|
+
if sub.type == "import_clause":
|
|
214
|
+
for clause_child in sub.children:
|
|
215
|
+
if clause_child.type == "identifier":
|
|
216
|
+
# default import: import Foo from "..."
|
|
217
|
+
names.append(node_text(clause_child, source))
|
|
218
|
+
is_default = True
|
|
219
|
+
elif clause_child.type == "named_imports":
|
|
220
|
+
# { getUser, createPost } or { getUser as gu }
|
|
221
|
+
for spec in clause_child.children:
|
|
222
|
+
if spec.type == "import_specifier":
|
|
223
|
+
name_node = spec.child_by_field_name("name")
|
|
224
|
+
alias_node = spec.child_by_field_name("alias")
|
|
225
|
+
# Use alias if present, otherwise the original name
|
|
226
|
+
target = alias_node if alias_node else name_node
|
|
227
|
+
if target:
|
|
228
|
+
names.append(node_text(target, source))
|
|
229
|
+
elif clause_child.type == "namespace_import":
|
|
230
|
+
# import * as api from "..."
|
|
231
|
+
for ns_child in clause_child.children:
|
|
232
|
+
if ns_child.type == "identifier":
|
|
233
|
+
names.append(node_text(ns_child, source))
|
|
234
|
+
is_default = True
|
|
235
|
+
|
|
236
|
+
if names:
|
|
237
|
+
imports.append(JSImport(
|
|
238
|
+
names=names,
|
|
239
|
+
module_path=module_path,
|
|
240
|
+
is_default=is_default,
|
|
241
|
+
line=line,
|
|
242
|
+
))
|
|
243
|
+
|
|
244
|
+
return imports
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def find_js_exported_functions(node: Node, source: bytes, file_path: str = "") -> list[JSExportedFunction]:
|
|
248
|
+
"""Find all exported function/const declarations in JS/TS.
|
|
249
|
+
|
|
250
|
+
Handles:
|
|
251
|
+
export function getUser(id) { ... }
|
|
252
|
+
export const getUser = (id) => fetch(...);
|
|
253
|
+
export const getUser = async (id) => { ... };
|
|
254
|
+
export default function handler(req, res) { ... }
|
|
255
|
+
"""
|
|
256
|
+
results = []
|
|
257
|
+
|
|
258
|
+
for child in node.children:
|
|
259
|
+
if child.type != "export_statement":
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
for sub in child.children:
|
|
263
|
+
# export function getUser(id) { ... }
|
|
264
|
+
if sub.type == "function_declaration":
|
|
265
|
+
name_node = sub.child_by_field_name("name")
|
|
266
|
+
if name_node:
|
|
267
|
+
body = sub.child_by_field_name("body") or sub
|
|
268
|
+
results.append(JSExportedFunction(
|
|
269
|
+
name=node_text(name_node, source),
|
|
270
|
+
body_node=body,
|
|
271
|
+
file_path=file_path,
|
|
272
|
+
line=sub.start_point[0] + 1,
|
|
273
|
+
))
|
|
274
|
+
|
|
275
|
+
# export const getUser = (id) => fetch(...)
|
|
276
|
+
elif sub.type == "lexical_declaration":
|
|
277
|
+
for decl in sub.children:
|
|
278
|
+
if decl.type == "variable_declarator":
|
|
279
|
+
name_node = decl.child_by_field_name("name")
|
|
280
|
+
value_node = decl.child_by_field_name("value")
|
|
281
|
+
if name_node and value_node:
|
|
282
|
+
# value_node could be arrow_function, function_expression, or call_expression
|
|
283
|
+
body = value_node
|
|
284
|
+
if value_node.type in ("arrow_function", "function_expression"):
|
|
285
|
+
body = value_node.child_by_field_name("body") or value_node
|
|
286
|
+
results.append(JSExportedFunction(
|
|
287
|
+
name=node_text(name_node, source),
|
|
288
|
+
body_node=body,
|
|
289
|
+
file_path=file_path,
|
|
290
|
+
line=decl.start_point[0] + 1,
|
|
291
|
+
))
|
|
292
|
+
|
|
293
|
+
return results
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ──────────────────────────────────────────────
|
|
297
|
+
# Type extraction helpers (TS interfaces, Python classes)
|
|
298
|
+
# ──────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
@dataclass
|
|
301
|
+
class TypeField:
|
|
302
|
+
"""A single field in a type/interface/class/enum definition."""
|
|
303
|
+
name: str
|
|
304
|
+
type_str: str
|
|
305
|
+
optional: bool = False
|
|
306
|
+
value: str | None = None # actual value for enum members and const assertions
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def extract_type_annotation(node: Node, source: bytes) -> str | None:
|
|
310
|
+
"""Extract type annotation from a TS parameter node.
|
|
311
|
+
|
|
312
|
+
Works with required_parameter, optional_parameter, and property_signature nodes
|
|
313
|
+
that have a type_annotation child.
|
|
314
|
+
"""
|
|
315
|
+
for child in node.children:
|
|
316
|
+
if child.type == "type_annotation":
|
|
317
|
+
text = node_text(child, source).strip()
|
|
318
|
+
if text.startswith(":"):
|
|
319
|
+
text = text[1:].strip()
|
|
320
|
+
return text
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def find_ts_interface_fields(node: Node, source: bytes) -> dict[str, list[TypeField]]:
|
|
325
|
+
"""Find all interface definitions in a TS/TSX file.
|
|
326
|
+
|
|
327
|
+
Returns a dict mapping type name to its field list.
|
|
328
|
+
Handles both exported and non-exported interfaces.
|
|
329
|
+
"""
|
|
330
|
+
result: dict[str, list[TypeField]] = {}
|
|
331
|
+
|
|
332
|
+
for child in node.children:
|
|
333
|
+
target = child
|
|
334
|
+
# Handle exported interfaces: export_statement -> interface_declaration
|
|
335
|
+
if child.type == "export_statement":
|
|
336
|
+
for sub in child.children:
|
|
337
|
+
if sub.type == "interface_declaration":
|
|
338
|
+
target = sub
|
|
339
|
+
break
|
|
340
|
+
|
|
341
|
+
if target.type == "interface_declaration":
|
|
342
|
+
name_node = target.child_by_field_name("name")
|
|
343
|
+
if not name_node:
|
|
344
|
+
continue
|
|
345
|
+
name = node_text(name_node, source)
|
|
346
|
+
fields = _extract_interface_body_fields(target, source)
|
|
347
|
+
if fields:
|
|
348
|
+
result[name] = fields
|
|
349
|
+
|
|
350
|
+
return result
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _extract_interface_body_fields(iface_node: Node, source: bytes) -> list[TypeField]:
|
|
354
|
+
"""Extract property signatures from an interface body."""
|
|
355
|
+
fields = []
|
|
356
|
+
body = iface_node.child_by_field_name("body")
|
|
357
|
+
if not body:
|
|
358
|
+
for child in iface_node.children:
|
|
359
|
+
if child.type in ("interface_body", "object_type"):
|
|
360
|
+
body = child
|
|
361
|
+
break
|
|
362
|
+
if not body:
|
|
363
|
+
return fields
|
|
364
|
+
|
|
365
|
+
for child in body.children:
|
|
366
|
+
if child.type == "property_signature":
|
|
367
|
+
prop_name = None
|
|
368
|
+
prop_type = "unknown"
|
|
369
|
+
optional = False
|
|
370
|
+
|
|
371
|
+
name_node = child.child_by_field_name("name")
|
|
372
|
+
if name_node:
|
|
373
|
+
prop_name = node_text(name_node, source)
|
|
374
|
+
|
|
375
|
+
for sub in child.children:
|
|
376
|
+
if node_text(sub, source) == "?":
|
|
377
|
+
optional = True
|
|
378
|
+
|
|
379
|
+
type_ann = extract_type_annotation(child, source)
|
|
380
|
+
if type_ann:
|
|
381
|
+
prop_type = type_ann
|
|
382
|
+
|
|
383
|
+
if prop_name:
|
|
384
|
+
fields.append(TypeField(name=prop_name, type_str=prop_type, optional=optional))
|
|
385
|
+
|
|
386
|
+
return fields
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def find_ts_enum_declarations(node: Node, source: bytes) -> dict[str, list[TypeField]]:
|
|
390
|
+
"""Find all enum definitions in a TS/TSX file.
|
|
391
|
+
|
|
392
|
+
Returns a dict mapping enum name to its member list.
|
|
393
|
+
Handles: enum Status { ACTIVE = "active", INACTIVE = "inactive" }
|
|
394
|
+
"""
|
|
395
|
+
result: dict[str, list[TypeField]] = {}
|
|
396
|
+
|
|
397
|
+
for child in node.children:
|
|
398
|
+
target = child
|
|
399
|
+
if child.type == "export_statement":
|
|
400
|
+
for sub in child.children:
|
|
401
|
+
if sub.type == "enum_declaration":
|
|
402
|
+
target = sub
|
|
403
|
+
break
|
|
404
|
+
|
|
405
|
+
if target.type == "enum_declaration":
|
|
406
|
+
name_node = target.child_by_field_name("name")
|
|
407
|
+
if not name_node:
|
|
408
|
+
continue
|
|
409
|
+
name = node_text(name_node, source)
|
|
410
|
+
members = _extract_enum_members(target, source)
|
|
411
|
+
if members:
|
|
412
|
+
result[name] = members
|
|
413
|
+
|
|
414
|
+
return result
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _extract_enum_members(enum_node: Node, source: bytes) -> list[TypeField]:
|
|
418
|
+
"""Extract members from an enum body."""
|
|
419
|
+
members = []
|
|
420
|
+
body = enum_node.child_by_field_name("body")
|
|
421
|
+
if not body:
|
|
422
|
+
# Try finding enum_body child directly
|
|
423
|
+
for child in enum_node.children:
|
|
424
|
+
if child.type == "enum_body":
|
|
425
|
+
body = child
|
|
426
|
+
break
|
|
427
|
+
if not body:
|
|
428
|
+
return members
|
|
429
|
+
|
|
430
|
+
for child in body.children:
|
|
431
|
+
if child.type in ("enum_member", "enum_assignment", "property_identifier"):
|
|
432
|
+
member_name = None
|
|
433
|
+
value_str = None
|
|
434
|
+
|
|
435
|
+
name_node = child.child_by_field_name("name")
|
|
436
|
+
if name_node:
|
|
437
|
+
member_name = node_text(name_node, source)
|
|
438
|
+
elif child.type == "property_identifier":
|
|
439
|
+
member_name = node_text(child, source)
|
|
440
|
+
|
|
441
|
+
value_node = child.child_by_field_name("value")
|
|
442
|
+
if value_node:
|
|
443
|
+
value_str = node_text(value_node, source).strip("'\"")
|
|
444
|
+
|
|
445
|
+
if member_name:
|
|
446
|
+
members.append(TypeField(
|
|
447
|
+
name=member_name,
|
|
448
|
+
type_str=f'"{value_str}"' if value_str else "auto",
|
|
449
|
+
value=value_str,
|
|
450
|
+
))
|
|
451
|
+
|
|
452
|
+
return members
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def find_ts_type_aliases(node: Node, source: bytes) -> dict[str, list[TypeField]]:
|
|
456
|
+
"""Find type alias declarations in a TS/TSX file.
|
|
457
|
+
|
|
458
|
+
Handles:
|
|
459
|
+
type User = { id: string; name: string } (object-like)
|
|
460
|
+
type Status = "active" | "inactive" (union literal)
|
|
461
|
+
"""
|
|
462
|
+
result: dict[str, list[TypeField]] = {}
|
|
463
|
+
|
|
464
|
+
for child in node.children:
|
|
465
|
+
target = child
|
|
466
|
+
if child.type == "export_statement":
|
|
467
|
+
for sub in child.children:
|
|
468
|
+
if sub.type == "type_alias_declaration":
|
|
469
|
+
target = sub
|
|
470
|
+
break
|
|
471
|
+
|
|
472
|
+
if target.type == "type_alias_declaration":
|
|
473
|
+
name_node = target.child_by_field_name("name")
|
|
474
|
+
if not name_node:
|
|
475
|
+
continue
|
|
476
|
+
name = node_text(name_node, source)
|
|
477
|
+
|
|
478
|
+
value_node = target.child_by_field_name("value")
|
|
479
|
+
if not value_node:
|
|
480
|
+
continue
|
|
481
|
+
|
|
482
|
+
# Object-like type alias: type User = { id: string; name: string }
|
|
483
|
+
if value_node.type == "object_type":
|
|
484
|
+
fields = []
|
|
485
|
+
for prop in value_node.children:
|
|
486
|
+
if prop.type == "property_signature":
|
|
487
|
+
prop_name = None
|
|
488
|
+
prop_type = "unknown"
|
|
489
|
+
optional = False
|
|
490
|
+
|
|
491
|
+
pn = prop.child_by_field_name("name")
|
|
492
|
+
if pn:
|
|
493
|
+
prop_name = node_text(pn, source)
|
|
494
|
+
for sub in prop.children:
|
|
495
|
+
if node_text(sub, source) == "?":
|
|
496
|
+
optional = True
|
|
497
|
+
type_ann = extract_type_annotation(prop, source)
|
|
498
|
+
if type_ann:
|
|
499
|
+
prop_type = type_ann
|
|
500
|
+
if prop_name:
|
|
501
|
+
fields.append(TypeField(name=prop_name, type_str=prop_type, optional=optional))
|
|
502
|
+
if fields:
|
|
503
|
+
result[name] = fields
|
|
504
|
+
|
|
505
|
+
# Union: type Status = "active" | "inactive" OR type Response = Success | Error
|
|
506
|
+
elif value_node.type == "union_type":
|
|
507
|
+
members = _flatten_union_type(value_node, source)
|
|
508
|
+
if members:
|
|
509
|
+
result[name] = members
|
|
510
|
+
|
|
511
|
+
# Intersection: type FullUser = BaseFields & AuthFields & { role: string }
|
|
512
|
+
elif value_node.type == "intersection_type":
|
|
513
|
+
fields = _extract_intersection_parts(value_node, source)
|
|
514
|
+
if fields:
|
|
515
|
+
result[name] = fields
|
|
516
|
+
|
|
517
|
+
# Generic type: type UserSummary = Pick<User, "id" | "name">
|
|
518
|
+
# Store as a marker — resolved lazily by the generic resolver
|
|
519
|
+
elif value_node.type == "generic_type":
|
|
520
|
+
generic_text = node_text(value_node, source).strip()
|
|
521
|
+
result[name] = [TypeField(name=name, type_str="__generic__", value=generic_text)]
|
|
522
|
+
|
|
523
|
+
return result
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _flatten_union_type(union_node: Node, source: bytes) -> list[TypeField]:
|
|
527
|
+
"""Recursively flatten a union_type node into a list of TypeField members."""
|
|
528
|
+
members = []
|
|
529
|
+
for child in union_node.children:
|
|
530
|
+
if child.type == "union_type":
|
|
531
|
+
members.extend(_flatten_union_type(child, source))
|
|
532
|
+
elif child.type == "literal_type":
|
|
533
|
+
val = node_text(child, source).strip("'\"")
|
|
534
|
+
if val:
|
|
535
|
+
members.append(TypeField(name=val, type_str="literal", value=val))
|
|
536
|
+
elif child.type == "type_identifier":
|
|
537
|
+
# Type reference in union: SuccessResponse | ErrorResponse
|
|
538
|
+
type_name = node_text(child, source)
|
|
539
|
+
members.append(TypeField(name=type_name, type_str="__type_ref__"))
|
|
540
|
+
elif child.type == "predefined_type":
|
|
541
|
+
type_name = node_text(child, source)
|
|
542
|
+
members.append(TypeField(name=type_name, type_str="predefined"))
|
|
543
|
+
return members
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _extract_intersection_parts(node: Node, source: bytes) -> list[TypeField]:
|
|
547
|
+
"""Extract fields from an intersection type: A & B & { inline: string }.
|
|
548
|
+
|
|
549
|
+
For type references (A, B): stores as __type_ref__ markers for later resolution.
|
|
550
|
+
For inline objects ({ inline: string }): extracts fields directly.
|
|
551
|
+
Handles nested intersections recursively.
|
|
552
|
+
"""
|
|
553
|
+
fields: list[TypeField] = []
|
|
554
|
+
|
|
555
|
+
for child in node.children:
|
|
556
|
+
if child.type == "intersection_type":
|
|
557
|
+
# Nested intersection — recurse
|
|
558
|
+
fields.extend(_extract_intersection_parts(child, source))
|
|
559
|
+
elif child.type == "type_identifier":
|
|
560
|
+
# Reference to another type — mark for later resolution
|
|
561
|
+
type_name = node_text(child, source)
|
|
562
|
+
fields.append(TypeField(name=type_name, type_str="__type_ref__"))
|
|
563
|
+
elif child.type == "object_type":
|
|
564
|
+
# Inline object: { role: string; active: boolean }
|
|
565
|
+
for prop in child.children:
|
|
566
|
+
if prop.type == "property_signature":
|
|
567
|
+
prop_name = None
|
|
568
|
+
prop_type = "unknown"
|
|
569
|
+
optional = False
|
|
570
|
+
|
|
571
|
+
pn = prop.child_by_field_name("name")
|
|
572
|
+
if pn:
|
|
573
|
+
prop_name = node_text(pn, source)
|
|
574
|
+
for sub in prop.children:
|
|
575
|
+
if node_text(sub, source) == "?":
|
|
576
|
+
optional = True
|
|
577
|
+
type_ann = extract_type_annotation(prop, source)
|
|
578
|
+
if type_ann:
|
|
579
|
+
prop_type = type_ann
|
|
580
|
+
if prop_name:
|
|
581
|
+
fields.append(TypeField(name=prop_name, type_str=prop_type, optional=optional))
|
|
582
|
+
|
|
583
|
+
return fields
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def find_ts_const_objects(node: Node, source: bytes) -> dict[str, list[TypeField]]:
|
|
587
|
+
"""Find const object declarations with 'as const' or UPPER_CASE names.
|
|
588
|
+
|
|
589
|
+
Handles: const STATUS = { ACTIVE: "active", INACTIVE: "inactive" } as const;
|
|
590
|
+
"""
|
|
591
|
+
result: dict[str, list[TypeField]] = {}
|
|
592
|
+
|
|
593
|
+
for child in node.children:
|
|
594
|
+
target = child
|
|
595
|
+
if child.type == "export_statement":
|
|
596
|
+
for sub in child.children:
|
|
597
|
+
if sub.type == "lexical_declaration":
|
|
598
|
+
target = sub
|
|
599
|
+
break
|
|
600
|
+
|
|
601
|
+
if target.type != "lexical_declaration":
|
|
602
|
+
continue
|
|
603
|
+
|
|
604
|
+
for decl in target.children:
|
|
605
|
+
if decl.type != "variable_declarator":
|
|
606
|
+
continue
|
|
607
|
+
|
|
608
|
+
name_node = decl.child_by_field_name("name")
|
|
609
|
+
value_node = decl.child_by_field_name("value")
|
|
610
|
+
if not name_node or not value_node:
|
|
611
|
+
continue
|
|
612
|
+
|
|
613
|
+
var_name = node_text(name_node, source)
|
|
614
|
+
|
|
615
|
+
# Check for "as const" assertion or UPPER_CASE convention
|
|
616
|
+
decl_text = node_text(decl, source)
|
|
617
|
+
is_const_assertion = "as const" in decl_text
|
|
618
|
+
is_upper_case = var_name == var_name.upper() and len(var_name) > 1
|
|
619
|
+
|
|
620
|
+
if not (is_const_assertion or is_upper_case):
|
|
621
|
+
continue
|
|
622
|
+
|
|
623
|
+
# Extract object properties — value might be wrapped in as_expression
|
|
624
|
+
obj_node = value_node
|
|
625
|
+
if value_node.type == "as_expression":
|
|
626
|
+
# Unwrap: { ... } as const → get the object inside
|
|
627
|
+
for sub in value_node.children:
|
|
628
|
+
if sub.type == "object":
|
|
629
|
+
obj_node = sub
|
|
630
|
+
break
|
|
631
|
+
|
|
632
|
+
if obj_node.type != "object":
|
|
633
|
+
continue
|
|
634
|
+
|
|
635
|
+
fields = []
|
|
636
|
+
for prop in obj_node.children:
|
|
637
|
+
if prop.type == "pair":
|
|
638
|
+
key_node = prop.child_by_field_name("key")
|
|
639
|
+
val_node = prop.child_by_field_name("value")
|
|
640
|
+
if key_node and val_node:
|
|
641
|
+
key = node_text(key_node, source).strip("'\"")
|
|
642
|
+
val = node_text(val_node, source).strip("'\"")
|
|
643
|
+
fields.append(TypeField(name=key, type_str=f'"{val}"', value=val))
|
|
644
|
+
|
|
645
|
+
if fields:
|
|
646
|
+
result[var_name] = fields
|
|
647
|
+
|
|
648
|
+
return result
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def find_py_class_fields(node: Node, source: bytes, class_name: str) -> list[TypeField]:
|
|
652
|
+
"""Extract typed fields from a Python class (Pydantic BaseModel, dataclass, etc.).
|
|
653
|
+
|
|
654
|
+
Looks for annotated assignments: name: str, price: float = 0.0, etc.
|
|
655
|
+
"""
|
|
656
|
+
for child in node.children:
|
|
657
|
+
if child.type != "class_definition":
|
|
658
|
+
continue
|
|
659
|
+
name_node = child.child_by_field_name("name")
|
|
660
|
+
if not name_node or node_text(name_node, source) != class_name:
|
|
661
|
+
continue
|
|
662
|
+
|
|
663
|
+
body = child.child_by_field_name("body")
|
|
664
|
+
if not body:
|
|
665
|
+
continue
|
|
666
|
+
|
|
667
|
+
return _extract_py_class_body_fields(body, source)
|
|
668
|
+
|
|
669
|
+
return []
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _extract_py_class_body_fields(body: Node, source: bytes) -> list[TypeField]:
|
|
673
|
+
"""Extract annotated assignments from a Python class body."""
|
|
674
|
+
fields = []
|
|
675
|
+
for stmt in body.children:
|
|
676
|
+
if stmt.type != "expression_statement":
|
|
677
|
+
continue
|
|
678
|
+
|
|
679
|
+
text = node_text(stmt, source).strip()
|
|
680
|
+
if ":" not in text:
|
|
681
|
+
continue
|
|
682
|
+
|
|
683
|
+
# Split on first ":" to get name and type
|
|
684
|
+
colon_idx = text.index(":")
|
|
685
|
+
name = text[:colon_idx].strip()
|
|
686
|
+
rest = text[colon_idx + 1:].strip()
|
|
687
|
+
|
|
688
|
+
# Remove default value if present
|
|
689
|
+
type_str = rest.split("=")[0].strip()
|
|
690
|
+
has_default = "=" in rest
|
|
691
|
+
optional = "Optional" in type_str or "| None" in type_str or has_default
|
|
692
|
+
|
|
693
|
+
if name and not name.startswith(("#", "def", "class", "@")):
|
|
694
|
+
fields.append(TypeField(name=name, type_str=type_str, optional=optional))
|
|
695
|
+
|
|
696
|
+
return fields
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def find_py_imports_with_names(node: Node, source: bytes) -> list["PyImport"]:
|
|
700
|
+
"""Extract Python from...import statements with individual names.
|
|
701
|
+
|
|
702
|
+
Handles: from .schemas import UserCreate, UserResponse
|
|
703
|
+
"""
|
|
704
|
+
imports = []
|
|
705
|
+
for child in node.children:
|
|
706
|
+
if child.type != "import_from_statement":
|
|
707
|
+
continue
|
|
708
|
+
|
|
709
|
+
text = node_text(child, source)
|
|
710
|
+
line = child.start_point[0] + 1
|
|
711
|
+
|
|
712
|
+
# Extract module path and names from the text via regex
|
|
713
|
+
import re as _re
|
|
714
|
+
m = _re.match(r"from\s+(\S+)\s+import\s+(.+)", text)
|
|
715
|
+
if not m:
|
|
716
|
+
continue
|
|
717
|
+
|
|
718
|
+
module_path = m.group(1)
|
|
719
|
+
names_str = m.group(2).strip().strip("()")
|
|
720
|
+
names = [n.strip().split(" as ")[-1].strip() for n in names_str.split(",") if n.strip()]
|
|
721
|
+
|
|
722
|
+
if names:
|
|
723
|
+
imports.append(PyImport(names=names, module_path=module_path, line=line))
|
|
724
|
+
|
|
725
|
+
return imports
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
@dataclass
|
|
729
|
+
class PyImport:
|
|
730
|
+
"""A parsed Python from...import statement."""
|
|
731
|
+
names: list[str]
|
|
732
|
+
module_path: str
|
|
733
|
+
line: int
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
# ──────────────────────────────────────────────
|
|
737
|
+
# Generic type parsing
|
|
738
|
+
# ──────────────────────────────────────────────
|
|
739
|
+
|
|
740
|
+
def parse_generic_type(type_str: str) -> tuple[str, list[str]] | None:
|
|
741
|
+
"""Parse a generic type string into its base type and arguments.
|
|
742
|
+
|
|
743
|
+
Examples:
|
|
744
|
+
"User[]" -> ("Array", ["User"])
|
|
745
|
+
"Array<User>" -> ("Array", ["User"])
|
|
746
|
+
"Promise<User>" -> ("Promise", ["User"])
|
|
747
|
+
"Pick<User, \\"id\\" | \\"name\\">" -> ("Pick", ["User", "\\"id\\" | \\"name\\""])
|
|
748
|
+
"Partial<User>" -> ("Partial", ["User"])
|
|
749
|
+
"ApiResponse<User>" -> ("ApiResponse", ["User"])
|
|
750
|
+
"Map<string, User>" -> ("Map", ["string", "User"])
|
|
751
|
+
"string" -> None (not generic)
|
|
752
|
+
|
|
753
|
+
Returns (base_type, [args]) or None if not a generic type.
|
|
754
|
+
"""
|
|
755
|
+
type_str = type_str.strip()
|
|
756
|
+
|
|
757
|
+
# Handle array shorthand: User[] -> ("Array", ["User"])
|
|
758
|
+
if type_str.endswith("[]"):
|
|
759
|
+
inner = type_str[:-2].strip()
|
|
760
|
+
if inner:
|
|
761
|
+
return ("Array", [inner])
|
|
762
|
+
return None
|
|
763
|
+
|
|
764
|
+
# Handle generic syntax: Name<Arg1, Arg2, ...>
|
|
765
|
+
lt_idx = type_str.find("<")
|
|
766
|
+
if lt_idx == -1:
|
|
767
|
+
return None
|
|
768
|
+
|
|
769
|
+
base = type_str[:lt_idx].strip()
|
|
770
|
+
if not base:
|
|
771
|
+
return None
|
|
772
|
+
|
|
773
|
+
# Extract the content between < and the matching >
|
|
774
|
+
args_str = _extract_between_angles(type_str, lt_idx)
|
|
775
|
+
if args_str is None:
|
|
776
|
+
return None
|
|
777
|
+
|
|
778
|
+
# Split arguments by top-level commas (respecting nested <> and quotes)
|
|
779
|
+
args = _split_generic_args(args_str)
|
|
780
|
+
return (base, args) if args else None
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _extract_between_angles(text: str, start: int) -> str | None:
|
|
784
|
+
"""Extract content between < and matching >, respecting nesting."""
|
|
785
|
+
depth = 0
|
|
786
|
+
i = start
|
|
787
|
+
while i < len(text):
|
|
788
|
+
if text[i] == "<":
|
|
789
|
+
depth += 1
|
|
790
|
+
elif text[i] == ">":
|
|
791
|
+
depth -= 1
|
|
792
|
+
if depth == 0:
|
|
793
|
+
return text[start + 1:i].strip()
|
|
794
|
+
i += 1
|
|
795
|
+
return None
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _split_generic_args(args_str: str) -> list[str]:
|
|
799
|
+
"""Split generic arguments by top-level commas, respecting nested <>, quotes, and |."""
|
|
800
|
+
parts = []
|
|
801
|
+
depth = 0
|
|
802
|
+
current = ""
|
|
803
|
+
in_string = None
|
|
804
|
+
|
|
805
|
+
for ch in args_str:
|
|
806
|
+
if in_string:
|
|
807
|
+
current += ch
|
|
808
|
+
if ch == in_string:
|
|
809
|
+
in_string = None
|
|
810
|
+
continue
|
|
811
|
+
|
|
812
|
+
if ch in ("'", '"'):
|
|
813
|
+
in_string = ch
|
|
814
|
+
current += ch
|
|
815
|
+
elif ch == "<":
|
|
816
|
+
depth += 1
|
|
817
|
+
current += ch
|
|
818
|
+
elif ch == ">":
|
|
819
|
+
depth -= 1
|
|
820
|
+
current += ch
|
|
821
|
+
elif ch == "," and depth == 0:
|
|
822
|
+
if current.strip():
|
|
823
|
+
parts.append(current.strip())
|
|
824
|
+
current = ""
|
|
825
|
+
else:
|
|
826
|
+
current += ch
|
|
827
|
+
|
|
828
|
+
if current.strip():
|
|
829
|
+
parts.append(current.strip())
|
|
830
|
+
return parts
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def extract_type_parameters(node: Node, source: bytes) -> list[str]:
|
|
834
|
+
"""Extract type parameter names from an interface or type alias declaration.
|
|
835
|
+
|
|
836
|
+
For `interface ApiResponse<T>` returns ["T"].
|
|
837
|
+
For `type Result<T, E>` returns ["T", "E"].
|
|
838
|
+
"""
|
|
839
|
+
params = []
|
|
840
|
+
for child in node.children:
|
|
841
|
+
if child.type == "type_parameters":
|
|
842
|
+
for param_node in child.children:
|
|
843
|
+
if param_node.type == "type_parameter":
|
|
844
|
+
name_node = param_node.child_by_field_name("name")
|
|
845
|
+
if name_node:
|
|
846
|
+
params.append(node_text(name_node, source))
|
|
847
|
+
return params
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
# ──────────────────────────────────────────────
|
|
851
|
+
# Class method type extraction (for controller handlers)
|
|
852
|
+
# ──────────────────────────────────────────────
|
|
853
|
+
|
|
854
|
+
@dataclass
|
|
855
|
+
class ClassMethod:
|
|
856
|
+
"""A class method with its request body type annotation."""
|
|
857
|
+
class_name: str
|
|
858
|
+
method_name: str
|
|
859
|
+
request_body_type: str | None
|
|
860
|
+
file_path: str
|
|
861
|
+
line: int
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
@dataclass
|
|
865
|
+
class ClassInstance:
|
|
866
|
+
"""A variable instantiated from a class: const x = new ClassName()."""
|
|
867
|
+
var_name: str
|
|
868
|
+
class_name: str
|
|
869
|
+
file_path: str
|
|
870
|
+
line: int
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def find_ts_class_methods(node: Node, source: bytes, file_path: str = "") -> list[ClassMethod]:
|
|
874
|
+
"""Find class methods with Request type annotations in their parameters.
|
|
875
|
+
|
|
876
|
+
Handles:
|
|
877
|
+
class UserController {
|
|
878
|
+
async create(req: Request<{}, {}, CreateUserBody>, res: Response) { ... }
|
|
879
|
+
}
|
|
880
|
+
"""
|
|
881
|
+
import re as _re
|
|
882
|
+
results = []
|
|
883
|
+
|
|
884
|
+
for child in node.children:
|
|
885
|
+
target = child
|
|
886
|
+
if child.type == "export_statement":
|
|
887
|
+
for sub in child.children:
|
|
888
|
+
if sub.type == "class_declaration":
|
|
889
|
+
target = sub
|
|
890
|
+
break
|
|
891
|
+
|
|
892
|
+
if target.type != "class_declaration":
|
|
893
|
+
continue
|
|
894
|
+
|
|
895
|
+
class_name_node = target.child_by_field_name("name")
|
|
896
|
+
if not class_name_node:
|
|
897
|
+
continue
|
|
898
|
+
class_name = node_text(class_name_node, source)
|
|
899
|
+
|
|
900
|
+
# Find the class body
|
|
901
|
+
body = target.child_by_field_name("body")
|
|
902
|
+
if not body:
|
|
903
|
+
continue
|
|
904
|
+
|
|
905
|
+
for member in body.children:
|
|
906
|
+
if member.type != "method_definition":
|
|
907
|
+
continue
|
|
908
|
+
|
|
909
|
+
# Get method name
|
|
910
|
+
method_name = None
|
|
911
|
+
for sub in member.children:
|
|
912
|
+
if sub.type == "property_identifier":
|
|
913
|
+
method_name = node_text(sub, source)
|
|
914
|
+
break
|
|
915
|
+
|
|
916
|
+
if not method_name:
|
|
917
|
+
continue
|
|
918
|
+
|
|
919
|
+
# Extract Request<P, Res, Body> from parameters
|
|
920
|
+
body_type = None
|
|
921
|
+
params_node = member.child_by_field_name("parameters")
|
|
922
|
+
if params_node:
|
|
923
|
+
params_text = node_text(params_node, source)
|
|
924
|
+
match = _re.search(r':\s*Request\s*<[^,]*,[^,]*,\s*(\w+)\s*>', params_text)
|
|
925
|
+
if match:
|
|
926
|
+
body_type = match.group(1)
|
|
927
|
+
|
|
928
|
+
results.append(ClassMethod(
|
|
929
|
+
class_name=class_name,
|
|
930
|
+
method_name=method_name,
|
|
931
|
+
request_body_type=body_type,
|
|
932
|
+
file_path=file_path,
|
|
933
|
+
line=member.start_point[0] + 1,
|
|
934
|
+
))
|
|
935
|
+
|
|
936
|
+
return results
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def find_ts_class_instances(node: Node, source: bytes, file_path: str = "") -> list[ClassInstance]:
|
|
940
|
+
"""Find variable declarations that instantiate a class.
|
|
941
|
+
|
|
942
|
+
Handles:
|
|
943
|
+
export const projectController = new ProjectController();
|
|
944
|
+
const controller = new UserController();
|
|
945
|
+
"""
|
|
946
|
+
results = []
|
|
947
|
+
|
|
948
|
+
for child in node.children:
|
|
949
|
+
target = child
|
|
950
|
+
if child.type == "export_statement":
|
|
951
|
+
for sub in child.children:
|
|
952
|
+
if sub.type == "lexical_declaration":
|
|
953
|
+
target = sub
|
|
954
|
+
break
|
|
955
|
+
|
|
956
|
+
if target.type != "lexical_declaration":
|
|
957
|
+
continue
|
|
958
|
+
|
|
959
|
+
for decl in target.children:
|
|
960
|
+
if decl.type != "variable_declarator":
|
|
961
|
+
continue
|
|
962
|
+
|
|
963
|
+
name_node = decl.child_by_field_name("name")
|
|
964
|
+
value_node = decl.child_by_field_name("value")
|
|
965
|
+
if not name_node or not value_node:
|
|
966
|
+
continue
|
|
967
|
+
|
|
968
|
+
# Check if value is a new_expression: new ClassName()
|
|
969
|
+
if value_node.type != "new_expression":
|
|
970
|
+
continue
|
|
971
|
+
|
|
972
|
+
var_name = node_text(name_node, source)
|
|
973
|
+
|
|
974
|
+
# Extract class name from new ClassName(...)
|
|
975
|
+
constructor_node = value_node.child_by_field_name("constructor")
|
|
976
|
+
if constructor_node:
|
|
977
|
+
class_name = node_text(constructor_node, source)
|
|
978
|
+
else:
|
|
979
|
+
# Fallback: first identifier child
|
|
980
|
+
class_name = None
|
|
981
|
+
for sub in value_node.children:
|
|
982
|
+
if sub.type == "identifier":
|
|
983
|
+
class_name = node_text(sub, source)
|
|
984
|
+
break
|
|
985
|
+
|
|
986
|
+
if class_name:
|
|
987
|
+
results.append(ClassInstance(
|
|
988
|
+
var_name=var_name,
|
|
989
|
+
class_name=class_name,
|
|
990
|
+
file_path=file_path,
|
|
991
|
+
line=decl.start_point[0] + 1,
|
|
992
|
+
))
|
|
993
|
+
|
|
994
|
+
return results
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
# ──────────────────────────────────────────────
|
|
998
|
+
# Re-export (barrel file) detection
|
|
999
|
+
# ──────────────────────────────────────────────
|
|
1000
|
+
|
|
1001
|
+
@dataclass
|
|
1002
|
+
class JSReExport:
|
|
1003
|
+
"""A re-export statement: export { X } from "./y" or export * from "./y"."""
|
|
1004
|
+
names: list[str] # ["User", "Post"] or ["*"] for star exports
|
|
1005
|
+
module_path: str # "./user"
|
|
1006
|
+
line: int
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def find_js_re_exports(node: Node, source: bytes) -> list[JSReExport]:
|
|
1010
|
+
"""Find all re-export statements in a JS/TS file.
|
|
1011
|
+
|
|
1012
|
+
Handles:
|
|
1013
|
+
export { User, Post } from "./types";
|
|
1014
|
+
export { User as AppUser } from "./types";
|
|
1015
|
+
export * from "./helpers";
|
|
1016
|
+
"""
|
|
1017
|
+
results = []
|
|
1018
|
+
for child in node.children:
|
|
1019
|
+
if child.type != "export_statement":
|
|
1020
|
+
continue
|
|
1021
|
+
|
|
1022
|
+
# Look for a source module string (the "from" part)
|
|
1023
|
+
module_path = None
|
|
1024
|
+
for sub in child.children:
|
|
1025
|
+
if sub.type == "string":
|
|
1026
|
+
module_path = node_text(sub, source).strip("'\"")
|
|
1027
|
+
break
|
|
1028
|
+
|
|
1029
|
+
if not module_path:
|
|
1030
|
+
continue # Not a re-export (it's a local export)
|
|
1031
|
+
|
|
1032
|
+
line = child.start_point[0] + 1
|
|
1033
|
+
names = []
|
|
1034
|
+
|
|
1035
|
+
# Check for star export: export * from "./y"
|
|
1036
|
+
child_text = node_text(child, source)
|
|
1037
|
+
if "* from" in child_text or "*from" in child_text:
|
|
1038
|
+
results.append(JSReExport(names=["*"], module_path=module_path, line=line))
|
|
1039
|
+
continue
|
|
1040
|
+
|
|
1041
|
+
# Check for named re-exports: export { X, Y } from "./y"
|
|
1042
|
+
for sub in child.children:
|
|
1043
|
+
if sub.type == "export_clause":
|
|
1044
|
+
for spec in sub.children:
|
|
1045
|
+
if spec.type == "export_specifier":
|
|
1046
|
+
name_node = spec.child_by_field_name("name")
|
|
1047
|
+
alias_node = spec.child_by_field_name("alias")
|
|
1048
|
+
if name_node:
|
|
1049
|
+
# Use the original name (not alias) for looking up the definition
|
|
1050
|
+
exported_name = node_text(alias_node, source) if alias_node else node_text(name_node, source)
|
|
1051
|
+
names.append(exported_name)
|
|
1052
|
+
|
|
1053
|
+
if names:
|
|
1054
|
+
results.append(JSReExport(names=names, module_path=module_path, line=line))
|
|
1055
|
+
|
|
1056
|
+
return results
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
# ──────────────────────────────────────────────
|
|
1060
|
+
# Dynamic URL resolution helpers
|
|
1061
|
+
# ──────────────────────────────────────────────
|
|
1062
|
+
|
|
1063
|
+
def resolve_url_from_node(node: Node, source: bytes, constants: dict[str, str] | None = None) -> str | None:
|
|
1064
|
+
"""Attempt to resolve a URL from various AST node types.
|
|
1065
|
+
|
|
1066
|
+
Handles:
|
|
1067
|
+
- String literals: "/api/users"
|
|
1068
|
+
- Template literals: `/api/users/${id}`
|
|
1069
|
+
- String concatenation: BASE + "/users/" + id
|
|
1070
|
+
- Variable references: BASE_URL (looked up in constants dict)
|
|
1071
|
+
|
|
1072
|
+
Returns a URL pattern string with unresolvable parts replaced by :param,
|
|
1073
|
+
or None if no URL can be extracted.
|
|
1074
|
+
"""
|
|
1075
|
+
if constants is None:
|
|
1076
|
+
constants = {}
|
|
1077
|
+
|
|
1078
|
+
if node.type in ("string", "string_fragment"):
|
|
1079
|
+
text = node_text(node, source).strip("'\"`")
|
|
1080
|
+
return text if text else None
|
|
1081
|
+
|
|
1082
|
+
if node.type == "template_string":
|
|
1083
|
+
return _resolve_template_literal(node, source, constants)
|
|
1084
|
+
|
|
1085
|
+
if node.type == "binary_expression":
|
|
1086
|
+
return _resolve_concatenation(node, source, constants)
|
|
1087
|
+
|
|
1088
|
+
if node.type == "identifier":
|
|
1089
|
+
name = node_text(node, source)
|
|
1090
|
+
if name in constants:
|
|
1091
|
+
return constants[name]
|
|
1092
|
+
return None
|
|
1093
|
+
|
|
1094
|
+
if node.type == "member_expression":
|
|
1095
|
+
# process.env.API_URL or similar
|
|
1096
|
+
text = node_text(node, source)
|
|
1097
|
+
if text in constants:
|
|
1098
|
+
return constants[text]
|
|
1099
|
+
return None
|
|
1100
|
+
|
|
1101
|
+
return None
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _resolve_template_literal(node: Node, source: bytes, constants: dict[str, str]) -> str | None:
|
|
1105
|
+
"""Resolve a template literal like `/api/users/${id}` to a URL pattern.
|
|
1106
|
+
|
|
1107
|
+
Strategy: keep static string fragments, resolve known constants,
|
|
1108
|
+
replace unknown interpolations with :param placeholders.
|
|
1109
|
+
"""
|
|
1110
|
+
parts = []
|
|
1111
|
+
for child in node.children:
|
|
1112
|
+
if child.type == "string_fragment":
|
|
1113
|
+
parts.append(node_text(child, source))
|
|
1114
|
+
elif child.type == "template_substitution":
|
|
1115
|
+
# The expression inside ${}
|
|
1116
|
+
expr = None
|
|
1117
|
+
for sub in child.children:
|
|
1118
|
+
if sub.type not in ("${", "}"):
|
|
1119
|
+
expr = sub
|
|
1120
|
+
break
|
|
1121
|
+
if expr:
|
|
1122
|
+
expr_text = node_text(expr, source)
|
|
1123
|
+
# Try to resolve from constants
|
|
1124
|
+
if expr_text in constants:
|
|
1125
|
+
parts.append(constants[expr_text])
|
|
1126
|
+
elif "." in expr_text and expr_text in constants:
|
|
1127
|
+
parts.append(constants[expr_text])
|
|
1128
|
+
else:
|
|
1129
|
+
# Unknown variable — use as param placeholder
|
|
1130
|
+
# Clean up the variable name for a readable param name
|
|
1131
|
+
param_name = expr_text.split(".")[-1].strip()
|
|
1132
|
+
if param_name:
|
|
1133
|
+
parts.append(f":{param_name}")
|
|
1134
|
+
else:
|
|
1135
|
+
parts.append(":param")
|
|
1136
|
+
elif child.type in ("`",):
|
|
1137
|
+
continue # skip the backtick delimiters
|
|
1138
|
+
|
|
1139
|
+
result = "".join(parts)
|
|
1140
|
+
return result if result else None
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def _resolve_concatenation(node: Node, source: bytes, constants: dict[str, str]) -> str | None:
|
|
1144
|
+
"""Resolve a binary expression (string concatenation) to a URL pattern.
|
|
1145
|
+
|
|
1146
|
+
Handles: BASE + "/users/" + id
|
|
1147
|
+
Strategy: flatten the + chain, resolve what we can, placeholder the rest.
|
|
1148
|
+
"""
|
|
1149
|
+
parts = _flatten_binary_expression(node, source, constants)
|
|
1150
|
+
if not parts:
|
|
1151
|
+
return None
|
|
1152
|
+
|
|
1153
|
+
result = "".join(parts)
|
|
1154
|
+
# If we got nothing useful (all placeholders, no path-like content), bail
|
|
1155
|
+
if "/" not in result:
|
|
1156
|
+
return None
|
|
1157
|
+
return result
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def _flatten_binary_expression(node: Node, source: bytes, constants: dict[str, str]) -> list[str]:
|
|
1161
|
+
"""Recursively flatten a + concatenation into resolved parts."""
|
|
1162
|
+
if node.type == "binary_expression":
|
|
1163
|
+
# Check operator is +
|
|
1164
|
+
op = None
|
|
1165
|
+
left = node.child_by_field_name("left")
|
|
1166
|
+
right = node.child_by_field_name("right")
|
|
1167
|
+
for child in node.children:
|
|
1168
|
+
if node_text(child, source) == "+":
|
|
1169
|
+
op = "+"
|
|
1170
|
+
break
|
|
1171
|
+
if op != "+":
|
|
1172
|
+
return []
|
|
1173
|
+
left_parts = _flatten_binary_expression(left, source, constants) if left else []
|
|
1174
|
+
right_parts = _flatten_binary_expression(right, source, constants) if right else []
|
|
1175
|
+
return left_parts + right_parts
|
|
1176
|
+
|
|
1177
|
+
if node.type in ("string", "string_fragment"):
|
|
1178
|
+
text = node_text(node, source).strip("'\"`")
|
|
1179
|
+
return [text] if text else []
|
|
1180
|
+
|
|
1181
|
+
if node.type == "template_string":
|
|
1182
|
+
resolved = _resolve_template_literal(node, source, constants)
|
|
1183
|
+
return [resolved] if resolved else []
|
|
1184
|
+
|
|
1185
|
+
if node.type == "identifier":
|
|
1186
|
+
name = node_text(node, source)
|
|
1187
|
+
if name in constants:
|
|
1188
|
+
return [constants[name]]
|
|
1189
|
+
# Unknown variable — use as param placeholder
|
|
1190
|
+
return [f":{name}"]
|
|
1191
|
+
|
|
1192
|
+
if node.type == "member_expression":
|
|
1193
|
+
text = node_text(node, source)
|
|
1194
|
+
if text in constants:
|
|
1195
|
+
return [constants[text]]
|
|
1196
|
+
return [f":{text.split('.')[-1]}"]
|
|
1197
|
+
|
|
1198
|
+
# Unknown node type — try raw text as fallback
|
|
1199
|
+
return []
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def build_constants_from_file(root_node: Node, source: bytes, env: dict[str, str] | None = None) -> dict[str, str]:
|
|
1203
|
+
"""Build a constants dict from top-level const/let assignments in a JS/TS file.
|
|
1204
|
+
|
|
1205
|
+
Resolves:
|
|
1206
|
+
const BASE = "/api" -> {"BASE": "/api"}
|
|
1207
|
+
const API_URL = process.env.API_URL -> {"API_URL": env value or ""}
|
|
1208
|
+
const BASE = process.env.NEXT_PUBLIC_API_URL || "/api" -> {"BASE": env value or "/api"}
|
|
1209
|
+
|
|
1210
|
+
Also includes env vars with their process.env.X full names.
|
|
1211
|
+
"""
|
|
1212
|
+
constants: dict[str, str] = {}
|
|
1213
|
+
if env is None:
|
|
1214
|
+
env = {}
|
|
1215
|
+
|
|
1216
|
+
# Add process.env.* entries from .env files
|
|
1217
|
+
for key, value in env.items():
|
|
1218
|
+
constants[f"process.env.{key}"] = value
|
|
1219
|
+
constants[f"import.meta.env.{key}"] = value
|
|
1220
|
+
|
|
1221
|
+
# Scan top-level variable declarations
|
|
1222
|
+
for child in root_node.children:
|
|
1223
|
+
_extract_const_declarations(child, source, constants, env)
|
|
1224
|
+
|
|
1225
|
+
return constants
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _extract_const_declarations(node: Node, source: bytes, constants: dict[str, str], env: dict[str, str]) -> None:
|
|
1229
|
+
"""Extract const/let declarations with string literal or env var values."""
|
|
1230
|
+
# Handle: const X = "value" or export const X = "value"
|
|
1231
|
+
target = node
|
|
1232
|
+
if node.type == "export_statement":
|
|
1233
|
+
for sub in node.children:
|
|
1234
|
+
if sub.type == "lexical_declaration":
|
|
1235
|
+
target = sub
|
|
1236
|
+
break
|
|
1237
|
+
|
|
1238
|
+
if target.type != "lexical_declaration":
|
|
1239
|
+
return
|
|
1240
|
+
|
|
1241
|
+
for decl in target.children:
|
|
1242
|
+
if decl.type != "variable_declarator":
|
|
1243
|
+
continue
|
|
1244
|
+
|
|
1245
|
+
name_node = decl.child_by_field_name("name")
|
|
1246
|
+
value_node = decl.child_by_field_name("value")
|
|
1247
|
+
|
|
1248
|
+
if not name_node or not value_node:
|
|
1249
|
+
continue
|
|
1250
|
+
|
|
1251
|
+
name = node_text(name_node, source)
|
|
1252
|
+
|
|
1253
|
+
# Direct string literal: const BASE = "/api"
|
|
1254
|
+
if value_node.type in ("string",):
|
|
1255
|
+
val = node_text(value_node, source).strip("'\"`")
|
|
1256
|
+
if val:
|
|
1257
|
+
constants[name] = val
|
|
1258
|
+
continue
|
|
1259
|
+
|
|
1260
|
+
# process.env.VAR reference
|
|
1261
|
+
if value_node.type == "member_expression":
|
|
1262
|
+
ref = node_text(value_node, source)
|
|
1263
|
+
if ref.startswith(("process.env.", "import.meta.env.")):
|
|
1264
|
+
var_key = ref.split(".")[-1]
|
|
1265
|
+
if var_key in env:
|
|
1266
|
+
constants[name] = env[var_key]
|
|
1267
|
+
constants[ref] = env[var_key]
|
|
1268
|
+
continue
|
|
1269
|
+
|
|
1270
|
+
# Logical OR fallback: process.env.VAR || "/default"
|
|
1271
|
+
if value_node.type == "binary_expression":
|
|
1272
|
+
val_text = node_text(value_node, source)
|
|
1273
|
+
# Pattern: process.env.X || "/fallback"
|
|
1274
|
+
import re as _re
|
|
1275
|
+
m = _re.match(r'(process\.env\.\w+|import\.meta\.env\.\w+)\s*\|\|\s*["\']([^"\']+)["\']', val_text)
|
|
1276
|
+
if m:
|
|
1277
|
+
env_ref = m.group(1)
|
|
1278
|
+
fallback = m.group(2)
|
|
1279
|
+
var_key = env_ref.split(".")[-1]
|
|
1280
|
+
resolved = env.get(var_key, fallback)
|
|
1281
|
+
constants[name] = resolved
|
|
1282
|
+
constants[env_ref] = resolved
|
|
1283
|
+
continue
|