tscode-kg 0.2.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.
tscode_kg/extractor.py ADDED
@@ -0,0 +1,827 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ extractor.py — TypeScript/JavaScript AST extractor for TypeScriptKG.
4
+
5
+ Uses tree-sitter to parse .ts, .tsx, .js, and .jsx files and emit NodeSpec /
6
+ EdgeSpec objects for the KGModule build pipeline.
7
+
8
+ Node kinds:
9
+ module — every indexed source file
10
+ class — class declaration
11
+ interface — TypeScript interface
12
+ type_alias — TypeScript type alias
13
+ enum — TypeScript enum
14
+ namespace — TypeScript namespace / module declaration
15
+ function — module-level function (declaration or const arrow)
16
+ method — method / accessor within a class
17
+ symbol — unresolved import stub
18
+
19
+ Edge relations:
20
+ CONTAINS — module→class/function/interface…, class→method
21
+ IMPORTS — module→module (resolved from import paths)
22
+ CALLS — function/method→function (best-effort via call expressions)
23
+ INHERITS — class extends class
24
+ IMPLEMENTS — class implements interface
25
+ EXTENDS — interface extends interface
26
+
27
+ Author: Eric G. Suchanek, PhD
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import re
33
+ from collections.abc import Iterator
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from kg_utils.extractor import KGExtractor
38
+ from kg_utils.specs import EdgeSpec, NodeSpec
39
+
40
+ try:
41
+ import tree_sitter_typescript as _tst
42
+ from tree_sitter import Language, Parser
43
+
44
+ _TS_LANGUAGE = Language(_tst.language_typescript())
45
+ _TSX_LANGUAGE = Language(_tst.language_tsx())
46
+ _HAS_TREE_SITTER = True
47
+ except Exception: # noqa: BLE001
48
+ _HAS_TREE_SITTER = False
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Constants
52
+ # ---------------------------------------------------------------------------
53
+
54
+ SKIP_DIRS: frozenset[str] = frozenset(
55
+ {
56
+ "node_modules",
57
+ ".git",
58
+ "__pycache__",
59
+ ".venv",
60
+ "dist",
61
+ "build",
62
+ ".next",
63
+ ".nuxt",
64
+ "coverage",
65
+ ".nyc_output",
66
+ ".turbo",
67
+ ".cache",
68
+ "out",
69
+ ".output",
70
+ ".pycodekg",
71
+ ".dockg",
72
+ ".agentkg",
73
+ ".tscodekg",
74
+ ".tscode_kg",
75
+ "vendor",
76
+ ".yarn",
77
+ ".pnp",
78
+ "storybook-static",
79
+ }
80
+ )
81
+
82
+ TS_EXTENSIONS: frozenset[str] = frozenset(
83
+ {".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"}
84
+ )
85
+
86
+ _KIND_PREFIX: dict[str, str] = {
87
+ "module": "mod",
88
+ "class": "cls",
89
+ "interface": "iface",
90
+ "type_alias": "type",
91
+ "enum": "enum",
92
+ "namespace": "ns",
93
+ "function": "fn",
94
+ "method": "meth",
95
+ "symbol": "sym",
96
+ }
97
+
98
+ # tree-sitter node types that declare a named scope
99
+ _CLASS_LIKE = frozenset({"class_declaration", "class"})
100
+ _INTERFACE_LIKE = frozenset({"interface_declaration"})
101
+ _TYPE_ALIAS = frozenset({"type_alias_declaration"})
102
+ _ENUM = frozenset({"enum_declaration"})
103
+ _NAMESPACE = frozenset({"module_declaration", "internal_module", "namespace_declaration"})
104
+ _METHOD_LIKE = frozenset({"method_definition", "method_signature", "public_field_definition"})
105
+ _FUNCTION_LIKE = frozenset({"function_declaration", "generator_function_declaration"})
106
+ _CALL_EXPR = frozenset({"call_expression", "new_expression"})
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Helpers
111
+ # ---------------------------------------------------------------------------
112
+
113
+
114
+ def _make_node_id(kind: str, rel_path: str, qualname: str = "") -> str:
115
+ prefix = _KIND_PREFIX.get(kind, kind[:3])
116
+ if qualname:
117
+ return f"{prefix}:{rel_path}:{qualname}"
118
+ return f"{prefix}:{rel_path}"
119
+
120
+
121
+ def _node_text(node: Any, source: bytes) -> str:
122
+ return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
123
+
124
+
125
+ def _child_text(node: Any, field: str, source: bytes) -> str:
126
+ child = node.child_by_field_name(field)
127
+ if child is None:
128
+ return ""
129
+ return _node_text(child, source)
130
+
131
+
132
+ def _extract_jsdoc(node: Any, source: bytes) -> str:
133
+ """Look for the immediately-preceding /** ... */ comment.
134
+
135
+ Handles two cases:
136
+ 1. ``node`` is a direct child of program/class_body — look at its siblings.
137
+ 2. ``node`` is wrapped in ``export_statement`` — look at the export's siblings.
138
+ """
139
+ # Resolve the "anchor" node whose preceding sibling should be the comment.
140
+ # If the direct parent is export_statement, the comment sits before the export.
141
+ anchor = node
142
+ parent = node.parent
143
+ if parent is not None and parent.type == "export_statement":
144
+ anchor = parent
145
+ parent = anchor.parent
146
+
147
+ if parent is None:
148
+ return ""
149
+
150
+ children = list(parent.children)
151
+ idx = next((i for i, c in enumerate(children) if c.id == anchor.id), -1)
152
+ if idx <= 0:
153
+ return ""
154
+ prev = children[idx - 1]
155
+ if prev.type != "comment":
156
+ return ""
157
+ text = _node_text(prev, source).strip()
158
+ if not text.startswith("/**"):
159
+ return ""
160
+ inner = text[3:]
161
+ if inner.endswith("*/"):
162
+ inner = inner[:-2]
163
+ lines = [re.sub(r"^\s*\*\s?", "", ln) for ln in inner.splitlines()]
164
+ return " ".join(ln for ln in lines if ln.strip())
165
+
166
+
167
+ def _lineno(node: Any) -> int:
168
+ """1-based start line."""
169
+ return node.start_point[0] + 1
170
+
171
+
172
+ def _end_lineno(node: Any) -> int:
173
+ """1-based end line."""
174
+ return node.end_point[0] + 1
175
+
176
+
177
+ def _resolve_import_path(importing_file: str, raw_spec: str) -> str:
178
+ """
179
+ Best-effort resolution of a relative import specifier to a repo-relative path.
180
+
181
+ Absolute/package imports become a ``sym:`` ID; relative imports are joined to
182
+ the importing file's directory and normalised.
183
+ """
184
+ if not raw_spec.startswith("."):
185
+ # Bare package import → symbol stub
186
+ module_name = raw_spec.split("/")[0].lstrip("@")
187
+ return f"sym:{module_name}"
188
+
189
+ base = Path(importing_file).parent / raw_spec
190
+ # Normalise without hitting the filesystem
191
+ try:
192
+ resolved = str(base).replace("\\", "/")
193
+ # Strip leading ./
194
+ resolved = re.sub(r"^\./", "", resolved)
195
+ except Exception: # noqa: BLE001
196
+ resolved = raw_spec
197
+
198
+ # If there's no extension, try .ts first (most common)
199
+ if Path(resolved).suffix not in TS_EXTENSIONS:
200
+ resolved = resolved + ".ts"
201
+ return resolved
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Per-file walker
206
+ # ---------------------------------------------------------------------------
207
+
208
+
209
+ class _FileWalker:
210
+ """Walk a single parsed tree and emit NodeSpec / EdgeSpec objects."""
211
+
212
+ def __init__(self, rel_path: str, source: bytes, tree: Any) -> None:
213
+ self.rel_path = rel_path
214
+ self.source = source
215
+ self.root = tree.root_node
216
+ self._mod_id = _make_node_id("module", rel_path)
217
+ self._emitted: list[NodeSpec | EdgeSpec] = []
218
+
219
+ # ------------------------------------------------------------------
220
+ # Public interface
221
+ # ------------------------------------------------------------------
222
+
223
+ def walk(self) -> list[NodeSpec | EdgeSpec]:
224
+ self._emitted = []
225
+ self._emit_module()
226
+ self._walk_node(self.root, class_ctx=None)
227
+ return self._emitted
228
+
229
+ # ------------------------------------------------------------------
230
+ # Module node
231
+ # ------------------------------------------------------------------
232
+
233
+ def _emit_module(self) -> None:
234
+ doc = self._module_jsdoc()
235
+ self._emitted.append(
236
+ NodeSpec(
237
+ node_id=self._mod_id,
238
+ kind="module",
239
+ name=Path(self.rel_path).name,
240
+ qualname=self.rel_path,
241
+ source_path=self.rel_path,
242
+ lineno=1,
243
+ end_lineno=self.root.end_point[0] + 1,
244
+ docstring=doc,
245
+ )
246
+ )
247
+
248
+ def _module_jsdoc(self) -> str:
249
+ """Return the first top-level /** comment if any."""
250
+ for child in self.root.children:
251
+ if child.type == "comment":
252
+ text = _node_text(child, self.source).strip()
253
+ if text.startswith("/**"):
254
+ inner = text[3:]
255
+ if inner.endswith("*/"):
256
+ inner = inner[:-2]
257
+ lines = [re.sub(r"^\s*\*\s?", "", ln) for ln in inner.splitlines()]
258
+ return " ".join(ln for ln in lines if ln.strip())
259
+ break
260
+ if child.type not in ("comment", "hash_bang_line"):
261
+ break
262
+ return ""
263
+
264
+ # ------------------------------------------------------------------
265
+ # Recursive walker
266
+ # ------------------------------------------------------------------
267
+
268
+ def _walk_node(self, node: Any, class_ctx: str | None) -> None:
269
+ t = node.type
270
+
271
+ if t == "import_statement":
272
+ self._handle_import(node)
273
+ return
274
+
275
+ if t in _CLASS_LIKE:
276
+ self._handle_class(node)
277
+ return
278
+
279
+ if t in _INTERFACE_LIKE:
280
+ self._handle_interface(node)
281
+ return
282
+
283
+ if t in _TYPE_ALIAS:
284
+ self._handle_type_alias(node)
285
+ return
286
+
287
+ if t in _ENUM:
288
+ self._handle_enum(node)
289
+ return
290
+
291
+ if t in _NAMESPACE:
292
+ self._handle_namespace(node)
293
+ return
294
+
295
+ if t in _FUNCTION_LIKE and class_ctx is None:
296
+ self._handle_function(node)
297
+ return
298
+
299
+ if t == "export_statement":
300
+ self._handle_export(node, class_ctx)
301
+ return
302
+
303
+ if t == "lexical_declaration" and class_ctx is None:
304
+ self._handle_lexical_declaration(node)
305
+ return
306
+
307
+ # Recurse into anything else (but not into class bodies — handled above)
308
+ for child in node.children:
309
+ self._walk_node(child, class_ctx)
310
+
311
+ # ------------------------------------------------------------------
312
+ # Import handling
313
+ # ------------------------------------------------------------------
314
+
315
+ def _handle_import(self, node: Any) -> None:
316
+ """Emit IMPORTS edge from this module to the imported module."""
317
+ source_node = node.child_by_field_name("source")
318
+ if source_node is None:
319
+ return
320
+ raw = _node_text(source_node, self.source).strip("'\"` \t")
321
+ if not raw:
322
+ return
323
+ target = _resolve_import_path(self.rel_path, raw)
324
+ if target.startswith("sym:"):
325
+ target_id = target
326
+ else:
327
+ target_id = _make_node_id("module", target)
328
+ self._emitted.append(
329
+ EdgeSpec(source_id=self._mod_id, target_id=target_id, relation="IMPORTS")
330
+ )
331
+
332
+ # ------------------------------------------------------------------
333
+ # Class handling
334
+ # ------------------------------------------------------------------
335
+
336
+ def _handle_class(self, node: Any) -> None:
337
+ name_node = node.child_by_field_name("name")
338
+ if name_node is None:
339
+ return
340
+ name = _node_text(name_node, self.source)
341
+ node_id = _make_node_id("class", self.rel_path, name)
342
+ doc = _extract_jsdoc(node, self.source)
343
+
344
+ self._emitted.append(
345
+ NodeSpec(
346
+ node_id=node_id,
347
+ kind="class",
348
+ name=name,
349
+ qualname=name,
350
+ source_path=self.rel_path,
351
+ lineno=_lineno(node),
352
+ end_lineno=_end_lineno(node),
353
+ docstring=doc,
354
+ )
355
+ )
356
+ self._emitted.append(
357
+ EdgeSpec(source_id=self._mod_id, target_id=node_id, relation="CONTAINS")
358
+ )
359
+
360
+ # Heritage: extends / implements (class_heritage is a direct child, not a named field)
361
+ heritage = next((c for c in node.children if c.type == "class_heritage"), None)
362
+ if heritage is not None:
363
+ self._handle_class_heritage(heritage, node_id)
364
+
365
+ # Body: methods
366
+ body = node.child_by_field_name("body")
367
+ if body is not None:
368
+ for child in body.children:
369
+ if child.type in _METHOD_LIKE:
370
+ self._handle_method(child, class_name=name, class_id=node_id)
371
+
372
+ def _handle_class_heritage(self, heritage_node: Any, class_id: str) -> None:
373
+ for child in heritage_node.children:
374
+ if child.type == "extends_clause":
375
+ for c in child.children:
376
+ if c.type in ("identifier", "member_expression"):
377
+ base_name = _node_text(c, self.source).split(".")[0]
378
+ target_id = _make_node_id("class", self.rel_path, base_name)
379
+ self._emitted.append(
380
+ EdgeSpec(
381
+ source_id=class_id,
382
+ target_id=target_id,
383
+ relation="INHERITS",
384
+ )
385
+ )
386
+ break
387
+ elif child.type == "implements_clause":
388
+ for c in child.children:
389
+ if c.type in ("identifier", "generic_type", "type_identifier"):
390
+ iface_name = _node_text(c, self.source).split("<")[0].strip()
391
+ target_id = _make_node_id("interface", self.rel_path, iface_name)
392
+ self._emitted.append(
393
+ EdgeSpec(
394
+ source_id=class_id,
395
+ target_id=target_id,
396
+ relation="IMPLEMENTS",
397
+ )
398
+ )
399
+
400
+ # ------------------------------------------------------------------
401
+ # Method handling
402
+ # ------------------------------------------------------------------
403
+
404
+ def _handle_method(self, node: Any, class_name: str, class_id: str) -> None:
405
+ name_node = node.child_by_field_name("name")
406
+ if name_node is None:
407
+ return
408
+ name = _node_text(name_node, self.source)
409
+ qualname = f"{class_name}.{name}"
410
+ node_id = _make_node_id("method", self.rel_path, qualname)
411
+ doc = _extract_jsdoc(node, self.source)
412
+
413
+ self._emitted.append(
414
+ NodeSpec(
415
+ node_id=node_id,
416
+ kind="method",
417
+ name=name,
418
+ qualname=qualname,
419
+ source_path=self.rel_path,
420
+ lineno=_lineno(node),
421
+ end_lineno=_end_lineno(node),
422
+ docstring=doc,
423
+ )
424
+ )
425
+ self._emitted.append(EdgeSpec(source_id=class_id, target_id=node_id, relation="CONTAINS"))
426
+
427
+ # CALLS edges from method body
428
+ body = node.child_by_field_name("body") or node.child_by_field_name("value")
429
+ if body is not None:
430
+ for call_name in self._collect_calls(body):
431
+ call_target = _make_node_id("function", self.rel_path, call_name)
432
+ self._emitted.append(
433
+ EdgeSpec(source_id=node_id, target_id=call_target, relation="CALLS")
434
+ )
435
+
436
+ # ------------------------------------------------------------------
437
+ # Interface handling
438
+ # ------------------------------------------------------------------
439
+
440
+ def _handle_interface(self, node: Any) -> None:
441
+ name_node = node.child_by_field_name("name")
442
+ if name_node is None:
443
+ return
444
+ name = _node_text(name_node, self.source)
445
+ node_id = _make_node_id("interface", self.rel_path, name)
446
+ doc = _extract_jsdoc(node, self.source)
447
+
448
+ self._emitted.append(
449
+ NodeSpec(
450
+ node_id=node_id,
451
+ kind="interface",
452
+ name=name,
453
+ qualname=name,
454
+ source_path=self.rel_path,
455
+ lineno=_lineno(node),
456
+ end_lineno=_end_lineno(node),
457
+ docstring=doc,
458
+ )
459
+ )
460
+ self._emitted.append(
461
+ EdgeSpec(source_id=self._mod_id, target_id=node_id, relation="CONTAINS")
462
+ )
463
+
464
+ # interface extends clause — tree-sitter uses extends_type_clause as direct child
465
+ extends_clause = next((c for c in node.children if c.type == "extends_type_clause"), None)
466
+ if extends_clause is not None:
467
+ for c in extends_clause.children:
468
+ if c.type in ("identifier", "type_identifier"):
469
+ base_name = _node_text(c, self.source)
470
+ target_id = _make_node_id("interface", self.rel_path, base_name)
471
+ self._emitted.append(
472
+ EdgeSpec(source_id=node_id, target_id=target_id, relation="EXTENDS")
473
+ )
474
+
475
+ # ------------------------------------------------------------------
476
+ # Type alias handling
477
+ # ------------------------------------------------------------------
478
+
479
+ def _handle_type_alias(self, node: Any) -> None:
480
+ name_node = node.child_by_field_name("name")
481
+ if name_node is None:
482
+ return
483
+ name = _node_text(name_node, self.source)
484
+ node_id = _make_node_id("type_alias", self.rel_path, name)
485
+ doc = _extract_jsdoc(node, self.source)
486
+
487
+ self._emitted.append(
488
+ NodeSpec(
489
+ node_id=node_id,
490
+ kind="type_alias",
491
+ name=name,
492
+ qualname=name,
493
+ source_path=self.rel_path,
494
+ lineno=_lineno(node),
495
+ end_lineno=_end_lineno(node),
496
+ docstring=doc,
497
+ )
498
+ )
499
+ self._emitted.append(
500
+ EdgeSpec(source_id=self._mod_id, target_id=node_id, relation="CONTAINS")
501
+ )
502
+
503
+ # ------------------------------------------------------------------
504
+ # Enum handling
505
+ # ------------------------------------------------------------------
506
+
507
+ def _handle_enum(self, node: Any) -> None:
508
+ name_node = node.child_by_field_name("name")
509
+ if name_node is None:
510
+ return
511
+ name = _node_text(name_node, self.source)
512
+ node_id = _make_node_id("enum", self.rel_path, name)
513
+ doc = _extract_jsdoc(node, self.source)
514
+
515
+ # Collect member names as metadata
516
+ body = node.child_by_field_name("body")
517
+ members: list[str] = []
518
+ if body is not None:
519
+ for child in body.children:
520
+ if child.type == "enum_assignment":
521
+ mname = child.child_by_field_name("name")
522
+ if mname:
523
+ members.append(_node_text(mname, self.source))
524
+ elif child.type in ("identifier", "string"):
525
+ members.append(_node_text(child, self.source).strip("'\""))
526
+
527
+ self._emitted.append(
528
+ NodeSpec(
529
+ node_id=node_id,
530
+ kind="enum",
531
+ name=name,
532
+ qualname=name,
533
+ source_path=self.rel_path,
534
+ lineno=_lineno(node),
535
+ end_lineno=_end_lineno(node),
536
+ docstring=doc or (f"Enum with members: {', '.join(members)}" if members else ""),
537
+ metadata={"members": members},
538
+ )
539
+ )
540
+ self._emitted.append(
541
+ EdgeSpec(source_id=self._mod_id, target_id=node_id, relation="CONTAINS")
542
+ )
543
+
544
+ # ------------------------------------------------------------------
545
+ # Namespace handling
546
+ # ------------------------------------------------------------------
547
+
548
+ def _handle_namespace(self, node: Any) -> None:
549
+ name_node = node.child_by_field_name("name")
550
+ if name_node is None:
551
+ return
552
+ name = _node_text(name_node, self.source)
553
+ node_id = _make_node_id("namespace", self.rel_path, name)
554
+ doc = _extract_jsdoc(node, self.source)
555
+
556
+ self._emitted.append(
557
+ NodeSpec(
558
+ node_id=node_id,
559
+ kind="namespace",
560
+ name=name,
561
+ qualname=name,
562
+ source_path=self.rel_path,
563
+ lineno=_lineno(node),
564
+ end_lineno=_end_lineno(node),
565
+ docstring=doc,
566
+ )
567
+ )
568
+ self._emitted.append(
569
+ EdgeSpec(source_id=self._mod_id, target_id=node_id, relation="CONTAINS")
570
+ )
571
+
572
+ # ------------------------------------------------------------------
573
+ # Function handling
574
+ # ------------------------------------------------------------------
575
+
576
+ def _handle_function(self, node: Any) -> None:
577
+ name_node = node.child_by_field_name("name")
578
+ if name_node is None:
579
+ return
580
+ name = _node_text(name_node, self.source)
581
+ node_id = _make_node_id("function", self.rel_path, name)
582
+ doc = _extract_jsdoc(node, self.source)
583
+
584
+ self._emitted.append(
585
+ NodeSpec(
586
+ node_id=node_id,
587
+ kind="function",
588
+ name=name,
589
+ qualname=name,
590
+ source_path=self.rel_path,
591
+ lineno=_lineno(node),
592
+ end_lineno=_end_lineno(node),
593
+ docstring=doc,
594
+ )
595
+ )
596
+ self._emitted.append(
597
+ EdgeSpec(source_id=self._mod_id, target_id=node_id, relation="CONTAINS")
598
+ )
599
+
600
+ body = node.child_by_field_name("body")
601
+ if body is not None:
602
+ for call_name in self._collect_calls(body):
603
+ call_target = _make_node_id("function", self.rel_path, call_name)
604
+ self._emitted.append(
605
+ EdgeSpec(source_id=node_id, target_id=call_target, relation="CALLS")
606
+ )
607
+
608
+ # ------------------------------------------------------------------
609
+ # Export statement: unwrap and delegate
610
+ # ------------------------------------------------------------------
611
+
612
+ def _handle_export(self, node: Any, class_ctx: str | None) -> None:
613
+ for child in node.children:
614
+ if child.type in _CLASS_LIKE:
615
+ self._handle_class(child)
616
+ elif child.type in _INTERFACE_LIKE:
617
+ self._handle_interface(child)
618
+ elif child.type in _TYPE_ALIAS:
619
+ self._handle_type_alias(child)
620
+ elif child.type in _ENUM:
621
+ self._handle_enum(child)
622
+ elif child.type in _NAMESPACE:
623
+ self._handle_namespace(child)
624
+ elif child.type in _FUNCTION_LIKE and class_ctx is None:
625
+ self._handle_function(child)
626
+ elif child.type == "lexical_declaration" and class_ctx is None:
627
+ self._handle_lexical_declaration(child)
628
+
629
+ # ------------------------------------------------------------------
630
+ # Lexical declarations: const/let fn = () => {}
631
+ # ------------------------------------------------------------------
632
+
633
+ def _handle_lexical_declaration(self, node: Any) -> None:
634
+ """Handle ``const fn = () => {}`` style function declarations."""
635
+ for declarator in node.children:
636
+ if declarator.type != "variable_declarator":
637
+ continue
638
+ name_node = declarator.child_by_field_name("name")
639
+ value_node = declarator.child_by_field_name("value")
640
+ if name_node is None or value_node is None:
641
+ continue
642
+ if value_node.type not in (
643
+ "arrow_function",
644
+ "function",
645
+ "function_expression",
646
+ "generator_function",
647
+ ):
648
+ continue
649
+
650
+ name = _node_text(name_node, self.source)
651
+ if not re.match(r"^[A-Za-z_$][A-Za-z0-9_$]*$", name):
652
+ continue
653
+
654
+ node_id = _make_node_id("function", self.rel_path, name)
655
+ doc = _extract_jsdoc(node, self.source)
656
+
657
+ self._emitted.append(
658
+ NodeSpec(
659
+ node_id=node_id,
660
+ kind="function",
661
+ name=name,
662
+ qualname=name,
663
+ source_path=self.rel_path,
664
+ lineno=_lineno(node),
665
+ end_lineno=_end_lineno(value_node),
666
+ docstring=doc,
667
+ )
668
+ )
669
+ self._emitted.append(
670
+ EdgeSpec(source_id=self._mod_id, target_id=node_id, relation="CONTAINS")
671
+ )
672
+
673
+ body = value_node.child_by_field_name("body")
674
+ if body is not None:
675
+ for call_name in self._collect_calls(body):
676
+ call_target = _make_node_id("function", self.rel_path, call_name)
677
+ self._emitted.append(
678
+ EdgeSpec(source_id=node_id, target_id=call_target, relation="CALLS")
679
+ )
680
+
681
+ # ------------------------------------------------------------------
682
+ # Call expression collector
683
+ # ------------------------------------------------------------------
684
+
685
+ def _collect_calls(self, body_node: Any, depth: int = 0) -> list[str]:
686
+ """Recursively collect directly-called function names (simple identifiers only)."""
687
+ if depth > 10:
688
+ return []
689
+ calls: list[str] = []
690
+ for child in body_node.children:
691
+ if child.type in _CALL_EXPR:
692
+ fn_node = child.child_by_field_name("function")
693
+ if fn_node is not None and fn_node.type == "identifier":
694
+ name = _node_text(fn_node, self.source)
695
+ if re.match(r"^[A-Za-z_$][A-Za-z0-9_$]*$", name):
696
+ calls.append(name)
697
+ calls.extend(self._collect_calls(child, depth + 1))
698
+ return calls
699
+
700
+
701
+ # ---------------------------------------------------------------------------
702
+ # File discovery
703
+ # ---------------------------------------------------------------------------
704
+
705
+
706
+ def _find_ts_files(
707
+ repo_root: Path,
708
+ include: set[str],
709
+ exclude: set[str],
710
+ ) -> list[Path]:
711
+ """Walk repo_root and return all TypeScript/JavaScript source files."""
712
+ all_excludes = SKIP_DIRS | exclude
713
+ result: list[Path] = []
714
+
715
+ def _walk(directory: Path, depth: int) -> None:
716
+ try:
717
+ entries = sorted(directory.iterdir())
718
+ except PermissionError:
719
+ return
720
+ for entry in entries:
721
+ if entry.name.startswith(".") and entry.name not in {"."}:
722
+ # Keep .ts/.tsx files even in hidden dirs (unlikely), skip hidden dirs
723
+ if entry.is_dir():
724
+ continue
725
+ if entry.is_dir():
726
+ if entry.name in all_excludes:
727
+ continue
728
+ if depth == 0 and include and entry.name not in include:
729
+ continue
730
+ _walk(entry, depth + 1)
731
+ elif entry.is_file() and entry.suffix in TS_EXTENSIONS:
732
+ result.append(entry)
733
+
734
+ _walk(repo_root, depth=0)
735
+ return result
736
+
737
+
738
+ # ---------------------------------------------------------------------------
739
+ # TSCodeExtractor
740
+ # ---------------------------------------------------------------------------
741
+
742
+
743
+ class TSCodeExtractor(KGExtractor):
744
+ """
745
+ KGExtractor backed by tree-sitter TypeScript / JavaScript AST parsing.
746
+
747
+ Yields :class:`NodeSpec` and :class:`EdgeSpec` objects for every .ts,
748
+ .tsx, .js, and .jsx file found under ``repo_path``.
749
+
750
+ :param repo_path: Absolute path to the TypeScript/JavaScript repository.
751
+ :param include: Top-level directory names to include (empty = all).
752
+ :param exclude: Directory names to exclude at every depth.
753
+ :param config: Optional domain config dict.
754
+ """
755
+
756
+ def __init__(
757
+ self,
758
+ repo_path: Path,
759
+ *,
760
+ include: set[str] | None = None,
761
+ exclude: set[str] | None = None,
762
+ config: dict[str, Any] | None = None,
763
+ ) -> None:
764
+ super().__init__(repo_path, config)
765
+ self._include: set[str] = include or set()
766
+ self._exclude: set[str] = exclude or set()
767
+
768
+ # ------------------------------------------------------------------
769
+ # KGExtractor protocol
770
+ # ------------------------------------------------------------------
771
+
772
+ def node_kinds(self) -> list[str]:
773
+ return [
774
+ "module",
775
+ "class",
776
+ "interface",
777
+ "type_alias",
778
+ "enum",
779
+ "namespace",
780
+ "function",
781
+ "method",
782
+ "symbol",
783
+ ]
784
+
785
+ def edge_kinds(self) -> list[str]:
786
+ return [
787
+ "CONTAINS",
788
+ "IMPORTS",
789
+ "CALLS",
790
+ "INHERITS",
791
+ "IMPLEMENTS",
792
+ "EXTENDS",
793
+ ]
794
+
795
+ def meaningful_node_kinds(self) -> list[str]:
796
+ """Exclude symbol stubs from vector indexing and coverage metrics."""
797
+ return [
798
+ "module",
799
+ "class",
800
+ "interface",
801
+ "type_alias",
802
+ "enum",
803
+ "namespace",
804
+ "function",
805
+ "method",
806
+ ]
807
+
808
+ def extract(self) -> Iterator[NodeSpec | EdgeSpec]:
809
+ if not _HAS_TREE_SITTER:
810
+ raise RuntimeError(
811
+ "tree-sitter and tree-sitter-typescript are required. "
812
+ "Install with: pip install tree-sitter tree-sitter-typescript"
813
+ )
814
+
815
+ files = _find_ts_files(self.repo_path, self._include, self._exclude)
816
+ for abs_path in files:
817
+ try:
818
+ rel_path = str(abs_path.relative_to(self.repo_path)).replace("\\", "/")
819
+ source = abs_path.read_bytes()
820
+ # Choose language based on extension
821
+ lang = _TSX_LANGUAGE if abs_path.suffix in {".tsx", ".jsx"} else _TS_LANGUAGE
822
+ parser = Parser(lang)
823
+ tree = parser.parse(source)
824
+ walker = _FileWalker(rel_path, source, tree)
825
+ yield from walker.walk()
826
+ except Exception: # noqa: BLE001
827
+ continue