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