seam-code 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. seam/__init__.py +3 -0
  2. seam/_data/schema.sql +225 -0
  3. seam/_web/assets/index-BL_tqprR.js +216 -0
  4. seam/_web/assets/index-GTKUhVyD.css +1 -0
  5. seam/_web/index.html +13 -0
  6. seam/analysis/__init__.py +14 -0
  7. seam/analysis/affected.py +254 -0
  8. seam/analysis/builtins.py +966 -0
  9. seam/analysis/byte_budget.py +217 -0
  10. seam/analysis/changes.py +709 -0
  11. seam/analysis/cluster_naming.py +260 -0
  12. seam/analysis/clustering.py +216 -0
  13. seam/analysis/confidence.py +699 -0
  14. seam/analysis/embeddings.py +195 -0
  15. seam/analysis/flows.py +708 -0
  16. seam/analysis/impact.py +444 -0
  17. seam/analysis/imports.py +994 -0
  18. seam/analysis/imports_ext.py +780 -0
  19. seam/analysis/imports_resolve.py +176 -0
  20. seam/analysis/processes.py +453 -0
  21. seam/analysis/relevance.py +155 -0
  22. seam/analysis/rwr.py +129 -0
  23. seam/analysis/staleness.py +328 -0
  24. seam/analysis/steer.py +282 -0
  25. seam/analysis/synthesis.py +253 -0
  26. seam/analysis/synthesis_channels.py +433 -0
  27. seam/analysis/testpaths.py +103 -0
  28. seam/analysis/traversal.py +470 -0
  29. seam/cli/__init__.py +0 -0
  30. seam/cli/install.py +232 -0
  31. seam/cli/main.py +2602 -0
  32. seam/cli/output.py +137 -0
  33. seam/cli/read.py +244 -0
  34. seam/cli/serve.py +145 -0
  35. seam/config.py +551 -0
  36. seam/indexer/__init__.py +0 -0
  37. seam/indexer/cluster_index.py +425 -0
  38. seam/indexer/db.py +496 -0
  39. seam/indexer/embedding_index.py +183 -0
  40. seam/indexer/field_access.py +536 -0
  41. seam/indexer/field_access_c_cpp.py +643 -0
  42. seam/indexer/field_access_ext.py +708 -0
  43. seam/indexer/field_access_ext2.py +408 -0
  44. seam/indexer/field_access_go_rust.py +737 -0
  45. seam/indexer/field_access_php_swift.py +888 -0
  46. seam/indexer/field_access_ts.py +626 -0
  47. seam/indexer/graph.py +321 -0
  48. seam/indexer/graph_c.py +562 -0
  49. seam/indexer/graph_c_cpp.py +39 -0
  50. seam/indexer/graph_common.py +644 -0
  51. seam/indexer/graph_cpp.py +615 -0
  52. seam/indexer/graph_csharp.py +651 -0
  53. seam/indexer/graph_go.py +723 -0
  54. seam/indexer/graph_go_rust.py +39 -0
  55. seam/indexer/graph_java.py +689 -0
  56. seam/indexer/graph_java_csharp.py +38 -0
  57. seam/indexer/graph_php.py +914 -0
  58. seam/indexer/graph_python.py +628 -0
  59. seam/indexer/graph_ruby.py +748 -0
  60. seam/indexer/graph_rust.py +653 -0
  61. seam/indexer/graph_scope_infer.py +902 -0
  62. seam/indexer/graph_scope_infer_ext.py +723 -0
  63. seam/indexer/graph_scope_infer_ext2.py +992 -0
  64. seam/indexer/graph_swift.py +1014 -0
  65. seam/indexer/graph_swift_infer.py +515 -0
  66. seam/indexer/graph_typescript.py +663 -0
  67. seam/indexer/migrations.py +816 -0
  68. seam/indexer/parser.py +204 -0
  69. seam/indexer/pipeline.py +197 -0
  70. seam/indexer/signatures.py +634 -0
  71. seam/indexer/signatures_ext.py +780 -0
  72. seam/indexer/sync.py +287 -0
  73. seam/indexer/synthesis_index.py +291 -0
  74. seam/indexer/tokenize.py +79 -0
  75. seam/installer/__init__.py +67 -0
  76. seam/installer/claude.py +97 -0
  77. seam/installer/codex.py +94 -0
  78. seam/installer/core.py +127 -0
  79. seam/installer/cursor.py +61 -0
  80. seam/installer/guide.py +110 -0
  81. seam/installer/jsonfile.py +85 -0
  82. seam/installer/markdownfile.py +146 -0
  83. seam/installer/tomlfile.py +72 -0
  84. seam/query/__init__.py +0 -0
  85. seam/query/clusters.py +206 -0
  86. seam/query/comments.py +217 -0
  87. seam/query/context.py +293 -0
  88. seam/query/engine.py +940 -0
  89. seam/query/fts.py +328 -0
  90. seam/query/names.py +470 -0
  91. seam/query/pack.py +433 -0
  92. seam/query/semantic.py +339 -0
  93. seam/query/structure.py +727 -0
  94. seam/server/__init__.py +0 -0
  95. seam/server/graph_api.py +437 -0
  96. seam/server/handler_common.py +323 -0
  97. seam/server/impact_handler.py +615 -0
  98. seam/server/mcp.py +556 -0
  99. seam/server/tools.py +697 -0
  100. seam/server/trace_handler.py +184 -0
  101. seam/server/web.py +922 -0
  102. seam/watcher/__init__.py +0 -0
  103. seam/watcher/__main__.py +56 -0
  104. seam/watcher/daemon.py +237 -0
  105. seam_code-0.3.0.dist-info/METADATA +318 -0
  106. seam_code-0.3.0.dist-info/RECORD +109 -0
  107. seam_code-0.3.0.dist-info/WHEEL +4 -0
  108. seam_code-0.3.0.dist-info/entry_points.txt +2 -0
  109. seam_code-0.3.0.dist-info/licenses/LICENSE +21 -0
