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.
- graphlens_typescript/__init__.py +5 -0
- graphlens_typescript/_adapter.py +304 -0
- graphlens_typescript/_deps.py +117 -0
- graphlens_typescript/_module_resolver.py +114 -0
- graphlens_typescript/_project_detector.py +107 -0
- graphlens_typescript/_visitor.py +1041 -0
- graphlens_typescript-0.2.2.dist-info/METADATA +8 -0
- graphlens_typescript-0.2.2.dist-info/RECORD +10 -0
- graphlens_typescript-0.2.2.dist-info/WHEEL +4 -0
- graphlens_typescript-0.2.2.dist-info/entry_points.txt +3 -0
|
@@ -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
|