seam-code 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. seam/__init__.py +3 -0
  2. seam/_data/schema.sql +225 -0
  3. seam/_web/assets/index-BL_tqprR.js +216 -0
  4. seam/_web/assets/index-GTKUhVyD.css +1 -0
  5. seam/_web/index.html +13 -0
  6. seam/analysis/__init__.py +14 -0
  7. seam/analysis/affected.py +254 -0
  8. seam/analysis/builtins.py +966 -0
  9. seam/analysis/byte_budget.py +217 -0
  10. seam/analysis/changes.py +709 -0
  11. seam/analysis/cluster_naming.py +260 -0
  12. seam/analysis/clustering.py +216 -0
  13. seam/analysis/confidence.py +699 -0
  14. seam/analysis/embeddings.py +195 -0
  15. seam/analysis/flows.py +708 -0
  16. seam/analysis/impact.py +444 -0
  17. seam/analysis/imports.py +994 -0
  18. seam/analysis/imports_ext.py +780 -0
  19. seam/analysis/imports_resolve.py +176 -0
  20. seam/analysis/processes.py +453 -0
  21. seam/analysis/relevance.py +155 -0
  22. seam/analysis/rwr.py +129 -0
  23. seam/analysis/staleness.py +328 -0
  24. seam/analysis/steer.py +282 -0
  25. seam/analysis/synthesis.py +253 -0
  26. seam/analysis/synthesis_channels.py +433 -0
  27. seam/analysis/testpaths.py +103 -0
  28. seam/analysis/traversal.py +470 -0
  29. seam/cli/__init__.py +0 -0
  30. seam/cli/install.py +232 -0
  31. seam/cli/main.py +2602 -0
  32. seam/cli/output.py +137 -0
  33. seam/cli/read.py +244 -0
  34. seam/cli/serve.py +145 -0
  35. seam/config.py +551 -0
  36. seam/indexer/__init__.py +0 -0
  37. seam/indexer/cluster_index.py +425 -0
  38. seam/indexer/db.py +496 -0
  39. seam/indexer/embedding_index.py +183 -0
  40. seam/indexer/field_access.py +536 -0
  41. seam/indexer/field_access_c_cpp.py +643 -0
  42. seam/indexer/field_access_ext.py +708 -0
  43. seam/indexer/field_access_ext2.py +408 -0
  44. seam/indexer/field_access_go_rust.py +737 -0
  45. seam/indexer/field_access_php_swift.py +888 -0
  46. seam/indexer/field_access_ts.py +626 -0
  47. seam/indexer/graph.py +321 -0
  48. seam/indexer/graph_c.py +562 -0
  49. seam/indexer/graph_c_cpp.py +39 -0
  50. seam/indexer/graph_common.py +644 -0
  51. seam/indexer/graph_cpp.py +615 -0
  52. seam/indexer/graph_csharp.py +651 -0
  53. seam/indexer/graph_go.py +723 -0
  54. seam/indexer/graph_go_rust.py +39 -0
  55. seam/indexer/graph_java.py +689 -0
  56. seam/indexer/graph_java_csharp.py +38 -0
  57. seam/indexer/graph_php.py +914 -0
  58. seam/indexer/graph_python.py +628 -0
  59. seam/indexer/graph_ruby.py +748 -0
  60. seam/indexer/graph_rust.py +653 -0
  61. seam/indexer/graph_scope_infer.py +902 -0
  62. seam/indexer/graph_scope_infer_ext.py +723 -0
  63. seam/indexer/graph_scope_infer_ext2.py +992 -0
  64. seam/indexer/graph_swift.py +1014 -0
  65. seam/indexer/graph_swift_infer.py +515 -0
  66. seam/indexer/graph_typescript.py +663 -0
  67. seam/indexer/migrations.py +816 -0
  68. seam/indexer/parser.py +204 -0
  69. seam/indexer/pipeline.py +197 -0
  70. seam/indexer/signatures.py +634 -0
  71. seam/indexer/signatures_ext.py +780 -0
  72. seam/indexer/sync.py +287 -0
  73. seam/indexer/synthesis_index.py +291 -0
  74. seam/indexer/tokenize.py +79 -0
  75. seam/installer/__init__.py +67 -0
  76. seam/installer/claude.py +97 -0
  77. seam/installer/codex.py +94 -0
  78. seam/installer/core.py +127 -0
  79. seam/installer/cursor.py +61 -0
  80. seam/installer/guide.py +110 -0
  81. seam/installer/jsonfile.py +85 -0
  82. seam/installer/markdownfile.py +146 -0
  83. seam/installer/tomlfile.py +72 -0
  84. seam/query/__init__.py +0 -0
  85. seam/query/clusters.py +206 -0
  86. seam/query/comments.py +217 -0
  87. seam/query/context.py +293 -0
  88. seam/query/engine.py +940 -0
  89. seam/query/fts.py +328 -0
  90. seam/query/names.py +470 -0
  91. seam/query/pack.py +433 -0
  92. seam/query/semantic.py +339 -0
  93. seam/query/structure.py +727 -0
  94. seam/server/__init__.py +0 -0
  95. seam/server/graph_api.py +437 -0
  96. seam/server/handler_common.py +323 -0
  97. seam/server/impact_handler.py +615 -0
  98. seam/server/mcp.py +556 -0
  99. seam/server/tools.py +697 -0
  100. seam/server/trace_handler.py +184 -0
  101. seam/server/web.py +922 -0
  102. seam/watcher/__init__.py +0 -0
  103. seam/watcher/__main__.py +56 -0
  104. seam/watcher/daemon.py +237 -0
  105. seam_code-0.3.0.dist-info/METADATA +318 -0
  106. seam_code-0.3.0.dist-info/RECORD +109 -0
  107. seam_code-0.3.0.dist-info/WHEEL +4 -0
  108. seam_code-0.3.0.dist-info/entry_points.txt +2 -0
  109. seam_code-0.3.0.dist-info/licenses/LICENSE +21 -0
