polycodegraph 0.1.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.
- codegraph/__init__.py +10 -0
- codegraph/analysis/__init__.py +30 -0
- codegraph/analysis/_common.py +125 -0
- codegraph/analysis/blast_radius.py +63 -0
- codegraph/analysis/cycles.py +79 -0
- codegraph/analysis/dataflow.py +861 -0
- codegraph/analysis/dead_code.py +165 -0
- codegraph/analysis/hotspots.py +68 -0
- codegraph/analysis/infrastructure.py +439 -0
- codegraph/analysis/metrics.py +52 -0
- codegraph/analysis/report.py +222 -0
- codegraph/analysis/roles.py +323 -0
- codegraph/analysis/untested.py +79 -0
- codegraph/cli.py +1506 -0
- codegraph/config.py +64 -0
- codegraph/embed/__init__.py +35 -0
- codegraph/embed/chunker.py +120 -0
- codegraph/embed/embedder.py +113 -0
- codegraph/embed/query.py +181 -0
- codegraph/embed/store.py +360 -0
- codegraph/graph/__init__.py +0 -0
- codegraph/graph/builder.py +212 -0
- codegraph/graph/schema.py +69 -0
- codegraph/graph/store_networkx.py +55 -0
- codegraph/graph/store_sqlite.py +249 -0
- codegraph/mcp_server/__init__.py +6 -0
- codegraph/mcp_server/server.py +933 -0
- codegraph/parsers/__init__.py +0 -0
- codegraph/parsers/base.py +70 -0
- codegraph/parsers/go.py +570 -0
- codegraph/parsers/python.py +1707 -0
- codegraph/parsers/typescript.py +1397 -0
- codegraph/py.typed +0 -0
- codegraph/resolve/__init__.py +4 -0
- codegraph/resolve/calls.py +480 -0
- codegraph/review/__init__.py +31 -0
- codegraph/review/baseline.py +32 -0
- codegraph/review/differ.py +211 -0
- codegraph/review/hook.py +70 -0
- codegraph/review/risk.py +219 -0
- codegraph/review/rules.py +342 -0
- codegraph/viz/__init__.py +17 -0
- codegraph/viz/_style.py +45 -0
- codegraph/viz/dashboard.py +740 -0
- codegraph/viz/diagrams.py +370 -0
- codegraph/viz/explore.py +453 -0
- codegraph/viz/hld.py +683 -0
- codegraph/viz/html.py +115 -0
- codegraph/viz/mermaid.py +111 -0
- codegraph/viz/svg.py +77 -0
- codegraph/web/__init__.py +4 -0
- codegraph/web/server.py +165 -0
- codegraph/web/static/app.css +664 -0
- codegraph/web/static/app.js +919 -0
- codegraph/web/static/index.html +112 -0
- codegraph/web/static/views/architecture.js +1671 -0
- codegraph/web/static/views/graph3d.css +564 -0
- codegraph/web/static/views/graph3d.js +999 -0
- codegraph/web/static/views/graph3d_transform.js +984 -0
- codegraph/workspace/__init__.py +34 -0
- codegraph/workspace/config.py +110 -0
- codegraph/workspace/operations.py +294 -0
- polycodegraph-0.1.0.dist-info/METADATA +687 -0
- polycodegraph-0.1.0.dist-info/RECORD +67 -0
- polycodegraph-0.1.0.dist-info/WHEEL +4 -0
- polycodegraph-0.1.0.dist-info/entry_points.txt +2 -0
- polycodegraph-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1397 @@
|
|
|
1
|
+
"""TypeScript/TSX/JavaScript extractor using tree-sitter."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path, PurePosixPath
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import tree_sitter
|
|
9
|
+
|
|
10
|
+
from codegraph.graph.schema import Edge, EdgeKind, Node, NodeKind, make_node_id
|
|
11
|
+
from codegraph.parsers.base import (
|
|
12
|
+
ExtractorBase,
|
|
13
|
+
load_parser,
|
|
14
|
+
node_text,
|
|
15
|
+
register_extractor,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_TEST_RE = re.compile(r"\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs)$")
|
|
19
|
+
_TEST_DIR_RE = re.compile(r"(^|[/\\])__tests__[/\\]")
|
|
20
|
+
|
|
21
|
+
EXT_TO_LANG: dict[str, str] = {
|
|
22
|
+
".ts": "typescript",
|
|
23
|
+
".tsx": "tsx",
|
|
24
|
+
".js": "javascript",
|
|
25
|
+
".jsx": "javascript",
|
|
26
|
+
".mjs": "javascript",
|
|
27
|
+
".cjs": "javascript",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_test_file(rel_path: str) -> bool:
|
|
32
|
+
return bool(_TEST_RE.search(rel_path) or _TEST_DIR_RE.search(rel_path))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# --- Public-API pragma detection ----------------------------------------
|
|
36
|
+
#
|
|
37
|
+
# A TypeScript/JavaScript function, method, or class is exempted from
|
|
38
|
+
# dead-code analysis by an immediately-preceding line comment of the form
|
|
39
|
+
# ``// pragma: codegraph-public-api`` or ``// codegraph: public-api``.
|
|
40
|
+
_PUBLIC_API_PRAGMAS_TS: tuple[str, ...] = (
|
|
41
|
+
"// pragma: codegraph-public-api",
|
|
42
|
+
"// codegraph: public-api",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _line_has_public_api_pragma_ts(line: str) -> bool:
|
|
47
|
+
stripped = line.strip()
|
|
48
|
+
return any(pragma in stripped for pragma in _PUBLIC_API_PRAGMAS_TS)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _has_public_api_pragma_ts(def_node: tree_sitter.Node, src: bytes) -> bool:
|
|
52
|
+
"""Return True if a TS def/class is preceded by a public-API pragma.
|
|
53
|
+
|
|
54
|
+
Mirrors the Python helper: walks backward past blank lines from the
|
|
55
|
+
definition's start byte and matches the first non-blank line against
|
|
56
|
+
the pragma forms. Same-line trailing pragmas are also accepted.
|
|
57
|
+
Walks through ``export_statement`` wrappers so a pragma above an
|
|
58
|
+
``export function foo()`` declaration is honored.
|
|
59
|
+
"""
|
|
60
|
+
container: tree_sitter.Node = def_node
|
|
61
|
+
parent = def_node.parent
|
|
62
|
+
while parent is not None and parent.type in (
|
|
63
|
+
"export_statement", "ambient_declaration",
|
|
64
|
+
):
|
|
65
|
+
container = parent
|
|
66
|
+
parent = parent.parent
|
|
67
|
+
start_byte = container.start_byte
|
|
68
|
+
|
|
69
|
+
sig_end = src.find(b"\n", start_byte)
|
|
70
|
+
if sig_end == -1:
|
|
71
|
+
sig_end = container.end_byte
|
|
72
|
+
sig_line = src[start_byte:sig_end].decode("utf-8", errors="replace")
|
|
73
|
+
if _line_has_public_api_pragma_ts(sig_line):
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
cursor = start_byte
|
|
77
|
+
if cursor > 0 and src[cursor - 1:cursor] == b"\n":
|
|
78
|
+
cursor -= 1
|
|
79
|
+
while cursor > 0:
|
|
80
|
+
prev_nl = src.rfind(b"\n", 0, cursor)
|
|
81
|
+
line_start = prev_nl + 1 if prev_nl != -1 else 0
|
|
82
|
+
line = src[line_start:cursor].decode("utf-8", errors="replace")
|
|
83
|
+
if not line.strip():
|
|
84
|
+
cursor = prev_nl
|
|
85
|
+
if cursor <= 0:
|
|
86
|
+
return False
|
|
87
|
+
continue
|
|
88
|
+
return _line_has_public_api_pragma_ts(line)
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _file_to_qualname(rel_path: str) -> str:
|
|
93
|
+
p = PurePosixPath(rel_path)
|
|
94
|
+
stem = str(p.with_suffix(""))
|
|
95
|
+
return stem.replace("/", ".")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _extract_string(node: tree_sitter.Node, src: bytes) -> str:
|
|
99
|
+
text = node_text(node, src)
|
|
100
|
+
return text.strip("'\"` ")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_HTTP_VERBS = {"get", "post", "put", "delete", "patch", "head", "options"}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _strip_quotes(text: str) -> str:
|
|
107
|
+
if len(text) >= 2 and text[0] in "'\"`" and text[-1] == text[0]:
|
|
108
|
+
return text[1:-1]
|
|
109
|
+
return text
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _object_top_level_keys(obj_node: tree_sitter.Node, src: bytes) -> list[str]:
|
|
113
|
+
"""Return the top-level keys of an object literal as a list of strings.
|
|
114
|
+
|
|
115
|
+
Handles `pair` (key: value) and `shorthand_property_identifier` shapes.
|
|
116
|
+
Spread elements and computed keys are skipped.
|
|
117
|
+
"""
|
|
118
|
+
keys: list[str] = []
|
|
119
|
+
if obj_node.type != "object":
|
|
120
|
+
return keys
|
|
121
|
+
for pair in obj_node.children:
|
|
122
|
+
if pair.type == "pair":
|
|
123
|
+
key_node = pair.child_by_field_name("key")
|
|
124
|
+
if key_node is None:
|
|
125
|
+
key_node = next(
|
|
126
|
+
(
|
|
127
|
+
c for c in pair.children
|
|
128
|
+
if c.type in ("property_identifier", "string", "identifier")
|
|
129
|
+
),
|
|
130
|
+
None,
|
|
131
|
+
)
|
|
132
|
+
if key_node is None:
|
|
133
|
+
continue
|
|
134
|
+
text = node_text(key_node, src)
|
|
135
|
+
if key_node.type == "string":
|
|
136
|
+
text = _strip_quotes(text)
|
|
137
|
+
keys.append(text)
|
|
138
|
+
elif pair.type == "shorthand_property_identifier":
|
|
139
|
+
keys.append(node_text(pair, src))
|
|
140
|
+
return keys
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _extract_body_keys_from_init(
|
|
144
|
+
init_node: tree_sitter.Node, src: bytes
|
|
145
|
+
) -> list[str]:
|
|
146
|
+
"""Given a fetch `init` object literal, extract body_keys.
|
|
147
|
+
|
|
148
|
+
Looks for `body: <value>` where <value> is either an object literal or
|
|
149
|
+
`JSON.stringify(<object literal>)`. Returns the top-level keys of that
|
|
150
|
+
object, or an empty list if not extractable.
|
|
151
|
+
"""
|
|
152
|
+
if init_node.type != "object":
|
|
153
|
+
return []
|
|
154
|
+
for pair in init_node.children:
|
|
155
|
+
if pair.type != "pair":
|
|
156
|
+
continue
|
|
157
|
+
key_node = pair.child_by_field_name("key")
|
|
158
|
+
if key_node is None:
|
|
159
|
+
continue
|
|
160
|
+
key_text = node_text(key_node, src)
|
|
161
|
+
if key_node.type == "string":
|
|
162
|
+
key_text = _strip_quotes(key_text)
|
|
163
|
+
if key_text != "body":
|
|
164
|
+
continue
|
|
165
|
+
value_node = pair.child_by_field_name("value")
|
|
166
|
+
if value_node is None:
|
|
167
|
+
named = [c for c in pair.children if c.is_named]
|
|
168
|
+
if len(named) >= 2:
|
|
169
|
+
value_node = named[-1]
|
|
170
|
+
if value_node is None:
|
|
171
|
+
return []
|
|
172
|
+
if value_node.type == "object":
|
|
173
|
+
return _object_top_level_keys(value_node, src)
|
|
174
|
+
if value_node.type == "call_expression":
|
|
175
|
+
func = value_node.child_by_field_name("function")
|
|
176
|
+
if func is not None and node_text(func, src) == "JSON.stringify":
|
|
177
|
+
args = value_node.child_by_field_name("arguments")
|
|
178
|
+
if args is not None:
|
|
179
|
+
inner = next(
|
|
180
|
+
(c for c in args.children if c.is_named), None
|
|
181
|
+
)
|
|
182
|
+
if inner is not None and inner.type == "object":
|
|
183
|
+
return _object_top_level_keys(inner, src)
|
|
184
|
+
return []
|
|
185
|
+
return []
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _extract_method_from_init(
|
|
189
|
+
init_node: tree_sitter.Node, src: bytes
|
|
190
|
+
) -> str | None:
|
|
191
|
+
"""Pull `method: "POST"` (or similar) from a fetch init object literal."""
|
|
192
|
+
if init_node.type != "object":
|
|
193
|
+
return None
|
|
194
|
+
for pair in init_node.children:
|
|
195
|
+
if pair.type != "pair":
|
|
196
|
+
continue
|
|
197
|
+
key_node = pair.child_by_field_name("key")
|
|
198
|
+
if key_node is None:
|
|
199
|
+
continue
|
|
200
|
+
key_text = node_text(key_node, src)
|
|
201
|
+
if key_node.type == "string":
|
|
202
|
+
key_text = _strip_quotes(key_text)
|
|
203
|
+
if key_text != "method":
|
|
204
|
+
continue
|
|
205
|
+
value_node = pair.child_by_field_name("value")
|
|
206
|
+
if value_node is None:
|
|
207
|
+
named = [c for c in pair.children if c.is_named]
|
|
208
|
+
if len(named) >= 2:
|
|
209
|
+
value_node = named[-1]
|
|
210
|
+
if value_node is None:
|
|
211
|
+
return None
|
|
212
|
+
if value_node.type == "string":
|
|
213
|
+
return _strip_quotes(node_text(value_node, src)).upper()
|
|
214
|
+
return None
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _classify_url_node(
|
|
219
|
+
url_node: tree_sitter.Node | None, src: bytes
|
|
220
|
+
) -> tuple[str, str]:
|
|
221
|
+
"""Return (url_text, url_kind) for a URL argument node.
|
|
222
|
+
|
|
223
|
+
url_kind is one of: "literal", "template", "dynamic".
|
|
224
|
+
For literals the text is unquoted; for templates the raw source (incl.
|
|
225
|
+
backticks and `${...}` placeholders) is preserved verbatim; for any
|
|
226
|
+
other expression, the kind is "dynamic" and the text is the source.
|
|
227
|
+
"""
|
|
228
|
+
if url_node is None:
|
|
229
|
+
return "", "dynamic"
|
|
230
|
+
if url_node.type == "string":
|
|
231
|
+
return _strip_quotes(node_text(url_node, src)), "literal"
|
|
232
|
+
if url_node.type == "template_string":
|
|
233
|
+
return node_text(url_node, src), "template"
|
|
234
|
+
return node_text(url_node, src), "dynamic"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
_SIMPLE_EXPR_TYPES = {
|
|
238
|
+
"identifier",
|
|
239
|
+
"string",
|
|
240
|
+
"number",
|
|
241
|
+
"true",
|
|
242
|
+
"false",
|
|
243
|
+
"null",
|
|
244
|
+
"undefined",
|
|
245
|
+
"member_expression",
|
|
246
|
+
"subscript_expression",
|
|
247
|
+
"this",
|
|
248
|
+
"super",
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _strip_type_annotation(text: str) -> str:
|
|
253
|
+
"""Remove the leading colon (and whitespace) from a type_annotation text."""
|
|
254
|
+
s = text.lstrip()
|
|
255
|
+
if s.startswith(":"):
|
|
256
|
+
s = s[1:]
|
|
257
|
+
return s.strip()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _extract_param(
|
|
261
|
+
p: tree_sitter.Node, src: bytes
|
|
262
|
+
) -> dict[str, str | None] | None:
|
|
263
|
+
"""Extract a single parameter from a `required_parameter`/`optional_parameter`
|
|
264
|
+
or any pattern node. Returns dict with name/type/default or None to skip.
|
|
265
|
+
"""
|
|
266
|
+
name: str | None = None
|
|
267
|
+
type_text: str | None = None
|
|
268
|
+
default_text: str | None = None
|
|
269
|
+
saw_eq = False
|
|
270
|
+
for c in p.children:
|
|
271
|
+
ct = c.type
|
|
272
|
+
if ct == "=":
|
|
273
|
+
saw_eq = True
|
|
274
|
+
continue
|
|
275
|
+
if saw_eq:
|
|
276
|
+
# Default value expression follows '='.
|
|
277
|
+
if c.is_named:
|
|
278
|
+
default_text = node_text(c, src)
|
|
279
|
+
continue
|
|
280
|
+
if ct == "identifier" and name is None:
|
|
281
|
+
name = node_text(c, src)
|
|
282
|
+
elif ct == "rest_pattern":
|
|
283
|
+
# `...rest` -> name = "...rest"
|
|
284
|
+
inner = next(
|
|
285
|
+
(cc for cc in c.children if cc.type == "identifier"),
|
|
286
|
+
None,
|
|
287
|
+
)
|
|
288
|
+
name = (
|
|
289
|
+
"..." + node_text(inner, src)
|
|
290
|
+
if inner is not None
|
|
291
|
+
else node_text(c, src)
|
|
292
|
+
)
|
|
293
|
+
elif ct == "type_annotation":
|
|
294
|
+
type_text = _strip_type_annotation(node_text(c, src))
|
|
295
|
+
elif ct in ("object_pattern", "array_pattern") and name is None:
|
|
296
|
+
# Destructured params -> use the raw text as the "name".
|
|
297
|
+
name = node_text(c, src)
|
|
298
|
+
elif ct == "?":
|
|
299
|
+
# Optional parameter marker; nothing to record beyond presence.
|
|
300
|
+
continue
|
|
301
|
+
if name is None:
|
|
302
|
+
return None
|
|
303
|
+
return {"name": name, "type": type_text, "default": default_text}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _extract_params(
|
|
307
|
+
params_node: tree_sitter.Node | None, src: bytes
|
|
308
|
+
) -> list[dict[str, str | None]]:
|
|
309
|
+
if params_node is None:
|
|
310
|
+
return []
|
|
311
|
+
out: list[dict[str, str | None]] = []
|
|
312
|
+
for c in params_node.children:
|
|
313
|
+
if c.type in ("required_parameter", "optional_parameter"):
|
|
314
|
+
p = _extract_param(c, src)
|
|
315
|
+
if p is not None:
|
|
316
|
+
out.append(p)
|
|
317
|
+
return out
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _extract_return_type(
|
|
321
|
+
func_node: tree_sitter.Node, params_node: tree_sitter.Node | None, src: bytes
|
|
322
|
+
) -> str | None:
|
|
323
|
+
"""Return the type annotation that follows `formal_parameters` inside a
|
|
324
|
+
function / method / arrow declaration. Returns text without the leading
|
|
325
|
+
colon, or None if absent.
|
|
326
|
+
"""
|
|
327
|
+
# Prefer the named field on TS nodes when present.
|
|
328
|
+
rt = func_node.child_by_field_name("return_type")
|
|
329
|
+
if rt is not None and rt.type == "type_annotation":
|
|
330
|
+
return _strip_type_annotation(node_text(rt, src))
|
|
331
|
+
# Fallback: walk siblings after `formal_parameters` by start_byte.
|
|
332
|
+
if params_node is None:
|
|
333
|
+
return None
|
|
334
|
+
after = False
|
|
335
|
+
params_end = params_node.end_byte
|
|
336
|
+
for c in func_node.children:
|
|
337
|
+
if not after:
|
|
338
|
+
if c.start_byte >= params_end:
|
|
339
|
+
after = True
|
|
340
|
+
else:
|
|
341
|
+
continue
|
|
342
|
+
if c.type == "type_annotation":
|
|
343
|
+
return _strip_type_annotation(node_text(c, src))
|
|
344
|
+
if c.type in ("statement_block", "=>"):
|
|
345
|
+
return None
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _arg_text(node: tree_sitter.Node, src: bytes) -> str:
|
|
350
|
+
"""Return the text of an argument expression, simplified to '<expr>' if
|
|
351
|
+
it is not in the allow-list of simple expression types.
|
|
352
|
+
"""
|
|
353
|
+
if node.type in _SIMPLE_EXPR_TYPES:
|
|
354
|
+
return node_text(node, src)
|
|
355
|
+
return "<expr>"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _split_call_arguments(
|
|
359
|
+
args_node: tree_sitter.Node, src: bytes
|
|
360
|
+
) -> tuple[list[str], dict[str, str]]:
|
|
361
|
+
"""Walk the children of a `call_expression` `arguments` node and return
|
|
362
|
+
(positional_args, kwargs).
|
|
363
|
+
|
|
364
|
+
Rule for object-literal -> kwargs:
|
|
365
|
+
Split a single object literal into kwargs only when there is exactly one
|
|
366
|
+
object-literal argument AND it appears as the last positional argument
|
|
367
|
+
(i.e. trailing options object). Otherwise the object literal is treated
|
|
368
|
+
as a normal positional arg, simplified to its source text or `<expr>`.
|
|
369
|
+
"""
|
|
370
|
+
# Collect named children (skip `(`, `)`, `,`).
|
|
371
|
+
items: list[tree_sitter.Node] = [c for c in args_node.children if c.is_named]
|
|
372
|
+
if not items:
|
|
373
|
+
return [], {}
|
|
374
|
+
|
|
375
|
+
object_indices = [i for i, n in enumerate(items) if n.type == "object"]
|
|
376
|
+
last_idx = len(items) - 1
|
|
377
|
+
split_kwargs = (
|
|
378
|
+
len(object_indices) == 1 and object_indices[0] == last_idx
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
args: list[str] = []
|
|
382
|
+
kwargs: dict[str, str] = {}
|
|
383
|
+
|
|
384
|
+
for idx, n in enumerate(items):
|
|
385
|
+
if n.type == "spread_element":
|
|
386
|
+
# `...rest` -> "*rest"
|
|
387
|
+
inner = next(
|
|
388
|
+
(cc for cc in n.children if cc.is_named),
|
|
389
|
+
None,
|
|
390
|
+
)
|
|
391
|
+
if inner is not None and inner.type == "identifier":
|
|
392
|
+
args.append("*" + node_text(inner, src))
|
|
393
|
+
else:
|
|
394
|
+
args.append("*<expr>")
|
|
395
|
+
continue
|
|
396
|
+
if split_kwargs and idx == last_idx and n.type == "object":
|
|
397
|
+
for pair in n.children:
|
|
398
|
+
if pair.type != "pair":
|
|
399
|
+
continue
|
|
400
|
+
key_node = pair.child_by_field_name("key")
|
|
401
|
+
if key_node is None:
|
|
402
|
+
key_node = next(
|
|
403
|
+
(
|
|
404
|
+
c for c in pair.children
|
|
405
|
+
if c.type in (
|
|
406
|
+
"property_identifier", "string", "identifier"
|
|
407
|
+
)
|
|
408
|
+
),
|
|
409
|
+
None,
|
|
410
|
+
)
|
|
411
|
+
value_node = pair.child_by_field_name("value")
|
|
412
|
+
if value_node is None:
|
|
413
|
+
# Last named child after the colon.
|
|
414
|
+
named = [c for c in pair.children if c.is_named]
|
|
415
|
+
if len(named) >= 2:
|
|
416
|
+
value_node = named[-1]
|
|
417
|
+
if key_node is None or value_node is None:
|
|
418
|
+
continue
|
|
419
|
+
key_text = node_text(key_node, src)
|
|
420
|
+
if key_node.type == "string":
|
|
421
|
+
key_text = key_text.strip("'\"`")
|
|
422
|
+
kwargs[key_text] = _arg_text(value_node, src)
|
|
423
|
+
continue
|
|
424
|
+
args.append(_arg_text(n, src))
|
|
425
|
+
|
|
426
|
+
return args, kwargs
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _named_imports(
|
|
430
|
+
clause: tree_sitter.Node | None, src: bytes
|
|
431
|
+
) -> list[str]:
|
|
432
|
+
"""Extract imported names from an import_clause.
|
|
433
|
+
|
|
434
|
+
Handles default imports, named imports (`{ a, b as c }`), and namespace
|
|
435
|
+
imports (`* as ns`). For aliased imports we record the *original* name
|
|
436
|
+
so the resolver can bind the alias used in the source via the same
|
|
437
|
+
full qualname.
|
|
438
|
+
"""
|
|
439
|
+
if clause is None:
|
|
440
|
+
return []
|
|
441
|
+
names: list[str] = []
|
|
442
|
+
for child in clause.children:
|
|
443
|
+
if child.type == "identifier":
|
|
444
|
+
# Default import: `import Foo from 'm'` -> 'Foo'.
|
|
445
|
+
names.append(node_text(child, src))
|
|
446
|
+
elif child.type == "named_imports":
|
|
447
|
+
for spec in child.children:
|
|
448
|
+
if spec.type != "import_specifier":
|
|
449
|
+
continue
|
|
450
|
+
# First identifier inside specifier is the original name.
|
|
451
|
+
first = next(
|
|
452
|
+
(c for c in spec.children if c.type == "identifier"),
|
|
453
|
+
None,
|
|
454
|
+
)
|
|
455
|
+
if first is not None:
|
|
456
|
+
names.append(node_text(first, src))
|
|
457
|
+
elif child.type == "namespace_import":
|
|
458
|
+
# `import * as ns from 'm'` -> bind `ns` to the module itself.
|
|
459
|
+
ident = next(
|
|
460
|
+
(c for c in child.children if c.type == "identifier"),
|
|
461
|
+
None,
|
|
462
|
+
)
|
|
463
|
+
if ident is not None:
|
|
464
|
+
# Namespace alias maps to the module, not a sub-name.
|
|
465
|
+
# We skip per-name edges for namespace imports; the existing
|
|
466
|
+
# source-level IMPORTS edge already covers the module.
|
|
467
|
+
continue
|
|
468
|
+
return names
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@register_extractor
|
|
472
|
+
class TypeScriptExtractor(ExtractorBase):
|
|
473
|
+
language = "typescript"
|
|
474
|
+
extensions = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
|
|
475
|
+
|
|
476
|
+
def parse_file(
|
|
477
|
+
self, path: Path, repo_root: Path
|
|
478
|
+
) -> tuple[list[Node], list[Edge]]:
|
|
479
|
+
src = path.read_bytes()
|
|
480
|
+
rel = path.relative_to(repo_root).as_posix()
|
|
481
|
+
ext = path.suffix.lower()
|
|
482
|
+
lang_key = EXT_TO_LANG.get(ext, "typescript")
|
|
483
|
+
parser = load_parser(lang_key)
|
|
484
|
+
tree = parser.parse(src)
|
|
485
|
+
root = tree.root_node
|
|
486
|
+
|
|
487
|
+
nodes: list[Node] = []
|
|
488
|
+
edges: list[Edge] = []
|
|
489
|
+
|
|
490
|
+
is_test = _is_test_file(rel)
|
|
491
|
+
qualname = _file_to_qualname(rel)
|
|
492
|
+
module_id = make_node_id(NodeKind.MODULE, qualname, rel)
|
|
493
|
+
module_node = Node(
|
|
494
|
+
id=module_id,
|
|
495
|
+
kind=NodeKind.MODULE,
|
|
496
|
+
name=path.stem,
|
|
497
|
+
qualname=qualname,
|
|
498
|
+
file=rel,
|
|
499
|
+
line_start=1,
|
|
500
|
+
line_end=root.end_point[0] + 1,
|
|
501
|
+
language=lang_key,
|
|
502
|
+
metadata={"is_test": is_test},
|
|
503
|
+
)
|
|
504
|
+
nodes.append(module_node)
|
|
505
|
+
|
|
506
|
+
if is_test:
|
|
507
|
+
test_id = make_node_id(NodeKind.TEST, qualname, rel)
|
|
508
|
+
test_node = Node(
|
|
509
|
+
id=test_id,
|
|
510
|
+
kind=NodeKind.TEST,
|
|
511
|
+
name=path.stem,
|
|
512
|
+
qualname=qualname,
|
|
513
|
+
file=rel,
|
|
514
|
+
line_start=1,
|
|
515
|
+
line_end=root.end_point[0] + 1,
|
|
516
|
+
language=lang_key,
|
|
517
|
+
metadata={"is_test": True},
|
|
518
|
+
)
|
|
519
|
+
nodes.append(test_node)
|
|
520
|
+
|
|
521
|
+
self._visit(root, rel, qualname, module_id, lang_key, src, nodes, edges)
|
|
522
|
+
self._collect_require_imports(root, rel, module_id, src, edges)
|
|
523
|
+
express_routes = self._collect_express_routes(root, src)
|
|
524
|
+
if express_routes:
|
|
525
|
+
module_node.metadata["express_routes"] = express_routes
|
|
526
|
+
return nodes, edges
|
|
527
|
+
|
|
528
|
+
def _collect_express_routes(
|
|
529
|
+
self, root: tree_sitter.Node, src: bytes,
|
|
530
|
+
) -> list[dict[str, Any]]:
|
|
531
|
+
"""Find Express/Koa-style route registrations anywhere in the tree.
|
|
532
|
+
|
|
533
|
+
Matches ``app.get('/x', fn)``, ``router.post('/y', mw, fn)``, etc.
|
|
534
|
+
Returns a list of ``{"method", "path", "handler_name", "line"}``
|
|
535
|
+
dicts which is stored on the module node's metadata for downstream
|
|
536
|
+
consumption by ``codegraph.analysis.infrastructure``.
|
|
537
|
+
"""
|
|
538
|
+
verbs = {"get", "post", "put", "delete", "patch", "head", "options", "all"}
|
|
539
|
+
out: list[dict[str, Any]] = []
|
|
540
|
+
stack: list[tree_sitter.Node] = [root]
|
|
541
|
+
while stack:
|
|
542
|
+
node = stack.pop()
|
|
543
|
+
if node.type == "call_expression":
|
|
544
|
+
func_child = node.child_by_field_name("function")
|
|
545
|
+
if func_child is not None and func_child.type == "member_expression":
|
|
546
|
+
obj_node = func_child.child_by_field_name("object")
|
|
547
|
+
prop_node = func_child.child_by_field_name("property")
|
|
548
|
+
receiver = node_text(obj_node, src) if obj_node else ""
|
|
549
|
+
verb = node_text(prop_node, src).lower() if prop_node else ""
|
|
550
|
+
receiver_lc = receiver.lower()
|
|
551
|
+
if (
|
|
552
|
+
verb in verbs
|
|
553
|
+
and (
|
|
554
|
+
"router" in receiver_lc
|
|
555
|
+
or "app" in receiver_lc
|
|
556
|
+
or "api" in receiver_lc
|
|
557
|
+
or receiver_lc in ("v1", "v2")
|
|
558
|
+
)
|
|
559
|
+
):
|
|
560
|
+
args_node = node.child_by_field_name("arguments")
|
|
561
|
+
if args_node is None:
|
|
562
|
+
for c in node.children:
|
|
563
|
+
if c.type == "arguments":
|
|
564
|
+
args_node = c
|
|
565
|
+
break
|
|
566
|
+
if args_node is not None:
|
|
567
|
+
arg_children = [
|
|
568
|
+
c for c in args_node.children
|
|
569
|
+
if c.type not in (",", "(", ")")
|
|
570
|
+
]
|
|
571
|
+
if arg_children and arg_children[0].type in (
|
|
572
|
+
"string", "template_string",
|
|
573
|
+
):
|
|
574
|
+
path_text = _extract_string(arg_children[0], src) \
|
|
575
|
+
if arg_children[0].type == "string" \
|
|
576
|
+
else node_text(arg_children[0], src).strip("`")
|
|
577
|
+
handler_name = ""
|
|
578
|
+
for c in arg_children[1:]:
|
|
579
|
+
if c.type == "identifier":
|
|
580
|
+
handler_name = node_text(c, src)
|
|
581
|
+
elif c.type in (
|
|
582
|
+
"arrow_function", "function",
|
|
583
|
+
"function_expression",
|
|
584
|
+
):
|
|
585
|
+
handler_name = ""
|
|
586
|
+
break
|
|
587
|
+
out.append({
|
|
588
|
+
"method": verb.upper(),
|
|
589
|
+
"path": path_text,
|
|
590
|
+
"handler_name": handler_name,
|
|
591
|
+
"line": node.start_point[0] + 1,
|
|
592
|
+
})
|
|
593
|
+
stack.extend(node.children)
|
|
594
|
+
return out
|
|
595
|
+
|
|
596
|
+
def _collect_require_imports(
|
|
597
|
+
self,
|
|
598
|
+
root: tree_sitter.Node,
|
|
599
|
+
rel: str,
|
|
600
|
+
module_id: str,
|
|
601
|
+
src: bytes,
|
|
602
|
+
edges: list[Edge],
|
|
603
|
+
) -> None:
|
|
604
|
+
"""Capture CommonJS ``require("x")`` and dynamic ``import("x")`` as
|
|
605
|
+
IMPORTS edges. Walks the whole tree once. Idempotent against the
|
|
606
|
+
ES-import handler — those run on ``import_statement`` nodes which
|
|
607
|
+
this loop ignores.
|
|
608
|
+
"""
|
|
609
|
+
stack: list[tree_sitter.Node] = [root]
|
|
610
|
+
while stack:
|
|
611
|
+
node = stack.pop()
|
|
612
|
+
if node.type == "call_expression":
|
|
613
|
+
func_child = node.child_by_field_name("function")
|
|
614
|
+
if func_child is None and node.children:
|
|
615
|
+
func_child = node.children[0]
|
|
616
|
+
fn_name = node_text(func_child, src) if func_child else ""
|
|
617
|
+
if fn_name in ("require", "import"):
|
|
618
|
+
args_node = node.child_by_field_name("arguments")
|
|
619
|
+
if args_node is None:
|
|
620
|
+
for c in node.children:
|
|
621
|
+
if c.type == "arguments":
|
|
622
|
+
args_node = c
|
|
623
|
+
break
|
|
624
|
+
target = ""
|
|
625
|
+
if args_node is not None:
|
|
626
|
+
for c in args_node.children:
|
|
627
|
+
if c.type == "string":
|
|
628
|
+
target = _extract_string(c, src)
|
|
629
|
+
break
|
|
630
|
+
if target:
|
|
631
|
+
edges.append(Edge(
|
|
632
|
+
src=module_id,
|
|
633
|
+
dst=f"unresolved::{target}",
|
|
634
|
+
kind=EdgeKind.IMPORTS,
|
|
635
|
+
file=rel,
|
|
636
|
+
line=node.start_point[0] + 1,
|
|
637
|
+
metadata={
|
|
638
|
+
"source": target,
|
|
639
|
+
"target_name": target,
|
|
640
|
+
"via": fn_name,
|
|
641
|
+
},
|
|
642
|
+
))
|
|
643
|
+
stack.extend(node.children)
|
|
644
|
+
|
|
645
|
+
def _visit(
|
|
646
|
+
self,
|
|
647
|
+
block: tree_sitter.Node,
|
|
648
|
+
rel: str,
|
|
649
|
+
parent_qualname: str,
|
|
650
|
+
parent_id: str,
|
|
651
|
+
lang: str,
|
|
652
|
+
src: bytes,
|
|
653
|
+
nodes: list[Node],
|
|
654
|
+
edges: list[Edge],
|
|
655
|
+
) -> None:
|
|
656
|
+
for child in block.children:
|
|
657
|
+
ct = child.type
|
|
658
|
+
if ct == "import_statement":
|
|
659
|
+
self._handle_import(child, rel, parent_id, src, edges)
|
|
660
|
+
elif ct in ("class_declaration", "abstract_class_declaration"):
|
|
661
|
+
self._handle_class(
|
|
662
|
+
child, rel, parent_qualname, parent_id, lang, src, nodes, edges
|
|
663
|
+
)
|
|
664
|
+
elif ct == "function_declaration":
|
|
665
|
+
self._handle_function_decl(
|
|
666
|
+
child, rel, parent_qualname, parent_id, lang, src, nodes, edges
|
|
667
|
+
)
|
|
668
|
+
elif ct in ("lexical_declaration", "variable_declaration"):
|
|
669
|
+
self._handle_lexical_decl(
|
|
670
|
+
child, rel, parent_qualname, parent_id, lang, src, nodes, edges
|
|
671
|
+
)
|
|
672
|
+
elif ct == "export_statement":
|
|
673
|
+
for sub in child.children:
|
|
674
|
+
if sub.type in (
|
|
675
|
+
"class_declaration", "abstract_class_declaration"
|
|
676
|
+
):
|
|
677
|
+
self._handle_class(
|
|
678
|
+
sub, rel, parent_qualname, parent_id, lang,
|
|
679
|
+
src, nodes, edges,
|
|
680
|
+
)
|
|
681
|
+
elif sub.type == "function_declaration":
|
|
682
|
+
self._handle_function_decl(
|
|
683
|
+
sub, rel, parent_qualname, parent_id, lang,
|
|
684
|
+
src, nodes, edges,
|
|
685
|
+
)
|
|
686
|
+
elif sub.type in (
|
|
687
|
+
"lexical_declaration", "variable_declaration"
|
|
688
|
+
):
|
|
689
|
+
self._handle_lexical_decl(
|
|
690
|
+
sub, rel, parent_qualname, parent_id, lang,
|
|
691
|
+
src, nodes, edges,
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
def _handle_import(
|
|
695
|
+
self,
|
|
696
|
+
node: tree_sitter.Node,
|
|
697
|
+
rel: str,
|
|
698
|
+
parent_id: str,
|
|
699
|
+
src: bytes,
|
|
700
|
+
edges: list[Edge],
|
|
701
|
+
) -> None:
|
|
702
|
+
source_node: tree_sitter.Node | None = None
|
|
703
|
+
clause_node: tree_sitter.Node | None = None
|
|
704
|
+
for child in node.children:
|
|
705
|
+
if child.type == "string" and source_node is None:
|
|
706
|
+
source_node = child
|
|
707
|
+
elif child.type == "import_clause":
|
|
708
|
+
clause_node = child
|
|
709
|
+
if source_node is None:
|
|
710
|
+
return
|
|
711
|
+
source = _extract_string(source_node, src)
|
|
712
|
+
line = node.start_point[0] + 1
|
|
713
|
+
named = _named_imports(clause_node, src)
|
|
714
|
+
|
|
715
|
+
# When there are no named imports (e.g. `import './side-effect'`,
|
|
716
|
+
# `import * as ns from './m'`), keep the module-level edge. When we
|
|
717
|
+
# have per-name edges, they carry binding info and the module-level
|
|
718
|
+
# edge would be redundant noise.
|
|
719
|
+
if not named:
|
|
720
|
+
edges.append(Edge(
|
|
721
|
+
src=parent_id,
|
|
722
|
+
dst=f"unresolved::{source}",
|
|
723
|
+
kind=EdgeKind.IMPORTS,
|
|
724
|
+
file=rel,
|
|
725
|
+
line=line,
|
|
726
|
+
metadata={"source": source, "target_name": source},
|
|
727
|
+
))
|
|
728
|
+
|
|
729
|
+
for imported_name in named:
|
|
730
|
+
edges.append(Edge(
|
|
731
|
+
src=parent_id,
|
|
732
|
+
dst=f"unresolved::{source}.{imported_name}",
|
|
733
|
+
kind=EdgeKind.IMPORTS,
|
|
734
|
+
file=rel,
|
|
735
|
+
line=line,
|
|
736
|
+
metadata={
|
|
737
|
+
"source": source,
|
|
738
|
+
"target_name": f"{source}.{imported_name}",
|
|
739
|
+
"imported_name": imported_name,
|
|
740
|
+
},
|
|
741
|
+
))
|
|
742
|
+
|
|
743
|
+
def _handle_class(
|
|
744
|
+
self,
|
|
745
|
+
node: tree_sitter.Node,
|
|
746
|
+
rel: str,
|
|
747
|
+
parent_qualname: str,
|
|
748
|
+
parent_id: str,
|
|
749
|
+
lang: str,
|
|
750
|
+
src: bytes,
|
|
751
|
+
nodes: list[Node],
|
|
752
|
+
edges: list[Edge],
|
|
753
|
+
) -> None:
|
|
754
|
+
name_node = node.child_by_field_name("name")
|
|
755
|
+
if name_node is None:
|
|
756
|
+
for c in node.children:
|
|
757
|
+
if c.type == "type_identifier":
|
|
758
|
+
name_node = c
|
|
759
|
+
break
|
|
760
|
+
if name_node is None:
|
|
761
|
+
return
|
|
762
|
+
name = node_text(name_node, src)
|
|
763
|
+
qualname = f"{parent_qualname}.{name}" if parent_qualname else name
|
|
764
|
+
class_id = make_node_id(NodeKind.CLASS, qualname, rel)
|
|
765
|
+
|
|
766
|
+
cls_md: dict[str, Any] = {}
|
|
767
|
+
if _has_public_api_pragma_ts(node, src):
|
|
768
|
+
cls_md["public_api"] = True
|
|
769
|
+
class_node = Node(
|
|
770
|
+
id=class_id,
|
|
771
|
+
kind=NodeKind.CLASS,
|
|
772
|
+
name=name,
|
|
773
|
+
qualname=qualname,
|
|
774
|
+
file=rel,
|
|
775
|
+
line_start=node.start_point[0] + 1,
|
|
776
|
+
line_end=node.end_point[0] + 1,
|
|
777
|
+
language=lang,
|
|
778
|
+
metadata=cls_md,
|
|
779
|
+
)
|
|
780
|
+
nodes.append(class_node)
|
|
781
|
+
|
|
782
|
+
edges.append(Edge(
|
|
783
|
+
src=class_id, dst=parent_id, kind=EdgeKind.DEFINED_IN,
|
|
784
|
+
file=rel, line=node.start_point[0] + 1,
|
|
785
|
+
))
|
|
786
|
+
|
|
787
|
+
for child in node.children:
|
|
788
|
+
if child.type == "class_heritage":
|
|
789
|
+
for sub in child.children:
|
|
790
|
+
if sub.type == "extends_clause":
|
|
791
|
+
for base in sub.children:
|
|
792
|
+
if base.is_named and base.type in (
|
|
793
|
+
"identifier", "member_expression",
|
|
794
|
+
"type_identifier",
|
|
795
|
+
):
|
|
796
|
+
base_name = node_text(base, src)
|
|
797
|
+
edges.append(Edge(
|
|
798
|
+
src=class_id,
|
|
799
|
+
dst=f"unresolved::{base_name}",
|
|
800
|
+
kind=EdgeKind.INHERITS,
|
|
801
|
+
file=rel,
|
|
802
|
+
line=node.start_point[0] + 1,
|
|
803
|
+
metadata={"target_name": base_name},
|
|
804
|
+
))
|
|
805
|
+
elif sub.type == "implements_clause":
|
|
806
|
+
for base in sub.children:
|
|
807
|
+
if base.is_named and base.type in (
|
|
808
|
+
"identifier", "type_identifier",
|
|
809
|
+
"generic_type",
|
|
810
|
+
):
|
|
811
|
+
base_name = node_text(base, src)
|
|
812
|
+
edges.append(Edge(
|
|
813
|
+
src=class_id,
|
|
814
|
+
dst=f"unresolved::{base_name}",
|
|
815
|
+
kind=EdgeKind.IMPLEMENTS,
|
|
816
|
+
file=rel,
|
|
817
|
+
line=node.start_point[0] + 1,
|
|
818
|
+
metadata={"target_name": base_name},
|
|
819
|
+
))
|
|
820
|
+
|
|
821
|
+
body = node.child_by_field_name("body")
|
|
822
|
+
if body is None:
|
|
823
|
+
for c in node.children:
|
|
824
|
+
if c.type == "class_body":
|
|
825
|
+
body = c
|
|
826
|
+
break
|
|
827
|
+
if body is not None:
|
|
828
|
+
for child in body.children:
|
|
829
|
+
if child.type == "method_definition":
|
|
830
|
+
self._handle_method(
|
|
831
|
+
child, rel, qualname, class_id, lang, src, nodes, edges
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
def _handle_method(
|
|
835
|
+
self,
|
|
836
|
+
node: tree_sitter.Node,
|
|
837
|
+
rel: str,
|
|
838
|
+
parent_qualname: str,
|
|
839
|
+
parent_id: str,
|
|
840
|
+
lang: str,
|
|
841
|
+
src: bytes,
|
|
842
|
+
nodes: list[Node],
|
|
843
|
+
edges: list[Edge],
|
|
844
|
+
) -> None:
|
|
845
|
+
name_node = node.child_by_field_name("name")
|
|
846
|
+
if name_node is None:
|
|
847
|
+
for c in node.children:
|
|
848
|
+
if c.type in ("property_identifier", "identifier"):
|
|
849
|
+
name_node = c
|
|
850
|
+
break
|
|
851
|
+
if name_node is None:
|
|
852
|
+
return
|
|
853
|
+
name = node_text(name_node, src)
|
|
854
|
+
qualname = f"{parent_qualname}.{name}"
|
|
855
|
+
method_id = make_node_id(NodeKind.METHOD, qualname, rel)
|
|
856
|
+
|
|
857
|
+
params = node.child_by_field_name("parameters")
|
|
858
|
+
sig = f"{name}{node_text(params, src)}" if params is not None else name
|
|
859
|
+
params_list = _extract_params(params, src)
|
|
860
|
+
return_type = _extract_return_type(node, params, src)
|
|
861
|
+
|
|
862
|
+
method_md: dict[str, Any] = {
|
|
863
|
+
"params": params_list,
|
|
864
|
+
"returns": return_type,
|
|
865
|
+
}
|
|
866
|
+
if _has_public_api_pragma_ts(node, src):
|
|
867
|
+
method_md["public_api"] = True
|
|
868
|
+
method_node = Node(
|
|
869
|
+
id=method_id,
|
|
870
|
+
kind=NodeKind.METHOD,
|
|
871
|
+
name=name,
|
|
872
|
+
qualname=qualname,
|
|
873
|
+
file=rel,
|
|
874
|
+
line_start=node.start_point[0] + 1,
|
|
875
|
+
line_end=node.end_point[0] + 1,
|
|
876
|
+
signature=sig,
|
|
877
|
+
language=lang,
|
|
878
|
+
metadata=method_md,
|
|
879
|
+
)
|
|
880
|
+
nodes.append(method_node)
|
|
881
|
+
|
|
882
|
+
edges.append(Edge(
|
|
883
|
+
src=method_id, dst=parent_id, kind=EdgeKind.DEFINED_IN,
|
|
884
|
+
file=rel, line=node.start_point[0] + 1,
|
|
885
|
+
))
|
|
886
|
+
|
|
887
|
+
body = node.child_by_field_name("body")
|
|
888
|
+
if body is not None:
|
|
889
|
+
self._collect_calls(body, rel, method_id, src, edges)
|
|
890
|
+
self._collect_fetches(body, rel, method_id, src, nodes, edges)
|
|
891
|
+
|
|
892
|
+
def _handle_function_decl(
|
|
893
|
+
self,
|
|
894
|
+
node: tree_sitter.Node,
|
|
895
|
+
rel: str,
|
|
896
|
+
parent_qualname: str,
|
|
897
|
+
parent_id: str,
|
|
898
|
+
lang: str,
|
|
899
|
+
src: bytes,
|
|
900
|
+
nodes: list[Node],
|
|
901
|
+
edges: list[Edge],
|
|
902
|
+
) -> None:
|
|
903
|
+
name_node = node.child_by_field_name("name")
|
|
904
|
+
if name_node is None:
|
|
905
|
+
for c in node.children:
|
|
906
|
+
if c.type == "identifier":
|
|
907
|
+
name_node = c
|
|
908
|
+
break
|
|
909
|
+
if name_node is None:
|
|
910
|
+
return
|
|
911
|
+
name = node_text(name_node, src)
|
|
912
|
+
qualname = f"{parent_qualname}.{name}" if parent_qualname else name
|
|
913
|
+
func_id = make_node_id(NodeKind.FUNCTION, qualname, rel)
|
|
914
|
+
|
|
915
|
+
params = node.child_by_field_name("parameters")
|
|
916
|
+
sig = f"{name}{node_text(params, src)}" if params is not None else name
|
|
917
|
+
params_list = _extract_params(params, src)
|
|
918
|
+
return_type = _extract_return_type(node, params, src)
|
|
919
|
+
|
|
920
|
+
func_md: dict[str, Any] = {
|
|
921
|
+
"params": params_list,
|
|
922
|
+
"returns": return_type,
|
|
923
|
+
}
|
|
924
|
+
if _has_public_api_pragma_ts(node, src):
|
|
925
|
+
func_md["public_api"] = True
|
|
926
|
+
func_node = Node(
|
|
927
|
+
id=func_id,
|
|
928
|
+
kind=NodeKind.FUNCTION,
|
|
929
|
+
name=name,
|
|
930
|
+
qualname=qualname,
|
|
931
|
+
file=rel,
|
|
932
|
+
line_start=node.start_point[0] + 1,
|
|
933
|
+
line_end=node.end_point[0] + 1,
|
|
934
|
+
signature=sig,
|
|
935
|
+
language=lang,
|
|
936
|
+
metadata=func_md,
|
|
937
|
+
)
|
|
938
|
+
nodes.append(func_node)
|
|
939
|
+
|
|
940
|
+
edges.append(Edge(
|
|
941
|
+
src=func_id, dst=parent_id, kind=EdgeKind.DEFINED_IN,
|
|
942
|
+
file=rel, line=node.start_point[0] + 1,
|
|
943
|
+
))
|
|
944
|
+
|
|
945
|
+
body = node.child_by_field_name("body")
|
|
946
|
+
if body is not None:
|
|
947
|
+
self._collect_calls(body, rel, func_id, src, edges)
|
|
948
|
+
self._collect_fetches(body, rel, func_id, src, nodes, edges)
|
|
949
|
+
|
|
950
|
+
def _handle_lexical_decl(
|
|
951
|
+
self,
|
|
952
|
+
node: tree_sitter.Node,
|
|
953
|
+
rel: str,
|
|
954
|
+
parent_qualname: str,
|
|
955
|
+
parent_id: str,
|
|
956
|
+
lang: str,
|
|
957
|
+
src: bytes,
|
|
958
|
+
nodes: list[Node],
|
|
959
|
+
edges: list[Edge],
|
|
960
|
+
) -> None:
|
|
961
|
+
for child in node.children:
|
|
962
|
+
if child.type != "variable_declarator":
|
|
963
|
+
continue
|
|
964
|
+
name_node = child.child_by_field_name("name")
|
|
965
|
+
if name_node is None:
|
|
966
|
+
for c in child.children:
|
|
967
|
+
if c.type == "identifier":
|
|
968
|
+
name_node = c
|
|
969
|
+
break
|
|
970
|
+
value_node = child.child_by_field_name("value")
|
|
971
|
+
if (
|
|
972
|
+
name_node is not None
|
|
973
|
+
and value_node is not None
|
|
974
|
+
and value_node.type in ("arrow_function", "function", "function_expression")
|
|
975
|
+
):
|
|
976
|
+
name = node_text(name_node, src)
|
|
977
|
+
qualname = (
|
|
978
|
+
f"{parent_qualname}.{name}" if parent_qualname else name
|
|
979
|
+
)
|
|
980
|
+
func_id = make_node_id(NodeKind.FUNCTION, qualname, rel)
|
|
981
|
+
|
|
982
|
+
arrow_params = value_node.child_by_field_name("parameters")
|
|
983
|
+
if arrow_params is None:
|
|
984
|
+
for c in value_node.children:
|
|
985
|
+
if c.type == "formal_parameters":
|
|
986
|
+
arrow_params = c
|
|
987
|
+
break
|
|
988
|
+
params_list = _extract_params(arrow_params, src)
|
|
989
|
+
return_type = _extract_return_type(
|
|
990
|
+
value_node, arrow_params, src
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
func_node = Node(
|
|
994
|
+
id=func_id,
|
|
995
|
+
kind=NodeKind.FUNCTION,
|
|
996
|
+
name=name,
|
|
997
|
+
qualname=qualname,
|
|
998
|
+
file=rel,
|
|
999
|
+
line_start=node.start_point[0] + 1,
|
|
1000
|
+
line_end=node.end_point[0] + 1,
|
|
1001
|
+
language=lang,
|
|
1002
|
+
metadata={
|
|
1003
|
+
"arrow": True,
|
|
1004
|
+
"params": params_list,
|
|
1005
|
+
"returns": return_type,
|
|
1006
|
+
},
|
|
1007
|
+
)
|
|
1008
|
+
nodes.append(func_node)
|
|
1009
|
+
|
|
1010
|
+
edges.append(Edge(
|
|
1011
|
+
src=func_id, dst=parent_id, kind=EdgeKind.DEFINED_IN,
|
|
1012
|
+
file=rel, line=node.start_point[0] + 1,
|
|
1013
|
+
))
|
|
1014
|
+
|
|
1015
|
+
body = value_node.child_by_field_name("body")
|
|
1016
|
+
if body is not None:
|
|
1017
|
+
self._collect_calls(body, rel, func_id, src, edges)
|
|
1018
|
+
self._collect_fetches(body, rel, func_id, src, nodes, edges)
|
|
1019
|
+
|
|
1020
|
+
def _collect_calls(
|
|
1021
|
+
self,
|
|
1022
|
+
node: tree_sitter.Node,
|
|
1023
|
+
rel: str,
|
|
1024
|
+
scope_id: str,
|
|
1025
|
+
src: bytes,
|
|
1026
|
+
edges: list[Edge],
|
|
1027
|
+
) -> None:
|
|
1028
|
+
stack: list[tree_sitter.Node] = list(node.children)
|
|
1029
|
+
while stack:
|
|
1030
|
+
child = stack.pop()
|
|
1031
|
+
if child.type == "call_expression":
|
|
1032
|
+
func_child = child.child_by_field_name("function")
|
|
1033
|
+
if func_child is None and child.children:
|
|
1034
|
+
func_child = child.children[0]
|
|
1035
|
+
if func_child is not None:
|
|
1036
|
+
name = node_text(func_child, src)
|
|
1037
|
+
args_node = child.child_by_field_name("arguments")
|
|
1038
|
+
if args_node is None:
|
|
1039
|
+
for c in child.children:
|
|
1040
|
+
if c.type == "arguments":
|
|
1041
|
+
args_node = c
|
|
1042
|
+
break
|
|
1043
|
+
if args_node is not None:
|
|
1044
|
+
call_args, call_kwargs = _split_call_arguments(
|
|
1045
|
+
args_node, src
|
|
1046
|
+
)
|
|
1047
|
+
else:
|
|
1048
|
+
call_args, call_kwargs = [], {}
|
|
1049
|
+
edges.append(Edge(
|
|
1050
|
+
src=scope_id,
|
|
1051
|
+
dst=f"unresolved::{name}",
|
|
1052
|
+
kind=EdgeKind.CALLS,
|
|
1053
|
+
file=rel,
|
|
1054
|
+
line=child.start_point[0] + 1,
|
|
1055
|
+
metadata={
|
|
1056
|
+
"target_name": name,
|
|
1057
|
+
"args": call_args,
|
|
1058
|
+
"kwargs": call_kwargs,
|
|
1059
|
+
},
|
|
1060
|
+
))
|
|
1061
|
+
stack.extend(child.children)
|
|
1062
|
+
|
|
1063
|
+
# ------------------------------------------------------------------
|
|
1064
|
+
# DF2: HTTP call-site detection (fetch / axios / SWR / api-clients)
|
|
1065
|
+
# ------------------------------------------------------------------
|
|
1066
|
+
|
|
1067
|
+
def _collect_fetches(
|
|
1068
|
+
self,
|
|
1069
|
+
node: tree_sitter.Node,
|
|
1070
|
+
rel: str,
|
|
1071
|
+
scope_id: str,
|
|
1072
|
+
src: bytes,
|
|
1073
|
+
nodes: list[Node],
|
|
1074
|
+
edges: list[Edge],
|
|
1075
|
+
) -> None:
|
|
1076
|
+
"""Walk the function body and emit FETCH_CALL edges for HTTP call sites.
|
|
1077
|
+
|
|
1078
|
+
Recognised patterns:
|
|
1079
|
+
fetch(url, init?) library=fetch
|
|
1080
|
+
axios.get/post/put/delete/patch(...) library=axios
|
|
1081
|
+
axios({ method, url, data }) library=axios
|
|
1082
|
+
useSWR(url, fetcher) library=swr (treated as GET)
|
|
1083
|
+
useQuery({ queryKey, queryFn }) library=tanstack (best-effort)
|
|
1084
|
+
apiClient.get/post/put/delete(url) library=apiclient (any ident)
|
|
1085
|
+
"""
|
|
1086
|
+
stack: list[tree_sitter.Node] = list(node.children)
|
|
1087
|
+
while stack:
|
|
1088
|
+
child = stack.pop()
|
|
1089
|
+
if child.type == "call_expression":
|
|
1090
|
+
self._maybe_emit_fetch(child, rel, scope_id, src, nodes, edges)
|
|
1091
|
+
stack.extend(child.children)
|
|
1092
|
+
|
|
1093
|
+
def _maybe_emit_fetch(
|
|
1094
|
+
self,
|
|
1095
|
+
call_node: tree_sitter.Node,
|
|
1096
|
+
rel: str,
|
|
1097
|
+
scope_id: str,
|
|
1098
|
+
src: bytes,
|
|
1099
|
+
nodes: list[Node],
|
|
1100
|
+
edges: list[Edge],
|
|
1101
|
+
) -> None:
|
|
1102
|
+
func_child = call_node.child_by_field_name("function")
|
|
1103
|
+
if func_child is None and call_node.children:
|
|
1104
|
+
func_child = call_node.children[0]
|
|
1105
|
+
if func_child is None:
|
|
1106
|
+
return
|
|
1107
|
+
args_node = call_node.child_by_field_name("arguments")
|
|
1108
|
+
if args_node is None:
|
|
1109
|
+
for c in call_node.children:
|
|
1110
|
+
if c.type == "arguments":
|
|
1111
|
+
args_node = c
|
|
1112
|
+
break
|
|
1113
|
+
if args_node is None:
|
|
1114
|
+
return
|
|
1115
|
+
named_args: list[tree_sitter.Node] = [
|
|
1116
|
+
c for c in args_node.children if c.is_named
|
|
1117
|
+
]
|
|
1118
|
+
|
|
1119
|
+
line = call_node.start_point[0] + 1
|
|
1120
|
+
|
|
1121
|
+
# --- fetch(url, init?) ---
|
|
1122
|
+
if func_child.type == "identifier" and node_text(func_child, src) == "fetch":
|
|
1123
|
+
if not named_args:
|
|
1124
|
+
return
|
|
1125
|
+
url_node = named_args[0]
|
|
1126
|
+
init_node = named_args[1] if len(named_args) >= 2 else None
|
|
1127
|
+
method = "GET"
|
|
1128
|
+
body_keys: list[str] = []
|
|
1129
|
+
if init_node is not None and init_node.type == "object":
|
|
1130
|
+
m = _extract_method_from_init(init_node, src)
|
|
1131
|
+
if m:
|
|
1132
|
+
method = m
|
|
1133
|
+
body_keys = _extract_body_keys_from_init(init_node, src)
|
|
1134
|
+
self._emit_fetch_edge(
|
|
1135
|
+
rel, scope_id, line, method, url_node,
|
|
1136
|
+
"fetch", body_keys, src, nodes, edges,
|
|
1137
|
+
)
|
|
1138
|
+
return
|
|
1139
|
+
|
|
1140
|
+
# --- useSWR(url, fetcher) ---
|
|
1141
|
+
if (
|
|
1142
|
+
func_child.type == "identifier"
|
|
1143
|
+
and node_text(func_child, src) == "useSWR"
|
|
1144
|
+
and named_args
|
|
1145
|
+
):
|
|
1146
|
+
self._emit_fetch_edge(
|
|
1147
|
+
rel, scope_id, line, "GET", named_args[0],
|
|
1148
|
+
"swr", [], src, nodes, edges,
|
|
1149
|
+
)
|
|
1150
|
+
return
|
|
1151
|
+
|
|
1152
|
+
# --- axios(config) — identifier call with single object arg ---
|
|
1153
|
+
if (
|
|
1154
|
+
func_child.type == "identifier"
|
|
1155
|
+
and node_text(func_child, src) == "axios"
|
|
1156
|
+
and named_args
|
|
1157
|
+
and named_args[0].type == "object"
|
|
1158
|
+
):
|
|
1159
|
+
cfg = named_args[0]
|
|
1160
|
+
method = "GET"
|
|
1161
|
+
cfg_url_node: tree_sitter.Node | None = None
|
|
1162
|
+
body_keys = []
|
|
1163
|
+
for pair in cfg.children:
|
|
1164
|
+
if pair.type != "pair":
|
|
1165
|
+
continue
|
|
1166
|
+
key_node = pair.child_by_field_name("key")
|
|
1167
|
+
if key_node is None:
|
|
1168
|
+
continue
|
|
1169
|
+
key_text = node_text(key_node, src)
|
|
1170
|
+
if key_node.type == "string":
|
|
1171
|
+
key_text = _strip_quotes(key_text)
|
|
1172
|
+
value_node = pair.child_by_field_name("value")
|
|
1173
|
+
if value_node is None:
|
|
1174
|
+
nm = [c for c in pair.children if c.is_named]
|
|
1175
|
+
if len(nm) >= 2:
|
|
1176
|
+
value_node = nm[-1]
|
|
1177
|
+
if value_node is None:
|
|
1178
|
+
continue
|
|
1179
|
+
if key_text == "method" and value_node.type == "string":
|
|
1180
|
+
method = _strip_quotes(node_text(value_node, src)).upper()
|
|
1181
|
+
elif key_text == "url":
|
|
1182
|
+
cfg_url_node = value_node
|
|
1183
|
+
elif key_text == "data" and value_node.type == "object":
|
|
1184
|
+
body_keys = _object_top_level_keys(value_node, src)
|
|
1185
|
+
if cfg_url_node is not None:
|
|
1186
|
+
self._emit_fetch_edge(
|
|
1187
|
+
rel, scope_id, line, method, cfg_url_node,
|
|
1188
|
+
"axios", body_keys, src, nodes, edges,
|
|
1189
|
+
)
|
|
1190
|
+
return
|
|
1191
|
+
|
|
1192
|
+
# --- useQuery({ queryKey, queryFn }) — best-effort ---
|
|
1193
|
+
if (
|
|
1194
|
+
func_child.type == "identifier"
|
|
1195
|
+
and node_text(func_child, src) == "useQuery"
|
|
1196
|
+
and named_args
|
|
1197
|
+
and named_args[0].type == "object"
|
|
1198
|
+
):
|
|
1199
|
+
self._maybe_emit_useQuery(
|
|
1200
|
+
named_args[0], rel, scope_id, src, nodes, edges,
|
|
1201
|
+
)
|
|
1202
|
+
return
|
|
1203
|
+
|
|
1204
|
+
# --- IDENT.METHOD(url, ...) — axios.get / apiClient.post / etc. ---
|
|
1205
|
+
if func_child.type == "member_expression":
|
|
1206
|
+
obj_node = func_child.child_by_field_name("object")
|
|
1207
|
+
prop_node = func_child.child_by_field_name("property")
|
|
1208
|
+
if obj_node is None or prop_node is None:
|
|
1209
|
+
return
|
|
1210
|
+
if obj_node.type != "identifier":
|
|
1211
|
+
return
|
|
1212
|
+
method_name = node_text(prop_node, src).lower()
|
|
1213
|
+
if method_name not in _HTTP_VERBS:
|
|
1214
|
+
return
|
|
1215
|
+
if not named_args:
|
|
1216
|
+
return
|
|
1217
|
+
url_node = named_args[0]
|
|
1218
|
+
# Only treat the first arg as a URL if it looks URL-ish.
|
|
1219
|
+
if url_node.type not in ("string", "template_string", "identifier"):
|
|
1220
|
+
return
|
|
1221
|
+
obj_name = node_text(obj_node, src)
|
|
1222
|
+
library = "axios" if obj_name == "axios" else "apiclient"
|
|
1223
|
+
method = method_name.upper()
|
|
1224
|
+
body_keys = []
|
|
1225
|
+
if (
|
|
1226
|
+
method in {"POST", "PUT", "PATCH"}
|
|
1227
|
+
and len(named_args) >= 2
|
|
1228
|
+
and named_args[1].type == "object"
|
|
1229
|
+
):
|
|
1230
|
+
body_keys = _object_top_level_keys(named_args[1], src)
|
|
1231
|
+
self._emit_fetch_edge(
|
|
1232
|
+
rel, scope_id, line, method, url_node,
|
|
1233
|
+
library, body_keys, src, nodes, edges,
|
|
1234
|
+
)
|
|
1235
|
+
return
|
|
1236
|
+
|
|
1237
|
+
def _maybe_emit_useQuery(
|
|
1238
|
+
self,
|
|
1239
|
+
cfg: tree_sitter.Node,
|
|
1240
|
+
rel: str,
|
|
1241
|
+
scope_id: str,
|
|
1242
|
+
src: bytes,
|
|
1243
|
+
nodes: list[Node],
|
|
1244
|
+
edges: list[Edge],
|
|
1245
|
+
) -> None:
|
|
1246
|
+
"""Best-effort: scan the queryFn body for a single fetch/axios call."""
|
|
1247
|
+
query_fn: tree_sitter.Node | None = None
|
|
1248
|
+
for pair in cfg.children:
|
|
1249
|
+
if pair.type != "pair":
|
|
1250
|
+
continue
|
|
1251
|
+
key_node = pair.child_by_field_name("key")
|
|
1252
|
+
if key_node is None:
|
|
1253
|
+
continue
|
|
1254
|
+
key_text = node_text(key_node, src)
|
|
1255
|
+
if key_node.type == "string":
|
|
1256
|
+
key_text = _strip_quotes(key_text)
|
|
1257
|
+
if key_text != "queryFn":
|
|
1258
|
+
continue
|
|
1259
|
+
value_node = pair.child_by_field_name("value")
|
|
1260
|
+
if value_node is None:
|
|
1261
|
+
nm = [c for c in pair.children if c.is_named]
|
|
1262
|
+
if len(nm) >= 2:
|
|
1263
|
+
value_node = nm[-1]
|
|
1264
|
+
query_fn = value_node
|
|
1265
|
+
break
|
|
1266
|
+
if query_fn is None:
|
|
1267
|
+
return
|
|
1268
|
+
if query_fn.type not in ("arrow_function", "function", "function_expression"):
|
|
1269
|
+
return
|
|
1270
|
+
body = query_fn.child_by_field_name("body")
|
|
1271
|
+
if body is None:
|
|
1272
|
+
return
|
|
1273
|
+
# Walk and find the first fetch/axios call site; emit with library=tanstack.
|
|
1274
|
+
stack: list[tree_sitter.Node] = list(body.children) if body.is_named else [body]
|
|
1275
|
+
# When body is an expression (arrow shorthand), it itself may be the call.
|
|
1276
|
+
if body.type == "call_expression":
|
|
1277
|
+
stack = [body]
|
|
1278
|
+
else:
|
|
1279
|
+
stack = list(body.children)
|
|
1280
|
+
stack.append(body)
|
|
1281
|
+
for sub in stack:
|
|
1282
|
+
for desc in _walk(sub):
|
|
1283
|
+
if desc.type != "call_expression":
|
|
1284
|
+
continue
|
|
1285
|
+
fc = desc.child_by_field_name("function")
|
|
1286
|
+
if fc is None:
|
|
1287
|
+
continue
|
|
1288
|
+
if fc.type == "identifier" and node_text(fc, src) == "fetch":
|
|
1289
|
+
args_node = desc.child_by_field_name("arguments")
|
|
1290
|
+
if args_node is None:
|
|
1291
|
+
continue
|
|
1292
|
+
n_args = [c for c in args_node.children if c.is_named]
|
|
1293
|
+
if not n_args:
|
|
1294
|
+
continue
|
|
1295
|
+
method = "GET"
|
|
1296
|
+
body_keys: list[str] = []
|
|
1297
|
+
if len(n_args) >= 2 and n_args[1].type == "object":
|
|
1298
|
+
m = _extract_method_from_init(n_args[1], src)
|
|
1299
|
+
if m:
|
|
1300
|
+
method = m
|
|
1301
|
+
body_keys = _extract_body_keys_from_init(n_args[1], src)
|
|
1302
|
+
self._emit_fetch_edge(
|
|
1303
|
+
rel, scope_id, desc.start_point[0] + 1, method,
|
|
1304
|
+
n_args[0], "tanstack", body_keys, src, nodes, edges,
|
|
1305
|
+
)
|
|
1306
|
+
return
|
|
1307
|
+
if fc.type == "member_expression":
|
|
1308
|
+
obj = fc.child_by_field_name("object")
|
|
1309
|
+
prop = fc.child_by_field_name("property")
|
|
1310
|
+
if (
|
|
1311
|
+
obj is not None and prop is not None
|
|
1312
|
+
and obj.type == "identifier"
|
|
1313
|
+
and node_text(obj, src) == "axios"
|
|
1314
|
+
and node_text(prop, src).lower() in _HTTP_VERBS
|
|
1315
|
+
):
|
|
1316
|
+
args_node = desc.child_by_field_name("arguments")
|
|
1317
|
+
if args_node is None:
|
|
1318
|
+
continue
|
|
1319
|
+
n_args = [c for c in args_node.children if c.is_named]
|
|
1320
|
+
if not n_args:
|
|
1321
|
+
continue
|
|
1322
|
+
method = node_text(prop, src).upper()
|
|
1323
|
+
body_keys = []
|
|
1324
|
+
if (
|
|
1325
|
+
method in {"POST", "PUT", "PATCH"}
|
|
1326
|
+
and len(n_args) >= 2
|
|
1327
|
+
and n_args[1].type == "object"
|
|
1328
|
+
):
|
|
1329
|
+
body_keys = _object_top_level_keys(n_args[1], src)
|
|
1330
|
+
self._emit_fetch_edge(
|
|
1331
|
+
rel, scope_id, desc.start_point[0] + 1, method,
|
|
1332
|
+
n_args[0], "tanstack", body_keys, src, nodes, edges,
|
|
1333
|
+
)
|
|
1334
|
+
return
|
|
1335
|
+
|
|
1336
|
+
def _emit_fetch_edge(
|
|
1337
|
+
self,
|
|
1338
|
+
rel: str,
|
|
1339
|
+
scope_id: str,
|
|
1340
|
+
line: int,
|
|
1341
|
+
method: str,
|
|
1342
|
+
url_node: tree_sitter.Node,
|
|
1343
|
+
library: str,
|
|
1344
|
+
body_keys: list[str],
|
|
1345
|
+
src: bytes,
|
|
1346
|
+
nodes: list[Node],
|
|
1347
|
+
edges: list[Edge],
|
|
1348
|
+
) -> None:
|
|
1349
|
+
url_text, url_kind = _classify_url_node(url_node, src)
|
|
1350
|
+
# Synthetic node id stable across files for the same (method, url).
|
|
1351
|
+
node_id = f"fetch::{method}::{url_text}"
|
|
1352
|
+
# De-duplicate synthetic nodes within this parse_file invocation.
|
|
1353
|
+
if not any(n.id == node_id for n in nodes):
|
|
1354
|
+
qn = f"fetch::{method}::{url_text}"
|
|
1355
|
+
nodes.append(Node(
|
|
1356
|
+
id=node_id,
|
|
1357
|
+
kind=NodeKind.VARIABLE,
|
|
1358
|
+
name=url_text or "<dynamic>",
|
|
1359
|
+
qualname=qn,
|
|
1360
|
+
file=rel,
|
|
1361
|
+
line_start=line,
|
|
1362
|
+
line_end=line,
|
|
1363
|
+
language="typescript",
|
|
1364
|
+
metadata={
|
|
1365
|
+
"synthetic_kind": "FETCH_TARGET",
|
|
1366
|
+
"method": method,
|
|
1367
|
+
"url": url_text,
|
|
1368
|
+
"url_kind": url_kind,
|
|
1369
|
+
},
|
|
1370
|
+
))
|
|
1371
|
+
edge_md: dict[str, Any] = {
|
|
1372
|
+
"method": method,
|
|
1373
|
+
"url": url_text,
|
|
1374
|
+
"library": library,
|
|
1375
|
+
"body_keys": body_keys,
|
|
1376
|
+
}
|
|
1377
|
+
if url_kind != "literal":
|
|
1378
|
+
edge_md["url_kind"] = url_kind
|
|
1379
|
+
edges.append(Edge(
|
|
1380
|
+
src=scope_id,
|
|
1381
|
+
dst=node_id,
|
|
1382
|
+
kind=EdgeKind.FETCH_CALL,
|
|
1383
|
+
file=rel,
|
|
1384
|
+
line=line,
|
|
1385
|
+
metadata=edge_md,
|
|
1386
|
+
))
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _walk(root: tree_sitter.Node) -> list[tree_sitter.Node]:
|
|
1390
|
+
"""Iterative descendant walk including the root."""
|
|
1391
|
+
out: list[tree_sitter.Node] = []
|
|
1392
|
+
stack: list[tree_sitter.Node] = [root]
|
|
1393
|
+
while stack:
|
|
1394
|
+
n = stack.pop()
|
|
1395
|
+
out.append(n)
|
|
1396
|
+
stack.extend(n.children)
|
|
1397
|
+
return out
|