seam-code 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- seam/__init__.py +3 -0
- seam/_data/schema.sql +225 -0
- seam/_web/assets/index-BL_tqprR.js +216 -0
- seam/_web/assets/index-GTKUhVyD.css +1 -0
- seam/_web/index.html +13 -0
- seam/analysis/__init__.py +14 -0
- seam/analysis/affected.py +254 -0
- seam/analysis/builtins.py +966 -0
- seam/analysis/byte_budget.py +217 -0
- seam/analysis/changes.py +709 -0
- seam/analysis/cluster_naming.py +260 -0
- seam/analysis/clustering.py +216 -0
- seam/analysis/confidence.py +699 -0
- seam/analysis/embeddings.py +195 -0
- seam/analysis/flows.py +708 -0
- seam/analysis/impact.py +444 -0
- seam/analysis/imports.py +994 -0
- seam/analysis/imports_ext.py +780 -0
- seam/analysis/imports_resolve.py +176 -0
- seam/analysis/processes.py +453 -0
- seam/analysis/relevance.py +155 -0
- seam/analysis/rwr.py +129 -0
- seam/analysis/staleness.py +328 -0
- seam/analysis/steer.py +282 -0
- seam/analysis/synthesis.py +253 -0
- seam/analysis/synthesis_channels.py +433 -0
- seam/analysis/testpaths.py +103 -0
- seam/analysis/traversal.py +470 -0
- seam/cli/__init__.py +0 -0
- seam/cli/install.py +232 -0
- seam/cli/main.py +2602 -0
- seam/cli/output.py +137 -0
- seam/cli/read.py +244 -0
- seam/cli/serve.py +145 -0
- seam/config.py +551 -0
- seam/indexer/__init__.py +0 -0
- seam/indexer/cluster_index.py +425 -0
- seam/indexer/db.py +496 -0
- seam/indexer/embedding_index.py +183 -0
- seam/indexer/field_access.py +536 -0
- seam/indexer/field_access_c_cpp.py +643 -0
- seam/indexer/field_access_ext.py +708 -0
- seam/indexer/field_access_ext2.py +408 -0
- seam/indexer/field_access_go_rust.py +737 -0
- seam/indexer/field_access_php_swift.py +888 -0
- seam/indexer/field_access_ts.py +626 -0
- seam/indexer/graph.py +321 -0
- seam/indexer/graph_c.py +562 -0
- seam/indexer/graph_c_cpp.py +39 -0
- seam/indexer/graph_common.py +644 -0
- seam/indexer/graph_cpp.py +615 -0
- seam/indexer/graph_csharp.py +651 -0
- seam/indexer/graph_go.py +723 -0
- seam/indexer/graph_go_rust.py +39 -0
- seam/indexer/graph_java.py +689 -0
- seam/indexer/graph_java_csharp.py +38 -0
- seam/indexer/graph_php.py +914 -0
- seam/indexer/graph_python.py +628 -0
- seam/indexer/graph_ruby.py +748 -0
- seam/indexer/graph_rust.py +653 -0
- seam/indexer/graph_scope_infer.py +902 -0
- seam/indexer/graph_scope_infer_ext.py +723 -0
- seam/indexer/graph_scope_infer_ext2.py +992 -0
- seam/indexer/graph_swift.py +1014 -0
- seam/indexer/graph_swift_infer.py +515 -0
- seam/indexer/graph_typescript.py +663 -0
- seam/indexer/migrations.py +816 -0
- seam/indexer/parser.py +204 -0
- seam/indexer/pipeline.py +197 -0
- seam/indexer/signatures.py +634 -0
- seam/indexer/signatures_ext.py +780 -0
- seam/indexer/sync.py +287 -0
- seam/indexer/synthesis_index.py +291 -0
- seam/indexer/tokenize.py +79 -0
- seam/installer/__init__.py +67 -0
- seam/installer/claude.py +97 -0
- seam/installer/codex.py +94 -0
- seam/installer/core.py +127 -0
- seam/installer/cursor.py +61 -0
- seam/installer/guide.py +110 -0
- seam/installer/jsonfile.py +85 -0
- seam/installer/markdownfile.py +146 -0
- seam/installer/tomlfile.py +72 -0
- seam/query/__init__.py +0 -0
- seam/query/clusters.py +206 -0
- seam/query/comments.py +217 -0
- seam/query/context.py +293 -0
- seam/query/engine.py +940 -0
- seam/query/fts.py +328 -0
- seam/query/names.py +470 -0
- seam/query/pack.py +433 -0
- seam/query/semantic.py +339 -0
- seam/query/structure.py +727 -0
- seam/server/__init__.py +0 -0
- seam/server/graph_api.py +437 -0
- seam/server/handler_common.py +323 -0
- seam/server/impact_handler.py +615 -0
- seam/server/mcp.py +556 -0
- seam/server/tools.py +697 -0
- seam/server/trace_handler.py +184 -0
- seam/server/web.py +922 -0
- seam/watcher/__init__.py +0 -0
- seam/watcher/__main__.py +56 -0
- seam/watcher/daemon.py +237 -0
- seam_code-0.3.0.dist-info/METADATA +318 -0
- seam_code-0.3.0.dist-info/RECORD +109 -0
- seam_code-0.3.0.dist-info/WHEEL +4 -0
- seam_code-0.3.0.dist-info/entry_points.txt +2 -0
- seam_code-0.3.0.dist-info/licenses/LICENSE +21 -0
seam/analysis/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
|