descry-codegraph 0.1.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.
- descry/__init__.py +4 -0
- descry/_env.py +30 -0
- descry/_graph.py +36 -0
- descry/ast_grep.py +382 -0
- descry/cli.py +457 -0
- descry/cross_lang.py +404 -0
- descry/embeddings.py +593 -0
- descry/generate.py +3579 -0
- descry/git_history.py +1631 -0
- descry/handlers.py +1907 -0
- descry/mcp_server.py +355 -0
- descry/query.py +2345 -0
- descry/scip/__init__.py +0 -0
- descry/scip/cache.py +1047 -0
- descry/scip/parser.py +531 -0
- descry/scip/pb2.py +92 -0
- descry/scip/support.py +174 -0
- descry/tree_sitter_parser.py +356 -0
- descry/web/__init__.py +0 -0
- descry/web/server.py +1457 -0
- descry/web/web/index.html +2444 -0
- descry/web/web/style.css +1791 -0
- descry_codegraph-0.1.0.dist-info/METADATA +345 -0
- descry_codegraph-0.1.0.dist-info/RECORD +27 -0
- descry_codegraph-0.1.0.dist-info/WHEEL +4 -0
- descry_codegraph-0.1.0.dist-info/entry_points.txt +5 -0
- descry_codegraph-0.1.0.dist-info/licenses/LICENSE +21 -0
descry/web/server.py
ADDED
|
@@ -0,0 +1,1457 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Descry Web UI Server
|
|
4
|
+
|
|
5
|
+
Browser-based interface for codebase knowledge graph tools.
|
|
6
|
+
Reuses the same underlying modules as the MCP server.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
uv run descry-web [--port 8787] [--host 127.0.0.1]
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import asyncio
|
|
14
|
+
import errno
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import stat
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from collections import defaultdict
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import uvicorn
|
|
26
|
+
from starlette.applications import Starlette
|
|
27
|
+
from starlette.middleware import Middleware
|
|
28
|
+
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
|
29
|
+
from starlette.requests import Request
|
|
30
|
+
from starlette.responses import JSONResponse, FileResponse, StreamingResponse
|
|
31
|
+
from starlette.routing import Route, Mount
|
|
32
|
+
from starlette.staticfiles import StaticFiles
|
|
33
|
+
|
|
34
|
+
from descry._env import safe_env
|
|
35
|
+
from descry import __version__
|
|
36
|
+
|
|
37
|
+
# Configure logging
|
|
38
|
+
_log_level = os.environ.get("DESCRY_LOG_LEVEL", "INFO")
|
|
39
|
+
logging.basicConfig(
|
|
40
|
+
level=getattr(logging, _log_level.upper(), logging.INFO),
|
|
41
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
42
|
+
)
|
|
43
|
+
logger = logging.getLogger("descry_web")
|
|
44
|
+
|
|
45
|
+
SERVER_VERSION = __version__
|
|
46
|
+
|
|
47
|
+
# --- Import shared modules ---
|
|
48
|
+
|
|
49
|
+
from descry.query import GraphQuerier, _get_syntax_lang # noqa: E402
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
from descry.cross_lang import CrossLangTracer
|
|
53
|
+
|
|
54
|
+
CROSS_LANG_AVAILABLE = True
|
|
55
|
+
except ImportError:
|
|
56
|
+
CROSS_LANG_AVAILABLE = False
|
|
57
|
+
CrossLangTracer = None
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
from descry.embeddings import (
|
|
61
|
+
embeddings_available,
|
|
62
|
+
SemanticSearcher,
|
|
63
|
+
get_embeddings_status,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
SEMANTIC_AVAILABLE = embeddings_available()
|
|
67
|
+
except ImportError:
|
|
68
|
+
SEMANTIC_AVAILABLE = False
|
|
69
|
+
SemanticSearcher = None
|
|
70
|
+
|
|
71
|
+
def get_embeddings_status(*args, **kwargs):
|
|
72
|
+
return {"available": False}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
from descry.scip.support import scip_available, get_scip_status
|
|
77
|
+
except ImportError:
|
|
78
|
+
|
|
79
|
+
def scip_available():
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
def get_scip_status():
|
|
83
|
+
return {"available": False}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
from descry.git_history import GitHistoryAnalyzer, GitError
|
|
88
|
+
|
|
89
|
+
GIT_HISTORY_AVAILABLE = True
|
|
90
|
+
except ImportError:
|
|
91
|
+
GIT_HISTORY_AVAILABLE = False
|
|
92
|
+
GitHistoryAnalyzer = None
|
|
93
|
+
GitError = Exception
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --- Project config (lazy-loaded via DescryConfig) ---
|
|
97
|
+
|
|
98
|
+
from descry.handlers import DescryConfig, DescryService # noqa: E402
|
|
99
|
+
|
|
100
|
+
WEB_DIR = Path(__file__).parent / "web"
|
|
101
|
+
|
|
102
|
+
_config: DescryConfig | None = None
|
|
103
|
+
_service: DescryService | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _get_config() -> DescryConfig:
|
|
107
|
+
global _config
|
|
108
|
+
if _config is None:
|
|
109
|
+
_config = DescryConfig.from_env()
|
|
110
|
+
return _config
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _get_service() -> DescryService:
|
|
114
|
+
global _service
|
|
115
|
+
if _service is None:
|
|
116
|
+
_service = DescryService(_get_config())
|
|
117
|
+
return _service
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --- Helper functions (pure functions) ---
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def is_natural_language_query(terms: list[str]) -> bool:
|
|
124
|
+
text = " ".join(terms).lower()
|
|
125
|
+
nl_indicators = [
|
|
126
|
+
"how to",
|
|
127
|
+
"what is",
|
|
128
|
+
"where is",
|
|
129
|
+
"where are",
|
|
130
|
+
"find the",
|
|
131
|
+
"show me",
|
|
132
|
+
"get the",
|
|
133
|
+
"look for",
|
|
134
|
+
"search for",
|
|
135
|
+
"related to",
|
|
136
|
+
"that handles",
|
|
137
|
+
"that does",
|
|
138
|
+
"responsible for",
|
|
139
|
+
"used for",
|
|
140
|
+
"deals with",
|
|
141
|
+
]
|
|
142
|
+
if any(p in text for p in nl_indicators):
|
|
143
|
+
return True
|
|
144
|
+
if terms and terms[0].lower() in ("how", "what", "where", "why", "which", "find"):
|
|
145
|
+
return True
|
|
146
|
+
code_patterns = [r"[a-z]+_[a-z]+", r"[a-z]+[A-Z][a-z]+", r"[A-Z][a-z]+[A-Z]", r"::"]
|
|
147
|
+
for pattern in code_patterns:
|
|
148
|
+
if re.search(pattern, text):
|
|
149
|
+
return False
|
|
150
|
+
return len(terms) >= 3
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def reciprocal_rank_fusion(
|
|
154
|
+
tfidf_results: list, semantic_results: list, k: int = 60
|
|
155
|
+
) -> list:
|
|
156
|
+
rrf_scores = defaultdict(float)
|
|
157
|
+
node_lookup = {}
|
|
158
|
+
for rank, node in enumerate(tfidf_results):
|
|
159
|
+
node_id = node["id"]
|
|
160
|
+
rrf_scores[node_id] += 1.0 / (k + rank + 1)
|
|
161
|
+
node_lookup[node_id] = node
|
|
162
|
+
for rank, (node, _) in enumerate(semantic_results):
|
|
163
|
+
node_id = node["id"]
|
|
164
|
+
rrf_scores[node_id] += 1.0 / (k + rank + 1)
|
|
165
|
+
node_lookup[node_id] = node
|
|
166
|
+
sorted_ids = sorted(rrf_scores.keys(), key=lambda x: rrf_scores[x], reverse=True)
|
|
167
|
+
return [(node_lookup[nid], rrf_scores[nid]) for nid in sorted_ids]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --- Cached instances (delegate to DescryService) ---
|
|
171
|
+
|
|
172
|
+
_graph_meta = {"mtime": 0, "nodes": 0, "edges": 0}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def _get_querier() -> GraphQuerier | None:
|
|
176
|
+
"""Get GraphQuerier via DescryService (lock-disciplined, async)."""
|
|
177
|
+
return await _get_service()._get_querier()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def _get_semantic_searcher() -> "SemanticSearcher | None":
|
|
181
|
+
return await _get_service()._get_semantic_searcher()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def _get_git_analyzer() -> "GitHistoryAnalyzer | None":
|
|
185
|
+
if not GIT_HISTORY_AVAILABLE:
|
|
186
|
+
return None
|
|
187
|
+
return await _get_service()._get_git_analyzer()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _update_graph_meta():
|
|
191
|
+
global _graph_meta
|
|
192
|
+
cfg = _get_config()
|
|
193
|
+
if cfg.graph_path.exists():
|
|
194
|
+
mtime = cfg.graph_path.stat().st_mtime
|
|
195
|
+
if mtime != _graph_meta["mtime"]:
|
|
196
|
+
try:
|
|
197
|
+
from descry._graph import load_graph_with_schema
|
|
198
|
+
|
|
199
|
+
data = load_graph_with_schema(cfg.graph_path)
|
|
200
|
+
_graph_meta = {
|
|
201
|
+
"mtime": mtime,
|
|
202
|
+
"nodes": len(data.get("nodes", [])),
|
|
203
|
+
"edges": len(data.get("edges", [])),
|
|
204
|
+
}
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _graph_status() -> dict:
|
|
210
|
+
cfg = _get_config()
|
|
211
|
+
if not cfg.graph_path.exists():
|
|
212
|
+
return {
|
|
213
|
+
"exists": False,
|
|
214
|
+
"age_str": "N/A",
|
|
215
|
+
"age_hours": None,
|
|
216
|
+
"nodes": 0,
|
|
217
|
+
"edges": 0,
|
|
218
|
+
}
|
|
219
|
+
mtime = cfg.graph_path.stat().st_mtime
|
|
220
|
+
age_hours = (time.time() - mtime) / 3600
|
|
221
|
+
_update_graph_meta()
|
|
222
|
+
return {
|
|
223
|
+
"exists": True,
|
|
224
|
+
"age_str": f"{age_hours:.1f}h ago",
|
|
225
|
+
"age_hours": round(age_hours, 2),
|
|
226
|
+
"nodes": _graph_meta["nodes"],
|
|
227
|
+
"edges": _graph_meta["edges"],
|
|
228
|
+
"stale": age_hours > cfg.max_stale_hours,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _node_to_dict(node: dict) -> dict:
|
|
233
|
+
"""Convert a graph node to a JSON-serializable dict for the frontend."""
|
|
234
|
+
meta = node.get("metadata", {})
|
|
235
|
+
node_id = node.get("id", "")
|
|
236
|
+
file_path = ""
|
|
237
|
+
if node_id.startswith("FILE:"):
|
|
238
|
+
file_path = node_id.split("::")[0].replace("FILE:", "")
|
|
239
|
+
lineno = meta.get("lineno")
|
|
240
|
+
return {
|
|
241
|
+
"id": node_id,
|
|
242
|
+
"type": node.get("type", "?"),
|
|
243
|
+
"name": meta.get("name", "unknown"),
|
|
244
|
+
"parent_name": meta.get("parent_name", ""),
|
|
245
|
+
"file_path": file_path,
|
|
246
|
+
"lineno": lineno,
|
|
247
|
+
"location": f"{file_path}:{lineno}" if file_path and lineno else file_path,
|
|
248
|
+
"signature": meta.get("signature", ""),
|
|
249
|
+
"docstring": meta.get("docstring", ""),
|
|
250
|
+
"token_count": meta.get("token_count", 0),
|
|
251
|
+
"in_degree": meta.get("in_degree", 0),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _caller_to_dict(caller_id: str, q: GraphQuerier) -> dict:
|
|
256
|
+
"""Convert a caller node ID to a dict with location info."""
|
|
257
|
+
result = {"id": caller_id, "name": caller_id}
|
|
258
|
+
file_path = ""
|
|
259
|
+
if caller_id.startswith("FILE:"):
|
|
260
|
+
parts = caller_id.split("::")
|
|
261
|
+
file_path = parts[0].replace("FILE:", "")
|
|
262
|
+
if len(parts) > 1:
|
|
263
|
+
result["name"] = parts[-1]
|
|
264
|
+
node_info = q.get_node_info(caller_id)
|
|
265
|
+
if node_info:
|
|
266
|
+
meta = node_info.get("metadata", {})
|
|
267
|
+
lineno = meta.get("lineno")
|
|
268
|
+
result["file_path"] = file_path
|
|
269
|
+
result["lineno"] = lineno
|
|
270
|
+
result["location"] = f"{file_path}:{lineno}" if lineno else file_path
|
|
271
|
+
result["type"] = node_info.get("type", "?")
|
|
272
|
+
result["signature"] = meta.get("signature", "")
|
|
273
|
+
else:
|
|
274
|
+
result["file_path"] = file_path
|
|
275
|
+
result["location"] = file_path
|
|
276
|
+
return result
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _openapi_path_exists() -> bool:
|
|
280
|
+
"""Check if an OpenAPI spec file exists in the project."""
|
|
281
|
+
cfg = _get_config()
|
|
282
|
+
for name in ("latest.json", "openapi.json"):
|
|
283
|
+
if (cfg.project_root / "public" / "api" / name).exists():
|
|
284
|
+
return True
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# --- API Endpoint Handlers ---
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
async def api_health(request: Request) -> JSONResponse:
|
|
292
|
+
status = _graph_status()
|
|
293
|
+
return JSONResponse(
|
|
294
|
+
{
|
|
295
|
+
"status": "ok"
|
|
296
|
+
if status["exists"] and not status.get("stale")
|
|
297
|
+
else ("stale" if status.get("stale") else "no_graph"),
|
|
298
|
+
"version": SERVER_VERSION,
|
|
299
|
+
"project_root": str(_get_config().project_root),
|
|
300
|
+
"graph": status,
|
|
301
|
+
"features": {
|
|
302
|
+
"scip": scip_available(),
|
|
303
|
+
"embeddings": SEMANTIC_AVAILABLE,
|
|
304
|
+
"git_history": GIT_HISTORY_AVAILABLE,
|
|
305
|
+
"cross_lang": CROSS_LANG_AVAILABLE and _openapi_path_exists(),
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async def api_status(request: Request) -> JSONResponse:
|
|
312
|
+
status = _graph_status()
|
|
313
|
+
return JSONResponse(status)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
async def api_ensure(request: Request) -> JSONResponse:
|
|
317
|
+
body = await request.json() if request.method == "POST" else {}
|
|
318
|
+
max_age = body.get("max_age_hours", 24)
|
|
319
|
+
status = _graph_status()
|
|
320
|
+
|
|
321
|
+
if not status["exists"]:
|
|
322
|
+
result = await _run_index()
|
|
323
|
+
return JSONResponse(
|
|
324
|
+
{"action": "generated", "result": result, "graph": _graph_status()}
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if status["age_hours"] and status["age_hours"] > max_age:
|
|
328
|
+
result = await _run_index()
|
|
329
|
+
return JSONResponse(
|
|
330
|
+
{"action": "refreshed", "result": result, "graph": _graph_status()}
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
return JSONResponse({"action": "ready", "graph": status})
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
async def _run_index() -> str:
|
|
337
|
+
"""Run the indexer on the configured project root (H.2: no caller input).
|
|
338
|
+
|
|
339
|
+
Uses sys.executable -m descry.generate so it works without `uv` on PATH (B.1).
|
|
340
|
+
Wrapped in asyncio.to_thread so the event loop stays responsive (C.1).
|
|
341
|
+
Subprocess env is sanitized (A.5).
|
|
342
|
+
"""
|
|
343
|
+
import subprocess
|
|
344
|
+
|
|
345
|
+
cfg = _get_config()
|
|
346
|
+
try:
|
|
347
|
+
result = await asyncio.to_thread(
|
|
348
|
+
subprocess.run,
|
|
349
|
+
[sys.executable, "-m", "descry.generate", str(cfg.project_root)],
|
|
350
|
+
capture_output=True,
|
|
351
|
+
text=True,
|
|
352
|
+
cwd=str(cfg.project_root),
|
|
353
|
+
timeout=600,
|
|
354
|
+
env=safe_env(),
|
|
355
|
+
)
|
|
356
|
+
return (
|
|
357
|
+
result.stdout.strip()
|
|
358
|
+
if result.returncode == 0
|
|
359
|
+
else f"Error: {result.stderr}"
|
|
360
|
+
)
|
|
361
|
+
except subprocess.TimeoutExpired:
|
|
362
|
+
return "Timed out after 10 minutes"
|
|
363
|
+
except Exception as e:
|
|
364
|
+
return f"Error: {e}"
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _reset_caches():
|
|
368
|
+
"""Reset all caches after reindexing."""
|
|
369
|
+
_get_service().reset_caches()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
async def api_index(request: Request) -> JSONResponse:
|
|
373
|
+
# H.2: path body param removed; always index cfg.project_root.
|
|
374
|
+
result = await _run_index()
|
|
375
|
+
_reset_caches()
|
|
376
|
+
return JSONResponse({"result": result, "graph": _graph_status()})
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
async def api_index_stream(request: Request) -> StreamingResponse:
|
|
380
|
+
"""Streaming reindex endpoint using Server-Sent Events for live output."""
|
|
381
|
+
|
|
382
|
+
async def event_stream():
|
|
383
|
+
yield f"data: {json.dumps({'type': 'start', 'message': 'Starting reindex...'})}\n\n"
|
|
384
|
+
|
|
385
|
+
cfg = _get_config()
|
|
386
|
+
env = {**safe_env(), "PYTHONUNBUFFERED": "1"}
|
|
387
|
+
proc = await asyncio.create_subprocess_exec(
|
|
388
|
+
sys.executable,
|
|
389
|
+
"-m",
|
|
390
|
+
"descry.generate",
|
|
391
|
+
str(cfg.project_root),
|
|
392
|
+
stdout=asyncio.subprocess.PIPE,
|
|
393
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
394
|
+
cwd=str(cfg.project_root),
|
|
395
|
+
env=env,
|
|
396
|
+
limit=10 * 1024 * 1024, # 10MB line buffer (indexer can emit large lines)
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
while True:
|
|
401
|
+
line = await asyncio.wait_for(proc.stdout.readline(), timeout=660)
|
|
402
|
+
if not line:
|
|
403
|
+
break
|
|
404
|
+
text = line.decode("utf-8", errors="replace").rstrip()
|
|
405
|
+
if text:
|
|
406
|
+
yield f"data: {json.dumps({'type': 'output', 'line': text})}\n\n"
|
|
407
|
+
except asyncio.TimeoutError:
|
|
408
|
+
proc.kill()
|
|
409
|
+
yield f"data: {json.dumps({'type': 'error', 'message': 'Timed out after 11 minutes'})}\n\n"
|
|
410
|
+
yield f"data: {json.dumps({'type': 'done', 'success': False})}\n\n"
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
await proc.wait()
|
|
414
|
+
success = proc.returncode == 0
|
|
415
|
+
|
|
416
|
+
if success:
|
|
417
|
+
_reset_caches()
|
|
418
|
+
graph = _graph_status()
|
|
419
|
+
yield f"data: {json.dumps({'type': 'done', 'success': True, 'graph': graph})}\n\n"
|
|
420
|
+
else:
|
|
421
|
+
yield f"data: {json.dumps({'type': 'done', 'success': False, 'message': f'Process exited with code {proc.returncode}'})}\n\n"
|
|
422
|
+
|
|
423
|
+
return StreamingResponse(
|
|
424
|
+
event_stream(),
|
|
425
|
+
media_type="text/event-stream",
|
|
426
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
async def api_search(request: Request) -> JSONResponse:
|
|
431
|
+
terms_str = request.query_params.get("terms", "")
|
|
432
|
+
terms = [t.strip() for t in terms_str.split(",") if t.strip()]
|
|
433
|
+
if not terms:
|
|
434
|
+
return JSONResponse({"error": "Missing 'terms' parameter"}, status_code=400)
|
|
435
|
+
|
|
436
|
+
limit = int(request.query_params.get("limit", "10"))
|
|
437
|
+
lang = request.query_params.get("lang")
|
|
438
|
+
crate = request.query_params.get("crate")
|
|
439
|
+
symbol_type = request.query_params.get("type")
|
|
440
|
+
exclude_tests = request.query_params.get("exclude_tests", "").lower() == "true"
|
|
441
|
+
|
|
442
|
+
q = await _get_querier()
|
|
443
|
+
if not q:
|
|
444
|
+
return JSONResponse(
|
|
445
|
+
{"error": "Graph not found. Run ensure first."}, status_code=503
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
# TF-IDF search
|
|
449
|
+
tfidf_results = q.search_docs(
|
|
450
|
+
terms,
|
|
451
|
+
lang=lang if lang != "all" else None,
|
|
452
|
+
crate=crate,
|
|
453
|
+
symbol_type=symbol_type if symbol_type != "all" else None,
|
|
454
|
+
exclude_tests=exclude_tests,
|
|
455
|
+
)[: limit * 2]
|
|
456
|
+
|
|
457
|
+
# Semantic search if available
|
|
458
|
+
semantic_results = []
|
|
459
|
+
search_method = "keyword"
|
|
460
|
+
if SEMANTIC_AVAILABLE and _get_config().graph_path.exists():
|
|
461
|
+
if is_natural_language_query(terms) or len(tfidf_results) < 3:
|
|
462
|
+
try:
|
|
463
|
+
searcher = await _get_semantic_searcher()
|
|
464
|
+
if searcher:
|
|
465
|
+
query = " ".join(terms)
|
|
466
|
+
# CPU-bound numpy/encode; run off the event loop.
|
|
467
|
+
semantic_results = await asyncio.to_thread(
|
|
468
|
+
searcher.search,
|
|
469
|
+
query,
|
|
470
|
+
limit=limit * 2,
|
|
471
|
+
min_score=0.25,
|
|
472
|
+
)
|
|
473
|
+
search_method = "hybrid"
|
|
474
|
+
except Exception as e:
|
|
475
|
+
logger.warning(f"Semantic search failed: {e}")
|
|
476
|
+
|
|
477
|
+
# Combine
|
|
478
|
+
if semantic_results and tfidf_results:
|
|
479
|
+
combined = reciprocal_rank_fusion(tfidf_results, semantic_results)
|
|
480
|
+
results = [node for node, _ in combined[:limit]]
|
|
481
|
+
search_method = "hybrid"
|
|
482
|
+
elif tfidf_results:
|
|
483
|
+
results = tfidf_results[:limit]
|
|
484
|
+
else:
|
|
485
|
+
results = []
|
|
486
|
+
|
|
487
|
+
return JSONResponse(
|
|
488
|
+
{
|
|
489
|
+
"results": [_node_to_dict(n) for n in results],
|
|
490
|
+
"method": search_method,
|
|
491
|
+
"query": " ".join(terms),
|
|
492
|
+
"total": len(results),
|
|
493
|
+
}
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
async def api_semantic(request: Request) -> JSONResponse:
|
|
498
|
+
query = request.query_params.get("query", "")
|
|
499
|
+
if not query:
|
|
500
|
+
return JSONResponse({"error": "Missing 'query' parameter"}, status_code=400)
|
|
501
|
+
limit = int(request.query_params.get("limit", "10"))
|
|
502
|
+
|
|
503
|
+
if not SEMANTIC_AVAILABLE:
|
|
504
|
+
return JSONResponse({"error": "Semantic search not available"}, status_code=503)
|
|
505
|
+
|
|
506
|
+
searcher = await _get_semantic_searcher()
|
|
507
|
+
if not searcher:
|
|
508
|
+
return JSONResponse({"error": "Graph not found"}, status_code=503)
|
|
509
|
+
|
|
510
|
+
try:
|
|
511
|
+
results = await asyncio.to_thread(searcher.search, query, limit=limit)
|
|
512
|
+
except Exception as e:
|
|
513
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
514
|
+
|
|
515
|
+
return JSONResponse(
|
|
516
|
+
{
|
|
517
|
+
"results": [
|
|
518
|
+
{**_node_to_dict(node), "score": round(score, 4)}
|
|
519
|
+
for node, score in results
|
|
520
|
+
],
|
|
521
|
+
"query": query,
|
|
522
|
+
"total": len(results),
|
|
523
|
+
}
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
async def api_callers(request: Request) -> JSONResponse:
|
|
528
|
+
name = request.query_params.get("name", "")
|
|
529
|
+
if not name:
|
|
530
|
+
return JSONResponse({"error": "Missing 'name' parameter"}, status_code=400)
|
|
531
|
+
limit = int(request.query_params.get("limit", "20"))
|
|
532
|
+
|
|
533
|
+
q = await _get_querier()
|
|
534
|
+
if not q:
|
|
535
|
+
return JSONResponse({"error": "Graph not found"}, status_code=503)
|
|
536
|
+
|
|
537
|
+
all_callers = q.get_callers(name)
|
|
538
|
+
fuzzy = False
|
|
539
|
+
if not all_callers:
|
|
540
|
+
all_callers = q.get_callers(name, fuzzy=True)
|
|
541
|
+
fuzzy = bool(all_callers)
|
|
542
|
+
|
|
543
|
+
callers = sorted(all_callers)[:limit]
|
|
544
|
+
return JSONResponse(
|
|
545
|
+
{
|
|
546
|
+
"symbol": name,
|
|
547
|
+
"fuzzy": fuzzy,
|
|
548
|
+
"total": len(all_callers),
|
|
549
|
+
"callers": [_caller_to_dict(c, q) for c in callers],
|
|
550
|
+
}
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
async def api_callees(request: Request) -> JSONResponse:
|
|
555
|
+
name = request.query_params.get("name", "")
|
|
556
|
+
if not name:
|
|
557
|
+
return JSONResponse({"error": "Missing 'name' parameter"}, status_code=400)
|
|
558
|
+
limit = int(request.query_params.get("limit", "20"))
|
|
559
|
+
|
|
560
|
+
q = await _get_querier()
|
|
561
|
+
if not q:
|
|
562
|
+
return JSONResponse({"error": "Graph not found"}, status_code=503)
|
|
563
|
+
|
|
564
|
+
matches = q.find_nodes_by_name(name)
|
|
565
|
+
func_matches = [m for m in matches if m["type"] in ("Function", "Method")]
|
|
566
|
+
fuzzy = False
|
|
567
|
+
if not func_matches:
|
|
568
|
+
matches = q.find_nodes_by_name(name, fuzzy=True)
|
|
569
|
+
func_matches = [m for m in matches if m["type"] in ("Function", "Method")]
|
|
570
|
+
fuzzy = bool(func_matches)
|
|
571
|
+
|
|
572
|
+
if not func_matches:
|
|
573
|
+
return JSONResponse(
|
|
574
|
+
{
|
|
575
|
+
"symbol": name,
|
|
576
|
+
"fuzzy": False,
|
|
577
|
+
"total": 0,
|
|
578
|
+
"callees": [],
|
|
579
|
+
"error": "Symbol not found",
|
|
580
|
+
}
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
node = func_matches[0]
|
|
584
|
+
callees = sorted(q.get_callees(node["id"]))[:limit]
|
|
585
|
+
|
|
586
|
+
return JSONResponse(
|
|
587
|
+
{
|
|
588
|
+
"symbol": node.get("metadata", {}).get("name", name),
|
|
589
|
+
"node_id": node["id"],
|
|
590
|
+
"fuzzy": fuzzy,
|
|
591
|
+
"total": len(callees),
|
|
592
|
+
"callees": [_caller_to_dict(c, q) for c in callees],
|
|
593
|
+
}
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
async def api_context(request: Request) -> JSONResponse:
|
|
598
|
+
node_id = request.query_params.get("node_id", "")
|
|
599
|
+
if not node_id:
|
|
600
|
+
return JSONResponse({"error": "Missing 'node_id' parameter"}, status_code=400)
|
|
601
|
+
|
|
602
|
+
full = request.query_params.get("full", "").lower() == "true"
|
|
603
|
+
brief = request.query_params.get("brief", "").lower() == "true"
|
|
604
|
+
expand_callees = request.query_params.get("expand_callees", "").lower() == "true"
|
|
605
|
+
depth = int(request.query_params.get("depth", "1"))
|
|
606
|
+
max_tokens = int(request.query_params.get("max_tokens", "2000"))
|
|
607
|
+
|
|
608
|
+
q = await _get_querier()
|
|
609
|
+
if not q:
|
|
610
|
+
return JSONResponse({"error": "Graph not found"}, status_code=503)
|
|
611
|
+
|
|
612
|
+
result = q.get_context_prompt(
|
|
613
|
+
node_id,
|
|
614
|
+
depth=depth,
|
|
615
|
+
max_tokens=max_tokens,
|
|
616
|
+
full=full,
|
|
617
|
+
brief=brief,
|
|
618
|
+
expand_callees=expand_callees,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
return JSONResponse({"node_id": node_id, "markdown": result})
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
async def api_structure(request: Request) -> JSONResponse:
|
|
625
|
+
filename = request.query_params.get("filename", "")
|
|
626
|
+
if not filename:
|
|
627
|
+
return JSONResponse({"error": "Missing 'filename' parameter"}, status_code=400)
|
|
628
|
+
|
|
629
|
+
q = await _get_querier()
|
|
630
|
+
if not q:
|
|
631
|
+
return JSONResponse({"error": "Graph not found"}, status_code=503)
|
|
632
|
+
|
|
633
|
+
matches = q.find_nodes_by_name(filename)
|
|
634
|
+
file_matches = [m for m in matches if m["type"] == "File"]
|
|
635
|
+
|
|
636
|
+
if not file_matches:
|
|
637
|
+
return JSONResponse({"error": f"File '{filename}' not found"}, status_code=404)
|
|
638
|
+
|
|
639
|
+
node_id = file_matches[0]["id"]
|
|
640
|
+
defs = []
|
|
641
|
+
imports = set()
|
|
642
|
+
for edge in q.outgoing[node_id]:
|
|
643
|
+
if edge["relation"] == "DEFINES":
|
|
644
|
+
target = q.nodes.get(edge["target"])
|
|
645
|
+
if target:
|
|
646
|
+
defs.append(target)
|
|
647
|
+
elif edge["relation"] == "IMPORTS":
|
|
648
|
+
target = edge["target"]
|
|
649
|
+
imports.add(
|
|
650
|
+
target.replace("MODULE:", "")
|
|
651
|
+
if target.startswith("MODULE:")
|
|
652
|
+
else target
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
grouped = {}
|
|
656
|
+
for type_name in ["Constant", "Class", "Function", "Configuration"]:
|
|
657
|
+
items = [
|
|
658
|
+
{
|
|
659
|
+
"name": d["metadata"]["name"],
|
|
660
|
+
"lineno": d["metadata"].get("lineno"),
|
|
661
|
+
"signature": d["metadata"].get("signature", ""),
|
|
662
|
+
"id": d.get("id", ""),
|
|
663
|
+
}
|
|
664
|
+
for d in defs
|
|
665
|
+
if d["type"] == type_name
|
|
666
|
+
]
|
|
667
|
+
if items:
|
|
668
|
+
grouped[type_name.lower()] = sorted(
|
|
669
|
+
items, key=lambda x: x.get("lineno") or 0
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
return JSONResponse(
|
|
673
|
+
{
|
|
674
|
+
"file": node_id,
|
|
675
|
+
"imports": sorted(imports),
|
|
676
|
+
"definitions": grouped,
|
|
677
|
+
}
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
async def api_flatten(request: Request) -> JSONResponse:
|
|
682
|
+
class_node_id = request.query_params.get("class_node_id", "")
|
|
683
|
+
if not class_node_id:
|
|
684
|
+
return JSONResponse(
|
|
685
|
+
{"error": "Missing 'class_node_id' parameter"}, status_code=400
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
q = await _get_querier()
|
|
689
|
+
if not q:
|
|
690
|
+
return JSONResponse({"error": "Graph not found"}, status_code=503)
|
|
691
|
+
|
|
692
|
+
result = q.flatten_class(class_node_id)
|
|
693
|
+
return JSONResponse({"class_node_id": class_node_id, "markdown": result})
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
async def api_impls(request: Request) -> JSONResponse:
|
|
697
|
+
method = request.query_params.get("method", "")
|
|
698
|
+
if not method:
|
|
699
|
+
return JSONResponse({"error": "Missing 'method' parameter"}, status_code=400)
|
|
700
|
+
trait_name = request.query_params.get("trait_name") or None
|
|
701
|
+
|
|
702
|
+
q = await _get_querier()
|
|
703
|
+
if not q:
|
|
704
|
+
return JSONResponse({"error": "Graph not found"}, status_code=503)
|
|
705
|
+
|
|
706
|
+
results = q.find_trait_impls(method, trait_name)
|
|
707
|
+
impls = []
|
|
708
|
+
for node in results:
|
|
709
|
+
meta = node.get("metadata", {})
|
|
710
|
+
node_id = node.get("id", "")
|
|
711
|
+
parts = node_id.split("::")
|
|
712
|
+
struct_name = parts[-2] if len(parts) >= 2 else "?"
|
|
713
|
+
file_path = (
|
|
714
|
+
node_id.split("::")[0].replace("FILE:", "")
|
|
715
|
+
if node_id.startswith("FILE:")
|
|
716
|
+
else ""
|
|
717
|
+
)
|
|
718
|
+
impls.append(
|
|
719
|
+
{
|
|
720
|
+
"id": node_id,
|
|
721
|
+
"struct_name": struct_name,
|
|
722
|
+
"trait_name": meta.get("trait_impl", "unknown"),
|
|
723
|
+
"file_path": file_path,
|
|
724
|
+
"lineno": meta.get("lineno"),
|
|
725
|
+
"signature": meta.get("signature", ""),
|
|
726
|
+
}
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
return JSONResponse(
|
|
730
|
+
{
|
|
731
|
+
"method": method,
|
|
732
|
+
"trait_filter": trait_name,
|
|
733
|
+
"total": len(impls),
|
|
734
|
+
"implementations": impls,
|
|
735
|
+
}
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
async def api_flow(request: Request) -> JSONResponse:
|
|
740
|
+
start = request.query_params.get("start", "")
|
|
741
|
+
if not start:
|
|
742
|
+
return JSONResponse({"error": "Missing 'start' parameter"}, status_code=400)
|
|
743
|
+
|
|
744
|
+
direction = request.query_params.get("direction", "forward")
|
|
745
|
+
depth = int(request.query_params.get("depth", "3"))
|
|
746
|
+
target = request.query_params.get("target") or None
|
|
747
|
+
inline_threshold = int(request.query_params.get("inline_threshold", "100"))
|
|
748
|
+
fmt = request.query_params.get("format", "markdown")
|
|
749
|
+
|
|
750
|
+
q = await _get_querier()
|
|
751
|
+
if not q:
|
|
752
|
+
return JSONResponse({"error": "Graph not found"}, status_code=503)
|
|
753
|
+
|
|
754
|
+
base = {"start": start, "direction": direction, "depth": depth}
|
|
755
|
+
|
|
756
|
+
if fmt == "tree":
|
|
757
|
+
tree = q.trace_flow_structured(
|
|
758
|
+
start_name=start,
|
|
759
|
+
direction=direction,
|
|
760
|
+
depth=depth,
|
|
761
|
+
target=target,
|
|
762
|
+
inline_threshold=inline_threshold,
|
|
763
|
+
)
|
|
764
|
+
return JSONResponse({**base, "tree": tree})
|
|
765
|
+
|
|
766
|
+
result = q.trace_flow(
|
|
767
|
+
start_name=start,
|
|
768
|
+
direction=direction,
|
|
769
|
+
depth=depth,
|
|
770
|
+
target=target,
|
|
771
|
+
inline_threshold=inline_threshold,
|
|
772
|
+
)
|
|
773
|
+
return JSONResponse({**base, "markdown": result})
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
async def api_path(request: Request) -> JSONResponse:
|
|
777
|
+
start = request.query_params.get("start", "")
|
|
778
|
+
end = request.query_params.get("end", "")
|
|
779
|
+
if not start or not end:
|
|
780
|
+
return JSONResponse(
|
|
781
|
+
{"error": "Missing 'start' and/or 'end' parameter"}, status_code=400
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
max_depth = int(request.query_params.get("max_depth", "10"))
|
|
785
|
+
direction = request.query_params.get("direction", "forward")
|
|
786
|
+
|
|
787
|
+
q = await _get_querier()
|
|
788
|
+
if not q:
|
|
789
|
+
return JSONResponse({"error": "Graph not found"}, status_code=503)
|
|
790
|
+
|
|
791
|
+
path = q.find_call_path(start, end, max_depth=max_depth, direction=direction)
|
|
792
|
+
if not path:
|
|
793
|
+
return JSONResponse(
|
|
794
|
+
{
|
|
795
|
+
"start": start,
|
|
796
|
+
"end": end,
|
|
797
|
+
"hops": 0,
|
|
798
|
+
"path": [],
|
|
799
|
+
"markdown": f"No path found from '{start}' to '{end}'",
|
|
800
|
+
}
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
# Build structured path + markdown
|
|
804
|
+
hops = []
|
|
805
|
+
md_lines = [
|
|
806
|
+
f"### Call Path: `{start}` -> `{end}` ({len(path)} hop{'s' if len(path) != 1 else ''})\n"
|
|
807
|
+
]
|
|
808
|
+
for i, hop in enumerate(path, 1):
|
|
809
|
+
caller_name = hop.get("caller_name", "?")
|
|
810
|
+
callee_name = hop.get("callee_name", "?")
|
|
811
|
+
file_path = hop.get("file_path", "")
|
|
812
|
+
call_line = hop.get("call_line")
|
|
813
|
+
snippet = hop.get("call_snippet", "")
|
|
814
|
+
hops.append(
|
|
815
|
+
{
|
|
816
|
+
"step": i,
|
|
817
|
+
"caller": caller_name,
|
|
818
|
+
"callee": callee_name,
|
|
819
|
+
"file_path": file_path,
|
|
820
|
+
"line": call_line,
|
|
821
|
+
"snippet": snippet,
|
|
822
|
+
}
|
|
823
|
+
)
|
|
824
|
+
md_lines.append(f"**{i}. {caller_name}** -> **{callee_name}**")
|
|
825
|
+
if file_path and call_line:
|
|
826
|
+
md_lines.append(f" {file_path}:{call_line}")
|
|
827
|
+
if snippet:
|
|
828
|
+
lang = _get_syntax_lang(file_path) if file_path else ""
|
|
829
|
+
md_lines.append(f"```{lang}")
|
|
830
|
+
md_lines.append(snippet)
|
|
831
|
+
md_lines.append("```")
|
|
832
|
+
md_lines.append("")
|
|
833
|
+
|
|
834
|
+
return JSONResponse(
|
|
835
|
+
{
|
|
836
|
+
"start": start,
|
|
837
|
+
"end": end,
|
|
838
|
+
"hops": len(path),
|
|
839
|
+
"path": hops,
|
|
840
|
+
"markdown": "\n".join(md_lines),
|
|
841
|
+
}
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
async def api_cross_lang(request: Request) -> JSONResponse:
|
|
846
|
+
if not CROSS_LANG_AVAILABLE:
|
|
847
|
+
return JSONResponse(
|
|
848
|
+
{
|
|
849
|
+
"not_configured": True,
|
|
850
|
+
"message": "Cross-language tracing module not available. Install the descry cross_lang dependency.",
|
|
851
|
+
}
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
mode = request.query_params.get("mode", "endpoint")
|
|
855
|
+
method = request.query_params.get("method")
|
|
856
|
+
path = request.query_params.get("path")
|
|
857
|
+
tag = request.query_params.get("tag")
|
|
858
|
+
|
|
859
|
+
cfg = _get_config()
|
|
860
|
+
openapi_path = cfg.project_root / "public" / "api" / "latest.json"
|
|
861
|
+
if not openapi_path.exists():
|
|
862
|
+
openapi_path = cfg.project_root / "public" / "api" / "openapi.json"
|
|
863
|
+
if not openapi_path.exists():
|
|
864
|
+
return JSONResponse(
|
|
865
|
+
{
|
|
866
|
+
"not_configured": True,
|
|
867
|
+
"message": "No OpenAPI spec found. Place your spec at public/api/openapi.json relative to the project root.",
|
|
868
|
+
}
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
graph_path = str(cfg.graph_path) if cfg.graph_path.exists() else None
|
|
872
|
+
tracer = CrossLangTracer(str(openapi_path), graph_path)
|
|
873
|
+
|
|
874
|
+
if mode == "stats":
|
|
875
|
+
return JSONResponse(tracer.get_stats())
|
|
876
|
+
elif mode == "list":
|
|
877
|
+
endpoints = tracer.list_endpoints(tag=tag)
|
|
878
|
+
return JSONResponse(
|
|
879
|
+
{"tag": tag, "total": len(endpoints), "endpoints": endpoints}
|
|
880
|
+
)
|
|
881
|
+
elif mode == "endpoint":
|
|
882
|
+
if not method or not path:
|
|
883
|
+
return JSONResponse(
|
|
884
|
+
{"error": "Endpoint mode requires 'method' and 'path'"}, status_code=400
|
|
885
|
+
)
|
|
886
|
+
info = tracer.get_handler_info(method.upper(), path)
|
|
887
|
+
if not info:
|
|
888
|
+
return JSONResponse(
|
|
889
|
+
{"error": f"No handler for {method.upper()} {path}"}, status_code=404
|
|
890
|
+
)
|
|
891
|
+
return JSONResponse(info)
|
|
892
|
+
else:
|
|
893
|
+
return JSONResponse({"error": f"Unknown mode '{mode}'"}, status_code=400)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
async def api_churn(request: Request) -> JSONResponse:
|
|
897
|
+
if not GIT_HISTORY_AVAILABLE:
|
|
898
|
+
return JSONResponse({"error": "Git history not available"}, status_code=503)
|
|
899
|
+
|
|
900
|
+
time_range = request.query_params.get("time_range")
|
|
901
|
+
path_filter = request.query_params.get("path_filter")
|
|
902
|
+
limit = int(request.query_params.get("limit", "20"))
|
|
903
|
+
mode = request.query_params.get("mode", "symbols")
|
|
904
|
+
exclude_generated = (
|
|
905
|
+
request.query_params.get("exclude_generated", "true").lower() == "true"
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
fmt = request.query_params.get("format", "markdown")
|
|
909
|
+
|
|
910
|
+
try:
|
|
911
|
+
analyzer = await _get_git_analyzer()
|
|
912
|
+
if fmt == "structured":
|
|
913
|
+
result = await asyncio.to_thread(
|
|
914
|
+
analyzer.get_churn_structured,
|
|
915
|
+
time_range=time_range,
|
|
916
|
+
path_filter=path_filter,
|
|
917
|
+
limit=limit,
|
|
918
|
+
mode=mode,
|
|
919
|
+
exclude_generated=exclude_generated,
|
|
920
|
+
)
|
|
921
|
+
return JSONResponse({"data": result})
|
|
922
|
+
else:
|
|
923
|
+
result = await asyncio.to_thread(
|
|
924
|
+
analyzer.get_churn,
|
|
925
|
+
time_range=time_range,
|
|
926
|
+
path_filter=path_filter,
|
|
927
|
+
limit=limit,
|
|
928
|
+
mode=mode,
|
|
929
|
+
exclude_generated=exclude_generated,
|
|
930
|
+
)
|
|
931
|
+
return JSONResponse({"mode": mode, "markdown": result})
|
|
932
|
+
except Exception as e:
|
|
933
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
async def api_evolution(request: Request) -> JSONResponse:
|
|
937
|
+
name = request.query_params.get("name", "")
|
|
938
|
+
if not name:
|
|
939
|
+
return JSONResponse({"error": "Missing 'name' parameter"}, status_code=400)
|
|
940
|
+
|
|
941
|
+
if not GIT_HISTORY_AVAILABLE:
|
|
942
|
+
return JSONResponse({"error": "Git history not available"}, status_code=503)
|
|
943
|
+
|
|
944
|
+
time_range = request.query_params.get("time_range")
|
|
945
|
+
limit = int(request.query_params.get("limit", "10"))
|
|
946
|
+
show_diff = request.query_params.get("show_diff", "").lower() == "true"
|
|
947
|
+
crate = request.query_params.get("crate")
|
|
948
|
+
|
|
949
|
+
try:
|
|
950
|
+
analyzer = await _get_git_analyzer()
|
|
951
|
+
result = await asyncio.to_thread(
|
|
952
|
+
analyzer.get_evolution,
|
|
953
|
+
name=name,
|
|
954
|
+
time_range=time_range,
|
|
955
|
+
limit=limit,
|
|
956
|
+
show_diff=show_diff,
|
|
957
|
+
crate=crate,
|
|
958
|
+
)
|
|
959
|
+
return JSONResponse({"name": name, "markdown": result})
|
|
960
|
+
except Exception as e:
|
|
961
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
async def api_changes(request: Request) -> JSONResponse:
|
|
965
|
+
if not GIT_HISTORY_AVAILABLE:
|
|
966
|
+
return JSONResponse({"error": "Git history not available"}, status_code=503)
|
|
967
|
+
|
|
968
|
+
commit_range = request.query_params.get("commit_range")
|
|
969
|
+
time_range = request.query_params.get("time_range")
|
|
970
|
+
path_filter = request.query_params.get("path_filter")
|
|
971
|
+
show_callers = request.query_params.get("show_callers", "true").lower() == "true"
|
|
972
|
+
limit = int(request.query_params.get("limit", "50"))
|
|
973
|
+
|
|
974
|
+
fmt = request.query_params.get("format", "markdown")
|
|
975
|
+
|
|
976
|
+
try:
|
|
977
|
+
analyzer = await _get_git_analyzer()
|
|
978
|
+
if fmt == "structured":
|
|
979
|
+
result = await asyncio.to_thread(
|
|
980
|
+
analyzer.get_changes_structured,
|
|
981
|
+
commit_range=commit_range,
|
|
982
|
+
time_range=time_range,
|
|
983
|
+
path_filter=path_filter,
|
|
984
|
+
show_callers=show_callers,
|
|
985
|
+
limit=limit,
|
|
986
|
+
)
|
|
987
|
+
return JSONResponse({"data": result})
|
|
988
|
+
else:
|
|
989
|
+
result = await asyncio.to_thread(
|
|
990
|
+
analyzer.get_changes,
|
|
991
|
+
commit_range=commit_range,
|
|
992
|
+
time_range=time_range,
|
|
993
|
+
path_filter=path_filter,
|
|
994
|
+
show_callers=show_callers,
|
|
995
|
+
limit=limit,
|
|
996
|
+
)
|
|
997
|
+
return JSONResponse({"markdown": result})
|
|
998
|
+
except Exception as e:
|
|
999
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
async def api_quick(request: Request) -> JSONResponse:
|
|
1003
|
+
name = request.query_params.get("name", "")
|
|
1004
|
+
if not name:
|
|
1005
|
+
return JSONResponse({"error": "Missing 'name' parameter"}, status_code=400)
|
|
1006
|
+
|
|
1007
|
+
full = request.query_params.get("full", "").lower() == "true"
|
|
1008
|
+
brief = request.query_params.get("brief", "").lower() == "true"
|
|
1009
|
+
|
|
1010
|
+
q = await _get_querier()
|
|
1011
|
+
if not q:
|
|
1012
|
+
return JSONResponse({"error": "Graph not found"}, status_code=503)
|
|
1013
|
+
|
|
1014
|
+
matches = q.find_nodes_by_name(name)
|
|
1015
|
+
|
|
1016
|
+
def type_priority(node):
|
|
1017
|
+
t = node.get("type", "")
|
|
1018
|
+
if t in ("Function", "Method"):
|
|
1019
|
+
return 0
|
|
1020
|
+
if t == "Class":
|
|
1021
|
+
return 1
|
|
1022
|
+
return 2
|
|
1023
|
+
|
|
1024
|
+
matches.sort(key=type_priority)
|
|
1025
|
+
if not matches:
|
|
1026
|
+
matches = q.find_nodes_by_name(name, fuzzy=True)
|
|
1027
|
+
matches.sort(key=type_priority)
|
|
1028
|
+
|
|
1029
|
+
if not matches:
|
|
1030
|
+
return JSONResponse({"error": f"No symbol found for '{name}'"}, status_code=404)
|
|
1031
|
+
|
|
1032
|
+
best = matches[0]
|
|
1033
|
+
node_id = best["id"]
|
|
1034
|
+
context = q.get_context_prompt(node_id, full=full, brief=brief)
|
|
1035
|
+
|
|
1036
|
+
return JSONResponse(
|
|
1037
|
+
{
|
|
1038
|
+
"symbol": _node_to_dict(best),
|
|
1039
|
+
"other_matches": len(matches) - 1,
|
|
1040
|
+
"markdown": context,
|
|
1041
|
+
}
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
_MAX_SOURCE_BYTES = 10 * 1024 * 1024 # 10 MiB
|
|
1046
|
+
|
|
1047
|
+
# Defense-in-depth: even with project-root containment, refuse to serve files
|
|
1048
|
+
# that commonly hold credentials or private keys. Matches path segments case-
|
|
1049
|
+
# insensitively. The web UI is local-only, but DNS-rebinding / cross-origin
|
|
1050
|
+
# bugs in browsers have historically exposed localhost services; make the
|
|
1051
|
+
# worst-case blast radius smaller than "every text file in the repo".
|
|
1052
|
+
_SENSITIVE_NAME_RE = re.compile(
|
|
1053
|
+
r"(?ix)"
|
|
1054
|
+
r"(^|/)\.env(\.|$)"
|
|
1055
|
+
r"|(^|/)\.envrc$"
|
|
1056
|
+
r"|(^|/)\.git/"
|
|
1057
|
+
r"|(^|/)\.ssh/"
|
|
1058
|
+
r"|(^|/)\.aws/"
|
|
1059
|
+
r"|(^|/)\.gnupg/"
|
|
1060
|
+
r"|(^|/)\.netrc$"
|
|
1061
|
+
r"|(^|/)\.npmrc$"
|
|
1062
|
+
r"|(^|/)\.pypirc$"
|
|
1063
|
+
r"|(^|/)id_(rsa|dsa|ecdsa|ed25519)(\.|$)"
|
|
1064
|
+
r"|\.(pem|key|p12|pfx|jks|keystore|asc|gpg)$"
|
|
1065
|
+
)
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def _is_sensitive_path(rel: str) -> bool:
|
|
1069
|
+
"""True if `rel` (project-root-relative POSIX path) matches a known secret pattern."""
|
|
1070
|
+
return bool(_SENSITIVE_NAME_RE.search(rel))
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _is_text_sample(sample: bytes) -> bool:
|
|
1074
|
+
"""Heuristic check: first N bytes look like text (UTF-8 or UTF-16, low NUL ratio)."""
|
|
1075
|
+
if not sample:
|
|
1076
|
+
return True
|
|
1077
|
+
# Strip BOM if present.
|
|
1078
|
+
if sample.startswith(b"\xef\xbb\xbf"):
|
|
1079
|
+
sample = sample[3:]
|
|
1080
|
+
elif sample.startswith(b"\xff\xfe") or sample.startswith(b"\xfe\xff"):
|
|
1081
|
+
# UTF-16 BOM; try decode before NUL check.
|
|
1082
|
+
try:
|
|
1083
|
+
sample.decode("utf-16")
|
|
1084
|
+
return True
|
|
1085
|
+
except UnicodeDecodeError:
|
|
1086
|
+
return False
|
|
1087
|
+
nul_count = sample.count(b"\x00")
|
|
1088
|
+
if nul_count > len(sample) * 0.01: # > 1% NUL
|
|
1089
|
+
return False
|
|
1090
|
+
try:
|
|
1091
|
+
sample.decode("utf-8")
|
|
1092
|
+
return True
|
|
1093
|
+
except UnicodeDecodeError:
|
|
1094
|
+
try:
|
|
1095
|
+
sample.decode("utf-16")
|
|
1096
|
+
return True
|
|
1097
|
+
except UnicodeDecodeError:
|
|
1098
|
+
return False
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
async def api_source(request: Request) -> JSONResponse:
|
|
1102
|
+
file_param = request.query_params.get("file", "")
|
|
1103
|
+
if not file_param:
|
|
1104
|
+
return JSONResponse({"error": "Missing 'file' parameter"}, status_code=400)
|
|
1105
|
+
|
|
1106
|
+
line = request.query_params.get("line")
|
|
1107
|
+
target_line = int(line) if line else None
|
|
1108
|
+
|
|
1109
|
+
# A.6 / H.1 pipeline: resolve → containment → S_ISREG → size → text-check → read
|
|
1110
|
+
cfg = _get_config()
|
|
1111
|
+
try:
|
|
1112
|
+
file_path = (cfg.project_root / file_param).resolve(strict=False)
|
|
1113
|
+
except (OSError, RuntimeError) as e:
|
|
1114
|
+
return JSONResponse({"error": f"Invalid path: {e}"}, status_code=400)
|
|
1115
|
+
|
|
1116
|
+
try:
|
|
1117
|
+
if not file_path.is_relative_to(cfg.resolved_project_root):
|
|
1118
|
+
return JSONResponse({"error": "Path outside project root"}, status_code=400)
|
|
1119
|
+
except ValueError:
|
|
1120
|
+
return JSONResponse({"error": "Path outside project root"}, status_code=400)
|
|
1121
|
+
|
|
1122
|
+
# Defense-in-depth secret blocklist. Check both the requested path and the
|
|
1123
|
+
# resolved path so a file symlinked to a sensitive target is still refused.
|
|
1124
|
+
try:
|
|
1125
|
+
rel_requested = file_param.replace("\\", "/")
|
|
1126
|
+
rel_resolved = file_path.relative_to(cfg.resolved_project_root).as_posix()
|
|
1127
|
+
except ValueError:
|
|
1128
|
+
rel_resolved = rel_requested
|
|
1129
|
+
if _is_sensitive_path(rel_requested) or _is_sensitive_path(rel_resolved):
|
|
1130
|
+
return JSONResponse(
|
|
1131
|
+
{"error": "Refusing to serve sensitive file"}, status_code=403
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
if not file_path.exists():
|
|
1135
|
+
return JSONResponse({"error": f"File not found: {file_param}"}, status_code=404)
|
|
1136
|
+
|
|
1137
|
+
try:
|
|
1138
|
+
st = file_path.stat()
|
|
1139
|
+
except OSError as e:
|
|
1140
|
+
return JSONResponse({"error": f"Cannot stat file: {e}"}, status_code=400)
|
|
1141
|
+
|
|
1142
|
+
if not stat.S_ISREG(st.st_mode):
|
|
1143
|
+
return JSONResponse({"error": "Not a regular file"}, status_code=400)
|
|
1144
|
+
if st.st_size > _MAX_SOURCE_BYTES:
|
|
1145
|
+
return JSONResponse({"error": "File too large"}, status_code=413)
|
|
1146
|
+
|
|
1147
|
+
# Use O_NOFOLLOW on the final open so a symlink swap between the earlier
|
|
1148
|
+
# containment check and this read cannot redirect us outside the project.
|
|
1149
|
+
try:
|
|
1150
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
1151
|
+
fd = os.open(str(file_path), flags)
|
|
1152
|
+
except OSError as e:
|
|
1153
|
+
return JSONResponse({"error": f"Cannot open file: {e}"}, status_code=400)
|
|
1154
|
+
try:
|
|
1155
|
+
with os.fdopen(fd, "rb") as f:
|
|
1156
|
+
sample = f.read(8192)
|
|
1157
|
+
if not _is_text_sample(sample):
|
|
1158
|
+
return JSONResponse({"error": "Not a text file"}, status_code=415)
|
|
1159
|
+
f.seek(0)
|
|
1160
|
+
content = f.read().decode("utf-8", errors="replace")
|
|
1161
|
+
except Exception as e:
|
|
1162
|
+
return JSONResponse({"error": f"Cannot read file: {e}"}, status_code=500)
|
|
1163
|
+
|
|
1164
|
+
lang = _get_syntax_lang(str(file_path))
|
|
1165
|
+
lines = content.split("\n")
|
|
1166
|
+
|
|
1167
|
+
# If a target line is specified, return a window around it
|
|
1168
|
+
context_lines = 50
|
|
1169
|
+
if target_line and target_line > 0:
|
|
1170
|
+
start = max(0, target_line - context_lines - 1)
|
|
1171
|
+
end = min(len(lines), target_line + context_lines)
|
|
1172
|
+
visible_lines = lines[start:end]
|
|
1173
|
+
return JSONResponse(
|
|
1174
|
+
{
|
|
1175
|
+
"file": file_param,
|
|
1176
|
+
"language": lang,
|
|
1177
|
+
"total_lines": len(lines),
|
|
1178
|
+
"start_line": start + 1,
|
|
1179
|
+
"end_line": end,
|
|
1180
|
+
"target_line": target_line,
|
|
1181
|
+
"content": "\n".join(visible_lines),
|
|
1182
|
+
}
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
# Return full file (capped at 2000 lines for safety)
|
|
1186
|
+
cap = 2000
|
|
1187
|
+
truncated = len(lines) > cap
|
|
1188
|
+
return JSONResponse(
|
|
1189
|
+
{
|
|
1190
|
+
"file": file_param,
|
|
1191
|
+
"language": lang,
|
|
1192
|
+
"total_lines": len(lines),
|
|
1193
|
+
"start_line": 1,
|
|
1194
|
+
"end_line": min(len(lines), cap),
|
|
1195
|
+
"target_line": None,
|
|
1196
|
+
"truncated": truncated,
|
|
1197
|
+
"content": "\n".join(lines[:cap]),
|
|
1198
|
+
}
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
async def api_examples(request: Request) -> JSONResponse:
|
|
1203
|
+
"""Return dynamic example symbols derived from the indexed graph.
|
|
1204
|
+
|
|
1205
|
+
Picks interesting symbols (high in-degree, classes, good flow candidates)
|
|
1206
|
+
so the UI "Try:" links always work with the current project.
|
|
1207
|
+
"""
|
|
1208
|
+
q = await _get_querier()
|
|
1209
|
+
if not q:
|
|
1210
|
+
return JSONResponse({"available": False})
|
|
1211
|
+
|
|
1212
|
+
nodes = q.data.get("nodes", [])
|
|
1213
|
+
if not nodes:
|
|
1214
|
+
return JSONResponse({"available": False})
|
|
1215
|
+
|
|
1216
|
+
edges = q.data.get("edges", [])
|
|
1217
|
+
|
|
1218
|
+
# Categorize nodes
|
|
1219
|
+
functions = []
|
|
1220
|
+
classes = []
|
|
1221
|
+
methods = []
|
|
1222
|
+
for n in nodes:
|
|
1223
|
+
meta = n.get("metadata", {})
|
|
1224
|
+
ntype = n.get("type", "")
|
|
1225
|
+
name = meta.get("name", "")
|
|
1226
|
+
# Skip test nodes and private/dunder names
|
|
1227
|
+
if not name or name.startswith("_") or "test" in n.get("id", "").lower():
|
|
1228
|
+
continue
|
|
1229
|
+
entry = {
|
|
1230
|
+
"name": name,
|
|
1231
|
+
"id": n["id"],
|
|
1232
|
+
"type": ntype,
|
|
1233
|
+
"in_degree": meta.get("in_degree", 0),
|
|
1234
|
+
"token_count": meta.get("token_count", 0),
|
|
1235
|
+
"file": n["id"].replace("FILE:", "").split("::")[0]
|
|
1236
|
+
if "::" in n["id"]
|
|
1237
|
+
else "",
|
|
1238
|
+
}
|
|
1239
|
+
if ntype == "Function":
|
|
1240
|
+
functions.append(entry)
|
|
1241
|
+
elif ntype in ("Class", "Struct"):
|
|
1242
|
+
classes.append(entry)
|
|
1243
|
+
elif ntype == "Method":
|
|
1244
|
+
methods.append(entry)
|
|
1245
|
+
|
|
1246
|
+
# Sort by in_degree (most-called first) for relevance
|
|
1247
|
+
functions.sort(key=lambda x: x["in_degree"], reverse=True)
|
|
1248
|
+
methods.sort(key=lambda x: x["in_degree"], reverse=True)
|
|
1249
|
+
|
|
1250
|
+
# Build outgoing edge counts for flow examples
|
|
1251
|
+
out_degree = {}
|
|
1252
|
+
for e in edges:
|
|
1253
|
+
src = e.get("source", "")
|
|
1254
|
+
out_degree[src] = out_degree.get(src, 0) + 1
|
|
1255
|
+
|
|
1256
|
+
# Pick "interesting" symbols for each tool
|
|
1257
|
+
# Search: a popular function, a class, and a method
|
|
1258
|
+
search_examples = []
|
|
1259
|
+
if functions:
|
|
1260
|
+
search_examples.append(functions[0]["name"])
|
|
1261
|
+
if classes:
|
|
1262
|
+
search_examples.append(classes[0]["name"])
|
|
1263
|
+
if methods and len(search_examples) < 3:
|
|
1264
|
+
search_examples.append(methods[0]["name"])
|
|
1265
|
+
|
|
1266
|
+
# Quick/callers/callees: functions with high in-degree (many callers)
|
|
1267
|
+
popular = [f for f in functions if f["in_degree"] >= 2][:3]
|
|
1268
|
+
popular_names = [f["name"] for f in popular] or [f["name"] for f in functions[:3]]
|
|
1269
|
+
|
|
1270
|
+
# Flow: find a function with good fanout (interesting forward trace)
|
|
1271
|
+
flow_candidates = []
|
|
1272
|
+
for f in functions[:20]:
|
|
1273
|
+
od = out_degree.get(f["id"], 0)
|
|
1274
|
+
if od >= 2:
|
|
1275
|
+
flow_candidates.append({"name": f["name"], "out_degree": od})
|
|
1276
|
+
flow_candidates.sort(key=lambda x: x["out_degree"], reverse=True)
|
|
1277
|
+
flow_fwd = (
|
|
1278
|
+
flow_candidates[0]["name"]
|
|
1279
|
+
if flow_candidates
|
|
1280
|
+
else (popular_names[0] if popular_names else "")
|
|
1281
|
+
)
|
|
1282
|
+
# Backward: pick a popular callee that differs from the forward symbol
|
|
1283
|
+
flow_bwd = ""
|
|
1284
|
+
for pn in popular_names:
|
|
1285
|
+
if pn != flow_fwd:
|
|
1286
|
+
flow_bwd = pn
|
|
1287
|
+
break
|
|
1288
|
+
if not flow_bwd:
|
|
1289
|
+
flow_bwd = popular_names[0] if popular_names else ""
|
|
1290
|
+
|
|
1291
|
+
# Path: pick two symbols likely connected
|
|
1292
|
+
path_start = flow_fwd
|
|
1293
|
+
path_end = popular_names[0] if popular_names else ""
|
|
1294
|
+
if path_start == path_end and len(popular_names) > 1:
|
|
1295
|
+
path_end = popular_names[1]
|
|
1296
|
+
|
|
1297
|
+
# Structure: pick files that have multiple symbols
|
|
1298
|
+
file_counts = {}
|
|
1299
|
+
for n in nodes:
|
|
1300
|
+
nid = n.get("id", "")
|
|
1301
|
+
if "::" in nid:
|
|
1302
|
+
f = nid.replace("FILE:", "").split("::")[0]
|
|
1303
|
+
file_counts[f] = file_counts.get(f, 0) + 1
|
|
1304
|
+
structure_files = sorted(file_counts.items(), key=lambda x: x[1], reverse=True)
|
|
1305
|
+
structure_examples = [f for f, _ in structure_files[:3]]
|
|
1306
|
+
|
|
1307
|
+
# Flatten: pick a class
|
|
1308
|
+
flatten_example = classes[0]["id"] if classes else ""
|
|
1309
|
+
|
|
1310
|
+
# Impls: pick method names that might have multiple implementations
|
|
1311
|
+
impl_candidates = []
|
|
1312
|
+
seen_names = {}
|
|
1313
|
+
for m in methods:
|
|
1314
|
+
n = m["name"]
|
|
1315
|
+
seen_names[n] = seen_names.get(n, 0) + 1
|
|
1316
|
+
impl_candidates = sorted(
|
|
1317
|
+
[(name, count) for name, count in seen_names.items() if count >= 2],
|
|
1318
|
+
key=lambda x: x[1],
|
|
1319
|
+
reverse=True,
|
|
1320
|
+
)
|
|
1321
|
+
impl_examples = [name for name, _ in impl_candidates[:3]]
|
|
1322
|
+
|
|
1323
|
+
return JSONResponse(
|
|
1324
|
+
{
|
|
1325
|
+
"available": True,
|
|
1326
|
+
"search": search_examples,
|
|
1327
|
+
"quick": popular_names[:3],
|
|
1328
|
+
"callers": popular_names[:3],
|
|
1329
|
+
"callees": popular_names[:2],
|
|
1330
|
+
"flow_forward": flow_fwd,
|
|
1331
|
+
"flow_backward": flow_bwd,
|
|
1332
|
+
"path_start": path_start,
|
|
1333
|
+
"path_end": path_end,
|
|
1334
|
+
"structure": structure_examples,
|
|
1335
|
+
"flatten": flatten_example,
|
|
1336
|
+
"impls": impl_examples,
|
|
1337
|
+
"evolution": popular_names[:2],
|
|
1338
|
+
}
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
# --- App entrypoint ---
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
async def index_page(request: Request) -> FileResponse:
|
|
1346
|
+
return FileResponse(WEB_DIR / "index.html")
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
routes = [
|
|
1350
|
+
Route("/", index_page),
|
|
1351
|
+
Route("/api/examples", api_examples),
|
|
1352
|
+
Route("/api/health", api_health),
|
|
1353
|
+
Route("/api/status", api_status),
|
|
1354
|
+
Route("/api/ensure", api_ensure, methods=["POST"]),
|
|
1355
|
+
Route("/api/index", api_index, methods=["POST"]),
|
|
1356
|
+
Route("/api/index/stream", api_index_stream, methods=["POST"]),
|
|
1357
|
+
Route("/api/search", api_search),
|
|
1358
|
+
Route("/api/semantic", api_semantic),
|
|
1359
|
+
Route("/api/quick", api_quick),
|
|
1360
|
+
Route("/api/callers", api_callers),
|
|
1361
|
+
Route("/api/callees", api_callees),
|
|
1362
|
+
Route("/api/context", api_context),
|
|
1363
|
+
Route("/api/structure", api_structure),
|
|
1364
|
+
Route("/api/flatten", api_flatten),
|
|
1365
|
+
Route("/api/impls", api_impls),
|
|
1366
|
+
Route("/api/flow", api_flow),
|
|
1367
|
+
Route("/api/path", api_path),
|
|
1368
|
+
Route("/api/cross-lang", api_cross_lang),
|
|
1369
|
+
Route("/api/churn", api_churn),
|
|
1370
|
+
Route("/api/evolution", api_evolution),
|
|
1371
|
+
Route("/api/changes", api_changes),
|
|
1372
|
+
Route("/api/source", api_source),
|
|
1373
|
+
Mount("/static", StaticFiles(directory=str(WEB_DIR)), name="static"),
|
|
1374
|
+
]
|
|
1375
|
+
|
|
1376
|
+
# The UI is same-origin with its API — no CORS headers are needed. Omitting
|
|
1377
|
+
# CORSMiddleware means browsers enforce same-origin by default, so a tab on
|
|
1378
|
+
# evil.com cannot read /api/source or trigger /api/index. TrustedHostMiddleware
|
|
1379
|
+
# rejects requests whose Host header isn't loopback, defeating DNS-rebinding
|
|
1380
|
+
# attacks that would otherwise bypass the 127.0.0.1 bind.
|
|
1381
|
+
_ALLOWED_HOSTS = ["127.0.0.1", "localhost", "[::1]"]
|
|
1382
|
+
|
|
1383
|
+
middleware = [
|
|
1384
|
+
Middleware(TrustedHostMiddleware, allowed_hosts=_ALLOWED_HOSTS),
|
|
1385
|
+
]
|
|
1386
|
+
|
|
1387
|
+
app = Starlette(routes=routes, middleware=middleware)
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def _loopback_host(value: str) -> str:
|
|
1394
|
+
"""argparse type: only accept loopback addresses for --host.
|
|
1395
|
+
|
|
1396
|
+
The web UI has no authentication and is designed for single-user local
|
|
1397
|
+
use. Binding to a non-loopback address (e.g. `0.0.0.0`) would expose every
|
|
1398
|
+
endpoint — including /api/source — to the LAN. See SECURITY.md and the
|
|
1399
|
+
'Web UI is local-only' section of the README.
|
|
1400
|
+
"""
|
|
1401
|
+
if value not in _LOOPBACK_HOSTS:
|
|
1402
|
+
raise argparse.ArgumentTypeError(
|
|
1403
|
+
f"--host must be a loopback address ({', '.join(sorted(_LOOPBACK_HOSTS))}); "
|
|
1404
|
+
f"got {value!r}. The web UI is unauthenticated by design; use a reverse "
|
|
1405
|
+
f"proxy if you need remote access."
|
|
1406
|
+
)
|
|
1407
|
+
return value
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
def main():
|
|
1411
|
+
parser = argparse.ArgumentParser(description="Descry Web UI")
|
|
1412
|
+
parser.add_argument("--port", type=int, default=8787, help="Port (default: 8787)")
|
|
1413
|
+
parser.add_argument(
|
|
1414
|
+
"--host",
|
|
1415
|
+
default="127.0.0.1",
|
|
1416
|
+
type=_loopback_host,
|
|
1417
|
+
help="Host (default: 127.0.0.1; only loopback addresses accepted)",
|
|
1418
|
+
)
|
|
1419
|
+
args = parser.parse_args()
|
|
1420
|
+
|
|
1421
|
+
cfg = _get_config()
|
|
1422
|
+
logger.info(f"Starting Descry Web UI on http://{args.host}:{args.port}")
|
|
1423
|
+
logger.info(f"Project root: {cfg.project_root}")
|
|
1424
|
+
logger.info(f"Graph path: {cfg.graph_path}")
|
|
1425
|
+
|
|
1426
|
+
# GraphQuerier resolves source file paths relative to CWD, so we must
|
|
1427
|
+
# run from the project root (same as the MCP server does).
|
|
1428
|
+
os.chdir(cfg.project_root)
|
|
1429
|
+
|
|
1430
|
+
# Pre-warm the graph via a one-off event loop (main() is sync; uvicorn
|
|
1431
|
+
# starts its own loop afterwards).
|
|
1432
|
+
if cfg.graph_path.exists():
|
|
1433
|
+
logger.info("Pre-warming graph...")
|
|
1434
|
+
|
|
1435
|
+
async def _prewarm():
|
|
1436
|
+
await _get_querier()
|
|
1437
|
+
|
|
1438
|
+
asyncio.run(_prewarm())
|
|
1439
|
+
_update_graph_meta()
|
|
1440
|
+
logger.info(
|
|
1441
|
+
f"Graph ready: {_graph_meta['nodes']:,}n / {_graph_meta['edges']:,}e"
|
|
1442
|
+
)
|
|
1443
|
+
|
|
1444
|
+
try:
|
|
1445
|
+
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
|
|
1446
|
+
except OSError as e:
|
|
1447
|
+
if e.errno == errno.EADDRINUSE:
|
|
1448
|
+
print(
|
|
1449
|
+
f"Port {args.port} already in use — pass --port N to override.",
|
|
1450
|
+
file=sys.stderr,
|
|
1451
|
+
)
|
|
1452
|
+
sys.exit(1)
|
|
1453
|
+
raise
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
if __name__ == "__main__":
|
|
1457
|
+
main()
|