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/server/tools.py ADDED
@@ -0,0 +1,697 @@
1
+ """MCP tool handlers — thin facade + remaining handlers.
2
+
3
+ This module is the public surface of the handler layer. It:
4
+ 1. Re-exports everything from handler_common, impact_handler, trace_handler so
5
+ that all existing imports (`from seam.server.tools import X`) continue to work
6
+ unchanged. seam/server/mcp.py, web.py, cli/main.py, cli/read.py, and all tests
7
+ require zero edits.
8
+ 2. Implements the remaining 10 handlers: query, context, search, changes, why,
9
+ clusters, flows, affected, context_pack, structure.
10
+
11
+ Split performed in Slice 2, P2 #103. Pure mechanical refactor — byte-identical output.
12
+
13
+ No business logic here. Query logic lives in seam/query/engine.py.
14
+ Impact logic lives in seam/analysis/impact.py.
15
+
16
+ Error conventions (matching mcp-tools.yaml):
17
+ {"error": "INVALID_INPUT", "message": "..."} — blank/whitespace input
18
+ {"error": "INVALID_QUERY", "message": "..."} — bad FTS5 syntax (seam_search only)
19
+ """
20
+
21
+ import logging
22
+ import sqlite3
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ import seam.config as config
27
+ from seam.analysis.affected import AffectedResult
28
+ from seam.analysis.affected import affected as run_affected
29
+ from seam.analysis.changes import (
30
+ DEFAULT_BASE_REF,
31
+ VALID_SCOPES,
32
+ ChangeReport,
33
+ NotAGitRepoError,
34
+ detect_changes,
35
+ )
36
+ from seam.analysis.processes import Flow, build_flow, list_entry_points
37
+ from seam.query import engine
38
+ from seam.query.clusters import cluster_members as query_cluster_members
39
+ from seam.query.clusters import list_clusters as query_list_clusters
40
+ from seam.query.comments import why as comments_why
41
+ from seam.query.pack import ContextPack, NeighborRef
42
+ from seam.query.pack import context_pack as run_context_pack
43
+ from seam.query.structure import StructureResult
44
+ from seam.query.structure import build_structure as run_build_structure
45
+
46
+ # ── Re-exports from sibling modules (facade — public surface unchanged) ───────
47
+ # Everything imported by mcp.py / web.py / main.py / tests keeps working.
48
+ from seam.server.handler_common import ( # noqa: F401 — re-exported as public API
49
+ _HEAVY_FIELDS,
50
+ _IMPACT_DEPTH_DEFAULT,
51
+ _IMPACT_DIRECTION_DEFAULT,
52
+ _QUERY_LIMIT_DEFAULT,
53
+ _QUERY_LIMIT_MAX,
54
+ _QUERY_LIMIT_MIN,
55
+ _SEARCH_LIMIT_DEFAULT,
56
+ _SEARCH_LIMIT_MAX,
57
+ _SEARCH_LIMIT_MIN,
58
+ _TRACE_DEPTH_DEFAULT,
59
+ _TRACE_DEPTH_MAX,
60
+ _TRACE_DEPTH_MIN,
61
+ _TRACE_ENDPOINT_CAND_CAP,
62
+ _apply_verbosity,
63
+ _clamp,
64
+ _invalid_input,
65
+ _invalid_query,
66
+ _maybe_attach_staleness,
67
+ _qualified_trace_candidates,
68
+ _relativize,
69
+ _resolve_uid,
70
+ _serialize_edge_hop,
71
+ _serialize_hop,
72
+ _trace_not_found,
73
+ compute_uid,
74
+ )
75
+ from seam.server.impact_handler import ( # noqa: F401 — re-exported as public API
76
+ _BYTE_CEILING_TRUNCATED_RESERVE,
77
+ _STEER_RESERVE_MARGIN,
78
+ _apply_byte_ceiling,
79
+ _attach_steer,
80
+ _compute_self_context,
81
+ _count_direction_entries,
82
+ _prioritize_tier_entries,
83
+ _serialize_tier_entry,
84
+ _shape_tier_group,
85
+ handle_seam_impact,
86
+ )
87
+ from seam.server.trace_handler import handle_seam_trace # noqa: F401 — re-exported
88
+
89
+ logger = logging.getLogger(__name__)
90
+
91
+ # ── Remaining handlers ────────────────────────────────────────────────────────
92
+
93
+
94
+ def handle_seam_query(
95
+ conn: sqlite3.Connection,
96
+ concept: str,
97
+ root: Path,
98
+ limit: int = _QUERY_LIMIT_DEFAULT,
99
+ *,
100
+ semantic: bool = True,
101
+ ) -> list[dict[str, Any]] | dict[str, Any]:
102
+ """Handler for the seam_query MCP tool.
103
+
104
+ Finds symbols related to a concept using hybrid FTS5 + 1-hop graph expansion.
105
+ Returns a list of QueryResult dicts, or an error dict on bad input.
106
+
107
+ Limit is clamped to [1, 50].
108
+
109
+ semantic=True (default): use hybrid path when available.
110
+ semantic=False: force keyword-only FTS5 (bypasses hybrid without mutating config).
111
+
112
+ NOTE: no `verbose` flag here. seam_query results carry NO Phase 4/5 enrichment
113
+ fields (only symbol/file/line/score/callers_count/callees_count), so lean mode
114
+ would be a no-op — query is enrichment-free, exactly like seam_search, and both
115
+ are deliberately excluded from the verbose contract.
116
+ """
117
+ # Validate: concept must not be empty or whitespace-only
118
+ if not concept or not concept.strip():
119
+ return _invalid_input("concept must not be empty or whitespace-only")
120
+
121
+ # Clamp limit to spec bounds
122
+ safe_limit = _clamp(limit, _QUERY_LIMIT_MIN, _QUERY_LIMIT_MAX)
123
+
124
+ # Mirror seam_search: a malformed FTS5 concept maps to INVALID_QUERY rather
125
+ # than silently returning [] (which an agent would read as "no such code").
126
+ try:
127
+ results = engine.query(conn, concept.strip(), safe_limit, semantic=semantic)
128
+ except sqlite3.OperationalError as exc:
129
+ return _invalid_query(f"FTS5 query syntax error: {exc}")
130
+
131
+ # Relativize file paths so consumers get portable paths.
132
+ # uid is computed from the ABSOLUTE path (r["file"]) BEFORE relativizing, so it
133
+ # round-trips through _resolve_uid (P6c). It lets a follow-up context/impact/trace
134
+ # call pin this exact symbol by handle — no homonym re-disambiguation round-trip.
135
+ return [
136
+ {
137
+ "symbol": r["symbol"],
138
+ "uid": compute_uid(r["file"], r["line"]),
139
+ "file": _relativize(r["file"], root),
140
+ "line": r["line"],
141
+ "score": r["score"],
142
+ "callers_count": r["callers_count"],
143
+ "callees_count": r["callees_count"],
144
+ }
145
+ for r in results
146
+ ]
147
+
148
+
149
+ def handle_seam_context(
150
+ conn: sqlite3.Connection,
151
+ symbol: str,
152
+ root: Path,
153
+ verbose: bool = True,
154
+ *,
155
+ uid: str | None = None,
156
+ ) -> dict[str, Any] | None:
157
+ """Handler for the seam_context MCP tool.
158
+
159
+ Returns a ContextResult dict for a known symbol, None for unknown symbols,
160
+ or an error dict on blank input.
161
+
162
+ uid (P6c): an optional stable handle (from a search/query result) that pins the
163
+ EXACT (file, line) symbol — an alternative to `symbol` that bypasses homonym
164
+ ambiguity and saves the disambiguation round-trip. When provided, `symbol` is
165
+ ignored. An unknown uid returns None (same not-found contract as an unknown name).
166
+
167
+ verbose=True (default): output byte-identical to pre-Phase-8.
168
+ verbose=False: decorators, is_exported, visibility, qualified_name are omitted.
169
+ signature and all core fields are kept.
170
+ """
171
+ if uid is not None:
172
+ # UID path: resolve the handle to the exact declaring (file, line) and build
173
+ # context for THAT row — not the first homonym by name.
174
+ resolved = _resolve_uid(conn, uid)
175
+ if resolved is None:
176
+ return None
177
+ _name, abs_file, line = resolved
178
+ result = engine.context_at(conn, abs_file, line)
179
+ else:
180
+ # Validate: symbol must not be empty or whitespace-only
181
+ if not symbol or not symbol.strip():
182
+ return _invalid_input("symbol must not be empty or whitespace-only")
183
+ result = engine.context(conn, symbol.strip())
184
+
185
+ if result is None:
186
+ return None
187
+
188
+ # Build the full record first, then apply verbosity stripping at the edge.
189
+ # WHY build-then-strip: the record is always fully built in verbose mode (backward
190
+ # compat); in lean mode _apply_verbosity removes only the 6 heavy keys.
191
+ record = {
192
+ "symbol": result["symbol"],
193
+ "file": _relativize(result["file"], root),
194
+ "line": result["line"],
195
+ "end_line": result["end_line"],
196
+ "kind": result["kind"],
197
+ "docstring": result["docstring"],
198
+ "callers": result["callers"],
199
+ "callees": result["callees"],
200
+ "ambiguous": result["ambiguous"], # Phase 1: True when name collision detected
201
+ "cluster_id": result["cluster_id"], # Phase 2: None when not clustered
202
+ "cluster_label": result["cluster_label"], # Phase 2: None when not clustered
203
+ "cluster_peers": result["cluster_peers"], # Phase 2: [] when not clustered / solo
204
+ # Phase 4: node enrichment fields (null when pre-v5 or extraction not available)
205
+ "signature": result["signature"],
206
+ "decorators": result["decorators"],
207
+ "is_exported": result["is_exported"],
208
+ "visibility": result["visibility"],
209
+ "qualified_name": result["qualified_name"],
210
+ # A3: field-access split — always [] for non-field/non-class seeds.
211
+ "field_readers": result["field_readers"],
212
+ "field_writers": result["field_writers"],
213
+ }
214
+ context_result = _apply_verbosity(record, verbose)
215
+ # P2: attach staleness banner LAST — purely additive, byte-identical when fresh.
216
+ return _maybe_attach_staleness(context_result, conn, root)
217
+
218
+
219
+ def handle_seam_search(
220
+ conn: sqlite3.Connection,
221
+ text: str,
222
+ root: Path,
223
+ limit: int = _SEARCH_LIMIT_DEFAULT,
224
+ *,
225
+ semantic: bool = True,
226
+ ) -> list[dict[str, Any]] | dict[str, Any]:
227
+ """Handler for the seam_search MCP tool.
228
+
229
+ Full-text search across symbol names and docstrings (FTS5 BM25).
230
+ Returns a list of SearchResult dicts, or an error dict on bad input.
231
+
232
+ Limit is clamped to [1, 100].
233
+ Maps sqlite3.OperationalError (FTS5 syntax error) to INVALID_QUERY.
234
+
235
+ semantic=True (default): use hybrid path when available.
236
+ semantic=False: force keyword-only FTS5 (bypasses hybrid without mutating config).
237
+ """
238
+ # Validate: text must not be empty or whitespace-only
239
+ if not text or not text.strip():
240
+ return _invalid_input("text must not be empty or whitespace-only")
241
+
242
+ # Clamp limit to spec bounds
243
+ safe_limit = _clamp(limit, _SEARCH_LIMIT_MIN, _SEARCH_LIMIT_MAX)
244
+
245
+ try:
246
+ results = engine.search(conn, text.strip(), safe_limit, semantic=semantic)
247
+ except sqlite3.OperationalError as exc:
248
+ # FTS5 rejects malformed query syntax with OperationalError
249
+ return _invalid_query(f"FTS5 query syntax error: {exc}")
250
+
251
+ # Relativize file paths. uid is computed from the ABSOLUTE path before
252
+ # relativizing so it resolves back to this exact symbol (P6c).
253
+ return [
254
+ {
255
+ "symbol": r["symbol"],
256
+ "uid": compute_uid(r["file"], r["line"]),
257
+ "file": _relativize(r["file"], root),
258
+ "line": r["line"],
259
+ "snippet": r["snippet"],
260
+ "score": r["score"],
261
+ }
262
+ for r in results
263
+ ]
264
+
265
+
266
+ # Default scope for seam_changes.
267
+ _CHANGES_SCOPE_DEFAULT = "working"
268
+ # Import the canonical default from analysis.changes to keep handler and analysis
269
+ # layer in sync — avoids silent drift when the default changes.
270
+ _CHANGES_BASE_REF_DEFAULT = DEFAULT_BASE_REF
271
+
272
+
273
+ def handle_seam_changes(
274
+ conn: sqlite3.Connection,
275
+ root: Path,
276
+ base_ref: str = _CHANGES_BASE_REF_DEFAULT,
277
+ scope: str = _CHANGES_SCOPE_DEFAULT,
278
+ ) -> dict[str, Any]:
279
+ """Handler for the seam_changes MCP tool.
280
+
281
+ Diffs the working tree / staged set / branch against a git ref, maps each
282
+ changed line range back to the symbols it touched, runs those through impact
283
+ analysis, and returns an overall risk level plus the affected symbols.
284
+
285
+ Args:
286
+ conn: Open SQLite connection (read-only).
287
+ root: Project root for path relativization AND the git repo root.
288
+ base_ref: Git ref for scope="branch" comparisons (e.g. "main").
289
+ scope: One of "working", "staged", "branch". Default: "working".
290
+
291
+ Returns:
292
+ A JSON-able dict with the ChangeReport fields, paths relativized to root.
293
+ On bad input: {"error": "INVALID_INPUT", "message": "..."}
294
+ On non-git dir: {"error": "NOT_A_GIT_REPO", "message": "..."}
295
+
296
+ Error shapes:
297
+ INVALID_INPUT — scope is not one of working/staged/branch.
298
+ NOT_A_GIT_REPO — root is not a git repository or git is unavailable.
299
+ """
300
+ # Validate scope.
301
+ if scope not in VALID_SCOPES:
302
+ return _invalid_input(f"scope must be one of {sorted(VALID_SCOPES)}; got {scope!r}")
303
+
304
+ # Validate base_ref is not blank (only used for branch scope, but validate always).
305
+ if not base_ref or not base_ref.strip():
306
+ return _invalid_input("base_ref must not be empty or whitespace-only")
307
+
308
+ try:
309
+ report: ChangeReport = detect_changes(
310
+ conn,
311
+ base_ref=base_ref.strip(),
312
+ scope=scope,
313
+ repo_root=root,
314
+ )
315
+ except NotAGitRepoError as exc:
316
+ logger.warning("seam_changes: not a git repo: %s", exc)
317
+ return {"error": "NOT_A_GIT_REPO", "message": str(exc)}
318
+
319
+ # Relativize all file paths in the report to root.
320
+ def _rel(p: str | None) -> str | None:
321
+ if p is None:
322
+ return None
323
+ return _relativize(p, root)
324
+
325
+ changed_symbols_out = [
326
+ {
327
+ "name": s["name"],
328
+ "file": _rel(s["file"]),
329
+ "kind": s["kind"],
330
+ "start_line": s["start_line"],
331
+ "end_line": s["end_line"],
332
+ "changed_lines": s["changed_lines"],
333
+ }
334
+ for s in report["changed_symbols"]
335
+ ]
336
+
337
+ affected_out = [
338
+ {
339
+ "name": a["name"],
340
+ "file": _rel(a["file"]),
341
+ "tier": a["tier"],
342
+ "confidence": a["confidence"],
343
+ "distance": a["distance"],
344
+ }
345
+ for a in report["affected"]
346
+ ]
347
+
348
+ changes_result: dict[str, Any] = {
349
+ "changed_symbols": changed_symbols_out,
350
+ "new_files": [_rel(f) for f in report["new_files"]],
351
+ "affected": affected_out,
352
+ "risk_level": report["risk_level"],
353
+ "ambiguous_warning": report["ambiguous_warning"],
354
+ "scope": report["scope"],
355
+ "base_ref": report["base_ref"],
356
+ # partial=True when changed symbols exceeded the cap (see ChangeReport docstring).
357
+ "partial": report["partial"],
358
+ }
359
+ # P2: attach staleness banner LAST — purely additive; risk_level etc. unchanged.
360
+ return _maybe_attach_staleness(changes_result, conn, root)
361
+
362
+
363
+ def handle_seam_why(
364
+ conn: sqlite3.Connection,
365
+ root: Path,
366
+ file: str | None = None,
367
+ line: int | None = None,
368
+ symbol: str | None = None,
369
+ ) -> list[dict[str, Any]] | dict[str, Any]:
370
+ """Handler for the seam_why MCP tool.
371
+
372
+ Returns semantic comments (WHY/HACK/NOTE/TODO/FIXME) near a file location
373
+ or a symbol. At least one of file or symbol is required.
374
+
375
+ Args:
376
+ conn: Open SQLite connection.
377
+ root: Project root — used to resolve relative file paths to absolute,
378
+ and to relativize output file paths.
379
+ file: File path (relative to root or absolute). When provided, the
380
+ handler resolves it against root before passing to why().
381
+ line: Line number (1-based). Only meaningful with file.
382
+ symbol: Symbol name to look up.
383
+
384
+ Returns:
385
+ List of comment dicts (file relativized to root) — empty list is valid.
386
+ Error dict {"error": "INVALID_INPUT", ...} when neither file nor symbol given.
387
+ """
388
+ # Validate: at least one of file/symbol is required
389
+ if file is None and symbol is None:
390
+ return _invalid_input("at least one of 'file' or 'symbol' is required")
391
+
392
+ # Resolve file path against root so why() gets an absolute path matching
393
+ # the DB's stored absolute paths (indexed at absolute-path time).
394
+ abs_file: str | None = None
395
+ if file is not None:
396
+ abs_file = str((root / file).resolve()) if not Path(file).is_absolute() else file
397
+
398
+ hits = comments_why(conn, file=abs_file, line=line, symbol=symbol)
399
+
400
+ # Relativize file paths for MCP consumers (consistent with other tools)
401
+ return [
402
+ {
403
+ "file": _relativize(hit["file"], root),
404
+ "line": hit["line"],
405
+ "marker": hit["marker"],
406
+ "text": hit["text"],
407
+ }
408
+ for hit in hits
409
+ ]
410
+
411
+
412
+ def handle_seam_clusters(
413
+ conn: sqlite3.Connection,
414
+ root: Path,
415
+ cluster_id: int | None = None,
416
+ ) -> list[dict[str, Any]]:
417
+ """Handler for the seam_clusters MCP tool.
418
+
419
+ With no cluster_id: returns [{id, label, size}] for all clusters.
420
+ With a cluster_id: returns [{name, file, line, kind}] for that cluster's members.
421
+ File paths in member rows are relativized to root.
422
+
423
+ Args:
424
+ conn: Open SQLite connection.
425
+ root: Project root for path relativization.
426
+ cluster_id: Optional. When provided, returns member symbols of that cluster.
427
+
428
+ Returns:
429
+ List of cluster summary dicts (no id) or member dicts (with relativized file).
430
+ Empty list when no clusters exist or the cluster ID is unknown.
431
+ """
432
+ if cluster_id is None:
433
+ # List all clusters — no file paths to relativize
434
+ clusters = query_list_clusters(conn)
435
+ return [{"id": c["id"], "label": c["label"], "size": c["size"]} for c in clusters]
436
+
437
+ # List members of a specific cluster — relativize file paths
438
+ members = query_cluster_members(conn, cluster_id)
439
+ return [
440
+ {
441
+ "name": m["name"],
442
+ "file": _relativize(m["file"], root),
443
+ "line": m["line"],
444
+ "kind": m["kind"],
445
+ }
446
+ for m in members
447
+ ]
448
+
449
+
450
+ def handle_seam_flows(
451
+ conn: sqlite3.Connection,
452
+ root: Path,
453
+ entry: str | None = None,
454
+ ) -> Flow | dict[str, Any] | None:
455
+ """Handler for the seam_flows MCP tool — execution-flow discovery.
456
+
457
+ With no entry: returns {"entry_points": [{name, kind, file, reach}]} — the
458
+ program's top execution starting points (call-graph roots ranked by how much
459
+ they reach downstream: CLI commands, web routes, MCP handlers, main, …).
460
+
461
+ With an entry: returns that entry point's Flow tree (forward call-chain
462
+ expansion, depth/breadth-capped), or None when the name is unknown — the MCP
463
+ boundary normalizes None to {"found": false}.
464
+
465
+ File paths are relativized to root. Confidence on each step uses the fast
466
+ name-count resolver (a flow is an overview; use seam_impact/seam_trace for
467
+ import-promoted confidence).
468
+
469
+ Args:
470
+ conn: Open SQLite connection (read-only).
471
+ root: Project root for path relativization.
472
+ entry: Optional entry-point symbol name. None → list mode.
473
+ """
474
+ if entry is None:
475
+ return {"entry_points": list_entry_points(conn, repo_root=root)}
476
+ entry = entry.strip()
477
+ if not entry:
478
+ return _invalid_input("entry must not be empty or whitespace-only")
479
+ return build_flow(conn, entry, repo_root=root)
480
+
481
+
482
+ def handle_seam_affected(
483
+ conn: sqlite3.Connection,
484
+ changed_files: list[str],
485
+ root: Path,
486
+ depth: int = config.SEAM_AFFECTED_DEPTH,
487
+ ) -> dict[str, Any]:
488
+ """Handler for the seam_affected MCP tool.
489
+
490
+ Given a list of changed file paths, finds all test files that depend on
491
+ symbols defined in those files (via upstream impact traversal).
492
+
493
+ Args:
494
+ conn: Open SQLite connection (read-only).
495
+ changed_files: List of file paths (absolute or relative to root).
496
+ Must not be empty.
497
+ root: Project root for path relativization and relative-path resolution.
498
+ depth: Max traversal depth for upstream impact. Default from config.
499
+
500
+ Returns:
501
+ A dict with keys:
502
+ changed_files — relativized paths of input files
503
+ affected_tests — relativized paths of affected test files (sorted)
504
+ total_dependents_traversed — count of all dependent entries traversed
505
+ Or an error dict on invalid input:
506
+ {"error": "INVALID_INPUT", "message": "..."}
507
+ """
508
+ # Validate: empty input is not useful and likely an agent mistake.
509
+ if not changed_files:
510
+ return _invalid_input("changed_files must not be empty")
511
+
512
+ # Clamp: reject oversized file lists (SEAM_MAX_AFFECTED_FILES cap).
513
+ # An agent accidentally passing the entire repo diff should get a clear error,
514
+ # not a silent O(n * symbols) traversal. Mirrors the _clamp discipline of other handlers.
515
+ max_files = config.SEAM_MAX_AFFECTED_FILES
516
+ if len(changed_files) > max_files:
517
+ return _invalid_input(
518
+ f"changed_files length {len(changed_files)} exceeds maximum {max_files}; "
519
+ "split the file list into smaller batches"
520
+ )
521
+
522
+ # Run the core affected-tests algorithm.
523
+ result: AffectedResult = run_affected(
524
+ conn,
525
+ changed_files,
526
+ depth=depth,
527
+ repo_root=root,
528
+ )
529
+
530
+ # Relativize all file paths so the MCP consumer gets portable paths.
531
+ # The analysis layer returns absolute paths (DB storage contract);
532
+ # the handler contract (like all other handlers) is to relativize before returning.
533
+ affected_result: dict[str, Any] = {
534
+ "changed_files": [_relativize(p, root) for p in result["changed_files"]],
535
+ "affected_tests": [_relativize(p, root) for p in result["affected_tests"]],
536
+ "total_dependents_traversed": result["total_dependents_traversed"],
537
+ # partial=True when a file exceeded SEAM_MAX_AFFECTED_SYMBOLS; result may be incomplete.
538
+ "partial": result["partial"],
539
+ }
540
+ # P2: attach staleness banner LAST — purely additive; risk verdicts unchanged.
541
+ return _maybe_attach_staleness(affected_result, conn, root)
542
+
543
+
544
+ def handle_seam_context_pack(
545
+ conn: sqlite3.Connection,
546
+ symbol: str,
547
+ root: Path,
548
+ verbose: bool = True,
549
+ ) -> dict[str, Any] | None:
550
+ """Handler for the seam_context_pack MCP tool.
551
+
552
+ Returns a fully-enriched context bundle for a symbol, or None for unknown
553
+ symbols, or an error dict on blank input.
554
+
555
+ The bundle contains:
556
+ target — full 360-degree ContextResult (file paths relativized)
557
+ callers — enriched 1-hop callers (NeighborRef, capped, paths relativized)
558
+ callees — enriched 1-hop callees (NeighborRef, capped, paths relativized)
559
+ why — WHY/HACK/NOTE/TODO/FIXME comments (capped)
560
+ cluster_peers — functional-area peers from target
561
+ truncated — {callers, callees, comments} counts of dropped entries
562
+
563
+ Mirrors handle_seam_context's contract:
564
+ - blank/whitespace → INVALID_INPUT error dict
565
+ - unknown symbol → None
566
+ - found symbol → serialized ContextPack with paths relativized
567
+
568
+ verbose=True (default): output byte-identical to pre-Phase-8.
569
+ verbose=False: heavy fields stripped from target and each neighbor.
570
+ """
571
+ # Validate: symbol must not be empty or whitespace-only
572
+ if not symbol or not symbol.strip():
573
+ return _invalid_input("symbol must not be empty or whitespace-only")
574
+
575
+ pack: ContextPack | None = run_context_pack(
576
+ conn,
577
+ symbol.strip(),
578
+ )
579
+
580
+ if pack is None:
581
+ return None
582
+
583
+ # Relativize file path in target (mirrors handle_seam_context).
584
+ # Apply _apply_verbosity so lean mode strips heavy fields from the target record.
585
+ target = pack["target"]
586
+ serialized_target = _apply_verbosity(
587
+ {
588
+ "symbol": target["symbol"],
589
+ "file": _relativize(target["file"], root),
590
+ "line": target["line"],
591
+ "end_line": target["end_line"],
592
+ "kind": target["kind"],
593
+ "docstring": target["docstring"],
594
+ "callers": target["callers"],
595
+ "callees": target["callees"],
596
+ "ambiguous": target["ambiguous"],
597
+ "cluster_id": target["cluster_id"],
598
+ "cluster_label": target["cluster_label"],
599
+ "cluster_peers": target["cluster_peers"],
600
+ "signature": target["signature"],
601
+ "decorators": target["decorators"],
602
+ "is_exported": target["is_exported"],
603
+ "visibility": target["visibility"],
604
+ "qualified_name": target["qualified_name"],
605
+ },
606
+ verbose,
607
+ )
608
+
609
+ # Relativize file paths in enriched neighbors.
610
+ # WHY direct key access (not .get()): NeighborRef is a TypedDict with all
611
+ # required keys — using .get() would silently return None for a renamed field
612
+ # instead of raising a KeyError that makes the bug visible.
613
+ # Apply _apply_verbosity so lean mode strips heavy fields from each neighbor.
614
+ def _serialize_neighbor(nb: NeighborRef) -> dict[str, Any]:
615
+ return _apply_verbosity(
616
+ {
617
+ "name": nb["name"],
618
+ "file": _relativize(nb["file"], root),
619
+ "line": nb["line"],
620
+ "kind": nb["kind"],
621
+ "signature": nb["signature"],
622
+ "decorators": nb["decorators"],
623
+ "is_exported": nb["is_exported"],
624
+ "visibility": nb["visibility"],
625
+ "qualified_name": nb["qualified_name"],
626
+ },
627
+ verbose,
628
+ )
629
+
630
+ return {
631
+ "target": serialized_target,
632
+ "callers": [_serialize_neighbor(nb) for nb in pack["callers"]],
633
+ "callees": [_serialize_neighbor(nb) for nb in pack["callees"]],
634
+ "why": [
635
+ {
636
+ "file": _relativize(hit["file"], root),
637
+ "line": hit["line"],
638
+ "marker": hit["marker"],
639
+ "text": hit["text"],
640
+ }
641
+ for hit in pack["why"]
642
+ ],
643
+ "cluster_peers": pack["cluster_peers"],
644
+ "truncated": pack["truncated"],
645
+ }
646
+
647
+
648
+ def handle_seam_structure(
649
+ conn: sqlite3.Connection,
650
+ root: Path,
651
+ *,
652
+ path: Path | None = None,
653
+ max_depth: int | None = None,
654
+ max_nodes: int | None = None,
655
+ include_functions: bool = False,
656
+ ) -> StructureResult:
657
+ """Handler for the seam_structure MCP tool — whole-repo structure tree.
658
+
659
+ Returns a directory -> file -> container/function tree built from the index.
660
+ Container nodes (class/interface/type) aggregate method/member rows into a
661
+ `members` count rather than emitting separate child nodes. Top-level functions
662
+ appear as 'function' children of their file node.
663
+
664
+ File paths in the tree are relativized to `root` (no absolute paths leak).
665
+ Container nodes carry path=None (they are logical, not file-backed).
666
+
667
+ Slice 3 params:
668
+ path: When set, scopes the tree to this subdirectory.
669
+ max_depth: Maximum nesting depth. None uses the config default.
670
+ max_nodes: Maximum total non-root nodes. None uses the config default.
671
+
672
+ This is a pure read; never raises — degrades to an empty safe tree on any error.
673
+
674
+ Args:
675
+ conn: Open SQLite connection to the Seam index (read-only).
676
+ root: Project root Path — used to relativize file paths.
677
+ path: Optional scope path. Absolute paths are honoured as-is; a relative
678
+ path is resolved against `root` (NOT cwd) by build_structure.
679
+ max_depth: Optional depth cap override.
680
+ max_nodes: Optional node-count cap override.
681
+
682
+ Returns:
683
+ StructureResult dict with keys:
684
+ tree: Root 'dir' StructureNode representing `root` (or scoped path).
685
+ truncated: Count of omitted nodes (0 when nothing was trimmed).
686
+ """
687
+ # Pass the scope path through unresolved: build_structure resolves a RELATIVE
688
+ # path against `root` (not cwd), so MCP callers and the CLI get root-relative
689
+ # scoping regardless of the server/process working directory.
690
+ return run_build_structure(
691
+ conn,
692
+ root,
693
+ path=path,
694
+ max_depth=max_depth,
695
+ max_nodes=max_nodes,
696
+ include_functions=include_functions,
697
+ )