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