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
@@ -0,0 +1,323 @@
1
+ """Shared helpers and constants for all MCP tool handlers.
2
+
3
+ This module is a LEAF in the handler dependency tree:
4
+ handler_common → analysis modules, query modules, config
5
+ impact_handler → handler_common
6
+ trace_handler → handler_common
7
+ tools → handler_common, impact_handler, trace_handler
8
+
9
+ Extracted from seam/server/tools.py (Slice 2, P2 #103) as a pure mechanical split.
10
+ No logic change — byte-identical output before and after the extraction.
11
+
12
+ Contains:
13
+ - _HEAVY_FIELDS, _apply_verbosity
14
+ - Handler limit constants (query/search/impact/trace)
15
+ - _serialize_hop, _serialize_edge_hop (used by trace handler + tests)
16
+ - _trace_not_found, _qualified_trace_candidates (used by trace handler)
17
+ - _relativize, _clamp
18
+ - compute_uid, _resolve_uid (P6c stable handle)
19
+ - _invalid_input, _invalid_query
20
+ - _maybe_attach_staleness (P2 staleness banner helper)
21
+ """
22
+
23
+ import hashlib
24
+ import logging
25
+ import sqlite3
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ import seam.config as config
30
+ from seam.analysis.flows import EdgeHop, Hop
31
+ from seam.analysis.staleness import StalenessVerdict, _watcher_is_alive, check_staleness
32
+ from seam.query.names import resolve_query_to_defs
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # ── Lean-output: heavy fields stripped when verbose=False ────────────────────
37
+
38
+ # These fields are valuable in verbose mode but inflate every record unnecessarily
39
+ # when the agent only needs the core identity + signature.
40
+ # Keys are ABSENT (not null) in lean mode — lean mode's whole point is fewer bytes.
41
+ # E4: synthesized_by added — provenance detail, stripped in lean mode like resolved_by.
42
+ # kind is NOT here — it is a core field always kept even in lean mode.
43
+ _HEAVY_FIELDS: frozenset[str] = frozenset(
44
+ {
45
+ "decorators",
46
+ "is_exported",
47
+ "visibility",
48
+ "qualified_name",
49
+ "resolved_by",
50
+ "best_candidate",
51
+ "synthesized_by", # E4: provenance detail — stripped in lean, like resolved_by
52
+ }
53
+ )
54
+
55
+
56
+ def _apply_verbosity(record: dict[str, Any], verbose: bool) -> dict[str, Any]:
57
+ """Strip heavy enrichment fields from a record when verbose=False.
58
+
59
+ Never mutates the input dict.
60
+
61
+ verbose=True → the SAME dict object is returned unchanged (zero-copy fast path;
62
+ callers build records inline and never mutate them post-call, so
63
+ returning the original is safe and byte-identical to pre-Phase-8).
64
+ verbose=False → a NEW dict is returned without the 6 heavy keys (decorators,
65
+ is_exported, visibility, qualified_name, resolved_by,
66
+ best_candidate). signature and all core identity fields are kept.
67
+ """
68
+ if verbose:
69
+ return record
70
+ # Build a copy without the heavy fields; missing keys are silently skipped.
71
+ return {k: v for k, v in record.items() if k not in _HEAVY_FIELDS}
72
+
73
+
74
+ # ── Limit bounds (from mcp-tools.yaml) ────────────────────────────────────────
75
+
76
+ _QUERY_LIMIT_MIN = 1
77
+ _QUERY_LIMIT_MAX = 50
78
+ _QUERY_LIMIT_DEFAULT = 10
79
+
80
+ _SEARCH_LIMIT_MIN = 1
81
+ _SEARCH_LIMIT_MAX = 100
82
+ _SEARCH_LIMIT_DEFAULT = 20
83
+
84
+ _IMPACT_DEPTH_DEFAULT = 3
85
+ _IMPACT_DIRECTION_DEFAULT = "upstream"
86
+
87
+ _TRACE_DEPTH_MIN = 1
88
+ _TRACE_DEPTH_MAX = 10
89
+ _TRACE_DEPTH_DEFAULT = 10
90
+ # Max bare->qualified candidates tried per trace endpoint on a path miss. Bounds the
91
+ # candidate-pair retry loop to CAP×CAP traces. A bare method name usually resolves to
92
+ # 1–2 qualified symbols; this caps the pathological common-name case ("run", "get").
93
+ _TRACE_ENDPOINT_CAND_CAP = 5
94
+
95
+
96
+ # ── Helpers ───────────────────────────────────────────────────────────────────
97
+
98
+
99
+ def _serialize_hop(hop: Hop, root: Path) -> dict[str, Any]:
100
+ """Serialize a flows.Hop dict for JSON output with path relativization.
101
+
102
+ Includes best_candidate (relativized) for AMBIGUOUS hops.
103
+
104
+ E4: when SEAM_EDGE_PROVENANCE=on, adds 'synthesized_by' (the synthesis channel name
105
+ when the hop is heuristic, null when statically extracted). synthesized_by is in
106
+ _HEAVY_FIELDS and therefore stripped in lean mode (verbose=False) by the caller's
107
+ _apply_verbosity call. 'kind' is always present (it was already emitted before E4).
108
+ """
109
+ raw_candidate: str | None = hop.get("best_candidate")
110
+ record: dict[str, Any] = {
111
+ "from_name": hop["from_name"],
112
+ "to_name": hop["to_name"],
113
+ "kind": hop["kind"],
114
+ "confidence": hop["confidence"],
115
+ "resolved_by": hop.get("resolved_by"),
116
+ "best_candidate": _relativize(raw_candidate, root) if raw_candidate is not None else None,
117
+ }
118
+ # E4: surface synthesized_by when edge-provenance is enabled.
119
+ # null (None) is retained — it is the common "static edge" value and is meaningful.
120
+ if config.SEAM_EDGE_PROVENANCE == "on":
121
+ record["synthesized_by"] = hop.get("synthesized_by")
122
+ return record
123
+
124
+
125
+ def _serialize_edge_hop(hop: EdgeHop, root: Path | None = None) -> dict[str, Any]:
126
+ """Serialize an EdgeHop TypedDict to a plain dict for JSON-safe output.
127
+
128
+ Phase 5: includes resolved_by for provenance (null when not available).
129
+ Includes best_candidate (relativized) for AMBIGUOUS hops.
130
+
131
+ E4: when SEAM_EDGE_PROVENANCE=on, adds 'synthesized_by' (channel name for
132
+ heuristic edges, null for static). In _HEAVY_FIELDS → stripped in lean mode.
133
+ """
134
+ raw_candidate = hop.get("best_candidate")
135
+ record: dict[str, Any] = {
136
+ "name": hop["name"],
137
+ "kind": hop["kind"],
138
+ "confidence": hop["confidence"],
139
+ "resolved_by": hop.get("resolved_by"), # Phase 5: null = unknown/fast-path
140
+ # best_candidate for AMBIGUOUS hops; relativized when root is provided.
141
+ "best_candidate": (
142
+ _relativize(raw_candidate, root)
143
+ if (raw_candidate is not None and root is not None)
144
+ else raw_candidate
145
+ ),
146
+ }
147
+ # E4: surface synthesized_by when edge-provenance is enabled.
148
+ # null (None) is retained — it is the common "static edge" value and is meaningful.
149
+ if config.SEAM_EDGE_PROVENANCE == "on":
150
+ record["synthesized_by"] = hop.get("synthesized_by")
151
+ return record
152
+
153
+
154
+ def _trace_not_found(source: str, target: str) -> dict[str, Any]:
155
+ """Empty trace result for an unknown uid/target_uid (P6c).
156
+
157
+ Mirrors the shape trace() produces for a genuinely-not-connected pair so an
158
+ unknown handle reads as "no path", not as an error.
159
+ """
160
+ return {
161
+ "found": False,
162
+ "source": source,
163
+ "target": target,
164
+ "paths": [],
165
+ "callers_source": [],
166
+ "callees_source": [],
167
+ "callers_target": [],
168
+ "callees_target": [],
169
+ }
170
+
171
+
172
+ def _qualified_trace_candidates(conn: sqlite3.Connection, name: str) -> list[str]:
173
+ """Resolve a BARE trace endpoint to its qualified symbol form(s).
174
+
175
+ WHY: with Tier B receiver inference, method call edges store qualified names
176
+ ('Class.method') on both ends, so a bare endpoint matches no edge and trace
177
+ returns nothing. This bridges bare -> qualified (the opposite direction from
178
+ expand_impact_seeds, which bridges qualified -> bare for impact's upstream walk).
179
+
180
+ Returns the qualified symbol names whose last dotted segment equals `name`
181
+ (e.g. 'bar' -> ['Foo.bar']). Returns [] for an already-qualified name (the caller
182
+ has already tried it) or when nothing resolves. Bounded by _TRACE_ENDPOINT_CAND_CAP
183
+ to keep the candidate-pair retry loop small. Never raises.
184
+ """
185
+ if "." in name:
186
+ return [] # already qualified — caller tried it directly
187
+ try:
188
+ rows = resolve_query_to_defs(conn, name)
189
+ except Exception: # noqa: BLE001 — read path never raises
190
+ logger.debug("_qualified_trace_candidates: resolve failed for %r", name, exc_info=True)
191
+ return []
192
+ out: list[str] = []
193
+ for row in rows:
194
+ qn = row["name"]
195
+ # Keep only the QUALIFIED forms (skip an exact bare match — already attempted).
196
+ if qn != name and qn not in out:
197
+ out.append(qn)
198
+ if len(out) >= _TRACE_ENDPOINT_CAND_CAP:
199
+ break
200
+ return out
201
+
202
+
203
+ def _relativize(abs_path: str, root: Path) -> str:
204
+ """Return abs_path relative to root; falls back to abs_path if not under root."""
205
+ try:
206
+ return str(Path(abs_path).relative_to(root))
207
+ except ValueError:
208
+ return abs_path
209
+
210
+
211
+ def _clamp(value: int, lo: int, hi: int) -> int:
212
+ """Clamp value to [lo, hi] inclusive."""
213
+ return max(lo, min(hi, value))
214
+
215
+
216
+ # ── Stable symbol UID handle (P6c) ────────────────────────────────────────────
217
+
218
+
219
+ def compute_uid(file_path: str, start_line: int) -> str:
220
+ """Compute a stable, opaque handle for a symbol: sha1(file_path)[:8] + ':' + line.
221
+
222
+ P6c: a homonym follow-up otherwise forces an agent to re-disambiguate by file
223
+ path (an extra round-trip). The UID is a pure computed string — NO schema
224
+ change, NO extra DB query. It is surfaced on search/query results and accepted
225
+ as an alternative to `name` on context/impact/trace, where it resolves to the
226
+ EXACT (file, line) symbol, bypassing homonym ambiguity.
227
+
228
+ file_path is the ABSOLUTE path stored in the files table. We hash the absolute
229
+ path (not the relativized output path) so the UID can be resolved back to the
230
+ exact row by recomputing it over each candidate symbol's absolute path.
231
+ """
232
+ digest = hashlib.sha1(file_path.encode("utf-8")).hexdigest()[:8]
233
+ return f"{digest}:{start_line}"
234
+
235
+
236
+ def _resolve_uid(conn: sqlite3.Connection, uid: str) -> tuple[str, str, int] | None:
237
+ """Resolve a UID handle to the exact (name, abs_file_path, start_line) it pins.
238
+
239
+ Strategy: a UID is sha1(abs_path)[:8] + ':' + start_line. The start_line is
240
+ recoverable directly; the file prefix is NOT reversible, so we narrow by
241
+ start_line in SQL (cheap — uses the line value) and recompute the UID for each
242
+ candidate symbol at that line until one matches. This keeps the read path lean:
243
+ no schema change, no O(files) scan — only the (typically tiny) set of symbols
244
+ that begin at the same line is examined.
245
+
246
+ Returns None for a malformed UID or one that matches no indexed symbol (the
247
+ same not-found contract as an unknown symbol name).
248
+ """
249
+ prefix, sep, line_str = uid.partition(":")
250
+ if not sep or not line_str.isdigit():
251
+ return None
252
+ start_line = int(line_str)
253
+
254
+ rows = conn.execute(
255
+ """
256
+ SELECT s.name AS name, f.path AS file
257
+ FROM symbols s
258
+ JOIN files f ON f.id = s.file_id
259
+ WHERE s.start_line = ?
260
+ ORDER BY s.id
261
+ """,
262
+ (start_line,),
263
+ ).fetchall()
264
+
265
+ for row in rows:
266
+ if compute_uid(row["file"], start_line) == uid:
267
+ return row["name"], row["file"], start_line
268
+ return None
269
+
270
+
271
+ def _invalid_input(message: str) -> dict[str, Any]:
272
+ # Log so an operator can see what an agent actually sent (the error dict
273
+ # otherwise vanishes into the agent's response with no server-side trace).
274
+ logger.warning("rejected (INVALID_INPUT): %s", message)
275
+ return {"error": "INVALID_INPUT", "message": message}
276
+
277
+
278
+ def _invalid_query(message: str) -> dict[str, Any]:
279
+ logger.warning("rejected (INVALID_QUERY): %s", message)
280
+ return {"error": "INVALID_QUERY", "message": message}
281
+
282
+
283
+ def _maybe_attach_staleness(
284
+ response: dict[str, Any],
285
+ conn: sqlite3.Connection,
286
+ root: Path,
287
+ ) -> dict[str, Any]:
288
+ """Attach the P2 staleness banner to a graph-traversal handler response.
289
+
290
+ Called as the LAST step in the 5 graph-traversal handlers (impact, changes,
291
+ affected, context, trace). Purely additive — never alters existing fields.
292
+
293
+ When SEAM_STALENESS_CHECK=off OR the index is fresh → returns response UNCHANGED
294
+ (byte-identical to pre-feature). When stale → adds a top-level `index_status`
295
+ key: {"stale": True, "reason": str, "hint": str}.
296
+
297
+ WHY last step: staleness is orthogonal to the handler's core logic. Attaching
298
+ it last keeps the core path clean and makes it easy to audit that only an
299
+ additive field is added.
300
+
301
+ Never raises — staleness check degrades gracefully on any IO error.
302
+ """
303
+ if config.SEAM_STALENESS_CHECK != "on":
304
+ return response
305
+
306
+ # Derive watcher-alive status from the standard PID file location.
307
+ # The convention is root/.seam/watcher.pid (same as main.py start/status).
308
+ pid_file = root / ".seam" / "watcher.pid"
309
+ watcher_alive = _watcher_is_alive(pid_file) is not None
310
+
311
+ verdict: StalenessVerdict = check_staleness(conn, root=root, watcher_alive=watcher_alive)
312
+ if verdict["stale"]:
313
+ # Additive only: build a new dict with index_status at the end.
314
+ # WHY new dict: we never mutate the input (same contract as _apply_verbosity).
315
+ result = dict(response)
316
+ result["index_status"] = {
317
+ "stale": True,
318
+ "reason": verdict["reason"],
319
+ "hint": verdict["hint"],
320
+ }
321
+ return result
322
+
323
+ return response