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/cli/main.py
ADDED
|
@@ -0,0 +1,2602 @@
|
|
|
1
|
+
"""Seam CLI entry point.
|
|
2
|
+
|
|
3
|
+
Commands (Phase 0)
|
|
4
|
+
------------------
|
|
5
|
+
init — Walk the project and index all symbols + edges into .seam/seam.db.
|
|
6
|
+
status — Show index stats (file/symbol/edge counts, freshness, watcher PID).
|
|
7
|
+
start — Start the MCP server (stdio foreground) + file watcher (background).
|
|
8
|
+
|
|
9
|
+
Commands (Phase 1 — code reasoning)
|
|
10
|
+
-------------------------------------
|
|
11
|
+
impact — Blast-radius analysis: what breaks if a symbol changes? Grouped by
|
|
12
|
+
risk tier (WILL_BREAK / LIKELY_AFFECTED / MAY_NEED_TESTING).
|
|
13
|
+
trace — Shortest call/dependency path from one symbol to another, with per-hop
|
|
14
|
+
confidence (EXTRACTED | INFERRED | AMBIGUOUS).
|
|
15
|
+
changes — Pre-commit risk check: map git diff to changed symbols, run impact
|
|
16
|
+
analysis, report an overall risk level (low/medium/high/critical).
|
|
17
|
+
|
|
18
|
+
Commands (Phase 1b — semantic comment nodes)
|
|
19
|
+
---------------------------------------------
|
|
20
|
+
why — Show WHY/HACK/NOTE/TODO/FIXME comments near a file location or symbol.
|
|
21
|
+
|
|
22
|
+
Commands (Phase 2 — graph clustering)
|
|
23
|
+
---------------------------------------
|
|
24
|
+
clusters — List all clusters (or members of one cluster with --id N).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import signal
|
|
30
|
+
import sqlite3
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
import time
|
|
34
|
+
from collections.abc import Mapping
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
import typer
|
|
39
|
+
from rich.console import Console
|
|
40
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
41
|
+
from rich.table import Table
|
|
42
|
+
|
|
43
|
+
import seam.config as config
|
|
44
|
+
from seam.analysis.changes import (
|
|
45
|
+
DEFAULT_BASE_REF,
|
|
46
|
+
VALID_SCOPES,
|
|
47
|
+
AffectedSymbol,
|
|
48
|
+
NotAGitRepoError,
|
|
49
|
+
detect_changes,
|
|
50
|
+
)
|
|
51
|
+
from seam.analysis.flows import callees as flows_callees
|
|
52
|
+
from seam.analysis.flows import callers as flows_callers
|
|
53
|
+
from seam.analysis.flows import trace as flows_trace
|
|
54
|
+
from seam.analysis.impact import (
|
|
55
|
+
TIER_LIKELY_AFFECTED,
|
|
56
|
+
TIER_MAY_NEED_TESTING,
|
|
57
|
+
TIER_WILL_BREAK,
|
|
58
|
+
)
|
|
59
|
+
from seam.analysis.staleness import _watcher_is_alive, check_staleness
|
|
60
|
+
from seam.cli.install import install_command, uninstall_command
|
|
61
|
+
from seam.cli.output import check_mutual_exclusion, emit_json, emit_json_error, print_quiet
|
|
62
|
+
from seam.cli.read import context_command, query_command, search_command
|
|
63
|
+
from seam.cli.serve import serve_command
|
|
64
|
+
from seam.indexer.cluster_index import get_llm_naming_summary, index_clusters
|
|
65
|
+
from seam.indexer.db import connect, init_db
|
|
66
|
+
from seam.indexer.embedding_index import index_embeddings
|
|
67
|
+
from seam.indexer.pipeline import index_one_file, walk_project
|
|
68
|
+
from seam.indexer.sync import sync as sync_project
|
|
69
|
+
from seam.indexer.synthesis_index import index_synthesis
|
|
70
|
+
from seam.query.clusters import cluster_members as query_cluster_members
|
|
71
|
+
from seam.query.clusters import list_clusters as query_list_clusters
|
|
72
|
+
from seam.query.comments import why as comments_why
|
|
73
|
+
|
|
74
|
+
# NOTE: seam.server.mcp (and the `mcp` package it needs) is imported LAZILY inside the
|
|
75
|
+
# `start` command — see _load_create_server(). This keeps the entire CLI usable with the
|
|
76
|
+
# `mcp` extra UNINSTALLED (pure-CLI install): only `seam start` requires it.
|
|
77
|
+
from seam.query.structure import StructureNode
|
|
78
|
+
from seam.server.tools import (
|
|
79
|
+
handle_seam_affected,
|
|
80
|
+
handle_seam_changes,
|
|
81
|
+
handle_seam_clusters,
|
|
82
|
+
handle_seam_context_pack,
|
|
83
|
+
handle_seam_flows,
|
|
84
|
+
handle_seam_impact,
|
|
85
|
+
handle_seam_structure,
|
|
86
|
+
handle_seam_trace,
|
|
87
|
+
handle_seam_why,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
app = typer.Typer(
|
|
91
|
+
name="seam",
|
|
92
|
+
help="Local code intelligence MCP server for AI agents.",
|
|
93
|
+
add_completion=False,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
console = Console()
|
|
97
|
+
|
|
98
|
+
# Register commands defined in sibling modules (kept out of this file, which is large).
|
|
99
|
+
app.command(name="install")(install_command)
|
|
100
|
+
app.command(name="uninstall")(uninstall_command)
|
|
101
|
+
# query/search/context complete the CLI-only surface — they reuse the transport-agnostic
|
|
102
|
+
# handlers and query SQLite directly, so they work with NO MCP server (and no `mcp` dep).
|
|
103
|
+
app.command(name="query")(query_command)
|
|
104
|
+
app.command(name="search")(search_command)
|
|
105
|
+
app.command(name="context")(context_command)
|
|
106
|
+
# serve starts the local Seam Explorer (FastAPI + uvicorn) — requires [web] extra.
|
|
107
|
+
app.command(name="serve")(serve_command)
|
|
108
|
+
|
|
109
|
+
# Top-level keys in a handle_seam_impact response that are NOT direction groups.
|
|
110
|
+
# Every consumer that iterates the impact result to find tier groups (quiet output,
|
|
111
|
+
# the total-entry count, Rich rendering) MUST skip these — otherwise risk_summary /
|
|
112
|
+
# truncated (which are {direction: {tier: int}} dicts) get treated as direction
|
|
113
|
+
# groups and, in the count path, len() is called on an int → TypeError.
|
|
114
|
+
# E4: 'next_actions' added — the steer list is top-level metadata, not a direction group.
|
|
115
|
+
_IMPACT_META_KEYS: frozenset[str] = frozenset(
|
|
116
|
+
{"found", "target", "hidden_tests", "risk_summary", "truncated", "byte_capped", "next_actions"}
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _render_next_actions(result: dict[str, Any]) -> None:
|
|
121
|
+
"""Render the E4 next_actions steer footer (Rich), if the handler produced one.
|
|
122
|
+
|
|
123
|
+
Shared by both the normal impact render and the all-trimmed early-return branch so
|
|
124
|
+
the human CLI surface always shows the same actionable hints (incl. the all-trimmed
|
|
125
|
+
anti-false-safe warning) that JSON/MCP consumers receive. No-op when absent (steer
|
|
126
|
+
off, or nothing was trimmed).
|
|
127
|
+
"""
|
|
128
|
+
next_actions = result.get("next_actions")
|
|
129
|
+
if next_actions and isinstance(next_actions, list):
|
|
130
|
+
console.print("\n[bold cyan]Next actions:[/bold cyan]")
|
|
131
|
+
for hint in next_actions:
|
|
132
|
+
console.print(f" [dim]→[/dim] {hint}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# _watcher_is_alive is defined in seam/analysis/staleness.py and imported above.
|
|
136
|
+
# It was moved there so the MCP handler layer (seam/server/tools.py) can use it
|
|
137
|
+
# for the P2 staleness banner without creating a circular import through seam.cli.
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── Commands ──────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.command()
|
|
144
|
+
def init(
|
|
145
|
+
path: str = typer.Argument(".", help="Project root to index (default: current directory)"),
|
|
146
|
+
db_dir: str = typer.Option(
|
|
147
|
+
"",
|
|
148
|
+
"--db-dir",
|
|
149
|
+
help="Override DB directory (used in tests; default: same as project root)",
|
|
150
|
+
),
|
|
151
|
+
semantic: bool = typer.Option(
|
|
152
|
+
False,
|
|
153
|
+
"--semantic",
|
|
154
|
+
help=(
|
|
155
|
+
"Also embed all symbols with the local fastembed model after indexing. "
|
|
156
|
+
"Requires: pip install 'seam-mcp[semantic]' and SEAM_SEMANTIC=on. "
|
|
157
|
+
"Downloads the model on first run (~67 MB); subsequent runs use the local cache."
|
|
158
|
+
),
|
|
159
|
+
),
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Index the project into .seam/seam.db.
|
|
162
|
+
|
|
163
|
+
Walks the project root, skips dot-dirs and common build/cache dirs,
|
|
164
|
+
selects files by extension (SEAM_LANGUAGE_MAP), skips files > SEAM_MAX_FILE_BYTES,
|
|
165
|
+
and writes all symbols + edges into .seam/seam.db.
|
|
166
|
+
|
|
167
|
+
Use --semantic to also build local embeddings for hybrid (semantic + keyword) search.
|
|
168
|
+
"""
|
|
169
|
+
start_ts = time.monotonic()
|
|
170
|
+
project_root = Path(path).resolve()
|
|
171
|
+
|
|
172
|
+
if not project_root.is_dir():
|
|
173
|
+
console.print(f"[red]Error:[/red] '{project_root}' is not a directory.")
|
|
174
|
+
raise typer.Exit(code=1)
|
|
175
|
+
|
|
176
|
+
# Determine DB root: --db-dir overrides for test isolation
|
|
177
|
+
db_root = Path(db_dir).resolve() if db_dir else project_root
|
|
178
|
+
db_path = config.get_db_path(db_root)
|
|
179
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
|
|
181
|
+
# Keep the index out of git: a self-scoped .gitignore (containing "*") INSIDE
|
|
182
|
+
# .seam/ makes git ignore the whole dir (db/-shm/-wal), so `seam_changes` never
|
|
183
|
+
# reports our own artifacts as changed files — without writing anything OUTSIDE
|
|
184
|
+
# .seam/ (preserves the "Seam touches nothing beyond .seam/" guarantee).
|
|
185
|
+
seam_gitignore = db_path.parent / ".gitignore"
|
|
186
|
+
if not seam_gitignore.exists():
|
|
187
|
+
seam_gitignore.write_text("*\n", encoding="utf-8")
|
|
188
|
+
|
|
189
|
+
# Collect files to index
|
|
190
|
+
files = walk_project(project_root)
|
|
191
|
+
|
|
192
|
+
total_symbols = 0
|
|
193
|
+
total_edges = 0
|
|
194
|
+
indexed_files = 0
|
|
195
|
+
skipped_files = 0
|
|
196
|
+
total_clusters = 0
|
|
197
|
+
total_synthesis: int | None = None # None = not yet run; -1 = failed; >=0 = count
|
|
198
|
+
total_embeddings: int | None = (
|
|
199
|
+
None # None = not requested; 0 = skipped; >0 = count; -1 = failed
|
|
200
|
+
)
|
|
201
|
+
llm_naming_summary: str | None = None
|
|
202
|
+
|
|
203
|
+
with Progress(
|
|
204
|
+
SpinnerColumn(),
|
|
205
|
+
TextColumn("[progress.description]{task.description}"),
|
|
206
|
+
transient=True,
|
|
207
|
+
console=console,
|
|
208
|
+
) as progress:
|
|
209
|
+
task = progress.add_task("Initialising database...", total=None)
|
|
210
|
+
|
|
211
|
+
conn = init_db(db_path)
|
|
212
|
+
try:
|
|
213
|
+
progress.update(task, description=f"Indexing {len(files)} files...")
|
|
214
|
+
for file_path in files:
|
|
215
|
+
progress.update(task, description=f"Indexing {file_path.name}...")
|
|
216
|
+
# None = skipped (unsupported/binary/error); (s, e) = indexed,
|
|
217
|
+
# even if (0, 0) for a valid-but-empty file.
|
|
218
|
+
result = index_one_file(conn, file_path)
|
|
219
|
+
if result is None:
|
|
220
|
+
skipped_files += 1
|
|
221
|
+
continue
|
|
222
|
+
indexed_files += 1
|
|
223
|
+
total_symbols += result[0]
|
|
224
|
+
total_edges += result[1]
|
|
225
|
+
|
|
226
|
+
# Phase 2: Clustering post-pass (whole-graph, runs after all files indexed).
|
|
227
|
+
# WHY: Clustering must see the complete graph (all files), not per-file fragments.
|
|
228
|
+
# This is intentionally AFTER the indexing loop — not inside index_one_file.
|
|
229
|
+
progress.update(task, description="Computing graph clusters...")
|
|
230
|
+
total_clusters = index_clusters(
|
|
231
|
+
conn,
|
|
232
|
+
naming_mode=config.SEAM_CLUSTER_NAMING,
|
|
233
|
+
llm_api_key=config.SEAM_LLM_API_KEY,
|
|
234
|
+
llm_model=config.SEAM_LLM_MODEL,
|
|
235
|
+
min_size=config.SEAM_CLUSTER_MIN_SIZE,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Issue #8: LLM naming summary — read after clustering completes.
|
|
239
|
+
# Only relevant when LLM naming was requested.
|
|
240
|
+
if config.SEAM_CLUSTER_NAMING == "llm" and total_clusters > 0:
|
|
241
|
+
llm_naming_summary = get_llm_naming_summary(conn)
|
|
242
|
+
|
|
243
|
+
# Edge-synthesis post-pass (PRD #83): synthesize dynamic-dispatch edges
|
|
244
|
+
# that a parser cannot see (interface overrides, etc.). Runs after clustering
|
|
245
|
+
# because it reads the already-extracted call graph (not cluster assignments).
|
|
246
|
+
# Returns -1 on failure (never raises), 0 when SEAM_EDGE_SYNTHESIS=off.
|
|
247
|
+
# WHY after clustering: synthesis is whole-graph and needs the complete edge
|
|
248
|
+
# set (including all inheritance edges) — runs last in the post-pass chain.
|
|
249
|
+
progress.update(task, description="Synthesizing dispatch edges...")
|
|
250
|
+
total_synthesis = index_synthesis(
|
|
251
|
+
conn,
|
|
252
|
+
enabled=config.SEAM_EDGE_SYNTHESIS == "on",
|
|
253
|
+
fanout_cap=config.SEAM_SYNTHESIS_FANOUT_CAP,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# --semantic: embed all symbols with the local fastembed model.
|
|
257
|
+
# Returns 0 when fastembed absent (skip cleanly), -1 on error, >=0 on success.
|
|
258
|
+
# WHY after clustering: embeddings are independent of cluster assignments
|
|
259
|
+
# but clustering is the slower of the two post-passes; running embeddings
|
|
260
|
+
# last keeps the flow: index → cluster → embed.
|
|
261
|
+
if semantic:
|
|
262
|
+
progress.update(task, description="Computing symbol embeddings...")
|
|
263
|
+
total_embeddings = index_embeddings(
|
|
264
|
+
conn,
|
|
265
|
+
model=config.SEAM_EMBED_MODEL,
|
|
266
|
+
batch=32,
|
|
267
|
+
)
|
|
268
|
+
finally:
|
|
269
|
+
conn.close()
|
|
270
|
+
|
|
271
|
+
# Issue #7: index_clusters returns -1 on error (not 0) to distinguish failure
|
|
272
|
+
# from "genuinely zero clusters." Display a visible yellow warning in that case.
|
|
273
|
+
clustering_failed = total_clusters < 0
|
|
274
|
+
display_clusters = str(total_clusters) if total_clusters >= 0 else "failed"
|
|
275
|
+
|
|
276
|
+
# Synthesis display: -1 = failed; 0 = off or no edges; >=1 = count of edges synthesized.
|
|
277
|
+
synthesis_failed = total_synthesis is not None and total_synthesis < 0
|
|
278
|
+
if total_synthesis is None or total_synthesis == 0:
|
|
279
|
+
display_synthesis: str | None = None # not shown unless synthesis produced edges or failed
|
|
280
|
+
elif synthesis_failed:
|
|
281
|
+
display_synthesis = "failed"
|
|
282
|
+
else:
|
|
283
|
+
display_synthesis = str(total_synthesis)
|
|
284
|
+
|
|
285
|
+
# Embedding display: None = not requested; 0 = skipped (fastembed absent);
|
|
286
|
+
# -1 = embedding failed; >=1 = count of symbols embedded.
|
|
287
|
+
embedding_failed = total_embeddings is not None and total_embeddings < 0
|
|
288
|
+
if total_embeddings is None:
|
|
289
|
+
display_embeddings = None # not shown in table when --semantic not requested
|
|
290
|
+
elif total_embeddings == 0:
|
|
291
|
+
display_embeddings = "skipped (fastembed not installed)"
|
|
292
|
+
elif embedding_failed:
|
|
293
|
+
display_embeddings = "failed"
|
|
294
|
+
else:
|
|
295
|
+
display_embeddings = f"{total_embeddings} symbols ({config.SEAM_EMBED_MODEL})"
|
|
296
|
+
|
|
297
|
+
elapsed = time.monotonic() - start_ts
|
|
298
|
+
|
|
299
|
+
# Summary table
|
|
300
|
+
table = Table(title="seam init — complete", show_header=False, box=None)
|
|
301
|
+
table.add_column("key", style="bold cyan", width=16)
|
|
302
|
+
table.add_column("value")
|
|
303
|
+
table.add_row("root", str(project_root))
|
|
304
|
+
table.add_row("db", str(db_path))
|
|
305
|
+
table.add_row("files found", str(len(files)))
|
|
306
|
+
table.add_row("files indexed", str(indexed_files))
|
|
307
|
+
table.add_row("files skipped", str(skipped_files))
|
|
308
|
+
table.add_row("symbols", str(total_symbols))
|
|
309
|
+
table.add_row("edges", str(total_edges))
|
|
310
|
+
table.add_row("clusters", display_clusters)
|
|
311
|
+
if display_synthesis is not None:
|
|
312
|
+
table.add_row("synth edges", display_synthesis)
|
|
313
|
+
if display_embeddings is not None:
|
|
314
|
+
table.add_row("embeddings", display_embeddings)
|
|
315
|
+
table.add_row("elapsed", f"{elapsed:.2f}s")
|
|
316
|
+
console.print(table)
|
|
317
|
+
|
|
318
|
+
# Issue #7: Visible yellow warning when clustering failed.
|
|
319
|
+
# Only shown when we indexed symbols — "0 clusters" on an empty repo is fine.
|
|
320
|
+
if clustering_failed and total_symbols > 0:
|
|
321
|
+
console.print(
|
|
322
|
+
"[yellow]clusters: failed[/yellow] "
|
|
323
|
+
"[dim](run with SEAM_LOG_LEVEL=DEBUG to see the error)[/dim]"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Visible yellow warning when synthesis post-pass failed.
|
|
327
|
+
if synthesis_failed and total_symbols > 0:
|
|
328
|
+
console.print(
|
|
329
|
+
"[yellow]synth edges: failed[/yellow] "
|
|
330
|
+
"[dim](run with SEAM_LOG_LEVEL=DEBUG to see the error; "
|
|
331
|
+
"run 'seam init' again to retry)[/dim]"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Visible yellow warning when embedding failed.
|
|
335
|
+
if embedding_failed and total_symbols > 0:
|
|
336
|
+
console.print(
|
|
337
|
+
"[yellow]embeddings: failed[/yellow] "
|
|
338
|
+
"[dim](run with SEAM_LOG_LEVEL=DEBUG to see the error; "
|
|
339
|
+
"run 'seam init --semantic' again to retry)[/dim]"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Actionable install hint when --semantic was requested but fastembed is absent.
|
|
343
|
+
# total_embeddings == 0 AND symbols present AND no failure means fastembed not installed.
|
|
344
|
+
if semantic and total_embeddings == 0 and not embedding_failed and total_symbols > 0:
|
|
345
|
+
console.print(
|
|
346
|
+
"[yellow]embeddings: skipped[/yellow] — fastembed is not installed.\n"
|
|
347
|
+
" Install it with: [bold]pip install 'seam-mcp\\[semantic]'[/bold]"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Issue #8: LLM naming summary line.
|
|
351
|
+
if llm_naming_summary:
|
|
352
|
+
console.print(f"[dim]{llm_naming_summary}[/dim]")
|
|
353
|
+
|
|
354
|
+
if skipped_files:
|
|
355
|
+
console.print(
|
|
356
|
+
f"[dim]{skipped_files} file(s) skipped (binary/oversize/parse error). "
|
|
357
|
+
f"Set SEAM_LOG_LEVEL=DEBUG to see which.[/dim]"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@app.command()
|
|
362
|
+
def status(
|
|
363
|
+
path: str = typer.Argument(
|
|
364
|
+
".", help="Project root whose index to inspect (default: current directory)"
|
|
365
|
+
),
|
|
366
|
+
db_dir: str = typer.Option(
|
|
367
|
+
"",
|
|
368
|
+
"--db-dir",
|
|
369
|
+
help="Override DB directory (default: same as project root)",
|
|
370
|
+
),
|
|
371
|
+
json_: bool = typer.Option(False, "--json", help="Emit structured JSON envelope to stdout."),
|
|
372
|
+
quiet: bool = typer.Option(False, "--quiet", help="Print bare values only (one per line)."),
|
|
373
|
+
) -> None:
|
|
374
|
+
"""Show index statistics and watcher status.
|
|
375
|
+
|
|
376
|
+
Reads the DB at <project>/.seam/seam.db and prints:
|
|
377
|
+
- file / symbol / edge counts
|
|
378
|
+
- last indexed_at timestamp
|
|
379
|
+
- watcher PID (if a live watcher is recorded)
|
|
380
|
+
- freshness: newest DB mtime vs newest on-disk file mtime
|
|
381
|
+
"""
|
|
382
|
+
try:
|
|
383
|
+
check_mutual_exclusion(json_=json_, quiet=quiet)
|
|
384
|
+
except ValueError as exc:
|
|
385
|
+
emit_json_error("INVALID_INPUT", str(exc))
|
|
386
|
+
|
|
387
|
+
# Mirror `init`: DB lives under the project root unless --db-dir overrides.
|
|
388
|
+
project_root = Path(path).resolve()
|
|
389
|
+
db_root = Path(db_dir).resolve() if db_dir else project_root
|
|
390
|
+
db_path = config.get_db_path(db_root)
|
|
391
|
+
|
|
392
|
+
if not db_path.exists():
|
|
393
|
+
if json_:
|
|
394
|
+
emit_json_error(
|
|
395
|
+
"NO_INDEX", "No index found. Run 'seam init' first to create the index."
|
|
396
|
+
)
|
|
397
|
+
console.print(
|
|
398
|
+
"[red]No index found.[/red] Run [bold]seam init[/bold] first to create the index."
|
|
399
|
+
)
|
|
400
|
+
raise typer.Exit(code=1)
|
|
401
|
+
|
|
402
|
+
try:
|
|
403
|
+
conn = connect(db_path)
|
|
404
|
+
except sqlite3.Error as exc:
|
|
405
|
+
if json_:
|
|
406
|
+
emit_json_error("DB_ERROR", f"Failed to open database: {exc}")
|
|
407
|
+
console.print(f"[red]Failed to open database:[/red] {exc}")
|
|
408
|
+
raise typer.Exit(code=1) from exc
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
# Exclude synthetic file rows (path starting ':', e.g. ':synthesis:') from the
|
|
412
|
+
# file count — they are post-pass bookkeeping rows, not real indexed files.
|
|
413
|
+
file_count = conn.execute("SELECT COUNT(*) FROM files WHERE path NOT LIKE ':%'").fetchone()[
|
|
414
|
+
0
|
|
415
|
+
]
|
|
416
|
+
symbol_count = conn.execute("SELECT COUNT(*) FROM symbols").fetchone()[0]
|
|
417
|
+
edge_count = conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
|
|
418
|
+
|
|
419
|
+
# Synthesized-edge count (edge-synthesis post-pass). Guard for pre-v12 indexes
|
|
420
|
+
# that lack the synthesized_by column. Lets an operator see how much of the
|
|
421
|
+
# graph is heuristic (synthesized) vs statically extracted.
|
|
422
|
+
try:
|
|
423
|
+
synth_edge_count = conn.execute(
|
|
424
|
+
"SELECT COUNT(*) FROM edges WHERE synthesized_by IS NOT NULL"
|
|
425
|
+
).fetchone()[0]
|
|
426
|
+
except Exception:
|
|
427
|
+
synth_edge_count = 0
|
|
428
|
+
|
|
429
|
+
# Cluster count (Phase 2). Guard for pre-v4 indexes (no clusters table).
|
|
430
|
+
try:
|
|
431
|
+
cluster_count = conn.execute("SELECT COUNT(*) FROM clusters").fetchone()[0]
|
|
432
|
+
except Exception:
|
|
433
|
+
cluster_count = 0
|
|
434
|
+
|
|
435
|
+
# Embedding stats (Semantic). Guard for pre-v7 indexes (no embeddings table).
|
|
436
|
+
# WHY guard: embeddings table added in v7 migration; older indexes don't have it.
|
|
437
|
+
# Query per-model counts so we can detect when stored model != configured model.
|
|
438
|
+
try:
|
|
439
|
+
embedding_rows = conn.execute(
|
|
440
|
+
"SELECT model, COUNT(*) AS cnt FROM embeddings GROUP BY model"
|
|
441
|
+
).fetchall()
|
|
442
|
+
embedding_model_counts: dict[str, int] = {
|
|
443
|
+
row["model"]: row["cnt"] for row in embedding_rows
|
|
444
|
+
}
|
|
445
|
+
embedding_count = sum(embedding_model_counts.values())
|
|
446
|
+
except Exception:
|
|
447
|
+
embedding_count = 0
|
|
448
|
+
embedding_model_counts = {}
|
|
449
|
+
|
|
450
|
+
# Most recent indexed_at across all files
|
|
451
|
+
last_indexed_row = conn.execute("SELECT MAX(indexed_at) FROM files").fetchone()[0]
|
|
452
|
+
last_indexed_str = (
|
|
453
|
+
time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(last_indexed_row))
|
|
454
|
+
if last_indexed_row is not None
|
|
455
|
+
else "never"
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# Freshness: delegate to seam/analysis/staleness.py — single source of truth.
|
|
459
|
+
# WHY: the MCP handler layer uses the same module; having two divergent heuristics
|
|
460
|
+
# would cause `seam status` and `seam_impact` index_status to disagree.
|
|
461
|
+
# respect_knob=False: the CLI freshness field is INDEPENDENT of SEAM_STALENESS_CHECK
|
|
462
|
+
# (which gates only the MCP banner). `seam status` must keep reporting freshness even
|
|
463
|
+
# when the banner is disabled — otherwise the knob would silently kill a pre-existing
|
|
464
|
+
# CLI feature and make `seam status --quiet` always report "fresh".
|
|
465
|
+
pid_file_inner = db_path.parent / "watcher.pid"
|
|
466
|
+
watcher_alive_inner = _watcher_is_alive(pid_file_inner) is not None
|
|
467
|
+
staleness_verdict = check_staleness(
|
|
468
|
+
conn, root=project_root, watcher_alive=watcher_alive_inner, respect_knob=False
|
|
469
|
+
)
|
|
470
|
+
freshness = "stale" if staleness_verdict["stale"] else "fresh"
|
|
471
|
+
|
|
472
|
+
finally:
|
|
473
|
+
conn.close()
|
|
474
|
+
|
|
475
|
+
# Watcher PID — only report it if the process is actually alive.
|
|
476
|
+
pid_file = db_path.parent / "watcher.pid"
|
|
477
|
+
alive_pid = _watcher_is_alive(pid_file)
|
|
478
|
+
watcher_status = f"PID {alive_pid}" if alive_pid is not None else "not running"
|
|
479
|
+
|
|
480
|
+
# ── JSON mode — build stats dict inline (no handler exists for status) ────
|
|
481
|
+
if json_:
|
|
482
|
+
# Detect model mismatch: configured model has zero stored rows but another does.
|
|
483
|
+
configured_count = embedding_model_counts.get(config.SEAM_EMBED_MODEL, 0)
|
|
484
|
+
model_mismatch = embedding_count > 0 and configured_count == 0
|
|
485
|
+
stats = {
|
|
486
|
+
"files": file_count,
|
|
487
|
+
"symbols": symbol_count,
|
|
488
|
+
"edges": edge_count,
|
|
489
|
+
# Synthesized (heuristic, INFERRED) edges added by the edge-synthesis
|
|
490
|
+
# post-pass; subset of "edges". 0 when synthesis is off or found nothing.
|
|
491
|
+
"synth_edges": synth_edge_count,
|
|
492
|
+
"clusters": cluster_count,
|
|
493
|
+
"last_indexed": last_indexed_str,
|
|
494
|
+
"watcher": watcher_status,
|
|
495
|
+
"freshness": freshness,
|
|
496
|
+
# Semantic search fields (always present — 0 when not yet populated)
|
|
497
|
+
"embedding_count": embedding_count,
|
|
498
|
+
"embed_model": config.SEAM_EMBED_MODEL,
|
|
499
|
+
# Per-model breakdown: {model_name: count}; empty dict when no embeddings.
|
|
500
|
+
"embedding_models": embedding_model_counts,
|
|
501
|
+
# True when stored model != configured model (embeddings stale/wrong model).
|
|
502
|
+
"embedding_model_mismatch": model_mismatch,
|
|
503
|
+
}
|
|
504
|
+
emit_json(stats)
|
|
505
|
+
return
|
|
506
|
+
|
|
507
|
+
# ── Quiet mode — print freshness (the single load-bearing field for CI gating)
|
|
508
|
+
if quiet:
|
|
509
|
+
sys.stdout.write(freshness + "\n")
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
# ── Rich (default) mode — existing rendering, unchanged ──────────────────
|
|
513
|
+
|
|
514
|
+
# Print summary table
|
|
515
|
+
table = Table(title="seam status", show_header=False, box=None)
|
|
516
|
+
table.add_column("key", style="bold cyan", width=16)
|
|
517
|
+
table.add_column("value")
|
|
518
|
+
table.add_row("files", str(file_count))
|
|
519
|
+
table.add_row("symbols", str(symbol_count))
|
|
520
|
+
table.add_row("edges", str(edge_count))
|
|
521
|
+
# Only show the synthesized-edge row when the pass produced any — keeps the
|
|
522
|
+
# default status compact on indexes without synthesis.
|
|
523
|
+
if synth_edge_count > 0:
|
|
524
|
+
table.add_row("synth edges", f"{synth_edge_count} (heuristic, INFERRED)")
|
|
525
|
+
table.add_row("clusters", str(cluster_count))
|
|
526
|
+
# Semantic embeddings row — always shown so staleness is visible.
|
|
527
|
+
# "0" means embeddings not yet built; run `seam init --semantic` to populate.
|
|
528
|
+
configured_count_rich = embedding_model_counts.get(config.SEAM_EMBED_MODEL, 0)
|
|
529
|
+
if embedding_count == 0:
|
|
530
|
+
embeddings_display = "0 (run 'seam init --semantic' to enable)"
|
|
531
|
+
elif configured_count_rich > 0:
|
|
532
|
+
embeddings_display = f"{configured_count_rich} ({config.SEAM_EMBED_MODEL})"
|
|
533
|
+
else:
|
|
534
|
+
# Embeddings exist but for a different model — show mismatch warning.
|
|
535
|
+
stored_models = ", ".join(f"{m}:{c}" for m, c in embedding_model_counts.items())
|
|
536
|
+
embeddings_display = (
|
|
537
|
+
f"{embedding_count} [stored: {stored_models}] "
|
|
538
|
+
f"⚠ model mismatch (configured: {config.SEAM_EMBED_MODEL}) "
|
|
539
|
+
"— run 'seam init --semantic' to rebuild"
|
|
540
|
+
)
|
|
541
|
+
table.add_row("embeddings", embeddings_display)
|
|
542
|
+
table.add_row("last indexed", last_indexed_str)
|
|
543
|
+
table.add_row("watcher", watcher_status)
|
|
544
|
+
table.add_row("freshness", freshness)
|
|
545
|
+
console.print(table)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _load_create_server() -> Any:
|
|
549
|
+
"""Import the MCP server factory lazily — the `mcp` package is an OPTIONAL extra.
|
|
550
|
+
|
|
551
|
+
WHY lazy (not a top-of-file import): the whole CLI must stay usable when `mcp`
|
|
552
|
+
is not installed (the pure-CLI install profile). Only `seam start` needs it, so
|
|
553
|
+
a missing extra yields a clear install hint here instead of crashing CLI startup.
|
|
554
|
+
"""
|
|
555
|
+
try:
|
|
556
|
+
from seam.server.mcp import create_server
|
|
557
|
+
except ImportError as exc:
|
|
558
|
+
Console(stderr=True).print(
|
|
559
|
+
"[red]MCP server support is not installed.[/red]\n"
|
|
560
|
+
"Install it with: [bold]pip install 'seam-mcp[server]'[/bold]"
|
|
561
|
+
" (from source: [bold]uv sync --extra server[/bold])"
|
|
562
|
+
)
|
|
563
|
+
raise typer.Exit(code=1) from exc
|
|
564
|
+
return create_server
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
@app.command()
|
|
568
|
+
def start(
|
|
569
|
+
path: str = typer.Argument(".", help="Project root to watch (default: current directory)"),
|
|
570
|
+
db_dir: str = typer.Option(
|
|
571
|
+
"",
|
|
572
|
+
"--db-dir",
|
|
573
|
+
help="Override DB directory (default: same as project root)",
|
|
574
|
+
),
|
|
575
|
+
) -> None:
|
|
576
|
+
"""Start the MCP server (stdio) and file watcher daemon.
|
|
577
|
+
|
|
578
|
+
Watcher runs in a background subprocess; MCP server runs in the foreground
|
|
579
|
+
using stdio transport so the calling process (e.g. Claude Desktop) can
|
|
580
|
+
communicate with it directly.
|
|
581
|
+
|
|
582
|
+
The watcher subprocess writes its own .seam/watcher.pid (single writer).
|
|
583
|
+
The MCP server does not have a separate PID file — it occupies the foreground.
|
|
584
|
+
"""
|
|
585
|
+
project_root = Path(path).resolve()
|
|
586
|
+
|
|
587
|
+
if not project_root.is_dir():
|
|
588
|
+
console.print(f"[red]Error:[/red] '{project_root}' is not a directory.")
|
|
589
|
+
raise typer.Exit(code=1)
|
|
590
|
+
|
|
591
|
+
db_root = Path(db_dir).resolve() if db_dir else project_root
|
|
592
|
+
db_path = config.get_db_path(db_root)
|
|
593
|
+
|
|
594
|
+
if not db_path.exists():
|
|
595
|
+
Console(stderr=True).print("[red]No index found.[/red] Run [bold]seam init[/bold] first.")
|
|
596
|
+
raise typer.Exit(code=1)
|
|
597
|
+
|
|
598
|
+
# Resolve the optional MCP server factory NOW — before spawning the watcher — so a
|
|
599
|
+
# pure-CLI install (no `mcp` extra) fails fast with an actionable hint, not mid-startup.
|
|
600
|
+
create_server = _load_create_server()
|
|
601
|
+
|
|
602
|
+
# Refuse to spawn a second watcher if a live one is already recorded —
|
|
603
|
+
# two writers on one DB is exactly the corruption we just designed out.
|
|
604
|
+
pid_file = db_path.parent / "watcher.pid"
|
|
605
|
+
existing = _watcher_is_alive(pid_file)
|
|
606
|
+
if existing is not None:
|
|
607
|
+
Console(stderr=True).print(
|
|
608
|
+
f"[yellow]A watcher is already running (PID {existing}).[/yellow] Not starting another."
|
|
609
|
+
)
|
|
610
|
+
raise typer.Exit(code=1)
|
|
611
|
+
|
|
612
|
+
logging.basicConfig(level=getattr(logging, config.SEAM_LOG_LEVEL, logging.INFO))
|
|
613
|
+
|
|
614
|
+
# ── Launch watcher as a clean module entry point ──────────────────────────
|
|
615
|
+
# `python -m seam.watcher <db> <root>` passes paths via argv (NOT interpolated
|
|
616
|
+
# into a -c source string), so paths with spaces/quotes/backslashes are safe.
|
|
617
|
+
# The subprocess writes its own PID file via SeamWatcher.start().
|
|
618
|
+
watcher_proc = subprocess.Popen( # noqa: S603 — controlled internal command, no shell
|
|
619
|
+
[sys.executable, "-m", "seam.watcher", str(db_path), str(project_root)],
|
|
620
|
+
stdout=subprocess.DEVNULL,
|
|
621
|
+
stderr=subprocess.DEVNULL,
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
# ── Open DB connection (read path) for the MCP server ─────────────────────
|
|
625
|
+
conn = connect(db_path)
|
|
626
|
+
|
|
627
|
+
# Single idempotent teardown used by both the signal path and normal exit.
|
|
628
|
+
_torn_down = False
|
|
629
|
+
|
|
630
|
+
def _teardown() -> None:
|
|
631
|
+
nonlocal _torn_down
|
|
632
|
+
if _torn_down:
|
|
633
|
+
return
|
|
634
|
+
_torn_down = True
|
|
635
|
+
watcher_proc.terminate()
|
|
636
|
+
conn.close()
|
|
637
|
+
|
|
638
|
+
def _on_signal(signum: int, frame: object) -> None: # noqa: ARG001
|
|
639
|
+
_teardown()
|
|
640
|
+
sys.exit(0)
|
|
641
|
+
|
|
642
|
+
signal.signal(signal.SIGTERM, _on_signal)
|
|
643
|
+
signal.signal(signal.SIGINT, _on_signal)
|
|
644
|
+
|
|
645
|
+
# ── Run MCP server in the foreground (stdio) until the client disconnects ──
|
|
646
|
+
try:
|
|
647
|
+
server = create_server(conn, project_root)
|
|
648
|
+
server.run(transport="stdio")
|
|
649
|
+
finally:
|
|
650
|
+
_teardown()
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
@app.command(name="impact")
|
|
654
|
+
def impact_cmd(
|
|
655
|
+
symbol: str = typer.Argument(
|
|
656
|
+
..., help="Symbol name to analyze (e.g. 'upsert_file', 'UserService.validate')"
|
|
657
|
+
),
|
|
658
|
+
direction: str = typer.Option(
|
|
659
|
+
"upstream", "--direction", "-d", help="upstream | downstream | both"
|
|
660
|
+
),
|
|
661
|
+
depth: int = typer.Option(3, "--depth", help="Max hop depth (1-10)"),
|
|
662
|
+
path: str = typer.Option(".", "--path", help="Project root (default: current directory)"),
|
|
663
|
+
db_dir: str = typer.Option("", "--db-dir", help="Override DB directory"),
|
|
664
|
+
include_tests: bool = typer.Option(
|
|
665
|
+
False,
|
|
666
|
+
"--include-tests/--no-include-tests",
|
|
667
|
+
help=(
|
|
668
|
+
"Include test-file dependents. Default: off — impact shows only the "
|
|
669
|
+
"production blast radius (test callers otherwise dominate the tiers). "
|
|
670
|
+
"Mirrors the include_tests param in the MCP tool."
|
|
671
|
+
),
|
|
672
|
+
),
|
|
673
|
+
lean: bool = typer.Option(
|
|
674
|
+
False,
|
|
675
|
+
"--lean",
|
|
676
|
+
help=(
|
|
677
|
+
"Omit heavy enrichment fields (resolved_by, best_candidate) from every tier entry. "
|
|
678
|
+
"signature and core fields are always kept. Identical to verbose=false in MCP."
|
|
679
|
+
),
|
|
680
|
+
),
|
|
681
|
+
limit: int = typer.Option(
|
|
682
|
+
config.SEAM_IMPACT_MAX_RESULTS,
|
|
683
|
+
"--limit",
|
|
684
|
+
help=(
|
|
685
|
+
"Per-tier entry cap. Default: SEAM_IMPACT_MAX_RESULTS (25). "
|
|
686
|
+
"Set to 0 for unlimited — returns the full transitive blast radius. "
|
|
687
|
+
"Identical to the limit parameter in the MCP tool."
|
|
688
|
+
),
|
|
689
|
+
),
|
|
690
|
+
max_bytes: int = typer.Option(
|
|
691
|
+
config.SEAM_IMPACT_MAX_BYTES,
|
|
692
|
+
"--max-bytes",
|
|
693
|
+
help=(
|
|
694
|
+
"Per-call character budget for the impact output; 0 = unlimited (default "
|
|
695
|
+
"SEAM_IMPACT_MAX_BYTES). Most-relevant dependents survive when trimmed. "
|
|
696
|
+
"Identical to the max_bytes parameter in the MCP tool."
|
|
697
|
+
),
|
|
698
|
+
),
|
|
699
|
+
json_: bool = typer.Option(False, "--json", help="Emit structured JSON envelope to stdout."),
|
|
700
|
+
quiet: bool = typer.Option(False, "--quiet", help="Print bare values only (one per line)."),
|
|
701
|
+
) -> None:
|
|
702
|
+
"""Show the blast radius of a symbol — what breaks if you change it.
|
|
703
|
+
|
|
704
|
+
Results are grouped into risk tiers:
|
|
705
|
+
WILL_BREAK (d=1) — direct dependents, definitely affected
|
|
706
|
+
LIKELY_AFFECTED (d=2) — indirect dependents, probably affected
|
|
707
|
+
MAY_NEED_TESTING (d=3+) — transitive dependents, test to be sure
|
|
708
|
+
|
|
709
|
+
Each entry shows the path confidence (EXTRACTED | INFERRED | AMBIGUOUS).
|
|
710
|
+
By default only the production blast radius is shown (test dependents are hidden
|
|
711
|
+
and their count reported); pass --include-tests to also see test-file dependents
|
|
712
|
+
(marked [test] in the output).
|
|
713
|
+
"""
|
|
714
|
+
# WHY: check mutual exclusion before any DB work so the error is immediate.
|
|
715
|
+
try:
|
|
716
|
+
check_mutual_exclusion(json_=json_, quiet=quiet)
|
|
717
|
+
except ValueError as exc:
|
|
718
|
+
emit_json_error("INVALID_INPUT", str(exc))
|
|
719
|
+
|
|
720
|
+
project_root = Path(path).resolve()
|
|
721
|
+
db_root = Path(db_dir).resolve() if db_dir else project_root
|
|
722
|
+
db_path = config.get_db_path(db_root)
|
|
723
|
+
|
|
724
|
+
if not db_path.exists():
|
|
725
|
+
if json_:
|
|
726
|
+
emit_json_error("NO_INDEX", "No index found. Run 'seam init' first.")
|
|
727
|
+
console.print("[red]No index found.[/red] Run [bold]seam init[/bold] first.")
|
|
728
|
+
raise typer.Exit(code=1)
|
|
729
|
+
|
|
730
|
+
valid_directions = {"upstream", "downstream", "both"}
|
|
731
|
+
if direction not in valid_directions:
|
|
732
|
+
if json_:
|
|
733
|
+
emit_json_error(
|
|
734
|
+
"INVALID_INPUT",
|
|
735
|
+
f"direction must be one of: upstream, downstream, both; got {direction!r}",
|
|
736
|
+
)
|
|
737
|
+
console.print(
|
|
738
|
+
f"[red]Invalid direction:[/red] {direction!r}. Choose: upstream, downstream, or both."
|
|
739
|
+
)
|
|
740
|
+
raise typer.Exit(code=1)
|
|
741
|
+
|
|
742
|
+
try:
|
|
743
|
+
conn = connect(db_path)
|
|
744
|
+
except sqlite3.Error as exc:
|
|
745
|
+
if json_:
|
|
746
|
+
emit_json_error("DB_ERROR", f"Failed to open database: {exc}")
|
|
747
|
+
console.print(f"[red]Failed to open database:[/red] {exc}")
|
|
748
|
+
raise typer.Exit(code=1) from exc
|
|
749
|
+
|
|
750
|
+
# include_tests comes straight from the --include-tests/--no-include-tests flag
|
|
751
|
+
# (default off = production-only blast radius), matching the MCP tool default.
|
|
752
|
+
|
|
753
|
+
# --lean sets verbose=False; output becomes byte-identical to the MCP tool
|
|
754
|
+
# called with verbose=False — heavy fields absent from every tier entry.
|
|
755
|
+
verbose = not lean
|
|
756
|
+
|
|
757
|
+
try:
|
|
758
|
+
# WHY: ALL three modes (--json, --quiet, Rich) route through handle_seam_impact
|
|
759
|
+
# so the --limit cap, --lean strip, risk_summary, and truncated counts apply
|
|
760
|
+
# uniformly. The Rich path previously called impact() directly and so silently
|
|
761
|
+
# ignored --limit and --lean (a confirmed parity bug). One handler = one source
|
|
762
|
+
# of truth; Rich now renders the same capped result --json returns.
|
|
763
|
+
result = handle_seam_impact(
|
|
764
|
+
conn,
|
|
765
|
+
target=symbol,
|
|
766
|
+
root=project_root,
|
|
767
|
+
direction=direction,
|
|
768
|
+
max_depth=depth,
|
|
769
|
+
include_tests=include_tests,
|
|
770
|
+
verbose=verbose,
|
|
771
|
+
limit=limit,
|
|
772
|
+
max_bytes=max_bytes,
|
|
773
|
+
)
|
|
774
|
+
finally:
|
|
775
|
+
conn.close()
|
|
776
|
+
|
|
777
|
+
# ── JSON mode ─────────────────────────────────────────────────────────────
|
|
778
|
+
if json_:
|
|
779
|
+
emit_json(result)
|
|
780
|
+
return
|
|
781
|
+
|
|
782
|
+
# ── Quiet mode — print one name per dependent entry ───────────────────────
|
|
783
|
+
if quiet:
|
|
784
|
+
for dir_key, tier_group in result.items():
|
|
785
|
+
# Skip metadata keys (risk_summary/truncated are dicts too — without this
|
|
786
|
+
# guard they'd be walked as direction groups and emit garbage).
|
|
787
|
+
if dir_key in _IMPACT_META_KEYS or not isinstance(tier_group, dict):
|
|
788
|
+
continue
|
|
789
|
+
for entries in tier_group.values():
|
|
790
|
+
for entry in entries:
|
|
791
|
+
print_quiet(entry, field="name")
|
|
792
|
+
# Signal truncation on stderr so stdout stays a pure bare-name list
|
|
793
|
+
# (a `seam impact X --quiet | wc -l` pipeline must not see this line).
|
|
794
|
+
# truncated conflates BOTH causes (count cap + byte ceiling are merged there for
|
|
795
|
+
# reconciliation), so subtract the byte portion to report the count-cap drops only —
|
|
796
|
+
# otherwise byte-trimmed entries are misattributed to --limit (and "use --limit 0"
|
|
797
|
+
# is nonsense when --limit 0 was already passed).
|
|
798
|
+
truncated = result.get("truncated")
|
|
799
|
+
byte_capped = result.get("byte_capped")
|
|
800
|
+
byte_omitted = byte_capped.get("omitted", 0) if byte_capped else 0
|
|
801
|
+
if truncated:
|
|
802
|
+
total_omitted = sum(n for tiers in truncated.values() for n in tiers.values())
|
|
803
|
+
limit_omitted = total_omitted - byte_omitted # count-cap portion only
|
|
804
|
+
if limit_omitted > 0:
|
|
805
|
+
sys.stderr.write(
|
|
806
|
+
f"# {limit_omitted} more entr(ies) truncated by --limit; "
|
|
807
|
+
"use --limit 0 for the full set\n"
|
|
808
|
+
)
|
|
809
|
+
# Note the byte ceiling separately so the user can distinguish the two controls.
|
|
810
|
+
if byte_capped:
|
|
811
|
+
bc_limit = byte_capped.get("limit", 0)
|
|
812
|
+
if byte_omitted > 0:
|
|
813
|
+
sys.stderr.write(
|
|
814
|
+
f"# {byte_omitted} entr(ies) trimmed to fit --max-bytes {bc_limit}; "
|
|
815
|
+
"raise --max-bytes or use --lean for more\n"
|
|
816
|
+
)
|
|
817
|
+
return
|
|
818
|
+
|
|
819
|
+
# ── Rich (default) mode — existing rendering, unchanged ──────────────────
|
|
820
|
+
|
|
821
|
+
# Distinguish "symbol not in the index" from "indexed but no dependents".
|
|
822
|
+
if not result.get("found", True):
|
|
823
|
+
console.print(
|
|
824
|
+
f"[yellow]Symbol '{symbol}' not found in the index[/yellow]"
|
|
825
|
+
" — check the name or run 'seam init'."
|
|
826
|
+
)
|
|
827
|
+
return
|
|
828
|
+
|
|
829
|
+
# Check if any results exist across all directions and tiers.
|
|
830
|
+
# Skip metadata keys: risk_summary/truncated are {tier: int} dicts, so without
|
|
831
|
+
# the guard `len(entries)` would be called on an int and raise TypeError.
|
|
832
|
+
total = sum(
|
|
833
|
+
len(entries)
|
|
834
|
+
for key, tier_group in result.items()
|
|
835
|
+
if isinstance(tier_group, dict) and key not in _IMPACT_META_KEYS
|
|
836
|
+
for entries in tier_group.values()
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
# hidden_tests is only present when the production-only default filtered tests out.
|
|
840
|
+
hidden_tests = result.get("hidden_tests", 0)
|
|
841
|
+
|
|
842
|
+
if total == 0:
|
|
843
|
+
# CRITICAL false-safe guard: an empty entry list can mean "trimmed to nothing"
|
|
844
|
+
# OR "no dependents". The byte ceiling can drop EVERY entry when --max-bytes is
|
|
845
|
+
# smaller than the envelope, so check byte_capped FIRST — printing "No dependents
|
|
846
|
+
# found" here when entries were merely trimmed would tell an agent the symbol is
|
|
847
|
+
# safe to delete (the same dangerous false-safe the hidden_tests branch guards).
|
|
848
|
+
byte_capped_empty = result.get("byte_capped")
|
|
849
|
+
if byte_capped_empty and byte_capped_empty.get("omitted", 0) > 0:
|
|
850
|
+
console.print(
|
|
851
|
+
f"[yellow]All {byte_capped_empty['omitted']} dependent(s) for "
|
|
852
|
+
f"[bold]{symbol}[/bold] were trimmed to fit --max-bytes "
|
|
853
|
+
f"{byte_capped_empty.get('limit')}[/yellow] — this is NOT 'no dependents'. "
|
|
854
|
+
"Raise --max-bytes or use --lean to see them."
|
|
855
|
+
)
|
|
856
|
+
elif hidden_tests:
|
|
857
|
+
# Critical distinction: this symbol is NOT dead code — it has test
|
|
858
|
+
# dependents hidden by the production-only default. Saying "no dependents"
|
|
859
|
+
# here would be a dangerous false-safe (an agent might delete/rewrite it).
|
|
860
|
+
console.print(
|
|
861
|
+
f"[yellow]No production dependents for [bold]{symbol}[/bold][/yellow] — "
|
|
862
|
+
f"but {hidden_tests} test dependent(s) hidden. "
|
|
863
|
+
"Re-run with --include-tests to see them."
|
|
864
|
+
)
|
|
865
|
+
else:
|
|
866
|
+
console.print(f"[dim]No dependents found for [bold]{symbol}[/bold].[/dim]")
|
|
867
|
+
# E4 (WATCH-3): render the steer here too, so the human surface gets the same
|
|
868
|
+
# actionable next_actions (incl. the richer all-trimmed anti-false-safe warning)
|
|
869
|
+
# that JSON/MCP consumers receive. Without this, the early return below hid the
|
|
870
|
+
# footer for the all-trimmed case.
|
|
871
|
+
_render_next_actions(result)
|
|
872
|
+
return
|
|
873
|
+
|
|
874
|
+
# Print a tiered summary per direction.
|
|
875
|
+
tier_order = [TIER_WILL_BREAK, TIER_LIKELY_AFFECTED, TIER_MAY_NEED_TESTING]
|
|
876
|
+
tier_labels = {
|
|
877
|
+
TIER_WILL_BREAK: "[bold red]WILL BREAK[/bold red] (d=1)",
|
|
878
|
+
TIER_LIKELY_AFFECTED: "[bold yellow]LIKELY AFFECTED[/bold yellow] (d=2)",
|
|
879
|
+
TIER_MAY_NEED_TESTING: "[dim]MAY NEED TESTING[/dim] (d=3+)",
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
# Iterate only direction keys (skip metadata keys: found/target/hidden_tests
|
|
883
|
+
# and the Phase 8 risk_summary/truncated dicts).
|
|
884
|
+
for direction_key, tier_group in result.items():
|
|
885
|
+
if direction_key in _IMPACT_META_KEYS or not isinstance(tier_group, dict):
|
|
886
|
+
continue
|
|
887
|
+
console.print(
|
|
888
|
+
f"\n[bold cyan]Impact ({direction_key})[/bold cyan] of [bold]{symbol}[/bold]:"
|
|
889
|
+
)
|
|
890
|
+
any_in_direction = any(len(entries) > 0 for entries in tier_group.values())
|
|
891
|
+
if not any_in_direction:
|
|
892
|
+
console.print(" [dim]No dependents.[/dim]")
|
|
893
|
+
continue
|
|
894
|
+
|
|
895
|
+
for tier in tier_order:
|
|
896
|
+
entries = tier_group.get(tier, [])
|
|
897
|
+
if not entries:
|
|
898
|
+
continue
|
|
899
|
+
console.print(f"\n {tier_labels[tier]}")
|
|
900
|
+
for entry in entries:
|
|
901
|
+
confidence_color = {
|
|
902
|
+
"EXTRACTED": "green",
|
|
903
|
+
"INFERRED": "yellow",
|
|
904
|
+
"AMBIGUOUS": "red",
|
|
905
|
+
}.get(entry["confidence"], "white")
|
|
906
|
+
# Show [test] marker when tests are included and this entry is from a test file.
|
|
907
|
+
test_marker = " [dim][test][/dim]" if entry.get("is_test") else ""
|
|
908
|
+
# Phase 5: surface the proximity best_candidate on AMBIGUOUS entries
|
|
909
|
+
# (story 6) so a human sees the likeliest declaration for a homonym.
|
|
910
|
+
# best_candidate is ALREADY relativized by handle_seam_impact — do not
|
|
911
|
+
# re-relativize (that would mangle the already-relative path). Absent in
|
|
912
|
+
# --lean mode, so use .get().
|
|
913
|
+
best = entry.get("best_candidate")
|
|
914
|
+
best_marker = f" [dim](best: {best})[/dim]" if best else ""
|
|
915
|
+
# E4: surface edge kind and synthesized marker when SEAM_EDGE_PROVENANCE=on.
|
|
916
|
+
# kind is kept in lean mode (core field); synthesized_by is stripped (heavy field).
|
|
917
|
+
# Absent when SEAM_EDGE_PROVENANCE=off (byte-identical pre-E4 output).
|
|
918
|
+
kind_marker = ""
|
|
919
|
+
synth_marker = ""
|
|
920
|
+
if config.SEAM_EDGE_PROVENANCE == "on":
|
|
921
|
+
edge_kind = entry.get("kind", "")
|
|
922
|
+
kind_marker = f" [dim]{edge_kind}[/dim]" if edge_kind else ""
|
|
923
|
+
# synthesized_by is present in verbose mode; absent in --lean.
|
|
924
|
+
# Non-null = heuristic edge from the synthesis post-pass.
|
|
925
|
+
# NOTE: use plain text for the channel name — avoid wrapping the
|
|
926
|
+
# channel string in [] which looks like Rich markup and gets stripped.
|
|
927
|
+
synth_channel = entry.get("synthesized_by")
|
|
928
|
+
if synth_channel:
|
|
929
|
+
synth_marker = f" [yellow](synth:{synth_channel})[/yellow]"
|
|
930
|
+
console.print(
|
|
931
|
+
f" [bold]{entry['name']}[/bold] "
|
|
932
|
+
f"[{confidence_color}]{entry['confidence']}[/{confidence_color}] "
|
|
933
|
+
f"[dim]d={entry['distance']}[/dim]{kind_marker}{test_marker}{best_marker}{synth_marker}"
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
# Per-direction truncation footer: when --limit capped this direction's tiers,
|
|
937
|
+
# tell the user how many entries were omitted and how to see them all. Without
|
|
938
|
+
# this the capped Rich output looks complete (the silent-cap parity bug).
|
|
939
|
+
# Gate on limit > 0: with --limit 0 the count cap is OFF, so any entries in
|
|
940
|
+
# `truncated` were dropped by the byte ceiling (reported by its own footer below) —
|
|
941
|
+
# showing "truncated by --limit (showing 0 per tier; use --limit 0)" here would
|
|
942
|
+
# both misattribute the cause and contradict the flag the user already passed.
|
|
943
|
+
if limit > 0:
|
|
944
|
+
dir_truncated = result.get("truncated", {}).get(direction_key, {})
|
|
945
|
+
omitted = sum(dir_truncated.values())
|
|
946
|
+
if omitted > 0:
|
|
947
|
+
console.print(
|
|
948
|
+
f"\n [dim]… {omitted} more entr(ies) truncated by --limit "
|
|
949
|
+
f"(showing {limit} per tier; use --limit 0 for the full blast radius).[/dim]"
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
# Byte-ceiling footer: when --max-bytes trimmed entries, tell the user.
|
|
953
|
+
# Kept separate from the per-direction --limit footer so the user can distinguish
|
|
954
|
+
# the two controls and knows to raise --max-bytes (not --limit) to see more.
|
|
955
|
+
byte_capped = result.get("byte_capped")
|
|
956
|
+
if byte_capped:
|
|
957
|
+
bc_omitted = byte_capped.get("omitted", 0)
|
|
958
|
+
bc_limit = byte_capped.get("limit", 0)
|
|
959
|
+
if bc_omitted > 0:
|
|
960
|
+
console.print(
|
|
961
|
+
f"\n[dim]… {bc_omitted} entr(ies) trimmed to fit --max-bytes {bc_limit}; "
|
|
962
|
+
"raise --max-bytes or use --lean for more.[/dim]"
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
# Footer: when production dependents were shown but tests were also hidden,
|
|
966
|
+
# note the hidden count so the blast radius is not silently under-reported.
|
|
967
|
+
if hidden_tests:
|
|
968
|
+
console.print(
|
|
969
|
+
f"\n[dim]({hidden_tests} test dependent(s) hidden; "
|
|
970
|
+
f"use --include-tests to show them)[/dim]"
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
# E4: next_actions steer footer (shared with the all-trimmed early-return branch).
|
|
974
|
+
_render_next_actions(result)
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
@app.command(name="trace")
|
|
978
|
+
def trace_cmd(
|
|
979
|
+
source: str = typer.Argument(..., help="Starting symbol name (e.g. 'init', 'parse_file')"),
|
|
980
|
+
target: str = typer.Argument(
|
|
981
|
+
..., help="Destination symbol name (e.g. 'upsert_file', 'init_db')"
|
|
982
|
+
),
|
|
983
|
+
depth: int = typer.Option(10, "--depth", help="Max hop depth (1-10)"),
|
|
984
|
+
path: str = typer.Option(".", "--path", help="Project root (default: current directory)"),
|
|
985
|
+
db_dir: str = typer.Option("", "--db-dir", help="Override DB directory"),
|
|
986
|
+
lean: bool = typer.Option(
|
|
987
|
+
False,
|
|
988
|
+
"--lean",
|
|
989
|
+
help=(
|
|
990
|
+
"Omit heavy enrichment fields (resolved_by, best_candidate) from every hop. "
|
|
991
|
+
"Identical to verbose=false in MCP."
|
|
992
|
+
),
|
|
993
|
+
),
|
|
994
|
+
json_: bool = typer.Option(False, "--json", help="Emit structured JSON envelope to stdout."),
|
|
995
|
+
quiet: bool = typer.Option(False, "--quiet", help="Print bare values only (one per line)."),
|
|
996
|
+
) -> None:
|
|
997
|
+
"""Trace the call/dependency path from one symbol to another.
|
|
998
|
+
|
|
999
|
+
Shows each hop with its edge kind (call | import | extends | implements |
|
|
1000
|
+
instantiates | holds | reads | writes | uses) and confidence level
|
|
1001
|
+
(EXTRACTED | INFERRED | AMBIGUOUS). Confidence colors:
|
|
1002
|
+
green = EXTRACTED (definitely this edge)
|
|
1003
|
+
yellow = INFERRED (heuristic best-guess)
|
|
1004
|
+
red = AMBIGUOUS (name collision — verify manually)
|
|
1005
|
+
|
|
1006
|
+
When SEAM_EDGE_PROVENANCE=on (default), synthesized hops from the edge-synthesis
|
|
1007
|
+
post-pass are labelled (synth:<channel>) so you can tell a heuristic edge from a
|
|
1008
|
+
statically-extracted one.
|
|
1009
|
+
|
|
1010
|
+
Also shows direct callers and callees of both source and target.
|
|
1011
|
+
|
|
1012
|
+
Exits with a clear message when no path exists between the symbols.
|
|
1013
|
+
"""
|
|
1014
|
+
try:
|
|
1015
|
+
check_mutual_exclusion(json_=json_, quiet=quiet)
|
|
1016
|
+
except ValueError as exc:
|
|
1017
|
+
emit_json_error("INVALID_INPUT", str(exc))
|
|
1018
|
+
|
|
1019
|
+
project_root = Path(path).resolve()
|
|
1020
|
+
db_root = Path(db_dir).resolve() if db_dir else project_root
|
|
1021
|
+
db_path = config.get_db_path(db_root)
|
|
1022
|
+
|
|
1023
|
+
if not db_path.exists():
|
|
1024
|
+
if json_:
|
|
1025
|
+
emit_json_error("NO_INDEX", "No index found. Run 'seam init' first.")
|
|
1026
|
+
console.print("[red]No index found.[/red] Run [bold]seam init[/bold] first.")
|
|
1027
|
+
raise typer.Exit(code=1)
|
|
1028
|
+
|
|
1029
|
+
try:
|
|
1030
|
+
conn = connect(db_path)
|
|
1031
|
+
except sqlite3.Error as exc:
|
|
1032
|
+
if json_:
|
|
1033
|
+
emit_json_error("DB_ERROR", f"Failed to open database: {exc}")
|
|
1034
|
+
console.print(f"[red]Failed to open database:[/red] {exc}")
|
|
1035
|
+
raise typer.Exit(code=1) from exc
|
|
1036
|
+
|
|
1037
|
+
# Clamp depth to [1, 10]
|
|
1038
|
+
safe_depth = max(1, min(10, depth))
|
|
1039
|
+
|
|
1040
|
+
# --lean sets verbose=False; output becomes byte-identical to the MCP tool
|
|
1041
|
+
# called with verbose=False — heavy fields absent from every hop.
|
|
1042
|
+
verbose = not lean
|
|
1043
|
+
|
|
1044
|
+
try:
|
|
1045
|
+
# WHY: reuse handle_seam_trace for --json/--quiet to ensure MCP/CLI parity.
|
|
1046
|
+
if json_ or quiet:
|
|
1047
|
+
result = handle_seam_trace(
|
|
1048
|
+
conn,
|
|
1049
|
+
source=source,
|
|
1050
|
+
target=target,
|
|
1051
|
+
root=project_root,
|
|
1052
|
+
max_depth=safe_depth,
|
|
1053
|
+
verbose=verbose,
|
|
1054
|
+
)
|
|
1055
|
+
else:
|
|
1056
|
+
# Thread project_root as repo_root for Phase 5 import-promotion so
|
|
1057
|
+
# imported homonym bindings resolve as EXTRACTED rather than AMBIGUOUS.
|
|
1058
|
+
paths = flows_trace(conn, source, target, max_depth=safe_depth, repo_root=project_root)
|
|
1059
|
+
callers_src = flows_callers(conn, source, repo_root=project_root)
|
|
1060
|
+
callees_src = flows_callees(conn, source, repo_root=project_root)
|
|
1061
|
+
callers_tgt = flows_callers(conn, target, repo_root=project_root)
|
|
1062
|
+
callees_tgt = flows_callees(conn, target, repo_root=project_root)
|
|
1063
|
+
finally:
|
|
1064
|
+
conn.close()
|
|
1065
|
+
|
|
1066
|
+
# ── JSON mode ─────────────────────────────────────────────────────────────
|
|
1067
|
+
if json_:
|
|
1068
|
+
emit_json(result)
|
|
1069
|
+
return
|
|
1070
|
+
|
|
1071
|
+
# ── Quiet mode — print hop names from the first path ─────────────────────
|
|
1072
|
+
if quiet:
|
|
1073
|
+
if result.get("found") and result.get("paths"):
|
|
1074
|
+
for hop in result["paths"][0]:
|
|
1075
|
+
sys.stdout.write(hop["from_name"] + "\n")
|
|
1076
|
+
# Print the final destination
|
|
1077
|
+
if result["paths"][0]:
|
|
1078
|
+
sys.stdout.write(result["paths"][0][-1]["to_name"] + "\n")
|
|
1079
|
+
return
|
|
1080
|
+
|
|
1081
|
+
# ── Rich (default) mode ───────────────────────────────────────────────────
|
|
1082
|
+
|
|
1083
|
+
# Confidence -> rich color mapping, used throughout.
|
|
1084
|
+
def _conf_color(c: str) -> str:
|
|
1085
|
+
return {"EXTRACTED": "green", "INFERRED": "yellow", "AMBIGUOUS": "red"}.get(c, "white")
|
|
1086
|
+
|
|
1087
|
+
# Phase 5: best_candidate is the proximity-ranked most-likely target attached to
|
|
1088
|
+
# AMBIGUOUS hops (story 6). Rendered relative to the project root so a human
|
|
1089
|
+
# scanning a homonym sees the likeliest declaration alongside the ambiguity.
|
|
1090
|
+
def _best_suffix(d: Mapping[str, Any]) -> str:
|
|
1091
|
+
best = d.get("best_candidate")
|
|
1092
|
+
if not best:
|
|
1093
|
+
return ""
|
|
1094
|
+
return f" [dim](best: {os.path.relpath(best, project_root)})[/dim]"
|
|
1095
|
+
|
|
1096
|
+
# ── Path result ───────────────────────────────────────────────────────────
|
|
1097
|
+
if not paths:
|
|
1098
|
+
console.print(
|
|
1099
|
+
f"\n[yellow]No path found[/yellow] from [bold]{source}[/bold] to [bold]{target}[/bold] "
|
|
1100
|
+
f"within {safe_depth} hop(s).\n"
|
|
1101
|
+
"[dim]Try increasing --depth or check the symbol names with 'seam search'.[/dim]"
|
|
1102
|
+
)
|
|
1103
|
+
else:
|
|
1104
|
+
# paths[0] is the shortest path (BFS returns a single-element list).
|
|
1105
|
+
found_path = paths[0]
|
|
1106
|
+
if not found_path:
|
|
1107
|
+
# Trivial self-path (source == target).
|
|
1108
|
+
console.print(f"\n[dim]{source} == {target} (same symbol, zero hops)[/dim]")
|
|
1109
|
+
else:
|
|
1110
|
+
console.print(
|
|
1111
|
+
f"\n[bold cyan]Path[/bold cyan] from [bold]{source}[/bold] to [bold]{target}[/bold] "
|
|
1112
|
+
f"({len(found_path)} hop(s)):"
|
|
1113
|
+
)
|
|
1114
|
+
arrow = " → "
|
|
1115
|
+
for hop in found_path:
|
|
1116
|
+
color = _conf_color(hop["confidence"])
|
|
1117
|
+
# Phase 5: append resolved_by when present for provenance visibility.
|
|
1118
|
+
# Format: "EXTRACTED [via import]" so users can spot promoted hops.
|
|
1119
|
+
rby = hop.get("resolved_by")
|
|
1120
|
+
rby_suffix = f" [dim][via {rby}][/dim]" if rby else ""
|
|
1121
|
+
# E4: show synthesized marker when the hop was heuristically inferred.
|
|
1122
|
+
# synthesized_by is None for static edges (no marker); a channel name
|
|
1123
|
+
# for synthesized edges (e.g. "interface-override", "event-emitter").
|
|
1124
|
+
# Gated by SEAM_EDGE_PROVENANCE so callers can revert to pre-E4 output.
|
|
1125
|
+
# NOTE: use () not [] for the channel — [] looks like Rich markup tags
|
|
1126
|
+
# and gets silently stripped by the Rich console renderer.
|
|
1127
|
+
trace_synth_marker = ""
|
|
1128
|
+
if config.SEAM_EDGE_PROVENANCE == "on":
|
|
1129
|
+
synth_ch = hop.get("synthesized_by")
|
|
1130
|
+
if synth_ch:
|
|
1131
|
+
trace_synth_marker = f" [yellow](synth:{synth_ch})[/yellow]"
|
|
1132
|
+
console.print(
|
|
1133
|
+
f" [bold]{hop['from_name']}[/bold]{arrow}[bold]{hop['to_name']}[/bold]"
|
|
1134
|
+
f" [dim]{hop['kind']}[/dim]"
|
|
1135
|
+
f" [{color}]{hop['confidence']}[/{color}]{rby_suffix}{_best_suffix(hop)}{trace_synth_marker}"
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
# ── One-hop neighborhood ──────────────────────────────────────────────────
|
|
1139
|
+
def _print_hops(label: str, hops: list) -> None: # type: ignore[type-arg]
|
|
1140
|
+
if not hops:
|
|
1141
|
+
console.print(f"\n [dim]{label}: none[/dim]")
|
|
1142
|
+
return
|
|
1143
|
+
console.print(f"\n [bold]{label}[/bold]:")
|
|
1144
|
+
for h in hops:
|
|
1145
|
+
color = _conf_color(h["confidence"])
|
|
1146
|
+
# Phase 5: show resolved_by in neighbourhood hops too when present.
|
|
1147
|
+
rby = h.get("resolved_by")
|
|
1148
|
+
rby_suffix = f" [dim][via {rby}][/dim]" if rby else ""
|
|
1149
|
+
console.print(
|
|
1150
|
+
f" [bold]{h['name']}[/bold] [dim]{h['kind']}[/dim]"
|
|
1151
|
+
f" [{color}]{h['confidence']}[/{color}]{rby_suffix}{_best_suffix(h)}"
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
console.print(f"\n[bold cyan]Neighborhood of [bold]{source}[/bold][/bold cyan]:")
|
|
1155
|
+
_print_hops(f"callers({source})", callers_src)
|
|
1156
|
+
_print_hops(f"callees({source})", callees_src)
|
|
1157
|
+
|
|
1158
|
+
console.print(f"\n[bold cyan]Neighborhood of [bold]{target}[/bold][/bold cyan]:")
|
|
1159
|
+
_print_hops(f"callers({target})", callers_tgt)
|
|
1160
|
+
_print_hops(f"callees({target})", callees_tgt)
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
@app.command(name="changes")
|
|
1164
|
+
def changes_cmd(
|
|
1165
|
+
base: str = typer.Option(
|
|
1166
|
+
DEFAULT_BASE_REF,
|
|
1167
|
+
"--base",
|
|
1168
|
+
"-b",
|
|
1169
|
+
help=f"Base git ref for branch scope (default: {DEFAULT_BASE_REF})",
|
|
1170
|
+
),
|
|
1171
|
+
scope: str = typer.Option(
|
|
1172
|
+
"working",
|
|
1173
|
+
"--scope",
|
|
1174
|
+
"-s",
|
|
1175
|
+
help="working | staged | branch",
|
|
1176
|
+
),
|
|
1177
|
+
path: str = typer.Option(".", "--path", help="Project root (default: current directory)"),
|
|
1178
|
+
db_dir: str = typer.Option("", "--db-dir", help="Override DB directory"),
|
|
1179
|
+
json_: bool = typer.Option(False, "--json", help="Emit structured JSON envelope to stdout."),
|
|
1180
|
+
quiet: bool = typer.Option(False, "--quiet", help="Print bare values only (one per line)."),
|
|
1181
|
+
stdin: bool = typer.Option(
|
|
1182
|
+
False,
|
|
1183
|
+
"--stdin",
|
|
1184
|
+
help=(
|
|
1185
|
+
"Read a newline-delimited list of file paths from stdin. "
|
|
1186
|
+
"Narrows changed_symbols and new_files to only those files. "
|
|
1187
|
+
"affected and risk_level intentionally reflect the FULL git diff "
|
|
1188
|
+
"(conservative — never under-reports risk)."
|
|
1189
|
+
),
|
|
1190
|
+
),
|
|
1191
|
+
) -> None:
|
|
1192
|
+
"""Pre-commit risk check — show what your changes break.
|
|
1193
|
+
|
|
1194
|
+
Maps git diff to the symbols it touched, runs impact analysis, and prints
|
|
1195
|
+
an overall risk level (low / medium / high / critical).
|
|
1196
|
+
|
|
1197
|
+
Scope:
|
|
1198
|
+
working — unstaged changes (git diff)
|
|
1199
|
+
staged — staged changes (git diff --cached)
|
|
1200
|
+
branch — all changes on this branch vs base ref (git diff <base>...HEAD)
|
|
1201
|
+
|
|
1202
|
+
Use --stdin to restrict the analysis to a precomputed file list, e.g.:
|
|
1203
|
+
git diff --name-only | seam changes --stdin --json
|
|
1204
|
+
"""
|
|
1205
|
+
try:
|
|
1206
|
+
check_mutual_exclusion(json_=json_, quiet=quiet)
|
|
1207
|
+
except ValueError as exc:
|
|
1208
|
+
emit_json_error("INVALID_INPUT", str(exc))
|
|
1209
|
+
|
|
1210
|
+
project_root = Path(path).resolve()
|
|
1211
|
+
db_root = Path(db_dir).resolve() if db_dir else project_root
|
|
1212
|
+
db_path = config.get_db_path(db_root)
|
|
1213
|
+
|
|
1214
|
+
# Validate scope early for a helpful message.
|
|
1215
|
+
if scope not in VALID_SCOPES:
|
|
1216
|
+
if json_:
|
|
1217
|
+
emit_json_error(
|
|
1218
|
+
"INVALID_INPUT", f"scope must be one of {sorted(VALID_SCOPES)}; got {scope!r}"
|
|
1219
|
+
)
|
|
1220
|
+
console.print(
|
|
1221
|
+
f"[red]Invalid scope:[/red] {scope!r}. "
|
|
1222
|
+
f"Choose one of: {', '.join(sorted(VALID_SCOPES))}."
|
|
1223
|
+
)
|
|
1224
|
+
raise typer.Exit(code=1)
|
|
1225
|
+
|
|
1226
|
+
if not db_path.exists():
|
|
1227
|
+
if json_:
|
|
1228
|
+
emit_json_error("NO_INDEX", "No index found. Run 'seam init' first.")
|
|
1229
|
+
console.print("[red]No index found.[/red] Run [bold]seam init[/bold] first.")
|
|
1230
|
+
raise typer.Exit(code=1)
|
|
1231
|
+
|
|
1232
|
+
try:
|
|
1233
|
+
conn = connect(db_path)
|
|
1234
|
+
except sqlite3.Error as exc:
|
|
1235
|
+
if json_:
|
|
1236
|
+
emit_json_error("DB_ERROR", f"Failed to open database: {exc}")
|
|
1237
|
+
console.print(f"[red]Failed to open database:[/red] {exc}")
|
|
1238
|
+
raise typer.Exit(code=1) from exc
|
|
1239
|
+
|
|
1240
|
+
# Read stdin before dispatching to detect_changes/handle_seam_changes because stdin
|
|
1241
|
+
# is a one-shot stream — it must be drained before the output-mode branch so the
|
|
1242
|
+
# resolved file set is available to both the json/quiet and Rich paths.
|
|
1243
|
+
# Paths are resolved here so they match the absolute paths stored in the DB.
|
|
1244
|
+
stdin_files: set[str] | None = None
|
|
1245
|
+
if stdin:
|
|
1246
|
+
raw_lines = sys.stdin.read().splitlines()
|
|
1247
|
+
resolved = []
|
|
1248
|
+
for line in raw_lines:
|
|
1249
|
+
line = line.strip()
|
|
1250
|
+
if line:
|
|
1251
|
+
resolved.append(
|
|
1252
|
+
str((project_root / line).resolve())
|
|
1253
|
+
if not Path(line).is_absolute()
|
|
1254
|
+
else str(Path(line).resolve())
|
|
1255
|
+
)
|
|
1256
|
+
stdin_files = set(resolved)
|
|
1257
|
+
|
|
1258
|
+
# WHY `Any`: handle_seam_changes returns dict[str,Any]; detect_changes returns
|
|
1259
|
+
# ChangeReport (a TypedDict). Both are accessed with the same string keys at
|
|
1260
|
+
# runtime, but mypy cannot unify them — `Any` is the honest annotation here.
|
|
1261
|
+
report: Any
|
|
1262
|
+
try:
|
|
1263
|
+
# WHY: reuse handle_seam_changes for --json/--quiet (MCP/CLI parity).
|
|
1264
|
+
if json_ or quiet:
|
|
1265
|
+
report = handle_seam_changes(conn, root=project_root, base_ref=base, scope=scope)
|
|
1266
|
+
else:
|
|
1267
|
+
try:
|
|
1268
|
+
report = detect_changes(conn, base_ref=base, scope=scope, repo_root=project_root)
|
|
1269
|
+
except NotAGitRepoError as exc:
|
|
1270
|
+
console.print(f"[red]Not a git repository:[/red] {exc}")
|
|
1271
|
+
raise typer.Exit(code=1) from exc
|
|
1272
|
+
finally:
|
|
1273
|
+
conn.close()
|
|
1274
|
+
|
|
1275
|
+
# ── Apply stdin file filter (when --stdin was given) ─────────────────────
|
|
1276
|
+
# WHY filter after report: detect_changes always runs the full git diff; we then
|
|
1277
|
+
# narrow changed_symbols and new_files to the user-provided file subset.
|
|
1278
|
+
# risk_level and affected intentionally reflect the FULL diff (conservative —
|
|
1279
|
+
# never under-reports risk to the caller).
|
|
1280
|
+
# Skip the filter when the report is an error dict (NOT_A_GIT_REPO etc).
|
|
1281
|
+
if (
|
|
1282
|
+
stdin_files is not None
|
|
1283
|
+
and isinstance(report, dict)
|
|
1284
|
+
and not report.get("error")
|
|
1285
|
+
and "changed_symbols" in report
|
|
1286
|
+
):
|
|
1287
|
+
# Determine the key used for file lookup in changed_symbols entries.
|
|
1288
|
+
# handle_seam_changes returns relativized paths; detect_changes returns absolute.
|
|
1289
|
+
# We filter by checking if the symbol's file appears in stdin_files.
|
|
1290
|
+
# For handle_seam_changes (json/quiet paths), file is relative; we need to
|
|
1291
|
+
# resolve it to compare with the resolved stdin_files set.
|
|
1292
|
+
def _abs_sym_file(sym_file: str | None) -> str | None:
|
|
1293
|
+
if sym_file is None:
|
|
1294
|
+
return None
|
|
1295
|
+
p = Path(sym_file)
|
|
1296
|
+
if p.is_absolute():
|
|
1297
|
+
return str(p)
|
|
1298
|
+
return str((project_root / p).resolve())
|
|
1299
|
+
|
|
1300
|
+
if isinstance(report, dict) and "changed_symbols" in report:
|
|
1301
|
+
report = dict(report) # shallow copy so we don't mutate the TypedDict
|
|
1302
|
+
report["changed_symbols"] = [
|
|
1303
|
+
s for s in report["changed_symbols"] if _abs_sym_file(s.get("file")) in stdin_files
|
|
1304
|
+
]
|
|
1305
|
+
report["new_files"] = [
|
|
1306
|
+
f for f in report.get("new_files", []) if _abs_sym_file(f) in stdin_files
|
|
1307
|
+
]
|
|
1308
|
+
|
|
1309
|
+
# ── Error guard — applies to ALL output modes ─────────────────────────────
|
|
1310
|
+
# handle_seam_changes returns {"error": "...", "message": "..."} for NOT_A_GIT_REPO.
|
|
1311
|
+
# Without this guard, --quiet calls print_quiet(report, "risk_level") → KeyError,
|
|
1312
|
+
# and --rich (default) would crash on report["risk_level"] below.
|
|
1313
|
+
# Mirror the --json branch: surface the error uniformly regardless of output mode.
|
|
1314
|
+
if isinstance(report, dict) and report.get("error"):
|
|
1315
|
+
if json_:
|
|
1316
|
+
emit_json_error(report["error"], report.get("message", ""))
|
|
1317
|
+
# --quiet and rich: print error to stderr and exit non-zero
|
|
1318
|
+
sys.stderr.write(f"Error [{report['error']}]: {report.get('message', '')}\n")
|
|
1319
|
+
raise typer.Exit(code=1)
|
|
1320
|
+
|
|
1321
|
+
# ── JSON mode ─────────────────────────────────────────────────────────────
|
|
1322
|
+
if json_:
|
|
1323
|
+
emit_json(report)
|
|
1324
|
+
return
|
|
1325
|
+
|
|
1326
|
+
# ── Quiet mode — print the risk level ────────────────────────────────────
|
|
1327
|
+
if quiet:
|
|
1328
|
+
print_quiet(report, field="risk_level")
|
|
1329
|
+
return
|
|
1330
|
+
|
|
1331
|
+
# ── Rich (default) mode — existing rendering, unchanged ──────────────────
|
|
1332
|
+
|
|
1333
|
+
# ── Summary header ────────────────────────────────────────────────────────
|
|
1334
|
+
risk_color = {
|
|
1335
|
+
"low": "green",
|
|
1336
|
+
"medium": "yellow",
|
|
1337
|
+
"high": "bold yellow",
|
|
1338
|
+
"critical": "bold red",
|
|
1339
|
+
}.get(report["risk_level"], "white")
|
|
1340
|
+
|
|
1341
|
+
console.print(
|
|
1342
|
+
f"\n[bold]seam changes[/bold] scope=[cyan]{report['scope']}[/cyan]"
|
|
1343
|
+
+ (f" base=[cyan]{report['base_ref']}[/cyan]" if scope == "branch" else "")
|
|
1344
|
+
)
|
|
1345
|
+
console.print(
|
|
1346
|
+
f"Risk: [{risk_color}]{report['risk_level'].upper()}[/{risk_color}]"
|
|
1347
|
+
+ (
|
|
1348
|
+
" [yellow](AMBIGUOUS edges — estimate uncertain)[/yellow]"
|
|
1349
|
+
if report["ambiguous_warning"]
|
|
1350
|
+
else ""
|
|
1351
|
+
)
|
|
1352
|
+
)
|
|
1353
|
+
|
|
1354
|
+
# Partial verdict marker: printed after the risk line when the impact cap was hit.
|
|
1355
|
+
# Format: "⚠ PARTIAL — impact cap (N) hit; M of K symbols analyzed"
|
|
1356
|
+
# This makes it immediately obvious the risk verdict only covers a subset.
|
|
1357
|
+
# Count only REAL changed symbols (exclude synthetic <module:...>/<new:...> entries):
|
|
1358
|
+
# the cap in _collect_impact applies to real names only, so the denominator must
|
|
1359
|
+
# match — otherwise the displayed fraction does not reconcile with what was capped.
|
|
1360
|
+
if report.get("partial"):
|
|
1361
|
+
cap = config.SEAM_MAX_IMPACT_SYMBOLS
|
|
1362
|
+
real_total = sum(1 for s in report["changed_symbols"] if not s["name"].startswith("<"))
|
|
1363
|
+
analyzed = min(cap, real_total)
|
|
1364
|
+
console.print(
|
|
1365
|
+
f"[yellow]⚠ PARTIAL[/yellow] — impact cap ({cap}) hit; "
|
|
1366
|
+
f"{analyzed} of {real_total} changed symbols analyzed"
|
|
1367
|
+
)
|
|
1368
|
+
|
|
1369
|
+
# ── Changed symbols ───────────────────────────────────────────────────────
|
|
1370
|
+
if not report["changed_symbols"] and not report["new_files"]:
|
|
1371
|
+
console.print("\n[dim]No changes detected.[/dim]")
|
|
1372
|
+
return
|
|
1373
|
+
|
|
1374
|
+
if report["new_files"]:
|
|
1375
|
+
console.print(
|
|
1376
|
+
f"\n[bold cyan]New / untracked files ({len(report['new_files'])}):[/bold cyan]"
|
|
1377
|
+
)
|
|
1378
|
+
for f in report["new_files"]:
|
|
1379
|
+
# Relativize for display
|
|
1380
|
+
try:
|
|
1381
|
+
rel = str(Path(f).relative_to(project_root))
|
|
1382
|
+
except ValueError:
|
|
1383
|
+
rel = f
|
|
1384
|
+
console.print(f" [green]+[/green] {rel}")
|
|
1385
|
+
|
|
1386
|
+
if report["changed_symbols"]:
|
|
1387
|
+
# Filter out synthetic module-level entries for cleaner display.
|
|
1388
|
+
real_syms = [s for s in report["changed_symbols"] if not s["name"].startswith("<")]
|
|
1389
|
+
module_syms = [s for s in report["changed_symbols"] if s["name"].startswith("<")]
|
|
1390
|
+
|
|
1391
|
+
if real_syms:
|
|
1392
|
+
console.print(f"\n[bold cyan]Changed symbols ({len(real_syms)}):[/bold cyan]")
|
|
1393
|
+
for sym in real_syms:
|
|
1394
|
+
try:
|
|
1395
|
+
rel_file = str(Path(sym["file"]).relative_to(project_root))
|
|
1396
|
+
except ValueError:
|
|
1397
|
+
rel_file = sym["file"]
|
|
1398
|
+
lines_str = (
|
|
1399
|
+
f" lines {sym['changed_lines'][:5]}"
|
|
1400
|
+
+ ("…" if len(sym["changed_lines"]) > 5 else "")
|
|
1401
|
+
if sym["changed_lines"]
|
|
1402
|
+
else ""
|
|
1403
|
+
)
|
|
1404
|
+
console.print(f" [bold]{sym['name']}[/bold] [dim]{rel_file}{lines_str}[/dim]")
|
|
1405
|
+
|
|
1406
|
+
if module_syms:
|
|
1407
|
+
console.print(f"\n[dim]Module-level changes ({len(module_syms)} file(s)).[/dim]")
|
|
1408
|
+
|
|
1409
|
+
# ── Affected (downstream) symbols ─────────────────────────────────────────
|
|
1410
|
+
if not report["affected"]:
|
|
1411
|
+
console.print("\n[dim]No downstream dependents found.[/dim]")
|
|
1412
|
+
return
|
|
1413
|
+
|
|
1414
|
+
tier_order = [TIER_WILL_BREAK, TIER_LIKELY_AFFECTED, TIER_MAY_NEED_TESTING]
|
|
1415
|
+
tier_labels = {
|
|
1416
|
+
TIER_WILL_BREAK: "[bold red]WILL BREAK[/bold red] (d=1)",
|
|
1417
|
+
TIER_LIKELY_AFFECTED: "[bold yellow]LIKELY AFFECTED[/bold yellow] (d=2)",
|
|
1418
|
+
TIER_MAY_NEED_TESTING: "[dim]MAY NEED TESTING[/dim] (d=3+)",
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
console.print(f"\n[bold cyan]Affected symbols ({len(report['affected'])}):[/bold cyan]")
|
|
1422
|
+
|
|
1423
|
+
# Group by tier for display.
|
|
1424
|
+
by_tier: dict[str, list[AffectedSymbol]] = {t: [] for t in tier_order}
|
|
1425
|
+
for a in report["affected"]:
|
|
1426
|
+
tier = a["tier"]
|
|
1427
|
+
if tier in by_tier:
|
|
1428
|
+
by_tier[tier].append(a)
|
|
1429
|
+
|
|
1430
|
+
for tier in tier_order:
|
|
1431
|
+
entries = by_tier[tier]
|
|
1432
|
+
if not entries:
|
|
1433
|
+
continue
|
|
1434
|
+
console.print(f"\n {tier_labels[tier]}")
|
|
1435
|
+
for entry in entries:
|
|
1436
|
+
confidence_color = {
|
|
1437
|
+
"EXTRACTED": "green",
|
|
1438
|
+
"INFERRED": "yellow",
|
|
1439
|
+
"AMBIGUOUS": "red",
|
|
1440
|
+
}.get(entry["confidence"], "white")
|
|
1441
|
+
console.print(
|
|
1442
|
+
f" [bold]{entry['name']}[/bold] "
|
|
1443
|
+
f"[{confidence_color}]{entry['confidence']}[/{confidence_color}] "
|
|
1444
|
+
f"[dim]d={entry['distance']}[/dim]"
|
|
1445
|
+
)
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
# ── seam why ──────────────────────────────────────────────────────────────────
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def _parse_why_target(target: str) -> tuple[str, int | None]:
|
|
1452
|
+
"""Parse a CLI target string into (file_path, line | None).
|
|
1453
|
+
|
|
1454
|
+
Accepts:
|
|
1455
|
+
'path/to/file.py' → ('path/to/file.py', None)
|
|
1456
|
+
'path/to/file.py:42' → ('path/to/file.py', 42)
|
|
1457
|
+
|
|
1458
|
+
Splits on the LAST ':' only. If the part after the last ':' is not a valid
|
|
1459
|
+
integer, the entire string is treated as a file path with no line.
|
|
1460
|
+
"""
|
|
1461
|
+
# Split on the last ':' to handle paths that may contain ':' (e.g. Windows drives)
|
|
1462
|
+
last_colon = target.rfind(":")
|
|
1463
|
+
if last_colon != -1:
|
|
1464
|
+
maybe_line = target[last_colon + 1 :]
|
|
1465
|
+
try:
|
|
1466
|
+
line = int(maybe_line)
|
|
1467
|
+
return target[:last_colon], line
|
|
1468
|
+
except ValueError:
|
|
1469
|
+
pass
|
|
1470
|
+
return target, None
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
@app.command(name="why")
|
|
1474
|
+
def why_cmd(
|
|
1475
|
+
target: str = typer.Argument(
|
|
1476
|
+
"",
|
|
1477
|
+
help="File path or 'file:line'. Examples: app.py, app.py:42. "
|
|
1478
|
+
"Optional when --symbol is given.",
|
|
1479
|
+
),
|
|
1480
|
+
symbol: str = typer.Option(
|
|
1481
|
+
"",
|
|
1482
|
+
"--symbol",
|
|
1483
|
+
"-s",
|
|
1484
|
+
help="Symbol name (alternative to a file target)",
|
|
1485
|
+
),
|
|
1486
|
+
path: str = typer.Option(".", "--path", help="Project root (default: current directory)"),
|
|
1487
|
+
db_dir: str = typer.Option("", "--db-dir", help="Override DB directory"),
|
|
1488
|
+
json_: bool = typer.Option(False, "--json", help="Emit structured JSON envelope to stdout."),
|
|
1489
|
+
quiet: bool = typer.Option(False, "--quiet", help="Print bare values only (one per line)."),
|
|
1490
|
+
) -> None:
|
|
1491
|
+
"""Show semantic comments (WHY/HACK/NOTE/TODO/FIXME) near a file location or symbol.
|
|
1492
|
+
|
|
1493
|
+
Examples:
|
|
1494
|
+
seam why app.py -- all semantic comments in app.py
|
|
1495
|
+
seam why app.py:42 -- comments near line 42
|
|
1496
|
+
seam why --symbol my_func -- comments inside or above my_func
|
|
1497
|
+
"""
|
|
1498
|
+
try:
|
|
1499
|
+
check_mutual_exclusion(json_=json_, quiet=quiet)
|
|
1500
|
+
except ValueError as exc:
|
|
1501
|
+
emit_json_error("INVALID_INPUT", str(exc))
|
|
1502
|
+
|
|
1503
|
+
project_root = Path(path).resolve()
|
|
1504
|
+
db_root = Path(db_dir).resolve() if db_dir else project_root
|
|
1505
|
+
db_path = config.get_db_path(db_root)
|
|
1506
|
+
|
|
1507
|
+
if not db_path.exists():
|
|
1508
|
+
if json_:
|
|
1509
|
+
emit_json_error("NO_INDEX", "No index found. Run 'seam init' first.")
|
|
1510
|
+
console.print("[red]No index found.[/red] Run [bold]seam init[/bold] first.")
|
|
1511
|
+
raise typer.Exit(code=1)
|
|
1512
|
+
|
|
1513
|
+
# Parse the positional target into file + optional line
|
|
1514
|
+
file_arg, line_arg = _parse_why_target(target)
|
|
1515
|
+
|
|
1516
|
+
# `target` is an OPTIONAL positional (default "") so `seam why --symbol foo`
|
|
1517
|
+
# works without a file. At least one of file/symbol must be provided — we
|
|
1518
|
+
# validate that below, after parsing.
|
|
1519
|
+
resolved_symbol = symbol.strip() if symbol else None
|
|
1520
|
+
|
|
1521
|
+
# Resolve the file path to absolute so it matches DB stored paths
|
|
1522
|
+
resolved_file: str | None = None
|
|
1523
|
+
if file_arg:
|
|
1524
|
+
resolved_file = str((project_root / file_arg).resolve())
|
|
1525
|
+
|
|
1526
|
+
# If neither resolved_file nor resolved_symbol is set after parsing, error out
|
|
1527
|
+
if not resolved_file and not resolved_symbol:
|
|
1528
|
+
if json_:
|
|
1529
|
+
emit_json_error("INVALID_INPUT", "Provide a file path or --symbol.")
|
|
1530
|
+
console.print("[red]Error:[/red] Provide a file path or --symbol.")
|
|
1531
|
+
raise typer.Exit(code=1)
|
|
1532
|
+
|
|
1533
|
+
try:
|
|
1534
|
+
conn = connect(db_path)
|
|
1535
|
+
except sqlite3.Error as exc:
|
|
1536
|
+
if json_:
|
|
1537
|
+
emit_json_error("DB_ERROR", f"Failed to open database: {exc}")
|
|
1538
|
+
console.print(f"[red]Failed to open database:[/red] {exc}")
|
|
1539
|
+
raise typer.Exit(code=1) from exc
|
|
1540
|
+
|
|
1541
|
+
# WHY `Any`: handle_seam_why returns list[dict[str,Any]] | dict[str,Any];
|
|
1542
|
+
# comments_why returns list[CommentHit]. Both are accessed with same string
|
|
1543
|
+
# keys at runtime; `Any` is the honest annotation to unify them for mypy.
|
|
1544
|
+
hits: Any
|
|
1545
|
+
try:
|
|
1546
|
+
# WHY: reuse handle_seam_why for --json/--quiet (MCP/CLI parity).
|
|
1547
|
+
if json_ or quiet:
|
|
1548
|
+
hits = handle_seam_why(
|
|
1549
|
+
conn,
|
|
1550
|
+
root=project_root,
|
|
1551
|
+
file=file_arg if file_arg else None,
|
|
1552
|
+
line=line_arg,
|
|
1553
|
+
symbol=resolved_symbol,
|
|
1554
|
+
)
|
|
1555
|
+
else:
|
|
1556
|
+
hits = comments_why(
|
|
1557
|
+
conn,
|
|
1558
|
+
file=resolved_file,
|
|
1559
|
+
line=line_arg,
|
|
1560
|
+
symbol=resolved_symbol,
|
|
1561
|
+
)
|
|
1562
|
+
finally:
|
|
1563
|
+
conn.close()
|
|
1564
|
+
|
|
1565
|
+
# ── JSON mode ─────────────────────────────────────────────────────────────
|
|
1566
|
+
if json_:
|
|
1567
|
+
emit_json(hits)
|
|
1568
|
+
return
|
|
1569
|
+
|
|
1570
|
+
# ── Quiet mode — print marker: text per hit ───────────────────────────────
|
|
1571
|
+
if quiet:
|
|
1572
|
+
for hit in hits:
|
|
1573
|
+
sys.stdout.write(f"{hit['marker']}: {hit['text']}\n")
|
|
1574
|
+
return
|
|
1575
|
+
|
|
1576
|
+
# ── Rich (default) mode — existing rendering, unchanged ──────────────────
|
|
1577
|
+
|
|
1578
|
+
if not hits:
|
|
1579
|
+
console.print("[dim]No semantic comments found[/dim]")
|
|
1580
|
+
return
|
|
1581
|
+
|
|
1582
|
+
# Render results: marker line text (file is context, usually already known)
|
|
1583
|
+
for hit in hits:
|
|
1584
|
+
# Relativize path for display
|
|
1585
|
+
try:
|
|
1586
|
+
rel_file = str(Path(hit["file"]).relative_to(project_root))
|
|
1587
|
+
except ValueError:
|
|
1588
|
+
rel_file = hit["file"]
|
|
1589
|
+
|
|
1590
|
+
marker_color = {
|
|
1591
|
+
"WHY": "cyan",
|
|
1592
|
+
"HACK": "yellow",
|
|
1593
|
+
"NOTE": "blue",
|
|
1594
|
+
"TODO": "green",
|
|
1595
|
+
"FIXME": "red",
|
|
1596
|
+
}.get(hit["marker"], "white")
|
|
1597
|
+
|
|
1598
|
+
console.print(
|
|
1599
|
+
f"[{marker_color}]{hit['marker']}[/{marker_color}]"
|
|
1600
|
+
f" [dim]line {hit['line']}[/dim]"
|
|
1601
|
+
f" [dim]{rel_file}[/dim]"
|
|
1602
|
+
f" {hit['text']}"
|
|
1603
|
+
)
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
# ── seam clusters ─────────────────────────────────────────────────────────────
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
@app.command(name="clusters")
|
|
1610
|
+
def clusters_cmd(
|
|
1611
|
+
cluster_id: int = typer.Option(
|
|
1612
|
+
-1,
|
|
1613
|
+
"--id",
|
|
1614
|
+
help="Cluster ID to list members of. Omit to list all clusters.",
|
|
1615
|
+
),
|
|
1616
|
+
path: str = typer.Option(".", "--path", help="Project root (default: current directory)"),
|
|
1617
|
+
db_dir: str = typer.Option("", "--db-dir", help="Override DB directory"),
|
|
1618
|
+
json_: bool = typer.Option(False, "--json", help="Emit structured JSON envelope to stdout."),
|
|
1619
|
+
quiet: bool = typer.Option(False, "--quiet", help="Print bare values only (one per line)."),
|
|
1620
|
+
) -> None:
|
|
1621
|
+
"""List all clusters or members of one cluster.
|
|
1622
|
+
|
|
1623
|
+
Examples:
|
|
1624
|
+
seam clusters -- list all clusters with id, label, size
|
|
1625
|
+
seam clusters --id 3 -- list all member symbols of cluster 3
|
|
1626
|
+
"""
|
|
1627
|
+
try:
|
|
1628
|
+
check_mutual_exclusion(json_=json_, quiet=quiet)
|
|
1629
|
+
except ValueError as exc:
|
|
1630
|
+
emit_json_error("INVALID_INPUT", str(exc))
|
|
1631
|
+
|
|
1632
|
+
project_root = Path(path).resolve()
|
|
1633
|
+
db_root = Path(db_dir).resolve() if db_dir else project_root
|
|
1634
|
+
db_path = config.get_db_path(db_root)
|
|
1635
|
+
|
|
1636
|
+
if not db_path.exists():
|
|
1637
|
+
if json_:
|
|
1638
|
+
emit_json_error("NO_INDEX", "No index found. Run 'seam init' first.")
|
|
1639
|
+
console.print("[red]No index found.[/red] Run [bold]seam init[/bold] first.")
|
|
1640
|
+
raise typer.Exit(code=1)
|
|
1641
|
+
|
|
1642
|
+
try:
|
|
1643
|
+
conn = connect(db_path)
|
|
1644
|
+
except sqlite3.Error as exc:
|
|
1645
|
+
if json_:
|
|
1646
|
+
emit_json_error("DB_ERROR", f"Failed to open database: {exc}")
|
|
1647
|
+
console.print(f"[red]Failed to open database:[/red] {exc}")
|
|
1648
|
+
raise typer.Exit(code=1) from exc
|
|
1649
|
+
|
|
1650
|
+
try:
|
|
1651
|
+
# WHY: reuse handle_seam_clusters for --json/--quiet (MCP/CLI parity).
|
|
1652
|
+
if json_ or quiet:
|
|
1653
|
+
cid = cluster_id if cluster_id >= 0 else None
|
|
1654
|
+
cluster_data = handle_seam_clusters(conn, root=project_root, cluster_id=cid)
|
|
1655
|
+
else:
|
|
1656
|
+
if cluster_id >= 0:
|
|
1657
|
+
members = query_cluster_members(conn, cluster_id)
|
|
1658
|
+
else:
|
|
1659
|
+
members = None
|
|
1660
|
+
clusters = query_list_clusters(conn)
|
|
1661
|
+
finally:
|
|
1662
|
+
conn.close()
|
|
1663
|
+
|
|
1664
|
+
# ── JSON mode ─────────────────────────────────────────────────────────────
|
|
1665
|
+
if json_:
|
|
1666
|
+
emit_json(cluster_data)
|
|
1667
|
+
return
|
|
1668
|
+
|
|
1669
|
+
# ── Quiet mode — print labels (cluster list) or names (member list) ───────
|
|
1670
|
+
if quiet:
|
|
1671
|
+
if cluster_id >= 0:
|
|
1672
|
+
# Quiet for members: print symbol names
|
|
1673
|
+
for item in cluster_data:
|
|
1674
|
+
sys.stdout.write(item["name"] + "\n")
|
|
1675
|
+
else:
|
|
1676
|
+
# Quiet for cluster list: print labels
|
|
1677
|
+
for item in cluster_data:
|
|
1678
|
+
sys.stdout.write(item["label"] + "\n")
|
|
1679
|
+
return
|
|
1680
|
+
|
|
1681
|
+
# ── Rich (default) mode — existing rendering, unchanged ──────────────────
|
|
1682
|
+
|
|
1683
|
+
if cluster_id >= 0:
|
|
1684
|
+
# Display members
|
|
1685
|
+
if not members:
|
|
1686
|
+
console.print(
|
|
1687
|
+
f"[yellow]No members found for cluster {cluster_id}[/yellow] "
|
|
1688
|
+
"— check the ID or run 'seam init' first."
|
|
1689
|
+
)
|
|
1690
|
+
return
|
|
1691
|
+
|
|
1692
|
+
table = Table(title=f"Cluster {cluster_id} members", show_header=True)
|
|
1693
|
+
table.add_column("name", style="bold")
|
|
1694
|
+
table.add_column("kind", style="dim")
|
|
1695
|
+
table.add_column("file", style="dim")
|
|
1696
|
+
table.add_column("line", style="dim")
|
|
1697
|
+
for m in members:
|
|
1698
|
+
# Relativize file path for display
|
|
1699
|
+
try:
|
|
1700
|
+
rel_file = str(Path(m["file"]).relative_to(project_root))
|
|
1701
|
+
except ValueError:
|
|
1702
|
+
rel_file = m["file"]
|
|
1703
|
+
table.add_row(m["name"], m["kind"], rel_file, str(m["line"]))
|
|
1704
|
+
console.print(table)
|
|
1705
|
+
else:
|
|
1706
|
+
# Display all clusters
|
|
1707
|
+
if not clusters:
|
|
1708
|
+
console.print(
|
|
1709
|
+
"[yellow]No clusters found.[/yellow] "
|
|
1710
|
+
"Run [bold]seam init[/bold] to compute clusters."
|
|
1711
|
+
)
|
|
1712
|
+
return
|
|
1713
|
+
|
|
1714
|
+
table = Table(title="Clusters", show_header=True)
|
|
1715
|
+
table.add_column("id", style="bold cyan", width=6)
|
|
1716
|
+
table.add_column("label")
|
|
1717
|
+
table.add_column("size", style="dim", width=6)
|
|
1718
|
+
for c in clusters:
|
|
1719
|
+
table.add_row(str(c["id"]), c["label"], str(c["size"]))
|
|
1720
|
+
console.print(table)
|
|
1721
|
+
|
|
1722
|
+
|
|
1723
|
+
# ── seam flows ────────────────────────────────────────────────────────────────
|
|
1724
|
+
|
|
1725
|
+
|
|
1726
|
+
def _print_flow_tree(steps: list[Any], prefix: str = "") -> None:
|
|
1727
|
+
"""Render a flow's step tree as an indented ├─/└─ tree (Rich mode)."""
|
|
1728
|
+
for i, step in enumerate(steps):
|
|
1729
|
+
last = i == len(steps) - 1
|
|
1730
|
+
branch = "└─ " if last else "├─ "
|
|
1731
|
+
mark = " [yellow]…[/yellow]" if step["truncated"] else ""
|
|
1732
|
+
console.print(f"{prefix}{branch}{step['name']} [dim]({step['kind'] or '?'})[/dim]{mark}")
|
|
1733
|
+
_print_flow_tree(step["children"], prefix + (" " if last else "│ "))
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
@app.command(name="flows")
|
|
1737
|
+
def flows_cmd(
|
|
1738
|
+
entry: str = typer.Argument(
|
|
1739
|
+
default="",
|
|
1740
|
+
help="Entry-point symbol to expand. Omit to list all entry points.",
|
|
1741
|
+
),
|
|
1742
|
+
path: str = typer.Option(".", "--path", help="Project root (default: current directory)"),
|
|
1743
|
+
db_dir: str = typer.Option("", "--db-dir", help="Override DB directory"),
|
|
1744
|
+
json_: bool = typer.Option(False, "--json", help="Emit structured JSON envelope to stdout."),
|
|
1745
|
+
quiet: bool = typer.Option(False, "--quiet", help="Print bare values only (one per line)."),
|
|
1746
|
+
) -> None:
|
|
1747
|
+
"""List execution entry points, or expand one entry point's flow.
|
|
1748
|
+
|
|
1749
|
+
Examples:
|
|
1750
|
+
seam flows -- list entry points (call-graph roots, by reach)
|
|
1751
|
+
seam flows init_db -- expand the flow rooted at init_db
|
|
1752
|
+
"""
|
|
1753
|
+
try:
|
|
1754
|
+
check_mutual_exclusion(json_=json_, quiet=quiet)
|
|
1755
|
+
except ValueError as exc:
|
|
1756
|
+
emit_json_error("INVALID_INPUT", str(exc))
|
|
1757
|
+
|
|
1758
|
+
project_root = Path(path).resolve()
|
|
1759
|
+
db_root = Path(db_dir).resolve() if db_dir else project_root
|
|
1760
|
+
db_path = config.get_db_path(db_root)
|
|
1761
|
+
|
|
1762
|
+
if not db_path.exists():
|
|
1763
|
+
if json_:
|
|
1764
|
+
emit_json_error("NO_INDEX", "No index found. Run 'seam init' first.")
|
|
1765
|
+
console.print("[red]No index found.[/red] Run [bold]seam init[/bold] first.")
|
|
1766
|
+
raise typer.Exit(code=1)
|
|
1767
|
+
|
|
1768
|
+
try:
|
|
1769
|
+
conn = connect(db_path)
|
|
1770
|
+
except sqlite3.Error as exc:
|
|
1771
|
+
if json_:
|
|
1772
|
+
emit_json_error("DB_ERROR", f"Failed to open database: {exc}")
|
|
1773
|
+
console.print(f"[red]Failed to open database:[/red] {exc}")
|
|
1774
|
+
raise typer.Exit(code=1) from exc
|
|
1775
|
+
|
|
1776
|
+
# WHY: reuse handle_seam_flows for all modes (MCP/CLI parity). None == unknown entry.
|
|
1777
|
+
entry_arg = entry.strip() or None
|
|
1778
|
+
try:
|
|
1779
|
+
result = handle_seam_flows(conn, root=project_root, entry=entry_arg)
|
|
1780
|
+
finally:
|
|
1781
|
+
conn.close()
|
|
1782
|
+
|
|
1783
|
+
# ── JSON mode — structured envelope; unknown entry mirrors not-found contract.
|
|
1784
|
+
if json_:
|
|
1785
|
+
emit_json(result if result is not None else {"found": False})
|
|
1786
|
+
return
|
|
1787
|
+
|
|
1788
|
+
# ── List mode (no entry) — the entry points ───────────────────────────────
|
|
1789
|
+
if entry_arg is None:
|
|
1790
|
+
assert result is not None # list mode always returns {"entry_points": [...]}
|
|
1791
|
+
points = result["entry_points"] # type: ignore[typeddict-item]
|
|
1792
|
+
if quiet:
|
|
1793
|
+
for p in points:
|
|
1794
|
+
sys.stdout.write(p["name"] + "\n")
|
|
1795
|
+
return
|
|
1796
|
+
if not points:
|
|
1797
|
+
console.print(
|
|
1798
|
+
"[yellow]No entry points found.[/yellow] Run [bold]seam init[/bold] first."
|
|
1799
|
+
)
|
|
1800
|
+
return
|
|
1801
|
+
table = Table(title="Execution entry points", show_header=True)
|
|
1802
|
+
table.add_column("entry", style="bold")
|
|
1803
|
+
table.add_column("kind", style="dim")
|
|
1804
|
+
table.add_column("reach", style="cyan", width=7)
|
|
1805
|
+
table.add_column("file", style="dim")
|
|
1806
|
+
for p in points:
|
|
1807
|
+
table.add_row(p["name"], p["kind"] or "", str(p["reach"]), p["file"] or "")
|
|
1808
|
+
console.print(table)
|
|
1809
|
+
return
|
|
1810
|
+
|
|
1811
|
+
# ── Drill mode (one entry) — the flow tree ────────────────────────────────
|
|
1812
|
+
if result is None:
|
|
1813
|
+
if not quiet:
|
|
1814
|
+
console.print(
|
|
1815
|
+
f"[yellow]No flow found for[/yellow] [bold]{entry_arg}[/bold] — unknown symbol."
|
|
1816
|
+
)
|
|
1817
|
+
return
|
|
1818
|
+
|
|
1819
|
+
if quiet:
|
|
1820
|
+
# Flatten: print every step name, depth-first, one per line.
|
|
1821
|
+
stack = list(reversed(result["steps"]))
|
|
1822
|
+
while stack:
|
|
1823
|
+
step = stack.pop()
|
|
1824
|
+
sys.stdout.write(step["name"] + "\n")
|
|
1825
|
+
stack.extend(reversed(step["children"]))
|
|
1826
|
+
return
|
|
1827
|
+
|
|
1828
|
+
console.print(
|
|
1829
|
+
f"[bold]{result['entry']}[/bold] "
|
|
1830
|
+
f"[dim]({result['kind'] or '?'} · {result['file'] or '?'})[/dim]"
|
|
1831
|
+
)
|
|
1832
|
+
footer = " · [yellow]truncated[/yellow]" if result["truncated"] else ""
|
|
1833
|
+
console.print(f"[dim]{result['total_steps']} steps{footer}[/dim]")
|
|
1834
|
+
_print_flow_tree(result["steps"])
|
|
1835
|
+
|
|
1836
|
+
|
|
1837
|
+
# ── seam structure ────────────────────────────────────────────────────────────
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
def _render_structure_quiet(node: StructureNode, depth: int = 0) -> None:
|
|
1841
|
+
"""Render the structure tree as an indented plain-text tree (quiet mode).
|
|
1842
|
+
|
|
1843
|
+
WHY a separate helper: keeps the command body readable and matches the
|
|
1844
|
+
_print_flow_tree pattern established by seam flows.
|
|
1845
|
+
|
|
1846
|
+
Slice 2: file and dir nodes include their 'area' label when present.
|
|
1847
|
+
"""
|
|
1848
|
+
indent = " " * depth
|
|
1849
|
+
kind = node["kind"]
|
|
1850
|
+
name = node["name"]
|
|
1851
|
+
path = node.get("path") or ""
|
|
1852
|
+
sym = node.get("symbol_count", 0)
|
|
1853
|
+
members = node.get("members", 0)
|
|
1854
|
+
area = node.get("area")
|
|
1855
|
+
|
|
1856
|
+
# Optional area suffix — shown for dir and file nodes when area is set.
|
|
1857
|
+
area_suffix = f" [{area}]" if area else ""
|
|
1858
|
+
|
|
1859
|
+
# One line per node: indent + kind marker + name + counts + optional area
|
|
1860
|
+
if kind == "dir":
|
|
1861
|
+
sys.stdout.write(f"{indent}[{kind}] {name}/ ({sym} symbols){area_suffix}\n")
|
|
1862
|
+
elif kind == "file":
|
|
1863
|
+
sys.stdout.write(f"{indent}[{kind}] {path} ({sym} symbols){area_suffix}\n")
|
|
1864
|
+
elif kind == "container":
|
|
1865
|
+
sys.stdout.write(f"{indent}[{kind}] {name} ({members} members)\n")
|
|
1866
|
+
else:
|
|
1867
|
+
sys.stdout.write(f"{indent}[{kind}] {name}\n")
|
|
1868
|
+
|
|
1869
|
+
for child in node.get("children", []):
|
|
1870
|
+
_render_structure_quiet(child, depth + 1)
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
def _render_structure_rich(node: StructureNode, prefix: str = "", is_root: bool = True) -> None:
|
|
1874
|
+
"""Render the structure tree with Rich ├─/└─ branch chars + colour (default mode).
|
|
1875
|
+
|
|
1876
|
+
WHY separate from _render_structure_quiet: the quiet helper is plain-text for
|
|
1877
|
+
pipes/scripts; this one mirrors _print_flow_tree (seam flows) so the interactive
|
|
1878
|
+
default view gets branch glyphs and colour, matching the project's CLI convention.
|
|
1879
|
+
The root node prints flush-left; children recurse under ├─/└─ connectors.
|
|
1880
|
+
"""
|
|
1881
|
+
area = node.get("area")
|
|
1882
|
+
area_suffix = f" [dim]\\[{area}][/dim]" if area else ""
|
|
1883
|
+
kind = node["kind"]
|
|
1884
|
+
|
|
1885
|
+
if is_root:
|
|
1886
|
+
sym = node.get("symbol_count", 0)
|
|
1887
|
+
console.print(
|
|
1888
|
+
f"[bold cyan]{node['name']}/[/bold cyan] [dim]({sym} symbols)[/dim]{area_suffix}"
|
|
1889
|
+
)
|
|
1890
|
+
else:
|
|
1891
|
+
# Connector is supplied by the parent via `prefix`; render this node's label.
|
|
1892
|
+
if kind == "dir":
|
|
1893
|
+
label = f"[cyan]{node['name']}/[/cyan] [dim]({node.get('symbol_count', 0)} symbols)[/dim]{area_suffix}"
|
|
1894
|
+
elif kind == "file":
|
|
1895
|
+
label = (
|
|
1896
|
+
f"{node['name']} [dim]({node.get('symbol_count', 0)} symbols)[/dim]{area_suffix}"
|
|
1897
|
+
)
|
|
1898
|
+
elif kind == "container":
|
|
1899
|
+
label = f"[green]{node['name']}[/green] [dim]({node.get('members', 0)} members)[/dim]"
|
|
1900
|
+
else: # function
|
|
1901
|
+
label = f"[yellow]{node['name']}[/yellow]"
|
|
1902
|
+
console.print(f"{prefix}{label}")
|
|
1903
|
+
|
|
1904
|
+
children = node.get("children", [])
|
|
1905
|
+
# Child indent: root's children start fresh; deeper levels extend the parent prefix.
|
|
1906
|
+
child_base = "" if is_root else prefix.replace("├─ ", "│ ").replace("└─ ", " ")
|
|
1907
|
+
for i, child in enumerate(children):
|
|
1908
|
+
last = i == len(children) - 1
|
|
1909
|
+
connector = "└─ " if last else "├─ "
|
|
1910
|
+
_render_structure_rich(child, child_base + connector, is_root=False)
|
|
1911
|
+
|
|
1912
|
+
|
|
1913
|
+
@app.command(name="structure")
|
|
1914
|
+
def structure_cmd(
|
|
1915
|
+
path: str = typer.Argument(".", help="Project root to inspect (default: current directory)"),
|
|
1916
|
+
db_dir: str = typer.Option("", "--db-dir", help="Override DB directory"),
|
|
1917
|
+
scope: str = typer.Option(
|
|
1918
|
+
"",
|
|
1919
|
+
"--scope",
|
|
1920
|
+
help=(
|
|
1921
|
+
"Scope the tree to a subdirectory (absolute or relative path). "
|
|
1922
|
+
"Only files under this path are included. Unknown paths yield an empty tree."
|
|
1923
|
+
),
|
|
1924
|
+
),
|
|
1925
|
+
depth: int = typer.Option(
|
|
1926
|
+
-1,
|
|
1927
|
+
"--depth",
|
|
1928
|
+
help=(
|
|
1929
|
+
"Maximum nesting depth of the tree (root=0). "
|
|
1930
|
+
"Nodes beyond this depth are omitted and counted in 'truncated'. "
|
|
1931
|
+
"Default: use SEAM_STRUCTURE_MAX_DEPTH (8)."
|
|
1932
|
+
),
|
|
1933
|
+
),
|
|
1934
|
+
symbols: bool = typer.Option(
|
|
1935
|
+
False,
|
|
1936
|
+
"--symbols",
|
|
1937
|
+
help=(
|
|
1938
|
+
"Also list standalone module-level functions under each file. "
|
|
1939
|
+
"Default off = compact module/area overview (dirs, files, classes only)."
|
|
1940
|
+
),
|
|
1941
|
+
),
|
|
1942
|
+
json_: bool = typer.Option(False, "--json", help="Emit structured JSON envelope to stdout."),
|
|
1943
|
+
quiet: bool = typer.Option(
|
|
1944
|
+
False,
|
|
1945
|
+
"--quiet",
|
|
1946
|
+
help="Print indented tree (one node per line) without the JSON envelope.",
|
|
1947
|
+
),
|
|
1948
|
+
) -> None:
|
|
1949
|
+
"""Show the whole-repository directory/file/container structure tree.
|
|
1950
|
+
|
|
1951
|
+
Reads the index and renders a compact skeleton of the codebase:
|
|
1952
|
+
- directory nodes (dir)
|
|
1953
|
+
- file nodes with symbol counts
|
|
1954
|
+
- container nodes (class/interface/type) with member counts
|
|
1955
|
+
- top-level function nodes
|
|
1956
|
+
|
|
1957
|
+
Examples:
|
|
1958
|
+
seam structure -- Rich indented tree (default)
|
|
1959
|
+
seam structure --json -- structured JSON envelope
|
|
1960
|
+
seam structure --quiet -- plain indented text (one node per line)
|
|
1961
|
+
seam structure /path/to/repo -- inspect a specific project
|
|
1962
|
+
seam structure --scope src/ -- scope to the src/ subdirectory
|
|
1963
|
+
seam structure --depth 3 -- limit tree to 3 nesting levels
|
|
1964
|
+
"""
|
|
1965
|
+
try:
|
|
1966
|
+
check_mutual_exclusion(json_=json_, quiet=quiet)
|
|
1967
|
+
except ValueError as exc:
|
|
1968
|
+
emit_json_error("INVALID_INPUT", str(exc))
|
|
1969
|
+
|
|
1970
|
+
project_root = Path(path).resolve()
|
|
1971
|
+
db_root = Path(db_dir).resolve() if db_dir else project_root
|
|
1972
|
+
db_path = config.get_db_path(db_root)
|
|
1973
|
+
|
|
1974
|
+
if not db_path.exists():
|
|
1975
|
+
if json_:
|
|
1976
|
+
emit_json_error("NO_INDEX", "No index found. Run 'seam init' first.")
|
|
1977
|
+
console.print("[red]No index found.[/red] Run [bold]seam init[/bold] first.")
|
|
1978
|
+
raise typer.Exit(code=1)
|
|
1979
|
+
|
|
1980
|
+
try:
|
|
1981
|
+
conn = connect(db_path)
|
|
1982
|
+
except sqlite3.Error as exc:
|
|
1983
|
+
if json_:
|
|
1984
|
+
emit_json_error("DB_ERROR", f"Failed to open database: {exc}")
|
|
1985
|
+
console.print(f"[red]Failed to open database:[/red] {exc}")
|
|
1986
|
+
raise typer.Exit(code=1) from exc
|
|
1987
|
+
|
|
1988
|
+
# Slice 3: optional scope path + depth override. scope="" means no scoping;
|
|
1989
|
+
# depth=-1 means use the config default. Pass the scope UNRESOLVED — build_structure
|
|
1990
|
+
# resolves a relative scope against the project root (NOT cwd), so
|
|
1991
|
+
# `seam structure /repo --scope src/` correctly means "/repo/src", not "$CWD/src".
|
|
1992
|
+
scope_path: Path | None = Path(scope) if scope else None
|
|
1993
|
+
max_depth_arg: int | None = depth if depth >= 0 else None
|
|
1994
|
+
|
|
1995
|
+
try:
|
|
1996
|
+
result = handle_seam_structure(
|
|
1997
|
+
conn,
|
|
1998
|
+
root=project_root,
|
|
1999
|
+
path=scope_path,
|
|
2000
|
+
max_depth=max_depth_arg,
|
|
2001
|
+
include_functions=symbols,
|
|
2002
|
+
)
|
|
2003
|
+
finally:
|
|
2004
|
+
conn.close()
|
|
2005
|
+
|
|
2006
|
+
# ── JSON mode ─────────────────────────────────────────────────────────────
|
|
2007
|
+
if json_:
|
|
2008
|
+
emit_json(result)
|
|
2009
|
+
return
|
|
2010
|
+
|
|
2011
|
+
# ── Quiet mode — indented plain-text tree ─────────────────────────────────
|
|
2012
|
+
if quiet:
|
|
2013
|
+
_render_structure_quiet(result["tree"])
|
|
2014
|
+
if result.get("truncated", 0) > 0:
|
|
2015
|
+
sys.stdout.write(f"[truncated: {result['truncated']} nodes omitted]\n")
|
|
2016
|
+
return
|
|
2017
|
+
|
|
2018
|
+
# ── Rich (default) mode — indented Rich tree with branch glyphs + colour ──
|
|
2019
|
+
tree = result["tree"]
|
|
2020
|
+
total = tree.get("symbol_count", 0)
|
|
2021
|
+
console.print(f"\n[dim]Repository structure ({total} total symbols)[/dim]")
|
|
2022
|
+
_render_structure_rich(tree)
|
|
2023
|
+
if result.get("truncated", 0) > 0:
|
|
2024
|
+
console.print(
|
|
2025
|
+
f"[dim yellow]({result['truncated']} nodes omitted by depth/node caps)[/dim yellow]"
|
|
2026
|
+
)
|
|
2027
|
+
|
|
2028
|
+
|
|
2029
|
+
# ── seam affected ─────────────────────────────────────────────────────────────
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
@app.command(name="affected")
|
|
2033
|
+
def affected_cmd(
|
|
2034
|
+
files: list[str] = typer.Argument(
|
|
2035
|
+
default=None,
|
|
2036
|
+
help="Changed file paths to analyze. Mutually exclusive with --stdin.",
|
|
2037
|
+
),
|
|
2038
|
+
stdin: bool = typer.Option(
|
|
2039
|
+
False,
|
|
2040
|
+
"--stdin",
|
|
2041
|
+
help=(
|
|
2042
|
+
"Read changed file paths from stdin (newline-delimited). "
|
|
2043
|
+
"Mutually exclusive with positional file arguments."
|
|
2044
|
+
),
|
|
2045
|
+
),
|
|
2046
|
+
depth: int = typer.Option(
|
|
2047
|
+
config.SEAM_AFFECTED_DEPTH,
|
|
2048
|
+
"--depth",
|
|
2049
|
+
help="Max upstream traversal depth (default: SEAM_AFFECTED_DEPTH env var).",
|
|
2050
|
+
),
|
|
2051
|
+
path: str = typer.Option(".", "--path", help="Project root (default: current directory)"),
|
|
2052
|
+
db_dir: str = typer.Option("", "--db-dir", help="Override DB directory"),
|
|
2053
|
+
json_: bool = typer.Option(False, "--json", help="Emit structured JSON envelope to stdout."),
|
|
2054
|
+
quiet: bool = typer.Option(
|
|
2055
|
+
False,
|
|
2056
|
+
"--quiet",
|
|
2057
|
+
help="Print bare test-file paths one per line (for piping into pytest).",
|
|
2058
|
+
),
|
|
2059
|
+
) -> None:
|
|
2060
|
+
"""Find which test files are impacted by changed source files.
|
|
2061
|
+
|
|
2062
|
+
Given changed files (as positional args or via --stdin), traverses the reverse-
|
|
2063
|
+
dependency graph to find all test files that depend on symbols in those files.
|
|
2064
|
+
|
|
2065
|
+
Examples:
|
|
2066
|
+
seam affected src/foo.py src/bar.py --json
|
|
2067
|
+
git diff --name-only | seam affected --stdin --quiet | xargs pytest
|
|
2068
|
+
seam affected src/foo.py --quiet # bare test paths, one per line
|
|
2069
|
+
|
|
2070
|
+
A changed file that is itself a test file is always included in the output.
|
|
2071
|
+
Files not in the index are silently skipped.
|
|
2072
|
+
"""
|
|
2073
|
+
# Mutual exclusion check (--json + --quiet is not allowed)
|
|
2074
|
+
try:
|
|
2075
|
+
check_mutual_exclusion(json_=json_, quiet=quiet)
|
|
2076
|
+
except ValueError as exc:
|
|
2077
|
+
emit_json_error("INVALID_INPUT", str(exc))
|
|
2078
|
+
|
|
2079
|
+
project_root = Path(path).resolve()
|
|
2080
|
+
db_root = Path(db_dir).resolve() if db_dir else project_root
|
|
2081
|
+
db_path = config.get_db_path(db_root)
|
|
2082
|
+
|
|
2083
|
+
if not db_path.exists():
|
|
2084
|
+
if json_:
|
|
2085
|
+
emit_json_error("NO_INDEX", "No index found. Run 'seam init' first.")
|
|
2086
|
+
console.print("[red]No index found.[/red] Run [bold]seam init[/bold] first.")
|
|
2087
|
+
raise typer.Exit(code=1)
|
|
2088
|
+
|
|
2089
|
+
# ── Determine input file list ─────────────────────────────────────────────
|
|
2090
|
+
# Enforce mutual exclusion explicitly — if both are given, error cleanly rather
|
|
2091
|
+
# than silently discarding the positional args (which would confuse the caller
|
|
2092
|
+
# into thinking their file list was analyzed when it was not).
|
|
2093
|
+
if stdin and files:
|
|
2094
|
+
if json_:
|
|
2095
|
+
emit_json_error(
|
|
2096
|
+
"INVALID_INPUT",
|
|
2097
|
+
"Provide file paths as positional arguments OR use --stdin, not both.",
|
|
2098
|
+
)
|
|
2099
|
+
console.print(
|
|
2100
|
+
"[red]Error:[/red] Use positional arguments OR [bold]--stdin[/bold], not both."
|
|
2101
|
+
)
|
|
2102
|
+
raise typer.Exit(code=1)
|
|
2103
|
+
|
|
2104
|
+
if stdin:
|
|
2105
|
+
raw_lines = sys.stdin.read().splitlines()
|
|
2106
|
+
input_files = [ln.strip() for ln in raw_lines if ln.strip()]
|
|
2107
|
+
else:
|
|
2108
|
+
input_files = list(files) if files else []
|
|
2109
|
+
|
|
2110
|
+
if not input_files:
|
|
2111
|
+
if json_:
|
|
2112
|
+
emit_json_error(
|
|
2113
|
+
"INVALID_INPUT",
|
|
2114
|
+
"Provide file paths as arguments or use --stdin to read from stdin.",
|
|
2115
|
+
)
|
|
2116
|
+
console.print(
|
|
2117
|
+
"[red]Error:[/red] Provide file paths as arguments or use [bold]--stdin[/bold]."
|
|
2118
|
+
)
|
|
2119
|
+
raise typer.Exit(code=1)
|
|
2120
|
+
|
|
2121
|
+
try:
|
|
2122
|
+
conn = connect(db_path)
|
|
2123
|
+
except sqlite3.Error as exc:
|
|
2124
|
+
if json_:
|
|
2125
|
+
emit_json_error("DB_ERROR", f"Failed to open database: {exc}")
|
|
2126
|
+
console.print(f"[red]Failed to open database:[/red] {exc}")
|
|
2127
|
+
raise typer.Exit(code=1) from exc
|
|
2128
|
+
|
|
2129
|
+
try:
|
|
2130
|
+
# Always route through handle_seam_affected so CLI output is byte-identical to
|
|
2131
|
+
# the MCP tool response (MCP/CLI parity). The handler also relativizes paths,
|
|
2132
|
+
# applies the file-list size cap, and returns a structured error dict on INVALID_INPUT
|
|
2133
|
+
# rather than raising — which the error guard below relies on.
|
|
2134
|
+
result = handle_seam_affected(conn, input_files, project_root, depth=depth)
|
|
2135
|
+
finally:
|
|
2136
|
+
conn.close()
|
|
2137
|
+
|
|
2138
|
+
# ── Error guard — applies to ALL output modes ─────────────────────────────
|
|
2139
|
+
# handle_seam_affected returns {"error": ..., "message": ...} on INVALID_INPUT.
|
|
2140
|
+
# Without this guard, --quiet and rich modes silently degrade to "No affected test
|
|
2141
|
+
# files found" even though the handler actually errored (e.g. oversized file list).
|
|
2142
|
+
if isinstance(result, dict) and result.get("error"):
|
|
2143
|
+
if json_:
|
|
2144
|
+
emit_json_error(result["error"], result.get("message", ""))
|
|
2145
|
+
# --quiet and rich: write error to stderr and exit non-zero
|
|
2146
|
+
sys.stderr.write(f"Error [{result['error']}]: {result.get('message', '')}\n")
|
|
2147
|
+
raise typer.Exit(code=1)
|
|
2148
|
+
|
|
2149
|
+
# ── JSON mode ─────────────────────────────────────────────────────────────
|
|
2150
|
+
if json_:
|
|
2151
|
+
emit_json(result)
|
|
2152
|
+
return
|
|
2153
|
+
|
|
2154
|
+
# ── Quiet mode — bare test-file paths for piping into pytest ─────────────
|
|
2155
|
+
if quiet:
|
|
2156
|
+
for test_path in result.get("affected_tests", []):
|
|
2157
|
+
sys.stdout.write(test_path + "\n")
|
|
2158
|
+
return
|
|
2159
|
+
|
|
2160
|
+
# ── Rich (default) mode ───────────────────────────────────────────────────
|
|
2161
|
+
affected_tests = result.get("affected_tests", [])
|
|
2162
|
+
total = result.get("total_dependents_traversed", 0)
|
|
2163
|
+
|
|
2164
|
+
if not affected_tests:
|
|
2165
|
+
console.print(
|
|
2166
|
+
f"[dim]No affected test files found.[/dim] [dim]({total} dependent(s) traversed)[/dim]"
|
|
2167
|
+
)
|
|
2168
|
+
return
|
|
2169
|
+
|
|
2170
|
+
console.print(
|
|
2171
|
+
f"\n[bold cyan]Affected tests[/bold cyan] "
|
|
2172
|
+
f"for [bold]{len(input_files)}[/bold] changed file(s) "
|
|
2173
|
+
f"[dim]({total} dependent(s) traversed):[/dim]"
|
|
2174
|
+
)
|
|
2175
|
+
for test_path in affected_tests:
|
|
2176
|
+
console.print(f" [green]•[/green] {test_path}")
|
|
2177
|
+
console.print(f"\n[dim]Run with:[/dim] [bold]pytest {' '.join(affected_tests)}[/bold]")
|
|
2178
|
+
|
|
2179
|
+
|
|
2180
|
+
# ── seam pack ─────────────────────────────────────────────────────────────────
|
|
2181
|
+
|
|
2182
|
+
|
|
2183
|
+
@app.command(name="pack")
|
|
2184
|
+
def pack_cmd(
|
|
2185
|
+
symbol: str = typer.Argument(..., help="Symbol name to build context pack for."),
|
|
2186
|
+
path: str = typer.Option(".", "--path", help="Project root (default: current directory)"),
|
|
2187
|
+
db_dir: str = typer.Option("", "--db-dir", help="Override DB directory"),
|
|
2188
|
+
lean: bool = typer.Option(
|
|
2189
|
+
False,
|
|
2190
|
+
"--lean",
|
|
2191
|
+
help=(
|
|
2192
|
+
"Omit heavy enrichment fields (decorators, is_exported, visibility, qualified_name) "
|
|
2193
|
+
"from target and all neighbors. Identical to verbose=false in MCP."
|
|
2194
|
+
),
|
|
2195
|
+
),
|
|
2196
|
+
json_: bool = typer.Option(False, "--json", help="Emit structured JSON envelope to stdout."),
|
|
2197
|
+
quiet: bool = typer.Option(
|
|
2198
|
+
False,
|
|
2199
|
+
"--quiet",
|
|
2200
|
+
help="Print terse human rendering without the JSON envelope.",
|
|
2201
|
+
),
|
|
2202
|
+
) -> None:
|
|
2203
|
+
"""Get a ready-to-paste context bundle for a symbol.
|
|
2204
|
+
|
|
2205
|
+
Returns target info, enriched callers/callees (with file, line, kind,
|
|
2206
|
+
signature), WHY/HACK/NOTE comments, cluster peers, and truncation counts —
|
|
2207
|
+
all in one call.
|
|
2208
|
+
|
|
2209
|
+
Examples:
|
|
2210
|
+
seam pack my_func
|
|
2211
|
+
seam pack my_func --json
|
|
2212
|
+
seam pack my_func --lean --json
|
|
2213
|
+
seam pack my_func --quiet
|
|
2214
|
+
"""
|
|
2215
|
+
try:
|
|
2216
|
+
check_mutual_exclusion(json_=json_, quiet=quiet)
|
|
2217
|
+
except ValueError as exc:
|
|
2218
|
+
emit_json_error("INVALID_INPUT", str(exc))
|
|
2219
|
+
|
|
2220
|
+
project_root = Path(path).resolve()
|
|
2221
|
+
db_root = Path(db_dir).resolve() if db_dir else project_root
|
|
2222
|
+
db_path = config.get_db_path(db_root)
|
|
2223
|
+
|
|
2224
|
+
if not db_path.exists():
|
|
2225
|
+
if json_:
|
|
2226
|
+
emit_json_error("NO_INDEX", "No index found. Run 'seam init' first.")
|
|
2227
|
+
console.print("[red]No index found.[/red] Run [bold]seam init[/bold] first.")
|
|
2228
|
+
raise typer.Exit(code=1)
|
|
2229
|
+
|
|
2230
|
+
try:
|
|
2231
|
+
conn = connect(db_path)
|
|
2232
|
+
except sqlite3.Error as exc:
|
|
2233
|
+
if json_:
|
|
2234
|
+
emit_json_error("DB_ERROR", f"Failed to open database: {exc}")
|
|
2235
|
+
console.print(f"[red]Failed to open database:[/red] {exc}")
|
|
2236
|
+
raise typer.Exit(code=1) from exc
|
|
2237
|
+
|
|
2238
|
+
# --lean sets verbose=False; output becomes byte-identical to the MCP tool
|
|
2239
|
+
# called with verbose=False — heavy fields absent from target and all neighbors.
|
|
2240
|
+
verbose = not lean
|
|
2241
|
+
|
|
2242
|
+
try:
|
|
2243
|
+
# WHY: always route through handle_seam_context_pack so MCP and CLI
|
|
2244
|
+
# produce the identical bundle (same paths, same caps, same truncation).
|
|
2245
|
+
result = handle_seam_context_pack(conn, symbol, project_root, verbose=verbose)
|
|
2246
|
+
finally:
|
|
2247
|
+
conn.close()
|
|
2248
|
+
|
|
2249
|
+
# ── Error dict from handler (blank input) ────────────────────────────────
|
|
2250
|
+
if isinstance(result, dict) and result.get("error"):
|
|
2251
|
+
if json_:
|
|
2252
|
+
emit_json_error(result["error"], result.get("message", ""))
|
|
2253
|
+
console.print(f"[red]Error:[/red] {result.get('message', result['error'])}")
|
|
2254
|
+
raise typer.Exit(code=1)
|
|
2255
|
+
|
|
2256
|
+
# ── Symbol not found ─────────────────────────────────────────────────────
|
|
2257
|
+
# WHY success envelope (not error): mirrors seam_context's contract.
|
|
2258
|
+
# A missing symbol is a valid answer — the agent reads found:false and
|
|
2259
|
+
# knows to check the name or run 'seam init'. Using emit_json_error with
|
|
2260
|
+
# a NOT_FOUND code would invent an undocumented error code AND diverge
|
|
2261
|
+
# from seam_context, which returns null (not an error) for missing symbols.
|
|
2262
|
+
if result is None:
|
|
2263
|
+
if json_:
|
|
2264
|
+
emit_json({"found": False, "symbol": symbol})
|
|
2265
|
+
return
|
|
2266
|
+
console.print(
|
|
2267
|
+
f"[yellow]Symbol '{symbol}' not found in the index[/yellow]"
|
|
2268
|
+
" — check the name or run 'seam init'."
|
|
2269
|
+
)
|
|
2270
|
+
return
|
|
2271
|
+
|
|
2272
|
+
# ── JSON mode ─────────────────────────────────────────────────────────────
|
|
2273
|
+
if json_:
|
|
2274
|
+
emit_json(result)
|
|
2275
|
+
return
|
|
2276
|
+
|
|
2277
|
+
# ── Quiet mode — terse rendering ─────────────────────────────────────────
|
|
2278
|
+
if quiet:
|
|
2279
|
+
target = result["target"]
|
|
2280
|
+
sys.stdout.write(
|
|
2281
|
+
f"{target['symbol']} {target['kind']} {target['file']}:{target['line']}\n"
|
|
2282
|
+
)
|
|
2283
|
+
if result["callers"]:
|
|
2284
|
+
sys.stdout.write("callers: " + ", ".join(nb["name"] for nb in result["callers"]) + "\n")
|
|
2285
|
+
if result["callees"]:
|
|
2286
|
+
sys.stdout.write("callees: " + ", ".join(nb["name"] for nb in result["callees"]) + "\n")
|
|
2287
|
+
for hit in result["why"]:
|
|
2288
|
+
sys.stdout.write(f"{hit['marker']}: {hit['text']}\n")
|
|
2289
|
+
return
|
|
2290
|
+
|
|
2291
|
+
# ── Rich (default) mode ───────────────────────────────────────────────────
|
|
2292
|
+
target = result["target"]
|
|
2293
|
+
trunc = result["truncated"]
|
|
2294
|
+
|
|
2295
|
+
# ── Target header ─────────────────────────────────────────────────────────
|
|
2296
|
+
ambig_marker = " [yellow](ambiguous)[/yellow]" if target.get("ambiguous") else ""
|
|
2297
|
+
console.print(
|
|
2298
|
+
f"\n[bold cyan]seam pack[/bold cyan] "
|
|
2299
|
+
f"[bold]{target['symbol']}[/bold]{ambig_marker}"
|
|
2300
|
+
f" [dim]{target['kind']} {target['file']}:{target['line']}[/dim]"
|
|
2301
|
+
)
|
|
2302
|
+
|
|
2303
|
+
if target.get("signature"):
|
|
2304
|
+
console.print(f" [dim]sig:[/dim] {target['signature']}")
|
|
2305
|
+
if target.get("docstring"):
|
|
2306
|
+
console.print(f" [dim]doc:[/dim] {target['docstring'][:100]}")
|
|
2307
|
+
|
|
2308
|
+
# ── Enriched callers/callees table ────────────────────────────────────────
|
|
2309
|
+
def _print_neighbors(label: str, neighbors: list, dropped: int) -> None:
|
|
2310
|
+
if not neighbors and not dropped:
|
|
2311
|
+
console.print(f"\n [dim]{label}: none[/dim]")
|
|
2312
|
+
return
|
|
2313
|
+
suffix = f" [dim](+{dropped} truncated)[/dim]" if dropped else ""
|
|
2314
|
+
console.print(f"\n [bold]{label}[/bold]{suffix}:")
|
|
2315
|
+
table = Table(show_header=True, box=None, padding=(0, 1))
|
|
2316
|
+
table.add_column("name", style="bold", width=28)
|
|
2317
|
+
table.add_column("kind", style="dim", width=10)
|
|
2318
|
+
table.add_column("file:line", style="dim")
|
|
2319
|
+
table.add_column("signature", style="dim")
|
|
2320
|
+
for nb in neighbors:
|
|
2321
|
+
sig = (nb.get("signature") or "")[:50]
|
|
2322
|
+
table.add_row(
|
|
2323
|
+
nb["name"],
|
|
2324
|
+
nb["kind"],
|
|
2325
|
+
f"{nb['file']}:{nb['line']}",
|
|
2326
|
+
sig,
|
|
2327
|
+
)
|
|
2328
|
+
console.print(table)
|
|
2329
|
+
|
|
2330
|
+
_print_neighbors("callers", result["callers"], trunc["callers"])
|
|
2331
|
+
_print_neighbors("callees", result["callees"], trunc["callees"])
|
|
2332
|
+
|
|
2333
|
+
# ── WHY comments ─────────────────────────────────────────────────────────
|
|
2334
|
+
why_hits = result["why"]
|
|
2335
|
+
if why_hits:
|
|
2336
|
+
suffix = f" [dim](+{trunc['comments']} truncated)[/dim]" if trunc["comments"] else ""
|
|
2337
|
+
console.print(f"\n [bold]why[/bold]{suffix}:")
|
|
2338
|
+
for hit in why_hits:
|
|
2339
|
+
marker_color = {
|
|
2340
|
+
"WHY": "cyan",
|
|
2341
|
+
"HACK": "yellow",
|
|
2342
|
+
"NOTE": "blue",
|
|
2343
|
+
"TODO": "green",
|
|
2344
|
+
"FIXME": "red",
|
|
2345
|
+
}.get(hit["marker"], "white")
|
|
2346
|
+
console.print(
|
|
2347
|
+
f" [{marker_color}]{hit['marker']}[/{marker_color}]"
|
|
2348
|
+
f" [dim]line {hit['line']}[/dim] {hit['text']}"
|
|
2349
|
+
)
|
|
2350
|
+
else:
|
|
2351
|
+
console.print("\n [dim]why: no semantic comments[/dim]")
|
|
2352
|
+
|
|
2353
|
+
# ── Cluster peers ─────────────────────────────────────────────────────────
|
|
2354
|
+
peers = result.get("cluster_peers", [])
|
|
2355
|
+
if peers:
|
|
2356
|
+
console.print(
|
|
2357
|
+
f"\n [bold]cluster peers[/bold]: {', '.join(peers[:8])}"
|
|
2358
|
+
+ (f" +{len(peers) - 8} more" if len(peers) > 8 else "")
|
|
2359
|
+
)
|
|
2360
|
+
|
|
2361
|
+
|
|
2362
|
+
# ── seam sync ─────────────────────────────────────────────────────────────────
|
|
2363
|
+
|
|
2364
|
+
|
|
2365
|
+
@app.command(name="sync")
|
|
2366
|
+
def sync_cmd(
|
|
2367
|
+
path: str = typer.Argument(".", help="Project root to sync (default: current directory)"),
|
|
2368
|
+
db_dir: str = typer.Option(
|
|
2369
|
+
"",
|
|
2370
|
+
"--db-dir",
|
|
2371
|
+
help="Override DB directory (default: same as project root)",
|
|
2372
|
+
),
|
|
2373
|
+
force_clusters: bool = typer.Option(
|
|
2374
|
+
False,
|
|
2375
|
+
"--force-clusters",
|
|
2376
|
+
help=(
|
|
2377
|
+
"Recompute clusters even when zero files changed "
|
|
2378
|
+
"(useful after the watcher already indexed your edits)."
|
|
2379
|
+
),
|
|
2380
|
+
),
|
|
2381
|
+
force_synthesis: bool = typer.Option(
|
|
2382
|
+
False,
|
|
2383
|
+
"--force-synthesis",
|
|
2384
|
+
help=(
|
|
2385
|
+
"Re-run edge synthesis even when zero files changed "
|
|
2386
|
+
"(useful after the watcher already indexed your edits). "
|
|
2387
|
+
"Mirrors --force-clusters for the synthesis post-pass."
|
|
2388
|
+
),
|
|
2389
|
+
),
|
|
2390
|
+
semantic: bool = typer.Option(
|
|
2391
|
+
False,
|
|
2392
|
+
"--semantic",
|
|
2393
|
+
help=(
|
|
2394
|
+
"Re-embed all symbols after sync (full re-embed, not incremental). "
|
|
2395
|
+
"Requires: pip install 'seam-mcp[semantic]'. "
|
|
2396
|
+
"Skips cleanly when fastembed is absent."
|
|
2397
|
+
),
|
|
2398
|
+
),
|
|
2399
|
+
json_: bool = typer.Option(False, "--json", help="Emit structured JSON envelope to stdout."),
|
|
2400
|
+
quiet: bool = typer.Option(
|
|
2401
|
+
False,
|
|
2402
|
+
"--quiet",
|
|
2403
|
+
"-q",
|
|
2404
|
+
help="Print bare key:value output, one per line (for hook use).",
|
|
2405
|
+
),
|
|
2406
|
+
) -> None:
|
|
2407
|
+
"""Incrementally refresh the Seam index by reconciling against the filesystem.
|
|
2408
|
+
|
|
2409
|
+
Detects files that changed since the last index (via mtime + SHA-1), re-indexes
|
|
2410
|
+
only those files, removes deleted files, and recomputes clusters if the graph
|
|
2411
|
+
changed. Much faster than `seam init` when only a few files changed.
|
|
2412
|
+
|
|
2413
|
+
Requires an existing index (run `seam init` first).
|
|
2414
|
+
|
|
2415
|
+
Examples:
|
|
2416
|
+
seam sync -- sync current directory
|
|
2417
|
+
seam sync /path/to/project -- sync a specific project
|
|
2418
|
+
seam sync --force-clusters -- recompute clusters even if nothing changed
|
|
2419
|
+
seam sync --semantic -- also re-embed symbols after reconciling
|
|
2420
|
+
seam sync -q >/dev/null 2>&1 -- quiet for git hook use
|
|
2421
|
+
seam sync --json -- structured output for CI / agents
|
|
2422
|
+
"""
|
|
2423
|
+
# WHY: check mutual exclusion before any DB work so the error is immediate.
|
|
2424
|
+
try:
|
|
2425
|
+
check_mutual_exclusion(json_=json_, quiet=quiet)
|
|
2426
|
+
except ValueError as exc:
|
|
2427
|
+
emit_json_error("INVALID_INPUT", str(exc))
|
|
2428
|
+
|
|
2429
|
+
project_root = Path(path).resolve()
|
|
2430
|
+
|
|
2431
|
+
if not project_root.is_dir():
|
|
2432
|
+
if json_:
|
|
2433
|
+
emit_json_error("INVALID_INPUT", f"'{project_root}' is not a directory.")
|
|
2434
|
+
console.print(f"[red]Error:[/red] '{project_root}' is not a directory.")
|
|
2435
|
+
raise typer.Exit(code=1)
|
|
2436
|
+
|
|
2437
|
+
# Mirror `init`'s path/db-dir resolution.
|
|
2438
|
+
db_root = Path(db_dir).resolve() if db_dir else project_root
|
|
2439
|
+
db_path = config.get_db_path(db_root)
|
|
2440
|
+
|
|
2441
|
+
# sync requires an existing index — use connect() (NOT init_db).
|
|
2442
|
+
# WHY: sync's responsibility is "reconcile an existing index"; bootstrapping
|
|
2443
|
+
# a new one is init's job. Failing clearly here prevents silent data loss
|
|
2444
|
+
# (e.g. the user accidentally runs sync in the wrong directory and gets an
|
|
2445
|
+
# empty index instead of an error).
|
|
2446
|
+
if not db_path.exists():
|
|
2447
|
+
if json_:
|
|
2448
|
+
emit_json_error(
|
|
2449
|
+
"NO_INDEX",
|
|
2450
|
+
f"No index found at '{db_path}'. Run 'seam init' first to create the index.",
|
|
2451
|
+
)
|
|
2452
|
+
console.print(
|
|
2453
|
+
"[red]No index found.[/red] Run [bold]seam init[/bold] first to create the index."
|
|
2454
|
+
)
|
|
2455
|
+
raise typer.Exit(code=1)
|
|
2456
|
+
|
|
2457
|
+
try:
|
|
2458
|
+
conn = connect(db_path)
|
|
2459
|
+
except sqlite3.Error as exc:
|
|
2460
|
+
if json_:
|
|
2461
|
+
emit_json_error("DB_ERROR", f"Failed to open database: {exc}")
|
|
2462
|
+
console.print(f"[red]Failed to open database:[/red] {exc}")
|
|
2463
|
+
raise typer.Exit(code=1) from exc
|
|
2464
|
+
|
|
2465
|
+
try:
|
|
2466
|
+
result = sync_project(
|
|
2467
|
+
conn,
|
|
2468
|
+
project_root,
|
|
2469
|
+
recompute_clusters=True,
|
|
2470
|
+
force_clusters=force_clusters,
|
|
2471
|
+
naming_mode=config.SEAM_CLUSTER_NAMING,
|
|
2472
|
+
llm_api_key=config.SEAM_LLM_API_KEY,
|
|
2473
|
+
llm_model=config.SEAM_LLM_MODEL,
|
|
2474
|
+
min_size=config.SEAM_CLUSTER_MIN_SIZE,
|
|
2475
|
+
synthesis_enabled=config.SEAM_EDGE_SYNTHESIS == "on",
|
|
2476
|
+
force_synthesis=force_synthesis,
|
|
2477
|
+
fanout_cap=config.SEAM_SYNTHESIS_FANOUT_CAP,
|
|
2478
|
+
)
|
|
2479
|
+
except sqlite3.Error as exc:
|
|
2480
|
+
# A genuine database-layer failure (lock, corruption, disk full mid-write).
|
|
2481
|
+
if json_:
|
|
2482
|
+
emit_json_error("DB_ERROR", f"Sync failed: {exc}")
|
|
2483
|
+
console.print(f"[red]Sync failed:[/red] {exc}")
|
|
2484
|
+
raise typer.Exit(code=1) from exc
|
|
2485
|
+
except Exception as exc: # noqa: BLE001
|
|
2486
|
+
# Catch-all so an unexpected error (e.g. an OSError walking the tree) still
|
|
2487
|
+
# produces a structured envelope instead of a raw traceback — the --json
|
|
2488
|
+
# contract must never be broken. DB_ERROR is the closest data-layer bucket
|
|
2489
|
+
# in the documented code set (NO_INDEX/INVALID_INPUT/INVALID_QUERY/
|
|
2490
|
+
# NOT_A_GIT_REPO/DB_ERROR); we do not invent a new code. The message keeps
|
|
2491
|
+
# the real error visible for diagnosis.
|
|
2492
|
+
if json_:
|
|
2493
|
+
emit_json_error("DB_ERROR", f"Sync failed: {exc}")
|
|
2494
|
+
console.print(f"[red]Sync failed:[/red] {exc}")
|
|
2495
|
+
raise typer.Exit(code=1) from exc
|
|
2496
|
+
finally:
|
|
2497
|
+
conn.close()
|
|
2498
|
+
|
|
2499
|
+
# --semantic: re-embed all symbols after reconciliation.
|
|
2500
|
+
# WHY re-open connection: conn was closed in the finally block above.
|
|
2501
|
+
# Full re-embed (not incremental) — same as init --semantic.
|
|
2502
|
+
if semantic:
|
|
2503
|
+
try:
|
|
2504
|
+
embed_conn = connect(db_path)
|
|
2505
|
+
try:
|
|
2506
|
+
_embed_count = index_embeddings(
|
|
2507
|
+
embed_conn,
|
|
2508
|
+
model=config.SEAM_EMBED_MODEL,
|
|
2509
|
+
batch=32,
|
|
2510
|
+
)
|
|
2511
|
+
finally:
|
|
2512
|
+
embed_conn.close()
|
|
2513
|
+
# _embed_count: 0 = skipped (fastembed absent), -1 = failed, >=1 = success
|
|
2514
|
+
if not quiet and _embed_count < 0:
|
|
2515
|
+
console.print(
|
|
2516
|
+
"[yellow]embeddings: failed[/yellow] "
|
|
2517
|
+
"[dim](run with SEAM_LOG_LEVEL=DEBUG to see the error)[/dim]"
|
|
2518
|
+
)
|
|
2519
|
+
except Exception as exc: # noqa: BLE001
|
|
2520
|
+
if not quiet:
|
|
2521
|
+
console.print(f"[yellow]embeddings: failed ({exc})[/yellow]")
|
|
2522
|
+
|
|
2523
|
+
# ── JSON mode ─────────────────────────────────────────────────────────────
|
|
2524
|
+
if json_:
|
|
2525
|
+
emit_json(result)
|
|
2526
|
+
return
|
|
2527
|
+
|
|
2528
|
+
# ── Quiet mode — key:value pairs, one per line, for hook use ─────────────
|
|
2529
|
+
# WHY key:value (not bare values): with mixed int/bool fields, bare positional
|
|
2530
|
+
# values are ambiguous to parse. "key: value\n" lets hooks do `grep "^added:"`.
|
|
2531
|
+
if quiet:
|
|
2532
|
+
sys.stdout.write(f"added: {result['added']}\n")
|
|
2533
|
+
sys.stdout.write(f"modified: {result['modified']}\n")
|
|
2534
|
+
sys.stdout.write(f"removed: {result['removed']}\n")
|
|
2535
|
+
sys.stdout.write(f"unchanged: {result['unchanged']}\n")
|
|
2536
|
+
sys.stdout.write(f"skipped: {result['skipped']}\n")
|
|
2537
|
+
sys.stdout.write(f"graph_changed: {result['graph_changed']}\n")
|
|
2538
|
+
sys.stdout.write(f"clusters_recomputed: {result['clusters_recomputed']}\n")
|
|
2539
|
+
sys.stdout.write(f"cluster_count: {result['cluster_count']}\n")
|
|
2540
|
+
sys.stdout.write(f"synthesis_recomputed: {result['synthesis_recomputed']}\n")
|
|
2541
|
+
sys.stdout.write(f"synthesis_count: {result['synthesis_count']}\n")
|
|
2542
|
+
return
|
|
2543
|
+
|
|
2544
|
+
# ── Rich (default) mode — summary table ───────────────────────────────────
|
|
2545
|
+
# cluster_count: None = recompute skipped (gate false); -1 = recompute RAN but
|
|
2546
|
+
# FAILED (index_clusters' error sentinel); >= 0 = success. Mirror `seam init`'s
|
|
2547
|
+
# display so a failed recompute reads as "failed", never a misleading "-1".
|
|
2548
|
+
cluster_count = result["cluster_count"]
|
|
2549
|
+
clustering_failed = cluster_count is not None and cluster_count < 0
|
|
2550
|
+
if cluster_count is None:
|
|
2551
|
+
cluster_display = "skipped"
|
|
2552
|
+
elif clustering_failed:
|
|
2553
|
+
cluster_display = "failed"
|
|
2554
|
+
else:
|
|
2555
|
+
cluster_display = str(cluster_count)
|
|
2556
|
+
|
|
2557
|
+
# synthesis_count: None = pass skipped; -1 = failed; 0 = no synth edges; >= 1 = count.
|
|
2558
|
+
sync_synthesis_count = result.get("synthesis_count")
|
|
2559
|
+
synthesis_failed_sync = sync_synthesis_count is not None and sync_synthesis_count < 0
|
|
2560
|
+
if sync_synthesis_count is None:
|
|
2561
|
+
synthesis_display = "skipped"
|
|
2562
|
+
elif synthesis_failed_sync:
|
|
2563
|
+
synthesis_display = "failed"
|
|
2564
|
+
else:
|
|
2565
|
+
synthesis_display = str(sync_synthesis_count)
|
|
2566
|
+
|
|
2567
|
+
table = Table(title="seam sync — complete", show_header=False, box=None)
|
|
2568
|
+
table.add_column("key", style="bold cyan", width=20)
|
|
2569
|
+
table.add_column("value")
|
|
2570
|
+
table.add_row("root", str(project_root))
|
|
2571
|
+
table.add_row("added", str(result["added"]))
|
|
2572
|
+
table.add_row("modified", str(result["modified"]))
|
|
2573
|
+
table.add_row("removed", str(result["removed"]))
|
|
2574
|
+
table.add_row("unchanged", str(result["unchanged"]))
|
|
2575
|
+
table.add_row("skipped", str(result["skipped"]))
|
|
2576
|
+
table.add_row("clusters", cluster_display)
|
|
2577
|
+
table.add_row("synth edges", synthesis_display)
|
|
2578
|
+
console.print(table)
|
|
2579
|
+
|
|
2580
|
+
# Visible failure warning when the gated cluster recompute failed — without
|
|
2581
|
+
# this the operator sees only "clusters: failed" in the table and might miss
|
|
2582
|
+
# that the index's clusters are now stale. Mirrors `seam init`'s guard.
|
|
2583
|
+
if clustering_failed:
|
|
2584
|
+
console.print(
|
|
2585
|
+
"[yellow]clusters: recompute failed[/yellow] "
|
|
2586
|
+
"[dim](clusters may be stale — run 'seam init' to rebuild; "
|
|
2587
|
+
"set SEAM_LOG_LEVEL=DEBUG to see the error)[/dim]"
|
|
2588
|
+
)
|
|
2589
|
+
|
|
2590
|
+
# Visible failure warning when synthesis failed.
|
|
2591
|
+
if synthesis_failed_sync:
|
|
2592
|
+
console.print(
|
|
2593
|
+
"[yellow]synth edges: recompute failed[/yellow] "
|
|
2594
|
+
"[dim](synthesized edges may be stale — run 'seam init' to rebuild; "
|
|
2595
|
+
"set SEAM_LOG_LEVEL=DEBUG to see the error)[/dim]"
|
|
2596
|
+
)
|
|
2597
|
+
|
|
2598
|
+
if result["skipped"] > 0:
|
|
2599
|
+
console.print(
|
|
2600
|
+
f"[dim]{result['skipped']} file(s) skipped (binary/oversize/parse error). "
|
|
2601
|
+
"Set SEAM_LOG_LEVEL=DEBUG to see which.[/dim]"
|
|
2602
|
+
)
|