graphlens-typescript 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1041 @@
1
+ """TypeScript CST visitor — builds graphlens nodes/relations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING
9
+
10
+ import tree_sitter_typescript as ts_typescript
11
+ from graphlens import (
12
+ GraphLens,
13
+ Node,
14
+ NodeKind,
15
+ Relation,
16
+ RelationKind,
17
+ )
18
+ from graphlens.utils import Span, make_node_id
19
+ from tree_sitter import Language, Parser
20
+ from tree_sitter import Node as TSNode
21
+
22
+ from graphlens_typescript._module_resolver import resolve_relative_import
23
+
24
+ if TYPE_CHECKING:
25
+ from pathlib import Path
26
+
27
+ logger = logging.getLogger("graphlens_typescript")
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Module-level singletons — one parser per grammar (ts / tsx)
31
+ # ---------------------------------------------------------------------------
32
+
33
+ _TS_LANGUAGE = Language(ts_typescript.language_typescript())
34
+ _TSX_LANGUAGE = Language(ts_typescript.language_tsx())
35
+
36
+ _ts_parser = Parser(_TS_LANGUAGE)
37
+ _tsx_parser = Parser(_TSX_LANGUAGE)
38
+
39
+ # Node types that can carry a method/property name in a class body
40
+ _METHOD_NAME_TYPES: frozenset[str] = frozenset({
41
+ "identifier",
42
+ "property_identifier",
43
+ "private_property_identifier",
44
+ })
45
+
46
+
47
+ def parse_typescript(source: bytes, *, tsx: bool = False) -> object:
48
+ """Parse TypeScript source bytes and return a tree-sitter Tree."""
49
+ return _tsx_parser.parse(source) if tsx else _ts_parser.parse(source)
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Visitor context and import classifier
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ @dataclass
58
+ class ImportClassifier:
59
+ """
60
+ Classifies an import's origin based on pre-computed name sets.
61
+
62
+ Origin values (stored in ``Node.metadata["origin"]``):
63
+ - ``"stdlib"`` — Node.js standard library / built-ins
64
+ - ``"internal"`` — module declared within the same project
65
+ - ``"third_party"`` — package listed in the project's dependency files
66
+ - ``"unknown"`` — none of the above
67
+ """
68
+
69
+ stdlib: frozenset[str] = field(default_factory=frozenset)
70
+ third_party: frozenset[str] = field(default_factory=frozenset)
71
+ internal: frozenset[str] = field(default_factory=frozenset)
72
+
73
+ def classify(self, top_level: str) -> str:
74
+ if top_level in self.stdlib:
75
+ return "stdlib"
76
+ if top_level in self.internal:
77
+ return "internal"
78
+ if top_level in self.third_party:
79
+ return "third_party"
80
+ return "unknown"
81
+
82
+
83
+ @dataclass
84
+ class VisitorContext:
85
+ """Context for one file's CST visit."""
86
+
87
+ project_name: str
88
+ file_path: Path
89
+ file_relative_path: str
90
+ source_root: Path
91
+ module_qualified_name: str
92
+ modules: dict[str, str] = field(default_factory=dict)
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Main visitor
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ class TypescriptASTVisitor:
101
+ """
102
+ Walks a tree-sitter TypeScript CST and populates a GraphLens.
103
+
104
+ Node types handled:
105
+ program, export_statement,
106
+ class_declaration, abstract_class_declaration, interface_declaration,
107
+ function_declaration, generator_function_declaration,
108
+ method_definition,
109
+ import_statement
110
+ """
111
+
112
+ def __init__(
113
+ self,
114
+ ctx: VisitorContext,
115
+ graph: GraphLens,
116
+ file_node_id: str,
117
+ source: bytes,
118
+ classifier: ImportClassifier | None = None,
119
+ ) -> None:
120
+ self._ctx = ctx
121
+ self._graph = graph
122
+ self._file_node_id = file_node_id
123
+ self._source = source
124
+ self._classifier = classifier or ImportClassifier()
125
+ self._modules: dict[str, str] = ctx.modules
126
+ # Stack of qualified name prefixes (current scope)
127
+ self._scope_stack: list[str] = [ctx.module_qualified_name]
128
+ # Stack of node IDs for emitting CONTAINS/DECLARES relations
129
+ self._container_stack: list[str] = [file_node_id]
130
+ # Stack of NodeKind to know if we're inside a class
131
+ self._kind_stack: list[NodeKind] = [NodeKind.FILE]
132
+
133
+ # -------------------------------------------------------------------------
134
+ # Dispatch
135
+ # -------------------------------------------------------------------------
136
+
137
+ def visit(self, node: TSNode) -> None:
138
+ handler = getattr(self, f"_visit_{node.type}", None)
139
+ if handler:
140
+ handler(node)
141
+ else:
142
+ self._visit_children(node)
143
+
144
+ def _visit_children(self, node: TSNode) -> None:
145
+ for child in node.children:
146
+ self.visit(child)
147
+
148
+ # -------------------------------------------------------------------------
149
+ # Root node
150
+ # -------------------------------------------------------------------------
151
+
152
+ def _visit_program(self, node: TSNode) -> None:
153
+ self._visit_children(node)
154
+
155
+ def _visit_lexical_declaration(self, node: TSNode) -> None:
156
+ """Handle top-level ``const/let foo = () => ...`` declarations."""
157
+ self._handle_lexical_declaration(node)
158
+
159
+ # -------------------------------------------------------------------------
160
+ # Export statement — unwrap and delegate to inner declaration
161
+ # -------------------------------------------------------------------------
162
+
163
+ def _visit_export_statement(self, node: TSNode) -> None:
164
+ """
165
+ Handle ``export`` statements.
166
+
167
+ Cases:
168
+ - ``export class/function/interface/abstract class`` → delegate
169
+ - ``export default class/function`` → delegate
170
+ - ``export { X, Y } from 'module'`` → re-export import
171
+ - ``export { X, Y }`` (local re-export) → skip (no external dep)
172
+ """
173
+ children = node.children
174
+
175
+ # Check for re-export with source: export { X } from 'module'
176
+ # Note: tree-sitter-typescript exposes the string as a direct child
177
+ # of export_statement, not wrapped in a from_clause node.
178
+ export_clause = next(
179
+ (c for c in children if c.type == "export_clause"), None
180
+ )
181
+ from_string = next(
182
+ (c for c in children if c.type == "string"), None
183
+ )
184
+ if export_clause is not None and from_string is not None:
185
+ module_path = _strip_string_quotes(_node_text(from_string))
186
+ if module_path:
187
+ self._emit_reexport(module_path)
188
+ return
189
+
190
+ # Unwrap exported declaration
191
+ for child in children:
192
+ if child.type in (
193
+ "class_declaration",
194
+ "abstract_class_declaration",
195
+ "interface_declaration",
196
+ "function_declaration",
197
+ "generator_function_declaration",
198
+ ):
199
+ self.visit(child)
200
+ return
201
+ if child.type == "lexical_declaration":
202
+ # export const/let Foo = ...
203
+ self._handle_lexical_declaration(child)
204
+ return
205
+
206
+ def _emit_reexport(self, module_path: str) -> None:
207
+ """Emit an IMPORT node for ``export { X } from 'module'``."""
208
+ is_relative = module_path.startswith(("./", "../", "."))
209
+ safe = module_path.replace("/", "_").replace(".", "_")
210
+ local_name = f"__reexport_{safe}"
211
+ self._emit_import(
212
+ local_name=local_name,
213
+ ext_qname=_module_path_to_qname(
214
+ module_path,
215
+ is_relative=is_relative,
216
+ current_qname=self._scope_stack[-1],
217
+ ),
218
+ is_relative=is_relative,
219
+ is_star=True,
220
+ )
221
+
222
+ # -------------------------------------------------------------------------
223
+ # Class / abstract class
224
+ # -------------------------------------------------------------------------
225
+
226
+ def _visit_class_declaration(self, node: TSNode) -> None:
227
+ self._handle_class(node, decorators=[], is_abstract=False)
228
+
229
+ def _visit_abstract_class_declaration(self, node: TSNode) -> None:
230
+ self._handle_class(node, decorators=[], is_abstract=True)
231
+
232
+ def _handle_class(
233
+ self,
234
+ node: TSNode,
235
+ decorators: list[str],
236
+ *,
237
+ is_abstract: bool,
238
+ ) -> None:
239
+ name_node = next(
240
+ (c for c in node.children if c.type == "type_identifier"), None
241
+ ) or next(
242
+ (c for c in node.children if c.type == "identifier"), None
243
+ )
244
+ if name_node is None:
245
+ return
246
+ name = _node_text(name_node)
247
+ qname = f"{self._scope_stack[-1]}.{name}"
248
+
249
+ # Extract base class from class_heritage → extends_clause
250
+ bases: list[str] = []
251
+ heritage = next(
252
+ (c for c in node.children if c.type == "class_heritage"), None
253
+ )
254
+ if heritage is not None:
255
+ extends = next(
256
+ (
257
+ c for c in heritage.children
258
+ if c.type == "extends_clause"
259
+ ),
260
+ None,
261
+ )
262
+ if extends is not None:
263
+ bases = _extract_heritage_bases(extends)
264
+
265
+ class_node = self._make_node(
266
+ NodeKind.CLASS,
267
+ qname,
268
+ name,
269
+ node,
270
+ metadata={
271
+ "decorators": decorators,
272
+ "bases": bases,
273
+ "is_abstract": is_abstract,
274
+ },
275
+ )
276
+ self._add_node_with_relation(class_node, RelationKind.DECLARES)
277
+
278
+ for base_name in bases:
279
+ sym = self._get_or_create_external_symbol(base_name)
280
+ self._graph.add_relation(
281
+ Relation(
282
+ source_id=class_node.id,
283
+ target_id=sym.id,
284
+ kind=RelationKind.INHERITS_FROM,
285
+ )
286
+ )
287
+
288
+ self._push(qname, class_node.id, NodeKind.CLASS)
289
+ body = next(
290
+ (c for c in node.children if c.type == "class_body"), None
291
+ )
292
+ if body:
293
+ self._visit_children(body)
294
+ self._pop()
295
+
296
+ # -------------------------------------------------------------------------
297
+ # Interface (treated as CLASS with is_abstract=True)
298
+ # -------------------------------------------------------------------------
299
+
300
+ def _visit_interface_declaration(self, node: TSNode) -> None:
301
+ name_node = next(
302
+ (c for c in node.children if c.type == "type_identifier"), None
303
+ ) or next(
304
+ (c for c in node.children if c.type == "identifier"), None
305
+ )
306
+ if name_node is None:
307
+ return
308
+ name = _node_text(name_node)
309
+ qname = f"{self._scope_stack[-1]}.{name}"
310
+
311
+ # Interfaces may extend other interfaces
312
+ bases: list[str] = []
313
+ extends_clause = next(
314
+ (c for c in node.children if c.type == "extends_type_clause"), None
315
+ )
316
+ if extends_clause is not None:
317
+ bases = _extract_heritage_bases(extends_clause)
318
+
319
+ class_node = self._make_node(
320
+ NodeKind.CLASS,
321
+ qname,
322
+ name,
323
+ node,
324
+ metadata={
325
+ "decorators": [],
326
+ "bases": bases,
327
+ "is_abstract": True,
328
+ "is_interface": True,
329
+ },
330
+ )
331
+ self._add_node_with_relation(class_node, RelationKind.DECLARES)
332
+
333
+ for base_name in bases:
334
+ sym = self._get_or_create_external_symbol(base_name)
335
+ self._graph.add_relation(
336
+ Relation(
337
+ source_id=class_node.id,
338
+ target_id=sym.id,
339
+ kind=RelationKind.INHERITS_FROM,
340
+ )
341
+ )
342
+
343
+ self._push(qname, class_node.id, NodeKind.CLASS)
344
+ body = next(
345
+ (c for c in node.children if c.type == "interface_body"), None
346
+ )
347
+ if body:
348
+ self._visit_children(body)
349
+ self._pop()
350
+
351
+ # -------------------------------------------------------------------------
352
+ # Function / method
353
+ # -------------------------------------------------------------------------
354
+
355
+ def _visit_function_declaration(self, node: TSNode) -> None:
356
+ self._handle_function(node, decorators=[])
357
+
358
+ def _visit_generator_function_declaration(self, node: TSNode) -> None:
359
+ self._handle_function(node, decorators=[])
360
+
361
+ def _visit_method_definition(self, node: TSNode) -> None:
362
+ """Handle method definitions inside class bodies."""
363
+ self._handle_function(node, decorators=[])
364
+
365
+ def _handle_function(
366
+ self, node: TSNode, decorators: list[str]
367
+ ) -> None:
368
+ is_async = any(c.type == "async" for c in node.children)
369
+ parent_kind = self._kind_stack[-1]
370
+ kind = (
371
+ NodeKind.METHOD if parent_kind == NodeKind.CLASS
372
+ else NodeKind.FUNCTION
373
+ )
374
+
375
+ # For method_definition the name lives in a property_name slot
376
+ # (identifier, property_identifier, or private_property_identifier).
377
+ name_node = next(
378
+ (c for c in node.children if c.type in _METHOD_NAME_TYPES),
379
+ None,
380
+ )
381
+ if name_node is None:
382
+ return
383
+ name = _node_text(name_node)
384
+ qname = f"{self._scope_stack[-1]}.{name}"
385
+
386
+ # Extract return type annotation if present
387
+ return_annotation: str | None = None
388
+ type_ann = next(
389
+ (c for c in node.children if c.type == "type_annotation"), None
390
+ )
391
+ if type_ann is not None:
392
+ return_annotation = _node_text(type_ann).lstrip(":").strip()
393
+
394
+ func_node = self._make_node(
395
+ kind,
396
+ qname,
397
+ name,
398
+ node,
399
+ metadata={
400
+ "decorators": decorators,
401
+ "is_async": is_async,
402
+ "return_annotation": return_annotation,
403
+ },
404
+ )
405
+ self._add_node_with_relation(func_node, RelationKind.DECLARES)
406
+
407
+ self._push(qname, func_node.id, kind)
408
+
409
+ # Parameters
410
+ params_node = next(
411
+ (c for c in node.children if c.type == "formal_parameters"), None
412
+ )
413
+ if params_node:
414
+ self._extract_parameters(params_node, func_node.id, qname)
415
+
416
+ # Body: extract calls + visit nested definitions
417
+ body = next(
418
+ (c for c in node.children if c.type == "statement_block"), None
419
+ )
420
+ if body:
421
+ self._extract_calls(body, func_node.id)
422
+ for child in body.children:
423
+ if child.type in (
424
+ "function_declaration",
425
+ "generator_function_declaration",
426
+ "class_declaration",
427
+ "abstract_class_declaration",
428
+ "interface_declaration",
429
+ "export_statement",
430
+ "lexical_declaration",
431
+ ):
432
+ self.visit(child)
433
+
434
+ self._pop()
435
+
436
+ # -------------------------------------------------------------------------
437
+ # Arrow functions / const functions via lexical_declaration
438
+ # -------------------------------------------------------------------------
439
+
440
+ def _handle_lexical_declaration(self, node: TSNode) -> None:
441
+ """Handle ``const/let foo = () => ...`` declarations."""
442
+ for declarator in node.children:
443
+ if declarator.type != "variable_declarator":
444
+ continue
445
+ name_node = next(
446
+ (c for c in declarator.children if c.type == "identifier"),
447
+ None,
448
+ )
449
+ _fn_types = ("arrow_function", "function", "function_expression")
450
+ value_node = next(
451
+ (c for c in declarator.children if c.type in _fn_types),
452
+ None,
453
+ )
454
+ if name_node is None or value_node is None:
455
+ continue
456
+ name = _node_text(name_node)
457
+ qname = f"{self._scope_stack[-1]}.{name}"
458
+
459
+ is_async = any(c.type == "async" for c in value_node.children)
460
+ parent_kind = self._kind_stack[-1]
461
+ kind = (
462
+ NodeKind.METHOD if parent_kind == NodeKind.CLASS
463
+ else NodeKind.FUNCTION
464
+ )
465
+
466
+ return_annotation: str | None = None
467
+ type_ann = next(
468
+ (
469
+ c for c in declarator.children
470
+ if c.type == "type_annotation"
471
+ ),
472
+ None,
473
+ )
474
+ if type_ann is not None:
475
+ return_annotation = _node_text(type_ann).lstrip(":").strip()
476
+
477
+ func_node = self._make_node(
478
+ kind,
479
+ qname,
480
+ name,
481
+ value_node,
482
+ metadata={
483
+ "decorators": [],
484
+ "is_async": is_async,
485
+ "return_annotation": return_annotation,
486
+ },
487
+ )
488
+ self._add_node_with_relation(func_node, RelationKind.DECLARES)
489
+
490
+ self._push(qname, func_node.id, kind)
491
+
492
+ params_node = next(
493
+ (
494
+ c for c in value_node.children
495
+ if c.type == "formal_parameters"
496
+ ),
497
+ None,
498
+ )
499
+ if params_node:
500
+ self._extract_parameters(params_node, func_node.id, qname)
501
+
502
+ body = next(
503
+ (
504
+ c for c in value_node.children
505
+ if c.type == "statement_block"
506
+ ),
507
+ None,
508
+ )
509
+ if body:
510
+ self._extract_calls(body, func_node.id)
511
+
512
+ self._pop()
513
+
514
+ # -------------------------------------------------------------------------
515
+ # Import statement
516
+ # -------------------------------------------------------------------------
517
+
518
+ def _visit_import_statement(self, node: TSNode) -> None:
519
+ """
520
+ Handle TypeScript import statements.
521
+
522
+ Covers:
523
+ - ``import X from 'module'`` (default import)
524
+ - ``import { A, B as C } from 'mod'`` (named imports)
525
+ - ``import * as NS from 'mod'`` (namespace import)
526
+ - ``import type { T } from 'mod'`` (type-only, same treatment)
527
+ - ``import 'mod'`` (side-effect import)
528
+ """
529
+ # Find the module path string
530
+ from_clause = next(
531
+ (c for c in node.children if c.type == "from_clause"), None
532
+ )
533
+ if from_clause is not None:
534
+ module_path = _string_from_from_clause(from_clause)
535
+ else:
536
+ # Side-effect import: import 'module'
537
+ str_node = next(
538
+ (c for c in node.children if c.type == "string"), None
539
+ )
540
+ module_path = (
541
+ _strip_string_quotes(_node_text(str_node)) if str_node else ""
542
+ )
543
+
544
+ if not module_path:
545
+ return
546
+
547
+ is_relative = module_path.startswith(("./", "../", "."))
548
+ # Strip node: scheme prefix for stdlib classification
549
+ classify_path = (
550
+ module_path[5:] if module_path.startswith("node:") else module_path
551
+ )
552
+ ext_qname = _module_path_to_qname(
553
+ classify_path,
554
+ is_relative=is_relative,
555
+ current_qname=self._scope_stack[-1],
556
+ )
557
+
558
+ # Find the import clause (may be absent for side-effect imports)
559
+ import_clause = next(
560
+ (c for c in node.children if c.type == "import_clause"), None
561
+ )
562
+
563
+ if import_clause is None:
564
+ # Side-effect import — emit a single synthetic import
565
+ self._emit_import(
566
+ local_name=f"__sideeffect_{_path_to_safe_name(module_path)}",
567
+ ext_qname=ext_qname,
568
+ is_relative=is_relative,
569
+ is_star=True,
570
+ )
571
+ return
572
+
573
+ # Process each element of the import clause
574
+ for child in import_clause.children:
575
+ if child.type == "identifier":
576
+ # Default import: import X from 'mod'
577
+ local_name = _node_text(child)
578
+ self._emit_import(
579
+ local_name=local_name,
580
+ ext_qname=f"{ext_qname}.default",
581
+ is_relative=is_relative,
582
+ alias=local_name,
583
+ )
584
+
585
+ elif child.type == "named_imports":
586
+ self._process_named_imports(
587
+ child, ext_qname=ext_qname, is_relative=is_relative
588
+ )
589
+
590
+ elif child.type == "namespace_import":
591
+ # Namespace import: import * as NS from 'mod'
592
+ id_node = next(
593
+ (c for c in child.children if c.type == "identifier"),
594
+ None,
595
+ )
596
+ if id_node is not None:
597
+ local_name = _node_text(id_node)
598
+ self._emit_import(
599
+ local_name=local_name,
600
+ ext_qname=ext_qname,
601
+ is_relative=is_relative,
602
+ alias=local_name,
603
+ is_star=True,
604
+ )
605
+
606
+ def _process_named_imports(
607
+ self,
608
+ named_imports_node: TSNode,
609
+ *,
610
+ ext_qname: str,
611
+ is_relative: bool,
612
+ ) -> None:
613
+ """Emit IMPORT nodes for ``{ A, B as C }`` named-import clauses."""
614
+ for spec in named_imports_node.children:
615
+ if spec.type != "import_specifier":
616
+ continue
617
+ identifiers = [
618
+ c for c in spec.children if c.type == "identifier"
619
+ ]
620
+ if not identifiers:
621
+ continue
622
+ if len(identifiers) == 1:
623
+ iname = _node_text(identifiers[0])
624
+ self._emit_import(
625
+ local_name=iname,
626
+ ext_qname=f"{ext_qname}.{iname}",
627
+ is_relative=is_relative,
628
+ )
629
+ else:
630
+ # "original as alias"
631
+ orig = _node_text(identifiers[0])
632
+ alias = _node_text(identifiers[1])
633
+ self._emit_import(
634
+ local_name=alias,
635
+ ext_qname=f"{ext_qname}.{orig}",
636
+ is_relative=is_relative,
637
+ alias=alias,
638
+ )
639
+
640
+ def _emit_import(
641
+ self,
642
+ *,
643
+ local_name: str,
644
+ ext_qname: str,
645
+ is_relative: bool,
646
+ alias: str | None = None,
647
+ is_star: bool = False,
648
+ ) -> None:
649
+ top_level = ext_qname.split(".", maxsplit=1)[0]
650
+ origin = (
651
+ "internal" if is_relative
652
+ else self._classifier.classify(top_level)
653
+ )
654
+
655
+ import_qname = f"{self._scope_stack[-1]}.{local_name}"
656
+ import_node = self._make_node(
657
+ NodeKind.IMPORT,
658
+ import_qname,
659
+ local_name,
660
+ metadata={
661
+ "alias": alias,
662
+ "is_relative": is_relative,
663
+ "original_name": ext_qname,
664
+ "is_star": is_star,
665
+ "origin": origin,
666
+ },
667
+ )
668
+ self._add_node_with_relation(import_node, RelationKind.DECLARES)
669
+
670
+ resolve_target_id: str | None = None
671
+ if origin == "internal":
672
+ parts = ext_qname.split(".")
673
+ for length in range(len(parts), 0, -1):
674
+ candidate = ".".join(parts[:length])
675
+ if candidate in self._modules:
676
+ resolve_target_id = self._modules[candidate]
677
+ break
678
+
679
+ if resolve_target_id is None:
680
+ ext_sym = self._get_or_create_external_symbol(
681
+ ext_qname, origin=origin
682
+ )
683
+ resolve_target_id = ext_sym.id
684
+
685
+ self._graph.add_relation(
686
+ Relation(
687
+ source_id=self._file_node_id,
688
+ target_id=resolve_target_id,
689
+ kind=RelationKind.IMPORTS,
690
+ )
691
+ )
692
+ self._graph.add_relation(
693
+ Relation(
694
+ source_id=import_node.id,
695
+ target_id=resolve_target_id,
696
+ kind=RelationKind.RESOLVES_TO,
697
+ )
698
+ )
699
+
700
+ # -------------------------------------------------------------------------
701
+ # Parameter extraction
702
+ # -------------------------------------------------------------------------
703
+
704
+ def _extract_parameters(
705
+ self, params_node: TSNode, function_id: str, function_qname: str
706
+ ) -> None:
707
+ for child in params_node.children:
708
+ param_name: str | None = None
709
+ annotation: str | None = None
710
+ has_default = False
711
+ is_variadic = False
712
+
713
+ if child.type == "identifier":
714
+ param_name = _node_text(child)
715
+
716
+ elif child.type == "required_parameter":
717
+ # Check if this is actually a rest param (...args: T)
718
+ rest_pat = next(
719
+ (
720
+ c for c in child.children
721
+ if c.type == "rest_pattern"
722
+ ),
723
+ None,
724
+ )
725
+ if rest_pat is not None:
726
+ id_node = next(
727
+ (
728
+ c for c in rest_pat.children
729
+ if c.type == "identifier"
730
+ ),
731
+ None,
732
+ )
733
+ param_name = _node_text(id_node) if id_node else None
734
+ is_variadic = True
735
+ else:
736
+ id_node = next(
737
+ (
738
+ c for c in child.children
739
+ if c.type in ("identifier", "this")
740
+ ),
741
+ None,
742
+ )
743
+ param_name = _node_text(id_node) if id_node else None
744
+ type_node = next(
745
+ (
746
+ c for c in child.children
747
+ if c.type == "type_annotation"
748
+ ),
749
+ None,
750
+ )
751
+ annotation = (
752
+ _node_text(type_node).lstrip(":").strip()
753
+ if type_node else None
754
+ )
755
+ # required_parameter can have an = initializer (default value)
756
+ if any(c.type == "=" for c in child.children):
757
+ has_default = True
758
+
759
+ elif child.type == "optional_parameter":
760
+ id_node = next(
761
+ (c for c in child.children if c.type == "identifier"),
762
+ None,
763
+ )
764
+ param_name = _node_text(id_node) if id_node else None
765
+ type_node = next(
766
+ (
767
+ c for c in child.children
768
+ if c.type == "type_annotation"
769
+ ),
770
+ None,
771
+ )
772
+ annotation = (
773
+ _node_text(type_node).lstrip(":").strip()
774
+ if type_node else None
775
+ )
776
+ has_default = True
777
+
778
+ elif child.type == "rest_parameter":
779
+ id_node = next(
780
+ (c for c in child.children if c.type == "identifier"), None
781
+ )
782
+ param_name = _node_text(id_node) if id_node else None
783
+ is_variadic = True
784
+
785
+ elif child.type == "assignment_pattern":
786
+ # Default value parameter: x = defaultVal
787
+ id_node = next(
788
+ (c for c in child.children if c.type == "identifier"), None
789
+ )
790
+ param_name = _node_text(id_node) if id_node else None
791
+ has_default = True
792
+
793
+ if not param_name or param_name == "this":
794
+ continue
795
+
796
+ param_qname = f"{function_qname}.{param_name}"
797
+ param_node = self._make_node(
798
+ NodeKind.PARAMETER,
799
+ param_qname,
800
+ param_name,
801
+ child,
802
+ metadata={
803
+ "annotation": annotation,
804
+ "has_default": has_default,
805
+ "is_variadic": is_variadic,
806
+ },
807
+ )
808
+ self._safe_add_node(param_node)
809
+ self._graph.add_relation(
810
+ Relation(
811
+ source_id=function_id,
812
+ target_id=param_node.id,
813
+ kind=RelationKind.DECLARES,
814
+ )
815
+ )
816
+
817
+ # -------------------------------------------------------------------------
818
+ # Call extraction
819
+ # -------------------------------------------------------------------------
820
+
821
+ def _extract_calls(self, body: TSNode, caller_id: str) -> None:
822
+ """Find all call expression nodes in body and emit CALLS relations."""
823
+ for child in body.children:
824
+ self._find_calls_in_node(child, caller_id)
825
+
826
+ def _find_calls_in_node(self, node: TSNode, caller_id: str) -> None:
827
+ if node.type == "call_expression":
828
+ func_node = next(
829
+ (
830
+ c for c in node.children
831
+ if c.type in ("identifier", "member_expression")
832
+ ),
833
+ None,
834
+ )
835
+ if func_node:
836
+ callee_name = _name_from_node(func_node)
837
+ if callee_name:
838
+ sym_id = make_node_id(
839
+ self._ctx.project_name,
840
+ callee_name,
841
+ NodeKind.SYMBOL.value,
842
+ )
843
+ if sym_id not in self._graph.nodes:
844
+ self._graph.add_node(
845
+ Node(
846
+ id=sym_id,
847
+ kind=NodeKind.SYMBOL,
848
+ qualified_name=callee_name,
849
+ name=callee_name.split(".")[-1],
850
+ span=_make_span(node),
851
+ )
852
+ )
853
+ self._graph.add_relation(
854
+ Relation(
855
+ source_id=caller_id,
856
+ target_id=sym_id,
857
+ kind=RelationKind.CALLS,
858
+ )
859
+ )
860
+ # Don't recurse into nested definitions
861
+ if node.type not in (
862
+ "function_declaration",
863
+ "generator_function_declaration",
864
+ "class_declaration",
865
+ "abstract_class_declaration",
866
+ "method_definition",
867
+ "arrow_function",
868
+ "function",
869
+ "function_expression",
870
+ ):
871
+ for child in node.children:
872
+ self._find_calls_in_node(child, caller_id)
873
+
874
+ # -------------------------------------------------------------------------
875
+ # Node helpers (language-agnostic)
876
+ # -------------------------------------------------------------------------
877
+
878
+ def _get_or_create_external_symbol(
879
+ self, qname: str, origin: str = "unknown"
880
+ ) -> Node:
881
+ sym_id = make_node_id(
882
+ self._ctx.project_name, qname, NodeKind.EXTERNAL_SYMBOL.value
883
+ )
884
+ if sym_id not in self._graph.nodes:
885
+ self._graph.add_node(
886
+ Node(
887
+ id=sym_id,
888
+ kind=NodeKind.EXTERNAL_SYMBOL,
889
+ qualified_name=qname,
890
+ name=qname.rsplit(".", maxsplit=1)[-1],
891
+ metadata={"origin": origin},
892
+ )
893
+ )
894
+ return self._graph.nodes[sym_id]
895
+
896
+ def _add_node_with_relation(
897
+ self, node: Node, rel_kind: RelationKind
898
+ ) -> None:
899
+ self._safe_add_node(node)
900
+ self._graph.add_relation(
901
+ Relation(
902
+ source_id=self._container_stack[-1],
903
+ target_id=node.id,
904
+ kind=rel_kind,
905
+ )
906
+ )
907
+
908
+ def _safe_add_node(self, node: Node) -> None:
909
+ if node.id not in self._graph.nodes:
910
+ self._graph.add_node(node)
911
+
912
+ def _make_node(
913
+ self,
914
+ kind: NodeKind,
915
+ qualified_name: str,
916
+ name: str,
917
+ ts_node: TSNode | None = None,
918
+ metadata: dict[str, object] | None = None,
919
+ ) -> Node:
920
+ return Node(
921
+ id=make_node_id(
922
+ self._ctx.project_name, qualified_name, kind.value
923
+ ),
924
+ kind=kind,
925
+ qualified_name=qualified_name,
926
+ name=name,
927
+ file_path=self._ctx.file_relative_path,
928
+ span=_make_span(ts_node) if ts_node else None,
929
+ metadata=metadata or {},
930
+ )
931
+
932
+ def _push(self, qname: str, node_id: str, kind: NodeKind) -> None:
933
+ self._scope_stack.append(qname)
934
+ self._container_stack.append(node_id)
935
+ self._kind_stack.append(kind)
936
+
937
+ def _pop(self) -> None:
938
+ self._scope_stack.pop()
939
+ self._container_stack.pop()
940
+ self._kind_stack.pop()
941
+
942
+
943
+ # ---------------------------------------------------------------------------
944
+ # Module-level helpers
945
+ # ---------------------------------------------------------------------------
946
+
947
+
948
+ def _node_text(node: TSNode) -> str:
949
+ return node.text.decode("utf-8")
950
+
951
+
952
+ def _strip_string_quotes(s: str) -> str:
953
+ """Strip surrounding single or double quotes from a string literal."""
954
+ if s and len(s) > 1 and s[0] in ("'", '"', "`") and s[-1] == s[0]:
955
+ return s[1:-1]
956
+ return s
957
+
958
+
959
+ def _string_from_from_clause(from_clause: TSNode) -> str:
960
+ """Extract the unquoted module path from a ``from_clause`` node."""
961
+ str_node = next(
962
+ (c for c in from_clause.children if c.type == "string"), None
963
+ )
964
+ if str_node is None:
965
+ return ""
966
+ return _strip_string_quotes(_node_text(str_node))
967
+
968
+
969
+ def _module_path_to_qname(
970
+ module_path: str,
971
+ *,
972
+ is_relative: bool,
973
+ current_qname: str,
974
+ ) -> str:
975
+ """Convert a module path to a dotted qualified name for graph storage."""
976
+ if is_relative:
977
+ return resolve_relative_import(current_qname, module_path)
978
+ # Absolute import: use the path as-is (slashes → dots for sub-paths)
979
+ # Top-level package name is the first segment
980
+ return module_path.replace("/", ".")
981
+
982
+
983
+ def _path_to_safe_name(path: str) -> str:
984
+ """Convert a module path to a safe Python identifier."""
985
+ return re.sub(r"[^a-zA-Z0-9]", "_", path).strip("_")
986
+
987
+
988
+ def _name_from_node(node: TSNode) -> str:
989
+ """Extract a dotted name from identifier or member_expression nodes."""
990
+ if node.type == "identifier":
991
+ return _node_text(node)
992
+ if node.type == "member_expression":
993
+ # member_expression: object "." property
994
+ obj_node = node.children[0]
995
+ prop_node = node.children[-1]
996
+ parent = _name_from_node(obj_node)
997
+ prop = _node_text(prop_node)
998
+ return f"{parent}.{prop}" if parent else prop
999
+ return ""
1000
+
1001
+
1002
+ def _extract_heritage_bases(heritage_node: TSNode) -> list[str]:
1003
+ """Extract base class / interface names from a heritage node."""
1004
+ bases: list[str] = []
1005
+ for child in heritage_node.children:
1006
+ if child.type in ("identifier", "type_identifier"):
1007
+ bases.append(_node_text(child))
1008
+ elif child.type == "member_expression":
1009
+ name = _name_from_node(child)
1010
+ if name:
1011
+ bases.append(name)
1012
+ elif child.type == "generic_type":
1013
+ # e.g. Base<T> — extract just the base name
1014
+ name_node = next(
1015
+ (
1016
+ c for c in child.children
1017
+ if c.type in ("type_identifier", "identifier")
1018
+ ),
1019
+ None,
1020
+ )
1021
+ if name_node:
1022
+ bases.append(_node_text(name_node))
1023
+ return bases
1024
+
1025
+
1026
+
1027
+ def _make_span(node: TSNode | None) -> Span | None:
1028
+ """Convert tree-sitter node positions to a Span (1-based)."""
1029
+ if node is None:
1030
+ return None
1031
+ try:
1032
+ sr, sc = node.start_point
1033
+ er, ec = node.end_point
1034
+ return Span(
1035
+ start_line=sr + 1,
1036
+ start_col=sc + 1,
1037
+ end_line=er + 1,
1038
+ end_col=ec + 1,
1039
+ )
1040
+ except Exception:
1041
+ return None