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.
Files changed (109) hide show
  1. seam/__init__.py +3 -0
  2. seam/_data/schema.sql +225 -0
  3. seam/_web/assets/index-BL_tqprR.js +216 -0
  4. seam/_web/assets/index-GTKUhVyD.css +1 -0
  5. seam/_web/index.html +13 -0
  6. seam/analysis/__init__.py +14 -0
  7. seam/analysis/affected.py +254 -0
  8. seam/analysis/builtins.py +966 -0
  9. seam/analysis/byte_budget.py +217 -0
  10. seam/analysis/changes.py +709 -0
  11. seam/analysis/cluster_naming.py +260 -0
  12. seam/analysis/clustering.py +216 -0
  13. seam/analysis/confidence.py +699 -0
  14. seam/analysis/embeddings.py +195 -0
  15. seam/analysis/flows.py +708 -0
  16. seam/analysis/impact.py +444 -0
  17. seam/analysis/imports.py +994 -0
  18. seam/analysis/imports_ext.py +780 -0
  19. seam/analysis/imports_resolve.py +176 -0
  20. seam/analysis/processes.py +453 -0
  21. seam/analysis/relevance.py +155 -0
  22. seam/analysis/rwr.py +129 -0
  23. seam/analysis/staleness.py +328 -0
  24. seam/analysis/steer.py +282 -0
  25. seam/analysis/synthesis.py +253 -0
  26. seam/analysis/synthesis_channels.py +433 -0
  27. seam/analysis/testpaths.py +103 -0
  28. seam/analysis/traversal.py +470 -0
  29. seam/cli/__init__.py +0 -0
  30. seam/cli/install.py +232 -0
  31. seam/cli/main.py +2602 -0
  32. seam/cli/output.py +137 -0
  33. seam/cli/read.py +244 -0
  34. seam/cli/serve.py +145 -0
  35. seam/config.py +551 -0
  36. seam/indexer/__init__.py +0 -0
  37. seam/indexer/cluster_index.py +425 -0
  38. seam/indexer/db.py +496 -0
  39. seam/indexer/embedding_index.py +183 -0
  40. seam/indexer/field_access.py +536 -0
  41. seam/indexer/field_access_c_cpp.py +643 -0
  42. seam/indexer/field_access_ext.py +708 -0
  43. seam/indexer/field_access_ext2.py +408 -0
  44. seam/indexer/field_access_go_rust.py +737 -0
  45. seam/indexer/field_access_php_swift.py +888 -0
  46. seam/indexer/field_access_ts.py +626 -0
  47. seam/indexer/graph.py +321 -0
  48. seam/indexer/graph_c.py +562 -0
  49. seam/indexer/graph_c_cpp.py +39 -0
  50. seam/indexer/graph_common.py +644 -0
  51. seam/indexer/graph_cpp.py +615 -0
  52. seam/indexer/graph_csharp.py +651 -0
  53. seam/indexer/graph_go.py +723 -0
  54. seam/indexer/graph_go_rust.py +39 -0
  55. seam/indexer/graph_java.py +689 -0
  56. seam/indexer/graph_java_csharp.py +38 -0
  57. seam/indexer/graph_php.py +914 -0
  58. seam/indexer/graph_python.py +628 -0
  59. seam/indexer/graph_ruby.py +748 -0
  60. seam/indexer/graph_rust.py +653 -0
  61. seam/indexer/graph_scope_infer.py +902 -0
  62. seam/indexer/graph_scope_infer_ext.py +723 -0
  63. seam/indexer/graph_scope_infer_ext2.py +992 -0
  64. seam/indexer/graph_swift.py +1014 -0
  65. seam/indexer/graph_swift_infer.py +515 -0
  66. seam/indexer/graph_typescript.py +663 -0
  67. seam/indexer/migrations.py +816 -0
  68. seam/indexer/parser.py +204 -0
  69. seam/indexer/pipeline.py +197 -0
  70. seam/indexer/signatures.py +634 -0
  71. seam/indexer/signatures_ext.py +780 -0
  72. seam/indexer/sync.py +287 -0
  73. seam/indexer/synthesis_index.py +291 -0
  74. seam/indexer/tokenize.py +79 -0
  75. seam/installer/__init__.py +67 -0
  76. seam/installer/claude.py +97 -0
  77. seam/installer/codex.py +94 -0
  78. seam/installer/core.py +127 -0
  79. seam/installer/cursor.py +61 -0
  80. seam/installer/guide.py +110 -0
  81. seam/installer/jsonfile.py +85 -0
  82. seam/installer/markdownfile.py +146 -0
  83. seam/installer/tomlfile.py +72 -0
  84. seam/query/__init__.py +0 -0
  85. seam/query/clusters.py +206 -0
  86. seam/query/comments.py +217 -0
  87. seam/query/context.py +293 -0
  88. seam/query/engine.py +940 -0
  89. seam/query/fts.py +328 -0
  90. seam/query/names.py +470 -0
  91. seam/query/pack.py +433 -0
  92. seam/query/semantic.py +339 -0
  93. seam/query/structure.py +727 -0
  94. seam/server/__init__.py +0 -0
  95. seam/server/graph_api.py +437 -0
  96. seam/server/handler_common.py +323 -0
  97. seam/server/impact_handler.py +615 -0
  98. seam/server/mcp.py +556 -0
  99. seam/server/tools.py +697 -0
  100. seam/server/trace_handler.py +184 -0
  101. seam/server/web.py +922 -0
  102. seam/watcher/__init__.py +0 -0
  103. seam/watcher/__main__.py +56 -0
  104. seam/watcher/daemon.py +237 -0
  105. seam_code-0.3.0.dist-info/METADATA +318 -0
  106. seam_code-0.3.0.dist-info/RECORD +109 -0
  107. seam_code-0.3.0.dist-info/WHEEL +4 -0
  108. seam_code-0.3.0.dist-info/entry_points.txt +2 -0
  109. seam_code-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -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