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
seam/analysis/flows.py ADDED
@@ -0,0 +1,708 @@
1
+ """Flow tracing — path-finding between symbols and one-hop caller/callee queries.
2
+
3
+ Contract
4
+ --------
5
+ ``trace(conn, source, target, max_depth, repo_root=None) -> list[Path]``
6
+
7
+ Find call/dependency path(s) from source to target over the edges table.
8
+ A Path is an ordered list of hops; each hop carries from-name, to-name,
9
+ edge kind, and edge confidence.
10
+
11
+ Returns [] when no path exists — this is a distinguishable "not connected"
12
+ result, not an error.
13
+
14
+ Decision: returns **shortest path only** (single BFS front-to-back).
15
+ Rationale: BFS by construction finds the shortest path first. Returning all
16
+ simple paths would require DFS with backtracking and can explode exponentially
17
+ on dense graphs. The single shortest path is what almost all callers want; if
18
+ multiple shortest paths exist at the same length, the lexicographically first
19
+ is returned (deterministic ordering).
20
+
21
+ If you need all simple paths up to a cap, use the lower-level edges table
22
+ directly — the BFS here does not enumerate them.
23
+
24
+ CYCLE-SAFE and bounded by max_depth.
25
+
26
+ ``callers(conn, symbol, repo_root=None) -> list[EdgeHop]``
27
+
28
+ One-hop upstream: who calls or imports `symbol`. Per-edge confidence included.
29
+
30
+ ``callees(conn, symbol, repo_root=None) -> list[EdgeHop]``
31
+
32
+ One-hop downstream: what `symbol` calls or imports. Per-edge confidence included.
33
+
34
+ Public types
35
+ ------------
36
+ ``Hop`` — one step in a Path: from_name, to_name, kind, confidence, resolved_by.
37
+ ``Path`` — list[Hop] (ordered, non-empty; len >= 1 for a connected pair 1 hop apart).
38
+ ``EdgeHop`` — one-hop result for callers()/callees(): name, kind, confidence, resolved_by.
39
+
40
+ Per-hop confidence
41
+ ------------------
42
+ Each Hop carries the confidence of that specific edge (not an aggregated path
43
+ confidence). This lets callers see which individual hops are AMBIGUOUS.
44
+
45
+ When repo_root is provided and SEAM_IMPORT_RESOLUTION="on", confidence is resolved
46
+ via resolve_edge() with full import-promotion context (Phase 5 homonym fix), and
47
+ resolved_by carries the provenance string. Without repo_root, falls back to the
48
+ name-count resolver and resolved_by is None.
49
+
50
+ The overall path confidence is the weakest hop (same rule as traversal.py), but
51
+ it is the callers' job to aggregate that from the Hop list if needed.
52
+
53
+ Module imports
54
+ --------------
55
+ stdlib: sqlite3, typing, logging, pathlib.
56
+ seam.analysis.confidence: all constants, load_name_counts, load_import_mappings,
57
+ resolve, resolve_edge.
58
+ seam.analysis.traversal: _SQL_VAR_BATCH (IN-clause batch limit) and _rank (BFS helper).
59
+ No server/cli/query imports.
60
+ """
61
+
62
+ import logging
63
+ import sqlite3
64
+ from pathlib import Path as FilePath
65
+ from typing import TypedDict, cast
66
+
67
+ import seam.config as config
68
+ from seam.analysis.confidence import (
69
+ CONFIDENCE_AMBIGUOUS,
70
+ CONFIDENCE_EXTRACTED,
71
+ CONFIDENCE_INFERRED,
72
+ Resolution,
73
+ load_import_mappings,
74
+ load_name_counts,
75
+ resolve,
76
+ resolve_edge,
77
+ )
78
+ from seam.analysis.imports import ImportMapping
79
+ from seam.analysis.traversal import (
80
+ _SQL_VAR_BATCH,
81
+ _rank,
82
+ )
83
+ from seam.query.names import expand_impact_seeds
84
+
85
+ logger = logging.getLogger(__name__)
86
+
87
+ # ── Type definitions ───────────────────────────────────────────────────────────
88
+
89
+
90
+ class Hop(TypedDict):
91
+ """One step on a call/dependency path.
92
+
93
+ Fields:
94
+ from_name — source symbol of this edge
95
+ to_name — target symbol of this edge
96
+ kind — edge kind. Full closed vocabulary:
97
+ call | import | extends | implements | instantiates |
98
+ holds | reads | writes | uses.
99
+ confidence — edge confidence: EXTRACTED | INFERRED | AMBIGUOUS
100
+ resolved_by — Phase 5: how confidence was decided (see RESOLVED_BY_* in confidence.py).
101
+ None for fast-path hops (name-count only, no import mapping context).
102
+ best_candidate — Phase 5: for AMBIGUOUS hops, the most-proximate declaring file.
103
+ None for non-AMBIGUOUS or when proximity data is unavailable.
104
+ synthesized_by — E4: synthesis channel name when this hop is a heuristic synthesized
105
+ edge (e.g. 'interface-override', 'closure-collection', 'event-emitter').
106
+ None when the hop is statically extracted. Same null-contract as
107
+ resolved_by/best_candidate: null ≡ statically extracted.
108
+ """
109
+
110
+ from_name: str
111
+ to_name: str
112
+ kind: str
113
+ confidence: str
114
+ resolved_by: str | None
115
+ best_candidate: str | None
116
+ synthesized_by: str | None
117
+
118
+
119
+ # Path is an ordered list of Hops from source to target.
120
+ # NOTE: This type alias named 'Path' is intentionally distinct from pathlib.Path
121
+ # (which is imported as FilePath in this module to avoid name collision).
122
+ # Invariants:
123
+ # - len >= 1 (a direct edge from source to target produces one Hop)
124
+ # - path[0].from_name == source, path[-1].to_name == target
125
+ # - consecutive hops are linked: path[i].to_name == path[i+1].from_name
126
+ Path = list[Hop]
127
+
128
+
129
+ class EdgeHop(TypedDict):
130
+ """One-hop result for callers() / callees().
131
+
132
+ Fields:
133
+ name — the neighboring symbol name
134
+ kind — edge kind. Full closed vocabulary:
135
+ call | import | extends | implements | instantiates |
136
+ holds | reads | writes | uses.
137
+ confidence — edge confidence: EXTRACTED | INFERRED | AMBIGUOUS
138
+ resolved_by — Phase 5: how confidence was decided. None for fast-path hops
139
+ (name-count only — full import-promotion context not available here).
140
+ best_candidate — Phase 5: for AMBIGUOUS hops, the most-proximate declaring file.
141
+ None for non-AMBIGUOUS or when proximity data is unavailable.
142
+ synthesized_by — E4: synthesis channel name when this hop is a heuristic synthesized
143
+ edge (e.g. 'interface-override', 'closure-collection', 'event-emitter').
144
+ None when the hop is statically extracted. Same null-contract as
145
+ resolved_by/best_candidate: null ≡ statically extracted.
146
+ """
147
+
148
+ name: str
149
+ kind: str
150
+ confidence: str
151
+ resolved_by: str | None
152
+ best_candidate: str | None
153
+ synthesized_by: str | None
154
+
155
+
156
+ # ── Internal helpers ───────────────────────────────────────────────────────────
157
+
158
+ # Re-export confidence constants for convenience (callers can import from here).
159
+ __all__ = [
160
+ "Hop",
161
+ "Path",
162
+ "EdgeHop",
163
+ "trace",
164
+ "callers",
165
+ "callees",
166
+ "CONFIDENCE_EXTRACTED",
167
+ "CONFIDENCE_INFERRED",
168
+ "CONFIDENCE_AMBIGUOUS",
169
+ ]
170
+
171
+
172
+ def _fetch_outgoing_edges(
173
+ conn: sqlite3.Connection,
174
+ names: set[str],
175
+ ) -> list[tuple[str, str, str, str, str, str | None]]:
176
+ """Fetch all outgoing edges from any name in `names`, with file context for Phase 5.
177
+
178
+ Returns list of (source_name, target_name, kind, ref_file_path, language, synthesized_by).
179
+ Self-edges (source == target) are excluded.
180
+
181
+ ref_file_path and language come from the edges.file_id → files JOIN, providing
182
+ the referencing file context required by resolve_edge() for import promotion.
183
+
184
+ synthesized_by (E4): the edge.synthesized_by column value — None for statically
185
+ extracted edges; a channel name string for synthesized edges.
186
+
187
+ The stored edges.confidence column is intentionally NOT selected: per-hop
188
+ confidence is resolved whole-index from target_name at read time (see
189
+ confidence.resolve_edge), so the stored same-file value is never surfaced here.
190
+
191
+ Batches IN-clause in groups of _SQL_VAR_BATCH to avoid the
192
+ SQLITE_MAX_VARIABLE_NUMBER (999) limit on Linux/CI.
193
+ """
194
+ if not names:
195
+ return []
196
+
197
+ names_list = list(names)
198
+ all_rows: list[tuple[str, str, str, str, str, str | None]] = []
199
+
200
+ for batch_start in range(0, len(names_list), _SQL_VAR_BATCH):
201
+ batch = names_list[batch_start : batch_start + _SQL_VAR_BATCH]
202
+ placeholders = ",".join("?" * len(batch))
203
+
204
+ # Downstream: follow edges from source to target, join files for context.
205
+ # E4: also select e.synthesized_by for provenance threading.
206
+ sql = f"""
207
+ SELECT e.source_name, e.target_name, e.kind,
208
+ f.path AS ref_file_path, f.language,
209
+ e.synthesized_by
210
+ FROM edges e
211
+ JOIN files f ON f.id = e.file_id
212
+ WHERE e.source_name IN ({placeholders})
213
+ AND e.source_name != e.target_name
214
+ """
215
+ rows = conn.execute(sql, batch).fetchall()
216
+ all_rows.extend(
217
+ (
218
+ row["source_name"],
219
+ row["target_name"],
220
+ row["kind"],
221
+ row["ref_file_path"],
222
+ row["language"],
223
+ row["synthesized_by"],
224
+ )
225
+ for row in rows
226
+ )
227
+
228
+ return all_rows
229
+
230
+
231
+ def _fetch_incoming_edges(
232
+ conn: sqlite3.Connection,
233
+ names: set[str],
234
+ ) -> list[tuple[str, str, str, str, str, str | None]]:
235
+ """Fetch all incoming edges to any name in `names`, with file context for Phase 5.
236
+
237
+ Returns list of (source_name, target_name, kind, ref_file_path, language, synthesized_by).
238
+ Self-edges (source == target) are excluded.
239
+
240
+ ref_file_path and language come from the edges.file_id → files JOIN, providing
241
+ the referencing file context required by resolve_edge() for import promotion.
242
+
243
+ synthesized_by (E4): the edge.synthesized_by column value — None for statically
244
+ extracted edges; a channel name string for synthesized edges.
245
+
246
+ Stored confidence is not selected — see _fetch_outgoing_edges for why.
247
+
248
+ Batches IN-clause in groups of _SQL_VAR_BATCH.
249
+ """
250
+ if not names:
251
+ return []
252
+
253
+ names_list = list(names)
254
+ all_rows: list[tuple[str, str, str, str, str, str | None]] = []
255
+
256
+ for batch_start in range(0, len(names_list), _SQL_VAR_BATCH):
257
+ batch = names_list[batch_start : batch_start + _SQL_VAR_BATCH]
258
+ placeholders = ",".join("?" * len(batch))
259
+
260
+ # Upstream: edges pointing AT these names, join files for context.
261
+ # E4: also select e.synthesized_by for provenance threading.
262
+ sql = f"""
263
+ SELECT e.source_name, e.target_name, e.kind,
264
+ f.path AS ref_file_path, f.language,
265
+ e.synthesized_by
266
+ FROM edges e
267
+ JOIN files f ON f.id = e.file_id
268
+ WHERE e.target_name IN ({placeholders})
269
+ AND e.source_name != e.target_name
270
+ """
271
+ rows = conn.execute(sql, batch).fetchall()
272
+ all_rows.extend(
273
+ (
274
+ row["source_name"],
275
+ row["target_name"],
276
+ row["kind"],
277
+ row["ref_file_path"],
278
+ row["language"],
279
+ row["synthesized_by"],
280
+ )
281
+ for row in rows
282
+ )
283
+
284
+ return all_rows
285
+
286
+
287
+ # ── Public interface ───────────────────────────────────────────────────────────
288
+
289
+
290
+ def _reconstruct_path(
291
+ target: str,
292
+ parent_map: dict[str, tuple[str, Hop]],
293
+ ) -> Path:
294
+ """Reconstruct the shortest path from parent-pointer map.
295
+
296
+ parent_map maps child_name -> (parent_name, Hop that produced child_name).
297
+ Walks backwards from target to rebuild the ordered hop sequence.
298
+ """
299
+ path: list[Hop] = []
300
+ current = target
301
+ while current in parent_map:
302
+ parent_name, hop = parent_map[current]
303
+ path.append(hop)
304
+ current = parent_name
305
+ path.reverse()
306
+ return path
307
+
308
+
309
+ def trace(
310
+ conn: sqlite3.Connection,
311
+ source: str,
312
+ target: str,
313
+ max_depth: int = 10,
314
+ repo_root: FilePath | None = None,
315
+ ) -> list[Path]:
316
+ """Find the shortest call/dependency path from source to target.
317
+
318
+ Args:
319
+ conn: Open SQLite connection (read-only; no writes).
320
+ source: Starting symbol name. If source == target, returns [[]].
321
+ target: Destination symbol name.
322
+ max_depth: Maximum number of hops allowed. Must be >= 1.
323
+ The caller is responsible for clamping (e.g. to [1, 10]).
324
+ repo_root: Repository root. When provided and SEAM_IMPORT_RESOLUTION="on",
325
+ each hop's confidence is resolved via resolve_edge() with import
326
+ promotion (Phase 5 homonym fix), and resolved_by carries provenance.
327
+ When None or SEAM_IMPORT_RESOLUTION="off", falls back to name-count.
328
+
329
+ Returns:
330
+ A list containing the single shortest Path from source to target,
331
+ or [] if no path exists within max_depth hops.
332
+
333
+ A Path is list[Hop] where each Hop carries from_name, to_name, kind,
334
+ confidence, and resolved_by. The path is ordered: path[0].from_name == source,
335
+ path[-1].to_name == target.
336
+
337
+ If source == target: returns [[]] — a one-element list containing an
338
+ empty path (trivially connected to itself, zero hops).
339
+
340
+ Cycle safety:
341
+ BFS uses a `visited` set. Each symbol is visited at most once.
342
+ A cycle (A->B->A) terminates because A is in visited before B is expanded.
343
+
344
+ Performance:
345
+ Level-by-level batched BFS — each BFS level issues ONE batched SQL query
346
+ for the entire frontier (via _fetch_outgoing_edges batching), rather than
347
+ one query per node. This avoids O(N) queries on large disconnected graphs.
348
+
349
+ SQLite variable limit:
350
+ IN-clauses are batched in groups of _SQL_VAR_BATCH (900) per BFS level.
351
+ """
352
+ if max_depth < 1:
353
+ return []
354
+
355
+ # Trivial case: source and target are the same symbol.
356
+ if source == target:
357
+ return [[]] # empty path — zero hops, trivially connected
358
+
359
+ # Expand the target to a set of alias names — bridges the qualified/bare edge gap.
360
+ # Seam's extractor stores edge target_name as bare "method" while the symbol is
361
+ # stored as "Class.method". The BFS checks tgt_name IN target_aliases so a hop
362
+ # that reaches bare "parse" is treated as reaching "Parser.parse".
363
+ # expand_impact_seeds("Parser.parse") -> ["Parser.parse", "parse"]
364
+ # The target_aliases set includes the original target too (exact match preserved).
365
+ target_seeds = expand_impact_seeds(conn, target)
366
+ target_aliases: set[str] = set(target_seeds)
367
+ # Always include the exact target — expand_impact_seeds returns it first but be explicit.
368
+ target_aliases.add(target)
369
+ logger.debug("trace: target=%r expanded aliases=%s", target, sorted(target_aliases))
370
+
371
+ # Load whole-index name-count map ONCE per trace() call.
372
+ name_counts = load_name_counts(conn)
373
+
374
+ # Determine whether full import-promotion is enabled for this trace call.
375
+ use_import_promotion = repo_root is not None and config.SEAM_IMPORT_RESOLUTION == "on"
376
+
377
+ # Per-file import mapping cache: path -> list[ImportMapping].
378
+ # Prevents repeated load_import_mappings calls for the same file within one trace.
379
+ _import_cache: dict[str, list[ImportMapping]] = {}
380
+
381
+ # Per-(file, target) resolution-result cache keyed on (ref_file_path, tgt_name).
382
+ # Avoids re-running the declaration-check and proximity SELECT in resolve_edge
383
+ # for repeated (file, target) pairs — common when multiple BFS levels cross the
384
+ # same edges (e.g. a frequently-called utility in many files).
385
+ _resolution_cache: dict[tuple[str, str], Resolution] = {}
386
+
387
+ def _get_import_mappings(file_path: str) -> list[ImportMapping]:
388
+ if file_path not in _import_cache:
389
+ _import_cache[file_path] = load_import_mappings(conn, file_path)
390
+ return _import_cache[file_path]
391
+
392
+ # BFS state.
393
+ visited: set[str] = {source}
394
+ frontier: set[str] = {source}
395
+
396
+ # Maps a discovered node to the (parent_name, Hop) that first reached it.
397
+ parent_map: dict[str, tuple[str, Hop]] = {}
398
+
399
+ for _level in range(max_depth):
400
+ if not frontier:
401
+ break
402
+
403
+ # Fetch ALL outgoing edges for the entire frontier in one batched query.
404
+ # Returns (source_name, target_name, kind, ref_file_path, language).
405
+ outgoing = _fetch_outgoing_edges(conn, frontier)
406
+
407
+ # Sort for determinism: (target_name, kind) lexicographic order ensures
408
+ # that when multiple parents can reach the same node at the same level,
409
+ # we pick the lexicographically-first (source_name, kind) path.
410
+ outgoing.sort(key=lambda r: (r[1], r[2]))
411
+
412
+ next_frontier: set[str] = set()
413
+
414
+ for src_name, tgt_name, kind, ref_file_path, language, synth_by in outgoing:
415
+ # Resolve per-hop confidence via full Phase 5 resolver when available,
416
+ # using the cache to avoid re-running declaration-check and proximity SELECTs
417
+ # for repeated (file, target) pairs across BFS levels.
418
+ if use_import_promotion and ref_file_path and language:
419
+ cache_key = (ref_file_path, tgt_name)
420
+ if cache_key not in _resolution_cache:
421
+ import_mappings = _get_import_mappings(ref_file_path)
422
+ _resolution_cache[cache_key] = resolve_edge(
423
+ target_name=tgt_name,
424
+ name_counts=name_counts,
425
+ language=language,
426
+ import_mappings=import_mappings,
427
+ referencing_file=FilePath(ref_file_path),
428
+ repo_root=repo_root,
429
+ conn=conn,
430
+ max_import_candidates=config.SEAM_MAX_IMPORT_CANDIDATES,
431
+ max_proximity_candidates=config.SEAM_PROXIMITY_MAX_CANDIDATES,
432
+ )
433
+ resolution = _resolution_cache[cache_key]
434
+ hop_confidence = resolution["confidence"]
435
+ hop_resolved_by = resolution["resolved_by"]
436
+ hop_best_candidate: str | None = resolution.get("best_candidate")
437
+ else:
438
+ hop_confidence = resolve(tgt_name, name_counts)
439
+ hop_resolved_by = None
440
+ hop_best_candidate = None
441
+
442
+ # E4: synthesized_by is the edge.synthesized_by column value from the DB.
443
+ # None = statically extracted; channel name = synthesized by post-pass.
444
+ hop = Hop(
445
+ from_name=src_name,
446
+ to_name=tgt_name,
447
+ kind=kind,
448
+ confidence=hop_confidence,
449
+ resolved_by=hop_resolved_by,
450
+ best_candidate=hop_best_candidate,
451
+ synthesized_by=synth_by,
452
+ )
453
+
454
+ # Found the target — record its parent and return immediately (BFS = shortest).
455
+ # tgt_name IN target_aliases handles the bare/qualified asymmetry:
456
+ # e.g. tgt_name="parse" matches target_aliases={"Parser.parse", "parse"}.
457
+ # _reconstruct_path uses tgt_name as the key (the actual edge target stored),
458
+ # so the returned path reflects the real edge, not the alias.
459
+ if tgt_name in target_aliases:
460
+ parent_map[tgt_name] = (src_name, hop)
461
+ path = _reconstruct_path(tgt_name, parent_map)
462
+ # Normalize the terminal hop: when the alias matched was a bare form
463
+ # (e.g. tgt_name="parse" for target="Parser.parse"), substitute the
464
+ # canonical target name in the last hop so the returned path always
465
+ # terminates with to_name matching what the caller asked about.
466
+ # WHY: agents comparing path[-1]["to_name"] to their requested target
467
+ # string would see a mismatch otherwise — this keeps the on-wire path
468
+ # internally consistent without any schema or edge-data change.
469
+ if path and tgt_name != target:
470
+ # Replace the terminal hop's to_name with the canonical target.
471
+ # Cast to Hop after substituting to_name so mypy is satisfied.
472
+ last_hop_dict = dict(path[-1])
473
+ last_hop_dict["to_name"] = target
474
+ path[-1] = cast(Hop, last_hop_dict)
475
+ logger.debug(
476
+ "trace(%r -> %r): found path of length %d via alias %r (import_promotion=%s)",
477
+ source,
478
+ target,
479
+ len(path),
480
+ tgt_name,
481
+ use_import_promotion,
482
+ )
483
+ return [path]
484
+
485
+ # Skip already-discovered symbols (cycle safety + BFS shortest-first).
486
+ if tgt_name in visited:
487
+ continue
488
+
489
+ if tgt_name not in next_frontier:
490
+ parent_map[tgt_name] = (src_name, hop)
491
+ next_frontier.add(tgt_name)
492
+
493
+ # Advance frontier; mark all new nodes as visited.
494
+ visited.update(next_frontier)
495
+ frontier = next_frontier
496
+
497
+ # No path found within max_depth.
498
+ logger.debug("trace(%r -> %r): no path found (max_depth=%d)", source, target, max_depth)
499
+ return []
500
+
501
+
502
+ def callers(
503
+ conn: sqlite3.Connection,
504
+ symbol: str,
505
+ repo_root: FilePath | None = None,
506
+ ) -> list[EdgeHop]:
507
+ """Return all one-hop upstream neighbors (who calls or imports `symbol`).
508
+
509
+ Args:
510
+ conn: Open SQLite connection (read-only).
511
+ symbol: Symbol name to look up. Empty string returns [].
512
+ repo_root: Repository root for Phase 5 import-promotion resolution.
513
+ When provided and SEAM_IMPORT_RESOLUTION="on", each hop's
514
+ confidence is resolved via resolve_edge() so imported bindings
515
+ of homonyms promote to EXTRACTED 'import'. None -> name-count only.
516
+
517
+ Returns:
518
+ List of EdgeHop dicts, each with:
519
+ name — the caller/importer symbol name
520
+ kind — full closed vocabulary: call | import | extends | implements |
521
+ instantiates | holds | reads | writes | uses (E4 corrected)
522
+ confidence — EXTRACTED | INFERRED | AMBIGUOUS
523
+ resolved_by — Phase 5 provenance string (or None for fast-path)
524
+
525
+ Results are sorted by name alphabetically for determinism.
526
+ Returns [] if the symbol has no callers or does not exist in the index.
527
+ """
528
+ if not symbol:
529
+ logger.debug("callers(): empty symbol — returning []")
530
+ return []
531
+
532
+ name_counts = load_name_counts(conn)
533
+ use_import_promotion = repo_root is not None and config.SEAM_IMPORT_RESOLUTION == "on"
534
+
535
+ # Per-file import mapping cache for this callers() call.
536
+ _import_cache: dict[str, list[ImportMapping]] = {}
537
+
538
+ # Per-(file, target) resolution-result cache: avoids re-running resolve_edge
539
+ # for repeated (file, target) pairs in the incoming-edge rows.
540
+ _resolution_cache: dict[tuple[str, str], Resolution] = {}
541
+
542
+ def _get_import_mappings(file_path: str) -> list[ImportMapping]:
543
+ if file_path not in _import_cache:
544
+ _import_cache[file_path] = load_import_mappings(conn, file_path)
545
+ return _import_cache[file_path]
546
+
547
+ rows = _fetch_incoming_edges(conn, {symbol})
548
+
549
+ # Deduplicate by (source_name, kind) — keep strongest (confidence, resolved_by,
550
+ # best_candidate, synthesized_by). All four provenance fields come from the same edge.
551
+ best: dict[tuple[str, str], tuple[str, str | None, str | None, str | None]] = {}
552
+
553
+ for src, tgt, kind, ref_file_path, language, synth_by in rows:
554
+ # Resolve confidence, using the cache to avoid repeated declaration SELECTs.
555
+ if use_import_promotion and ref_file_path and language:
556
+ cache_key = (ref_file_path, tgt)
557
+ if cache_key not in _resolution_cache:
558
+ import_mappings = _get_import_mappings(ref_file_path)
559
+ _resolution_cache[cache_key] = resolve_edge(
560
+ target_name=tgt,
561
+ name_counts=name_counts,
562
+ language=language,
563
+ import_mappings=import_mappings,
564
+ referencing_file=FilePath(ref_file_path),
565
+ repo_root=repo_root,
566
+ conn=conn,
567
+ max_import_candidates=config.SEAM_MAX_IMPORT_CANDIDATES,
568
+ max_proximity_candidates=config.SEAM_PROXIMITY_MAX_CANDIDATES,
569
+ )
570
+ resolution = _resolution_cache[cache_key]
571
+ resolved_confidence = resolution["confidence"]
572
+ resolved_by = resolution["resolved_by"]
573
+ best_candidate: str | None = resolution.get("best_candidate")
574
+ else:
575
+ resolved_confidence = resolve(tgt, name_counts)
576
+ resolved_by = None
577
+ best_candidate = None
578
+
579
+ key = (src, kind)
580
+ existing = best.get(key)
581
+ if existing is None:
582
+ best[key] = (resolved_confidence, resolved_by, best_candidate, synth_by)
583
+ else:
584
+ # Keep stronger confidence (higher rank); update all provenance fields.
585
+ if _rank(resolved_confidence) > _rank(existing[0]):
586
+ best[key] = (resolved_confidence, resolved_by, best_candidate, synth_by)
587
+
588
+ result: list[EdgeHop] = [
589
+ EdgeHop(
590
+ name=name,
591
+ kind=kind,
592
+ confidence=conf,
593
+ resolved_by=rby,
594
+ best_candidate=bc,
595
+ synthesized_by=synth_by,
596
+ )
597
+ for (name, kind), (conf, rby, bc, synth_by) in sorted(best.items())
598
+ ]
599
+ logger.debug(
600
+ "callers(%r): %d one-hop callers (import_promotion=%s)",
601
+ symbol,
602
+ len(result),
603
+ use_import_promotion,
604
+ )
605
+ return result
606
+
607
+
608
+ def callees(
609
+ conn: sqlite3.Connection,
610
+ symbol: str,
611
+ repo_root: FilePath | None = None,
612
+ ) -> list[EdgeHop]:
613
+ """Return all one-hop downstream neighbors (what `symbol` calls or imports).
614
+
615
+ Args:
616
+ conn: Open SQLite connection (read-only).
617
+ symbol: Symbol name to look up. Empty string returns [].
618
+ repo_root: Repository root for Phase 5 import-promotion resolution.
619
+ When provided and SEAM_IMPORT_RESOLUTION="on", each hop's
620
+ confidence is resolved via resolve_edge() so imported bindings
621
+ of homonyms promote to EXTRACTED 'import'. None -> name-count only.
622
+
623
+ Returns:
624
+ List of EdgeHop dicts, each with:
625
+ name — the callee/importee symbol name
626
+ kind — full closed vocabulary: call | import | extends | implements |
627
+ instantiates | holds | reads | writes | uses (E4 corrected)
628
+ confidence — EXTRACTED | INFERRED | AMBIGUOUS
629
+ resolved_by — Phase 5 provenance string (or None for fast-path)
630
+
631
+ Results are sorted by name alphabetically for determinism.
632
+ Returns [] if the symbol calls nothing or does not exist in the index.
633
+ """
634
+ if not symbol:
635
+ logger.debug("callees(): empty symbol — returning []")
636
+ return []
637
+
638
+ name_counts = load_name_counts(conn)
639
+ use_import_promotion = repo_root is not None and config.SEAM_IMPORT_RESOLUTION == "on"
640
+
641
+ _import_cache: dict[str, list[ImportMapping]] = {}
642
+
643
+ # Per-(file, target) resolution-result cache: avoids re-running resolve_edge
644
+ # for repeated (file, target) pairs in the outgoing-edge rows.
645
+ _resolution_cache: dict[tuple[str, str], Resolution] = {}
646
+
647
+ def _get_import_mappings(file_path: str) -> list[ImportMapping]:
648
+ if file_path not in _import_cache:
649
+ _import_cache[file_path] = load_import_mappings(conn, file_path)
650
+ return _import_cache[file_path]
651
+
652
+ rows = _fetch_outgoing_edges(conn, {symbol})
653
+
654
+ # Deduplicate by (target_name, kind) — keep strongest (confidence, resolved_by,
655
+ # best_candidate, synthesized_by). All four provenance fields come from the same edge.
656
+ best: dict[tuple[str, str], tuple[str, str | None, str | None, str | None]] = {}
657
+
658
+ for _src, tgt, kind, ref_file_path, language, synth_by in rows:
659
+ if use_import_promotion and ref_file_path and language:
660
+ cache_key = (ref_file_path, tgt)
661
+ if cache_key not in _resolution_cache:
662
+ import_mappings = _get_import_mappings(ref_file_path)
663
+ _resolution_cache[cache_key] = resolve_edge(
664
+ target_name=tgt,
665
+ name_counts=name_counts,
666
+ language=language,
667
+ import_mappings=import_mappings,
668
+ referencing_file=FilePath(ref_file_path),
669
+ repo_root=repo_root,
670
+ conn=conn,
671
+ max_import_candidates=config.SEAM_MAX_IMPORT_CANDIDATES,
672
+ max_proximity_candidates=config.SEAM_PROXIMITY_MAX_CANDIDATES,
673
+ )
674
+ resolution = _resolution_cache[cache_key]
675
+ resolved_confidence = resolution["confidence"]
676
+ resolved_by = resolution["resolved_by"]
677
+ best_candidate_c: str | None = resolution.get("best_candidate")
678
+ else:
679
+ resolved_confidence = resolve(tgt, name_counts)
680
+ resolved_by = None
681
+ best_candidate_c = None
682
+
683
+ key = (tgt, kind)
684
+ existing = best.get(key)
685
+ if existing is None:
686
+ best[key] = (resolved_confidence, resolved_by, best_candidate_c, synth_by)
687
+ else:
688
+ if _rank(resolved_confidence) > _rank(existing[0]):
689
+ best[key] = (resolved_confidence, resolved_by, best_candidate_c, synth_by)
690
+
691
+ result: list[EdgeHop] = [
692
+ EdgeHop(
693
+ name=name,
694
+ kind=kind,
695
+ confidence=conf,
696
+ resolved_by=rby,
697
+ best_candidate=bc,
698
+ synthesized_by=synth_by,
699
+ )
700
+ for (name, kind), (conf, rby, bc, synth_by) in sorted(best.items())
701
+ ]
702
+ logger.debug(
703
+ "callees(%r): %d one-hop callees (import_promotion=%s)",
704
+ symbol,
705
+ len(result),
706
+ use_import_promotion,
707
+ )
708
+ return result