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/__init__.py +42 -0
- tscode_kg/__main__.py +6 -0
- tscode_kg/analysis.py +1829 -0
- tscode_kg/app.py +1355 -0
- tscode_kg/bridge.py +114 -0
- tscode_kg/centrality.py +434 -0
- tscode_kg/cli/__init__.py +1 -0
- tscode_kg/cli/cmd_analyze.py +69 -0
- tscode_kg/cli/cmd_bridges.py +38 -0
- tscode_kg/cli/cmd_build.py +86 -0
- tscode_kg/cli/cmd_centrality.py +124 -0
- tscode_kg/cli/cmd_explain.py +58 -0
- tscode_kg/cli/cmd_framework_nodes.py +43 -0
- tscode_kg/cli/cmd_hooks.py +125 -0
- tscode_kg/cli/cmd_init.py +234 -0
- tscode_kg/cli/cmd_mcp.py +35 -0
- tscode_kg/cli/cmd_model.py +52 -0
- tscode_kg/cli/cmd_query.py +75 -0
- tscode_kg/cli/cmd_snapshot.py +431 -0
- tscode_kg/cli/cmd_viz.py +175 -0
- tscode_kg/cli/main.py +56 -0
- tscode_kg/coderank.py +564 -0
- tscode_kg/config.py +36 -0
- tscode_kg/explain.py +270 -0
- tscode_kg/extractor.py +827 -0
- tscode_kg/framework_detector.py +106 -0
- tscode_kg/kg.py +193 -0
- tscode_kg/layout3d.py +492 -0
- tscode_kg/mcp_server.py +1412 -0
- tscode_kg/snapshots.py +64 -0
- tscode_kg/viz3d.py +1457 -0
- tscode_kg/viz3d_timeline.py +369 -0
- tscode_kg-0.2.0.dist-info/METADATA +196 -0
- tscode_kg-0.2.0.dist-info/RECORD +37 -0
- tscode_kg-0.2.0.dist-info/WHEEL +4 -0
- tscode_kg-0.2.0.dist-info/entry_points.txt +15 -0
- tscode_kg-0.2.0.dist-info/licenses/LICENSE +24 -0
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
|