seam-code 0.3.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.
- seam/__init__.py +3 -0
- seam/_data/schema.sql +225 -0
- seam/_web/assets/index-BL_tqprR.js +216 -0
- seam/_web/assets/index-GTKUhVyD.css +1 -0
- seam/_web/index.html +13 -0
- seam/analysis/__init__.py +14 -0
- seam/analysis/affected.py +254 -0
- seam/analysis/builtins.py +966 -0
- seam/analysis/byte_budget.py +217 -0
- seam/analysis/changes.py +709 -0
- seam/analysis/cluster_naming.py +260 -0
- seam/analysis/clustering.py +216 -0
- seam/analysis/confidence.py +699 -0
- seam/analysis/embeddings.py +195 -0
- seam/analysis/flows.py +708 -0
- seam/analysis/impact.py +444 -0
- seam/analysis/imports.py +994 -0
- seam/analysis/imports_ext.py +780 -0
- seam/analysis/imports_resolve.py +176 -0
- seam/analysis/processes.py +453 -0
- seam/analysis/relevance.py +155 -0
- seam/analysis/rwr.py +129 -0
- seam/analysis/staleness.py +328 -0
- seam/analysis/steer.py +282 -0
- seam/analysis/synthesis.py +253 -0
- seam/analysis/synthesis_channels.py +433 -0
- seam/analysis/testpaths.py +103 -0
- seam/analysis/traversal.py +470 -0
- seam/cli/__init__.py +0 -0
- seam/cli/install.py +232 -0
- seam/cli/main.py +2602 -0
- seam/cli/output.py +137 -0
- seam/cli/read.py +244 -0
- seam/cli/serve.py +145 -0
- seam/config.py +551 -0
- seam/indexer/__init__.py +0 -0
- seam/indexer/cluster_index.py +425 -0
- seam/indexer/db.py +496 -0
- seam/indexer/embedding_index.py +183 -0
- seam/indexer/field_access.py +536 -0
- seam/indexer/field_access_c_cpp.py +643 -0
- seam/indexer/field_access_ext.py +708 -0
- seam/indexer/field_access_ext2.py +408 -0
- seam/indexer/field_access_go_rust.py +737 -0
- seam/indexer/field_access_php_swift.py +888 -0
- seam/indexer/field_access_ts.py +626 -0
- seam/indexer/graph.py +321 -0
- seam/indexer/graph_c.py +562 -0
- seam/indexer/graph_c_cpp.py +39 -0
- seam/indexer/graph_common.py +644 -0
- seam/indexer/graph_cpp.py +615 -0
- seam/indexer/graph_csharp.py +651 -0
- seam/indexer/graph_go.py +723 -0
- seam/indexer/graph_go_rust.py +39 -0
- seam/indexer/graph_java.py +689 -0
- seam/indexer/graph_java_csharp.py +38 -0
- seam/indexer/graph_php.py +914 -0
- seam/indexer/graph_python.py +628 -0
- seam/indexer/graph_ruby.py +748 -0
- seam/indexer/graph_rust.py +653 -0
- seam/indexer/graph_scope_infer.py +902 -0
- seam/indexer/graph_scope_infer_ext.py +723 -0
- seam/indexer/graph_scope_infer_ext2.py +992 -0
- seam/indexer/graph_swift.py +1014 -0
- seam/indexer/graph_swift_infer.py +515 -0
- seam/indexer/graph_typescript.py +663 -0
- seam/indexer/migrations.py +816 -0
- seam/indexer/parser.py +204 -0
- seam/indexer/pipeline.py +197 -0
- seam/indexer/signatures.py +634 -0
- seam/indexer/signatures_ext.py +780 -0
- seam/indexer/sync.py +287 -0
- seam/indexer/synthesis_index.py +291 -0
- seam/indexer/tokenize.py +79 -0
- seam/installer/__init__.py +67 -0
- seam/installer/claude.py +97 -0
- seam/installer/codex.py +94 -0
- seam/installer/core.py +127 -0
- seam/installer/cursor.py +61 -0
- seam/installer/guide.py +110 -0
- seam/installer/jsonfile.py +85 -0
- seam/installer/markdownfile.py +146 -0
- seam/installer/tomlfile.py +72 -0
- seam/query/__init__.py +0 -0
- seam/query/clusters.py +206 -0
- seam/query/comments.py +217 -0
- seam/query/context.py +293 -0
- seam/query/engine.py +940 -0
- seam/query/fts.py +328 -0
- seam/query/names.py +470 -0
- seam/query/pack.py +433 -0
- seam/query/semantic.py +339 -0
- seam/query/structure.py +727 -0
- seam/server/__init__.py +0 -0
- seam/server/graph_api.py +437 -0
- seam/server/handler_common.py +323 -0
- seam/server/impact_handler.py +615 -0
- seam/server/mcp.py +556 -0
- seam/server/tools.py +697 -0
- seam/server/trace_handler.py +184 -0
- seam/server/web.py +922 -0
- seam/watcher/__init__.py +0 -0
- seam/watcher/__main__.py +56 -0
- seam/watcher/daemon.py +237 -0
- seam_code-0.3.0.dist-info/METADATA +318 -0
- seam_code-0.3.0.dist-info/RECORD +109 -0
- seam_code-0.3.0.dist-info/WHEEL +4 -0
- seam_code-0.3.0.dist-info/entry_points.txt +2 -0
- seam_code-0.3.0.dist-info/licenses/LICENSE +21 -0
seam/analysis/imports.py
ADDED
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
"""Import mapping extraction and resolution for Phase 5 confidence promotion.
|
|
2
|
+
|
|
3
|
+
Leaf module — imports ONLY stdlib + tree_sitter types + seam.indexer TypedDicts.
|
|
4
|
+
Must NOT import seam.query, seam.server, traversal.py, flows.py, or confidence.py.
|
|
5
|
+
|
|
6
|
+
Provides:
|
|
7
|
+
ImportMapping (TypedDict) — one import binding from a source file.
|
|
8
|
+
extract_import_mappings(root, filepath, language) -> list[ImportMapping]
|
|
9
|
+
Parse a file's AST and return all import bindings.
|
|
10
|
+
Never raises — returns [] on any failure.
|
|
11
|
+
resolve_import_source(source_module, referencing_file, repo_root, language) -> list[str]
|
|
12
|
+
Map an import source string to absolute file paths that exist on disk.
|
|
13
|
+
Returns [] for third-party / unresolvable sources.
|
|
14
|
+
compute_path_proximity(referencing_file, candidate_file) -> int
|
|
15
|
+
Pure path-distance score (shared directory segment count).
|
|
16
|
+
Higher = closer. Used for AMBIGUOUS tie-break (step D).
|
|
17
|
+
|
|
18
|
+
Per-language extension resolution order:
|
|
19
|
+
Python : ['.py', '/__init__.py']
|
|
20
|
+
Rust : ['.rs', '/mod.rs']
|
|
21
|
+
TS : ['.ts', '.tsx', '.d.ts', '.js']
|
|
22
|
+
JS : ['.js', '.mjs', '.cjs', '/index.js']
|
|
23
|
+
Go : package directory (last segment of path)
|
|
24
|
+
|
|
25
|
+
P3 (tsconfig + go.mod resolution):
|
|
26
|
+
- TS/JS: tsconfig.json / jsconfig.json `compilerOptions.paths` + `baseUrl`
|
|
27
|
+
aliases (e.g. '@/*' -> ['src/*']) are expanded BEFORE the third-party
|
|
28
|
+
fallback so '@/foo' resolves to a real file. Config is read ONCE per
|
|
29
|
+
repo_root and cached (zero read-time cost).
|
|
30
|
+
- Go: the `module <path>` line in go.mod is read once per repo_root; a
|
|
31
|
+
module-qualified import that starts with that prefix is stripped to a
|
|
32
|
+
repo-relative directory and resolved normally.
|
|
33
|
+
|
|
34
|
+
Out of scope: Java/C#/PHP package resolution (stays []), barrel chasing.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import logging
|
|
38
|
+
import os
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import TypedDict
|
|
41
|
+
|
|
42
|
+
# Phase 9+10: import the new-language stub extractors/resolvers from the companion leaf module.
|
|
43
|
+
# imports_ext is a leaf (no seam deps) so importing it here does not create a cycle.
|
|
44
|
+
# Each import is a separate from-block — sorted alphabetically per ruff-isort (_ext prefix).
|
|
45
|
+
from seam.analysis.imports_ext import (
|
|
46
|
+
_extract_c as _ext_extract_c,
|
|
47
|
+
)
|
|
48
|
+
from seam.analysis.imports_ext import (
|
|
49
|
+
_extract_cpp as _ext_extract_cpp,
|
|
50
|
+
)
|
|
51
|
+
from seam.analysis.imports_ext import (
|
|
52
|
+
_extract_csharp as _ext_extract_csharp,
|
|
53
|
+
)
|
|
54
|
+
from seam.analysis.imports_ext import (
|
|
55
|
+
_extract_java as _ext_extract_java,
|
|
56
|
+
)
|
|
57
|
+
from seam.analysis.imports_ext import (
|
|
58
|
+
_extract_php as _ext_extract_php,
|
|
59
|
+
)
|
|
60
|
+
from seam.analysis.imports_ext import (
|
|
61
|
+
_extract_ruby as _ext_extract_ruby,
|
|
62
|
+
)
|
|
63
|
+
from seam.analysis.imports_ext import (
|
|
64
|
+
_extract_swift as _ext_extract_swift,
|
|
65
|
+
)
|
|
66
|
+
from seam.analysis.imports_ext import (
|
|
67
|
+
_resolve_c as _ext_resolve_c,
|
|
68
|
+
)
|
|
69
|
+
from seam.analysis.imports_ext import (
|
|
70
|
+
_resolve_cpp as _ext_resolve_cpp,
|
|
71
|
+
)
|
|
72
|
+
from seam.analysis.imports_ext import (
|
|
73
|
+
_resolve_csharp as _ext_resolve_csharp,
|
|
74
|
+
)
|
|
75
|
+
from seam.analysis.imports_ext import (
|
|
76
|
+
_resolve_java as _ext_resolve_java,
|
|
77
|
+
)
|
|
78
|
+
from seam.analysis.imports_ext import (
|
|
79
|
+
_resolve_php as _ext_resolve_php,
|
|
80
|
+
)
|
|
81
|
+
from seam.analysis.imports_ext import (
|
|
82
|
+
_resolve_ruby as _ext_resolve_ruby,
|
|
83
|
+
)
|
|
84
|
+
from seam.analysis.imports_ext import (
|
|
85
|
+
_resolve_swift as _ext_resolve_swift,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# P3 resolution helpers live in a companion leaf (split to keep this file under
|
|
89
|
+
# the 1000-line cap). Re-exported here so the public resolve_import_source path
|
|
90
|
+
# and the test suite can reach them via seam.analysis.imports.
|
|
91
|
+
from seam.analysis.imports_resolve import (
|
|
92
|
+
_load_go_module,
|
|
93
|
+
_resolve_ts_alias,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
logger = logging.getLogger(__name__)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── Data types ────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ImportMapping(TypedDict):
|
|
103
|
+
"""One import binding extracted from a source file.
|
|
104
|
+
|
|
105
|
+
Fields:
|
|
106
|
+
local_name: The name used in the referencing file (e.g. alias or original).
|
|
107
|
+
exported_name: The original name in the source module (== local_name if no alias).
|
|
108
|
+
source_module: The import source as written (e.g. 'app.parser', './utils').
|
|
109
|
+
is_default: True for Python 'import X' or TS 'import X from ...' (default).
|
|
110
|
+
is_namespace: True for TS 'import * as ns from ...' or similar namespace import.
|
|
111
|
+
is_wildcard: True for 'from x import *' or 'use x::*' — no specific binding.
|
|
112
|
+
line: 1-based source line number of the import statement.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
local_name: str
|
|
116
|
+
exported_name: str
|
|
117
|
+
source_module: str
|
|
118
|
+
is_default: bool
|
|
119
|
+
is_namespace: bool
|
|
120
|
+
is_wildcard: bool
|
|
121
|
+
line: int
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── Per-language extension resolution orders ──────────────────────────────────
|
|
125
|
+
# Each entry is tried in order. Strings starting with '/' are appended as
|
|
126
|
+
# a directory/filename suffix (e.g. '/__init__.py') rather than replacing the ext.
|
|
127
|
+
|
|
128
|
+
_PY_EXTENSIONS = [".py", "/__init__.py"]
|
|
129
|
+
_TS_EXTENSIONS = [".ts", ".tsx", ".d.ts", ".js"]
|
|
130
|
+
_JS_EXTENSIONS = [".js", ".mjs", ".cjs", "/index.js"]
|
|
131
|
+
_RS_EXTENSIONS = [".rs", "/mod.rs"]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── Python import extraction ──────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _extract_python(root: object, filepath: Path) -> list[ImportMapping]:
|
|
138
|
+
"""Extract ImportMapping records from a Python AST root node.
|
|
139
|
+
|
|
140
|
+
Handles:
|
|
141
|
+
- import X
|
|
142
|
+
- import X as Y
|
|
143
|
+
- from X import Y
|
|
144
|
+
- from X import Y as Z
|
|
145
|
+
- from X import *
|
|
146
|
+
- from . import x (relative)
|
|
147
|
+
- from .mod import x (relative)
|
|
148
|
+
Never raises.
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
from tree_sitter import Node
|
|
152
|
+
|
|
153
|
+
if not isinstance(root, Node):
|
|
154
|
+
return []
|
|
155
|
+
mappings: list[ImportMapping] = []
|
|
156
|
+
_walk_python(root, filepath, mappings)
|
|
157
|
+
return mappings
|
|
158
|
+
except Exception as exc: # noqa: BLE001
|
|
159
|
+
# Log at debug so failures are traceable without spamming production output.
|
|
160
|
+
logger.debug("_extract_python: extraction failed for %s: %r", filepath, exc)
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _text(node: object) -> str:
|
|
165
|
+
"""Extract UTF-8 text from a tree-sitter Node."""
|
|
166
|
+
try:
|
|
167
|
+
from tree_sitter import Node as TSNode
|
|
168
|
+
|
|
169
|
+
if isinstance(node, TSNode) and node.text is not None:
|
|
170
|
+
return node.text.decode("utf-8", errors="replace")
|
|
171
|
+
except Exception: # noqa: BLE001
|
|
172
|
+
pass
|
|
173
|
+
return ""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _walk_python(node: object, filepath: Path, mappings: list[ImportMapping]) -> None:
|
|
177
|
+
"""Recursive AST walker for Python import extraction."""
|
|
178
|
+
from tree_sitter import Node
|
|
179
|
+
|
|
180
|
+
if not isinstance(node, Node):
|
|
181
|
+
return
|
|
182
|
+
line = node.start_point[0] + 1
|
|
183
|
+
|
|
184
|
+
if node.type == "import_statement":
|
|
185
|
+
# import X / import X as Y / import X, Y
|
|
186
|
+
for child in node.children:
|
|
187
|
+
if child.type == "dotted_name":
|
|
188
|
+
name = _text(child)
|
|
189
|
+
mappings.append(
|
|
190
|
+
ImportMapping(
|
|
191
|
+
local_name=name,
|
|
192
|
+
exported_name=name,
|
|
193
|
+
source_module=name,
|
|
194
|
+
is_default=True,
|
|
195
|
+
is_namespace=False,
|
|
196
|
+
is_wildcard=False,
|
|
197
|
+
line=line,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
elif child.type == "aliased_import":
|
|
201
|
+
name_node = child.child_by_field_name("name")
|
|
202
|
+
alias_node = child.child_by_field_name("alias")
|
|
203
|
+
if name_node:
|
|
204
|
+
name = _text(name_node)
|
|
205
|
+
alias = _text(alias_node) if alias_node else name
|
|
206
|
+
mappings.append(
|
|
207
|
+
ImportMapping(
|
|
208
|
+
local_name=alias,
|
|
209
|
+
exported_name=name,
|
|
210
|
+
source_module=name,
|
|
211
|
+
is_default=True,
|
|
212
|
+
is_namespace=False,
|
|
213
|
+
is_wildcard=False,
|
|
214
|
+
line=line,
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
elif node.type == "import_from_statement":
|
|
219
|
+
# from X import Y / from X import Y as Z / from X import *
|
|
220
|
+
# Also handles relative: from . import x, from .mod import x
|
|
221
|
+
source = _python_from_source(node)
|
|
222
|
+
found_import_kw = False
|
|
223
|
+
for child in node.children:
|
|
224
|
+
if child.type == "import":
|
|
225
|
+
found_import_kw = True
|
|
226
|
+
continue
|
|
227
|
+
if not found_import_kw:
|
|
228
|
+
continue
|
|
229
|
+
if child.type == "wildcard_import":
|
|
230
|
+
# Wildcard import: extracted so the module source is recorded,
|
|
231
|
+
# but is_wildcard=True causes confidence.py to skip promotion
|
|
232
|
+
# because no specific exported name is bound.
|
|
233
|
+
mappings.append(
|
|
234
|
+
ImportMapping(
|
|
235
|
+
local_name="*",
|
|
236
|
+
exported_name="*",
|
|
237
|
+
source_module=source,
|
|
238
|
+
is_default=False,
|
|
239
|
+
is_namespace=False,
|
|
240
|
+
is_wildcard=True,
|
|
241
|
+
line=line,
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
elif child.type in ("dotted_name", "identifier"):
|
|
245
|
+
name = _text(child)
|
|
246
|
+
mappings.append(
|
|
247
|
+
ImportMapping(
|
|
248
|
+
local_name=name,
|
|
249
|
+
exported_name=name,
|
|
250
|
+
source_module=source,
|
|
251
|
+
is_default=False,
|
|
252
|
+
is_namespace=False,
|
|
253
|
+
is_wildcard=False,
|
|
254
|
+
line=line,
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
elif child.type == "aliased_import":
|
|
258
|
+
name_node = child.child_by_field_name("name")
|
|
259
|
+
alias_node = child.child_by_field_name("alias")
|
|
260
|
+
if name_node:
|
|
261
|
+
name = _text(name_node)
|
|
262
|
+
alias = _text(alias_node) if alias_node else name
|
|
263
|
+
mappings.append(
|
|
264
|
+
ImportMapping(
|
|
265
|
+
local_name=alias,
|
|
266
|
+
exported_name=name,
|
|
267
|
+
source_module=source,
|
|
268
|
+
is_default=False,
|
|
269
|
+
is_namespace=False,
|
|
270
|
+
is_wildcard=False,
|
|
271
|
+
line=line,
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
else:
|
|
276
|
+
for child in node.children:
|
|
277
|
+
_walk_python(child, filepath, mappings)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _python_from_source(node: object) -> str:
|
|
281
|
+
"""Extract the source module from a Python import_from_statement node.
|
|
282
|
+
|
|
283
|
+
Handles: 'from app.mod import ...' → 'app.mod'
|
|
284
|
+
And relative: 'from . import ...' → '.' | 'from .mod import ...' → '.mod'
|
|
285
|
+
|
|
286
|
+
tree-sitter Python AST structure:
|
|
287
|
+
import_from_statement:
|
|
288
|
+
from
|
|
289
|
+
relative_import: ← present for relative imports
|
|
290
|
+
import_prefix: '.' | '..'
|
|
291
|
+
dotted_name: 'parser' ← optional
|
|
292
|
+
dotted_name: 'app.mod' ← present for absolute imports
|
|
293
|
+
import
|
|
294
|
+
...
|
|
295
|
+
"""
|
|
296
|
+
try:
|
|
297
|
+
from tree_sitter import Node
|
|
298
|
+
|
|
299
|
+
if not isinstance(node, Node):
|
|
300
|
+
return ""
|
|
301
|
+
dots = 0
|
|
302
|
+
module_part = ""
|
|
303
|
+
seen_from = False
|
|
304
|
+
for child in node.children:
|
|
305
|
+
if child.type == "from":
|
|
306
|
+
seen_from = True
|
|
307
|
+
continue
|
|
308
|
+
if child.type == "import":
|
|
309
|
+
break
|
|
310
|
+
if not seen_from:
|
|
311
|
+
continue
|
|
312
|
+
if child.type == "relative_import":
|
|
313
|
+
# Relative import: contains import_prefix (dots) + optional dotted_name
|
|
314
|
+
for sub in child.children:
|
|
315
|
+
if sub.type == "import_prefix":
|
|
316
|
+
dots = len(_text(sub))
|
|
317
|
+
elif sub.type == "dotted_name":
|
|
318
|
+
module_part = _text(sub)
|
|
319
|
+
elif child.type == "dotted_name":
|
|
320
|
+
# Absolute import: 'from app.parser import ...'
|
|
321
|
+
module_part = _text(child)
|
|
322
|
+
prefix = "." * dots
|
|
323
|
+
return f"{prefix}{module_part}" if module_part else prefix or ""
|
|
324
|
+
except Exception: # noqa: BLE001
|
|
325
|
+
return ""
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ── TypeScript / JavaScript import extraction ─────────────────────────────────
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _extract_typescript(root: object, filepath: Path) -> list[ImportMapping]:
|
|
332
|
+
"""Extract ImportMapping records from a TypeScript/JS AST root node.
|
|
333
|
+
|
|
334
|
+
Handles:
|
|
335
|
+
- import { foo } from './bar'
|
|
336
|
+
- import { x as y } from './bar'
|
|
337
|
+
- import Default from './bar' (default import)
|
|
338
|
+
- import * as ns from './bar' (namespace import)
|
|
339
|
+
- import './side' (side-effect only — no binding, skip)
|
|
340
|
+
Never raises.
|
|
341
|
+
"""
|
|
342
|
+
try:
|
|
343
|
+
from tree_sitter import Node
|
|
344
|
+
|
|
345
|
+
if not isinstance(root, Node):
|
|
346
|
+
return []
|
|
347
|
+
mappings: list[ImportMapping] = []
|
|
348
|
+
_walk_typescript(root, filepath, mappings)
|
|
349
|
+
return mappings
|
|
350
|
+
except Exception as exc: # noqa: BLE001
|
|
351
|
+
logger.debug("_extract_typescript: extraction failed for %s: %r", filepath, exc)
|
|
352
|
+
return []
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _walk_typescript(node: object, filepath: Path, mappings: list[ImportMapping]) -> None:
|
|
356
|
+
"""Recursive AST walker for TypeScript/JS import extraction."""
|
|
357
|
+
from tree_sitter import Node
|
|
358
|
+
|
|
359
|
+
if not isinstance(node, Node):
|
|
360
|
+
return
|
|
361
|
+
line = node.start_point[0] + 1
|
|
362
|
+
|
|
363
|
+
if node.type == "import_statement":
|
|
364
|
+
# Extract source module from the 'string' child
|
|
365
|
+
source = ""
|
|
366
|
+
for child in node.children:
|
|
367
|
+
if child.type == "string":
|
|
368
|
+
raw = _text(child)
|
|
369
|
+
source = raw.strip("'\"")
|
|
370
|
+
break
|
|
371
|
+
|
|
372
|
+
if not source:
|
|
373
|
+
# Side-effect-only import or unparseable → no bindings
|
|
374
|
+
for child in node.children:
|
|
375
|
+
_walk_typescript(child, filepath, mappings)
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
# Find the import_clause
|
|
379
|
+
for child in node.children:
|
|
380
|
+
if child.type == "import_clause":
|
|
381
|
+
_extract_ts_clause(child, source, line, mappings)
|
|
382
|
+
break
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
for child in node.children:
|
|
386
|
+
_walk_typescript(child, filepath, mappings)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _extract_ts_clause(
|
|
390
|
+
clause: object,
|
|
391
|
+
source: str,
|
|
392
|
+
line: int,
|
|
393
|
+
mappings: list[ImportMapping],
|
|
394
|
+
) -> None:
|
|
395
|
+
"""Extract bindings from a TypeScript import_clause node."""
|
|
396
|
+
from tree_sitter import Node
|
|
397
|
+
|
|
398
|
+
if not isinstance(clause, Node):
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
for child in clause.children:
|
|
402
|
+
if child.type == "identifier":
|
|
403
|
+
# Default import: import Foo from '...'
|
|
404
|
+
name = _text(child)
|
|
405
|
+
mappings.append(
|
|
406
|
+
ImportMapping(
|
|
407
|
+
local_name=name,
|
|
408
|
+
exported_name=name,
|
|
409
|
+
source_module=source,
|
|
410
|
+
is_default=True,
|
|
411
|
+
is_namespace=False,
|
|
412
|
+
is_wildcard=False,
|
|
413
|
+
line=line,
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
elif child.type == "namespace_import":
|
|
418
|
+
# import * as ns from '...'
|
|
419
|
+
for sub in child.children:
|
|
420
|
+
if sub.type == "identifier":
|
|
421
|
+
alias = _text(sub)
|
|
422
|
+
mappings.append(
|
|
423
|
+
ImportMapping(
|
|
424
|
+
local_name=alias,
|
|
425
|
+
exported_name="*",
|
|
426
|
+
source_module=source,
|
|
427
|
+
is_default=False,
|
|
428
|
+
is_namespace=True,
|
|
429
|
+
is_wildcard=False,
|
|
430
|
+
line=line,
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
break
|
|
434
|
+
|
|
435
|
+
elif child.type == "named_imports":
|
|
436
|
+
# import { foo, bar as b } from '...'
|
|
437
|
+
for spec in child.children:
|
|
438
|
+
if spec.type == "import_specifier":
|
|
439
|
+
# local_name is the alias used inside this file (call-site match);
|
|
440
|
+
# exported_name is the original name from the declaring module
|
|
441
|
+
# (used by confidence.py to look up the symbol in the index).
|
|
442
|
+
name_node = spec.child_by_field_name("name")
|
|
443
|
+
alias_node = spec.child_by_field_name("alias")
|
|
444
|
+
if name_node is None and spec.children:
|
|
445
|
+
name_node = spec.children[0]
|
|
446
|
+
if name_node:
|
|
447
|
+
orig_name = _text(name_node)
|
|
448
|
+
local = _text(alias_node) if alias_node else orig_name
|
|
449
|
+
mappings.append(
|
|
450
|
+
ImportMapping(
|
|
451
|
+
local_name=local,
|
|
452
|
+
exported_name=orig_name,
|
|
453
|
+
source_module=source,
|
|
454
|
+
is_default=False,
|
|
455
|
+
is_namespace=False,
|
|
456
|
+
is_wildcard=False,
|
|
457
|
+
line=line,
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ── Go import extraction ──────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _extract_go(root: object, filepath: Path) -> list[ImportMapping]:
|
|
466
|
+
"""Extract ImportMapping records from a Go AST root node.
|
|
467
|
+
|
|
468
|
+
Handles:
|
|
469
|
+
- import "module/pkg" → local_name = last segment of path
|
|
470
|
+
- import p "module/pkg" → local_name = p
|
|
471
|
+
- Grouped import blocks
|
|
472
|
+
Never raises.
|
|
473
|
+
"""
|
|
474
|
+
try:
|
|
475
|
+
from tree_sitter import Node
|
|
476
|
+
|
|
477
|
+
if not isinstance(root, Node):
|
|
478
|
+
return []
|
|
479
|
+
mappings: list[ImportMapping] = []
|
|
480
|
+
_walk_go(root, filepath, mappings)
|
|
481
|
+
return mappings
|
|
482
|
+
except Exception as exc: # noqa: BLE001
|
|
483
|
+
logger.debug("_extract_go: extraction failed for %s: %r", filepath, exc)
|
|
484
|
+
return []
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _walk_go(node: object, filepath: Path, mappings: list[ImportMapping]) -> None:
|
|
488
|
+
"""Recursive AST walker for Go import extraction."""
|
|
489
|
+
from tree_sitter import Node
|
|
490
|
+
|
|
491
|
+
if not isinstance(node, Node):
|
|
492
|
+
return
|
|
493
|
+
line = node.start_point[0] + 1
|
|
494
|
+
|
|
495
|
+
if node.type == "import_declaration":
|
|
496
|
+
for child in node.children:
|
|
497
|
+
if child.type == "import_spec_list":
|
|
498
|
+
for spec in child.children:
|
|
499
|
+
if spec.type == "import_spec":
|
|
500
|
+
_extract_go_spec(spec, line, mappings)
|
|
501
|
+
elif child.type == "import_spec":
|
|
502
|
+
_extract_go_spec(child, line, mappings)
|
|
503
|
+
return
|
|
504
|
+
|
|
505
|
+
for child in node.children:
|
|
506
|
+
_walk_go(child, filepath, mappings)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _extract_go_spec(spec: object, line: int, mappings: list[ImportMapping]) -> None:
|
|
510
|
+
"""Extract one Go import_spec into an ImportMapping."""
|
|
511
|
+
from tree_sitter import Node
|
|
512
|
+
|
|
513
|
+
if not isinstance(spec, Node):
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
path_node = spec.child_by_field_name("path")
|
|
517
|
+
name_node = spec.child_by_field_name("name")
|
|
518
|
+
|
|
519
|
+
if path_node is None:
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
raw_path = _text(path_node).strip('"') # e.g. "fmt" or "github.com/user/pkg"
|
|
523
|
+
# Go convention: local name is the last path segment (package name)
|
|
524
|
+
last_segment = raw_path.rsplit("/", 1)[-1]
|
|
525
|
+
|
|
526
|
+
if name_node:
|
|
527
|
+
# Explicit alias: import p "path"
|
|
528
|
+
alias = _text(name_node)
|
|
529
|
+
if alias == "_":
|
|
530
|
+
return # blank import — no binding
|
|
531
|
+
local = alias
|
|
532
|
+
else:
|
|
533
|
+
local = last_segment
|
|
534
|
+
|
|
535
|
+
mappings.append(
|
|
536
|
+
ImportMapping(
|
|
537
|
+
local_name=local,
|
|
538
|
+
exported_name=last_segment,
|
|
539
|
+
source_module=raw_path,
|
|
540
|
+
is_default=False,
|
|
541
|
+
is_namespace=False,
|
|
542
|
+
is_wildcard=False,
|
|
543
|
+
line=line,
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
# ── Rust use statement extraction ─────────────────────────────────────────────
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _extract_rust(root: object, filepath: Path) -> list[ImportMapping]:
|
|
552
|
+
"""Extract ImportMapping records from a Rust AST root node.
|
|
553
|
+
|
|
554
|
+
Handles:
|
|
555
|
+
- use crate::mod::Thing;
|
|
556
|
+
- use super::x;
|
|
557
|
+
- use a::b::{c, d};
|
|
558
|
+
- use x::* (wildcard)
|
|
559
|
+
- use a::b as c (alias)
|
|
560
|
+
Never raises.
|
|
561
|
+
"""
|
|
562
|
+
try:
|
|
563
|
+
from tree_sitter import Node
|
|
564
|
+
|
|
565
|
+
if not isinstance(root, Node):
|
|
566
|
+
return []
|
|
567
|
+
mappings: list[ImportMapping] = []
|
|
568
|
+
_walk_rust(root, filepath, mappings)
|
|
569
|
+
return mappings
|
|
570
|
+
except Exception as exc: # noqa: BLE001
|
|
571
|
+
logger.debug("_extract_rust: extraction failed for %s: %r", filepath, exc)
|
|
572
|
+
return []
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _walk_rust(node: object, filepath: Path, mappings: list[ImportMapping]) -> None:
|
|
576
|
+
"""Recursive AST walker for Rust use-declaration extraction."""
|
|
577
|
+
from tree_sitter import Node
|
|
578
|
+
|
|
579
|
+
if not isinstance(node, Node):
|
|
580
|
+
return
|
|
581
|
+
line = node.start_point[0] + 1
|
|
582
|
+
|
|
583
|
+
if node.type == "use_declaration":
|
|
584
|
+
# Recurse into the use tree
|
|
585
|
+
for child in node.children:
|
|
586
|
+
if child.type != "use":
|
|
587
|
+
_extract_rust_use_tree(child, "", line, mappings)
|
|
588
|
+
return
|
|
589
|
+
|
|
590
|
+
for child in node.children:
|
|
591
|
+
_walk_rust(child, filepath, mappings)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _extract_rust_use_tree(
|
|
595
|
+
node: object,
|
|
596
|
+
prefix: str,
|
|
597
|
+
line: int,
|
|
598
|
+
mappings: list[ImportMapping],
|
|
599
|
+
) -> None:
|
|
600
|
+
"""Recursively expand a Rust use_tree into ImportMapping records."""
|
|
601
|
+
from tree_sitter import Node
|
|
602
|
+
|
|
603
|
+
if not isinstance(node, Node):
|
|
604
|
+
return
|
|
605
|
+
|
|
606
|
+
if node.type == "use_wildcard":
|
|
607
|
+
# use path::*
|
|
608
|
+
source = prefix.rstrip("::")
|
|
609
|
+
mappings.append(
|
|
610
|
+
ImportMapping(
|
|
611
|
+
local_name="*",
|
|
612
|
+
exported_name="*",
|
|
613
|
+
source_module=source,
|
|
614
|
+
is_default=False,
|
|
615
|
+
is_namespace=False,
|
|
616
|
+
is_wildcard=True,
|
|
617
|
+
line=line,
|
|
618
|
+
)
|
|
619
|
+
)
|
|
620
|
+
return
|
|
621
|
+
|
|
622
|
+
if node.type == "use_as_clause":
|
|
623
|
+
# use a::b as c
|
|
624
|
+
path_node = node.child_by_field_name("path")
|
|
625
|
+
alias_node = node.child_by_field_name("alias")
|
|
626
|
+
if path_node and alias_node:
|
|
627
|
+
path = _text(path_node)
|
|
628
|
+
alias = _text(alias_node)
|
|
629
|
+
source = f"{prefix.rstrip('::')}::{path}" if prefix else path
|
|
630
|
+
# Last segment of path is the exported name
|
|
631
|
+
exported = path.rsplit("::", 1)[-1]
|
|
632
|
+
mappings.append(
|
|
633
|
+
ImportMapping(
|
|
634
|
+
local_name=alias,
|
|
635
|
+
exported_name=exported,
|
|
636
|
+
source_module=source.replace(f"::{exported}", "") or source,
|
|
637
|
+
is_default=False,
|
|
638
|
+
is_namespace=False,
|
|
639
|
+
is_wildcard=False,
|
|
640
|
+
line=line,
|
|
641
|
+
)
|
|
642
|
+
)
|
|
643
|
+
return
|
|
644
|
+
|
|
645
|
+
if node.type == "use_list":
|
|
646
|
+
# use a::{b, c, d}
|
|
647
|
+
for child in node.children:
|
|
648
|
+
_extract_rust_use_tree(child, prefix, line, mappings)
|
|
649
|
+
return
|
|
650
|
+
|
|
651
|
+
if node.type == "scoped_use_list":
|
|
652
|
+
# use a::b::{c, d} — has a 'path' child and a 'list' child
|
|
653
|
+
path_node = node.child_by_field_name("path")
|
|
654
|
+
list_node = node.child_by_field_name("list")
|
|
655
|
+
new_prefix = ""
|
|
656
|
+
if path_node:
|
|
657
|
+
path_text = _text(path_node)
|
|
658
|
+
new_prefix = f"{prefix}{path_text}::" if prefix else f"{path_text}::"
|
|
659
|
+
if list_node:
|
|
660
|
+
_extract_rust_use_tree(list_node, new_prefix, line, mappings)
|
|
661
|
+
return
|
|
662
|
+
|
|
663
|
+
if node.type in ("use_tree", "identifier", "scoped_identifier"):
|
|
664
|
+
# Simple use item or fully qualified path
|
|
665
|
+
text = _text(node)
|
|
666
|
+
if not text or text in (";", "{", "}", ",", "use"):
|
|
667
|
+
return
|
|
668
|
+
# Last segment is the local binding name
|
|
669
|
+
local = text.rsplit("::", 1)[-1]
|
|
670
|
+
source = f"{prefix.rstrip('::')}" if prefix else text
|
|
671
|
+
if prefix:
|
|
672
|
+
full = f"{prefix}{text}"
|
|
673
|
+
source = full.rsplit("::", 1)[0] if "::" in full else prefix
|
|
674
|
+
mappings.append(
|
|
675
|
+
ImportMapping(
|
|
676
|
+
local_name=local,
|
|
677
|
+
exported_name=local,
|
|
678
|
+
source_module=source,
|
|
679
|
+
is_default=False,
|
|
680
|
+
is_namespace=False,
|
|
681
|
+
is_wildcard=False,
|
|
682
|
+
line=line,
|
|
683
|
+
)
|
|
684
|
+
)
|
|
685
|
+
return
|
|
686
|
+
|
|
687
|
+
# Recurse into child nodes for composite structures
|
|
688
|
+
for child in node.children:
|
|
689
|
+
_extract_rust_use_tree(child, prefix, line, mappings)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
# ── Public: extract_import_mappings ──────────────────────────────────────────
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def extract_import_mappings(
|
|
696
|
+
root: object,
|
|
697
|
+
filepath: Path,
|
|
698
|
+
language: str,
|
|
699
|
+
) -> list[ImportMapping]:
|
|
700
|
+
"""Extract all import/use mappings from an AST root node.
|
|
701
|
+
|
|
702
|
+
Per-language dispatch: Python, TypeScript, JavaScript, Go, Rust.
|
|
703
|
+
Never raises — returns [] on any failure.
|
|
704
|
+
|
|
705
|
+
Args:
|
|
706
|
+
root: tree-sitter root Node from parser.parse_*(path).
|
|
707
|
+
filepath: Absolute path to the source file being parsed.
|
|
708
|
+
language: Language identifier (must match SEAM_LANGUAGE_MAP values).
|
|
709
|
+
|
|
710
|
+
Returns:
|
|
711
|
+
List of ImportMapping TypedDicts. Empty list on parse error or no imports.
|
|
712
|
+
"""
|
|
713
|
+
if root is None:
|
|
714
|
+
return []
|
|
715
|
+
try:
|
|
716
|
+
if language == "python":
|
|
717
|
+
return _extract_python(root, filepath)
|
|
718
|
+
if language in ("typescript", "javascript"):
|
|
719
|
+
return _extract_typescript(root, filepath)
|
|
720
|
+
if language == "go":
|
|
721
|
+
return _extract_go(root, filepath)
|
|
722
|
+
if language == "rust":
|
|
723
|
+
return _extract_rust(root, filepath)
|
|
724
|
+
# Phase 9 — new languages routed to imports_ext (stubs return [])
|
|
725
|
+
if language == "java":
|
|
726
|
+
return _ext_extract_java(root, filepath)
|
|
727
|
+
if language == "csharp":
|
|
728
|
+
return _ext_extract_csharp(root, filepath)
|
|
729
|
+
if language == "ruby":
|
|
730
|
+
return _ext_extract_ruby(root, filepath)
|
|
731
|
+
if language == "c":
|
|
732
|
+
return _ext_extract_c(root, filepath)
|
|
733
|
+
if language == "cpp":
|
|
734
|
+
return _ext_extract_cpp(root, filepath)
|
|
735
|
+
if language == "php":
|
|
736
|
+
return _ext_extract_php(root, filepath)
|
|
737
|
+
# Phase 10 — Swift
|
|
738
|
+
if language == "swift":
|
|
739
|
+
return _ext_extract_swift(root, filepath)
|
|
740
|
+
except Exception: # noqa: BLE001
|
|
741
|
+
pass
|
|
742
|
+
return []
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
# ── Public: resolve_import_source ─────────────────────────────────────────────
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def resolve_import_source(
|
|
749
|
+
source_module: str,
|
|
750
|
+
referencing_file: Path,
|
|
751
|
+
repo_root: Path,
|
|
752
|
+
language: str,
|
|
753
|
+
) -> list[str]:
|
|
754
|
+
"""Map an import source string to candidate absolute file paths.
|
|
755
|
+
|
|
756
|
+
Tries per-language extension resolution order against the file system.
|
|
757
|
+
Relative sources (starting with '.') resolve from the referencing file's dir.
|
|
758
|
+
Absolute/dotted module paths resolve from repo_root using dot→slash conversion.
|
|
759
|
+
Third-party sources that don't map to an indexed file return [].
|
|
760
|
+
|
|
761
|
+
Out of scope: tsconfig aliases, Go module prefix stripping, barrel chasing.
|
|
762
|
+
|
|
763
|
+
Args:
|
|
764
|
+
source_module: Import source as written (e.g. './parser', 'app.parser').
|
|
765
|
+
referencing_file: Absolute path of the importing file (for relative resolution).
|
|
766
|
+
repo_root: Repository root (for absolute module resolution).
|
|
767
|
+
language: Language identifier.
|
|
768
|
+
|
|
769
|
+
Returns:
|
|
770
|
+
List of existing absolute file path strings (may be empty).
|
|
771
|
+
Never raises.
|
|
772
|
+
"""
|
|
773
|
+
if not source_module:
|
|
774
|
+
return []
|
|
775
|
+
try:
|
|
776
|
+
if language == "python":
|
|
777
|
+
return _resolve_python(source_module, referencing_file, repo_root)
|
|
778
|
+
if language in ("typescript", "javascript"):
|
|
779
|
+
exts = _TS_EXTENSIONS if language == "typescript" else _JS_EXTENSIONS
|
|
780
|
+
# P3: try tsconfig/jsconfig path-alias expansion (e.g. '@/foo') BEFORE
|
|
781
|
+
# the third-party fallback. Relative imports skip this (handled below).
|
|
782
|
+
if not (source_module.startswith("./") or source_module.startswith("../")):
|
|
783
|
+
alias_hits = _resolve_ts_alias(
|
|
784
|
+
source_module, repo_root, exts, _probe_extensions
|
|
785
|
+
)
|
|
786
|
+
if alias_hits:
|
|
787
|
+
return alias_hits
|
|
788
|
+
return _resolve_relative_or_dotted(source_module, referencing_file, repo_root, exts)
|
|
789
|
+
if language == "go":
|
|
790
|
+
return _resolve_go(source_module, referencing_file, repo_root)
|
|
791
|
+
if language == "rust":
|
|
792
|
+
return _resolve_rust(source_module, referencing_file, repo_root)
|
|
793
|
+
# Phase 9 — new languages routed to imports_ext (stubs/out-of-scope return [])
|
|
794
|
+
if language == "java":
|
|
795
|
+
return _ext_resolve_java(source_module, referencing_file, repo_root)
|
|
796
|
+
if language == "csharp":
|
|
797
|
+
return _ext_resolve_csharp(source_module, referencing_file, repo_root)
|
|
798
|
+
if language == "ruby":
|
|
799
|
+
return _ext_resolve_ruby(source_module, referencing_file, repo_root)
|
|
800
|
+
if language == "c":
|
|
801
|
+
return _ext_resolve_c(source_module, referencing_file, repo_root)
|
|
802
|
+
if language == "cpp":
|
|
803
|
+
return _ext_resolve_cpp(source_module, referencing_file, repo_root)
|
|
804
|
+
if language == "php":
|
|
805
|
+
return _ext_resolve_php(source_module, referencing_file, repo_root)
|
|
806
|
+
# Phase 10 — Swift
|
|
807
|
+
if language == "swift":
|
|
808
|
+
return _ext_resolve_swift(source_module, referencing_file, repo_root)
|
|
809
|
+
except Exception: # noqa: BLE001
|
|
810
|
+
pass
|
|
811
|
+
return []
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _probe_extensions(base: Path, extensions: list[str]) -> list[str]:
|
|
815
|
+
"""Return existing file paths by appending each extension to base.
|
|
816
|
+
|
|
817
|
+
Shared probe loop used by Python, TS/JS, and Rust resolvers so that the
|
|
818
|
+
per-language extension lists (_PY_EXTENSIONS etc.) are the single source of
|
|
819
|
+
resolution order — each resolver calls this instead of duplicating the loop.
|
|
820
|
+
|
|
821
|
+
Args:
|
|
822
|
+
base: Candidate path stem (no extension) as a Path object.
|
|
823
|
+
extensions: Ordered list of extension strings to try. Strings starting
|
|
824
|
+
with '/' are directory suffixes (e.g. '/__init__.py' for packages);
|
|
825
|
+
strings starting with '.' are regular extensions.
|
|
826
|
+
"""
|
|
827
|
+
results: list[str] = []
|
|
828
|
+
for ext in extensions:
|
|
829
|
+
# String concatenation (not Path /) because '/__init__.py' must append
|
|
830
|
+
# as-is; Path would normalise the leading slash away.
|
|
831
|
+
candidate = Path(str(base) + ext)
|
|
832
|
+
if candidate.exists():
|
|
833
|
+
results.append(str(candidate))
|
|
834
|
+
return results
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _resolve_python(
|
|
838
|
+
source_module: str,
|
|
839
|
+
referencing_file: Path,
|
|
840
|
+
repo_root: Path,
|
|
841
|
+
) -> list[str]:
|
|
842
|
+
"""Resolve Python module source to file paths using _PY_EXTENSIONS."""
|
|
843
|
+
is_relative = source_module.startswith(".")
|
|
844
|
+
|
|
845
|
+
if is_relative:
|
|
846
|
+
# Count leading dots for relative level
|
|
847
|
+
level = len(source_module) - len(source_module.lstrip("."))
|
|
848
|
+
module_path = source_module.lstrip(".")
|
|
849
|
+
base = referencing_file.parent
|
|
850
|
+
for _ in range(level - 1):
|
|
851
|
+
base = base.parent
|
|
852
|
+
if module_path:
|
|
853
|
+
candidate_base = base / module_path.replace(".", "/")
|
|
854
|
+
else:
|
|
855
|
+
candidate_base = base
|
|
856
|
+
else:
|
|
857
|
+
# Absolute: convert dots to path separators, resolve from repo_root
|
|
858
|
+
candidate_base = repo_root / source_module.replace(".", "/")
|
|
859
|
+
|
|
860
|
+
return _probe_extensions(candidate_base, _PY_EXTENSIONS)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _resolve_relative_or_dotted(
|
|
864
|
+
source_module: str,
|
|
865
|
+
referencing_file: Path,
|
|
866
|
+
repo_root: Path,
|
|
867
|
+
extensions: list[str],
|
|
868
|
+
) -> list[str]:
|
|
869
|
+
"""Resolve TypeScript/JavaScript import source to file paths.
|
|
870
|
+
|
|
871
|
+
Handles:
|
|
872
|
+
- Relative paths starting with ./ or ../
|
|
873
|
+
- Absolute-ish paths starting without . (treated as third-party → [])
|
|
874
|
+
"""
|
|
875
|
+
if not (source_module.startswith("./") or source_module.startswith("../")):
|
|
876
|
+
# Not a relative import → treat as third-party/bare specifier → []
|
|
877
|
+
return []
|
|
878
|
+
|
|
879
|
+
base = referencing_file.parent / source_module
|
|
880
|
+
# os.path.normpath resolves ./ and ../ lexically without following symlinks,
|
|
881
|
+
# so candidate paths match exactly how the indexer stores them (str(filepath)
|
|
882
|
+
# after repo_root is resolved but individual files are NOT symlink-expanded).
|
|
883
|
+
# Path.resolve() would expand /tmp → /private/tmp on macOS, breaking the
|
|
884
|
+
# declaring-file lookup for any repo checked out under a symlinked prefix.
|
|
885
|
+
try:
|
|
886
|
+
base = Path(os.path.normpath(base))
|
|
887
|
+
except Exception: # noqa: BLE001
|
|
888
|
+
return []
|
|
889
|
+
|
|
890
|
+
return _probe_extensions(base, extensions)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _resolve_go(
|
|
894
|
+
source_module: str,
|
|
895
|
+
referencing_file: Path,
|
|
896
|
+
repo_root: Path,
|
|
897
|
+
max_candidates: int = 25,
|
|
898
|
+
) -> list[str]:
|
|
899
|
+
"""Resolve a Go import path to a directory of .go files.
|
|
900
|
+
|
|
901
|
+
Go imports use directory-based packages. We look for the import path as a
|
|
902
|
+
subdirectory under repo_root (same-repo relative reference).
|
|
903
|
+
|
|
904
|
+
P3: cross-package module-qualified imports (e.g. 'github.com/org/repo/pkg')
|
|
905
|
+
now resolve. `_load_go_module` reads the `module <path>` line from go.mod
|
|
906
|
+
once per repo; when the import starts with that module prefix, the prefix is
|
|
907
|
+
stripped to a repo-relative directory ('pkg') and resolved normally. Imports
|
|
908
|
+
OUTSIDE the module prefix (third-party deps) still correctly return [].
|
|
909
|
+
"""
|
|
910
|
+
# P3: strip the go.mod module prefix from a cross-package import so the
|
|
911
|
+
# remaining path is repo-relative. External deps keep their full path → [].
|
|
912
|
+
effective = source_module
|
|
913
|
+
module_prefix = _load_go_module(repo_root)
|
|
914
|
+
if module_prefix and source_module.startswith(module_prefix):
|
|
915
|
+
stripped = source_module[len(module_prefix) :].lstrip("/")
|
|
916
|
+
# Bare 'module' (root package) → no subdir; treat as repo_root itself.
|
|
917
|
+
effective = stripped if stripped else "."
|
|
918
|
+
|
|
919
|
+
# Treat the import path as a directory relative to repo_root.
|
|
920
|
+
# Paths not under the module prefix silently return [] because is_dir()
|
|
921
|
+
# will be False (the full third-party path has no matching local dir).
|
|
922
|
+
candidate_dir = repo_root / effective
|
|
923
|
+
if candidate_dir.is_dir():
|
|
924
|
+
go_files = list(candidate_dir.glob("*.go"))
|
|
925
|
+
return [str(f) for f in go_files[:max_candidates]]
|
|
926
|
+
return []
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _resolve_rust(
|
|
930
|
+
source_module: str,
|
|
931
|
+
referencing_file: Path,
|
|
932
|
+
repo_root: Path,
|
|
933
|
+
) -> list[str]:
|
|
934
|
+
"""Resolve a Rust use path to source file paths.
|
|
935
|
+
|
|
936
|
+
Handles:
|
|
937
|
+
- crate:: → relative to src/ directory
|
|
938
|
+
- super:: → relative to parent directory
|
|
939
|
+
- Other paths → try repo_root-relative resolution
|
|
940
|
+
|
|
941
|
+
Extension order: ['.rs', '/mod.rs']
|
|
942
|
+
"""
|
|
943
|
+
if source_module.startswith("crate::"):
|
|
944
|
+
# Resolve relative to repo_root/src/
|
|
945
|
+
parts = source_module[len("crate::") :].replace("::", "/")
|
|
946
|
+
base = repo_root / "src" / parts
|
|
947
|
+
elif source_module.startswith("super::"):
|
|
948
|
+
parts = source_module[len("super::") :].replace("::", "/")
|
|
949
|
+
base = referencing_file.parent.parent / parts
|
|
950
|
+
elif source_module.startswith("self::"):
|
|
951
|
+
parts = source_module[len("self::") :].replace("::", "/")
|
|
952
|
+
base = referencing_file.parent / parts
|
|
953
|
+
else:
|
|
954
|
+
# Try as a path from repo_root
|
|
955
|
+
parts = source_module.replace("::", "/")
|
|
956
|
+
base = repo_root / parts
|
|
957
|
+
|
|
958
|
+
return _probe_extensions(base, _RS_EXTENSIONS)
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
# ── Public: compute_path_proximity ────────────────────────────────────────────
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def compute_path_proximity(referencing_file: Path, candidate_file: Path) -> int:
|
|
965
|
+
"""Return shared path segment count between two files' parent directories.
|
|
966
|
+
|
|
967
|
+
Higher score = more shared directory ancestry = closer proximity.
|
|
968
|
+
Pure function: no I/O, no side effects, never raises.
|
|
969
|
+
|
|
970
|
+
Used for AMBIGUOUS edge tie-break (step D): when multiple files declare
|
|
971
|
+
the same symbol name, the one that shares the most directory ancestry
|
|
972
|
+
with the referencing file is the most likely intended target.
|
|
973
|
+
|
|
974
|
+
Args:
|
|
975
|
+
referencing_file: The file containing the reference (e.g. import/call).
|
|
976
|
+
candidate_file: A candidate declaring file for the referenced symbol.
|
|
977
|
+
|
|
978
|
+
Returns:
|
|
979
|
+
Number of shared parent directory path parts (>= 0).
|
|
980
|
+
Identical parent directories return the full parent depth.
|
|
981
|
+
"""
|
|
982
|
+
try:
|
|
983
|
+
ref_parts = referencing_file.parent.parts
|
|
984
|
+
cand_parts = candidate_file.parent.parts
|
|
985
|
+
# Count shared prefix segments
|
|
986
|
+
shared = 0
|
|
987
|
+
for a, b in zip(ref_parts, cand_parts):
|
|
988
|
+
if a == b:
|
|
989
|
+
shared += 1
|
|
990
|
+
else:
|
|
991
|
+
break
|
|
992
|
+
return shared
|
|
993
|
+
except Exception: # noqa: BLE001
|
|
994
|
+
return 0
|