seam/server/web.py ADDED
@@ -0,0 +1,922 @@
1
+ """FastAPI web app factory for the Seam Explorer.
2
+
3
+ Exposed as: create_web_app(db_path: Path, root: Path) -> FastAPI
4
+
5
+ Design:
6
+ - App construction NEVER opens the DB or reads any files. Connections are opened
7
+ PER REQUEST inside route handlers. This ensures create_web_app() and .openapi()
8
+ work with no DB file present (required for OpenAPI schema dumping at startup and
9
+ for FastAPI auto-docs).
10
+ - Routes delegate all business logic to existing handle_seam_* handlers (tools.py)
11
+ and build_neighborhood (graph_api.py) — zero query-logic duplication.
12
+ - Pydantic response models are the source of truth for TS codegen (openapi-typescript).
13
+ - StaticFiles mounted at '/' serves seam/_web/ (built SPA). If the directory is absent
14
+ (dev before first build), a small HTML page tells the user to run 'make build-web'.
15
+ - 127.0.0.1-only binding is enforced by the CLI (seam serve), not by this module.
16
+ This module is transport-agnostic — only the factory and routes live here.
17
+ - Error mapping:
18
+ NO_INDEX → 503 Service Unavailable (index not ready)
19
+ INVALID_INPUT → 400 Bad Request
20
+ unknown symbol → 404 {"found": false}
21
+
22
+ IMPORT NOTE: 'import fastapi' at the top of this module is intentional. This module
23
+ is only imported via the lazy CLI path (`seam serve` / `create_web_app`). The `fastapi`
24
+ package is an optional extra (`seam-mcp[web]`) and a dev group dep — it is never imported
25
+ at CLI startup, only when this file is imported. See CLAUDE.md "FastAPI import is lazy".
26
+ """
27
+
28
+ import json
29
+ import sqlite3
30
+ import time
31
+ from pathlib import Path
32
+ from typing import Any, Literal, cast
33
+
34
+ from fastapi import FastAPI, HTTPException, Query
35
+ from fastapi.responses import HTMLResponse
36
+ from fastapi.staticfiles import StaticFiles
37
+ from pydantic import BaseModel
38
+
39
+ from seam.indexer.db import connect
40
+ from seam.server.graph_api import (
41
+ build_constellation,
42
+ build_neighborhood,
43
+ list_structure,
44
+ top_hub_symbols,
45
+ )
46
+ from seam.server.tools import (
47
+ handle_seam_changes,
48
+ handle_seam_clusters,
49
+ handle_seam_context,
50
+ handle_seam_impact,
51
+ handle_seam_search,
52
+ handle_seam_trace,
53
+ handle_seam_why,
54
+ )
55
+
56
+ # ── Pydantic response models (source of truth for TS codegen) ─────────────────
57
+ # These models define the exact JSON shape that openapi-typescript will consume.
58
+ # Keep field names snake_case — the TS codegen will use them verbatim.
59
+
60
+
61
+ class StatusResponse(BaseModel):
62
+ """Response for GET /api/status."""
63
+
64
+ root: str
65
+ symbol_count: int
66
+ edge_count: int
67
+ cluster_count: int
68
+ last_indexed: str | None
69
+ languages: list[str]
70
+
71
+
72
+ class SearchResultItem(BaseModel):
73
+ """One item in a search result list."""
74
+
75
+ name: str
76
+ kind: str
77
+ file: str
78
+ line: int
79
+ signature: str | None
80
+ cluster_id: int | None
81
+ cluster_label: str | None
82
+
83
+
84
+ class SearchResponse(BaseModel):
85
+ """Response for GET /api/search."""
86
+
87
+ results: list[SearchResultItem]
88
+
89
+
90
+ class GraphNode(BaseModel):
91
+ """A node in the neighborhood graph (one per unique symbol NAME)."""
92
+
93
+ id: str
94
+ name: str
95
+ kind: str
96
+ signature: str | None
97
+ visibility: str | None
98
+ is_exported: bool | None
99
+ cluster_id: int | None
100
+ cluster_label: str | None
101
+ definition_count: int
102
+
103
+
104
+ class GraphEdge(BaseModel):
105
+ """An edge in the neighborhood graph."""
106
+
107
+ id: int
108
+ source: str
109
+ target: str
110
+ kind: str
111
+ confidence: str
112
+
113
+
114
+ class NeighborhoodResponse(BaseModel):
115
+ """Response for GET /api/graph/neighborhood."""
116
+
117
+ center: str
118
+ nodes: list[GraphNode]
119
+ edges: list[GraphEdge]
120
+
121
+
122
+ class SymbolDefinition(BaseModel):
123
+ """One definition (file-level occurrence) of a symbol."""
124
+
125
+ file: str
126
+ line: int
127
+ signature: str | None
128
+ docstring: str | None
129
+ visibility: str | None
130
+ is_exported: bool | None
131
+ qualified_name: str | None
132
+ decorators: list[str]
133
+
134
+
135
+ class ClusterInfo(BaseModel):
136
+ """Cluster identity for a symbol."""
137
+
138
+ id: int
139
+ label: str | None
140
+
141
+
142
+ class WhyComment(BaseModel):
143
+ """A WHY/HACK/NOTE/TODO/FIXME comment near a symbol."""
144
+
145
+ kind: str
146
+ text: str
147
+ file: str
148
+ line: int
149
+
150
+
151
+ class SymbolResponse(BaseModel):
152
+ """Response for GET /api/symbol/{name}."""
153
+
154
+ name: str
155
+ definitions: list[SymbolDefinition]
156
+ callers: list[str]
157
+ callees: list[str]
158
+ cluster: ClusterInfo | None
159
+ peers: list[str]
160
+ why: list[WhyComment]
161
+
162
+
163
+ class ClusterItem(BaseModel):
164
+ """One cluster in the cluster list.
165
+
166
+ `representative` is a member symbol NAME the UI can center the graph on when
167
+ the cluster is clicked as an entry point — clusters themselves are not symbols,
168
+ so the landing page needs a real symbol to open. None only if the cluster has
169
+ no clustered symbols (shouldn't happen, but degrades safely to label fallback).
170
+ """
171
+
172
+ cluster_id: int
173
+ label: str | None
174
+ size: int
175
+ representative: str | None
176
+
177
+
178
+ class ClustersResponse(BaseModel):
179
+ """Response for GET /api/clusters."""
180
+
181
+ clusters: list[ClusterItem]
182
+
183
+
184
+ class ImpactEntry(BaseModel):
185
+ """One affected symbol in an impact (blast-radius) result.
186
+
187
+ Lean field set: the overlay only needs identity + tier + location. Heavy
188
+ provenance fields (resolved_by/best_candidate) are stripped by passing
189
+ verbose=False to handle_seam_impact, keeping the payload small.
190
+ """
191
+
192
+ name: str
193
+ distance: int
194
+ confidence: str
195
+ tier: str
196
+ file: str | None
197
+ is_test: bool
198
+
199
+
200
+ class ImpactResponse(BaseModel):
201
+ """Response for GET /api/impact.
202
+
203
+ risk_summary is the honest full-count per tier (computed before any cap), so
204
+ the UI can show true totals even when entry lists are capped by `limit`.
205
+ upstream/downstream are present only for the requested direction(s).
206
+ truncated is present only when a tier was capped.
207
+ """
208
+
209
+ found: bool
210
+ target: str
211
+ risk_summary: dict[str, dict[str, int]]
212
+ upstream: dict[str, list[ImpactEntry]] | None = None
213
+ downstream: dict[str, list[ImpactEntry]] | None = None
214
+ truncated: dict[str, dict[str, int]] | None = None
215
+
216
+
217
+ class TraceHop(BaseModel):
218
+ """One edge in a trace path."""
219
+
220
+ from_name: str
221
+ to_name: str
222
+ kind: str
223
+ confidence: str
224
+
225
+
226
+ class TraceResponse(BaseModel):
227
+ """Response for GET /api/trace.
228
+
229
+ Only `paths` is surfaced (the path overlay's input). The handler's
230
+ callers/callees-of-source/target lists are intentionally dropped — the
231
+ neighborhood endpoint already covers immediate neighbors. paths[0] is the
232
+ shortest path; empty list when source and target are not connected.
233
+ """
234
+
235
+ found: bool
236
+ source: str
237
+ target: str
238
+ paths: list[list[TraceHop]]
239
+
240
+
241
+ class ChangedSymbol(BaseModel):
242
+ """A symbol touched by the current diff."""
243
+
244
+ name: str
245
+ file: str | None
246
+ kind: str
247
+ start_line: int
248
+ end_line: int
249
+ changed_lines: list[int]
250
+
251
+
252
+ class AffectedEntry(BaseModel):
253
+ """A symbol impacted by the changed set (downstream of a change)."""
254
+
255
+ name: str
256
+ file: str | None
257
+ tier: str
258
+ confidence: str
259
+ distance: int
260
+
261
+
262
+ class ChangesResponse(BaseModel):
263
+ """Response for GET /api/changes (git diff → changed symbols → risk)."""
264
+
265
+ changed_symbols: list[ChangedSymbol]
266
+ new_files: list[str]
267
+ affected: list[AffectedEntry]
268
+ risk_level: str
269
+ ambiguous_warning: bool
270
+ scope: str
271
+ base_ref: str
272
+ partial: bool
273
+
274
+
275
+ class ConstellationCluster(BaseModel):
276
+ """One cluster region in the whole-repo overview."""
277
+
278
+ cluster_id: int
279
+ label: str | None
280
+ size: int
281
+
282
+
283
+ class ConstellationLink(BaseModel):
284
+ """A weighted inter-cluster link (count of cross-cluster edges)."""
285
+
286
+ source: int
287
+ target: int
288
+ weight: int
289
+
290
+
291
+ class ConstellationResponse(BaseModel):
292
+ """Response for GET /api/constellation."""
293
+
294
+ clusters: list[ConstellationCluster]
295
+ links: list[ConstellationLink]
296
+
297
+
298
+ class HubSymbol(BaseModel):
299
+ """A highest-degree 'hub' symbol — a landing-page entry point."""
300
+
301
+ name: str
302
+ kind: str | None
303
+ degree: int
304
+ # Representative declaring file (relativized) — lets the UI bucket hubs by area.
305
+ path: str | None
306
+
307
+
308
+ class HubsResponse(BaseModel):
309
+ """Response for GET /api/hubs."""
310
+
311
+ symbols: list[HubSymbol]
312
+
313
+
314
+ class StructureSymbol(BaseModel):
315
+ """One symbol row for the structure map (flat — the SPA builds the tree)."""
316
+
317
+ path: str
318
+ name: str
319
+ kind: str
320
+ line: int
321
+ qualified_name: str | None
322
+
323
+
324
+ class StructureResponse(BaseModel):
325
+ """Response for GET /api/structure."""
326
+
327
+ symbols: list[StructureSymbol]
328
+
329
+
330
+ class ErrorResponse(BaseModel):
331
+ """Standard error body for 4xx/5xx responses."""
332
+
333
+ code: str
334
+ message: str
335
+
336
+
337
+ # ── Helpers ───────────────────────────────────────────────────────────────────
338
+
339
+ # HTML shown when seam/_web/ is absent (dev, before first `make build-web`).
340
+ _BUILD_HINT_HTML = """<!DOCTYPE html>
341
+ <html lang="en">
342
+ <head><meta charset="UTF-8"><title>Seam Explorer</title></head>
343
+ <body>
344
+ <h1>Seam Explorer — frontend not built yet</h1>
345
+ <p>Run <code>make build-web</code> to build the frontend, then restart <code>seam serve</code>.</p>
346
+ <p>The <strong>API</strong> is available at <a href="/api/status">/api/status</a>.</p>
347
+ </body>
348
+ </html>
349
+ """
350
+
351
+
352
+ def _get_conn(db_path: Path) -> sqlite3.Connection:
353
+ """Open a fresh SQLite connection for this request.
354
+
355
+ WHY per-request: app construction must not touch the DB (OpenAPI schema dump
356
+ must work with no DB file present). Opening here ensures each request gets an
357
+ isolated connection that is closed after the request completes.
358
+
359
+ Raises HTTPException 503 when no index exists (db_path absent).
360
+ Raises HTTPException 503 on DB open failure.
361
+ """
362
+ if not db_path.exists():
363
+ # The index has not been created yet — tell the caller to run seam init.
364
+ raise HTTPException(
365
+ status_code=503,
366
+ detail={"code": "NO_INDEX", "message": "No index found. Run 'seam init' first."},
367
+ )
368
+ try:
369
+ return connect(db_path)
370
+ except sqlite3.Error as exc:
371
+ raise HTTPException(
372
+ status_code=503,
373
+ detail={"code": "DB_ERROR", "message": f"Failed to open database: {exc}"},
374
+ ) from exc
375
+
376
+
377
+ def _check_handler_error(result: Any) -> None:
378
+ """Raise HTTPException if the handler returned an error dict.
379
+
380
+ Handlers return {"error": "CODE", "message": "..."} on invalid input.
381
+ Map these to HTTP 4xx responses rather than 200s with error payloads.
382
+
383
+ Error code → HTTP status:
384
+ INVALID_INPUT → 400
385
+ INVALID_QUERY → 400
386
+ * → 400 (safe default for handler errors)
387
+ """
388
+ if isinstance(result, dict) and "error" in result:
389
+ code = result.get("error", "UNKNOWN")
390
+ message = result.get("message", "")
391
+ raise HTTPException(
392
+ status_code=400,
393
+ detail={"code": code, "message": message},
394
+ )
395
+
396
+
397
+ def _fetch_all_symbol_definitions(
398
+ conn: sqlite3.Connection,
399
+ symbol_name: str,
400
+ root: Path,
401
+ ) -> list[dict[str, Any]]:
402
+ """Fetch all definitions (file-level rows) for a symbol name.
403
+
404
+ Handles homonyms: multiple files can define the same name. Returns one entry
405
+ per (file, line) pair so the detail panel can list all of them.
406
+ """
407
+ rows = conn.execute(
408
+ """
409
+ SELECT
410
+ f.path AS file,
411
+ s.start_line AS line,
412
+ s.signature,
413
+ s.docstring,
414
+ s.visibility,
415
+ s.is_exported,
416
+ s.qualified_name,
417
+ s.decorators
418
+ FROM symbols s
419
+ JOIN files f ON f.id = s.file_id
420
+ WHERE s.name = ?
421
+ ORDER BY s.id
422
+ """,
423
+ (symbol_name,),
424
+ ).fetchall()
425
+
426
+ result = []
427
+ for row in rows:
428
+ # Relativize file path so the UI gets portable paths
429
+ try:
430
+ file_rel = str(Path(row["file"]).relative_to(root))
431
+ except ValueError:
432
+ file_rel = row["file"]
433
+
434
+ # Decode is_exported (stored as 0/1/NULL)
435
+ raw_exp = row["is_exported"]
436
+ is_exported: bool | None = None if raw_exp is None else bool(raw_exp)
437
+
438
+ # Decode decorators (stored as JSON text, NULL for pre-v5 rows)
439
+ raw_dec = row["decorators"]
440
+ decorators: list[str] = []
441
+ if raw_dec is not None:
442
+ try:
443
+ decorators = json.loads(raw_dec)
444
+ except (json.JSONDecodeError, TypeError, ValueError):
445
+ decorators = []
446
+
447
+ result.append({
448
+ "file": file_rel,
449
+ "line": row["line"],
450
+ "signature": row["signature"],
451
+ "docstring": row["docstring"],
452
+ "visibility": row["visibility"],
453
+ "is_exported": is_exported,
454
+ "qualified_name": row["qualified_name"],
455
+ "decorators": decorators,
456
+ })
457
+ return result
458
+
459
+
460
+ def _relativize_path(abs_path: str, root: Path) -> str:
461
+ """Return abs_path relative to root; fall back to abs_path if not under root."""
462
+ try:
463
+ return str(Path(abs_path).relative_to(root))
464
+ except ValueError:
465
+ return abs_path
466
+
467
+
468
+ def _fetch_languages(conn: sqlite3.Connection) -> list[str]:
469
+ """Return the distinct languages present in the index."""
470
+ rows = conn.execute("SELECT DISTINCT language FROM files WHERE language IS NOT NULL").fetchall()
471
+ return sorted(row["language"] for row in rows)
472
+
473
+
474
+ # ── App factory ───────────────────────────────────────────────────────────────
475
+
476
+
477
+ def create_web_app(db_path: Path, root: Path) -> FastAPI:
478
+ """Create and configure the Seam Explorer FastAPI application.
479
+
480
+ CRITICAL: do NOT open the DB or read any files here. Only call this function
481
+ during app construction. All DB access happens inside route handlers (per request).
482
+
483
+ Args:
484
+ db_path: Absolute path to the seam.db SQLite file.
485
+ root: Project root used for file path relativization in responses.
486
+
487
+ Returns:
488
+ Configured FastAPI application. Mount and run with uvicorn.
489
+ """
490
+ # FastAPI instance with metadata for the generated OpenAPI schema.
491
+ # The schema is consumed by openapi-typescript to generate src/api/types.ts.
492
+ app = FastAPI(
493
+ title="Seam Explorer API",
494
+ description="Local code-intelligence graph explorer for Seam indexes.",
495
+ version="1.0.0",
496
+ )
497
+
498
+ # ── Route: GET /api/status ────────────────────────────────────────────────
499
+
500
+ @app.get("/api/status", response_model=StatusResponse, tags=["status"])
501
+ def get_status() -> StatusResponse:
502
+ """Return index statistics and metadata.
503
+
504
+ Returns symbol_count, edge_count, cluster_count, last_indexed timestamp,
505
+ and the list of languages present in the index.
506
+ """
507
+ conn = _get_conn(db_path)
508
+ try:
509
+ symbol_count = conn.execute("SELECT COUNT(*) FROM symbols").fetchone()[0]
510
+ edge_count = conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
511
+ # Guard for pre-v4 indexes (no clusters table)
512
+ try:
513
+ cluster_count = conn.execute("SELECT COUNT(*) FROM clusters").fetchone()[0]
514
+ except Exception:
515
+ cluster_count = 0
516
+
517
+ # Most recent indexed_at across all files
518
+ last_indexed_row = conn.execute("SELECT MAX(indexed_at) FROM files").fetchone()[0]
519
+ last_indexed: str | None = None
520
+ if last_indexed_row is not None:
521
+ last_indexed = time.strftime(
522
+ "%Y-%m-%d %H:%M:%S", time.localtime(last_indexed_row)
523
+ )
524
+
525
+ languages = _fetch_languages(conn)
526
+ finally:
527
+ conn.close()
528
+
529
+ return StatusResponse(
530
+ root=str(root),
531
+ symbol_count=symbol_count,
532
+ edge_count=edge_count,
533
+ cluster_count=cluster_count,
534
+ last_indexed=last_indexed,
535
+ languages=languages,
536
+ )
537
+
538
+ # ── Route: GET /api/search ────────────────────────────────────────────────
539
+
540
+ @app.get("/api/search", response_model=SearchResponse, tags=["search"])
541
+ def get_search(
542
+ q: str = Query(..., description="Search query text"),
543
+ limit: int = Query(20, ge=1, le=100, description="Maximum results to return"),
544
+ ) -> SearchResponse:
545
+ """Full-text search over symbol names, docstrings, and signatures.
546
+
547
+ Reuses handle_seam_search which applies FTS5 + rescoring + LIKE/fuzzy fallback.
548
+ Returns matching symbols with their file location, kind, and cluster info.
549
+ """
550
+ conn = _get_conn(db_path)
551
+ try:
552
+ result = handle_seam_search(conn, q, root, limit=limit)
553
+ finally:
554
+ conn.close()
555
+
556
+ # Handler returns error dict on invalid input
557
+ _check_handler_error(result)
558
+
559
+ # Build response: handle_seam_search returns SearchResult dicts.
560
+ # SearchResult has: symbol, file, line, snippet, score.
561
+ # We need to enrich with kind, signature, cluster_id, cluster_label.
562
+ # Rather than re-query, accept that search results carry only the core FTS fields;
563
+ # the frontend can call /api/symbol/{name} for full detail on selection.
564
+ # Cast: _check_handler_error confirmed this is not an error dict, so it's a list.
565
+ search_rows = cast(list[dict[str, Any]], result)
566
+ items: list[SearchResultItem] = []
567
+ for r in search_rows:
568
+ items.append(SearchResultItem(
569
+ name=str(r["symbol"]),
570
+ kind="", # SearchResult doesn't include kind — caller uses symbol endpoint
571
+ file=str(r["file"]),
572
+ line=int(r["line"]),
573
+ signature=None, # SearchResult doesn't include signature
574
+ cluster_id=None, # SearchResult doesn't include cluster
575
+ cluster_label=None,
576
+ ))
577
+ return SearchResponse(results=items)
578
+
579
+ # ── Route: GET /api/graph/neighborhood ────────────────────────────────────
580
+
581
+ @app.get(
582
+ "/api/graph/neighborhood",
583
+ response_model=NeighborhoodResponse,
584
+ tags=["graph"],
585
+ )
586
+ def get_neighborhood(
587
+ symbol: str = Query(..., description="Symbol name to center the graph on"),
588
+ # Literal constrains the param at the boundary: FastAPI returns 422 for any
589
+ # value outside the set, rather than build_neighborhood silently treating a
590
+ # typo'd direction as "both" (the else branch). Defensive — the SPA only ever
591
+ # sends valid values, but a public localhost API shouldn't accept garbage.
592
+ direction: Literal["both", "callers", "callees"] = Query(
593
+ "both",
594
+ description="Edge direction: 'both' | 'callers' | 'callees'",
595
+ ),
596
+ ) -> NeighborhoodResponse:
597
+ """Return a depth-1 neighborhood graph for a symbol.
598
+
599
+ Nodes = unique symbol NAMES (homonym-collapse, consistent with seam_impact/trace).
600
+ Edges carry kind (call/import) and confidence (EXTRACTED/INFERRED/AMBIGUOUS).
601
+ Unknown symbol returns empty nodes/edges (not a 404 — the client may be expanding
602
+ a node whose declaration is outside the indexed files).
603
+ """
604
+ conn = _get_conn(db_path)
605
+ try:
606
+ neighborhood = build_neighborhood(conn, symbol, direction=direction)
607
+ finally:
608
+ conn.close()
609
+
610
+ return NeighborhoodResponse(
611
+ center=neighborhood["center"],
612
+ nodes=[GraphNode(**n) for n in neighborhood["nodes"]],
613
+ edges=[GraphEdge(**e) for e in neighborhood["edges"]],
614
+ )
615
+
616
+ # ── Route: GET /api/symbol/{name} ─────────────────────────────────────────
617
+
618
+ @app.get(
619
+ "/api/symbol/{name}",
620
+ response_model=SymbolResponse,
621
+ responses={404: {"model": dict, "description": "Symbol not found"}},
622
+ tags=["symbol"],
623
+ )
624
+ def get_symbol(name: str) -> SymbolResponse:
625
+ """Return full detail for a symbol name.
626
+
627
+ Returns ALL definitions (for homonyms), callers, callees, cluster info,
628
+ peers, and WHY/HACK/NOTE/TODO/FIXME comments.
629
+
630
+ Reuses handle_seam_context (360-degree view) and handle_seam_why.
631
+ 404 with {"found": false} when the symbol is not in the index.
632
+ """
633
+ conn = _get_conn(db_path)
634
+ try:
635
+ ctx = handle_seam_context(conn, name, root)
636
+ if ctx is None:
637
+ # Unknown symbol
638
+ raise HTTPException(status_code=404, detail={"found": False})
639
+ _check_handler_error(ctx)
640
+
641
+ # Fetch all definitions (handle_seam_context returns only the canonical one)
642
+ definitions = _fetch_all_symbol_definitions(conn, name, root)
643
+
644
+ # WHY comments for this symbol
645
+ why_raw = handle_seam_why(conn, root, symbol=name)
646
+ _check_handler_error(why_raw)
647
+ finally:
648
+ conn.close()
649
+
650
+ # Build cluster info (None when symbol is not clustered)
651
+ cluster: ClusterInfo | None = None
652
+ if ctx.get("cluster_id") is not None:
653
+ cluster = ClusterInfo(
654
+ id=ctx["cluster_id"],
655
+ label=ctx.get("cluster_label"),
656
+ )
657
+
658
+ # Build WHY comment list.
659
+ # Cast: _check_handler_error confirmed why_raw is not an error dict, so it's a list.
660
+ why_comments: list[WhyComment] = []
661
+ for w in cast(list[dict[str, Any]], why_raw or []):
662
+ why_comments.append(WhyComment(
663
+ kind=str(w.get("marker", "")),
664
+ text=str(w.get("text", "")),
665
+ file=str(w.get("file", "")),
666
+ line=int(w.get("line", 0)),
667
+ ))
668
+
669
+ return SymbolResponse(
670
+ name=name,
671
+ definitions=[SymbolDefinition(**d) for d in definitions],
672
+ callers=ctx.get("callers", []),
673
+ callees=ctx.get("callees", []),
674
+ cluster=cluster,
675
+ peers=ctx.get("cluster_peers", []),
676
+ why=why_comments,
677
+ )
678
+
679
+ # ── Route: GET /api/clusters ──────────────────────────────────────────────
680
+
681
+ @app.get("/api/clusters", response_model=ClustersResponse, tags=["clusters"])
682
+ def get_clusters() -> ClustersResponse:
683
+ """Return all functional-area clusters in the index.
684
+
685
+ Each cluster has an id, label (deterministic or LLM-generated), and size
686
+ (number of symbols). Returns an empty list when no clusters exist (e.g.
687
+ fresh index with SEAM_CLUSTER_MIN_SIZE=2 and very few edges).
688
+ """
689
+ conn = _get_conn(db_path)
690
+ try:
691
+ raw = handle_seam_clusters(conn, root)
692
+ # Pick one representative member symbol per cluster so the landing-page
693
+ # entry points open a real neighborhood (clusters aren't symbols). The
694
+ # name comes from the MIN(id) row per cluster → deterministic across runs.
695
+ rep_rows = conn.execute(
696
+ "SELECT cluster_id, name, MIN(id) FROM symbols "
697
+ "WHERE cluster_id IS NOT NULL GROUP BY cluster_id"
698
+ ).fetchall()
699
+ finally:
700
+ conn.close()
701
+
702
+ representatives = {row["cluster_id"]: row["name"] for row in rep_rows}
703
+ items: list[ClusterItem] = []
704
+ for c in raw: # type: ignore[union-attr]
705
+ items.append(ClusterItem(
706
+ cluster_id=c["id"],
707
+ label=c.get("label"),
708
+ size=c.get("size", 0),
709
+ representative=representatives.get(c["id"]),
710
+ ))
711
+ return ClustersResponse(clusters=items)
712
+
713
+ # ── Route: GET /api/impact ────────────────────────────────────────────────
714
+
715
+ @app.get("/api/impact", response_model=ImpactResponse, tags=["graph"])
716
+ def get_impact(
717
+ symbol: str = Query(..., description="Target symbol to analyze"),
718
+ direction: Literal["both", "upstream", "downstream"] = Query(
719
+ "both", description="Blast-radius direction"
720
+ ),
721
+ max_depth: int = Query(3, ge=1, le=10, description="Max traversal hops"),
722
+ include_tests: bool = Query(True, description="Include test-file dependents"),
723
+ limit: int = Query(25, ge=0, description="Per-tier cap (0 = unlimited)"),
724
+ ) -> ImpactResponse:
725
+ """Blast-radius analysis for a symbol, grouped by risk tier.
726
+
727
+ Reuses handle_seam_impact verbatim. verbose=False keeps the payload lean
728
+ (only name/distance/confidence/tier/file/is_test per entry). Unknown symbol
729
+ returns found:false with empty tiers (not a 404 — same contract as the MCP tool).
730
+ """
731
+ conn = _get_conn(db_path)
732
+ try:
733
+ result = handle_seam_impact(
734
+ conn,
735
+ symbol,
736
+ root,
737
+ direction=direction,
738
+ max_depth=max_depth,
739
+ include_tests=include_tests,
740
+ verbose=False,
741
+ limit=limit,
742
+ )
743
+ finally:
744
+ conn.close()
745
+
746
+ _check_handler_error(result)
747
+ res = cast(dict[str, Any], result)
748
+
749
+ def _to_tiers(group: dict[str, Any]) -> dict[str, list[ImpactEntry]]:
750
+ return {tier: [ImpactEntry(**e) for e in entries] for tier, entries in group.items()}
751
+
752
+ return ImpactResponse(
753
+ found=res["found"],
754
+ target=res["target"],
755
+ risk_summary=res.get("risk_summary", {}),
756
+ upstream=_to_tiers(res["upstream"]) if "upstream" in res else None,
757
+ downstream=_to_tiers(res["downstream"]) if "downstream" in res else None,
758
+ truncated=res.get("truncated"),
759
+ )
760
+
761
+ # ── Route: GET /api/trace ─────────────────────────────────────────────────
762
+
763
+ @app.get("/api/trace", response_model=TraceResponse, tags=["graph"])
764
+ def get_trace(
765
+ source: str = Query(..., description="Path start symbol"),
766
+ target: str = Query(..., description="Path end symbol"),
767
+ max_depth: int = Query(10, ge=1, le=10, description="Max path length in hops"),
768
+ ) -> TraceResponse:
769
+ """Shortest call/dependency path from source to target.
770
+
771
+ Reuses handle_seam_trace; only `paths` is surfaced (verbose=False strips
772
+ per-hop provenance). found:false + empty paths when unconnected.
773
+ """
774
+ conn = _get_conn(db_path)
775
+ try:
776
+ result = handle_seam_trace(
777
+ conn, source, target, root, max_depth=max_depth, verbose=False
778
+ )
779
+ finally:
780
+ conn.close()
781
+
782
+ _check_handler_error(result)
783
+ res = cast(dict[str, Any], result)
784
+
785
+ return TraceResponse(
786
+ found=res["found"],
787
+ source=res["source"],
788
+ target=res["target"],
789
+ paths=[[TraceHop(**hop) for hop in path] for path in res["paths"]],
790
+ )
791
+
792
+ # ── Route: GET /api/changes ───────────────────────────────────────────────
793
+
794
+ @app.get("/api/changes", response_model=ChangesResponse, tags=["graph"])
795
+ def get_changes(
796
+ scope: Literal["working", "staged", "branch"] = Query(
797
+ "working", description="Diff scope"
798
+ ),
799
+ base_ref: str = Query("HEAD", description="Base ref (branch scope only)"),
800
+ ) -> ChangesResponse:
801
+ """Git diff → changed symbols → risk level.
802
+
803
+ Reuses handle_seam_changes. NOT_A_GIT_REPO (non-git root) is mapped to 400
804
+ by _check_handler_error, which the SPA shows as a friendly notice.
805
+ """
806
+ conn = _get_conn(db_path)
807
+ try:
808
+ result = handle_seam_changes(conn, root, base_ref=base_ref, scope=scope)
809
+ finally:
810
+ conn.close()
811
+
812
+ _check_handler_error(result)
813
+ res = cast(dict[str, Any], result)
814
+
815
+ return ChangesResponse(
816
+ changed_symbols=[ChangedSymbol(**s) for s in res["changed_symbols"]],
817
+ new_files=res["new_files"],
818
+ affected=[AffectedEntry(**a) for a in res["affected"]],
819
+ risk_level=res["risk_level"],
820
+ ambiguous_warning=res["ambiguous_warning"],
821
+ scope=res["scope"],
822
+ base_ref=res["base_ref"],
823
+ partial=res["partial"],
824
+ )
825
+
826
+ # ── Route: GET /api/constellation ─────────────────────────────────────────
827
+
828
+ @app.get(
829
+ "/api/constellation", response_model=ConstellationResponse, tags=["graph"]
830
+ )
831
+ def get_constellation() -> ConstellationResponse:
832
+ """Whole-repo overview: cluster regions + weighted inter-cluster links.
833
+
834
+ Reuses build_constellation (graph_api). Empty/pre-v4 index → empty envelope.
835
+ """
836
+ conn = _get_conn(db_path)
837
+ try:
838
+ data = build_constellation(conn)
839
+ finally:
840
+ conn.close()
841
+
842
+ return ConstellationResponse(
843
+ clusters=[ConstellationCluster(**c) for c in data["clusters"]],
844
+ links=[ConstellationLink(**link) for link in data["links"]],
845
+ )
846
+
847
+ # ── Route: GET /api/hubs ──────────────────────────────────────────────────
848
+
849
+ @app.get("/api/hubs", response_model=HubsResponse, tags=["graph"])
850
+ def get_hubs(
851
+ limit: int = Query(60, ge=1, le=200, description="How many hub symbols to return"),
852
+ ) -> HubsResponse:
853
+ """Return the most-connected symbols — landing-page entry points.
854
+
855
+ Reuses graph_api.top_hub_symbols (degree-ranked, defined-only). The default
856
+ is high enough (60) that the Explorer's area cards each get covered when it
857
+ buckets hubs by their declaring path.
858
+ """
859
+ conn = _get_conn(db_path)
860
+ try:
861
+ hubs = top_hub_symbols(conn, limit=limit)
862
+ finally:
863
+ conn.close()
864
+ # Relativize each hub's representative path so the UI matches it against
865
+ # the (already relativized) structure paths when bucketing into areas.
866
+ symbols = [
867
+ HubSymbol(
868
+ name=h["name"],
869
+ kind=h["kind"],
870
+ degree=h["degree"],
871
+ path=_relativize_path(h["path"], root) if h["path"] else None,
872
+ )
873
+ for h in hubs
874
+ ]
875
+ return HubsResponse(symbols=symbols)
876
+
877
+ # ── Route: GET /api/structure ─────────────────────────────────────────────
878
+
879
+ @app.get("/api/structure", response_model=StructureResponse, tags=["graph"])
880
+ def get_structure() -> StructureResponse:
881
+ """Return every symbol with its file path — source for the structure treemap.
882
+
883
+ Reuses graph_api.list_structure; paths are relativized to the project root.
884
+ The SPA builds the folder → file → class → method tree from this flat list.
885
+ """
886
+ conn = _get_conn(db_path)
887
+ try:
888
+ rows = list_structure(conn)
889
+ finally:
890
+ conn.close()
891
+
892
+ symbols = [
893
+ StructureSymbol(
894
+ path=_relativize_path(r["path"], root),
895
+ name=r["name"],
896
+ kind=r["kind"],
897
+ line=r["line"],
898
+ qualified_name=r["qualified_name"],
899
+ )
900
+ for r in rows
901
+ ]
902
+ return StructureResponse(symbols=symbols)
903
+
904
+ # ── Static SPA mount ─────────────────────────────────────────────────────
905
+ # Mount the built SPA at '/'. This must come AFTER all /api/* routes so FastAPI
906
+ # resolves API routes before falling through to static files.
907
+ # html=True enables index.html serving for client-side routing (SPA fallback).
908
+
909
+ web_dir = Path(__file__).parent.parent / "_web"
910
+ if web_dir.exists() and web_dir.is_dir():
911
+ # Built SPA present — serve it.
912
+ app.mount("/", StaticFiles(directory=str(web_dir), html=True), name="static")
913
+ else:
914
+ # Frontend not built yet — serve a helpful hint page at '/'.
915
+ # WHY a separate route instead of StaticFiles: StaticFiles requires the
916
+ # directory to exist; a plain GET route is the fallback that always works.
917
+ @app.get("/", response_class=HTMLResponse, include_in_schema=False)
918
+ def spa_root() -> HTMLResponse:
919
+ """Fallback landing page when seam/_web/ is absent."""
920
+ return HTMLResponse(_BUILD_HINT_HTML)
921
+
922
+ return app