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/query/engine.py ADDED
@@ -0,0 +1,940 @@
1
+ """Query engine — read path for all MCP tool queries.
2
+
3
+ All functions take an open sqlite3.Connection. No connection management here.
4
+ Returns typed dicts matching the MCP tool output spec in docs/api-contracts/mcp-tools.yaml.
5
+
6
+ context(): Tier A Slice 2 — resolves bare names to all qualified definitions and merges
7
+ callers/callees. See seam/query/names.py for the qualified<->bare bridging details.
8
+ search() / query(): FTS5 BM25 + OR-join + rescore + LIKE/fuzzy fallback (Phase 3).
9
+ Phase 3 (Slice 1): OR-join expression via fts.build_match_query() prevents one non-matching
10
+ word from zeroing the result; fts.rescore() applies name/path/test/cluster signals.
11
+ Phase 3 (Slice 2): LIKE→fuzzy two-tier fallback (FTS zero → LIKE %term% → DL fuzzy).
12
+ Hybrid semantic (T6): opt-in RRF merge when SEAM_SEMANTIC=on and embeddings present.
13
+ RRF (Reciprocal Rank Fusion) merges BM25 FTS id-list with cosine semantic id-list.
14
+ FTS snippets are preserved for FTS hits; semantic-only hits get snippet="".
15
+ The hybrid path is skipped transparently if SEAM_SEMANTIC=off or embeddings missing.
16
+
17
+ Context helpers (_collect_edges_for_names, _build_merged_context_result, _build_context_result)
18
+ live in seam/query/context.py to keep engine.py under the 1000-line limit.
19
+ """
20
+
21
+ import json
22
+ import logging
23
+ import sqlite3
24
+ from typing import TypedDict, cast
25
+
26
+ import seam.config as config
27
+ from seam.config import SEAM_FUZZY_MAX_CANDIDATES, SEAM_FUZZY_MAX_DIST
28
+ from seam.query import fts
29
+ from seam.query.context import build_context_result as _build_context_result_fn
30
+ from seam.query.context import build_merged_context_result as _build_merged_context_result_fn
31
+ from seam.query.context import collect_edges_for_names as _collect_edges_for_names
32
+ from seam.query.fts import extract_terms as _extract_terms
33
+ from seam.query.names import edge_match_names as _edge_match_names
34
+ from seam.query.names import resolve_query_to_defs as _resolve_query_to_defs
35
+ from seam.query.semantic import rrf_merge, semantic_candidates
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # Module-level flag: emit the "SEAM_SEMANTIC=on but no embeddings" warning at most once
40
+ # per process. Without this, every search/query call would spam the same warning.
41
+ _hybrid_warned: bool = False
42
+
43
+
44
+ class QueryResult(TypedDict):
45
+ symbol: str
46
+ file: str
47
+ line: int
48
+ score: float
49
+ callers_count: int
50
+ callees_count: int
51
+
52
+
53
+ class ContextResult(TypedDict):
54
+ symbol: str
55
+ file: str
56
+ line: int
57
+ end_line: int
58
+ kind: str
59
+ docstring: str | None
60
+ callers: list[str]
61
+ callees: list[str]
62
+ ambiguous: bool # True when multiple symbols share this name in the index (Phase 1)
63
+ cluster_id: int | None # Phase 2: cluster this symbol belongs to (None if not clustered)
64
+ cluster_label: str | None # Phase 2: human-readable cluster label
65
+ cluster_peers: list[str] # Phase 2: other symbols in the same cluster (may be empty)
66
+ # Phase 4 enrichment fields. All None for pre-v5 rows (no migration yet) or
67
+ # unsupported languages — callers should treat None as "unknown", not as absent.
68
+ signature: str | None
69
+ decorators: list[str] # [] when none extracted or for pre-v5 rows
70
+ is_exported: bool | None
71
+ visibility: str | None
72
+ qualified_name: str | None
73
+ # A3: field-access split. For kind='field' seeds, these list the functions that
74
+ # read/write this field via reads/writes edges. For class seeds, aggregated across
75
+ # all fields. For function/method seeds, both are [] (no reads/writes edges target
76
+ # a function; the field only has access sites from methods).
77
+ # Always present ([] not absent) so agents can check without key-existence guards.
78
+ field_readers: list[str]
79
+ field_writers: list[str]
80
+
81
+
82
+ class SearchResult(TypedDict):
83
+ symbol: str
84
+ file: str
85
+ line: int
86
+ snippet: str
87
+ score: float
88
+
89
+
90
+ # ── Shared Phase 4 enrichment decoder ────────────────────────────────────────
91
+
92
+
93
+ def decode_enrichment_fields(row: sqlite3.Row) -> tuple[list[str], bool | None]:
94
+ """Decode the Phase 4 SQLite enrichment columns from a DB row.
95
+
96
+ Returns (decorators, is_exported) ready for use in ContextResult or NeighborRef.
97
+
98
+ WHY extracted: context() and pack._enrich_neighbors both need to decode the
99
+ same two SQLite-encoded fields. Keeping the logic in one place ensures they
100
+ always agree on null-contract semantics and avoids drift over time.
101
+
102
+ Rules:
103
+ decorators: NULL → [] (pre-v5 row or nothing extracted)
104
+ JSON TEXT → decoded list (corrupted JSON → [] gracefully)
105
+ is_exported: NULL → None (pre-v5 row or extraction unavailable)
106
+ 0 → False
107
+ 1 → True
108
+ """
109
+ raw_dec = row["decorators"]
110
+ raw_exp = row["is_exported"]
111
+
112
+ # Decode decorators: stored as JSON TEXT; pre-v5 rows have NULL.
113
+ if raw_dec is None:
114
+ decorators: list[str] = []
115
+ else:
116
+ try:
117
+ decorators = json.loads(raw_dec)
118
+ except (json.JSONDecodeError, TypeError, ValueError):
119
+ # Corrupted JSON — degrade gracefully, never crash the read path.
120
+ decorators = []
121
+
122
+ # Decode is_exported: SQLite has no native bool; stored as 0/1/NULL.
123
+ if raw_exp is None:
124
+ is_exported: bool | None = None
125
+ else:
126
+ is_exported = bool(raw_exp)
127
+
128
+ return decorators, is_exported
129
+
130
+
131
+ # ── Damerau-Levenshtein edit distance (pure-Python, bounded) ─────────────────
132
+
133
+
134
+ def _bounded_edit_distance(a: str, b: str, max_dist: int) -> int:
135
+ """Compute the Damerau-Levenshtein edit distance between a and b.
136
+
137
+ Returns the true distance, or max_dist+1 if it exceeds max_dist.
138
+ The early-exit bound keeps the inner loop cost manageable when scanning
139
+ many candidates.
140
+
141
+ WHY Damerau-Levenshtein (not plain Levenshtein):
142
+ Transpositions (e.g. 'authenitcate' → 'authenticate') are the most
143
+ common real-world typo pattern. DL handles them as cost=1 rather than
144
+ cost=2, giving better recall for common typos.
145
+ """
146
+ la, lb = len(a), len(b)
147
+ # Early bound: if lengths differ by more than max_dist, impossible to match
148
+ if abs(la - lb) > max_dist:
149
+ return max_dist + 1
150
+
151
+ # DP matrix: (la+1) x (lb+1)
152
+ # Row represents current character of a; column represents current char of b.
153
+ prev_prev = list(range(lb + 1))
154
+ prev = [0] * (lb + 1)
155
+ curr = [0] * (lb + 1)
156
+
157
+ for i in range(1, la + 1):
158
+ curr[0] = i
159
+ row_min = i
160
+ for j in range(1, lb + 1):
161
+ cost = 0 if a[i - 1] == b[j - 1] else 1
162
+ curr[j] = min(
163
+ prev[j] + 1, # deletion
164
+ curr[j - 1] + 1, # insertion
165
+ prev[j - 1] + cost, # substitution
166
+ )
167
+ # Transposition: swap adjacent chars
168
+ if i > 1 and j > 1 and a[i - 1] == b[j - 2] and a[i - 2] == b[j - 1]:
169
+ curr[j] = min(curr[j], prev_prev[j - 2] + cost)
170
+ row_min = min(row_min, curr[j])
171
+
172
+ # Early-exit: if the entire row is above max_dist, no path can succeed
173
+ if row_min > max_dist:
174
+ return max_dist + 1
175
+
176
+ prev_prev, prev, curr = prev, curr, [0] * (lb + 1)
177
+
178
+ return prev[lb]
179
+
180
+
181
+ # ── LIKE→fuzzy fallback ───────────────────────────────────────────────────────
182
+
183
+
184
+ def _escape_like(term: str) -> str:
185
+ """Escape LIKE metacharacters in a term so they are treated as literals.
186
+
187
+ SQLite LIKE has three special characters: % (any sequence), _ (any single char),
188
+ and the escape char itself (here: backslash). Escaping prevents a query for
189
+ 'get_user' from matching 'getXuser' due to the _ wildcard.
190
+
191
+ Must be paired with ESCAPE '\\' in the SQL clause.
192
+ """
193
+ # Order matters: escape backslash first so we do not double-escape
194
+ term = term.replace("\\", "\\\\")
195
+ term = term.replace("%", "\\%")
196
+ term = term.replace("_", "\\_")
197
+ return term
198
+
199
+
200
+ def _like_fallback(
201
+ conn: sqlite3.Connection,
202
+ term: str,
203
+ limit: int,
204
+ ) -> list[dict]:
205
+ """LIKE %term% substring query — second tier fallback after FTS returns zero rows.
206
+
207
+ Returns rows in the same dict shape as the FTS query (symbol, file, line,
208
+ snippet="", score=0.0, cluster_id) so they can pass through rescore().
209
+
210
+ WHY escape: SQLite LIKE treats '_' as a single-char wildcard and '%' as a
211
+ multi-char wildcard. Without escaping, a search for 'get_user' matches
212
+ 'getXuser', 'get1user', etc. — over-broadening the fallback results.
213
+ """
214
+ escaped = _escape_like(term)
215
+ # Phase 4: SELECT s.signature so rescore() Signal-6 can fire for LIKE fallback rows.
216
+ sql = """
217
+ SELECT
218
+ s.name AS symbol,
219
+ f.path AS file,
220
+ s.start_line AS line,
221
+ s.cluster_id AS cluster_id,
222
+ s.signature AS signature
223
+ FROM symbols s
224
+ JOIN files f ON f.id = s.file_id
225
+ WHERE s.name LIKE ? ESCAPE '\\'
226
+ LIMIT ?
227
+ """
228
+ rows = conn.execute(sql, (f"%{escaped}%", limit)).fetchall()
229
+ return [
230
+ {
231
+ "symbol": row["symbol"],
232
+ "file": row["file"],
233
+ "line": row["line"],
234
+ "snippet": "",
235
+ "score": 0.0,
236
+ "cluster_id": row["cluster_id"],
237
+ # Phase 4: include signature for rescore Signal-6 (nullable — pre-v5 rows have NULL).
238
+ "signature": row["signature"],
239
+ }
240
+ for row in rows
241
+ ]
242
+
243
+
244
+ def _fuzzy_fallback(
245
+ conn: sqlite3.Connection,
246
+ term: str,
247
+ max_dist: int,
248
+ candidate_cap: int,
249
+ limit: int,
250
+ ) -> list[dict]:
251
+ """Bounded Damerau-Levenshtein fuzzy match over distinct symbol names.
252
+
253
+ Third-tier fallback. Reads at most candidate_cap distinct symbol names from
254
+ the DB, computes edit distance against `term`, and returns those within max_dist.
255
+
256
+ WHY bounded candidate cap: this is O(n * |term|) per call. On large indexes
257
+ (thousands of symbols), scanning all names would be slow. The cap keeps the
258
+ tail-case bounded; SEAM_FUZZY_MAX_CANDIDATES controls it via env var.
259
+
260
+ WHY length-window pre-filter: only names whose length is within max_dist of the
261
+ term length can possibly achieve edit-distance <= max_dist. Filtering in SQL
262
+ before the Python DL loop avoids scanning irrelevant names (e.g. a 20-char name
263
+ can never match a 3-char term at dist=1). This also makes the cap more meaningful:
264
+ all names that CAN match are eligible, not just the first N in rowid order.
265
+
266
+ WHY ORDER BY name: deterministic ordering means repeated calls with the same
267
+ inputs return the same result set (reproducible for agents/tests).
268
+ """
269
+ term_lower = term.lower()
270
+ term_len = len(term_lower)
271
+
272
+ # Pre-filter by length window: |len(name) - len(term)| <= max_dist is a necessary
273
+ # (not sufficient) condition for edit-distance <= max_dist. Filter in SQL so we
274
+ # only pull names that can possibly match, then order deterministically.
275
+ name_rows = conn.execute(
276
+ """
277
+ SELECT DISTINCT name FROM symbols
278
+ WHERE length(name) BETWEEN ? AND ?
279
+ ORDER BY name
280
+ LIMIT ?
281
+ """,
282
+ (max(1, term_len - max_dist), term_len + max_dist, candidate_cap),
283
+ ).fetchall()
284
+
285
+ matches: list[str] = []
286
+ for row in name_rows:
287
+ name = row["name"]
288
+ dist = _bounded_edit_distance(term_lower, name.lower(), max_dist)
289
+ if dist <= max_dist:
290
+ matches.append(name)
291
+
292
+ if not matches:
293
+ return []
294
+
295
+ # Fetch full rows for all matched symbol names.
296
+ # Phase 4: SELECT s.signature so rescore() Signal-6 can fire for fuzzy fallback rows.
297
+ placeholders = ",".join("?" * len(matches))
298
+ sql = f"""
299
+ SELECT
300
+ s.name AS symbol,
301
+ f.path AS file,
302
+ s.start_line AS line,
303
+ s.cluster_id AS cluster_id,
304
+ s.signature AS signature
305
+ FROM symbols s
306
+ JOIN files f ON f.id = s.file_id
307
+ WHERE s.name IN ({placeholders})
308
+ LIMIT ?
309
+ """
310
+ rows = conn.execute(sql, matches + [limit]).fetchall()
311
+ return [
312
+ {
313
+ "symbol": row["symbol"],
314
+ "file": row["file"],
315
+ "line": row["line"],
316
+ "snippet": "",
317
+ "score": 0.0,
318
+ "cluster_id": row["cluster_id"],
319
+ # Phase 4: include signature for rescore Signal-6 (nullable — pre-v5 rows have NULL).
320
+ "signature": row["signature"],
321
+ }
322
+ for row in rows
323
+ ]
324
+
325
+
326
+ # _extract_terms is imported from seam.query.fts rather than duplicated here because
327
+ # both the MATCH expression builder (build_match_query) and the rescore signal
328
+ # computation must tokenise identically. A local copy would silently drift.
329
+
330
+
331
+ # ── Hybrid search helpers ─────────────────────────────────────────────────────
332
+
333
+
334
+ def _hydrate_symbol_rows(
335
+ conn: sqlite3.Connection, symbol_ids: list[int]
336
+ ) -> dict[int, dict]:
337
+ """Fetch symbol + file data for a list of symbol IDs.
338
+
339
+ Returns a dict mapping symbol_id → row dict with keys:
340
+ symbol, file, line, cluster_id, signature.
341
+
342
+ Used by the hybrid path to hydrate RRF-merged id lists into full rows.
343
+ WHY: After rrf_merge we have a ranked list of symbol_ids. To produce
344
+ SearchResult objects we need name, file, line etc. One query fetches all
345
+ needed columns at once rather than N per-id lookups.
346
+ """
347
+ if not symbol_ids:
348
+ return {}
349
+ placeholders = ",".join("?" * len(symbol_ids))
350
+ sql = f"""
351
+ SELECT
352
+ s.id AS id,
353
+ s.name AS symbol,
354
+ f.path AS file,
355
+ s.start_line AS line,
356
+ s.cluster_id AS cluster_id,
357
+ s.signature AS signature
358
+ FROM symbols s
359
+ JOIN files f ON f.id = s.file_id
360
+ WHERE s.id IN ({placeholders})
361
+ """
362
+ rows = conn.execute(sql, symbol_ids).fetchall()
363
+ return {row["id"]: dict(row) for row in rows}
364
+
365
+
366
+ def _is_hybrid_enabled(conn: sqlite3.Connection) -> bool:
367
+ """Return True when the hybrid path should be used for this query.
368
+
369
+ Conditions (all must hold):
370
+ 1. SEAM_SEMANTIC config is 'on'.
371
+ 2. The embeddings table has at least one row for the configured model.
372
+
373
+ WHY check at query time vs. at startup: the embeddings table can be
374
+ populated between process starts without restarting the server. Checking
375
+ per-query ensures newly indexed embeddings are immediately available without
376
+ requiring a server restart. The check is a single COUNT(*) — negligible cost.
377
+ """
378
+ if config.SEAM_SEMANTIC != "on":
379
+ return False
380
+ count = conn.execute(
381
+ "SELECT COUNT(*) FROM embeddings WHERE model = ?",
382
+ (config.SEAM_EMBED_MODEL,),
383
+ ).fetchone()[0]
384
+ if count == 0:
385
+ # SEAM_SEMANTIC=on but no embeddings — warn once per process.
386
+ global _hybrid_warned # noqa: PLW0603
387
+ if not _hybrid_warned:
388
+ _hybrid_warned = True
389
+ logger.warning(
390
+ "_is_hybrid_enabled: SEAM_SEMANTIC=on but no embeddings found for "
391
+ "model=%r. Run 'seam init --semantic' to build the embedding index.",
392
+ config.SEAM_EMBED_MODEL,
393
+ )
394
+ return False
395
+ return True
396
+
397
+
398
+ # ── Hybrid search results helper ─────────────────────────────────────────────
399
+
400
+
401
+ def _hybrid_search_results(
402
+ conn: sqlite3.Connection,
403
+ text: str,
404
+ fts_rows: list[dict],
405
+ fts_symbol_ids: list[int],
406
+ limit: int,
407
+ ) -> list[SearchResult] | None:
408
+ """Compute hybrid (FTS + semantic) search results via RRF.
409
+
410
+ Returns a list of SearchResult when semantic adds new candidates beyond FTS,
411
+ or None to signal "fall through to the FTS-only path".
412
+
413
+ WHY extracted from search(): to keep search() under 200 lines (DRIFT-2).
414
+ WHY returns None: a None return lets search() stay in the normal FTS path
415
+ when semantic adds nothing new — semantically clear vs. returning [].
416
+
417
+ Snippet contract (STOP-2):
418
+ FTS rows carry real BM25 snippets. When a merged result was found by FTS,
419
+ we preserve its snippet. Semantic-only results get snippet="" (no FTS row).
420
+ """
421
+ sem_candidates = semantic_candidates(
422
+ conn,
423
+ text,
424
+ model=config.SEAM_EMBED_MODEL,
425
+ limit=config.SEAM_SEMANTIC_LIMIT,
426
+ )
427
+ sem_ids = [sid for sid, _score in sem_candidates]
428
+
429
+ # Check if semantic added any new candidates beyond FTS.
430
+ fts_id_set = set(fts_symbol_ids)
431
+ new_sem_ids = [sid for sid in sem_ids if sid not in fts_id_set]
432
+
433
+ if not new_sem_ids:
434
+ return None # No new recall — fall through to FTS rescore path.
435
+
436
+ # Build snippet + score lookup for FTS rows (preserve real BM25 snippets).
437
+ fts_id_to_snippet: dict[int, str] = {r["id"]: r.get("snippet", "") for r in fts_rows}
438
+
439
+ # RRF merge: combine FTS id list (BM25-ordered) and semantic id list (cosine-ordered).
440
+ merged_ids = rrf_merge(fts_symbol_ids, sem_ids)
441
+
442
+ # Hydrate the merged id list into full rows (batch fetch — one SQL query).
443
+ id_to_row = _hydrate_symbol_rows(conn, merged_ids)
444
+
445
+ # Build SearchResult list in merged-rank order. RRF score = 1/(k+rank).
446
+ k = config.SEAM_RRF_K
447
+ results: list[SearchResult] = []
448
+ for rank, sym_id in enumerate(merged_ids[:limit], start=1):
449
+ if sym_id not in id_to_row:
450
+ continue
451
+ row = id_to_row[sym_id]
452
+ rrf_score = 1.0 / (k + rank)
453
+ # Use the real FTS snippet for ids that came from FTS; "" for semantic-only.
454
+ snippet = fts_id_to_snippet.get(sym_id, "")
455
+ results.append(
456
+ SearchResult(
457
+ symbol=row["symbol"],
458
+ file=row["file"],
459
+ line=row["line"],
460
+ snippet=snippet,
461
+ score=rrf_score,
462
+ )
463
+ )
464
+
465
+ if results:
466
+ logger.debug(
467
+ "search: hybrid path returned %d results (%d FTS + %d new semantic) for %r",
468
+ len(results),
469
+ len(fts_symbol_ids),
470
+ len(new_sem_ids),
471
+ text,
472
+ )
473
+
474
+ return results if results else None
475
+
476
+
477
+ # ── search ───────────────────────────────────────────────────────────────────
478
+
479
+
480
+ def search(
481
+ conn: sqlite3.Connection,
482
+ text: str,
483
+ limit: int = 20,
484
+ *,
485
+ semantic: bool = True,
486
+ ) -> list[SearchResult]:
487
+ """Full-text search across symbol names and docstrings.
488
+
489
+ Phase 3 (Slice 1) changes vs. the original:
490
+ - Builds the MATCH expression via fts.build_match_query() (OR-join of prefix
491
+ terms) instead of passing raw text. This prevents one non-matching word from
492
+ zeroing the entire result set (the implicit-AND bug).
493
+ - Passes FTS rows through fts.rescore() for multi-signal ranking.
494
+ - LIKE→fuzzy fallback: when FTS returns zero rows:
495
+ 1. Falls back to LIKE %term% per term.
496
+ 2. If still empty and term ≥3 chars, tries bounded Damerau-Levenshtein
497
+ fuzzy match over distinct symbol names (capped at SEAM_FUZZY_MAX_CANDIDATES).
498
+
499
+ Semantic search (T6):
500
+ - When SEAM_SEMANTIC=on AND embeddings exist for the configured model:
501
+ 1. FTS5 candidates (id list, ranked by BM25) are combined with semantic
502
+ candidates (id list, ranked by cosine) via rrf_merge.
503
+ 2. The merged id list is hydrated into SearchResult rows.
504
+ 3. Score in SearchResult is the RRF rank (1/(k+rank), higher=better).
505
+ - When semantic is off or unavailable: existing pure-FTS5 path is used.
506
+ Behavior is byte-identical to pre-T6 in this case.
507
+
508
+ Result ordering: highest score first. The returned `score` is the NEGATED bm25
509
+ value + rescore bonuses, so higher = more relevant. FTS rows carry meaningful
510
+ BM25 scores; fallback rows start at 0.0 before rescore bonuses are applied.
511
+
512
+ Raises sqlite3.OperationalError on genuinely malformed FTS5 syntax.
513
+ NOTE: After OR-join, most user input won't be malformed. But the propagation
514
+ path is preserved so callers (MCP handlers) can still map it to INVALID_QUERY.
515
+ """
516
+ terms = _extract_terms(text)
517
+
518
+ # Step 1: try FTS5 with the safe OR-join expression
519
+ match_expr = fts.build_match_query(text)
520
+ fts_rows: list[dict] = []
521
+
522
+ # Also collect FTS symbol IDs for the hybrid path (if needed)
523
+ fts_symbol_ids: list[int] = []
524
+
525
+ if match_expr:
526
+ # Let OperationalError (genuinely malformed FTS5) propagate to caller.
527
+ # With OR-join, this only fires on actual syntax errors, not multi-word queries.
528
+ # Phase 4: SELECT s.signature so that fts.rescore() Signal-6 (signature boost)
529
+ # can fire. Without s.signature in the row, rescore() always sees None and the
530
+ # boost is permanently dead code. This column is nullable — pre-v5 rows have NULL.
531
+ sql = """
532
+ SELECT
533
+ s.id AS id,
534
+ s.name AS symbol,
535
+ f.path AS file,
536
+ s.start_line AS line,
537
+ snippet(symbols_fts, 0, '<b>', '</b>', '...', 8) AS snippet,
538
+ -- Column weights (name, docstring, signature, search_text): the Tier D #12
539
+ -- split-token column is weighted LOWEST so it adds camelCase RECALL without
540
+ -- outranking or evicting real name/doc/signature hits from the LIMIT window.
541
+ bm25(symbols_fts, 10.0, 2.0, 2.0, 1.0) AS score,
542
+ s.cluster_id AS cluster_id,
543
+ s.signature AS signature
544
+ FROM symbols_fts
545
+ JOIN symbols s ON s.id = symbols_fts.rowid
546
+ JOIN files f ON f.id = s.file_id
547
+ WHERE symbols_fts MATCH ?
548
+ ORDER BY score -- raw bm25 ascending = most relevant first
549
+ LIMIT ?
550
+ """
551
+ raw_rows = conn.execute(sql, (match_expr, limit)).fetchall()
552
+ fts_rows = [
553
+ {
554
+ "id": row["id"],
555
+ "symbol": row["symbol"],
556
+ "file": row["file"],
557
+ "line": row["line"],
558
+ "snippet": row["snippet"] or "",
559
+ # Flip bm25 sign: contract wants higher = better; raw bm25 is lower = better
560
+ "score": -float(row["score"]),
561
+ "cluster_id": row["cluster_id"],
562
+ # Phase 4: include signature so rescore() Signal-6 can fire.
563
+ "signature": row["signature"],
564
+ }
565
+ for row in raw_rows
566
+ ]
567
+ # Collect FTS symbol IDs (BM25-order = best first) for RRF merge
568
+ fts_symbol_ids = [r["id"] for r in fts_rows]
569
+
570
+ # ── Hybrid path: merge FTS and semantic candidates ────────────────────────
571
+ # When SEAM_SEMANTIC=on AND embeddings exist AND semantic=True (caller opt-in):
572
+ # combine both recall sources. Semantic ONLY ADDS recall — FTS hits never dropped.
573
+ # `semantic=False` lets callers (e.g. CLI --no-semantic) bypass hybrid without
574
+ # mutating global config (DRIFT-1 fix).
575
+ if semantic and _is_hybrid_enabled(conn):
576
+ hybrid_results = _hybrid_search_results(
577
+ conn, text, fts_rows, fts_symbol_ids, limit
578
+ )
579
+ if hybrid_results is not None:
580
+ return hybrid_results
581
+
582
+ if fts_rows:
583
+ # Rescore and return FTS results (the common happy path)
584
+ rescored = fts.rescore(fts_rows, terms)
585
+ return [
586
+ SearchResult(
587
+ symbol=r["symbol"],
588
+ file=r["file"],
589
+ line=r["line"],
590
+ snippet=r.get("snippet", ""),
591
+ score=r["score"],
592
+ )
593
+ for r in rescored
594
+ ]
595
+
596
+ # ── Step 2: LIKE fallback — FTS returned zero rows ────────────────────────
597
+ # WHY: FTS5 requires tokens to be indexed. A query term that was never seen
598
+ # (a typo like 'autenticate') gets zero FTS hits. LIKE %term% catches substrings.
599
+ if not match_expr:
600
+ # match_expr was empty sentinel — all tokens were stripped (operators/short).
601
+ # Log at INFO to distinguish "query discarded" from "genuine no-match".
602
+ logger.info(
603
+ "search: match_expr empty (query discarded) — all tokens stripped from %r; "
604
+ "returning []",
605
+ text,
606
+ )
607
+ return []
608
+ logger.debug("search: FTS returned zero rows for %r — trying LIKE fallback", text)
609
+
610
+ like_rows: list[dict] = []
611
+ for term in terms:
612
+ found = _like_fallback(conn, term, limit)
613
+ # Deduplicate by symbol name across multiple term searches
614
+ seen = {r["symbol"] for r in like_rows}
615
+ like_rows.extend(r for r in found if r["symbol"] not in seen)
616
+ if len(like_rows) >= limit:
617
+ break
618
+
619
+ if like_rows:
620
+ logger.debug("search: LIKE fallback found %d rows", len(like_rows))
621
+ rescored = fts.rescore(like_rows, terms)
622
+ return [
623
+ SearchResult(
624
+ symbol=r["symbol"],
625
+ file=r["file"],
626
+ line=r["line"],
627
+ snippet=r.get("snippet", ""),
628
+ score=r["score"],
629
+ )
630
+ for r in rescored[:limit]
631
+ ]
632
+
633
+ # ── Step 3: fuzzy fallback — LIKE also returned nothing ───────────────────
634
+ # Only attempt for terms that are long enough for edit-distance to be meaningful.
635
+ logger.debug("search: LIKE returned zero rows for %r — trying fuzzy fallback", text)
636
+
637
+ fuzzy_rows: list[dict] = []
638
+ for term in terms:
639
+ if len(term) < 3:
640
+ continue
641
+ found = _fuzzy_fallback(
642
+ conn,
643
+ term,
644
+ max_dist=SEAM_FUZZY_MAX_DIST,
645
+ candidate_cap=SEAM_FUZZY_MAX_CANDIDATES,
646
+ limit=limit,
647
+ )
648
+ seen = {r["symbol"] for r in fuzzy_rows}
649
+ fuzzy_rows.extend(r for r in found if r["symbol"] not in seen)
650
+
651
+ if fuzzy_rows:
652
+ logger.debug("search: fuzzy fallback found %d rows", len(fuzzy_rows))
653
+ rescored = fts.rescore(fuzzy_rows, terms)
654
+ return [
655
+ SearchResult(
656
+ symbol=r["symbol"],
657
+ file=r["file"],
658
+ line=r["line"],
659
+ snippet=r.get("snippet", ""),
660
+ score=r["score"],
661
+ )
662
+ for r in rescored[:limit]
663
+ ]
664
+
665
+ # All three tiers exhausted — genuine miss (not a query-discard).
666
+ logger.info(
667
+ "search: all tiers ran (FTS + LIKE + fuzzy), genuine miss for %r — returning []",
668
+ text,
669
+ )
670
+ return []
671
+
672
+
673
+ # ── query ────────────────────────────────────────────────────────────────────
674
+
675
+
676
+ def query(
677
+ conn: sqlite3.Connection,
678
+ concept: str,
679
+ limit: int = 10,
680
+ *,
681
+ semantic: bool = True,
682
+ ) -> list[QueryResult]:
683
+ """Find symbols related to a concept (FTS5 seed + 1-hop graph expansion).
684
+
685
+ Algorithm:
686
+ 1. Build MATCH expression via fts.build_match_query() (OR-join fix).
687
+ 2. FTS5 MATCH to get seed symbols (with BM25 score).
688
+ 3. Rescore seeds via fts.rescore().
689
+ 3b. [Semantic] When SEAM_SEMANTIC=on and embeddings exist: semantic candidates are
690
+ injected into seed_map with score=0.5 (below FTS rescored seeds, above neighbors).
691
+ WHY score=0.5: FTS rescored seeds have scores in roughly [0.5, 5.0]; graph
692
+ neighbors have score=0.0. Placing semantic seeds at 0.5 makes them rank below
693
+ confident FTS matches but above pure-graph neighbors — semantically relevant but
694
+ not as strong as keyword-matched seeds.
695
+ 4. For each seed symbol, collect 1-hop neighbors via edges
696
+ (both direct callees and callers — anything connected).
697
+ 5. Deduplicate; seed symbols keep their (rescored) score, neighbors get 0.
698
+ 6. For each symbol in the result set, compute callers_count + callees_count.
699
+ 7. Sort seeds-first, then by score descending, apply limit.
700
+
701
+ Raises sqlite3.OperationalError on malformed FTS5 syntax (the caller maps
702
+ this to INVALID_QUERY, mirroring search() — do NOT swallow it here, or a
703
+ malformed concept looks identical to "no matches").
704
+ """
705
+ terms = _extract_terms(concept)
706
+
707
+ # Step 1+2: FTS5 seed query with OR-join
708
+ match_expr = fts.build_match_query(concept)
709
+
710
+ seed_map: dict[str, tuple[str, int, float]] = {}
711
+
712
+ if match_expr:
713
+ # Phase 4: include s.signature so rescore() Signal-6 (signature boost) fires.
714
+ seed_sql = """
715
+ SELECT
716
+ s.name AS name,
717
+ f.path AS file,
718
+ s.start_line AS line,
719
+ -- Column weights (name, docstring, signature, search_text): the Tier D #12
720
+ -- split-token column is weighted LOWEST so it adds camelCase RECALL without
721
+ -- outranking or evicting real name/doc/signature hits from the LIMIT window.
722
+ bm25(symbols_fts, 10.0, 2.0, 2.0, 1.0) AS score,
723
+ s.cluster_id AS cluster_id,
724
+ s.signature AS signature
725
+ FROM symbols_fts
726
+ JOIN symbols s ON s.id = symbols_fts.rowid
727
+ JOIN files f ON f.id = s.file_id
728
+ WHERE symbols_fts MATCH ?
729
+ ORDER BY score
730
+ LIMIT ?
731
+ """
732
+ # Let OperationalError (malformed FTS5) propagate — same contract as search():
733
+ # caller maps it to INVALID_QUERY so it is distinct from "no matches found".
734
+ seed_rows_raw = conn.execute(seed_sql, (match_expr, limit)).fetchall()
735
+
736
+ if seed_rows_raw:
737
+ # Rescore to apply name/path/cluster/signature signals
738
+ seed_dicts = [
739
+ {
740
+ "symbol": row["name"],
741
+ "file": row["file"],
742
+ "line": row["line"],
743
+ "snippet": "",
744
+ "score": -float(
745
+ row["score"]
746
+ ), # negate: contract wants higher=better; raw bm25 is negative (lower=better)
747
+ "cluster_id": row["cluster_id"],
748
+ # Phase 4: include signature for rescore Signal-6.
749
+ "signature": row["signature"],
750
+ }
751
+ for row in seed_rows_raw
752
+ ]
753
+ rescored_seeds = fts.rescore(seed_dicts, terms)
754
+
755
+ # Build seed map: name -> (file, line, score)
756
+ for row in rescored_seeds:
757
+ seed_map[row["symbol"]] = (row["file"], row["line"], row["score"])
758
+
759
+ # ── Hybrid augmentation for query(): inject semantic seeds ───────────────
760
+ # When SEAM_SEMANTIC=on AND embeddings exist, semantic candidates are fetched
761
+ # and their symbol names are added to seed_map as additional seeds (score=0.5
762
+ # — below FTS rescored seeds but above 1-hop graph neighbors at score=0.0).
763
+ # This lets semantic-only symbols appear as peers of FTS seeds, going through
764
+ # the same 1-hop expansion and callers/callees enrichment path.
765
+ if semantic and _is_hybrid_enabled(conn):
766
+ sem_candidates = semantic_candidates(
767
+ conn,
768
+ concept,
769
+ model=config.SEAM_EMBED_MODEL,
770
+ limit=config.SEAM_SEMANTIC_LIMIT,
771
+ )
772
+ if sem_candidates:
773
+ sem_ids = [sid for sid, _s in sem_candidates]
774
+ id_to_row = _hydrate_symbol_rows(conn, sem_ids)
775
+ for sym_id, _sem_score in sem_candidates:
776
+ if sym_id not in id_to_row:
777
+ continue
778
+ row = id_to_row[sym_id]
779
+ name = row["symbol"]
780
+ # Add to seed_map only if not already present (FTS seeds take priority)
781
+ if name not in seed_map:
782
+ # Score 0.5: above graph neighbors (0.0), below FTS rescored seeds.
783
+ seed_map[name] = (row["file"], row["line"], 0.5)
784
+
785
+ if not seed_map:
786
+ if not match_expr:
787
+ # Empty sentinel: query was discarded (all tokens stripped).
788
+ logger.info(
789
+ "query: match_expr empty (query discarded) — all tokens stripped from %r; "
790
+ "returning []",
791
+ concept,
792
+ )
793
+ else:
794
+ # FTS ran but found nothing — genuine miss.
795
+ logger.info(
796
+ "query: FTS5 ran, genuine miss for %r — returning []",
797
+ concept,
798
+ )
799
+ return []
800
+
801
+ # Step 3: 1-hop expansion — collect neighbors of seed symbols.
802
+ # WHY expand seed names: a class seed like "CompanionManager" has NO call edges
803
+ # targeting the class name itself — callers invoke bare member names ("start", "stop").
804
+ # edge_match_names expands container seeds to include those bare member names so
805
+ # the neighbor SQL finds callers-of-members as neighbors of the class.
806
+ neighbor_map: dict[str, tuple[str, int, float]] = {}
807
+ seed_names = list(seed_map.keys())
808
+
809
+ # Build the full set of edge-lookup names: for each seed, expand via edge_match_names
810
+ # (which includes bare/qualified bridging AND member fan-out for containers).
811
+ edge_lookup_names: list[str] = []
812
+ seen_edge_names: set[str] = set()
813
+ for seed_name in seed_names:
814
+ for match_name in _edge_match_names(conn, seed_name):
815
+ if match_name not in seen_edge_names:
816
+ edge_lookup_names.append(match_name)
817
+ seen_edge_names.add(match_name)
818
+
819
+ # Batch the edge lookup to avoid hitting SQLite's SQLITE_MAX_VARIABLE_NUMBER=999 limit.
820
+ # Each batch of N names uses 2*N params (source_IN + target_IN), so cap at 450.
821
+ # WHY needed: Slice 3 container fan-out can produce up to 51 names per seed × 20 seeds
822
+ # = 1020 names → 2040 params, exceeding the 999-param limit on Linux/CI.
823
+ # traversal.py uses the same 900-item guard pattern (_SQL_VAR_BATCH).
824
+ neighbor_batch_size = 450 # 450 names × 2 params each = 900 total per query
825
+ neighbor_rows_raw: list[dict] = []
826
+ for batch_start in range(0, max(len(edge_lookup_names), 1), neighbor_batch_size):
827
+ batch = edge_lookup_names[batch_start : batch_start + neighbor_batch_size]
828
+ if not batch:
829
+ break
830
+ ph = ",".join("?" * len(batch))
831
+ batch_sql = f"""
832
+ SELECT DISTINCT
833
+ s.name AS name,
834
+ f.path AS file,
835
+ s.start_line AS line
836
+ FROM edges e
837
+ JOIN symbols s ON (
838
+ s.name = e.target_name OR s.name = e.source_name
839
+ )
840
+ JOIN files f ON f.id = s.file_id
841
+ WHERE e.source_name IN ({ph})
842
+ OR e.target_name IN ({ph})
843
+ """
844
+ neighbor_rows_raw.extend(conn.execute(batch_sql, batch + batch).fetchall())
845
+ for row in neighbor_rows_raw:
846
+ name = row["name"]
847
+ if name not in seed_map and name not in neighbor_map:
848
+ neighbor_map[name] = (row["file"], row["line"], 0.0)
849
+
850
+ # Step 4: Combine and deduplicate
851
+ combined: dict[str, tuple[str, int, float]] = {**neighbor_map, **seed_map}
852
+
853
+ # Step 5: Compute callers/callees counts for each symbol in the result set.
854
+ # WHY _collect_edges_for_names instead of exact COUNT(*): a qualified symbol like
855
+ # "Parser.parse" has edges stored with bare "parse" as target_name, so an exact
856
+ # COUNT WHERE target_name="Parser.parse" returns 0. Using _edge_match_names expands
857
+ # "Parser.parse" → ["Parser.parse", "parse"] before counting, matching the same
858
+ # bridging logic used by context() — consistent, never shows 0 for qualified methods.
859
+ result: list[QueryResult] = []
860
+ for name, (file, line, score) in combined.items():
861
+ match_names = _edge_match_names(conn, name)
862
+ callers_set, callees_set = _collect_edges_for_names(conn, match_names)
863
+ result.append(
864
+ QueryResult(
865
+ symbol=name,
866
+ file=file,
867
+ line=line,
868
+ score=score,
869
+ callers_count=len(callers_set),
870
+ callees_count=len(callees_set),
871
+ )
872
+ )
873
+
874
+ # Step 6: seeds rank above neighbors; within each group, higher score first.
875
+ seed_name_set = set(seed_map)
876
+ result.sort(key=lambda r: (r["symbol"] in seed_name_set, r["score"]), reverse=True)
877
+ return result[:limit]
878
+
879
+
880
+ # ── context ──────────────────────────────────────────────────────────────────
881
+
882
+
883
+ def context(conn: sqlite3.Connection, symbol_name: str) -> ContextResult | None:
884
+ """Get 360-degree view of a symbol: location, kind, docstring, callers, callees.
885
+
886
+ Tier A Slice 2: resolves bare names to all qualified definitions and merges
887
+ callers/callees across them. Sets ambiguous=True when resolution spans >1 definition.
888
+ Exact single-def match stays byte-stable. Returns None when nothing is found.
889
+ Per-edge confidence is preserved (union never invents confidence).
890
+ """
891
+ # resolve_query_to_defs handles: exact match, bare-name suffix scan, qualified-not-found.
892
+ def_rows = _resolve_query_to_defs(conn, symbol_name)
893
+ if not def_rows:
894
+ return None
895
+
896
+ # Distinguish bare-name resolution from exact-name match: resolve_query_to_defs may
897
+ # return a "Parser.parse" row for a query of "parse" (bare suffix scan). The returned
898
+ # def's name differs from symbol_name in that case, so we cannot use dup_count for
899
+ # the caller's "Parser.parse" — it would misrepresent collision count for "parse".
900
+ is_exact_match = def_rows[0]["name"] == symbol_name
901
+
902
+ # Fast path: single exact-match def → byte-stable, dup_count drives ambiguous.
903
+ if is_exact_match and len(def_rows) == 1:
904
+ dup_count = conn.execute(
905
+ "SELECT COUNT(*) FROM symbols WHERE name = ?", (symbol_name,)
906
+ ).fetchone()[0]
907
+ return cast(ContextResult, _build_context_result_fn(conn, def_rows[0], dup_count=dup_count, decode_enrichment_fields_fn=decode_enrichment_fields))
908
+
909
+ # Multi-def path: bare-name homonym or exact-name collision → merge and mark ambiguous.
910
+ return cast(ContextResult, _build_merged_context_result_fn(conn, def_rows, decode_enrichment_fields))
911
+
912
+
913
+ def context_at(
914
+ conn: sqlite3.Connection, file_path: str, start_line: int
915
+ ) -> ContextResult | None:
916
+ """P6c: resolve the EXACT symbol at (file_path, start_line) — bypasses name lookup.
917
+
918
+ Returns None when no symbol is at that exact location (unknown/stale UID).
919
+ """
920
+ row = conn.execute(
921
+ """
922
+ SELECT s.name, f.path AS file, s.start_line, s.end_line, s.kind, s.docstring,
923
+ s.signature, s.decorators, s.is_exported, s.visibility, s.qualified_name
924
+ FROM symbols s
925
+ JOIN files f ON s.file_id = f.id
926
+ WHERE f.path = ? AND s.start_line = ?
927
+ ORDER BY s.id
928
+ LIMIT 1
929
+ """,
930
+ (file_path, start_line),
931
+ ).fetchone()
932
+
933
+ if row is None:
934
+ return None
935
+
936
+ dup_count = conn.execute(
937
+ "SELECT COUNT(*) FROM symbols WHERE name = ?", (row["name"],)
938
+ ).fetchone()[0]
939
+
940
+ return cast(ContextResult, _build_context_result_fn(conn, row, dup_count=dup_count, decode_enrichment_fields_fn=decode_enrichment_fields))