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/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
|