seam-code 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. seam/__init__.py +3 -0
  2. seam/_data/schema.sql +225 -0
  3. seam/_web/assets/index-BL_tqprR.js +216 -0
  4. seam/_web/assets/index-GTKUhVyD.css +1 -0
  5. seam/_web/index.html +13 -0
  6. seam/analysis/__init__.py +14 -0
  7. seam/analysis/affected.py +254 -0
  8. seam/analysis/builtins.py +966 -0
  9. seam/analysis/byte_budget.py +217 -0
  10. seam/analysis/changes.py +709 -0
  11. seam/analysis/cluster_naming.py +260 -0
  12. seam/analysis/clustering.py +216 -0
  13. seam/analysis/confidence.py +699 -0
  14. seam/analysis/embeddings.py +195 -0
  15. seam/analysis/flows.py +708 -0
  16. seam/analysis/impact.py +444 -0
  17. seam/analysis/imports.py +994 -0
  18. seam/analysis/imports_ext.py +780 -0
  19. seam/analysis/imports_resolve.py +176 -0
  20. seam/analysis/processes.py +453 -0
  21. seam/analysis/relevance.py +155 -0
  22. seam/analysis/rwr.py +129 -0
  23. seam/analysis/staleness.py +328 -0
  24. seam/analysis/steer.py +282 -0
  25. seam/analysis/synthesis.py +253 -0
  26. seam/analysis/synthesis_channels.py +433 -0
  27. seam/analysis/testpaths.py +103 -0
  28. seam/analysis/traversal.py +470 -0
  29. seam/cli/__init__.py +0 -0
  30. seam/cli/install.py +232 -0
  31. seam/cli/main.py +2602 -0
  32. seam/cli/output.py +137 -0
  33. seam/cli/read.py +244 -0
  34. seam/cli/serve.py +145 -0
  35. seam/config.py +551 -0
  36. seam/indexer/__init__.py +0 -0
  37. seam/indexer/cluster_index.py +425 -0
  38. seam/indexer/db.py +496 -0
  39. seam/indexer/embedding_index.py +183 -0
  40. seam/indexer/field_access.py +536 -0
  41. seam/indexer/field_access_c_cpp.py +643 -0
  42. seam/indexer/field_access_ext.py +708 -0
  43. seam/indexer/field_access_ext2.py +408 -0
  44. seam/indexer/field_access_go_rust.py +737 -0
  45. seam/indexer/field_access_php_swift.py +888 -0
  46. seam/indexer/field_access_ts.py +626 -0
  47. seam/indexer/graph.py +321 -0
  48. seam/indexer/graph_c.py +562 -0
  49. seam/indexer/graph_c_cpp.py +39 -0
  50. seam/indexer/graph_common.py +644 -0
  51. seam/indexer/graph_cpp.py +615 -0
  52. seam/indexer/graph_csharp.py +651 -0
  53. seam/indexer/graph_go.py +723 -0
  54. seam/indexer/graph_go_rust.py +39 -0
  55. seam/indexer/graph_java.py +689 -0
  56. seam/indexer/graph_java_csharp.py +38 -0
  57. seam/indexer/graph_php.py +914 -0
  58. seam/indexer/graph_python.py +628 -0
  59. seam/indexer/graph_ruby.py +748 -0
  60. seam/indexer/graph_rust.py +653 -0
  61. seam/indexer/graph_scope_infer.py +902 -0
  62. seam/indexer/graph_scope_infer_ext.py +723 -0
  63. seam/indexer/graph_scope_infer_ext2.py +992 -0
  64. seam/indexer/graph_swift.py +1014 -0
  65. seam/indexer/graph_swift_infer.py +515 -0
  66. seam/indexer/graph_typescript.py +663 -0
  67. seam/indexer/migrations.py +816 -0
  68. seam/indexer/parser.py +204 -0
  69. seam/indexer/pipeline.py +197 -0
  70. seam/indexer/signatures.py +634 -0
  71. seam/indexer/signatures_ext.py +780 -0
  72. seam/indexer/sync.py +287 -0
  73. seam/indexer/synthesis_index.py +291 -0
  74. seam/indexer/tokenize.py +79 -0
  75. seam/installer/__init__.py +67 -0
  76. seam/installer/claude.py +97 -0
  77. seam/installer/codex.py +94 -0
  78. seam/installer/core.py +127 -0
  79. seam/installer/cursor.py +61 -0
  80. seam/installer/guide.py +110 -0
  81. seam/installer/jsonfile.py +85 -0
  82. seam/installer/markdownfile.py +146 -0
  83. seam/installer/tomlfile.py +72 -0
  84. seam/query/__init__.py +0 -0
  85. seam/query/clusters.py +206 -0
  86. seam/query/comments.py +217 -0
  87. seam/query/context.py +293 -0
  88. seam/query/engine.py +940 -0
  89. seam/query/fts.py +328 -0
  90. seam/query/names.py +470 -0
  91. seam/query/pack.py +433 -0
  92. seam/query/semantic.py +339 -0
  93. seam/query/structure.py +727 -0
  94. seam/server/__init__.py +0 -0
  95. seam/server/graph_api.py +437 -0
  96. seam/server/handler_common.py +323 -0
  97. seam/server/impact_handler.py +615 -0
  98. seam/server/mcp.py +556 -0
  99. seam/server/tools.py +697 -0
  100. seam/server/trace_handler.py +184 -0
  101. seam/server/web.py +922 -0
  102. seam/watcher/__init__.py +0 -0
  103. seam/watcher/__main__.py +56 -0
  104. seam/watcher/daemon.py +237 -0
  105. seam_code-0.3.0.dist-info/METADATA +318 -0
  106. seam_code-0.3.0.dist-info/RECORD +109 -0
  107. seam_code-0.3.0.dist-info/WHEEL +4 -0
  108. seam_code-0.3.0.dist-info/entry_points.txt +2 -0
  109. seam_code-0.3.0.dist-info/licenses/LICENSE +21 -0
seam/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
+ )