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/sync.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Filesystem reconcile for Seam — the engine behind `seam sync`.
|
|
2
|
+
|
|
3
|
+
Performs a mtime-pre-filter → SHA-1-confirm reconcile of the existing index
|
|
4
|
+
against the current on-disk state, then runs a full cluster recompute gated
|
|
5
|
+
on whether the graph actually changed.
|
|
6
|
+
|
|
7
|
+
Import hierarchy:
|
|
8
|
+
cli.main → indexer.sync → indexer.pipeline, indexer.db, indexer.cluster_index
|
|
9
|
+
(never the reverse — this module must not import from cli)
|
|
10
|
+
|
|
11
|
+
Design decisions:
|
|
12
|
+
- Reconcile is filesystem-based (mtime + SHA-1), NOT git-based.
|
|
13
|
+
Catches non-git repos AND committed changes from pull/checkout/merge.
|
|
14
|
+
- mtime pre-filter: if stored mtime == on-disk st_mtime → unchanged, no read.
|
|
15
|
+
- Hash confirm: if mtime differs but hash matches → unchanged, no re-index.
|
|
16
|
+
(touch-without-content-change pays one hash per sync, never a re-index.)
|
|
17
|
+
- Cluster recompute is FULL (not incremental) and gated on graph_changed.
|
|
18
|
+
One new edge can re-partition unrelated communities → incremental is wrong.
|
|
19
|
+
- Per-file errors are swallowed by index_one_file (returns None → skipped).
|
|
20
|
+
sync() itself never raises for per-file issues.
|
|
21
|
+
- Logging: INFO for the final summary line, DEBUG for per-file decisions.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import sqlite3
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TypedDict
|
|
28
|
+
|
|
29
|
+
from seam.indexer.cluster_index import index_clusters
|
|
30
|
+
from seam.indexer.db import delete_file
|
|
31
|
+
from seam.indexer.pipeline import index_one_file, sha1, walk_project
|
|
32
|
+
from seam.indexer.synthesis_index import index_synthesis
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SyncResult(TypedDict):
|
|
38
|
+
"""Outcome of one sync() call.
|
|
39
|
+
|
|
40
|
+
Keys mirror the PRD §Implementation Decisions shape exactly.
|
|
41
|
+
cluster_count is None when clusters were NOT recomputed this sync.
|
|
42
|
+
synthesis_count is None when synthesis was NOT run this sync.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
added: int # files present on disk, absent from index → indexed
|
|
46
|
+
modified: int # tracked files whose content hash changed → re-indexed
|
|
47
|
+
removed: int # tracked files no longer on disk → deleted
|
|
48
|
+
unchanged: int # tracked files skipped (mtime match or hash match)
|
|
49
|
+
skipped: int # files index_one_file declined (unsupported/binary/error)
|
|
50
|
+
graph_changed: bool # (added + modified + removed) > 0
|
|
51
|
+
clusters_recomputed: bool # whether index_clusters ran this sync
|
|
52
|
+
cluster_count: int | None # result of index_clusters when it ran, else None
|
|
53
|
+
synthesis_recomputed: bool # whether index_synthesis ran this sync
|
|
54
|
+
synthesis_count: int | None # result of index_synthesis when it ran, else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load_tracked(conn: sqlite3.Connection) -> dict[str, tuple[float, str]]:
|
|
58
|
+
"""Load all tracked files from the DB as {abs_path: (mtime, file_hash)}.
|
|
59
|
+
|
|
60
|
+
WHY: one bulk read at reconcile start is cheaper than per-file queries.
|
|
61
|
+
Returns absolute string paths as keys so comparison with walk_project output
|
|
62
|
+
(which also returns absolute paths) is direct.
|
|
63
|
+
|
|
64
|
+
Excludes synthetic file rows (path starting with ':') used by post-passes
|
|
65
|
+
like index_synthesis to store non-file-scoped edges. These rows are not real
|
|
66
|
+
files on disk and must never be treated as "removed" by the reconcile loop.
|
|
67
|
+
"""
|
|
68
|
+
rows = conn.execute(
|
|
69
|
+
"SELECT path, mtime, file_hash FROM files WHERE path NOT LIKE ':%'"
|
|
70
|
+
).fetchall()
|
|
71
|
+
return {row["path"]: (float(row["mtime"]), row["file_hash"]) for row in rows}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def sync(
|
|
75
|
+
conn: sqlite3.Connection,
|
|
76
|
+
root: Path,
|
|
77
|
+
*,
|
|
78
|
+
recompute_clusters: bool = True,
|
|
79
|
+
force_clusters: bool = False,
|
|
80
|
+
naming_mode: str,
|
|
81
|
+
llm_api_key: str | None,
|
|
82
|
+
llm_model: str | None,
|
|
83
|
+
min_size: int,
|
|
84
|
+
synthesis_enabled: bool = True,
|
|
85
|
+
force_synthesis: bool = False,
|
|
86
|
+
fanout_cap: int = 40,
|
|
87
|
+
) -> SyncResult:
|
|
88
|
+
"""Reconcile the existing index against the current on-disk state.
|
|
89
|
+
|
|
90
|
+
Algorithm (per PRD):
|
|
91
|
+
1. Load tracked {abs_path: (mtime, hash)} from files table.
|
|
92
|
+
2. walk_project(root) → current indexable files.
|
|
93
|
+
3. For each current file:
|
|
94
|
+
- Not in tracked → index_one_file → added (None → skipped).
|
|
95
|
+
- In tracked, st_mtime == stored mtime → unchanged (no read).
|
|
96
|
+
- In tracked, mtime differs, read+sha1 → if hash same → unchanged
|
|
97
|
+
(touched but not changed); if hash differs → index_one_file → modified
|
|
98
|
+
(None → skipped).
|
|
99
|
+
4. For each tracked path absent from current set → delete_file → removed.
|
|
100
|
+
5. Gate: graph_changed = (added + modified + removed) > 0.
|
|
101
|
+
Run index_clusters iff recompute_clusters and (graph_changed or force_clusters).
|
|
102
|
+
|
|
103
|
+
Per-file errors: index_one_file already returns None on any per-file failure;
|
|
104
|
+
sync counts them in `skipped` and continues. DB-level errors propagate to the CLI.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
conn: Open write-access SQLite connection.
|
|
108
|
+
root: Project root to reconcile against.
|
|
109
|
+
recompute_clusters: Master switch — when False, clusters are never recomputed.
|
|
110
|
+
force_clusters: Override gate — recompute clusters even when graph_changed is False.
|
|
111
|
+
naming_mode: Passed verbatim to index_clusters (from config).
|
|
112
|
+
llm_api_key: Passed verbatim to index_clusters (from config).
|
|
113
|
+
llm_model: Passed verbatim to index_clusters (from config).
|
|
114
|
+
min_size: Passed verbatim to index_clusters (from config).
|
|
115
|
+
synthesis_enabled: When False, synthesis pass is skipped (SEAM_EDGE_SYNTHESIS=off).
|
|
116
|
+
force_synthesis: Override gate — rerun synthesis even when graph_changed is False.
|
|
117
|
+
fanout_cap: Per-channel fan-out cap for index_synthesis.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
SyncResult with counts and gate outcomes.
|
|
121
|
+
"""
|
|
122
|
+
added = 0
|
|
123
|
+
modified = 0
|
|
124
|
+
removed = 0
|
|
125
|
+
unchanged = 0
|
|
126
|
+
skipped = 0
|
|
127
|
+
|
|
128
|
+
# ── Step 1: Load current DB state ─────────────────────────────────────────
|
|
129
|
+
# One bulk read is much cheaper than per-file queries in the reconcile loop.
|
|
130
|
+
tracked = _load_tracked(conn)
|
|
131
|
+
|
|
132
|
+
# ── Step 2: Walk on-disk files ────────────────────────────────────────────
|
|
133
|
+
current_paths = walk_project(root)
|
|
134
|
+
# Build a set of abs-string paths for fast "is this tracked?" lookup.
|
|
135
|
+
current_set = {str(p) for p in current_paths}
|
|
136
|
+
|
|
137
|
+
# ── Step 3: Reconcile each on-disk file ───────────────────────────────────
|
|
138
|
+
for path in current_paths:
|
|
139
|
+
abs_str = str(path)
|
|
140
|
+
|
|
141
|
+
if abs_str not in tracked:
|
|
142
|
+
# New file — not in index at all → index it.
|
|
143
|
+
logger.debug("sync: NEW %s", path)
|
|
144
|
+
result = index_one_file(conn, path)
|
|
145
|
+
if result is None:
|
|
146
|
+
skipped += 1
|
|
147
|
+
logger.debug("sync: SKIPPED (index failed) %s", path)
|
|
148
|
+
else:
|
|
149
|
+
added += 1
|
|
150
|
+
else:
|
|
151
|
+
stored_mtime, stored_hash = tracked[abs_str]
|
|
152
|
+
|
|
153
|
+
# Cheap mtime pre-filter: if mtime matches → definitely unchanged.
|
|
154
|
+
try:
|
|
155
|
+
disk_mtime = path.stat().st_mtime
|
|
156
|
+
except OSError as exc:
|
|
157
|
+
# File disappeared between walk and stat — treat as skipped.
|
|
158
|
+
# It will show as removed in step 4 on the next sync.
|
|
159
|
+
logger.debug("sync: stat failed for %s: %s — skipping", path, exc)
|
|
160
|
+
skipped += 1
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
if disk_mtime == stored_mtime:
|
|
164
|
+
# mtime matches → unchanged without reading content.
|
|
165
|
+
logger.debug("sync: UNCHANGED (mtime match) %s", path)
|
|
166
|
+
unchanged += 1
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
# mtime differs — need to read and hash to decide.
|
|
170
|
+
try:
|
|
171
|
+
content = path.read_bytes()
|
|
172
|
+
except OSError as exc:
|
|
173
|
+
logger.debug("sync: read failed for %s: %s — skipping", path, exc)
|
|
174
|
+
skipped += 1
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
disk_hash = sha1(content)
|
|
178
|
+
|
|
179
|
+
if disk_hash == stored_hash:
|
|
180
|
+
# mtime changed but content is identical (e.g. `touch`).
|
|
181
|
+
# WHY: we do NOT update stored mtime here — that would cost
|
|
182
|
+
# a write for zero benefit. On the next sync this file re-hashes
|
|
183
|
+
# once, still no re-index. Documented accepted inefficiency.
|
|
184
|
+
logger.debug("sync: UNCHANGED (hash match, touch) %s", path)
|
|
185
|
+
unchanged += 1
|
|
186
|
+
else:
|
|
187
|
+
# Content actually changed → re-index.
|
|
188
|
+
logger.debug("sync: MODIFIED %s", path)
|
|
189
|
+
result = index_one_file(conn, path)
|
|
190
|
+
if result is None:
|
|
191
|
+
skipped += 1
|
|
192
|
+
logger.debug("sync: SKIPPED (re-index failed) %s", path)
|
|
193
|
+
else:
|
|
194
|
+
modified += 1
|
|
195
|
+
|
|
196
|
+
# ── Step 4: Delete tracked files no longer on disk ────────────────────────
|
|
197
|
+
# Double-check the file is ACTUALLY gone before deleting (CodeGraph's
|
|
198
|
+
# existsSync guard, roadmap §6.1). A tracked path can be absent from the
|
|
199
|
+
# walk set for benign reasons — a transient FS/permission hiccup, a
|
|
200
|
+
# wrong-directory sync, or a --db-dir pointed at another project's index.
|
|
201
|
+
# Trusting the walk set alone would let any of those silently wipe the
|
|
202
|
+
# entire index. We only remove a file once it genuinely no longer exists.
|
|
203
|
+
for abs_str in tracked:
|
|
204
|
+
if abs_str not in current_set and not Path(abs_str).exists():
|
|
205
|
+
logger.debug("sync: REMOVED %s", abs_str)
|
|
206
|
+
delete_file(conn, Path(abs_str))
|
|
207
|
+
removed += 1
|
|
208
|
+
|
|
209
|
+
# ── Step 5: Gate cluster recompute ────────────────────────────────────────
|
|
210
|
+
graph_changed = (added + modified + removed) > 0
|
|
211
|
+
|
|
212
|
+
clusters_recomputed = False
|
|
213
|
+
cluster_count: int | None = None
|
|
214
|
+
|
|
215
|
+
if recompute_clusters and (graph_changed or force_clusters):
|
|
216
|
+
logger.debug(
|
|
217
|
+
"sync: running index_clusters (graph_changed=%s, force=%s)",
|
|
218
|
+
graph_changed,
|
|
219
|
+
force_clusters,
|
|
220
|
+
)
|
|
221
|
+
cluster_count = index_clusters(
|
|
222
|
+
conn,
|
|
223
|
+
naming_mode=naming_mode,
|
|
224
|
+
llm_api_key=llm_api_key,
|
|
225
|
+
llm_model=llm_model,
|
|
226
|
+
min_size=min_size,
|
|
227
|
+
)
|
|
228
|
+
# index_clusters returns -1 on failure (it never raises). "Recomputed"
|
|
229
|
+
# must mean "clusters were successfully refreshed" — a failed pass leaves
|
|
230
|
+
# them stale, so do NOT claim success. The -1 sentinel is preserved in
|
|
231
|
+
# cluster_count so callers can tell "ran but failed" (-1) apart from
|
|
232
|
+
# "did not run" (None), and the CLI surfaces it as a visible failure
|
|
233
|
+
# (mirroring `seam init`'s clustering_failed guard).
|
|
234
|
+
clusters_recomputed = cluster_count >= 0
|
|
235
|
+
|
|
236
|
+
# ── Step 6: Gate synthesis recompute ──────────────────────────────────────
|
|
237
|
+
# Run synthesis iff synthesis_enabled AND (graph_changed OR force_synthesis).
|
|
238
|
+
# Mirrors the cluster-recompute gate exactly: a full-graph pass is needed
|
|
239
|
+
# whenever the edge graph has changed (a new interface or new implementation
|
|
240
|
+
# may affect the override fan-out). --force-synthesis handles the watcher-
|
|
241
|
+
# already-indexed-edits case (same as --force-clusters).
|
|
242
|
+
synthesis_recomputed = False
|
|
243
|
+
synthesis_count: int | None = None
|
|
244
|
+
|
|
245
|
+
if synthesis_enabled and (graph_changed or force_synthesis):
|
|
246
|
+
logger.debug(
|
|
247
|
+
"sync: running index_synthesis (graph_changed=%s, force=%s)",
|
|
248
|
+
graph_changed,
|
|
249
|
+
force_synthesis,
|
|
250
|
+
)
|
|
251
|
+
synthesis_count = index_synthesis(
|
|
252
|
+
conn,
|
|
253
|
+
enabled=synthesis_enabled,
|
|
254
|
+
fanout_cap=fanout_cap,
|
|
255
|
+
)
|
|
256
|
+
# index_synthesis returns -1 on failure (it never raises), 0 when disabled.
|
|
257
|
+
# synthesis_recomputed=True only when the pass actually succeeded (>=0).
|
|
258
|
+
synthesis_recomputed = synthesis_count >= 0
|
|
259
|
+
|
|
260
|
+
logger.info(
|
|
261
|
+
"sync: added=%d modified=%d removed=%d unchanged=%d skipped=%d "
|
|
262
|
+
"graph_changed=%s clusters_recomputed=%s cluster_count=%s "
|
|
263
|
+
"synthesis_recomputed=%s synthesis_count=%s",
|
|
264
|
+
added,
|
|
265
|
+
modified,
|
|
266
|
+
removed,
|
|
267
|
+
unchanged,
|
|
268
|
+
skipped,
|
|
269
|
+
graph_changed,
|
|
270
|
+
clusters_recomputed,
|
|
271
|
+
cluster_count,
|
|
272
|
+
synthesis_recomputed,
|
|
273
|
+
synthesis_count,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
return SyncResult(
|
|
277
|
+
added=added,
|
|
278
|
+
modified=modified,
|
|
279
|
+
removed=removed,
|
|
280
|
+
unchanged=unchanged,
|
|
281
|
+
skipped=skipped,
|
|
282
|
+
graph_changed=graph_changed,
|
|
283
|
+
clusters_recomputed=clusters_recomputed,
|
|
284
|
+
cluster_count=cluster_count,
|
|
285
|
+
synthesis_recomputed=synthesis_recomputed,
|
|
286
|
+
synthesis_count=synthesis_count,
|
|
287
|
+
)
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Edge-synthesis orchestration — bridge between pure engine and DB persistence.
|
|
2
|
+
|
|
3
|
+
Reads symbols+edges from the DB, calls synthesize_edges() (pure engine), and writes
|
|
4
|
+
the synthesized edges in a single transaction.
|
|
5
|
+
|
|
6
|
+
This module sits in the indexer layer and is called by cli/main.py after clustering
|
|
7
|
+
(never per-file, always whole-graph). It mirrors the pattern of cluster_index.py
|
|
8
|
+
exactly: a public never-raising wrapper around an inner implementation that may raise.
|
|
9
|
+
|
|
10
|
+
Import hierarchy (enforced):
|
|
11
|
+
indexer/synthesis_index → analysis.synthesis + config
|
|
12
|
+
analysis modules → pure, no DB writes
|
|
13
|
+
cli → this module (not the other way)
|
|
14
|
+
|
|
15
|
+
Design decisions:
|
|
16
|
+
- Reads all symbols + edges from the connection in one pass.
|
|
17
|
+
- Calls synthesize_edges (pure) then writes in ONE transaction.
|
|
18
|
+
- Synthesized edges are stored under a special synthetic "file" row (':synthesis:')
|
|
19
|
+
that is NOT a real file on disk. This avoids relaxing the edges FK constraint while
|
|
20
|
+
keeping edges.file_id a valid FK reference. The synthetic row is upserted once and
|
|
21
|
+
is never deleted by the normal file-cascade mechanism (its path is ':synthesis:').
|
|
22
|
+
- ON EACH CALL: deletes ALL existing synthesized edges first (WHERE synthesized_by
|
|
23
|
+
IS NOT NULL), then re-inserts fresh ones. This ensures idempotency: calling
|
|
24
|
+
index_synthesis twice yields the same set of edges without duplicates.
|
|
25
|
+
- NEVER raises: returns -1 on error (signals failure to CLI); any error logs
|
|
26
|
+
a warning. Returns 0 when enabled=False. Returns count >= 0 on success.
|
|
27
|
+
- SEAM_EDGE_SYNTHESIS=off: returns 0 immediately, writes nothing.
|
|
28
|
+
- Watcher does NOT call this — only seam init and seam sync do.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import logging
|
|
32
|
+
import sqlite3
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from seam.analysis.synthesis import synthesize_edges
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# Sentinel file path for synthesized edges — not a real on-disk file.
|
|
40
|
+
# Using a colon-prefixed path to ensure it can never collide with a real repo path.
|
|
41
|
+
_SYNTHESIS_FILE_PATH = ":synthesis:"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ensure_synthesis_file_row(conn: sqlite3.Connection) -> int:
|
|
45
|
+
"""Upsert the synthetic file row and return its id.
|
|
46
|
+
|
|
47
|
+
Synthesized edges are not file-scoped, but edges.file_id has a FK to files(id).
|
|
48
|
+
We use a permanent synthetic file row (path=':synthesis:') that is never
|
|
49
|
+
cascade-deleted by normal re-indexing. This preserves FK integrity without
|
|
50
|
+
relaxing constraints or adding nullable columns.
|
|
51
|
+
|
|
52
|
+
Returns the file_id of the synthetic row.
|
|
53
|
+
"""
|
|
54
|
+
conn.execute(
|
|
55
|
+
"""
|
|
56
|
+
INSERT INTO files (path, language, file_hash, mtime, indexed_at)
|
|
57
|
+
VALUES (?, '', '', 0.0, 0.0)
|
|
58
|
+
ON CONFLICT(path) DO NOTHING
|
|
59
|
+
""",
|
|
60
|
+
(_SYNTHESIS_FILE_PATH,),
|
|
61
|
+
)
|
|
62
|
+
row = conn.execute(
|
|
63
|
+
"SELECT id FROM files WHERE path = ?", (_SYNTHESIS_FILE_PATH,)
|
|
64
|
+
).fetchone()
|
|
65
|
+
return row[0]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def index_synthesis(
|
|
69
|
+
conn: sqlite3.Connection,
|
|
70
|
+
*,
|
|
71
|
+
enabled: bool,
|
|
72
|
+
fanout_cap: int,
|
|
73
|
+
) -> int:
|
|
74
|
+
"""Synthesize dynamic-dispatch edges and persist them to the DB.
|
|
75
|
+
|
|
76
|
+
Reads the full symbol+edge graph, calls the synthesis engine, and stores
|
|
77
|
+
the resulting synthesized edges in a single transaction.
|
|
78
|
+
|
|
79
|
+
Called by `seam init` after clustering and by `seam sync` when the graph
|
|
80
|
+
changed (or when --force-synthesis is passed). NEVER called by the watcher.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
conn: Open SQLite connection (must have write access).
|
|
84
|
+
enabled: When False, skip synthesis entirely and return 0 (SEAM_EDGE_SYNTHESIS=off).
|
|
85
|
+
fanout_cap: Per-channel fan-out cap passed to synthesize_edges (from config).
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Number of synthesized edges written (>= 0). Returns -1 on error (never raises).
|
|
89
|
+
Returns 0 when enabled=False.
|
|
90
|
+
|
|
91
|
+
WHY -1 (not 0) on error: lets the CLI distinguish "zero synthesized edges because
|
|
92
|
+
no interface-override patterns exist" from "synthesis failed." Same contract as
|
|
93
|
+
index_clusters (which also returns -1 on failure, never raises).
|
|
94
|
+
"""
|
|
95
|
+
if not enabled:
|
|
96
|
+
logger.debug("synthesis_index: SEAM_EDGE_SYNTHESIS=off — skipping synthesis pass")
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
return _index_synthesis_impl(conn, fanout_cap)
|
|
101
|
+
except Exception as exc: # noqa: BLE001
|
|
102
|
+
logger.warning(
|
|
103
|
+
"synthesis_index: failed to synthesize edges (%s: %s) — "
|
|
104
|
+
"synthesized edges will be absent; run 'seam init' again to retry",
|
|
105
|
+
type(exc).__name__,
|
|
106
|
+
exc,
|
|
107
|
+
)
|
|
108
|
+
return -1
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _load_file_sources(conn: sqlite3.Connection) -> dict[str, str]:
|
|
112
|
+
"""Load source text for all real indexed files.
|
|
113
|
+
|
|
114
|
+
Reads the 'files' table for all non-synthetic paths, then reads each file from
|
|
115
|
+
disk. Silently skips files that are missing, unreadable, or exceed the size limit.
|
|
116
|
+
|
|
117
|
+
WHY here and not in the engine: the synthesis engine is a pure leaf (no DB/IO);
|
|
118
|
+
IO must happen in the bridge layer. We pass the loaded sources to synthesize_edges().
|
|
119
|
+
|
|
120
|
+
Returns a dict mapping file path string → source text (possibly empty if all failed).
|
|
121
|
+
Never raises.
|
|
122
|
+
"""
|
|
123
|
+
from seam import config as cfg # lazy import to keep leaf contract on synthesis.py
|
|
124
|
+
|
|
125
|
+
sources: dict[str, str] = {}
|
|
126
|
+
# Total-corpus budget: stop loading once cumulative source size crosses the cap so
|
|
127
|
+
# a huge monorepo cannot OOM the final init step. 0 = unlimited (see config knob).
|
|
128
|
+
max_total = cfg.SEAM_SYNTHESIS_MAX_SOURCE_BYTES
|
|
129
|
+
total_bytes = 0
|
|
130
|
+
loaded = 0
|
|
131
|
+
tracked = 0
|
|
132
|
+
capped = False
|
|
133
|
+
try:
|
|
134
|
+
path_rows = conn.execute(
|
|
135
|
+
"SELECT path FROM files WHERE path != ? ORDER BY path",
|
|
136
|
+
(_SYNTHESIS_FILE_PATH,),
|
|
137
|
+
).fetchall()
|
|
138
|
+
tracked = len(path_rows)
|
|
139
|
+
|
|
140
|
+
for row in path_rows:
|
|
141
|
+
raw_path = row[0] if isinstance(row, (list, tuple)) else row["path"]
|
|
142
|
+
if not raw_path or raw_path == _SYNTHESIS_FILE_PATH:
|
|
143
|
+
continue
|
|
144
|
+
try:
|
|
145
|
+
p = Path(raw_path)
|
|
146
|
+
if not p.exists() or not p.is_file():
|
|
147
|
+
continue
|
|
148
|
+
# Respect the max file size limit to avoid loading huge generated files.
|
|
149
|
+
size = p.stat().st_size
|
|
150
|
+
if size > cfg.SEAM_MAX_FILE_BYTES:
|
|
151
|
+
continue
|
|
152
|
+
# Stop before crossing the total-corpus budget (bounded memory).
|
|
153
|
+
if max_total > 0 and total_bytes + size > max_total:
|
|
154
|
+
capped = True
|
|
155
|
+
break
|
|
156
|
+
# Read with errors="replace" to handle encoding issues gracefully.
|
|
157
|
+
sources[raw_path] = p.read_text(encoding="utf-8", errors="replace")
|
|
158
|
+
total_bytes += size
|
|
159
|
+
loaded += 1
|
|
160
|
+
except Exception: # noqa: BLE001
|
|
161
|
+
# Missing / unreadable file — skip silently (never-raise contract).
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
# Observability: never silently under-produce. A capped scan is a WARNING
|
|
165
|
+
# (synthesis will be partial); a complete scan logs a DEBUG summary so an
|
|
166
|
+
# operator can see how many of the tracked files actually fed the channels.
|
|
167
|
+
if capped:
|
|
168
|
+
logger.warning(
|
|
169
|
+
"synthesis_index: source-load budget reached (%d bytes, %d/%d files) — "
|
|
170
|
+
"source-text channels see a PARTIAL corpus; raise "
|
|
171
|
+
"SEAM_SYNTHESIS_MAX_SOURCE_BYTES to scan more",
|
|
172
|
+
total_bytes,
|
|
173
|
+
loaded,
|
|
174
|
+
tracked,
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
logger.debug(
|
|
178
|
+
"synthesis_index: loaded %d/%d source files (%d bytes) for synthesis",
|
|
179
|
+
loaded,
|
|
180
|
+
tracked,
|
|
181
|
+
total_bytes,
|
|
182
|
+
)
|
|
183
|
+
except Exception as exc: # noqa: BLE001
|
|
184
|
+
logger.warning(
|
|
185
|
+
"synthesis_index: failed to load file sources (%s: %s) — "
|
|
186
|
+
"source-text channels will have no input",
|
|
187
|
+
type(exc).__name__,
|
|
188
|
+
exc,
|
|
189
|
+
)
|
|
190
|
+
return sources
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _index_synthesis_impl(conn: sqlite3.Connection, fanout_cap: int) -> int:
|
|
194
|
+
"""Inner implementation. May raise — outer function is the guard.
|
|
195
|
+
|
|
196
|
+
WHY separate function: the outer wrapper catches ALL exceptions and converts
|
|
197
|
+
them to -1. Having a clean inner function makes the logic easier to reason
|
|
198
|
+
about and test (tests can call the inner function directly if needed).
|
|
199
|
+
"""
|
|
200
|
+
# ── Step 1: Read all symbols ──────────────────────────────────────────────
|
|
201
|
+
# We need: name (for qualified method lookup), kind (to identify methods vs classes).
|
|
202
|
+
symbol_rows = conn.execute(
|
|
203
|
+
"""
|
|
204
|
+
SELECT s.name, s.kind
|
|
205
|
+
FROM symbols s
|
|
206
|
+
ORDER BY s.name
|
|
207
|
+
"""
|
|
208
|
+
).fetchall()
|
|
209
|
+
|
|
210
|
+
if not symbol_rows:
|
|
211
|
+
logger.debug("synthesis_index: no symbols in index, skipping synthesis")
|
|
212
|
+
# Still delete stale synthesized edges (from a previous run when symbols existed).
|
|
213
|
+
with conn:
|
|
214
|
+
conn.execute("DELETE FROM edges WHERE synthesized_by IS NOT NULL")
|
|
215
|
+
return 0
|
|
216
|
+
|
|
217
|
+
symbols = [{"name": row["name"], "kind": row["kind"]} for row in symbol_rows]
|
|
218
|
+
|
|
219
|
+
# ── Step 2: Read all edges (only the ones needed for synthesis) ────────────
|
|
220
|
+
# We need: source, target, kind (to identify extends/implements pairs).
|
|
221
|
+
# Only read statically-extracted edges (synthesized_by IS NULL) to avoid
|
|
222
|
+
# feeding synthesized edges back into the engine (no feedback loop).
|
|
223
|
+
edge_rows = conn.execute(
|
|
224
|
+
"""
|
|
225
|
+
SELECT DISTINCT source_name, target_name, kind
|
|
226
|
+
FROM edges
|
|
227
|
+
WHERE synthesized_by IS NULL
|
|
228
|
+
"""
|
|
229
|
+
).fetchall()
|
|
230
|
+
|
|
231
|
+
edges = [
|
|
232
|
+
{"source": row["source_name"], "target": row["target_name"], "kind": row["kind"]}
|
|
233
|
+
for row in edge_rows
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
# ── Step 3: Load source text for source-text-based channels ─────────────
|
|
237
|
+
# The A1a/A1b channels (closure-collection, event-emitter) need the actual
|
|
238
|
+
# source text to scan for dispatch patterns. We read from the 'files' table —
|
|
239
|
+
# each row has a 'path' column with the on-disk path. We skip missing/unreadable
|
|
240
|
+
# files silently (never-raise contract), and skip the synthetic ':synthesis:' row.
|
|
241
|
+
file_sources: dict[str, str] = _load_file_sources(conn)
|
|
242
|
+
|
|
243
|
+
# ── Step 4: Run the pure synthesis engine ────────────────────────────────
|
|
244
|
+
# synthesize_edges is pure and never raises (it degrades to [] on error).
|
|
245
|
+
synth_edges = synthesize_edges(
|
|
246
|
+
symbols,
|
|
247
|
+
edges,
|
|
248
|
+
file_sources=file_sources,
|
|
249
|
+
fanout_cap=fanout_cap,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# ── Step 5: Write in ONE transaction ─────────────────────────────────────
|
|
253
|
+
# Upsert the synthetic file row, DELETE all previous synthesized edges, then
|
|
254
|
+
# INSERT the fresh ones — atomically. Folding the synthetic-row upsert in here
|
|
255
|
+
# (rather than a separate earlier transaction) keeps the whole write a single
|
|
256
|
+
# consistent unit: a crash can't leave the ':synthesis:' row with no edges.
|
|
257
|
+
# Idempotent: the DELETE makes a second call produce the same result.
|
|
258
|
+
with conn:
|
|
259
|
+
synth_file_id = _ensure_synthesis_file_row(conn)
|
|
260
|
+
# Clear all previous synthesized edges (from ALL channels) so this pass
|
|
261
|
+
# is idempotent: running index_synthesis twice produces the same result.
|
|
262
|
+
conn.execute("DELETE FROM edges WHERE synthesized_by IS NOT NULL")
|
|
263
|
+
|
|
264
|
+
if synth_edges:
|
|
265
|
+
conn.executemany(
|
|
266
|
+
"""
|
|
267
|
+
INSERT INTO edges (source_name, target_name, kind, file_id, line, confidence, synthesized_by)
|
|
268
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
269
|
+
""",
|
|
270
|
+
[
|
|
271
|
+
(
|
|
272
|
+
e["source"],
|
|
273
|
+
e["target"],
|
|
274
|
+
e["kind"],
|
|
275
|
+
synth_file_id,
|
|
276
|
+
e.get("line", 0),
|
|
277
|
+
e["confidence"],
|
|
278
|
+
e["synthesized_by"],
|
|
279
|
+
)
|
|
280
|
+
for e in synth_edges
|
|
281
|
+
],
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
count = len(synth_edges)
|
|
285
|
+
logger.info(
|
|
286
|
+
"synthesis_index: wrote %d synthesized edges (%s file_id=%d)",
|
|
287
|
+
count,
|
|
288
|
+
_SYNTHESIS_FILE_PATH,
|
|
289
|
+
synth_file_id,
|
|
290
|
+
)
|
|
291
|
+
return count
|
seam/indexer/tokenize.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Identifier compound-split tokenization (Tier D #12) — search-recall leaf.
|
|
2
|
+
|
|
3
|
+
PROBLEM: ``symbols_fts`` uses FTS5's default unicode61 tokenizer, which does NOT split
|
|
4
|
+
camelCase or snake_case. So ``GlobalPushToTalkShortcutMonitor`` is one opaque token and a
|
|
5
|
+
natural-language query ("push to talk shortcut monitor") can never reach its sub-words.
|
|
6
|
+
The split must happen at INDEX time (a stored concatenated token can't be un-joined at
|
|
7
|
+
query time), so the indexer writes ``split_identifier(name)`` into ``symbols.search_text``
|
|
8
|
+
(a dedicated 4th FTS column) and the query layer splits query terms with the SAME function.
|
|
9
|
+
|
|
10
|
+
LAYER: pure leaf — imports only stdlib. No DB, no config, no other seam modules. Used by
|
|
11
|
+
both ``seam/indexer/db.py`` (index side) and ``seam/query/fts.py`` (query side) so the two
|
|
12
|
+
sides tokenize identically. Every function is pure, deterministic, and never raises.
|
|
13
|
+
|
|
14
|
+
WHY identifier-only (no docstring): the docstring is already its own ``symbols_fts`` column,
|
|
15
|
+
so folding it into search_text would double-index prose and dilute ranking. search_text is
|
|
16
|
+
kept to identifier vocabulary (name + qualified_name segments) only.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
# camelCase / acronym boundary insertion. Two passes, applied in order:
|
|
22
|
+
# 1. "<any><Upper><lower>+" → split before an Upper that starts a Word (handles the
|
|
23
|
+
# acronym→Word boundary: "HTTPServer" → "HTTP Server", "parseJSONData" → "...JSON Data").
|
|
24
|
+
# 2. "<lower|digit><Upper>" → split a lowercase/digit run before the next Upper
|
|
25
|
+
# ("fooBar" → "foo Bar", "v2Loader" → "v2 Loader").
|
|
26
|
+
_BOUNDARY_ACRONYM_WORD = re.compile(r"(.)([A-Z][a-z]+)")
|
|
27
|
+
_BOUNDARY_LOWER_UPPER = re.compile(r"([a-z0-9])([A-Z])")
|
|
28
|
+
|
|
29
|
+
# Separators that delimit identifier parts across languages: dot (qualified names),
|
|
30
|
+
# underscore (snake_case), hyphen (kebab), slash/colon (paths/namespaces).
|
|
31
|
+
_SEPARATORS = re.compile(r"[._\-/:]+")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def split_identifier(text: str) -> list[str]:
|
|
35
|
+
"""Split a code identifier into lowercased sub-word tokens (deduped, order-preserving).
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
"GlobalPushToTalkShortcutMonitor" -> ["global","push","to","talk","shortcut","monitor"]
|
|
39
|
+
"parseJSONData" -> ["parse","json","data"]
|
|
40
|
+
"find_cycle" -> ["find","cycle"]
|
|
41
|
+
"Class.method" -> ["class","method"]
|
|
42
|
+
"v2Loader" -> ["v2","loader"]
|
|
43
|
+
"" -> []
|
|
44
|
+
|
|
45
|
+
Pure and total: any string in, a (possibly empty) list of lowercase tokens out. Never raises.
|
|
46
|
+
"""
|
|
47
|
+
if not text:
|
|
48
|
+
return []
|
|
49
|
+
# Normalize cross-language separators to spaces first, then insert camelCase boundaries.
|
|
50
|
+
s = _SEPARATORS.sub(" ", text)
|
|
51
|
+
s = _BOUNDARY_ACRONYM_WORD.sub(r"\1 \2", s)
|
|
52
|
+
s = _BOUNDARY_LOWER_UPPER.sub(r"\1 \2", s)
|
|
53
|
+
|
|
54
|
+
seen: set[str] = set()
|
|
55
|
+
out: list[str] = []
|
|
56
|
+
for tok in s.split():
|
|
57
|
+
low = tok.lower()
|
|
58
|
+
if low and low not in seen:
|
|
59
|
+
seen.add(low)
|
|
60
|
+
out.append(low)
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_search_text(name: str, qualified_name: str | None = None) -> str:
|
|
65
|
+
"""Return the space-joined search_text for a symbol: split(name) ∪ split(qualified_name).
|
|
66
|
+
|
|
67
|
+
qualified_name is folded in (deduped against the name tokens) to recover the enclosing
|
|
68
|
+
type/namespace word WHERE qualified_name carries it (e.g. 'Class.method' → adds 'class';
|
|
69
|
+
a bare top-level function whose qualified_name is just its own name adds nothing). docstring
|
|
70
|
+
is deliberately NOT folded — it is already its own FTS column. Never raises.
|
|
71
|
+
"""
|
|
72
|
+
toks = split_identifier(name)
|
|
73
|
+
if qualified_name:
|
|
74
|
+
seen = set(toks)
|
|
75
|
+
for t in split_identifier(qualified_name):
|
|
76
|
+
if t not in seen:
|
|
77
|
+
seen.add(t)
|
|
78
|
+
toks.append(t)
|
|
79
|
+
return " ".join(toks)
|