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/query/pack.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Context-Pack primitive — Phase 6.
|
|
2
|
+
|
|
3
|
+
Single public function:
|
|
4
|
+
context_pack(conn, symbol_name) -> ContextPack | None
|
|
5
|
+
|
|
6
|
+
Orchestrates EXISTING read primitives into one ready-to-paste bundle:
|
|
7
|
+
- target: engine.context() verbatim (full 360-degree ContextResult)
|
|
8
|
+
- callers: 1-hop callers enriched to NeighborRef (name, file, line, kind, signature)
|
|
9
|
+
- callees: 1-hop callees enriched to NeighborRef
|
|
10
|
+
- why: comments.why(symbol=...) results, capped
|
|
11
|
+
- cluster_peers: taken directly from target (no extra query)
|
|
12
|
+
- truncated: {callers, callees, comments} counts of entries dropped by caps
|
|
13
|
+
|
|
14
|
+
WHY a new module instead of extending engine.py:
|
|
15
|
+
pack.py is deliberately thin orchestration. Keeping it separate makes it clear
|
|
16
|
+
that it adds no extraction logic — it only composes existing primitives.
|
|
17
|
+
engine.py is already large; mixing in cap/truncation logic would obscure the
|
|
18
|
+
core search/query/context path.
|
|
19
|
+
|
|
20
|
+
Caps are config-driven from seam/config.py:
|
|
21
|
+
SEAM_PACK_NEIGHBOR_LIMIT — global max per list (callers, callees)
|
|
22
|
+
SEAM_PACK_PER_FILE_CAP — max entries from any single file (diversity)
|
|
23
|
+
SEAM_PACK_MAX_COMMENTS — max WHY comments included
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import sqlite3
|
|
28
|
+
from typing import TypedDict
|
|
29
|
+
|
|
30
|
+
import seam.config as config
|
|
31
|
+
from seam.analysis.rwr import personalized_pagerank
|
|
32
|
+
from seam.analysis.testpaths import is_test_file
|
|
33
|
+
from seam.query.comments import CommentHit
|
|
34
|
+
from seam.query.comments import why as comments_why
|
|
35
|
+
from seam.query.engine import ContextResult, decode_enrichment_fields
|
|
36
|
+
from seam.query.engine import context as engine_context
|
|
37
|
+
from seam.query.names import edge_match_names as _edge_match_names
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
# WHY 900 (not 999): SQLite's hard host-parameter limit is 999 by default.
|
|
42
|
+
# Using 900 gives a safety margin for other bindings in the same statement and
|
|
43
|
+
# avoids breaking on SQLite builds compiled with a lower SQLITE_MAX_VARIABLE_NUMBER.
|
|
44
|
+
# This is a module constant (not a config knob) because it reflects a SQLite
|
|
45
|
+
# implementation constraint the user cannot influence at runtime.
|
|
46
|
+
_SQLITE_MAX_IN_PARAMS = 900
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── TypedDicts ────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class NeighborRef(TypedDict):
|
|
53
|
+
"""An enriched 1-hop neighbor entry.
|
|
54
|
+
|
|
55
|
+
The five PRD-required fields are: name, file, line, kind, signature.
|
|
56
|
+
We include all Phase 4 enrichment fields for null-contract consistency
|
|
57
|
+
with other tools (signature/decorators/is_exported/visibility/qualified_name).
|
|
58
|
+
Fields are None for pre-v5 rows or when extraction was not available.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
file: str
|
|
63
|
+
line: int
|
|
64
|
+
kind: str
|
|
65
|
+
signature: str | None
|
|
66
|
+
decorators: list[str]
|
|
67
|
+
is_exported: bool | None
|
|
68
|
+
visibility: str | None
|
|
69
|
+
qualified_name: str | None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class TruncatedCounts(TypedDict):
|
|
73
|
+
"""Counts of entries dropped by caps in each list."""
|
|
74
|
+
|
|
75
|
+
callers: int
|
|
76
|
+
callees: int
|
|
77
|
+
comments: int
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ContextPack(TypedDict):
|
|
81
|
+
"""A ready-to-paste context bundle for a symbol.
|
|
82
|
+
|
|
83
|
+
Returned by context_pack(). None means the symbol was not found.
|
|
84
|
+
|
|
85
|
+
Fields:
|
|
86
|
+
target: Full 360-degree ContextResult from engine.context().
|
|
87
|
+
callers: Enriched 1-hop callers (NeighborRef list, capped).
|
|
88
|
+
callees: Enriched 1-hop callees (NeighborRef list, capped).
|
|
89
|
+
why: WHY/HACK/NOTE/TODO/FIXME comments attached to the symbol (capped).
|
|
90
|
+
cluster_peers: Functional-area peers taken directly from target.cluster_peers.
|
|
91
|
+
truncated: Counts of entries dropped by caps (callers, callees, comments).
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
target: ContextResult
|
|
95
|
+
callers: list[NeighborRef]
|
|
96
|
+
callees: list[NeighborRef]
|
|
97
|
+
why: list[CommentHit]
|
|
98
|
+
cluster_peers: list[str]
|
|
99
|
+
truncated: TruncatedCounts
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── Internal helpers ──────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _fetch_local_subgraph(
|
|
106
|
+
conn: sqlite3.Connection,
|
|
107
|
+
seeds: set[str],
|
|
108
|
+
*,
|
|
109
|
+
max_depth: int,
|
|
110
|
+
max_nodes: int,
|
|
111
|
+
) -> dict[str, set[str]]:
|
|
112
|
+
"""Collect a bounded UNDIRECTED local subgraph around `seeds` from the edges table.
|
|
113
|
+
|
|
114
|
+
Depth-capped, node-capped BFS: from the current frontier, fetch every edge whose
|
|
115
|
+
source_name OR target_name is in the frontier, add both endpoints as undirected
|
|
116
|
+
neighbours, and advance the frontier — stopping at `max_depth` hops or once `max_nodes`
|
|
117
|
+
distinct nodes have been seen. This keeps the RWR walk O(subgraph), never a whole-graph load.
|
|
118
|
+
|
|
119
|
+
`seeds` is the symbol's edge_match_names (qualified + bare) so the walk is rooted at both
|
|
120
|
+
storage forms. Returns {name: set(neighbour names)} (always includes the seeds). Returns the
|
|
121
|
+
seed-only adjacency on any DB error — RWR then degrades to no ranking, never raises.
|
|
122
|
+
"""
|
|
123
|
+
adjacency: dict[str, set[str]] = {s: set() for s in seeds}
|
|
124
|
+
expanded: set[str] = set() # frontier nodes already queried (avoid re-expanding)
|
|
125
|
+
frontier: set[str] = set(seeds)
|
|
126
|
+
# Chunk the IN(...) to respect SQLite's host-parameter limit. The same name set is bound
|
|
127
|
+
# twice (source_name + target_name), so each chunk may use up to 2*len(chunk) params.
|
|
128
|
+
half = max(1, _SQLITE_MAX_IN_PARAMS // 2)
|
|
129
|
+
try:
|
|
130
|
+
for _ in range(max(0, max_depth)):
|
|
131
|
+
if not frontier or len(adjacency) >= max_nodes:
|
|
132
|
+
break
|
|
133
|
+
expanded |= frontier
|
|
134
|
+
next_frontier: set[str] = set()
|
|
135
|
+
frontier_list = sorted(frontier)
|
|
136
|
+
for start in range(0, len(frontier_list), half):
|
|
137
|
+
chunk = frontier_list[start : start + half]
|
|
138
|
+
ph = ",".join("?" * len(chunk))
|
|
139
|
+
rows = conn.execute(
|
|
140
|
+
f"SELECT source_name, target_name FROM edges "
|
|
141
|
+
f"WHERE source_name IN ({ph}) OR target_name IN ({ph})",
|
|
142
|
+
chunk + chunk,
|
|
143
|
+
).fetchall()
|
|
144
|
+
for row in rows:
|
|
145
|
+
s, t = row["source_name"], row["target_name"]
|
|
146
|
+
if s == t:
|
|
147
|
+
continue # self-loop: irrelevant to ranking
|
|
148
|
+
# Undirected: record both directions.
|
|
149
|
+
adjacency.setdefault(s, set()).add(t)
|
|
150
|
+
adjacency.setdefault(t, set()).add(s)
|
|
151
|
+
for endpoint in (s, t):
|
|
152
|
+
if endpoint not in expanded:
|
|
153
|
+
next_frontier.add(endpoint)
|
|
154
|
+
if len(adjacency) >= max_nodes:
|
|
155
|
+
break # hard stop on hub fan-out within a single depth level
|
|
156
|
+
frontier = next_frontier
|
|
157
|
+
except Exception: # noqa: BLE001 — read path must never raise.
|
|
158
|
+
logger.debug("_fetch_local_subgraph: DB error for seeds=%r", seeds, exc_info=True)
|
|
159
|
+
return {s: set() for s in seeds}
|
|
160
|
+
return adjacency
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _neighbor_scores(conn: sqlite3.Connection, seed: str) -> dict[str, float]:
|
|
164
|
+
"""Personalized-PageRank relevance scores for nodes near `seed` (E3).
|
|
165
|
+
|
|
166
|
+
Roots the walk at the seed's edge_match_names (qualified + bare) so neighbours reachable via
|
|
167
|
+
either storage form are scored against the same logical seed. Returns {} when ranking is
|
|
168
|
+
disabled (callers then fall back to min_id order) or on any failure. Never raises.
|
|
169
|
+
"""
|
|
170
|
+
if config.SEAM_PACK_RELEVANCE_RANK != "on":
|
|
171
|
+
return {}
|
|
172
|
+
try:
|
|
173
|
+
try:
|
|
174
|
+
seeds = set(_edge_match_names(conn, seed)) or {seed}
|
|
175
|
+
except Exception: # noqa: BLE001
|
|
176
|
+
seeds = {seed}
|
|
177
|
+
adjacency = _fetch_local_subgraph(
|
|
178
|
+
conn, seeds,
|
|
179
|
+
max_depth=config.SEAM_RWR_MAX_DEPTH,
|
|
180
|
+
max_nodes=config.SEAM_RWR_MAX_NODES,
|
|
181
|
+
)
|
|
182
|
+
return personalized_pagerank(adjacency, seeds)
|
|
183
|
+
except Exception: # noqa: BLE001 — ranking is best-effort; never break the pack.
|
|
184
|
+
logger.debug("_neighbor_scores: degraded for seed=%r", seed, exc_info=True)
|
|
185
|
+
return {}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _enrich_neighbors(
|
|
189
|
+
conn: sqlite3.Connection,
|
|
190
|
+
names: list[str],
|
|
191
|
+
*,
|
|
192
|
+
neighbor_limit: int,
|
|
193
|
+
per_file_cap: int,
|
|
194
|
+
scores: dict[str, float] | None = None,
|
|
195
|
+
) -> tuple[list[NeighborRef], int]:
|
|
196
|
+
"""Enrich a list of neighbor names to NeighborRef dicts and apply caps.
|
|
197
|
+
|
|
198
|
+
Returns (enriched_list, truncated_count).
|
|
199
|
+
|
|
200
|
+
truncated_count counts ONLY entries dropped by caps (global limit or per-file
|
|
201
|
+
cap). Names with no symbols row (external/unindexed symbols) are silently
|
|
202
|
+
skipped and NOT counted — a higher cap never retrieves them so counting them
|
|
203
|
+
would mislead agents into running fallback queries for phantom symbols.
|
|
204
|
+
|
|
205
|
+
Algorithm:
|
|
206
|
+
1. Deduplicate the input names while preserving order.
|
|
207
|
+
2. Batch-lookup all distinct names in chunked WHERE name IN (...) queries.
|
|
208
|
+
Chunks are at most _SQLITE_MAX_IN_PARAMS wide to avoid SQLite's hard
|
|
209
|
+
host-parameter limit (default 999). Tie-break: first match per name
|
|
210
|
+
by lowest symbol id (mirrors context()).
|
|
211
|
+
3. Build all_refs in min_id order (ORDER BY min_id in the SQL, preserved
|
|
212
|
+
via dict insertion order).
|
|
213
|
+
4. Apply per-file cap in min_id order.
|
|
214
|
+
5. Apply global neighbor_limit.
|
|
215
|
+
6. Count dropped entries (cap drops only, NOT unindexed skips).
|
|
216
|
+
|
|
217
|
+
WHY batch: caller/callee lists can be large for hot utilities. N+1 queries
|
|
218
|
+
(one context() call per name) would be O(n) round-trips. The batched IN(...)
|
|
219
|
+
is O(1) round-trips regardless of list size.
|
|
220
|
+
|
|
221
|
+
WHY per-file before global: the PRD spec (§4.5a) requires per-file cap
|
|
222
|
+
applied first so the global limit sees an already-diverse list.
|
|
223
|
+
"""
|
|
224
|
+
if not names:
|
|
225
|
+
return [], 0
|
|
226
|
+
|
|
227
|
+
# Step 1: deduplicate while preserving order
|
|
228
|
+
seen: set[str] = set()
|
|
229
|
+
unique_names: list[str] = []
|
|
230
|
+
for n in names:
|
|
231
|
+
if n not in seen:
|
|
232
|
+
seen.add(n)
|
|
233
|
+
unique_names.append(n)
|
|
234
|
+
|
|
235
|
+
if not unique_names:
|
|
236
|
+
return [], 0
|
|
237
|
+
|
|
238
|
+
# Step 2: batch-lookup in chunks of _SQLITE_MAX_IN_PARAMS.
|
|
239
|
+
# WHY chunks: SQLite's SQLITE_MAX_VARIABLE_NUMBER is 999 by default.
|
|
240
|
+
# One hot utility can have thousands of callers; chunking prevents
|
|
241
|
+
# OperationalError that would otherwise be swallowed by the context_pack
|
|
242
|
+
# except block, silently returning empty callers/callees for the busiest symbols.
|
|
243
|
+
# We select ALL Phase 4 enrichment fields for null-contract consistency.
|
|
244
|
+
sql_template = """
|
|
245
|
+
SELECT
|
|
246
|
+
s.name,
|
|
247
|
+
f.path AS file,
|
|
248
|
+
s.start_line AS line,
|
|
249
|
+
s.kind,
|
|
250
|
+
s.signature,
|
|
251
|
+
s.decorators,
|
|
252
|
+
s.is_exported,
|
|
253
|
+
s.visibility,
|
|
254
|
+
s.qualified_name,
|
|
255
|
+
MIN(s.id) AS min_id
|
|
256
|
+
FROM symbols s
|
|
257
|
+
JOIN files f ON f.id = s.file_id
|
|
258
|
+
WHERE s.name IN ({placeholders})
|
|
259
|
+
GROUP BY s.name
|
|
260
|
+
ORDER BY min_id
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
# Build a lookup: name -> NeighborRef (first match per name, lowest id wins).
|
|
264
|
+
# Each name appears in exactly one chunk, and each chunk is ORDER BY min_id, so
|
|
265
|
+
# ordering is exact min_id within a chunk. With ≤_SQLITE_MAX_IN_PARAMS distinct
|
|
266
|
+
# names (the overwhelmingly common case) there is a single chunk and the global
|
|
267
|
+
# order is exact; across multiple chunks the order is still fully deterministic
|
|
268
|
+
# for a given index (chunk slice order is fixed), just not globally min_id-sorted.
|
|
269
|
+
name_to_ref: dict[str, NeighborRef] = {}
|
|
270
|
+
|
|
271
|
+
for chunk_start in range(0, len(unique_names), _SQLITE_MAX_IN_PARAMS):
|
|
272
|
+
chunk = unique_names[chunk_start : chunk_start + _SQLITE_MAX_IN_PARAMS]
|
|
273
|
+
placeholders = ",".join("?" * len(chunk))
|
|
274
|
+
sql = sql_template.format(placeholders=placeholders)
|
|
275
|
+
rows = conn.execute(sql, chunk).fetchall()
|
|
276
|
+
|
|
277
|
+
for row in rows:
|
|
278
|
+
# Reuse the shared decode helper from engine.py — single source of truth
|
|
279
|
+
# for 0/1/NULL→bool and JSON TEXT→list[str] semantics.
|
|
280
|
+
decorators, is_exported = decode_enrichment_fields(row)
|
|
281
|
+
|
|
282
|
+
name_to_ref[row["name"]] = NeighborRef(
|
|
283
|
+
name=row["name"],
|
|
284
|
+
file=row["file"],
|
|
285
|
+
line=row["line"],
|
|
286
|
+
kind=row["kind"],
|
|
287
|
+
signature=row["signature"],
|
|
288
|
+
decorators=decorators,
|
|
289
|
+
is_exported=is_exported,
|
|
290
|
+
visibility=row["visibility"],
|
|
291
|
+
qualified_name=row["qualified_name"],
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Log unindexed skips at DEBUG level for observability.
|
|
295
|
+
# These are NOT cap drops — they are edges to external/unindexed symbols.
|
|
296
|
+
unindexed_count = len(unique_names) - len(name_to_ref)
|
|
297
|
+
if unindexed_count > 0:
|
|
298
|
+
logger.debug(
|
|
299
|
+
"_enrich_neighbors: skipped %d unindexed name(s) (external symbols, not in index)",
|
|
300
|
+
unindexed_count,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Step 3: Build all_refs in insertion order (= min_id order within the single
|
|
304
|
+
# common-case chunk; deterministic across chunks). dict insertion order is
|
|
305
|
+
# preserved (Python 3.7+), so values() reflects the Step-2 ordering.
|
|
306
|
+
all_refs: list[NeighborRef] = list(name_to_ref.values())
|
|
307
|
+
|
|
308
|
+
# Step 3b (E3): relevance-rank before the caps so the kept N are the most relevant neighbors,
|
|
309
|
+
# not the lowest-symbol-id ones. Key = (-PPR score, is_test, min_id):
|
|
310
|
+
# - higher personalized-PageRank score (relevance to the seed) first;
|
|
311
|
+
# - production before test on an equal score (usability tie-break);
|
|
312
|
+
# - min_id last — Python's stable sort preserves the Step-3 order within full ties, so an
|
|
313
|
+
# empty `scores` map (ranking disabled or RWR degraded) is a byte-identical no-op.
|
|
314
|
+
if scores:
|
|
315
|
+
all_refs.sort(
|
|
316
|
+
key=lambda r: (-scores.get(r["name"], 0.0), is_test_file(r["file"]))
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Step 4: Per-file cap — count entries per file; drop once cap is hit.
|
|
320
|
+
# Iterate in min_id order (already correct from Step 3).
|
|
321
|
+
file_counts: dict[str, int] = {}
|
|
322
|
+
capped_refs: list[NeighborRef] = []
|
|
323
|
+
for ref in all_refs:
|
|
324
|
+
file_key = ref["file"]
|
|
325
|
+
count = file_counts.get(file_key, 0)
|
|
326
|
+
if count < per_file_cap:
|
|
327
|
+
capped_refs.append(ref)
|
|
328
|
+
file_counts[file_key] = count + 1
|
|
329
|
+
# else: silently drop (counted in truncated below)
|
|
330
|
+
|
|
331
|
+
# Step 5: Global limit
|
|
332
|
+
final = capped_refs[:neighbor_limit]
|
|
333
|
+
|
|
334
|
+
# Step 6: truncated counts ONLY cap drops (per-file + global).
|
|
335
|
+
# Unindexed skips are NOT counted — a higher cap never retrieves them.
|
|
336
|
+
truncated = (len(all_refs) - len(capped_refs)) + (len(capped_refs) - len(final))
|
|
337
|
+
|
|
338
|
+
return final, truncated
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ── Public API ────────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def context_pack(
|
|
345
|
+
conn: sqlite3.Connection,
|
|
346
|
+
symbol_name: str,
|
|
347
|
+
) -> ContextPack | None:
|
|
348
|
+
"""Build a ready-to-paste context bundle for a symbol.
|
|
349
|
+
|
|
350
|
+
Returns None when the symbol is not in the index (same contract as context()).
|
|
351
|
+
Every sub-lookup degrades to empty rather than raising — NEVER raises.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
conn: Open SQLite connection to the Seam index (read-only).
|
|
355
|
+
symbol_name: The symbol name to look up.
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
A ContextPack TypedDict, or None if the symbol is not found.
|
|
359
|
+
"""
|
|
360
|
+
# Step 1: Fetch full 360-degree target context.
|
|
361
|
+
# context() returns None for unknown symbols — propagate that contract.
|
|
362
|
+
try:
|
|
363
|
+
target = engine_context(conn, symbol_name)
|
|
364
|
+
except Exception:
|
|
365
|
+
# engine.context() should never raise for valid DB, but degrade gracefully.
|
|
366
|
+
logger.warning("context_pack: engine.context() raised for %r", symbol_name, exc_info=True)
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
if target is None:
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
# Step 2: Gather caller/callee names from target (bare string lists).
|
|
373
|
+
caller_names: list[str] = target["callers"]
|
|
374
|
+
callee_names: list[str] = target["callees"]
|
|
375
|
+
|
|
376
|
+
# Step 3: Enrich callers and callees in batched lookups.
|
|
377
|
+
# Degrade gracefully on any DB error (empty list, not a crash).
|
|
378
|
+
neighbor_limit = config.SEAM_PACK_NEIGHBOR_LIMIT
|
|
379
|
+
per_file_cap = config.SEAM_PACK_PER_FILE_CAP
|
|
380
|
+
|
|
381
|
+
# E3: compute personalized-PageRank relevance scores ONCE (one bounded subgraph + one walk,
|
|
382
|
+
# personalized to the seed) and reuse for both caller and callee ranking. Empty when ranking
|
|
383
|
+
# is disabled or RWR degrades → enrichment falls back to min_id order. Never raises.
|
|
384
|
+
scores = _neighbor_scores(conn, target["symbol"])
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
enriched_callers, callers_dropped = _enrich_neighbors(
|
|
388
|
+
conn, caller_names,
|
|
389
|
+
neighbor_limit=neighbor_limit,
|
|
390
|
+
per_file_cap=per_file_cap,
|
|
391
|
+
scores=scores,
|
|
392
|
+
)
|
|
393
|
+
except Exception:
|
|
394
|
+
logger.warning("context_pack: caller enrichment failed for %r", symbol_name, exc_info=True)
|
|
395
|
+
enriched_callers, callers_dropped = [], 0
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
enriched_callees, callees_dropped = _enrich_neighbors(
|
|
399
|
+
conn, callee_names,
|
|
400
|
+
neighbor_limit=neighbor_limit,
|
|
401
|
+
per_file_cap=per_file_cap,
|
|
402
|
+
scores=scores,
|
|
403
|
+
)
|
|
404
|
+
except Exception:
|
|
405
|
+
logger.warning("context_pack: callee enrichment failed for %r", symbol_name, exc_info=True)
|
|
406
|
+
enriched_callees, callees_dropped = [], 0
|
|
407
|
+
|
|
408
|
+
# Step 4: Fetch WHY comments, apply comment cap.
|
|
409
|
+
max_comments = config.SEAM_PACK_MAX_COMMENTS
|
|
410
|
+
try:
|
|
411
|
+
all_comments = comments_why(conn, symbol=symbol_name)
|
|
412
|
+
except Exception:
|
|
413
|
+
logger.warning("context_pack: comments.why() raised for %r", symbol_name, exc_info=True)
|
|
414
|
+
all_comments = []
|
|
415
|
+
|
|
416
|
+
comments_dropped = max(0, len(all_comments) - max_comments)
|
|
417
|
+
capped_comments = all_comments[:max_comments]
|
|
418
|
+
|
|
419
|
+
# Step 5: Cluster peers come directly from target (no extra query needed).
|
|
420
|
+
cluster_peers: list[str] = target.get("cluster_peers") or []
|
|
421
|
+
|
|
422
|
+
return ContextPack(
|
|
423
|
+
target=target,
|
|
424
|
+
callers=enriched_callers,
|
|
425
|
+
callees=enriched_callees,
|
|
426
|
+
why=capped_comments,
|
|
427
|
+
cluster_peers=cluster_peers,
|
|
428
|
+
truncated=TruncatedCounts(
|
|
429
|
+
callers=callers_dropped,
|
|
430
|
+
callees=callees_dropped,
|
|
431
|
+
comments=comments_dropped,
|
|
432
|
+
),
|
|
433
|
+
)
|