seam-code 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- seam/__init__.py +3 -0
- seam/_data/schema.sql +225 -0
- seam/_web/assets/index-BL_tqprR.js +216 -0
- seam/_web/assets/index-GTKUhVyD.css +1 -0
- seam/_web/index.html +13 -0
- seam/analysis/__init__.py +14 -0
- seam/analysis/affected.py +254 -0
- seam/analysis/builtins.py +966 -0
- seam/analysis/byte_budget.py +217 -0
- seam/analysis/changes.py +709 -0
- seam/analysis/cluster_naming.py +260 -0
- seam/analysis/clustering.py +216 -0
- seam/analysis/confidence.py +699 -0
- seam/analysis/embeddings.py +195 -0
- seam/analysis/flows.py +708 -0
- seam/analysis/impact.py +444 -0
- seam/analysis/imports.py +994 -0
- seam/analysis/imports_ext.py +780 -0
- seam/analysis/imports_resolve.py +176 -0
- seam/analysis/processes.py +453 -0
- seam/analysis/relevance.py +155 -0
- seam/analysis/rwr.py +129 -0
- seam/analysis/staleness.py +328 -0
- seam/analysis/steer.py +282 -0
- seam/analysis/synthesis.py +253 -0
- seam/analysis/synthesis_channels.py +433 -0
- seam/analysis/testpaths.py +103 -0
- seam/analysis/traversal.py +470 -0
- seam/cli/__init__.py +0 -0
- seam/cli/install.py +232 -0
- seam/cli/main.py +2602 -0
- seam/cli/output.py +137 -0
- seam/cli/read.py +244 -0
- seam/cli/serve.py +145 -0
- seam/config.py +551 -0
- seam/indexer/__init__.py +0 -0
- seam/indexer/cluster_index.py +425 -0
- seam/indexer/db.py +496 -0
- seam/indexer/embedding_index.py +183 -0
- seam/indexer/field_access.py +536 -0
- seam/indexer/field_access_c_cpp.py +643 -0
- seam/indexer/field_access_ext.py +708 -0
- seam/indexer/field_access_ext2.py +408 -0
- seam/indexer/field_access_go_rust.py +737 -0
- seam/indexer/field_access_php_swift.py +888 -0
- seam/indexer/field_access_ts.py +626 -0
- seam/indexer/graph.py +321 -0
- seam/indexer/graph_c.py +562 -0
- seam/indexer/graph_c_cpp.py +39 -0
- seam/indexer/graph_common.py +644 -0
- seam/indexer/graph_cpp.py +615 -0
- seam/indexer/graph_csharp.py +651 -0
- seam/indexer/graph_go.py +723 -0
- seam/indexer/graph_go_rust.py +39 -0
- seam/indexer/graph_java.py +689 -0
- seam/indexer/graph_java_csharp.py +38 -0
- seam/indexer/graph_php.py +914 -0
- seam/indexer/graph_python.py +628 -0
- seam/indexer/graph_ruby.py +748 -0
- seam/indexer/graph_rust.py +653 -0
- seam/indexer/graph_scope_infer.py +902 -0
- seam/indexer/graph_scope_infer_ext.py +723 -0
- seam/indexer/graph_scope_infer_ext2.py +992 -0
- seam/indexer/graph_swift.py +1014 -0
- seam/indexer/graph_swift_infer.py +515 -0
- seam/indexer/graph_typescript.py +663 -0
- seam/indexer/migrations.py +816 -0
- seam/indexer/parser.py +204 -0
- seam/indexer/pipeline.py +197 -0
- seam/indexer/signatures.py +634 -0
- seam/indexer/signatures_ext.py +780 -0
- seam/indexer/sync.py +287 -0
- seam/indexer/synthesis_index.py +291 -0
- seam/indexer/tokenize.py +79 -0
- seam/installer/__init__.py +67 -0
- seam/installer/claude.py +97 -0
- seam/installer/codex.py +94 -0
- seam/installer/core.py +127 -0
- seam/installer/cursor.py +61 -0
- seam/installer/guide.py +110 -0
- seam/installer/jsonfile.py +85 -0
- seam/installer/markdownfile.py +146 -0
- seam/installer/tomlfile.py +72 -0
- seam/query/__init__.py +0 -0
- seam/query/clusters.py +206 -0
- seam/query/comments.py +217 -0
- seam/query/context.py +293 -0
- seam/query/engine.py +940 -0
- seam/query/fts.py +328 -0
- seam/query/names.py +470 -0
- seam/query/pack.py +433 -0
- seam/query/semantic.py +339 -0
- seam/query/structure.py +727 -0
- seam/server/__init__.py +0 -0
- seam/server/graph_api.py +437 -0
- seam/server/handler_common.py +323 -0
- seam/server/impact_handler.py +615 -0
- seam/server/mcp.py +556 -0
- seam/server/tools.py +697 -0
- seam/server/trace_handler.py +184 -0
- seam/server/web.py +922 -0
- seam/watcher/__init__.py +0 -0
- seam/watcher/__main__.py +56 -0
- seam/watcher/daemon.py +237 -0
- seam_code-0.3.0.dist-info/METADATA +318 -0
- seam_code-0.3.0.dist-info/RECORD +109 -0
- seam_code-0.3.0.dist-info/WHEEL +4 -0
- seam_code-0.3.0.dist-info/entry_points.txt +2 -0
- seam_code-0.3.0.dist-info/licenses/LICENSE +21 -0
seam/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
|
+
)
|