graphlens-typescript 0.2.2__tar.gz → 0.4.0__tar.gz
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-0.2.2 → graphlens_typescript-0.4.0}/PKG-INFO +1 -1
- {graphlens_typescript-0.2.2 → graphlens_typescript-0.4.0}/pyproject.toml +3 -1
- {graphlens_typescript-0.2.2 → graphlens_typescript-0.4.0}/src/graphlens_typescript/__init__.py +2 -1
- {graphlens_typescript-0.2.2 → graphlens_typescript-0.4.0}/src/graphlens_typescript/_adapter.py +179 -6
- {graphlens_typescript-0.2.2 → graphlens_typescript-0.4.0}/src/graphlens_typescript/_module_resolver.py +74 -11
- {graphlens_typescript-0.2.2 → graphlens_typescript-0.4.0}/src/graphlens_typescript/_project_detector.py +25 -19
- graphlens_typescript-0.4.0/src/graphlens_typescript/_resolver.py +137 -0
- {graphlens_typescript-0.2.2 → graphlens_typescript-0.4.0}/src/graphlens_typescript/_visitor.py +784 -185
- graphlens_typescript-0.4.0/src/graphlens_typescript/ts_resolver.js +93 -0
- {graphlens_typescript-0.2.2 → graphlens_typescript-0.4.0}/src/graphlens_typescript/_deps.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "graphlens-typescript"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "TypeScript language adapter for graphlens"
|
|
5
5
|
requires-python = ">=3.13"
|
|
6
6
|
dependencies = [
|
|
@@ -12,6 +12,8 @@ dependencies = [
|
|
|
12
12
|
[build-system]
|
|
13
13
|
requires = ["uv_build>=0.9.18,<0.12.0"]
|
|
14
14
|
build-backend = "uv_build"
|
|
15
|
+
# uv_build ships all files under src/graphlens_typescript/ by default,
|
|
16
|
+
# so ts_resolver.js is included in the wheel without an explicit include rule.
|
|
15
17
|
|
|
16
18
|
[tool.uv.sources]
|
|
17
19
|
graphlens = { workspace = true }
|
{graphlens_typescript-0.2.2 → graphlens_typescript-0.4.0}/src/graphlens_typescript/__init__.py
RENAMED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""graphlens_typescript — TypeScript language adapter for graphlens."""
|
|
2
2
|
|
|
3
3
|
from graphlens_typescript._adapter import TypescriptAdapter
|
|
4
|
+
from graphlens_typescript._resolver import TsResolver
|
|
4
5
|
|
|
5
|
-
__all__ = ["TypescriptAdapter"]
|
|
6
|
+
__all__ = ["TsResolver", "TypescriptAdapter"]
|
{graphlens_typescript-0.2.2 → graphlens_typescript-0.4.0}/src/graphlens_typescript/_adapter.py
RENAMED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
from pathlib import Path
|
|
6
7
|
from typing import TYPE_CHECKING
|
|
7
8
|
|
|
8
9
|
from graphlens import (
|
|
@@ -13,7 +14,8 @@ from graphlens import (
|
|
|
13
14
|
Relation,
|
|
14
15
|
RelationKind,
|
|
15
16
|
)
|
|
16
|
-
from graphlens.utils import make_node_id
|
|
17
|
+
from graphlens.utils import SpanIndex, make_node_id
|
|
18
|
+
from graphlens.utils.roots import filter_nested_root_files
|
|
17
19
|
|
|
18
20
|
from graphlens_typescript._deps import (
|
|
19
21
|
TYPESCRIPT_DEFAULT_DEP_PARSERS,
|
|
@@ -22,23 +24,24 @@ from graphlens_typescript._deps import (
|
|
|
22
24
|
from graphlens_typescript._module_resolver import (
|
|
23
25
|
file_to_qualified_name,
|
|
24
26
|
find_source_roots,
|
|
27
|
+
load_tsconfig_path_aliases,
|
|
25
28
|
)
|
|
26
29
|
from graphlens_typescript._project_detector import (
|
|
27
30
|
detect_project_name,
|
|
28
31
|
find_typescript_roots,
|
|
29
32
|
is_typescript_project,
|
|
30
33
|
)
|
|
34
|
+
from graphlens_typescript._resolver import TsResolver
|
|
31
35
|
from graphlens_typescript._visitor import (
|
|
32
36
|
ImportClassifier,
|
|
37
|
+
OccurrenceRef,
|
|
33
38
|
TypescriptASTVisitor,
|
|
34
39
|
VisitorContext,
|
|
35
40
|
parse_typescript,
|
|
36
41
|
)
|
|
37
42
|
|
|
38
43
|
if TYPE_CHECKING:
|
|
39
|
-
from
|
|
40
|
-
|
|
41
|
-
from graphlens.contracts import DependencyFileParser
|
|
44
|
+
from graphlens.contracts import DependencyFileParser, SymbolResolver
|
|
42
45
|
|
|
43
46
|
logger = logging.getLogger("graphlens_typescript")
|
|
44
47
|
|
|
@@ -47,6 +50,18 @@ _STDLIB = get_stdlib_names()
|
|
|
47
50
|
# Declaration files contain only type information — skip them during analysis
|
|
48
51
|
_DECLARATION_SUFFIXES: tuple[str, ...] = (".d.ts", ".d.mts", ".d.cts")
|
|
49
52
|
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Role → RelationKind mapping
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
_ROLE_TO_KIND: dict[str, RelationKind] = {
|
|
58
|
+
"call": RelationKind.CALLS,
|
|
59
|
+
"base": RelationKind.INHERITS_FROM,
|
|
60
|
+
"annotation": RelationKind.HAS_TYPE,
|
|
61
|
+
"read": RelationKind.REFERENCES,
|
|
62
|
+
"write": RelationKind.REFERENCES,
|
|
63
|
+
}
|
|
64
|
+
|
|
50
65
|
|
|
51
66
|
class TypescriptAdapter(LanguageAdapter):
|
|
52
67
|
"""Language adapter for TypeScript projects."""
|
|
@@ -54,6 +69,7 @@ class TypescriptAdapter(LanguageAdapter):
|
|
|
54
69
|
def __init__(
|
|
55
70
|
self,
|
|
56
71
|
dep_parsers: list[DependencyFileParser] | None = None,
|
|
72
|
+
resolver: SymbolResolver | None = None,
|
|
57
73
|
) -> None:
|
|
58
74
|
"""
|
|
59
75
|
Initialize the TypeScript adapter.
|
|
@@ -63,6 +79,10 @@ class TypescriptAdapter(LanguageAdapter):
|
|
|
63
79
|
names from manifest files. Pass a custom list to support
|
|
64
80
|
non-standard package managers.
|
|
65
81
|
Defaults to ``TYPESCRIPT_DEFAULT_DEP_PARSERS``.
|
|
82
|
+
resolver: symbol resolver used for cross-file resolution of
|
|
83
|
+
calls, references, annotations, and base classes.
|
|
84
|
+
Defaults to ``TsResolver``. Inject a custom or null
|
|
85
|
+
resolver to override resolution behaviour.
|
|
66
86
|
|
|
67
87
|
"""
|
|
68
88
|
self._dep_parsers = (
|
|
@@ -70,14 +90,20 @@ class TypescriptAdapter(LanguageAdapter):
|
|
|
70
90
|
if dep_parsers is not None
|
|
71
91
|
else TYPESCRIPT_DEFAULT_DEP_PARSERS
|
|
72
92
|
)
|
|
93
|
+
self._resolver: SymbolResolver = (
|
|
94
|
+
resolver if resolver is not None else TsResolver()
|
|
95
|
+
)
|
|
73
96
|
|
|
74
97
|
def language(self) -> str:
|
|
98
|
+
"""Return the language identifier for this adapter."""
|
|
75
99
|
return "typescript"
|
|
76
100
|
|
|
77
101
|
def file_extensions(self) -> set[str]:
|
|
102
|
+
"""Return the set of file extensions handled by this adapter."""
|
|
78
103
|
return {".ts", ".tsx", ".mts", ".cts"}
|
|
79
104
|
|
|
80
105
|
def can_handle(self, project_root: Path) -> bool:
|
|
106
|
+
"""Return True if the project root is a TypeScript project."""
|
|
81
107
|
return is_typescript_project(project_root)
|
|
82
108
|
|
|
83
109
|
def collect_files(self, project_root: Path) -> list[Path]:
|
|
@@ -98,6 +124,18 @@ class TypescriptAdapter(LanguageAdapter):
|
|
|
98
124
|
project_root: Path,
|
|
99
125
|
files: list[Path] | None = None,
|
|
100
126
|
) -> GraphLens:
|
|
127
|
+
"""
|
|
128
|
+
Analyze a TypeScript project and return a populated GraphLens.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
project_root: the root directory of the project (or monorepo).
|
|
132
|
+
files: optional explicit list of files to analyze; when omitted
|
|
133
|
+
all TypeScript source files are collected automatically.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
A ``GraphLens`` containing the structural and relational nodes.
|
|
137
|
+
|
|
138
|
+
"""
|
|
101
139
|
graph = GraphLens()
|
|
102
140
|
|
|
103
141
|
if files is not None:
|
|
@@ -107,27 +145,142 @@ class TypescriptAdapter(LanguageAdapter):
|
|
|
107
145
|
project_root,
|
|
108
146
|
files,
|
|
109
147
|
self._dep_parsers,
|
|
148
|
+
self._resolver,
|
|
110
149
|
)
|
|
111
150
|
else:
|
|
112
|
-
|
|
151
|
+
lang_roots = find_typescript_roots(project_root)
|
|
152
|
+
for lang_root in lang_roots:
|
|
113
153
|
root_files = self.collect_files(lang_root)
|
|
154
|
+
root_files = filter_nested_root_files(
|
|
155
|
+
root_files,
|
|
156
|
+
lang_root,
|
|
157
|
+
lang_roots,
|
|
158
|
+
)
|
|
114
159
|
_analyze_root(
|
|
115
160
|
graph,
|
|
116
161
|
project_root,
|
|
117
162
|
lang_root,
|
|
118
163
|
root_files,
|
|
119
164
|
self._dep_parsers,
|
|
165
|
+
self._resolver,
|
|
120
166
|
)
|
|
121
167
|
|
|
122
168
|
return graph
|
|
123
169
|
|
|
124
170
|
|
|
125
|
-
def
|
|
171
|
+
def _ensure_external_symbol(
|
|
172
|
+
graph: GraphLens, project_name: str, qname: str, origin: str
|
|
173
|
+
) -> str:
|
|
174
|
+
"""
|
|
175
|
+
Return the id of an EXTERNAL_SYMBOL node for ``qname``.
|
|
176
|
+
|
|
177
|
+
Creates the node if it does not yet exist in ``graph``.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
graph: the graph to update in-place.
|
|
181
|
+
project_name: used as the namespace for ``make_node_id``.
|
|
182
|
+
qname: fully-qualified name of the external symbol.
|
|
183
|
+
origin: one of ``"stdlib"``, ``"third_party"``, ``"unknown"``,
|
|
184
|
+
or ``"internal"`` (fallback when the module node is absent).
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
The node id of the EXTERNAL_SYMBOL.
|
|
188
|
+
|
|
189
|
+
"""
|
|
190
|
+
sym_id = make_node_id(
|
|
191
|
+
project_name, qname, NodeKind.EXTERNAL_SYMBOL.value
|
|
192
|
+
)
|
|
193
|
+
if sym_id not in graph.nodes:
|
|
194
|
+
graph.add_node(
|
|
195
|
+
Node(
|
|
196
|
+
id=sym_id,
|
|
197
|
+
kind=NodeKind.EXTERNAL_SYMBOL,
|
|
198
|
+
qualified_name=qname,
|
|
199
|
+
name=qname.rsplit(".", maxsplit=1)[-1],
|
|
200
|
+
metadata={"origin": origin},
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
return sym_id
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _resolve_occurrences(
|
|
207
|
+
graph: GraphLens,
|
|
208
|
+
project_name: str,
|
|
209
|
+
resolver: SymbolResolver,
|
|
210
|
+
span_index: SpanIndex,
|
|
211
|
+
occurrences: list[tuple[str, OccurrenceRef]],
|
|
212
|
+
) -> None:
|
|
213
|
+
"""
|
|
214
|
+
Resolve all accumulated occurrences and emit edges (batched).
|
|
215
|
+
|
|
216
|
+
Collects all (file, line, col) queries in one list, issues a single
|
|
217
|
+
``resolver.resolve_all(queries)`` call, then maps results back to
|
|
218
|
+
graph edges.
|
|
219
|
+
|
|
220
|
+
For each ``(abs_path, occ)`` pair:
|
|
221
|
+
|
|
222
|
+
1. Receive the definition site from the batch result.
|
|
223
|
+
2. If ``ref is None`` — skip.
|
|
224
|
+
3. If the definition is internal, look up the target node id via
|
|
225
|
+
``span_index.at()``.
|
|
226
|
+
4. If the node is not found (or origin is external), create/reuse an
|
|
227
|
+
``EXTERNAL_SYMBOL`` fallback node.
|
|
228
|
+
5. Emit a ``Relation`` of the appropriate kind, with span metadata
|
|
229
|
+
and, for read/write occurrences, an ``access`` key.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
graph: the graph to update in-place.
|
|
233
|
+
project_name: namespace used for EXTERNAL_SYMBOL node ids.
|
|
234
|
+
resolver: the symbol resolver that was already ``prepare()``d.
|
|
235
|
+
span_index: pre-built index of node spans from ``graph``.
|
|
236
|
+
occurrences: list of ``(absolute_file_path, OccurrenceRef)`` pairs
|
|
237
|
+
collected during the file-visit loop.
|
|
238
|
+
|
|
239
|
+
"""
|
|
240
|
+
queries: list[tuple[Path, int, int]] = [
|
|
241
|
+
(Path(p), o.line, o.col) for (p, o) in occurrences
|
|
242
|
+
]
|
|
243
|
+
refs = resolver.resolve_all(queries)
|
|
244
|
+
for (_p, occ), ref in zip(occurrences, refs, strict=True):
|
|
245
|
+
if ref is None:
|
|
246
|
+
continue
|
|
247
|
+
target_id: str | None = None
|
|
248
|
+
if ref.origin == "internal" and ref.file_path is not None:
|
|
249
|
+
target_id = span_index.at(
|
|
250
|
+
str(ref.file_path), ref.line, ref.col
|
|
251
|
+
)
|
|
252
|
+
if target_id is None:
|
|
253
|
+
fallback_qname = (
|
|
254
|
+
ref.full_name
|
|
255
|
+
if ref.full_name
|
|
256
|
+
else f"{occ.role}@{occ.line}:{occ.col}"
|
|
257
|
+
)
|
|
258
|
+
target_id = _ensure_external_symbol(
|
|
259
|
+
graph,
|
|
260
|
+
project_name,
|
|
261
|
+
fallback_qname,
|
|
262
|
+
ref.origin,
|
|
263
|
+
)
|
|
264
|
+
metadata: dict[str, object] = {"span": occ.span}
|
|
265
|
+
if occ.role in ("read", "write"):
|
|
266
|
+
metadata["access"] = occ.role
|
|
267
|
+
graph.add_relation(
|
|
268
|
+
Relation(
|
|
269
|
+
source_id=occ.enclosing_id,
|
|
270
|
+
target_id=target_id,
|
|
271
|
+
kind=_ROLE_TO_KIND[occ.role],
|
|
272
|
+
metadata=metadata,
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _analyze_root( # noqa: PLR0913, PLR0915
|
|
126
278
|
graph: GraphLens,
|
|
127
279
|
project_root: Path,
|
|
128
280
|
lang_root: Path,
|
|
129
281
|
files: list[Path],
|
|
130
282
|
dep_parsers: list[DependencyFileParser],
|
|
283
|
+
resolver: SymbolResolver,
|
|
131
284
|
) -> None:
|
|
132
285
|
"""Analyze one TypeScript project root and populate graph in-place."""
|
|
133
286
|
project_name = detect_project_name(lang_root)
|
|
@@ -149,6 +302,14 @@ def _analyze_root(
|
|
|
149
302
|
if parser.can_parse(lang_root):
|
|
150
303
|
third_party.update(parser.parse(lang_root))
|
|
151
304
|
|
|
305
|
+
# BUG 2 fix: root-level config files (e.g. next.config.ts) can contribute
|
|
306
|
+
# names like "next" or "sentry" to internal_tops. When a name is also a
|
|
307
|
+
# declared third-party package, the package.json is authoritative.
|
|
308
|
+
internal_tops = {t for t in internal_tops if t not in third_party}
|
|
309
|
+
|
|
310
|
+
# Load tsconfig path aliases once per root (BUG 1 fix)
|
|
311
|
+
path_aliases = load_tsconfig_path_aliases(lang_root)
|
|
312
|
+
|
|
152
313
|
classifier = ImportClassifier(
|
|
153
314
|
stdlib=_STDLIB,
|
|
154
315
|
third_party=frozenset(third_party),
|
|
@@ -169,6 +330,7 @@ def _analyze_root(
|
|
|
169
330
|
)
|
|
170
331
|
|
|
171
332
|
modules: dict[str, str] = {}
|
|
333
|
+
all_occurrences: list[tuple[str, OccurrenceRef]] = []
|
|
172
334
|
|
|
173
335
|
for file in files:
|
|
174
336
|
source_root = (
|
|
@@ -233,11 +395,22 @@ def _analyze_root(
|
|
|
233
395
|
source_root=source_root,
|
|
234
396
|
module_qualified_name=module_qname,
|
|
235
397
|
modules=modules,
|
|
398
|
+
path_aliases=path_aliases,
|
|
236
399
|
)
|
|
237
400
|
visitor = TypescriptASTVisitor(
|
|
238
401
|
ctx, graph, file_id, source_bytes, classifier
|
|
239
402
|
)
|
|
240
403
|
visitor.visit(tree.root_node)
|
|
404
|
+
all_occurrences.extend(
|
|
405
|
+
(visitor.abs_file_path, o) for o in visitor.occurrences
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Resolution pass: bind occurrences to real nodes or EXTERNAL_SYMBOL
|
|
409
|
+
span_index = SpanIndex.from_graph(graph)
|
|
410
|
+
resolver.prepare(lang_root, files)
|
|
411
|
+
_resolve_occurrences(
|
|
412
|
+
graph, project_name, resolver, span_index, all_occurrences
|
|
413
|
+
)
|
|
241
414
|
|
|
242
415
|
# PROJECT --CONTAINS--> top-level modules
|
|
243
416
|
top_level = {qn: mid for qn, mid in modules.items() if "." not in qn}
|
|
@@ -2,12 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
|
|
9
|
+
# Matches "prefix/*": ["./target/*"] — single target, glob on both sides
|
|
10
|
+
_ALIAS_RE = re.compile(
|
|
11
|
+
r'"([^"]+)/\*"\s*:\s*\[\s*"\./([^"]*)/\*"\s*\]'
|
|
12
|
+
)
|
|
13
|
+
|
|
7
14
|
# Extensions to strip when converting file path to module name
|
|
8
|
-
_TS_EXTENSIONS: frozenset[str] = frozenset(
|
|
9
|
-
|
|
10
|
-
|
|
15
|
+
_TS_EXTENSIONS: frozenset[str] = frozenset(
|
|
16
|
+
{
|
|
17
|
+
".ts",
|
|
18
|
+
".tsx",
|
|
19
|
+
".mts",
|
|
20
|
+
".cts",
|
|
21
|
+
}
|
|
22
|
+
)
|
|
11
23
|
|
|
12
24
|
# Files that represent the package root (like __init__.py in Python)
|
|
13
25
|
_INDEX_STEMS: frozenset[str] = frozenset({"index"})
|
|
@@ -17,19 +29,70 @@ def find_source_roots(project_root: Path, files: list[Path]) -> list[Path]:
|
|
|
17
29
|
"""
|
|
18
30
|
Detect TypeScript source roots.
|
|
19
31
|
|
|
20
|
-
Prefers a ``src/`` sub-directory when source files live there
|
|
21
|
-
|
|
32
|
+
Prefers a ``src/`` sub-directory when source files live there,
|
|
33
|
+
but also includes ``project_root`` for files outside ``src/``.
|
|
34
|
+
Falls back to ``[project_root]`` for non-src-layout projects.
|
|
22
35
|
"""
|
|
23
36
|
src = project_root / "src"
|
|
24
|
-
if (
|
|
25
|
-
src
|
|
26
|
-
and files
|
|
27
|
-
and any(f.is_relative_to(src) for f in files)
|
|
28
|
-
):
|
|
29
|
-
return [src]
|
|
37
|
+
if src.is_dir() and files and any(f.is_relative_to(src) for f in files):
|
|
38
|
+
return [src, project_root]
|
|
30
39
|
return [project_root]
|
|
31
40
|
|
|
32
41
|
|
|
42
|
+
def load_tsconfig_path_aliases(project_root: Path) -> dict[str, str]:
|
|
43
|
+
"""
|
|
44
|
+
Read ``tsconfig.json`` and return a prefix-alias map.
|
|
45
|
+
|
|
46
|
+
Extracts ``compilerOptions.paths`` entries of the form
|
|
47
|
+
``"<prefix>/*": ["<target>/*"]`` and converts them to
|
|
48
|
+
``{"<prefix>/": "<target>/"}`` (stripping the ``/*`` glob and
|
|
49
|
+
leading ``./``). Multi-target entries and non-glob patterns are
|
|
50
|
+
silently ignored. Returns ``{}`` on any error — never raises.
|
|
51
|
+
"""
|
|
52
|
+
tsconfig = project_root / "tsconfig.json"
|
|
53
|
+
try:
|
|
54
|
+
raw = tsconfig.read_text(encoding="utf-8")
|
|
55
|
+
except OSError:
|
|
56
|
+
return {}
|
|
57
|
+
try:
|
|
58
|
+
# Strip // line comments and trailing commas before parsing
|
|
59
|
+
clean = re.sub(r"//[^\n]*", "", raw)
|
|
60
|
+
clean = re.sub(r",(\s*[}\]])", r"\1", clean)
|
|
61
|
+
data = json.loads(clean)
|
|
62
|
+
paths = data.get("compilerOptions", {}).get("paths", {})
|
|
63
|
+
if not isinstance(paths, dict):
|
|
64
|
+
return {}
|
|
65
|
+
except Exception:
|
|
66
|
+
return {}
|
|
67
|
+
aliases: dict[str, str] = {}
|
|
68
|
+
for alias_pattern, targets in paths.items():
|
|
69
|
+
if not alias_pattern.endswith("/*"):
|
|
70
|
+
continue
|
|
71
|
+
if not isinstance(targets, list) or len(targets) != 1:
|
|
72
|
+
continue
|
|
73
|
+
target = targets[0]
|
|
74
|
+
if not isinstance(target, str) or not target.endswith("/*"):
|
|
75
|
+
continue
|
|
76
|
+
alias_prefix = alias_pattern[:-1] # strip trailing *
|
|
77
|
+
target_prefix = target.lstrip("./")[:-1] # strip ./ and trailing *
|
|
78
|
+
aliases[alias_prefix] = target_prefix
|
|
79
|
+
return aliases
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def apply_path_alias(import_path: str, aliases: dict[str, str]) -> str:
|
|
83
|
+
"""
|
|
84
|
+
Rewrite ``import_path`` if it matches a tsconfig path alias prefix.
|
|
85
|
+
|
|
86
|
+
For example, with ``aliases = {"@/": "src/"}``, rewrites
|
|
87
|
+
``"@/client/v2"`` → ``"src/client/v2"``.
|
|
88
|
+
Returns ``import_path`` unchanged when no alias prefix matches.
|
|
89
|
+
"""
|
|
90
|
+
for prefix, target in aliases.items():
|
|
91
|
+
if import_path.startswith(prefix):
|
|
92
|
+
return target + import_path[len(prefix):]
|
|
93
|
+
return import_path
|
|
94
|
+
|
|
95
|
+
|
|
33
96
|
def file_to_qualified_name(file_path: Path, source_root: Path) -> str:
|
|
34
97
|
"""
|
|
35
98
|
Convert a TypeScript file path to a dotted module qualified name.
|
|
@@ -6,6 +6,8 @@ import json
|
|
|
6
6
|
import re
|
|
7
7
|
from typing import TYPE_CHECKING
|
|
8
8
|
|
|
9
|
+
from graphlens.utils import collect_marker_roots
|
|
10
|
+
|
|
9
11
|
if TYPE_CHECKING:
|
|
10
12
|
from pathlib import Path
|
|
11
13
|
|
|
@@ -42,26 +44,30 @@ def find_typescript_roots(search_root: Path) -> list[Path]:
|
|
|
42
44
|
"""
|
|
43
45
|
Find TypeScript project roots within search_root (monorepo support).
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
A marker at ``search_root`` does not hide nested package roots.
|
|
48
|
+
``tsconfig`` files inside an existing ``package.json`` root are treated as
|
|
49
|
+
that package's config rather than as independent roots.
|
|
48
50
|
"""
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
51
|
+
package_roots = collect_marker_roots(
|
|
52
|
+
search_root,
|
|
53
|
+
("package.json",),
|
|
54
|
+
excluded_dirs=_EXCLUDED_DIRS,
|
|
55
|
+
fallback_to_search_root=False,
|
|
56
|
+
)
|
|
57
|
+
tsconfig_roots = collect_marker_roots(
|
|
58
|
+
search_root,
|
|
59
|
+
("tsconfig.json",),
|
|
60
|
+
excluded_dirs=_EXCLUDED_DIRS,
|
|
61
|
+
fallback_to_search_root=False,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
roots = list(package_roots)
|
|
65
|
+
for tsconfig_root in tsconfig_roots:
|
|
66
|
+
if tsconfig_root in roots:
|
|
67
|
+
continue
|
|
68
|
+
if any(tsconfig_root.is_relative_to(root) for root in package_roots):
|
|
69
|
+
continue
|
|
70
|
+
roots.append(tsconfig_root)
|
|
65
71
|
|
|
66
72
|
return sorted(roots) if roots else [search_root]
|
|
67
73
|
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""TypeScript type-aware resolver via a Node subprocess (Compiler API)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess # nosec B404
|
|
10
|
+
from importlib.resources import files as _pkg_files
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from graphlens.contracts import Occurrence, ResolvedRef, SymbolResolver
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("graphlens_typescript")
|
|
16
|
+
|
|
17
|
+
_TS_VERSION = "5.8.3"
|
|
18
|
+
Query = tuple[Path, int, int] # (absolute file, 1-based line, 1-based col)
|
|
19
|
+
|
|
20
|
+
_BRIDGE_JS = "ts_resolver.js"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cache_root() -> Path:
|
|
24
|
+
base = os.environ.get("XDG_CACHE_HOME")
|
|
25
|
+
root = Path(base) if base else Path.home() / ".cache"
|
|
26
|
+
return root / "graphlens" / "ts-resolver"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TsResolver(SymbolResolver):
|
|
30
|
+
"""Resolves TS symbols via a bundled Node script. Never raises."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, ts_version: str = _TS_VERSION) -> None:
|
|
33
|
+
"""Initialise resolver with a pinned typescript version."""
|
|
34
|
+
self._ts_version = ts_version
|
|
35
|
+
self._root: Path | None = None
|
|
36
|
+
self._cache_dir: Path = _cache_root() / ts_version
|
|
37
|
+
self._disabled = False
|
|
38
|
+
|
|
39
|
+
def prepare(self, project_root: Path, files: list[Path]) -> None: # noqa: ARG002
|
|
40
|
+
"""Set up the engine for a project before any queries."""
|
|
41
|
+
self._root = project_root
|
|
42
|
+
try:
|
|
43
|
+
self.ensure_typescript()
|
|
44
|
+
except Exception:
|
|
45
|
+
logger.warning("TsResolver disabled: typescript unavailable")
|
|
46
|
+
self._disabled = True
|
|
47
|
+
|
|
48
|
+
def ensure_typescript(self) -> None:
|
|
49
|
+
"""Install typescript into the cache dir if not already present."""
|
|
50
|
+
sentinel = (
|
|
51
|
+
self._cache_dir / "node_modules" / "typescript"
|
|
52
|
+
/ "lib" / "typescript.js"
|
|
53
|
+
)
|
|
54
|
+
if sentinel.exists():
|
|
55
|
+
return
|
|
56
|
+
if shutil.which("node") is None or shutil.which("npm") is None:
|
|
57
|
+
msg = "node/npm not found"
|
|
58
|
+
raise RuntimeError(msg)
|
|
59
|
+
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
(self._cache_dir / "package.json").write_text('{"private":true}')
|
|
61
|
+
subprocess.run( # nosec B603 B607
|
|
62
|
+
["npm", "install", # noqa: S607
|
|
63
|
+
f"typescript@{self._ts_version}",
|
|
64
|
+
"--no-save", "--no-audit", "--prefer-offline"],
|
|
65
|
+
cwd=str(self._cache_dir),
|
|
66
|
+
check=True, capture_output=True, timeout=300,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def resolve_all(
|
|
70
|
+
self, queries: list[Query]
|
|
71
|
+
) -> list[ResolvedRef | None]:
|
|
72
|
+
"""Resolve a batch of positions; never raises."""
|
|
73
|
+
if self._disabled or not queries or self._root is None:
|
|
74
|
+
return [None] * len(queries)
|
|
75
|
+
try:
|
|
76
|
+
payload = self._run_bridge(self._build_request(queries))
|
|
77
|
+
return self._parse_response(payload)
|
|
78
|
+
except Exception:
|
|
79
|
+
logger.warning("TsResolver batch failed; degrading to None")
|
|
80
|
+
return [None] * len(queries)
|
|
81
|
+
|
|
82
|
+
def definition_at(
|
|
83
|
+
self, file: Path, line: int, col: int
|
|
84
|
+
) -> ResolvedRef | None:
|
|
85
|
+
"""Resolve the symbol at a position to its definition (cross-file)."""
|
|
86
|
+
return self.resolve_all([(file, line, col)])[0]
|
|
87
|
+
|
|
88
|
+
def infer_type_at(
|
|
89
|
+
self, file: Path, line: int, col: int
|
|
90
|
+
) -> ResolvedRef | None:
|
|
91
|
+
"""Infer the type of the expression at a position."""
|
|
92
|
+
return self.resolve_all([(file, line, col)])[0]
|
|
93
|
+
|
|
94
|
+
def references_to(self, file: Path, line: int, col: int) -> list[Occurrence]: # noqa: ARG002, E501
|
|
95
|
+
"""Return all references to the symbol at a position."""
|
|
96
|
+
return [] # references batch not used by the resolution pass; deferred
|
|
97
|
+
|
|
98
|
+
def _build_request(self, queries: list[Query]) -> dict:
|
|
99
|
+
"""Build the JSON request payload for the Node bridge."""
|
|
100
|
+
return {
|
|
101
|
+
"project_root": str(self._root),
|
|
102
|
+
"queries": [
|
|
103
|
+
{"file": str(f), "line": ln, "col": c}
|
|
104
|
+
for (f, ln, c) in queries
|
|
105
|
+
],
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
def _parse_response(
|
|
109
|
+
self, payload: dict
|
|
110
|
+
) -> list[ResolvedRef | None]:
|
|
111
|
+
"""Map the bridge JSON response to a list of ResolvedRef or None."""
|
|
112
|
+
out: list[ResolvedRef | None] = []
|
|
113
|
+
for item in payload.get("results", []):
|
|
114
|
+
if not item:
|
|
115
|
+
out.append(None)
|
|
116
|
+
continue
|
|
117
|
+
out.append(ResolvedRef(
|
|
118
|
+
full_name=item.get("name", ""),
|
|
119
|
+
file_path=Path(item["file"]) if item.get("file") else None,
|
|
120
|
+
line=item.get("line", 1),
|
|
121
|
+
col=item.get("col", 1),
|
|
122
|
+
kind=item.get("kind", "unknown"),
|
|
123
|
+
origin=item.get("origin", "unknown"),
|
|
124
|
+
))
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
def _run_bridge(self, request: dict) -> dict:
|
|
128
|
+
"""Invoke the Node bridge as a subprocess, return parsed JSON."""
|
|
129
|
+
bridge = _pkg_files("graphlens_typescript") / _BRIDGE_JS
|
|
130
|
+
env = dict(os.environ, TS_CACHE_DIR=str(self._cache_dir))
|
|
131
|
+
completed = subprocess.run( # nosec B603 B607
|
|
132
|
+
["node", str(bridge)], # noqa: S607
|
|
133
|
+
input=json.dumps(request),
|
|
134
|
+
capture_output=True, text=True, env=env,
|
|
135
|
+
cwd=str(self._root), timeout=600, check=False,
|
|
136
|
+
)
|
|
137
|
+
return json.loads(completed.stdout)
|