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,699 @@
1
+ """Whole-index confidence resolver — single source of truth for the
2
+ EXTRACTED / AMBIGUOUS / INFERRED rule.
3
+
4
+ Phase 5 additions:
5
+ - Resolution TypedDict: confidence + resolved_by + best_candidate.
6
+ - resolve_edge(): full resolver with provenance, builtin filtering, import
7
+ promotion (step A), and proximity tie-break (step D).
8
+ - load_import_mappings(): load a file's import bindings from the DB.
9
+ - resolve() kept as a backward-compat thin shim.
10
+
11
+ Resolution rule (scope: whole index, evaluated at read time):
12
+ 1. Same-file import binds target to exactly one indexed file → EXTRACTED, 'import'.
13
+ 2. name count == 1 → EXTRACTED, 'name-unique'.
14
+ name count > 1 → AMBIGUOUS, 'name-collision' (+ proximity best_candidate).
15
+ 3. count == 0 AND is_builtin(name, lang) → INFERRED, 'builtin'.
16
+ count == 0 AND not builtin → INFERRED, 'unresolved'.
17
+
18
+ Design rationale:
19
+ Confidence is a property of *global* state (the full index), not a per-file
20
+ property. Resolving at read time means it is always fresh after any
21
+ incremental watcher re-index — no write-amplification, no staleness.
22
+ The stored edges.confidence column is a same-file lower-bound hint kept for
23
+ debugging; read-time resolution here is authoritative and overrides it.
24
+
25
+ Import rules (no circular deps):
26
+ This module imports ONLY stdlib + seam.analysis.builtins + seam.analysis.imports.
27
+ It must NOT import traversal.py, flows.py, or any other seam analysis module.
28
+ traversal.py and flows.py import their confidence constants FROM here.
29
+ builtins.py and imports.py are LEAVES — they import only stdlib.
30
+ """
31
+
32
+ import logging
33
+ import os
34
+ import sqlite3
35
+ from pathlib import Path
36
+ from typing import TypedDict
37
+
38
+ import seam.config as config
39
+ from seam.analysis.builtins import is_builtin
40
+ from seam.analysis.imports import (
41
+ ImportMapping,
42
+ compute_path_proximity,
43
+ resolve_import_source,
44
+ )
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ # ── Module-level "already warned" guard ──────────────────────────────────────
49
+ # Prevents the empty-import_mappings warning from firing on every hop of a hot traversal.
50
+ # Set to True on the first emission; never reset so it fires at most once per process.
51
+ _import_mappings_empty_warned: bool = False
52
+
53
+ # ── Canonical confidence constants ────────────────────────────────────────────
54
+ # These are the three possible values for the confidence field on edges and hops.
55
+ # All other seam modules (traversal, flows) import them from here.
56
+
57
+ CONFIDENCE_EXTRACTED = "EXTRACTED" # target name is unique in the full index
58
+ CONFIDENCE_AMBIGUOUS = "AMBIGUOUS" # target name matches >1 indexed symbol
59
+ CONFIDENCE_INFERRED = "INFERRED" # target not indexed (external, stdlib, dynamic)
60
+
61
+ # ── resolved_by vocabulary (Phase 5) ──────────────────────────────────────────
62
+ # Stable string enum — surfaced verbatim in MCP/CLI output.
63
+ # null/None for pre-v6 / unresolved-context rows (same null-contract as Phase 4 fields).
64
+ RESOLVED_BY_IMPORT = "import" # promoted via a resolved same-file import
65
+ RESOLVED_BY_NAME_UNIQUE = "name-unique" # name appears exactly once in the index
66
+ RESOLVED_BY_COLLISION = "name-collision" # name appears >1 times (homonym)
67
+ RESOLVED_BY_BUILTIN = "builtin" # name is a known language builtin (count==0)
68
+ RESOLVED_BY_UNRESOLVED = "unresolved" # count==0, not a builtin
69
+
70
+
71
+ class Resolution(TypedDict):
72
+ """Result of resolve_edge(): confidence tier + provenance + optional tie-break.
73
+
74
+ Fields:
75
+ confidence: EXTRACTED | AMBIGUOUS | INFERRED
76
+ resolved_by: how the tier was decided (see RESOLVED_BY_* constants above)
77
+ None for pre-v6 / unknown-context rows (null-contract)
78
+ best_candidate: for AMBIGUOUS edges, the most file-path-proximate declaring file
79
+ path (None when not applicable or no proximity data available)
80
+ """
81
+
82
+ confidence: str
83
+ resolved_by: str | None
84
+ best_candidate: str | None
85
+
86
+
87
+ # ── DB helper ─────────────────────────────────────────────────────────────────
88
+
89
+
90
+ def load_name_counts(conn: sqlite3.Connection) -> dict[str, int]:
91
+ """Load a name → count map from the symbols table in a single GROUP BY query.
92
+
93
+ This is the only DB call this module makes. Called once per query in
94
+ traversal.walk / flows.trace / flows.callers / flows.callees so that
95
+ confidence is resolved against the full index without per-edge round-trips.
96
+
97
+ Args:
98
+ conn: Open SQLite connection (read-only semantics; no writes).
99
+
100
+ Returns:
101
+ dict mapping every symbol name to its occurrence count across all files.
102
+ An empty dict when the symbols table has no rows.
103
+ """
104
+ rows = conn.execute("SELECT name, COUNT(*) AS cnt FROM symbols GROUP BY name").fetchall()
105
+ # Positional access works under any row_factory (and db.connect() sets sqlite3.Row).
106
+ result: dict[str, int] = {row[0]: row[1] for row in rows}
107
+ if not result:
108
+ # Empty map → EVERY edge resolves to INFERRED (the exact silent degradation
109
+ # issue #9 fixed). Surface it loudly: an empty symbols table almost always
110
+ # means the index was never built or is mid-rebuild, not that the code is
111
+ # genuinely all-external. Without this, the regression returns invisibly.
112
+ logger.warning(
113
+ "load_name_counts: symbols table is empty — all edge confidence will "
114
+ "resolve to INFERRED. Run 'seam init' to (re)build the index."
115
+ )
116
+ else:
117
+ logger.debug("load_name_counts: %d distinct symbol names loaded", len(result))
118
+ return result
119
+
120
+
121
+ def load_import_mappings(
122
+ conn: sqlite3.Connection,
123
+ file_path: str,
124
+ ) -> list[ImportMapping]:
125
+ """Load import mappings for a file from the import_mappings table.
126
+
127
+ Single DB query, mirrors load_name_counts() design pattern.
128
+ Returns [] if the table doesn't exist (pre-v6 DB) or the file is unknown.
129
+ Never raises.
130
+
131
+ Emits a once-per-process warning when the import_mappings table is empty while
132
+ symbols exist — almost always a stale pre-v6 index that needs 'seam init'.
133
+ Mirroring load_name_counts()'s empty-index warning pattern.
134
+
135
+ Args:
136
+ conn: Open SQLite connection (read-only semantics).
137
+ file_path: Absolute path of the referencing file (as stored in files.path).
138
+ """
139
+ global _import_mappings_empty_warned # noqa: PLW0603 — module-level once-warning guard
140
+ try:
141
+ rows = conn.execute(
142
+ """
143
+ SELECT im.local_name, im.exported_name, im.source_module,
144
+ im.is_default, im.is_namespace, im.is_wildcard, im.line
145
+ FROM import_mappings im
146
+ JOIN files f ON f.id = im.file_id
147
+ WHERE f.path = ?
148
+ """,
149
+ (file_path,),
150
+ ).fetchall()
151
+
152
+ # Guard: only warn when the TABLE is empty, not just this file (which may import nothing).
153
+ # The extra two SELECTs are cheap because they run at most once per process.
154
+ if not rows and not _import_mappings_empty_warned:
155
+ try:
156
+ any_mapping = conn.execute("SELECT 1 FROM import_mappings LIMIT 1").fetchone()
157
+ any_symbol = conn.execute("SELECT 1 FROM symbols LIMIT 1").fetchone()
158
+ if any_mapping is None and any_symbol is not None:
159
+ _import_mappings_empty_warned = True
160
+ logger.warning(
161
+ "load_import_mappings: import_mappings table is empty but symbols exist — "
162
+ "run 'seam init' to enable import promotion (Phase 5 resolution)."
163
+ )
164
+ except Exception: # noqa: BLE001
165
+ pass # pre-v6 DB — table absent, warning is moot
166
+
167
+ return [
168
+ ImportMapping(
169
+ local_name=row["local_name"],
170
+ exported_name=row["exported_name"],
171
+ source_module=row["source_module"],
172
+ is_default=bool(row["is_default"]),
173
+ is_namespace=bool(row["is_namespace"]),
174
+ is_wildcard=bool(row["is_wildcard"]),
175
+ line=row["line"],
176
+ )
177
+ for row in rows
178
+ ]
179
+ except Exception: # noqa: BLE001
180
+ # Table absent (pre-v6) or any other error → degrade gracefully
181
+ return []
182
+
183
+
184
+ # P4 — barrel directory-entry filenames. A directory import (`from './barrel'`)
185
+ # resolves to one of these inside that directory. TS/JS only — the languages
186
+ # where the barrel/index.* convention exists. Ordered by precedence.
187
+ _BARREL_INDEX_FILES = ("index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs")
188
+
189
+
190
+ def _resolve_barrel_source(
191
+ source_module: str,
192
+ referencing_file: Path,
193
+ repo_root: Path,
194
+ language: str,
195
+ max_import_candidates: int,
196
+ ) -> list[str]:
197
+ """Resolve an import source, adding TS/JS directory→index.* barrel probing.
198
+
199
+ `resolve_import_source` does not map a directory import (`./barrel`) to its
200
+ `index.ts` entry (the TS extension order has no `/index.*`). Barrels ARE
201
+ directory imports, so this P4-local helper falls back to probing for a
202
+ barrel index file inside the resolved directory when the normal resolution
203
+ returns nothing. Only applies to TS/JS; never raises.
204
+ """
205
+ hits = resolve_import_source(source_module, referencing_file, repo_root, language)[
206
+ :max_import_candidates
207
+ ]
208
+ if hits or language not in ("typescript", "javascript"):
209
+ return hits
210
+
211
+ # Directory-import barrel fallback: probe <dir>/index.* for relative sources.
212
+ if not (source_module.startswith("./") or source_module.startswith("../")):
213
+ return hits
214
+ try:
215
+ base = Path(os.path.normpath(referencing_file.parent / source_module))
216
+ except Exception: # noqa: BLE001
217
+ return hits
218
+ for index_name in _BARREL_INDEX_FILES:
219
+ candidate = base / index_name
220
+ if candidate.exists():
221
+ return [str(candidate)]
222
+ return hits
223
+
224
+
225
+ def _chase_barrel(
226
+ exported: str,
227
+ barrel_paths: list[str],
228
+ repo_root: Path,
229
+ conn: sqlite3.Connection,
230
+ language: str,
231
+ max_import_candidates: int,
232
+ depth: int,
233
+ visited: set[tuple[str, str]],
234
+ ) -> str | None:
235
+ """Follow a re-export chain through barrel files to the real declarer.
236
+
237
+ P4 — barrel re-export following. A barrel (e.g. index.ts) re-exports a name
238
+ from a sibling but does not declare it. This walks each barrel's OWN
239
+ import_mappings for `exported`, resolves the re-export source to file(s),
240
+ checks whether those declare the name, and recurses if they are themselves
241
+ barrels — up to `depth` more hops.
242
+
243
+ Returns the single declaring file path on success, or None when the chain
244
+ dead-ends, branches ambiguously, exceeds depth, or cycles. Never raises.
245
+
246
+ Args:
247
+ exported: The exported name being chased (stable across hops here —
248
+ re-exports modeled as non-aliased named bindings).
249
+ barrel_paths: Candidate barrel file paths to inspect at this hop.
250
+ repo_root: Repo root for import-source resolution.
251
+ conn: DB connection (read-only).
252
+ language: Language of the chain (TS/JS barrels in practice).
253
+ max_import_candidates: Per-import candidate cap (perf bound).
254
+ depth: Remaining hops allowed (decremented per recursion).
255
+ visited: (file, name) pairs already seen — cycle + repeat-DB guard.
256
+ """
257
+ if depth <= 0:
258
+ return None
259
+
260
+ for barrel_path in barrel_paths:
261
+ key = (barrel_path, exported)
262
+ if key in visited:
263
+ # Cycle or already-explored barrel — skip to guarantee termination
264
+ # and avoid repeated DB hits (the per-resolution cache requirement).
265
+ continue
266
+ visited.add(key)
267
+
268
+ # Load THIS barrel's own re-export mappings for the exported name.
269
+ barrel_maps = load_import_mappings(conn, barrel_path)
270
+ try:
271
+ barrel_dir = Path(barrel_path)
272
+ except Exception: # noqa: BLE001
273
+ continue
274
+
275
+ for m in barrel_maps:
276
+ if m["is_wildcard"] or m["is_namespace"]:
277
+ continue
278
+ # Match on the local binding the barrel re-exports under (the name a
279
+ # consumer sees) — equals `exported` for plain `export { X } from`.
280
+ if m["local_name"] != exported:
281
+ continue
282
+
283
+ next_exported = m["exported_name"]
284
+ next_paths = _resolve_barrel_source(
285
+ m["source_module"], barrel_dir, repo_root, language,
286
+ max_import_candidates,
287
+ )
288
+ if not next_paths:
289
+ continue
290
+
291
+ # Does any resolved file actually DECLARE the name? → real declarer.
292
+ declaring = _declaring_files(conn, next_exported, next_paths)
293
+ if len(declaring) == 1:
294
+ return declaring[0]
295
+ if len(declaring) > 1:
296
+ # Branches to multiple declarers → genuinely ambiguous; stop.
297
+ return None
298
+
299
+ # None declare it → those files are themselves barrels; recurse.
300
+ hit = _chase_barrel(
301
+ next_exported, next_paths, repo_root, conn, language,
302
+ max_import_candidates, depth - 1, visited,
303
+ )
304
+ if hit is not None:
305
+ return hit
306
+
307
+ return None
308
+
309
+
310
+ def _declaring_files(
311
+ conn: sqlite3.Connection,
312
+ name: str,
313
+ candidate_paths: list[str],
314
+ ) -> list[str]:
315
+ """Return which of candidate_paths declare `name` in the index. Never raises."""
316
+ try:
317
+ rows = conn.execute(
318
+ """
319
+ SELECT f.path FROM symbols s
320
+ JOIN files f ON f.id = s.file_id
321
+ WHERE s.name = ? AND f.path IN ({})
322
+ """.format(",".join("?" * len(candidate_paths))),
323
+ [name, *candidate_paths],
324
+ ).fetchall()
325
+ except Exception: # noqa: BLE001
326
+ return []
327
+ return [row["path"] for row in rows]
328
+
329
+
330
+ def _resolve_with_import_promotion(
331
+ target_name: str,
332
+ name_counts: dict[str, int],
333
+ import_mappings: list[ImportMapping],
334
+ referencing_file: Path,
335
+ repo_root: Path,
336
+ conn: sqlite3.Connection,
337
+ language: str,
338
+ max_import_candidates: int,
339
+ max_proximity_candidates: int,
340
+ ) -> Resolution:
341
+ """Attempt import-promotion resolution (step A).
342
+
343
+ Checks same-file import mappings for a binding of target_name to exactly
344
+ one indexed declaring file. If found, promotes to EXTRACTED 'import'.
345
+
346
+ P4: when the resolved source file does NOT itself declare the exported name
347
+ (i.e. it is a barrel that re-exports from siblings), the chain is followed
348
+ through the barrel's OWN import_mappings up to SEAM_BARREL_DEPTH hops to find
349
+ the real declarer (bounded + cycle-safe + cached per (file, name)).
350
+
351
+ Falls through to name-count resolution when:
352
+ - No import binding found.
353
+ - Import source resolves to no indexed file (third-party).
354
+ - Resolved file does NOT declare the exported name AND no barrel chain
355
+ reaches a single declarer (prevents false promotion).
356
+ - Import is wildcard (no specific binding — star import, story 27).
357
+
358
+ Args are pre-validated by the caller; this function does not guard them.
359
+ """
360
+ # Scan import mappings for a binding of target_name.
361
+ # Wildcards and namespace imports are skipped: a wildcard has no specific exported-name
362
+ # binding to look up, and a namespace import binds the whole module object rather
363
+ # than an individual symbol, so neither can narrow the target to a declaring file.
364
+ for mapping in import_mappings:
365
+ if mapping["is_wildcard"]:
366
+ continue
367
+ if mapping["is_namespace"]:
368
+ continue
369
+ if mapping["local_name"] != target_name:
370
+ continue
371
+
372
+ # This import binds target_name locally. Resolve the source module to file paths.
373
+ # When barrel following is enabled, a directory import (`from './barrel'`)
374
+ # additionally resolves to its index.* entry so the chain can be chased;
375
+ # SEAM_BARREL_DEPTH=0 uses the plain resolver → byte-identical to pre-P4.
376
+ if config.SEAM_BARREL_DEPTH > 0:
377
+ candidate_paths = _resolve_barrel_source(
378
+ mapping["source_module"],
379
+ referencing_file,
380
+ repo_root,
381
+ language,
382
+ max_import_candidates,
383
+ )
384
+ else:
385
+ candidate_paths = resolve_import_source(
386
+ mapping["source_module"],
387
+ referencing_file,
388
+ repo_root,
389
+ language,
390
+ )[:max_import_candidates]
391
+
392
+ if not candidate_paths:
393
+ # Third-party or unresolvable source — fall through to name-count rule.
394
+ # Debug log so "why AMBIGUOUS not EXTRACTED" is diagnosable without reading source.
395
+ logger.debug(
396
+ "_resolve_with_import_promotion: %r — source %r unresolvable (third-party/out-of-scope)",
397
+ target_name,
398
+ mapping["source_module"],
399
+ )
400
+ continue
401
+
402
+ # Check which candidate files actually declare the exported name in the index.
403
+ exported = mapping["exported_name"]
404
+ declaring_paths = _declaring_files(conn, exported, candidate_paths)
405
+ if len(declaring_paths) == 1:
406
+ # Exactly one indexed file declares the name → promote to EXTRACTED
407
+ return Resolution(
408
+ confidence=CONFIDENCE_EXTRACTED,
409
+ resolved_by=RESOLVED_BY_IMPORT,
410
+ best_candidate=declaring_paths[0],
411
+ )
412
+ # 0 or >1 declaring files — can't promote directly; fall through.
413
+ # Debug logs so "why AMBIGUOUS not EXTRACTED" is answerable without reading source.
414
+ if not declaring_paths:
415
+ # P4: the resolved source declares nothing — it may be a BARREL that
416
+ # re-exports the name from a sibling. Chase the re-export chain
417
+ # (bounded by SEAM_BARREL_DEPTH, cycle-safe, cached per (file, name))
418
+ # to find the real declarer. SEAM_BARREL_DEPTH=0 disables → pre-P4.
419
+ if config.SEAM_BARREL_DEPTH > 0:
420
+ hit = _chase_barrel(
421
+ exported=exported,
422
+ barrel_paths=candidate_paths,
423
+ repo_root=repo_root,
424
+ conn=conn,
425
+ language=language,
426
+ max_import_candidates=max_import_candidates,
427
+ depth=config.SEAM_BARREL_DEPTH,
428
+ visited=set(),
429
+ )
430
+ if hit is not None:
431
+ return Resolution(
432
+ confidence=CONFIDENCE_EXTRACTED,
433
+ resolved_by=RESOLVED_BY_IMPORT,
434
+ best_candidate=hit,
435
+ )
436
+ logger.debug(
437
+ "_resolve_with_import_promotion: %r — exported name %r not declared in resolved file(s) %s",
438
+ target_name,
439
+ exported,
440
+ candidate_paths,
441
+ )
442
+ else:
443
+ logger.debug(
444
+ "_resolve_with_import_promotion: %r — %d declaring files found (>1, ambiguous): %s",
445
+ target_name,
446
+ len(declaring_paths),
447
+ declaring_paths,
448
+ )
449
+
450
+ # No import binding matched — fall through to name-count rule.
451
+ logger.debug(
452
+ "_resolve_with_import_promotion: %r — no import binding matched, using name-count rule",
453
+ target_name,
454
+ )
455
+ return _resolve_name_count(
456
+ target_name,
457
+ name_counts,
458
+ language=language,
459
+ referencing_file=referencing_file,
460
+ max_proximity_candidates=max_proximity_candidates,
461
+ conn=conn,
462
+ )
463
+
464
+
465
+ def _resolve_name_count(
466
+ target_name: str,
467
+ name_counts: dict[str, int],
468
+ language: str | None = None,
469
+ referencing_file: Path | None = None,
470
+ max_proximity_candidates: int = 25,
471
+ conn: sqlite3.Connection | None = None,
472
+ candidate_files: list[str] | None = None,
473
+ ) -> Resolution:
474
+ """Name-count resolution rule with builtin filtering and proximity tie-break.
475
+
476
+ Resolution order:
477
+ count == 1 → EXTRACTED, 'name-unique'.
478
+ count > 1 → AMBIGUOUS, 'name-collision' + proximity best_candidate if possible.
479
+ count == 0 AND is_builtin(name, lang) → INFERRED, 'builtin'.
480
+ count == 0 AND not builtin → INFERRED, 'unresolved'.
481
+
482
+ The builtin check fires ONLY when count==0 — structural guarantee for story 5.
483
+ """
484
+ count = name_counts.get(target_name, 0)
485
+
486
+ if count == 1:
487
+ return Resolution(
488
+ confidence=CONFIDENCE_EXTRACTED,
489
+ resolved_by=RESOLVED_BY_NAME_UNIQUE,
490
+ best_candidate=None,
491
+ )
492
+
493
+ if count > 1:
494
+ # AMBIGUOUS — proximity narrows the best_candidate hint but keeps the tier
495
+ # AMBIGUOUS because path distance alone can't guarantee the correct target.
496
+ best = _proximity_best_candidate(
497
+ target_name=target_name,
498
+ referencing_file=referencing_file,
499
+ max_candidates=max_proximity_candidates,
500
+ conn=conn,
501
+ candidate_files=candidate_files,
502
+ )
503
+ return Resolution(
504
+ confidence=CONFIDENCE_AMBIGUOUS,
505
+ resolved_by=RESOLVED_BY_COLLISION,
506
+ best_candidate=best,
507
+ )
508
+
509
+ # count == 0: check builtins ONLY here (structural guard for story 5).
510
+ # A user-defined name with count >= 1 can NEVER reach this branch.
511
+ # SEAM_BUILTIN_FILTERING="off" disables builtin tagging entirely.
512
+ if language and config.SEAM_BUILTIN_FILTERING == "on" and is_builtin(target_name, language):
513
+ return Resolution(
514
+ confidence=CONFIDENCE_INFERRED,
515
+ resolved_by=RESOLVED_BY_BUILTIN,
516
+ best_candidate=None,
517
+ )
518
+
519
+ return Resolution(
520
+ confidence=CONFIDENCE_INFERRED,
521
+ resolved_by=RESOLVED_BY_UNRESOLVED,
522
+ best_candidate=None,
523
+ )
524
+
525
+
526
+ def _proximity_best_candidate(
527
+ target_name: str,
528
+ referencing_file: Path | None,
529
+ max_candidates: int,
530
+ conn: sqlite3.Connection | None,
531
+ candidate_files: list[str] | None = None,
532
+ ) -> str | None:
533
+ """Return the file path of the most proximately-close declaring symbol.
534
+
535
+ Used for step D: AMBIGUOUS edge tie-break by file-path proximity.
536
+ Returns None when insufficient context is available.
537
+
538
+ Args:
539
+ target_name: Symbol name to look up declaring files for.
540
+ referencing_file: The file that references the symbol (for proximity calc).
541
+ max_candidates: Maximum declaring files to evaluate (performance cap).
542
+ conn: DB connection for querying declaring files (may be None).
543
+ candidate_files: Pre-provided list of declaring file paths (for testing).
544
+ """
545
+ if referencing_file is None:
546
+ return None
547
+
548
+ # Get candidate declaring file paths (from conn or direct)
549
+ paths: list[str] = []
550
+ if candidate_files is not None:
551
+ paths = candidate_files[:max_candidates]
552
+ elif conn is not None:
553
+ try:
554
+ rows = conn.execute(
555
+ """
556
+ SELECT DISTINCT f.path FROM symbols s
557
+ JOIN files f ON f.id = s.file_id
558
+ WHERE s.name = ?
559
+ LIMIT ?
560
+ """,
561
+ (target_name, max_candidates),
562
+ ).fetchall()
563
+ paths = [row["path"] for row in rows]
564
+ except Exception: # noqa: BLE001
565
+ return None
566
+
567
+ if not paths:
568
+ return None
569
+
570
+ # Rank by path proximity — higher score = same directory or closer tree
571
+ best_path: str | None = None
572
+ best_score: int = -1
573
+ for p in paths:
574
+ try:
575
+ score = compute_path_proximity(referencing_file, Path(p))
576
+ except Exception: # noqa: BLE001
577
+ score = 0
578
+ if score > best_score:
579
+ best_score = score
580
+ best_path = p
581
+
582
+ return best_path
583
+
584
+
585
+ # ── Public resolvers ───────────────────────────────────────────────────────────
586
+
587
+
588
+ def resolve_edge(
589
+ target_name: str,
590
+ name_counts: dict[str, int],
591
+ language: str | None = None,
592
+ import_mappings: list[ImportMapping] | None = None,
593
+ referencing_file: Path | None = None,
594
+ repo_root: Path | None = None,
595
+ conn: sqlite3.Connection | None = None,
596
+ max_import_candidates: int = 25,
597
+ max_proximity_candidates: int = 25,
598
+ candidate_files: list[str] | None = None,
599
+ ) -> Resolution:
600
+ """Full Phase 5 resolver: returns a Resolution with confidence + resolved_by.
601
+
602
+ Resolution order (four-step):
603
+ 1. If import_mappings provided AND a non-wildcard mapping binds target_name
604
+ to exactly one indexed declaring file → EXTRACTED, 'import' (story A).
605
+ 2. name-count rule:
606
+ count == 1 → EXTRACTED, 'name-unique'.
607
+ count > 1 → AMBIGUOUS, 'name-collision' + proximity best_candidate (D).
608
+ 3. count == 0 AND is_builtin(name, language) → INFERRED, 'builtin' (C).
609
+ 4. count == 0 AND not builtin → INFERRED, 'unresolved'.
610
+
611
+ The builtin check fires ONLY at count==0 (structural guarantee for story 5).
612
+ A user-defined name with count >= 1 is NEVER filtered as builtin.
613
+
614
+ Degrades gracefully: any missing context (no language, no mappings, etc.)
615
+ falls back to the name-count rule. Never raises.
616
+
617
+ Args:
618
+ target_name: The edge target name to resolve.
619
+ name_counts: Whole-index name → count map (from load_name_counts).
620
+ language: Language of the referencing file. Required for builtin
621
+ check and import source resolution. None → no builtin check.
622
+ import_mappings: Parsed import bindings for the referencing file
623
+ (from load_import_mappings). None → skip step A.
624
+ referencing_file: Path to the referencing source file. Required for
625
+ import source resolution and proximity scoring.
626
+ repo_root: Repository root. Required for import source resolution.
627
+ conn: DB connection. Required for step A declaration check
628
+ and step D proximity query. None → limited resolution.
629
+ max_import_candidates: Cap on candidate declaring files per import (perf).
630
+ max_proximity_candidates: Cap on candidates for proximity ranking (perf).
631
+ candidate_files: Pre-provided candidate file paths for testing proximity
632
+ without a real DB connection.
633
+ """
634
+ try:
635
+ # Step A: import promotion — beats global collision (the homonym fix).
636
+ # Requires SEAM_IMPORT_RESOLUTION="on" (default); skip when disabled.
637
+ if (
638
+ config.SEAM_IMPORT_RESOLUTION == "on"
639
+ and import_mappings is not None
640
+ and referencing_file is not None
641
+ and repo_root is not None
642
+ and conn is not None
643
+ and language is not None
644
+ ):
645
+ return _resolve_with_import_promotion(
646
+ target_name=target_name,
647
+ name_counts=name_counts,
648
+ import_mappings=import_mappings,
649
+ referencing_file=referencing_file,
650
+ repo_root=repo_root,
651
+ conn=conn,
652
+ language=language,
653
+ max_import_candidates=max_import_candidates,
654
+ max_proximity_candidates=max_proximity_candidates,
655
+ )
656
+
657
+ # Steps 2-4: name-count rule (+ builtin check at count==0)
658
+ return _resolve_name_count(
659
+ target_name=target_name,
660
+ name_counts=name_counts,
661
+ language=language,
662
+ referencing_file=referencing_file,
663
+ max_proximity_candidates=max_proximity_candidates,
664
+ conn=conn,
665
+ candidate_files=candidate_files,
666
+ )
667
+ except Exception: # noqa: BLE001
668
+ # Degrade gracefully: any failure falls back to plain name-count string
669
+ return Resolution(
670
+ confidence=resolve(target_name, name_counts),
671
+ resolved_by=None,
672
+ best_candidate=None,
673
+ )
674
+
675
+
676
+ # ── Pure resolver ──────────────────────────────────────────────────────────────
677
+
678
+
679
+ def resolve(target_name: str, name_counts: dict[str, int]) -> str:
680
+ """Resolve confidence for a single edge target against the whole-index name map.
681
+
682
+ Pure function — no I/O, no side effects.
683
+
684
+ Args:
685
+ target_name: The edge target (callee / importee) name to resolve.
686
+ name_counts: Mapping produced by load_name_counts(conn).
687
+
688
+ Returns:
689
+ CONFIDENCE_EXTRACTED if the name appears exactly once in the index.
690
+ CONFIDENCE_AMBIGUOUS if the name appears more than once.
691
+ CONFIDENCE_INFERRED if the name is absent (count == 0 or missing key).
692
+ """
693
+ count = name_counts.get(target_name, 0)
694
+ if count == 1:
695
+ return CONFIDENCE_EXTRACTED
696
+ if count > 1:
697
+ return CONFIDENCE_AMBIGUOUS
698
+ # count == 0: name not in index — external library, stdlib, or dynamic call.
699
+ return CONFIDENCE_INFERRED