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/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()