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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: graphlens-typescript
3
- Version: 0.2.2
3
+ Version: 0.4.0
4
4
  Summary: TypeScript language adapter for graphlens
5
5
  Requires-Dist: graphlens
6
6
  Requires-Dist: tree-sitter>=0.24
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphlens-typescript"
3
- version = "0.2.2"
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 }
@@ -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"]
@@ -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 pathlib import Path
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
- for lang_root in find_typescript_roots(project_root):
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 _analyze_root(
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
- ".ts", ".tsx", ".mts", ".cts",
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
- Falls back to ``project_root``.
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.is_dir()
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
- Returns [search_root] if search_root itself has markers.
46
- Otherwise walks subdirectories for marker files and returns distinct roots.
47
- Falls back to [search_root] if nothing found.
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
- if _has_typescript_markers(search_root):
50
- return [search_root]
51
-
52
- roots: list[Path] = []
53
- for marker in TYPESCRIPT_MARKERS:
54
- for marker_file in sorted(search_root.rglob(marker)):
55
- rel_parts = marker_file.relative_to(search_root).parts
56
- if _EXCLUDED_DIRS & set(rel_parts):
57
- continue
58
- candidate = marker_file.parent
59
- if any(
60
- candidate == r or candidate.is_relative_to(r)
61
- for r in roots
62
- ):
63
- continue
64
- roots.append(candidate)
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)