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/indexer/db.py ADDED
@@ -0,0 +1,496 @@
1
+ """SQLite read/write operations for the Seam index.
2
+
3
+ Schema defined in docs/database/schema.sql.
4
+ All operations use explicit connections (no connection pool) — caller controls lifetime.
5
+
6
+ Key design decisions:
7
+ - init_db verifies FTS5 availability before running the schema script.
8
+ - init_db runs guarded migrations: v1->v2 (edges.confidence), v2->v3 (comments table),
9
+ v3->v4 (clusters + cluster_id), v4->v5 (Phase 4 node enrichment fields),
10
+ v5->v6 (Phase 5 import_mappings table), v6->v7 (semantic embeddings table),
11
+ v9->v10 (Tier B B1: edges.receiver column for call-edge receiver capture),
12
+ v11->v12 (edge-synthesis post-pass: edges.synthesized_by column).
13
+ - upsert_file is a single transaction: INSERT OR REPLACE the file row,
14
+ DELETE old symbols/edges/comments, then re-insert. Triggers handle FTS sync.
15
+ - delete_file DELETE FROM files cascades to symbols/edges/comments via FK; FTS
16
+ triggers fire on each symbol DELETE.
17
+ - Edge["source"] -> source_name, Edge["target"] -> target_name (see CONTRACT.md).
18
+ - Edge["confidence"] -> edges.confidence (schema v2 addition).
19
+ - Edge["receiver"] -> edges.receiver (schema v10 addition; NULL for non-attribute edges).
20
+ - Comment["marker"/"text"/"line"] -> comments table (schema v3 addition).
21
+ - Phase 4 fields: symbols gain signature, decorators (JSON text), is_exported,
22
+ visibility, qualified_name. FTS5 rebuilt to index (name, docstring, signature).
23
+ - Phase 5 (schema v6): import_mappings table. Populated by upsert_import_mappings(),
24
+ cleaned up by delete_import_mappings(). Per-file delete-then-insert like upsert_file.
25
+ - Semantic search (schema v7): embeddings table. Populated ONLY by `seam init --semantic`.
26
+ Not backfilled by migration — falls back to FTS5-only when absent. ON DELETE CASCADE
27
+ keeps embeddings in sync with symbol deletions automatically.
28
+ - Tier B B1 (schema v10): edges.receiver TEXT column. NULL on pre-v10 rows (null-contract,
29
+ mirrors Phase 4/5 fields). Populated at index time for Python attribute calls.
30
+ """
31
+
32
+ import json
33
+ import logging
34
+ import sqlite3
35
+ import time
36
+ from pathlib import Path
37
+ from typing import TYPE_CHECKING
38
+
39
+ from seam.analysis.processes import compute_entry_score
40
+ from seam.config import SEAM_TOKENIZE_IDENTIFIERS
41
+ from seam.indexer.migrations import (
42
+ _run_migration_v1_to_v2,
43
+ _run_migration_v2_to_v3,
44
+ _run_migration_v3_to_v4,
45
+ _run_migration_v4_to_v5,
46
+ _run_migration_v5_to_v6,
47
+ _run_migration_v6_to_v7,
48
+ _run_migration_v7_to_v8,
49
+ _run_migration_v8_to_v9,
50
+ _run_migration_v9_to_v10,
51
+ _run_migration_v10_to_v11,
52
+ _run_migration_v11_to_v12,
53
+ )
54
+ from seam.indexer.tokenize import build_search_text
55
+
56
+ if TYPE_CHECKING:
57
+ from seam.analysis.imports import ImportMapping
58
+ from seam.indexer.graph import Comment, Edge, Symbol
59
+
60
+ logger = logging.getLogger(__name__)
61
+
62
+ # Schema SQL location. Two layouts must both work:
63
+ # - INSTALLED wheel: shipped inside the package at seam/_data/schema.sql (via hatch
64
+ # force-include in pyproject) — docs/ is NOT packaged, so the dev path is absent.
65
+ # - DEV checkout / editable install: the canonical file at <repo>/docs/database/schema.sql.
66
+ # Packaged-first so a real `pip install` works; falls back to the repo copy in dev.
67
+ _PACKAGED_SCHEMA_PATH = Path(__file__).parent.parent / "_data" / "schema.sql"
68
+ _DEV_SCHEMA_PATH = Path(__file__).parents[2] / "docs" / "database" / "schema.sql"
69
+ _SCHEMA_PATH = _PACKAGED_SCHEMA_PATH if _PACKAGED_SCHEMA_PATH.exists() else _DEV_SCHEMA_PATH
70
+
71
+ # Busy timeout (ms) so a concurrent reader (MCP server) doesn't make a
72
+ # concurrent writer (watcher) fail immediately with "database is locked".
73
+ _BUSY_TIMEOUT_MS = 5000
74
+
75
+
76
+ def connect(db_path: Path, *, check_same_thread: bool = True) -> sqlite3.Connection:
77
+ """Open a SQLite connection with Seam's required per-connection PRAGMAs.
78
+
79
+ CRITICAL: `PRAGMA foreign_keys` is per-connection, not stored in the DB file.
80
+ Every connection that writes (init, watcher) MUST enable it or ON DELETE
81
+ CASCADE is silently off — `delete_file` and re-index would orphan rows.
82
+ All callers (init_db, watcher, server, status) go through here so no
83
+ connection can accidentally run without FK enforcement.
84
+
85
+ Auto-migration: if the DB has an existing schema (metadata table present)
86
+ but is older than the current version, pending guarded migrations are run.
87
+ This prevents "no such column: signature" errors when a user upgrades Seam
88
+ and opens a pre-v5 index via `seam start`, `seam query`, `seam context`, etc.
89
+ without re-running `seam init`.
90
+
91
+ Fresh-DB guard: a brand-new empty file (no metadata table) is NOT migrated
92
+ here — init_db handles fresh DBs. The guard checks for metadata table
93
+ existence before reading schema_version.
94
+
95
+ Args:
96
+ db_path: Path to the SQLite file.
97
+ check_same_thread: Pass False for connections shared across threads
98
+ (the watcher's observer/timer threads share one connection).
99
+ """
100
+ conn = sqlite3.connect(str(db_path), check_same_thread=check_same_thread)
101
+ conn.row_factory = sqlite3.Row
102
+ conn.execute("PRAGMA foreign_keys = ON")
103
+ conn.execute(f"PRAGMA busy_timeout = {_BUSY_TIMEOUT_MS}")
104
+
105
+ # Auto-migrate: run pending guarded migrations on an already-initialized DB.
106
+ # Guard: only attempt when the metadata table exists (i.e. this is not a fresh
107
+ # empty file). A fresh empty file has no metadata table yet and must go through
108
+ # init_db() which runs the full schema creation + migrations in the right order.
109
+ _run_pending_migrations_if_needed(conn)
110
+
111
+ return conn
112
+
113
+
114
+ def _run_pending_migrations_if_needed(conn: sqlite3.Connection) -> None:
115
+ """Run any pending guarded migrations on an already-initialized DB.
116
+
117
+ WHY: connect() is called by all read-path entry points (seam start, seam query,
118
+ seam context, seam status, the watcher, etc.). Before this function existed,
119
+ a user with a pre-v5 DB who ran any of those commands WITHOUT re-running
120
+ `seam init` would get OperationalError: no such column: signature because the
121
+ new engine SELECTs include Phase 4 columns.
122
+
123
+ Guard: only run when:
124
+ 1. The metadata table EXISTS (i.e. this is an initialized DB, not a fresh
125
+ empty file with no schema yet).
126
+ 2. schema_version < current version (11).
127
+
128
+ Fresh-DB safety: a brand-new empty file (no metadata table yet) is left alone —
129
+ init_db() will create the schema and run all migrations in the correct order.
130
+
131
+ These migrations are idempotent + version-guarded, so running them here is safe
132
+ even if init_db() has already applied them (they become no-ops at version >= N).
133
+
134
+ NOTE: The v1→v2, v2→v3, and v3→v4 migrations also need the schema script output
135
+ (clusters table) to exist before they can run. For the connect() path on a v4 DB
136
+ we only need to run v4→v5 (the only missing migration for a Phase 4 upgrade).
137
+ The prior migrations already ran when the user first ran `seam init` at their
138
+ respective versions, so the schema is already at v4 for a v4 DB.
139
+ """
140
+ try:
141
+ # Guard 1: check if the metadata table exists at all.
142
+ # SQLite stores table names in sqlite_master; this is a single cheap query.
143
+ tables = {
144
+ row[0]
145
+ for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
146
+ }
147
+ if "metadata" not in tables:
148
+ # Fresh empty file — no migrations to run (init_db handles this path).
149
+ return
150
+
151
+ # Guard 2: read current schema_version.
152
+ row = conn.execute("SELECT value FROM metadata WHERE key='schema_version'").fetchone()
153
+ version = int(row["value"]) if row else 0
154
+
155
+ if version >= 12:
156
+ return # Already up to date — no-op.
157
+
158
+ # Version is < 12: run pending migrations in order.
159
+ # Each migration is guarded by its own version check — safe to call
160
+ # when already at or above that version (they become no-ops).
161
+ if version < 2:
162
+ _run_migration_v1_to_v2(conn)
163
+ if version < 3:
164
+ _run_migration_v2_to_v3(conn)
165
+ if version < 4:
166
+ _run_migration_v3_to_v4(conn)
167
+ # v4→v5: Phase 4 migration (adds enrichment columns + FTS rebuild).
168
+ if version < 5:
169
+ _run_migration_v4_to_v5(conn)
170
+ # v5→v6: Phase 5 migration (adds import_mappings table).
171
+ if version < 6:
172
+ _run_migration_v5_to_v6(conn)
173
+ # v6→v7: Semantic search migration (adds embeddings table).
174
+ if version < 7:
175
+ _run_migration_v6_to_v7(conn)
176
+ # v7→v8: P2 cluster quality migration (adds clusters.cohesion column).
177
+ if version < 8:
178
+ _run_migration_v7_to_v8(conn)
179
+ # v8→v9: P6b framework entry-point scoring (adds symbols.entry_score column).
180
+ if version < 9:
181
+ _run_migration_v8_to_v9(conn)
182
+ # v9→v10: Tier B B1 receiver capture (adds edges.receiver column).
183
+ if version < 10:
184
+ _run_migration_v9_to_v10(conn)
185
+ # v10→v11: Tier D #12 identifier split tokens (adds symbols.search_text + FTS column).
186
+ if version < 11:
187
+ _run_migration_v10_to_v11(conn)
188
+ # v11→v12: Edge-synthesis post-pass (adds edges.synthesized_by column).
189
+ if version < 12:
190
+ _run_migration_v11_to_v12(conn)
191
+
192
+ except Exception as exc: # noqa: BLE001
193
+ # Do NOT raise here — failing to auto-migrate should not crash a read-only
194
+ # command. Log at WARNING so the operator can diagnose, then continue.
195
+ # The OperationalError would surface on the first query anyway if the schema
196
+ # is truly broken, giving a clear diagnostic message.
197
+ logger.warning(
198
+ "connect(): auto-migration attempt failed (%s). Run 'seam init' to rebuild the index.",
199
+ exc,
200
+ )
201
+
202
+
203
+ def init_db(db_path: Path) -> sqlite3.Connection:
204
+ """Open (or create) the SQLite database and apply the schema.
205
+
206
+ Steps:
207
+ 1. Open connection via connect() (sets foreign_keys + busy_timeout).
208
+ 2. Verify FTS5 is available; raise RuntimeError with a clear message if not.
209
+ 3. Execute the schema SQL (idempotent — uses CREATE TABLE IF NOT EXISTS).
210
+ 4. Run guarded v1->v2 migration (adds edges.confidence if absent).
211
+
212
+ Returns the open connection. Caller is responsible for closing it.
213
+ Raises RuntimeError if FTS5 is unavailable.
214
+ """
215
+ conn = connect(db_path)
216
+
217
+ # Verify FTS5 — attempt to create a temp virtual table then drop it.
218
+ try:
219
+ conn.execute("CREATE VIRTUAL TABLE _seam_fts5_check USING fts5(content)")
220
+ conn.execute("DROP TABLE IF EXISTS _seam_fts5_check")
221
+ except sqlite3.OperationalError as exc:
222
+ conn.close()
223
+ raise RuntimeError(
224
+ "SQLite FTS5 extension is not available. "
225
+ "Seam requires a SQLite build compiled with FTS5 support. "
226
+ f"Original error: {exc}"
227
+ ) from exc
228
+
229
+ # Apply schema (CREATE TABLE IF NOT EXISTS — safe to run multiple times).
230
+ schema_sql = _SCHEMA_PATH.read_text()
231
+ conn.executescript(schema_sql)
232
+
233
+ # Run v1->v2 migration guard (adds edges.confidence if absent on an existing db).
234
+ _run_migration_v1_to_v2(conn)
235
+
236
+ # Run v2->v3 migration guard (bumps schema_version; comments table already created).
237
+ _run_migration_v2_to_v3(conn)
238
+
239
+ # Run v3->v4 migration guard (adds clusters table + symbols.cluster_id column).
240
+ _run_migration_v3_to_v4(conn)
241
+
242
+ # Run v4->v5 migration guard (adds Phase 4 node enrichment fields + FTS rebuild).
243
+ _run_migration_v4_to_v5(conn)
244
+
245
+ # Run v5->v6 migration guard (adds import_mappings table — Phase 5).
246
+ _run_migration_v5_to_v6(conn)
247
+
248
+ # Run v6->v7 migration guard (adds embeddings table — semantic search foundation).
249
+ _run_migration_v6_to_v7(conn)
250
+
251
+ # Run v7->v8 migration guard (adds clusters.cohesion column — P2 cluster quality).
252
+ _run_migration_v7_to_v8(conn)
253
+
254
+ # Run v8->v9 migration guard (adds symbols.entry_score column — P6b framework scoring).
255
+ _run_migration_v8_to_v9(conn)
256
+
257
+ # Run v9->v10 migration guard (adds edges.receiver column — Tier B B1 receiver capture).
258
+ _run_migration_v9_to_v10(conn)
259
+
260
+ # Run v10->v11 migration guard (adds symbols.search_text + 4th FTS column — Tier D #12).
261
+ _run_migration_v10_to_v11(conn)
262
+
263
+ # Run v11->v12 migration guard (adds edges.synthesized_by column — edge-synthesis post-pass).
264
+ _run_migration_v11_to_v12(conn)
265
+
266
+ return conn
267
+
268
+
269
+ def upsert_file(
270
+ conn: sqlite3.Connection,
271
+ filepath: Path,
272
+ language: str,
273
+ file_hash: str,
274
+ symbols: "list[Symbol]",
275
+ edges: "list[Edge]",
276
+ comments: "list[Comment] | None" = None,
277
+ ) -> None:
278
+ """Atomically replace all data for a file. Idempotent: safe to call twice.
279
+
280
+ Steps (single transaction):
281
+ 1. UPSERT the file row via ON CONFLICT DO UPDATE — keeps a STABLE id
282
+ across re-index (INSERT OR REPLACE would churn the autoincrement id
283
+ and strand child rows). Captures new mtime + indexed_at.
284
+ 2. Retrieve the file_id for this path.
285
+ 3. DELETE existing edges, symbols, and comments for that file_id. Both
286
+ edges and symbols are deleted explicitly (deleting symbols does NOT
287
+ cascade to edges — edges hang off files, not symbols). FTS triggers
288
+ fire per-symbol DELETE. Comments are FK-cascaded but deleted explicitly
289
+ for clarity and symmetry with the other child tables.
290
+ 4. INSERT new symbols.
291
+ 5. INSERT new edges mapping Edge['source'] -> source_name,
292
+ Edge['target'] -> target_name.
293
+ 6. INSERT new comments (schema v3). Each Comment has marker, text, line.
294
+ """
295
+ mtime = filepath.stat().st_mtime
296
+ indexed_at = time.time()
297
+
298
+ with conn: # single transaction — commit on success, rollback on error
299
+ # 1. Upsert the file row, preserving its id across re-index.
300
+ conn.execute(
301
+ """
302
+ INSERT INTO files (path, language, file_hash, mtime, indexed_at)
303
+ VALUES (?, ?, ?, ?, ?)
304
+ ON CONFLICT(path) DO UPDATE SET
305
+ language = excluded.language,
306
+ file_hash = excluded.file_hash,
307
+ mtime = excluded.mtime,
308
+ indexed_at = excluded.indexed_at
309
+ """,
310
+ (str(filepath), language, file_hash, mtime, indexed_at),
311
+ )
312
+
313
+ # 2. Retrieve file_id (stable across re-index thanks to the upsert above)
314
+ row = conn.execute("SELECT id FROM files WHERE path = ?", (str(filepath),)).fetchone()
315
+ file_id: int = row["id"]
316
+
317
+ # 3. Delete old children explicitly. Edges must be deleted directly:
318
+ # deleting symbols does NOT remove edges (edges reference files, not
319
+ # symbols). FTS triggers fire on each symbol DELETE to stay in sync.
320
+ # Comments are also deleted explicitly for symmetry (FK cascade would
321
+ # also handle them, but explicit is clearer and consistent).
322
+ conn.execute("DELETE FROM edges WHERE file_id = ?", (file_id,))
323
+ conn.execute("DELETE FROM symbols WHERE file_id = ?", (file_id,))
324
+ conn.execute("DELETE FROM comments WHERE file_id = ?", (file_id,))
325
+
326
+ # 4. Insert symbols — includes Phase 4 enrichment fields (schema v5) and the
327
+ # P6b framework entry_score (schema v9, computed at index time from the
328
+ # file path pattern + decorator text — see processes.compute_entry_score).
329
+ file_path_str = str(filepath)
330
+ conn.executemany(
331
+ """
332
+ INSERT INTO symbols (
333
+ file_id, name, kind, start_line, end_line, docstring,
334
+ signature, decorators, is_exported, visibility, qualified_name,
335
+ entry_score, search_text
336
+ )
337
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
338
+ """,
339
+ [
340
+ (
341
+ file_id,
342
+ sym["name"],
343
+ sym["kind"],
344
+ sym["start_line"],
345
+ sym["end_line"],
346
+ sym.get("docstring"),
347
+ # Phase 4 fields — .get() so Symbol dicts without these keys remain valid
348
+ # (e.g. callers that bypass extract_node_fields or pre-Phase-4 test fixtures).
349
+ sym.get("signature"),
350
+ # decorators is a list; JSON-encode it for TEXT storage. Preserves order and
351
+ # round-trips cleanly via json.loads() in the read path (engine.context).
352
+ json.dumps(sym.get("decorators"))
353
+ if sym.get("decorators") is not None
354
+ else "[]",
355
+ # SQLite has no native bool type; 1/0/NULL maps to True/False/unknown.
356
+ # The (1 if x else 0) form avoids mypy's complaint about assigning bool to int.
357
+ (1 if sym["is_exported"] else 0)
358
+ if sym.get("is_exported") is not None
359
+ else None,
360
+ sym.get("visibility"),
361
+ sym.get("qualified_name"),
362
+ # P6b: framework entry-point multiplier. Pure + never-raises; the
363
+ # neutral baseline 1.0 is stored when nothing matches so ranking is
364
+ # byte-identical to raw reach for non-entry symbols.
365
+ compute_entry_score(file_path_str, sym.get("decorators")),
366
+ # Tier D #12: split-token search text (camelCase recall). NULL when the
367
+ # knob is off → byte-identical to pre-D12 (no split-token FTS hits).
368
+ build_search_text(sym["name"], sym.get("qualified_name"))
369
+ if SEAM_TOKENIZE_IDENTIFIERS == "on"
370
+ else None,
371
+ )
372
+ for sym in symbols
373
+ ],
374
+ )
375
+
376
+ # 5. Insert edges — contract: Edge['source'] -> source_name,
377
+ # Edge['target'] -> target_name
378
+ # Edge['confidence'] -> confidence (schema v2)
379
+ # Edge['receiver'] -> receiver (schema v10, nullable)
380
+ # Edge['synthesized_by'] -> synthesized_by (schema v12, nullable)
381
+ #
382
+ # synthesized_by: NULL for statically extracted edges (the default for all
383
+ # parser-emitted edges); a channel name string for synthesis post-pass edges.
384
+ # upsert_file is only called for file-scoped extraction — synthesized_by is
385
+ # always None here. index_synthesis writes its own edges directly (not via
386
+ # upsert_file) because synthesized edges are not file-scoped.
387
+ conn.executemany(
388
+ """
389
+ INSERT INTO edges (source_name, target_name, kind, file_id, line, confidence, receiver, synthesized_by)
390
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
391
+ """,
392
+ [
393
+ (
394
+ edge["source"], # Edge field 'source' -> column source_name
395
+ edge["target"], # Edge field 'target' -> column target_name
396
+ edge["kind"],
397
+ file_id,
398
+ edge["line"],
399
+ edge["confidence"], # required field — fail loud if missing
400
+ edge.get("receiver"), # nullable: None for import/bare-call edges
401
+ edge.get("synthesized_by"), # nullable: None for parser-extracted edges
402
+ )
403
+ for edge in edges
404
+ ],
405
+ )
406
+
407
+ # 6. Insert comments (schema v3). Defaults to [] when not provided
408
+ # (e.g. callers that haven't been updated yet for backward compat).
409
+ for comment in comments or []:
410
+ conn.execute(
411
+ "INSERT INTO comments (file_id, line, marker, text) VALUES (?, ?, ?, ?)",
412
+ (file_id, comment["line"], comment["marker"], comment["text"]),
413
+ )
414
+
415
+
416
+ def delete_file(conn: sqlite3.Connection, filepath: Path) -> None:
417
+ """Remove a file and all its symbols, edges, and comments from the index.
418
+
419
+ Cascade (FK ON DELETE CASCADE) removes symbols, edges, comments, and
420
+ import_mappings automatically. FTS triggers fire on each symbol DELETE,
421
+ keeping FTS in sync. Silently succeeds if the path was never indexed.
422
+ """
423
+ with conn:
424
+ conn.execute("DELETE FROM files WHERE path = ?", (str(filepath),))
425
+
426
+
427
+ def upsert_import_mappings(
428
+ conn: sqlite3.Connection,
429
+ filepath: Path,
430
+ mappings: "list[ImportMapping]",
431
+ ) -> None:
432
+ """Replace all import mappings for a file (delete-then-insert, single transaction).
433
+
434
+ This mirrors the upsert_file delete-children pattern: delete old rows for this
435
+ file_id then insert the new set. Idempotent: safe to call repeatedly for the
436
+ same file.
437
+
438
+ Called by index_one_file() after upsert_file() so that the file row and its
439
+ file_id already exist. Silently no-ops if the file is not in the DB.
440
+
441
+ Args:
442
+ conn: Open SQLite connection with write access.
443
+ filepath: Absolute path of the indexed source file.
444
+ mappings: Import mapping records extracted from the file's AST.
445
+ """
446
+ row = conn.execute("SELECT id FROM files WHERE path = ?", (str(filepath),)).fetchone()
447
+ if row is None:
448
+ # File not in index — no-op (could happen if indexing was skipped).
449
+ return
450
+
451
+ file_id: int = row["id"]
452
+ with conn:
453
+ # Delete existing mappings for this file (idempotent on re-index)
454
+ conn.execute("DELETE FROM import_mappings WHERE file_id = ?", (file_id,))
455
+ # Insert new mappings
456
+ if mappings:
457
+ conn.executemany(
458
+ """
459
+ INSERT INTO import_mappings
460
+ (file_id, local_name, exported_name, source_module,
461
+ is_default, is_namespace, is_wildcard, line)
462
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
463
+ """,
464
+ [
465
+ (
466
+ file_id,
467
+ m["local_name"],
468
+ m["exported_name"],
469
+ m["source_module"],
470
+ 1 if m["is_default"] else 0,
471
+ 1 if m["is_namespace"] else 0,
472
+ 1 if m["is_wildcard"] else 0,
473
+ m["line"],
474
+ )
475
+ for m in mappings
476
+ ],
477
+ )
478
+
479
+
480
+ def delete_import_mappings(conn: sqlite3.Connection, filepath: Path) -> None:
481
+ """Remove all import mappings for a file from the index.
482
+
483
+ Silently succeeds if the path was never indexed or has no mappings.
484
+ Called by watcher on file delete to clean up stale mappings before
485
+ the FK cascade has a chance to handle it (belt-and-suspenders).
486
+
487
+ Args:
488
+ conn: Open SQLite connection with write access.
489
+ filepath: Absolute path of the source file being removed.
490
+ """
491
+ row = conn.execute("SELECT id FROM files WHERE path = ?", (str(filepath),)).fetchone()
492
+ if row is None:
493
+ return
494
+ file_id: int = row["id"]
495
+ with conn:
496
+ conn.execute("DELETE FROM import_mappings WHERE file_id = ?", (file_id,))
@@ -0,0 +1,183 @@
1
+ """Embedding index orchestration — bridge between analysis.embeddings and persistence (T4).
2
+
3
+ Mirrors the structure of seam/indexer/cluster_index.py:
4
+ - Public function wraps an inner implementation in an exception guard.
5
+ - Returns -1 on error (sentinel), ≥0 on success, 0 when skipped (fastembed absent).
6
+ - Never raises — callers (`seam init --semantic`) can always inspect the return value.
7
+
8
+ Import hierarchy (enforced):
9
+ indexer/embedding_index → analysis.embeddings (leaf) + config
10
+ analysis.embeddings → stdlib + fastembed(lazy); no numpy import, no DB
11
+ cli → this module (not the other way)
12
+
13
+ Design decisions:
14
+ - Reads ALL symbols in one pass (id, name, signature, docstring).
15
+ - Embeds in batches of `batch` texts (default from caller — CLI passes config value).
16
+ - Upserts via INSERT OR REPLACE so repeated calls are idempotent (no duplicates).
17
+ - dim is inferred from the first returned vector (len(blob) // 4) — avoids hard-coding.
18
+ - Returns the count of upserted rows, or 0 when skipped, or -1 on failure.
19
+ """
20
+
21
+ import logging
22
+ import sqlite3
23
+
24
+ from seam.analysis.embeddings import embed_texts, is_available, symbol_text
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def index_embeddings(
30
+ conn: sqlite3.Connection,
31
+ *,
32
+ model: str,
33
+ batch: int = 32,
34
+ ) -> int:
35
+ """Embed all indexed symbols and persist vectors to the embeddings table.
36
+
37
+ Reads the full symbol table, batches texts through the local fastembed model,
38
+ and upserts one row per symbol into `embeddings(symbol_id, model, dim, vector)`.
39
+
40
+ Called by `seam init --semantic` after the full indexing + cluster pass.
41
+ NOT called by the file watcher or `seam sync` (incremental re-embedding is a
42
+ future enhancement; full re-index is the safe baseline).
43
+
44
+ Args:
45
+ conn: Open SQLite connection with write access.
46
+ model: FastEmbed model name (e.g. "BAAI/bge-small-en-v1.5").
47
+ batch: Number of texts to embed per fastembed call. Larger values use
48
+ more RAM but reduce per-call overhead. Default 32 is a safe default.
49
+
50
+ Returns:
51
+ Number of symbols embedded and upserted (≥0).
52
+ Returns 0 when fastembed is unavailable (skip cleanly — no error).
53
+ Returns -1 on any unexpected error (never re-raises — mirrors index_clusters).
54
+
55
+ WHY -1 sentinel: consistent with index_clusters; lets the CLI distinguish
56
+ "zero embeddings because fastembed absent" (0) from "embedding failed" (-1).
57
+ """
58
+ # Fast skip when fastembed is not installed — no error, no log noise.
59
+ if not is_available():
60
+ logger.debug("embedding_index: fastembed not available — skipping semantic indexing")
61
+ return 0
62
+
63
+ try:
64
+ return _index_embeddings_impl(conn, model=model, batch=batch)
65
+ except Exception as exc:
66
+ logger.warning(
67
+ "embedding_index: failed to compute embeddings (%s: %s) — "
68
+ "embeddings table will be empty; run 'seam init --semantic' again to retry.",
69
+ type(exc).__name__,
70
+ exc,
71
+ )
72
+ return -1
73
+
74
+
75
+ def _index_embeddings_impl(
76
+ conn: sqlite3.Connection,
77
+ *,
78
+ model: str,
79
+ batch: int,
80
+ ) -> int:
81
+ """Inner implementation — may raise; outer function is the guard.
82
+
83
+ WHY separate function: the outer wrapper catches ALL exceptions and converts
84
+ them to -1. Having a clean inner function makes the logic easier to reason
85
+ about and test without needing to trigger the guard.
86
+
87
+ Steps:
88
+ 1. Fetch all symbols (id, name, signature, docstring) in one query.
89
+ 2. Build canonical text for each symbol via symbol_text().
90
+ 3. Embed in batches using embed_texts().
91
+ 4. Upsert each embedding into the embeddings table (INSERT OR REPLACE).
92
+ 5. Return the total count of upserted rows.
93
+ """
94
+ # ── Step 1: Read all symbols ──────────────────────────────────────────────
95
+ rows = conn.execute(
96
+ "SELECT id, name, signature, docstring FROM symbols ORDER BY id"
97
+ ).fetchall()
98
+
99
+ if not rows:
100
+ logger.debug("embedding_index: no symbols in index — nothing to embed")
101
+ return 0
102
+
103
+ # ── Step 2: Build canonical texts ─────────────────────────────────────────
104
+ # Precompute (symbol_id, text) pairs; keep IDs aligned with text list.
105
+ symbol_ids: list[int] = []
106
+ texts: list[str] = []
107
+ for row in rows:
108
+ symbol_ids.append(row["id"])
109
+ texts.append(
110
+ symbol_text(
111
+ row["name"],
112
+ row["signature"], # may be None
113
+ row["docstring"], # may be None
114
+ )
115
+ )
116
+
117
+ # ── Step 3 + 4: Embed in batches + upsert ─────────────────────────────────
118
+ # All batches run inside a SINGLE outer transaction: if ANY batch fails
119
+ # mid-run, the entire set of inserts is rolled back so the embeddings table
120
+ # stays empty (clean-retry state). Without the single transaction, a partial
121
+ # failure would leave half-populated embeddings that look valid but are not.
122
+ # Batching remains for memory — we still process in `batch`-sized windows;
123
+ # we just don't commit after each batch.
124
+ total = 0
125
+ inferred_dim: int | None = None
126
+
127
+ with conn:
128
+ for batch_start in range(0, len(texts), batch):
129
+ batch_texts = texts[batch_start : batch_start + batch]
130
+ batch_ids = symbol_ids[batch_start : batch_start + batch]
131
+ batch_end = batch_start + len(batch_texts)
132
+
133
+ # embed_texts returns [] on any failure — treat as a hard error at this level
134
+ # so the outer guard can return -1 (empty batch = model crashed mid-run).
135
+ blobs = embed_texts(batch_texts, model)
136
+
137
+ # Zip-truncation guard: blobs and batch_texts must be the same length.
138
+ # If embed_texts returns fewer vectors than texts (silent truncation),
139
+ # raise immediately so the outer guard returns -1 rather than silently
140
+ # storing incomplete data.
141
+ if not blobs:
142
+ raise RuntimeError(
143
+ f"embed_texts returned no vectors for batch "
144
+ f"[{batch_start}:{batch_end}] — fastembed may have failed silently"
145
+ )
146
+ if len(blobs) != len(batch_texts):
147
+ logger.warning(
148
+ "embedding_index: zip-truncation detected in batch [%d:%d] — "
149
+ "embed_texts returned %d vectors for %d texts; "
150
+ "raising to trigger -1 sentinel.",
151
+ batch_start,
152
+ batch_end,
153
+ len(blobs),
154
+ len(batch_texts),
155
+ )
156
+ raise RuntimeError(
157
+ f"embed_texts returned {len(blobs)} vectors for {len(batch_texts)} texts "
158
+ f"in batch [{batch_start}:{batch_end}] — zip-truncation guard triggered"
159
+ )
160
+
161
+ # Infer dimension from the first blob: len(bytes) / 4 bytes-per-float32.
162
+ if inferred_dim is None:
163
+ inferred_dim = len(blobs[0]) // 4
164
+
165
+ conn.executemany(
166
+ """
167
+ INSERT OR REPLACE INTO embeddings (symbol_id, model, dim, vector)
168
+ VALUES (?, ?, ?, ?)
169
+ """,
170
+ [
171
+ (sid, model, inferred_dim, blob)
172
+ for sid, blob in zip(batch_ids, blobs)
173
+ ],
174
+ )
175
+ total += len(blobs)
176
+
177
+ logger.info(
178
+ "embedding_index: %d symbol(s) embedded with model %r (dim=%s)",
179
+ total,
180
+ model,
181
+ inferred_dim,
182
+ )
183
+ return total