seam/query/fts.py ADDED
@@ -0,0 +1,328 @@
1
+ """FTS5 query construction and multi-signal rescoring (Phase 3, Slice 1).
2
+
3
+ Pure leaf module — no imports from query/server/cli layers, no DB access.
4
+ Only stdlib + typing. Must not open a database connection.
5
+
6
+ WHY this module exists:
7
+ Before Phase 3, engine.py passed the raw user text directly into the FTS5
8
+ MATCH clause. FTS5 implicitly ANDs space-separated terms, so a query like
9
+ "parse issues board" returned ZERO hits if 'board' was not indexed — even
10
+ though 'parse' matched many symbols. This module fixes that by building an
11
+ OR-joined prefix-quoted MATCH expression, and adds multi-signal rescoring
12
+ so precision is recovered without requiring all terms to match.
13
+
14
+ build_match_query(text) -> str
15
+ Tokenise, strip FTS5 operators/specials, wrap each surviving term as "term"*,
16
+ join with OR. Returns a safe sentinel (empty string) on fully-stripped input.
17
+
18
+ rescore(rows, terms) -> list
19
+ Multi-signal rerank over FTS result rows. Signals (per CodeGraph research §4.2):
20
+ 1. Exact name match: +80
21
+ 2. Prefix name match: +40 (name starts with any query term)
22
+ 3. Path relevance: +10 per term found in file path (directory/filename)
23
+ 4. Test-file dampening: -30 when query has no test-signal and file is a test file
24
+ 5. Cluster boost: +20 when row shares the dominant seed's cluster_id
25
+ 6. Signature boost: +15 per term found in signature (Phase 4; additive/small
26
+ to avoid displacing exact-name matches — see constant comment)
27
+
28
+ Score is an UNBOUNDED relevance heuristic — document as "relevance", never as %.
29
+ """
30
+
31
+ import logging
32
+ import re
33
+ from typing import Any
34
+
35
+ from seam.analysis.testpaths import is_test_file
36
+ from seam.config import SEAM_TOKENIZE_IDENTIFIERS
37
+ from seam.indexer.tokenize import split_identifier
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # ── FTS5 special characters and operators to strip ────────────────────────────
42
+
43
+ # Characters that are special in FTS5 MATCH syntax.
44
+ # We strip these so user input cannot inject MATCH operators or crash the query.
45
+ _FTS5_SPECIAL_CHARS: re.Pattern = re.compile(r'["\'\(\)\*\+\-\^\:\.]')
46
+
47
+ # FTS5 boolean operators and NEAR function — must not survive as query terms.
48
+ # Checked case-insensitively against individual tokens after splitting.
49
+ _FTS5_OPERATORS: frozenset[str] = frozenset({"AND", "OR", "NOT", "NEAR"})
50
+
51
+ # Minimum token length to include in the MATCH expression.
52
+ # 1-char prefix queries are valid FTS5 and can match single-letter symbols (e.g. 'x').
53
+ # WHY 1 not 2: a search for 'x' should find symbol 'x'. The original 2 silently dropped
54
+ # single-char queries, making them appear to return no results.
55
+ # NOTE: fuzzy fallback still requires >= 3 chars (too noisy for short terms).
56
+ _MIN_TERM_LEN: int = 1
57
+
58
+ # Sentinel returned when the input strips to nothing.
59
+ # An empty string is a valid SQLite expression that yields zero FTS5 rows without
60
+ # raising OperationalError, so callers need no special-case branch for stripped queries.
61
+ _EMPTY_SENTINEL: str = ""
62
+
63
+
64
+ # ── Rescoring constants ───────────────────────────────────────────────────────
65
+ # These mirror CodeGraph's query-utils.ts weights (§4.2) adapted for Seam's
66
+ # data model (cluster_id vs. no cluster concept in CodeGraph).
67
+
68
+ _BONUS_EXACT_NAME: float = 80.0 # name IS the query term (highest signal)
69
+ _BONUS_PREFIX_NAME: float = 40.0 # name STARTS WITH a query term
70
+ _BONUS_PATH_PER_TERM: float = 10.0 # query term appears in the file path
71
+ _PENALTY_TEST_FILE: float = -30.0 # test-file dampening on non-test queries
72
+ _BONUS_CLUSTER_PEER: float = 20.0 # shares cluster with the strongest-score row
73
+ # Phase 4: small additive boost when a query term appears in the signature.
74
+ # Intentionally smaller than name/path signals so signature-only matches rank
75
+ # below exact-name matches. Per PRD: MUST NOT regress existing signals.
76
+ _BONUS_SIGNATURE_PER_TERM: float = 15.0
77
+ # P2 cohesion bonus: scales a small +0.1 with the row's cluster cohesion ratio.
78
+ # Deliberately tiny — it only nudges ordering AMONG otherwise-equal results
79
+ # (e.g. two name-tied symbols), never overrides a name/path signal. Fires ONLY
80
+ # when a row carries a non-NULL 'cohesion' key, so rows without it are unchanged.
81
+ _BONUS_COHESION_MAX: float = 0.1
82
+
83
+ # A query is considered "test-aware" if any term is one of these words,
84
+ # in which case we suppress the test-file dampening.
85
+ _TEST_QUERY_SIGNALS: frozenset[str] = frozenset({"test", "tests", "spec", "fixture"})
86
+
87
+
88
+ # ═══════════════════════════════════════════════════════════════════════════════
89
+ # Public API
90
+ # ═══════════════════════════════════════════════════════════════════════════════
91
+
92
+
93
+ def extract_terms(text: str) -> list[str]:
94
+ """Extract plain query terms from raw user text (no FTS5 operators).
95
+
96
+ Public single source of truth for tokenisation used by both build_match_query()
97
+ and engine.py rescore() signal computation.
98
+
99
+ Algorithm mirrors build_match_query() step 1-4 but returns plain tokens
100
+ (NOT wrapped as quoted prefixes). Used by rescore() to compute name/path signals.
101
+
102
+ WHY public: engine.py previously had a private _extract_terms that duplicated
103
+ this exact logic (same regex, same operator set, same min-len). Centralising
104
+ here ensures that the tokeniser and query builder always agree.
105
+ """
106
+ if not text or not text.strip():
107
+ return []
108
+ cleaned = _FTS5_SPECIAL_CHARS.sub(" ", text)
109
+ tokens = cleaned.split()
110
+ return [t for t in tokens if t.upper() not in _FTS5_OPERATORS and len(t) >= _MIN_TERM_LEN]
111
+
112
+
113
+ def build_match_query(text: str) -> str:
114
+ """Build a safe FTS5 MATCH expression from raw user input.
115
+
116
+ Algorithm:
117
+ 1. Strip FTS5 special characters (quotes, parens, *, ^, etc.).
118
+ 2. Split on whitespace into candidate tokens.
119
+ 3. Discard FTS5 boolean operators (AND, OR, NOT, NEAR — case-insensitive).
120
+ 4. Discard tokens shorter than _MIN_TERM_LEN characters.
121
+ 5. Wrap each surviving token as a quoted prefix: "token"*
122
+ 6. Join with " OR " so one non-matching word cannot zero the result.
123
+
124
+ The OR-join is the core fix for the implicit-AND zero-hit bug:
125
+ "parse issues board" → "parse"* OR "issues"* OR "board"*
126
+ → returns all symbols matching ANY of the three terms.
127
+
128
+ Returns:
129
+ A safe FTS5 MATCH expression string, or _EMPTY_SENTINEL ("") when all
130
+ tokens are stripped. The empty string triggers zero FTS5 rows (SQLite
131
+ handles it without raising OperationalError).
132
+ """
133
+ if not text or not text.strip():
134
+ return _EMPTY_SENTINEL
135
+
136
+ # Step 1: strip FTS5 special characters
137
+ cleaned = _FTS5_SPECIAL_CHARS.sub(" ", text)
138
+
139
+ # Step 2: split into tokens
140
+ tokens = cleaned.split()
141
+
142
+ # Steps 3+4: filter operators and short tokens
143
+ surviving: list[str] = []
144
+ for token in tokens:
145
+ # Case-insensitive operator check
146
+ if token.upper() in _FTS5_OPERATORS:
147
+ logger.debug("fts: stripped FTS5 operator %r from query", token)
148
+ continue
149
+ if len(token) < _MIN_TERM_LEN:
150
+ logger.debug("fts: stripped short token %r from query", token)
151
+ continue
152
+ surviving.append(token)
153
+
154
+ if not surviving:
155
+ # Log at INFO (not DEBUG) so operators can distinguish "query discarded because
156
+ # all tokens were operators/too-short" from "FTS ran but genuinely found nothing".
157
+ # Both surface as an empty result set to the caller, but the cause is different.
158
+ logger.info(
159
+ "fts: all tokens stripped from query %r — returning sentinel (zero FTS5 rows)", text
160
+ )
161
+ return _EMPTY_SENTINEL
162
+
163
+ # Tier D #12: expand each surviving token into its identifier sub-words so a camelCase
164
+ # query term ("GlobalPushToTalk") matches the split index, and the original token is kept
165
+ # (so exact/prefix matching on the name column still fires). split_identifier on a plain
166
+ # lowercase word returns just itself, so natural-language queries are unaffected.
167
+ # Gated on the knob: when off, the term set is byte-identical to pre-D12.
168
+ expanded: list[str] = []
169
+ seen_terms: set[str] = set()
170
+ for token in surviving:
171
+ candidates = [token]
172
+ if SEAM_TOKENIZE_IDENTIFIERS == "on":
173
+ # Drop single-char sub-words: a "a"* / "x"* prefix term matches a huge swath of
174
+ # the index and only adds noise (the original camelCase token is already kept).
175
+ # Guard at >=2 explicitly (not _MIN_TERM_LEN, which is 1) since sub-tokens are the
176
+ # high-fan-out source — the OR-expansion makes short terms disproportionately costly.
177
+ candidates += [sub for sub in split_identifier(token) if len(sub) >= 2]
178
+ for cand in candidates:
179
+ low = cand.lower()
180
+ if low not in seen_terms:
181
+ seen_terms.add(low)
182
+ expanded.append(cand)
183
+
184
+ # Step 5+6: build quoted-prefix OR expression.
185
+ # Each term is wrapped as "term"* so FTS5 does prefix-matching (matches any
186
+ # indexed token starting with 'term'). Terms are OR-joined so that one
187
+ # non-matching word cannot zero the entire result set — the fix for the
188
+ # implicit-AND bug where "parse issues board" returned nothing if 'board' was
189
+ # not indexed, even though 'parse' matched many symbols.
190
+ terms = [f'"{t}"*' for t in expanded]
191
+ match_expr = " OR ".join(terms)
192
+
193
+ logger.debug("fts: built MATCH expression: %r", match_expr)
194
+ return match_expr
195
+
196
+
197
+ def rescore(rows: list[dict[str, Any]], terms: list[str]) -> list[dict[str, Any]]:
198
+ """Multi-signal rerank over FTS result rows.
199
+
200
+ Applies five signals on top of the FTS BM25 score:
201
+ 1. Exact name match bonus (+80): symbol name equals a query term (case-insensitive)
202
+ 2. Prefix name match bonus (+40): symbol name starts with a query term
203
+ 3. Path relevance (+10/term): query term appears in the file path
204
+ 4. Test-file dampening (-30): if query has no test-signal and file is a test file
205
+ 5. Cluster boost (+20): row shares cluster_id with the row that has the highest
206
+ raw FTS score (the "strongest seed").
207
+
208
+ The final score is the base FTS score + all applicable bonuses/penalties.
209
+ It is an UNBOUNDED relevance heuristic — callers MUST NOT render it as a %.
210
+
211
+ Args:
212
+ rows: List of result dicts. Each row must have keys: symbol (str), file (str),
213
+ score (float). Optional key: cluster_id (int | None).
214
+ terms: The query terms as plain strings (already stripped of FTS5 operators).
215
+ Used for name/path relevance checks.
216
+
217
+ Returns:
218
+ The same rows with updated 'score' fields, sorted highest-first.
219
+ Returns a new list; the input is not mutated.
220
+ """
221
+ if not rows:
222
+ return []
223
+
224
+ # Normalise terms to lowercase for case-insensitive comparison
225
+ lower_terms = [t.lower() for t in terms if t]
226
+
227
+ # Determine whether the query itself has test-awareness
228
+ # (suppresses dampening so test-related queries surface test files normally)
229
+ is_test_query = bool(_TEST_QUERY_SIGNALS & frozenset(lower_terms))
230
+
231
+ # Signal 5 (cluster boost): identify the dominant cluster from the highest-scoring row.
232
+ # FTS5 returns rows ordered by BM25 (best first), so rows[0] is the strongest seed.
233
+ # Peers of that seed are likely related — boosting them improves recall without
234
+ # widening to unrelated clusters.
235
+ dominant_cluster_id: int | None = None
236
+ if rows:
237
+ first_cluster = rows[0].get("cluster_id")
238
+ if first_cluster is not None:
239
+ dominant_cluster_id = int(first_cluster)
240
+
241
+ # Apply signals to each row (work on copies to avoid mutating input)
242
+ scored: list[dict[str, Any]] = []
243
+ for row in rows:
244
+ # Shallow copy: we only replace 'score', rest stays identical
245
+ new_row = dict(row)
246
+ base_score = float(row.get("score", 0.0))
247
+ bonus = 0.0
248
+
249
+ name_lower = str(row.get("symbol", "")).lower()
250
+ file_lower = str(row.get("file", "")).lower()
251
+
252
+ if lower_terms:
253
+ # Signal 1: exact name match — name IS one of the query terms
254
+ if name_lower in lower_terms:
255
+ bonus += _BONUS_EXACT_NAME
256
+ logger.debug(
257
+ "fts.rescore: exact-name bonus +%.0f for %r", _BONUS_EXACT_NAME, row["symbol"]
258
+ )
259
+
260
+ # Signal 2: prefix name match — name starts with any query term
261
+ elif any(name_lower.startswith(t) for t in lower_terms):
262
+ bonus += _BONUS_PREFIX_NAME
263
+ logger.debug(
264
+ "fts.rescore: prefix-name bonus +%.0f for %r", _BONUS_PREFIX_NAME, row["symbol"]
265
+ )
266
+
267
+ # Signal 3: path relevance — query term appears in the file path
268
+ path_bonus = sum(_BONUS_PATH_PER_TERM for t in lower_terms if t in file_lower)
269
+ bonus += path_bonus
270
+
271
+ # Signal 4: test-file dampening — only on non-test queries
272
+ if not is_test_query and is_test_file(row.get("file")):
273
+ bonus += _PENALTY_TEST_FILE
274
+ logger.debug(
275
+ "fts.rescore: test-file penalty %.0f for %r", _PENALTY_TEST_FILE, row["symbol"]
276
+ )
277
+
278
+ # Signal 5: cluster boost — shares cluster with the strongest seed
279
+ row_cluster = row.get("cluster_id")
280
+ if (
281
+ dominant_cluster_id is not None
282
+ and row_cluster is not None
283
+ and int(row_cluster) == dominant_cluster_id
284
+ ):
285
+ bonus += _BONUS_CLUSTER_PEER
286
+ logger.debug(
287
+ "fts.rescore: cluster boost +%.0f for %r", _BONUS_CLUSTER_PEER, row["symbol"]
288
+ )
289
+
290
+ # Signal 6 (Phase 4): signature boost — query term appears in the signature.
291
+ # Only fires when signature is non-NULL and a query term is found in it.
292
+ # WHY additive and small: signature matches are weaker than name matches (many
293
+ # functions share the same param types like 'conn' or 'str'). The 15-point cap
294
+ # per term keeps this below the exact-name (80) and prefix-name (40) signals so
295
+ # the ranking order for name-matching symbols is preserved.
296
+ if lower_terms:
297
+ raw_sig = row.get("signature")
298
+ if raw_sig is not None:
299
+ sig_lower = str(raw_sig).lower()
300
+ sig_bonus = sum(
301
+ _BONUS_SIGNATURE_PER_TERM for t in lower_terms if t in sig_lower
302
+ )
303
+ if sig_bonus > 0:
304
+ bonus += sig_bonus
305
+ logger.debug(
306
+ "fts.rescore: signature boost +%.0f for %r",
307
+ sig_bonus,
308
+ row["symbol"],
309
+ )
310
+
311
+ # Signal 7 (P2): cohesion bonus — additive, tiny, opt-in.
312
+ # Fires ONLY when the row carries a non-NULL 'cohesion' value (in [0,1]).
313
+ # Rows without the key (or with NULL) get NO bonus → byte-identical to pre-P2.
314
+ # WHY guard on the key: search()/fallback rows do not select cohesion today,
315
+ # so this is dead-weight-free unless a caller explicitly supplies cohesion.
316
+ cohesion = row.get("cohesion")
317
+ if cohesion is not None:
318
+ try:
319
+ bonus += _BONUS_COHESION_MAX * float(cohesion)
320
+ except (TypeError, ValueError):
321
+ pass # non-numeric cohesion → no bonus (defensive, never raises)
322
+
323
+ new_row["score"] = base_score + bonus
324
+ scored.append(new_row)
325
+
326
+ # Sort by final score descending (higher = more relevant per API contract)
327
+ scored.sort(key=lambda r: r["score"], reverse=True)
328
+ return scored