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.
Files changed (67) hide show
  1. codegraph/__init__.py +10 -0
  2. codegraph/analysis/__init__.py +30 -0
  3. codegraph/analysis/_common.py +125 -0
  4. codegraph/analysis/blast_radius.py +63 -0
  5. codegraph/analysis/cycles.py +79 -0
  6. codegraph/analysis/dataflow.py +861 -0
  7. codegraph/analysis/dead_code.py +165 -0
  8. codegraph/analysis/hotspots.py +68 -0
  9. codegraph/analysis/infrastructure.py +439 -0
  10. codegraph/analysis/metrics.py +52 -0
  11. codegraph/analysis/report.py +222 -0
  12. codegraph/analysis/roles.py +323 -0
  13. codegraph/analysis/untested.py +79 -0
  14. codegraph/cli.py +1506 -0
  15. codegraph/config.py +64 -0
  16. codegraph/embed/__init__.py +35 -0
  17. codegraph/embed/chunker.py +120 -0
  18. codegraph/embed/embedder.py +113 -0
  19. codegraph/embed/query.py +181 -0
  20. codegraph/embed/store.py +360 -0
  21. codegraph/graph/__init__.py +0 -0
  22. codegraph/graph/builder.py +212 -0
  23. codegraph/graph/schema.py +69 -0
  24. codegraph/graph/store_networkx.py +55 -0
  25. codegraph/graph/store_sqlite.py +249 -0
  26. codegraph/mcp_server/__init__.py +6 -0
  27. codegraph/mcp_server/server.py +933 -0
  28. codegraph/parsers/__init__.py +0 -0
  29. codegraph/parsers/base.py +70 -0
  30. codegraph/parsers/go.py +570 -0
  31. codegraph/parsers/python.py +1707 -0
  32. codegraph/parsers/typescript.py +1397 -0
  33. codegraph/py.typed +0 -0
  34. codegraph/resolve/__init__.py +4 -0
  35. codegraph/resolve/calls.py +480 -0
  36. codegraph/review/__init__.py +31 -0
  37. codegraph/review/baseline.py +32 -0
  38. codegraph/review/differ.py +211 -0
  39. codegraph/review/hook.py +70 -0
  40. codegraph/review/risk.py +219 -0
  41. codegraph/review/rules.py +342 -0
  42. codegraph/viz/__init__.py +17 -0
  43. codegraph/viz/_style.py +45 -0
  44. codegraph/viz/dashboard.py +740 -0
  45. codegraph/viz/diagrams.py +370 -0
  46. codegraph/viz/explore.py +453 -0
  47. codegraph/viz/hld.py +683 -0
  48. codegraph/viz/html.py +115 -0
  49. codegraph/viz/mermaid.py +111 -0
  50. codegraph/viz/svg.py +77 -0
  51. codegraph/web/__init__.py +4 -0
  52. codegraph/web/server.py +165 -0
  53. codegraph/web/static/app.css +664 -0
  54. codegraph/web/static/app.js +919 -0
  55. codegraph/web/static/index.html +112 -0
  56. codegraph/web/static/views/architecture.js +1671 -0
  57. codegraph/web/static/views/graph3d.css +564 -0
  58. codegraph/web/static/views/graph3d.js +999 -0
  59. codegraph/web/static/views/graph3d_transform.js +984 -0
  60. codegraph/workspace/__init__.py +34 -0
  61. codegraph/workspace/config.py +110 -0
  62. codegraph/workspace/operations.py +294 -0
  63. polycodegraph-0.1.0.dist-info/METADATA +687 -0
  64. polycodegraph-0.1.0.dist-info/RECORD +67 -0
  65. polycodegraph-0.1.0.dist-info/WHEEL +4 -0
  66. polycodegraph-0.1.0.dist-info/entry_points.txt +2 -0
  67. 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