codemap-python 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.
Files changed (58) hide show
  1. analysis/__init__.py +1 -0
  2. analysis/architecture/__init__.py +1 -0
  3. analysis/architecture/architecture_engine.py +155 -0
  4. analysis/architecture/dependency_cycles.py +103 -0
  5. analysis/architecture/risk_radar.py +220 -0
  6. analysis/call_graph/__init__.py +1 -0
  7. analysis/call_graph/call_extractor.py +91 -0
  8. analysis/call_graph/call_graph_builder.py +1 -0
  9. analysis/call_graph/call_resolver.py +56 -0
  10. analysis/call_graph/context_models.py +1 -0
  11. analysis/call_graph/cross_file_resolver.py +122 -0
  12. analysis/call_graph/execution_tracker.py +1 -0
  13. analysis/call_graph/flow_builder.py +1 -0
  14. analysis/call_graph/models.py +1 -0
  15. analysis/core/__init__.py +1 -0
  16. analysis/core/ast_context.py +1 -0
  17. analysis/core/ast_parser.py +8 -0
  18. analysis/core/class_extractor.py +35 -0
  19. analysis/core/function_extractor.py +16 -0
  20. analysis/core/import_extractor.py +43 -0
  21. analysis/explain/__init__.py +1 -0
  22. analysis/explain/docstring_extractor.py +45 -0
  23. analysis/explain/explain_runner.py +177 -0
  24. analysis/explain/repo_summary_generator.py +138 -0
  25. analysis/explain/return_analyzer.py +114 -0
  26. analysis/explain/risk_flags.py +1 -0
  27. analysis/explain/signature_extractor.py +104 -0
  28. analysis/explain/summary_generator.py +282 -0
  29. analysis/graph/__init__.py +1 -0
  30. analysis/graph/callgraph_index.py +117 -0
  31. analysis/graph/entrypoint_detector.py +1 -0
  32. analysis/graph/impact_analyzer.py +210 -0
  33. analysis/indexing/__init__.py +1 -0
  34. analysis/indexing/import_resolver.py +156 -0
  35. analysis/indexing/symbol_index.py +150 -0
  36. analysis/runners/__init__.py +1 -0
  37. analysis/runners/phase4_runner.py +137 -0
  38. analysis/utils/__init__.py +1 -0
  39. analysis/utils/ast_helpers.py +1 -0
  40. analysis/utils/cache_manager.py +659 -0
  41. analysis/utils/path_resolver.py +1 -0
  42. analysis/utils/repo_fetcher.py +469 -0
  43. cli.py +1728 -0
  44. codemap_cli.py +11 -0
  45. codemap_python-0.1.0.dist-info/METADATA +399 -0
  46. codemap_python-0.1.0.dist-info/RECORD +58 -0
  47. codemap_python-0.1.0.dist-info/WHEEL +5 -0
  48. codemap_python-0.1.0.dist-info/entry_points.txt +2 -0
  49. codemap_python-0.1.0.dist-info/top_level.txt +5 -0
  50. security_utils.py +51 -0
  51. ui/__init__.py +1 -0
  52. ui/app.py +2160 -0
  53. ui/device_id.py +27 -0
  54. ui/static/app.js +2703 -0
  55. ui/static/styles.css +1268 -0
  56. ui/templates/index.html +231 -0
  57. ui/utils/__init__.py +1 -0
  58. ui/utils/registry_manager.py +190 -0
ui/app.py ADDED
@@ -0,0 +1,2160 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import sys
8
+ import hashlib
9
+ from collections import Counter
10
+ from datetime import datetime, timezone
11
+ from threading import RLock
12
+ from typing import Any, Dict, List, Optional, Tuple
13
+
14
+ from fastapi import FastAPI, Query, Request
15
+ from fastapi.responses import HTMLResponse, JSONResponse
16
+ from fastapi.staticfiles import StaticFiles
17
+ from fastapi.templating import Jinja2Templates
18
+
19
+ from analysis.utils.cache_manager import (
20
+ compute_analysis_fingerprint,
21
+ compute_repo_hash,
22
+ get_cache_dir,
23
+ list_caches as cm_list_caches,
24
+ touch_last_accessed,
25
+ upsert_metadata,
26
+ )
27
+ from ui.utils.registry_manager import (
28
+ add_repo as registry_add_repo,
29
+ clear_repos as registry_clear_repos,
30
+ load_registry as registry_load,
31
+ remove_repo as registry_remove_repo,
32
+ save_registry_atomic as registry_save,
33
+ set_remember as registry_set_remember,
34
+ )
35
+ from security_utils import redact_payload, redact_secrets
36
+
37
+
38
+ PROJECT_ROOT = os.path.dirname(os.path.dirname(__file__))
39
+ ANALYSIS_ROOT = os.path.join(PROJECT_ROOT, "analysis")
40
+ DEFAULT_REPO = os.getenv("CODEMAP_UI_REPO", "testing_repo")
41
+ GLOBAL_CACHE_DIR = os.path.join(PROJECT_ROOT, ".codemap_cache")
42
+
43
+ MISSING_CACHE_MESSAGE = "Not analyzed yet. Run: python cli.py api analyze --path <repo>"
44
+
45
+ _SENSITIVE_FIELD_RE = re.compile(r"(?i)(api[_-]?key|token|authorization|bearer|basic|secret|password)")
46
+
47
+ _SESSION_LOCK = RLock()
48
+ _SESSION_WORKSPACE: Dict[str, Any] = {"active_repo_hash": "", "repos": []}
49
+ _SESSION_WORKSPACE_READY = False
50
+
51
+
52
+ app = FastAPI(title="CodeMap UI")
53
+ templates = Jinja2Templates(directory=os.path.join(os.path.dirname(__file__), "templates"))
54
+ app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static")
55
+ SEARCH_INDEX_CACHE: Dict[str, List[Dict[str, Any]]] = {}
56
+ GRAPH_INDEX_CACHE: Dict[str, Dict[str, Any]] = {}
57
+
58
+
59
+ def _load_json(path: str, default: Any) -> Any:
60
+ if not os.path.exists(path):
61
+ return default
62
+ with open(path, "r", encoding="utf-8") as f:
63
+ return json.load(f)
64
+
65
+
66
+ def _strip_sensitive_fields(payload: Any) -> Any:
67
+ if isinstance(payload, dict):
68
+ out: Dict[str, Any] = {}
69
+ for k, v in payload.items():
70
+ key = str(k or "")
71
+ if _SENSITIVE_FIELD_RE.search(key):
72
+ continue
73
+ out[key] = _strip_sensitive_fields(v)
74
+ return out
75
+ if isinstance(payload, list):
76
+ return [_strip_sensitive_fields(v) for v in payload]
77
+ return payload
78
+
79
+
80
+ def _cache_dir_size(path: str) -> int:
81
+ total = 0
82
+ if not os.path.isdir(path):
83
+ return 0
84
+ for root, _dirs, files in os.walk(path):
85
+ for name in files:
86
+ fp = os.path.join(root, name)
87
+ try:
88
+ total += int(os.path.getsize(fp))
89
+ except OSError:
90
+ continue
91
+ return int(total)
92
+
93
+
94
+ def _resolve_repo_dir(repo_dir: Optional[str]) -> str:
95
+ candidate = os.path.abspath(repo_dir or DEFAULT_REPO)
96
+ if os.path.exists(candidate):
97
+ return candidate
98
+ fallback = os.path.abspath(os.path.join(ANALYSIS_ROOT, repo_dir or DEFAULT_REPO))
99
+ if os.path.exists(fallback):
100
+ return fallback
101
+ return candidate
102
+
103
+
104
+ def _now_utc() -> str:
105
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
106
+
107
+
108
+ def _ui_state_path(cache_dir: str) -> str:
109
+ return os.path.join(cache_dir, "ui_state.json")
110
+
111
+
112
+ def _default_ui_state() -> Dict[str, Any]:
113
+ return {
114
+ "last_symbol": "",
115
+ "recent_symbols": [],
116
+ "recent_files": [],
117
+ "updated_at": _now_utc(),
118
+ }
119
+
120
+
121
+ def _ensure_parent(path: str) -> None:
122
+ os.makedirs(os.path.dirname(path), exist_ok=True)
123
+
124
+
125
+ def _save_json(path: str, data: Any) -> None:
126
+ _ensure_parent(path)
127
+ with open(path, "w", encoding="utf-8") as f:
128
+ json.dump(data, f, indent=2)
129
+
130
+
131
+ def _repo_ctx_from_dir(repo_dir: str) -> Dict[str, str]:
132
+ resolved = _resolve_repo_dir(repo_dir)
133
+ cache_dir = get_cache_dir(resolved)
134
+ return {
135
+ "repo_dir": resolved,
136
+ "repo_hash": compute_repo_hash(resolved),
137
+ "cache_dir": cache_dir,
138
+ "project_tree_path": os.path.join(cache_dir, "project_tree.json"),
139
+ "explain_path": os.path.join(cache_dir, "explain.json"),
140
+ "resolved_calls_path": os.path.join(cache_dir, "resolved_calls.json"),
141
+ "manifest_path": os.path.join(cache_dir, "manifest.json"),
142
+ "metrics_path": os.path.join(cache_dir, "analysis_metrics.json"),
143
+ "ui_state_path": _ui_state_path(cache_dir),
144
+ }
145
+
146
+
147
+ def _ensure_ui_state(ctx: Dict[str, str]) -> Dict[str, Any]:
148
+ state = _load_json(ctx["ui_state_path"], None)
149
+ if not isinstance(state, dict):
150
+ state = _default_ui_state()
151
+ _save_json(ctx["ui_state_path"], state)
152
+ return state
153
+
154
+
155
+ def _workspace_repo_from_registry(repo: Dict[str, Any]) -> Dict[str, Any]:
156
+ payload = {
157
+ "repo_hash": str(repo.get("repo_hash", "") or ""),
158
+ "name": str(repo.get("display_name", "") or repo.get("name", "") or ""),
159
+ "path": str(repo.get("repo_path", "") or ""),
160
+ "source": str(repo.get("source", "filesystem") or "filesystem"),
161
+ "repo_url": str(repo.get("repo_url", "") or ""),
162
+ "ref": str(repo.get("ref", "") or ""),
163
+ "mode": str(repo.get("mode", "") or ""),
164
+ "last_opened": str(repo.get("last_opened_at", "") or ""),
165
+ "private_mode": bool(repo.get("private_mode", False)),
166
+ }
167
+ return _repo_entry_from_payload(payload)
168
+
169
+
170
+ def _workspace_repo_to_registry(repo: Dict[str, Any]) -> Dict[str, Any]:
171
+ return {
172
+ "repo_hash": str(repo.get("repo_hash", "") or ""),
173
+ "display_name": str(repo.get("name", "") or ""),
174
+ "source": str(repo.get("source", "filesystem") or "filesystem"),
175
+ "repo_path": str(repo.get("path", "") or ""),
176
+ "repo_url": str(repo.get("repo_url", "") or ""),
177
+ "ref": str(repo.get("ref", "") or ""),
178
+ "mode": str(repo.get("mode", "") or ""),
179
+ "private_mode": bool(repo.get("private_mode", False)),
180
+ "added_at": str(repo.get("added_at", "") or _now_utc()),
181
+ "last_opened_at": str(repo.get("last_opened", "") or ""),
182
+ }
183
+
184
+
185
+ def _sync_session_workspace(force: bool = False) -> Dict[str, Any]:
186
+ global _SESSION_WORKSPACE_READY, _SESSION_WORKSPACE
187
+ with _SESSION_LOCK:
188
+ if _SESSION_WORKSPACE_READY and not force:
189
+ return {
190
+ "active_repo_hash": str(_SESSION_WORKSPACE.get("active_repo_hash", "") or ""),
191
+ "repos": [dict(r) for r in _SESSION_WORKSPACE.get("repos", []) if isinstance(r, dict)],
192
+ }
193
+ reg = registry_load(base_dir=GLOBAL_CACHE_DIR)
194
+ remember = bool(reg.get("remember_repos", False))
195
+ repos_raw = reg.get("repos", []) if remember else []
196
+ repos: List[Dict[str, Any]] = []
197
+ for repo in repos_raw:
198
+ if not isinstance(repo, dict):
199
+ continue
200
+ if not str(repo.get("repo_hash", "") or "").strip():
201
+ continue
202
+ repos.append(_workspace_repo_from_registry(repo))
203
+ active_repo_hash = repos[0]["repo_hash"] if repos else ""
204
+ _SESSION_WORKSPACE = {
205
+ "active_repo_hash": active_repo_hash,
206
+ "repos": repos,
207
+ }
208
+ _SESSION_WORKSPACE_READY = True
209
+ return {
210
+ "active_repo_hash": active_repo_hash,
211
+ "repos": [dict(r) for r in repos],
212
+ }
213
+
214
+
215
+ def _load_workspaces() -> Dict[str, Any]:
216
+ return _sync_session_workspace(force=False)
217
+
218
+
219
+ def _cache_manifest(cache_dir: str) -> Dict[str, Any]:
220
+ path = os.path.join(cache_dir, "manifest.json")
221
+ data = _load_json(path, {})
222
+ return data if isinstance(data, dict) else {}
223
+
224
+
225
+ def _list_cache_status() -> List[Dict[str, Any]]:
226
+ items: List[Dict[str, Any]] = []
227
+ for cache in cm_list_caches():
228
+ exp = cache.get("expires", {}) if isinstance(cache.get("expires"), dict) else {}
229
+ items.append(
230
+ {
231
+ "repo_hash": cache.get("repo_hash"),
232
+ "repo_path": cache.get("repo_path", ""),
233
+ "repo_url": cache.get("repo_url", ""),
234
+ "source": cache.get("source", "filesystem"),
235
+ "cache_dir": cache.get("cache_dir"),
236
+ "workspace_dir": cache.get("workspace_dir", ""),
237
+ "size_bytes": int(cache.get("size_bytes", 0)),
238
+ "last_updated": cache.get("last_accessed_at"),
239
+ "analysis_version": cache.get("analysis_version"),
240
+ "private_mode": bool(cache.get("private_mode", False)),
241
+ "retention": {
242
+ "mode": exp.get("mode", "ttl"),
243
+ "ttl_days": int(cache.get("retention_days", 14) or 14),
244
+ "days_left": exp.get("days_left"),
245
+ "expired": bool(exp.get("expired", False)),
246
+ },
247
+ "has": cache.get("has", {}),
248
+ }
249
+ )
250
+ return items
251
+
252
+
253
+ def _save_workspaces(ws: Dict[str, Any]) -> None:
254
+ global _SESSION_WORKSPACE_READY, _SESSION_WORKSPACE
255
+ with _SESSION_LOCK:
256
+ repos = ws.get("repos", []) if isinstance(ws, dict) else []
257
+ normalized_repos = [r for r in repos if isinstance(r, dict) and str(r.get("repo_hash", "") or "").strip()]
258
+ active = str((ws or {}).get("active_repo_hash", "") or "")
259
+ if active and not any(str(r.get("repo_hash", "")) == active for r in normalized_repos):
260
+ active = normalized_repos[0]["repo_hash"] if normalized_repos else ""
261
+ _SESSION_WORKSPACE = {"active_repo_hash": active, "repos": normalized_repos}
262
+ _SESSION_WORKSPACE_READY = True
263
+
264
+ reg = registry_load(base_dir=GLOBAL_CACHE_DIR)
265
+ if bool(reg.get("remember_repos", False)):
266
+ reg_repos = [_workspace_repo_to_registry(r) for r in normalized_repos]
267
+ registry_save(
268
+ {
269
+ "version": int(reg.get("version", 1) or 1),
270
+ "remember_repos": True,
271
+ "repos": reg_repos,
272
+ },
273
+ base_dir=GLOBAL_CACHE_DIR,
274
+ )
275
+
276
+
277
+ def _repo_entry(repo_dir: str) -> Dict[str, str]:
278
+ resolved = _resolve_repo_dir(repo_dir)
279
+ repo_hash = compute_repo_hash(resolved)
280
+ return {
281
+ "name": os.path.basename(resolved.rstrip("\\/")) or resolved,
282
+ "path": resolved,
283
+ "repo_hash": repo_hash,
284
+ "last_opened": _now_utc(),
285
+ "source": "filesystem",
286
+ "repo_url": "",
287
+ "ref": "",
288
+ "mode": "",
289
+ }
290
+
291
+
292
+ def _repo_entry_from_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
293
+ path = _resolve_repo_dir(str(payload.get("path", "") or ""))
294
+ repo_hash = str(payload.get("repo_hash", "") or compute_repo_hash(path))
295
+ source = str(payload.get("source", "filesystem") or "filesystem")
296
+ if source not in {"filesystem", "github"}:
297
+ source = "filesystem"
298
+ repo_url = str(payload.get("repo_url", "") or "").strip()
299
+ if repo_url:
300
+ try:
301
+ from analysis.utils.repo_fetcher import normalize_github_url
302
+ repo_url = normalize_github_url(repo_url)
303
+ except Exception:
304
+ repo_url = redact_secrets(repo_url)
305
+ return {
306
+ "name": str(payload.get("name") or os.path.basename(path.rstrip("\\/")) or path),
307
+ "path": path,
308
+ "repo_hash": repo_hash,
309
+ "last_opened": _now_utc(),
310
+ "source": source,
311
+ "repo_url": repo_url,
312
+ "ref": str(payload.get("ref", "") or ""),
313
+ "mode": str(payload.get("mode", "") or ""),
314
+ "private_mode": bool(payload.get("private_mode", False)),
315
+ }
316
+
317
+
318
+ def _ensure_default_workspace() -> Dict[str, Any]:
319
+ return _load_workspaces()
320
+
321
+
322
+ def _upsert_workspace_repo(entry: Dict[str, Any], set_active: bool = True) -> Dict[str, Any]:
323
+ ws = _load_workspaces()
324
+ repos = ws.get("repos", [])
325
+ if not isinstance(repos, list):
326
+ repos = []
327
+ existing = next((r for r in repos if isinstance(r, dict) and r.get("repo_hash") == entry.get("repo_hash")), None)
328
+ if existing:
329
+ existing.update(entry)
330
+ existing["last_opened"] = _now_utc()
331
+ else:
332
+ repos.append(entry)
333
+ ws["repos"] = repos
334
+ if set_active:
335
+ ws["active_repo_hash"] = str(entry.get("repo_hash", "") or "")
336
+ _save_workspaces(ws)
337
+ return ws
338
+
339
+
340
+ def _cli_json(args: List[str], timeout_sec: int = 1800) -> Dict[str, Any]:
341
+ return _cli_json_with_input(args=args, timeout_sec=timeout_sec, stdin_text=None, extra_env=None)
342
+
343
+
344
+ def _cli_json_with_input(
345
+ args: List[str],
346
+ timeout_sec: int = 1800,
347
+ stdin_text: Optional[str] = None,
348
+ extra_env: Optional[Dict[str, str]] = None,
349
+ ) -> Dict[str, Any]:
350
+ cmd = [sys.executable, os.path.join(PROJECT_ROOT, "cli.py"), "api"] + list(args)
351
+ env = os.environ.copy()
352
+ if isinstance(extra_env, dict):
353
+ for k, v in extra_env.items():
354
+ key = str(k or "").strip()
355
+ if not key:
356
+ continue
357
+ env[key] = str(v or "")
358
+ try:
359
+ proc = subprocess.run(
360
+ cmd,
361
+ cwd=PROJECT_ROOT,
362
+ capture_output=True,
363
+ text=True,
364
+ input=stdin_text,
365
+ env=env,
366
+ timeout=timeout_sec,
367
+ check=False,
368
+ )
369
+ except subprocess.TimeoutExpired:
370
+ return {"ok": False, "error": "CLI_TIMEOUT", "message": "CLI analyze timed out"}
371
+ except Exception as e:
372
+ return {"ok": False, "error": "CLI_EXEC_FAILED", "message": redact_secrets(str(e))}
373
+
374
+ stdout = redact_secrets((proc.stdout or "").strip())
375
+ stderr = redact_secrets((proc.stderr or "").strip())
376
+ payload: Dict[str, Any]
377
+ try:
378
+ payload = json.loads(stdout) if stdout else {"ok": False, "error": "EMPTY_OUTPUT", "message": "CLI returned empty output"}
379
+ except Exception:
380
+ payload = {
381
+ "ok": False,
382
+ "error": "INVALID_CLI_JSON",
383
+ "message": "Failed to parse CLI JSON output",
384
+ "stdout": stdout[-4000:],
385
+ "stderr": stderr[-4000:],
386
+ }
387
+ if proc.returncode != 0 and payload.get("ok") is not False:
388
+ payload = {
389
+ "ok": False,
390
+ "error": "CLI_FAILED",
391
+ "message": stderr or payload.get("message") or "CLI command failed",
392
+ "stdout": stdout[-4000:],
393
+ "stderr": stderr[-4000:],
394
+ }
395
+ return redact_payload(payload)
396
+
397
+
398
+ def _repo_analyze_command(repo: Dict[str, Any]) -> str:
399
+ source = str(repo.get("source", "filesystem") or "filesystem")
400
+ if source == "github":
401
+ repo_url = str(repo.get("repo_url", "") or "").strip()
402
+ ref = str(repo.get("ref", "") or "").strip() or "main"
403
+ mode = str(repo.get("mode", "") or "zip")
404
+ return f"python cli.py api analyze --github {repo_url} --ref {ref} --mode {mode}"
405
+ return f"python cli.py api analyze --path {repo.get('path', '<repo>')}"
406
+
407
+
408
+ def _get_active_repo_entry() -> Optional[Dict[str, str]]:
409
+ ws = _ensure_default_workspace()
410
+ repos = ws.get("repos", [])
411
+ active_hash = ws.get("active_repo_hash", "")
412
+ for repo in repos:
413
+ if repo.get("repo_hash") == active_hash:
414
+ return repo
415
+ return None
416
+
417
+
418
+ def _active_repo_ctx() -> Optional[Dict[str, str]]:
419
+ active = _get_active_repo_entry()
420
+ if not active:
421
+ return None
422
+ ctx = _repo_ctx_from_dir(active["path"])
423
+ try:
424
+ touch_last_accessed(ctx["repo_hash"])
425
+ except Exception:
426
+ pass
427
+ _ensure_ui_state(ctx)
428
+ return ctx
429
+
430
+
431
+ def _repo_ctx(repo: Optional[str]) -> Dict[str, str]:
432
+ # Backward-compatible helper retained for older internal call sites.
433
+ if repo:
434
+ ctx = _repo_ctx_from_dir(repo)
435
+ try:
436
+ touch_last_accessed(ctx["repo_hash"])
437
+ except Exception:
438
+ pass
439
+ return ctx
440
+ active = _active_repo_ctx()
441
+ if active:
442
+ try:
443
+ touch_last_accessed(active["repo_hash"])
444
+ except Exception:
445
+ pass
446
+ return active
447
+ repo_dir = _resolve_repo_dir(repo)
448
+ ctx = _repo_ctx_from_dir(repo_dir)
449
+ try:
450
+ touch_last_accessed(ctx["repo_hash"])
451
+ except Exception:
452
+ pass
453
+ return ctx
454
+
455
+
456
+ def _resolve_repo_dir_from_payload(payload: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]:
457
+ if not isinstance(payload, dict):
458
+ payload = {}
459
+
460
+ repo_raw = payload.get("repo")
461
+ if isinstance(repo_raw, str) and repo_raw.strip():
462
+ return _resolve_repo_dir(repo_raw.strip()), None
463
+
464
+ repo_hash = str(payload.get("repo_hash", "") or "").strip()
465
+ if repo_hash:
466
+ ws = _ensure_default_workspace()
467
+ for repo in ws.get("repos", []):
468
+ if isinstance(repo, dict) and str(repo.get("repo_hash", "")) == repo_hash:
469
+ return _resolve_repo_dir(str(repo.get("path", "") or "")), None
470
+ return None, "REPO_NOT_FOUND"
471
+
472
+ github_url = str(payload.get("github", "") or "").strip()
473
+ if github_url:
474
+ ref = str(payload.get("ref", "") or "main").strip() or "main"
475
+ mode = str(payload.get("mode", "") or "zip").strip().lower() or "zip"
476
+ try:
477
+ from analysis.utils.repo_fetcher import resolve_workspace_paths
478
+
479
+ ws_paths = resolve_workspace_paths(github_url, ref, mode)
480
+ return str(ws_paths.get("repo_dir", "") or ""), None
481
+ except Exception as e:
482
+ return None, redact_secrets(str(e))
483
+
484
+ active = _get_active_repo_entry()
485
+ if active:
486
+ return _resolve_repo_dir(str(active.get("path", "") or "")), None
487
+ return None, "NO_ACTIVE_REPO"
488
+
489
+
490
+ def _repo_fingerprint(repo_dir: str, cache_dir: str) -> str:
491
+ git_dir = os.path.join(repo_dir, ".git")
492
+ if os.path.isdir(git_dir):
493
+ try:
494
+ proc = subprocess.run(
495
+ ["git", "-C", repo_dir, "rev-parse", "HEAD"],
496
+ capture_output=True,
497
+ text=True,
498
+ timeout=4,
499
+ check=False,
500
+ )
501
+ sha = str(proc.stdout or "").strip()
502
+ if proc.returncode == 0 and re.fullmatch(r"[0-9a-fA-F]{40}", sha):
503
+ return f"git:{sha.lower()}"
504
+ except Exception:
505
+ pass
506
+ try:
507
+ fp = compute_analysis_fingerprint(repo_dir)
508
+ if fp:
509
+ return f"analysis:{fp}"
510
+ except Exception:
511
+ pass
512
+ parts: List[str] = []
513
+ for name in ("resolved_calls.json", "risk_radar.json", "project_tree.json"):
514
+ p = os.path.join(cache_dir, name)
515
+ if not os.path.exists(p):
516
+ parts.append(f"{name}:missing")
517
+ continue
518
+ st = os.stat(p)
519
+ parts.append(f"{name}:{int(st.st_mtime_ns)}:{int(st.st_size)}")
520
+ return "fallback:" + hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
521
+
522
+
523
+ def _has_analysis_cache(ctx: Dict[str, str]) -> bool:
524
+ if not os.path.exists(ctx["cache_dir"]):
525
+ return False
526
+ return os.path.exists(ctx["explain_path"]) and os.path.exists(ctx["resolved_calls_path"])
527
+
528
+
529
+ def _missing_cache_response() -> JSONResponse:
530
+ return JSONResponse(
531
+ status_code=400,
532
+ content={"ok": False, "error": "CACHE_NOT_FOUND", "message": MISSING_CACHE_MESSAGE},
533
+ )
534
+
535
+
536
+ def _no_active_repo_response() -> JSONResponse:
537
+ return JSONResponse(
538
+ status_code=400,
539
+ content={
540
+ "ok": False,
541
+ "error": "NO_ACTIVE_REPO",
542
+ "message": "No repository selected. Add one in workspace first.",
543
+ },
544
+ )
545
+
546
+
547
+ def _norm(path: str) -> str:
548
+ return os.path.normcase(os.path.abspath(path))
549
+
550
+
551
+ def _rel_file(ctx: Dict[str, str], file_path: str) -> str:
552
+ if not file_path:
553
+ return ""
554
+ abs_path = os.path.abspath(file_path)
555
+ if _norm(abs_path).startswith(_norm(ctx["repo_dir"])):
556
+ return os.path.relpath(abs_path, ctx["repo_dir"]).replace("\\", "/")
557
+ return file_path.replace("\\", "/")
558
+
559
+
560
+ def _load_repo_data(ctx: Dict[str, str]) -> Dict[str, Any]:
561
+ explain = _load_json(ctx["explain_path"], {})
562
+ resolved_calls = _load_json(ctx["resolved_calls_path"], [])
563
+ return {"explain": explain, "resolved_calls": resolved_calls}
564
+
565
+
566
+ def _repo_registry_data() -> List[Dict[str, Any]]:
567
+ ws = _ensure_default_workspace()
568
+ repos = ws.get("repos", []) if isinstance(ws, dict) else []
569
+ status_by_hash = {str(s.get("repo_hash", "")): s for s in _list_cache_status()}
570
+ items: List[Dict[str, Any]] = []
571
+
572
+ for repo in repos:
573
+ if not isinstance(repo, dict):
574
+ continue
575
+ entry = _repo_entry_from_payload(repo)
576
+ repo_hash = entry["repo_hash"]
577
+ if not repo_hash:
578
+ continue
579
+ status = status_by_hash.get(repo_hash, {})
580
+ has_map = status.get("has", {}) if isinstance(status.get("has"), dict) else {}
581
+ has_analysis = bool(has_map.get("explain") and has_map.get("resolved_calls"))
582
+ items.append(
583
+ {
584
+ "repo_hash": repo_hash,
585
+ "name": entry.get("name", ""),
586
+ "source": entry.get("source", "filesystem"),
587
+ "repo_path": entry.get("path", ""),
588
+ "repo_url": entry.get("repo_url", "") or status.get("repo_url", ""),
589
+ "ref": entry.get("ref", "") or status.get("ref", ""),
590
+ "mode": entry.get("mode", ""),
591
+ "cache_dir": status.get("cache_dir") or get_cache_dir(entry["path"]),
592
+ "workspace_dir": status.get("workspace_dir", ""),
593
+ "has_analysis": has_analysis,
594
+ "last_updated": status.get("last_updated"),
595
+ "size_bytes": int(status.get("size_bytes", 0)),
596
+ "retention": status.get("retention", {}),
597
+ "private_mode": bool(status.get("private_mode", False) or entry.get("private_mode", False)),
598
+ "analyze_command": _repo_analyze_command(entry),
599
+ }
600
+ )
601
+
602
+ items.sort(key=lambda x: (str(x.get("name", "")).lower(), str(x.get("repo_hash", ""))))
603
+ return items
604
+
605
+
606
+ def _build_symbol_connections(
607
+ ctx: Dict[str, str],
608
+ fqn: str,
609
+ explain: Dict[str, Any],
610
+ resolved_calls: List[Dict[str, Any]],
611
+ ) -> Dict[str, Any]:
612
+ called_by: List[Dict[str, Any]] = []
613
+ used_in: List[Dict[str, Any]] = []
614
+ calls_counter: Counter[str] = Counter()
615
+
616
+ for call in resolved_calls:
617
+ caller_fqn = call.get("caller_fqn")
618
+ callee_fqn = call.get("callee_fqn")
619
+ file_path = call.get("file", "")
620
+ line = int(call.get("line", -1))
621
+
622
+ if callee_fqn == fqn:
623
+ item = {
624
+ "fqn": caller_fqn,
625
+ "file": _rel_file(ctx, file_path),
626
+ "line": line,
627
+ }
628
+ called_by.append(item)
629
+ used_in.append(item)
630
+
631
+ if caller_fqn == fqn and callee_fqn:
632
+ calls_counter[callee_fqn] += 1
633
+
634
+ called_by.sort(key=lambda x: (x.get("file", ""), int(x.get("line", -1)), x.get("fqn", "")))
635
+ used_in.sort(key=lambda x: (x.get("file", ""), int(x.get("line", -1)), x.get("fqn", "")))
636
+
637
+ calls: List[Dict[str, Any]] = []
638
+ for callee_fqn, count in sorted(calls_counter.items(), key=lambda x: (x[0].lower(), x[1])):
639
+ parts = callee_fqn.split(".")
640
+ if len(parts) >= 2 and parts[-2][:1].isupper():
641
+ name = f"{parts[-2]}.{parts[-1]}"
642
+ else:
643
+ name = parts[-1]
644
+ calls.append(
645
+ {
646
+ "name": name,
647
+ "fqn": callee_fqn,
648
+ "count": int(count),
649
+ "clickable": callee_fqn in explain,
650
+ }
651
+ )
652
+
653
+ return {
654
+ "called_by": called_by,
655
+ "calls": calls,
656
+ "used_in": used_in,
657
+ }
658
+
659
+
660
+ def _display_and_module_from_fqn(fqn: str) -> Dict[str, str]:
661
+ parts = fqn.split(".")
662
+ if len(parts) >= 2 and parts[-2][:1].isupper():
663
+ return {
664
+ "display": f"{parts[-2]}.{parts[-1]}",
665
+ "module": ".".join(parts[:-2]),
666
+ "class_name": parts[-2],
667
+ "short_name": parts[-1],
668
+ }
669
+ return {
670
+ "display": parts[-1],
671
+ "module": ".".join(parts[:-1]),
672
+ "class_name": "",
673
+ "short_name": parts[-1],
674
+ }
675
+
676
+
677
+ def _ai_cache_root(cache_dir: str) -> str:
678
+ path = os.path.join(cache_dir, "ai")
679
+ os.makedirs(path, exist_ok=True)
680
+ return path
681
+
682
+
683
+ def _repo_summary_cache_path(cache_dir: str) -> str:
684
+ return os.path.join(_ai_cache_root(cache_dir), "repo_summary.json")
685
+
686
+
687
+ def _safe_symbol_key(fqn: str) -> str:
688
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", str(fqn or "")).strip("._") or "symbol"
689
+
690
+
691
+ def _symbol_summary_cache_path(cache_dir: str, fqn: str) -> str:
692
+ return os.path.join(_ai_cache_root(cache_dir), "symbols", f"{_safe_symbol_key(fqn)}.json")
693
+
694
+
695
+ def _load_repo_summary_cached(cache_dir: str) -> Dict[str, Any]:
696
+ return _load_json(_repo_summary_cache_path(cache_dir), {})
697
+
698
+
699
+ def _load_symbol_summary_cached(cache_dir: str, fqn: str) -> Dict[str, Any]:
700
+ return _load_json(_symbol_summary_cache_path(cache_dir, fqn), {})
701
+
702
+
703
+ def _summary_markdown_from_structured(summary: Dict[str, Any]) -> str:
704
+ if not isinstance(summary, dict):
705
+ return ""
706
+ lines: List[str] = []
707
+ one_liner = str(summary.get("one_liner", "") or "").strip()
708
+ if one_liner:
709
+ lines.append(f"- {one_liner}")
710
+ bullets = summary.get("bullets", [])
711
+ if isinstance(bullets, list):
712
+ for item in bullets[:7]:
713
+ clean = str(item or "").strip()
714
+ if clean:
715
+ lines.append(f"- {clean}")
716
+ notes = summary.get("notes", [])
717
+ if isinstance(notes, list):
718
+ for item in notes[:5]:
719
+ clean = str(item or "").strip()
720
+ if clean:
721
+ lines.append(f"- Note: {clean}")
722
+ return "\n".join(lines).strip()
723
+
724
+
725
+ def _summary_structured_from_markdown(content_markdown: str) -> Dict[str, Any]:
726
+ lines = [ln.strip() for ln in str(content_markdown or "").splitlines() if ln.strip()]
727
+ one_liner = ""
728
+ bullets: List[str] = []
729
+ if lines:
730
+ one_liner = re.sub(r"^\-\s*", "", lines[0]).strip()
731
+ for ln in lines[1:8]:
732
+ clean = re.sub(r"^\-\s*", "", ln).strip()
733
+ if clean:
734
+ bullets.append(clean)
735
+ return {"one_liner": one_liner, "bullets": bullets, "notes": []}
736
+
737
+
738
+ def _build_search_index(ctx: Dict[str, str]) -> List[Dict[str, Any]]:
739
+ cache_key = ctx["repo_hash"]
740
+ if cache_key in SEARCH_INDEX_CACHE:
741
+ return SEARCH_INDEX_CACHE[cache_key]
742
+
743
+ explain = _load_json(ctx["explain_path"], {})
744
+ items: List[Dict[str, Any]] = []
745
+ for fqn, obj in explain.items():
746
+ dm = _display_and_module_from_fqn(fqn)
747
+ loc = obj.get("location") or {}
748
+ rel_file = _rel_file(ctx, loc.get("file", ""))
749
+ searchable = " ".join([
750
+ fqn.lower(),
751
+ dm["display"].lower(),
752
+ dm["short_name"].lower(),
753
+ dm["class_name"].lower(),
754
+ ]).strip()
755
+ items.append({
756
+ "fqn": fqn,
757
+ "display": dm["display"],
758
+ "module": dm["module"],
759
+ "file": rel_file,
760
+ "line": int(loc.get("start_line", -1)),
761
+ "_searchable": searchable,
762
+ })
763
+ SEARCH_INDEX_CACHE[cache_key] = items
764
+ return items
765
+
766
+
767
+ def _classify_symbol(fqn: str, explain: Dict[str, Any]) -> str:
768
+ if fqn.startswith("builtins."):
769
+ return "builtin"
770
+ if fqn in explain:
771
+ return "local"
772
+ if fqn.startswith("external::"):
773
+ return "external"
774
+ return "external"
775
+
776
+
777
+ def _short_label(fqn: str) -> str:
778
+ if fqn.startswith("external::"):
779
+ return fqn.split("external::", 1)[1]
780
+ parts = fqn.split(".")
781
+ if len(parts) >= 2 and parts[-2][:1].isupper():
782
+ return f"{parts[-2]}.{parts[-1]}"
783
+ return parts[-1]
784
+
785
+
786
+ def _record_ai_fingerprint_source(repo_hash: str, fingerprint: str) -> None:
787
+ if not repo_hash:
788
+ return
789
+ try:
790
+ cache_dir = os.path.join(GLOBAL_CACHE_DIR, str(repo_hash))
791
+ manifest_path = os.path.join(cache_dir, "manifest.json")
792
+ manifest = _load_json(manifest_path, {})
793
+ if not isinstance(manifest, dict):
794
+ manifest = {}
795
+ manifest["ai_fingerprint_source"] = str(fingerprint or "")
796
+ manifest["updated_at"] = manifest.get("updated_at") or _now_utc()
797
+ os.makedirs(cache_dir, exist_ok=True)
798
+ with open(manifest_path, "w", encoding="utf-8") as f:
799
+ json.dump(manifest, f, indent=2)
800
+ except Exception:
801
+ return
802
+
803
+
804
+ def _analysis_version_from_cache(cache_dir: str) -> str:
805
+ manifest = _load_json(os.path.join(cache_dir, "manifest.json"), {})
806
+ if isinstance(manifest, dict):
807
+ return str(manifest.get("analysis_version", "") or "")
808
+ return ""
809
+
810
+
811
+ def _build_graph_index(ctx: Dict[str, str]) -> Dict[str, Any]:
812
+ cache_key = ctx["repo_hash"]
813
+ resolved_mtime = os.path.getmtime(ctx["resolved_calls_path"]) if os.path.exists(ctx["resolved_calls_path"]) else -1
814
+ explain_mtime = os.path.getmtime(ctx["explain_path"]) if os.path.exists(ctx["explain_path"]) else -1
815
+ signature = f"{resolved_mtime}:{explain_mtime}"
816
+
817
+ cached = GRAPH_INDEX_CACHE.get(cache_key)
818
+ if cached and cached.get("signature") == signature:
819
+ return cached["index"]
820
+
821
+ explain = _load_json(ctx["explain_path"], {})
822
+ resolved_calls = _load_json(ctx["resolved_calls_path"], [])
823
+
824
+ callees_map: Dict[str, List[str]] = {}
825
+ callers_map: Dict[str, List[str]] = {}
826
+ edge_counts: Dict[tuple, int] = {}
827
+
828
+ for call in resolved_calls:
829
+ caller = call.get("caller_fqn")
830
+ if not caller:
831
+ continue
832
+ callee = call.get("callee_fqn")
833
+ if not callee:
834
+ raw_name = str(call.get("callee") or "<unknown>").strip()
835
+ callee = f"external::{raw_name}"
836
+
837
+ callees_map.setdefault(caller, []).append(callee)
838
+ callers_map.setdefault(callee, []).append(caller)
839
+ edge_key = (caller, callee)
840
+ edge_counts[edge_key] = edge_counts.get(edge_key, 0) + 1
841
+
842
+ index = {
843
+ "explain": explain,
844
+ "callees_map": callees_map,
845
+ "callers_map": callers_map,
846
+ "edge_counts": edge_counts,
847
+ }
848
+ GRAPH_INDEX_CACHE[cache_key] = {"signature": signature, "index": index}
849
+ return index
850
+
851
+
852
+ def _normalize_ui_state(state: Dict[str, Any]) -> Dict[str, Any]:
853
+ if not isinstance(state, dict):
854
+ return _default_ui_state()
855
+ norm = _default_ui_state()
856
+ norm["last_symbol"] = str(state.get("last_symbol", "") or "")
857
+ norm["recent_symbols"] = [x for x in state.get("recent_symbols", []) if isinstance(x, str)][:20]
858
+ norm["recent_files"] = [x for x in state.get("recent_files", []) if isinstance(x, str)][:20]
859
+ norm["updated_at"] = str(state.get("updated_at", _now_utc()))
860
+ return norm
861
+
862
+
863
+ def _push_recent(items: List[str], value: str, limit: int = 20) -> List[str]:
864
+ clean = [x for x in items if isinstance(x, str) and x != value]
865
+ clean.insert(0, value)
866
+ return clean[:limit]
867
+
868
+
869
+ @app.get("/", response_class=HTMLResponse)
870
+ def index(request: Request):
871
+ return templates.TemplateResponse("index.html", {"request": request, "default_repo": DEFAULT_REPO})
872
+
873
+
874
+ @app.get("/api/workspace")
875
+ def api_workspace():
876
+ ws = _ensure_default_workspace()
877
+ normalized_repos = []
878
+ for repo in ws.get("repos", []):
879
+ if isinstance(repo, dict):
880
+ normalized_repos.append(_repo_entry_from_payload(repo))
881
+ return {
882
+ "ok": True,
883
+ "repos": normalized_repos,
884
+ "active_repo_hash": ws.get("active_repo_hash", ""),
885
+ }
886
+
887
+
888
+ @app.get("/api/repo_registry")
889
+ def api_repo_registry():
890
+ ws = _ensure_default_workspace()
891
+ return {
892
+ "ok": True,
893
+ "active_repo_hash": ws.get("active_repo_hash", ""),
894
+ "repos": _repo_registry_data(),
895
+ }
896
+
897
+
898
+ def _registry_public_payload() -> Dict[str, Any]:
899
+ reg = registry_load(base_dir=GLOBAL_CACHE_DIR)
900
+ repos = reg.get("repos", []) if isinstance(reg.get("repos"), list) else []
901
+ safe_repos = []
902
+ for repo in repos:
903
+ if not isinstance(repo, dict):
904
+ continue
905
+ safe_repos.append(
906
+ {
907
+ "repo_hash": str(repo.get("repo_hash", "") or ""),
908
+ "display_name": str(repo.get("display_name", "") or ""),
909
+ "source": str(repo.get("source", "filesystem") or "filesystem"),
910
+ "repo_path": str(repo.get("repo_path", "") or ""),
911
+ "repo_url": str(repo.get("repo_url", "") or ""),
912
+ "ref": str(repo.get("ref", "") or ""),
913
+ "mode": str(repo.get("mode", "") or ""),
914
+ "added_at": str(repo.get("added_at", "") or ""),
915
+ "last_opened_at": str(repo.get("last_opened_at", "") or ""),
916
+ "private_mode": bool(repo.get("private_mode", False)),
917
+ }
918
+ )
919
+ ws = _ensure_default_workspace()
920
+ payload = {
921
+ "ok": True,
922
+ "version": int(reg.get("version", 1) or 1),
923
+ "remember_repos": bool(reg.get("remember_repos", False)),
924
+ "repos": safe_repos,
925
+ "session_repos": [dict(r) for r in ws.get("repos", []) if isinstance(r, dict)],
926
+ "active_repo_hash": str(ws.get("active_repo_hash", "") or ""),
927
+ }
928
+ return _strip_sensitive_fields(payload)
929
+
930
+
931
+ @app.get("/api/registry")
932
+ def api_registry_get():
933
+ return _registry_public_payload()
934
+
935
+
936
+ @app.post("/api/registry/settings")
937
+ async def api_registry_settings(request: Request):
938
+ body = await request.json()
939
+ payload = body if isinstance(body, dict) else {}
940
+ remember = bool(payload.get("remember_repos", False))
941
+ reg = registry_set_remember(remember, base_dir=GLOBAL_CACHE_DIR)
942
+ if remember:
943
+ _sync_session_workspace(force=True)
944
+ else:
945
+ _save_workspaces({"active_repo_hash": "", "repos": []})
946
+ return {
947
+ "ok": True,
948
+ "remember_repos": bool(reg.get("remember_repos", False)),
949
+ }
950
+
951
+
952
+ @app.post("/api/registry/repos/add")
953
+ async def api_registry_repos_add(request: Request):
954
+ from analysis.utils.repo_fetcher import normalize_github_url, resolve_workspace_paths
955
+
956
+ body = await request.json()
957
+ payload = body if isinstance(body, dict) else {}
958
+ source = str(payload.get("source", "filesystem") or "filesystem").strip().lower()
959
+ display_name = str(payload.get("display_name", "") or "").strip()
960
+ open_after_add = bool(payload.get("open_after_add", True))
961
+ private_mode = bool(payload.get("private_mode", False))
962
+ remember = bool(registry_load(base_dir=GLOBAL_CACHE_DIR).get("remember_repos", False))
963
+
964
+ if source == "github":
965
+ repo_url = str(payload.get("repo_url", "") or "").strip()
966
+ ref = str(payload.get("ref", "") or "").strip() or "main"
967
+ mode = str(payload.get("mode", "") or "zip").strip().lower() or "zip"
968
+ if not repo_url:
969
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_GITHUB_URL"})
970
+ if mode not in {"zip", "git"}:
971
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_MODE"})
972
+ try:
973
+ normalized_url = normalize_github_url(repo_url)
974
+ ws_paths = resolve_workspace_paths(normalized_url, ref, mode)
975
+ except Exception as e:
976
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_GITHUB_URL", "message": redact_secrets(str(e))})
977
+ repo_path = str(ws_paths.get("repo_dir", "") or "")
978
+ entry = _repo_entry_from_payload(
979
+ {
980
+ "path": repo_path,
981
+ "name": display_name or ws_paths.get("repo_name", "") or os.path.basename(repo_path.rstrip("\\/")) or repo_path,
982
+ "source": "github",
983
+ "repo_url": normalized_url,
984
+ "ref": ref,
985
+ "mode": mode,
986
+ "private_mode": private_mode,
987
+ }
988
+ )
989
+ else:
990
+ repo_path = str(payload.get("repo_path", "") or "").strip()
991
+ if not repo_path:
992
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_PATH"})
993
+ resolved = _resolve_repo_dir(repo_path)
994
+ if not os.path.isdir(resolved):
995
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_PATH"})
996
+ entry = _repo_entry_from_payload(
997
+ {
998
+ "path": resolved,
999
+ "name": display_name or os.path.basename(resolved.rstrip("\\/")) or resolved,
1000
+ "source": "filesystem",
1001
+ "private_mode": private_mode,
1002
+ }
1003
+ )
1004
+
1005
+ ws = _load_workspaces()
1006
+ repos = ws.get("repos", []) if isinstance(ws.get("repos"), list) else []
1007
+ existing = next((r for r in repos if isinstance(r, dict) and str(r.get("repo_hash", "")) == str(entry.get("repo_hash", ""))), None)
1008
+ if existing:
1009
+ existing.update(entry)
1010
+ existing["last_opened"] = _now_utc()
1011
+ else:
1012
+ repos.append(entry)
1013
+ ws["repos"] = repos
1014
+ if open_after_add or not str(ws.get("active_repo_hash", "") or ""):
1015
+ ws["active_repo_hash"] = str(entry.get("repo_hash", "") or "")
1016
+ _save_workspaces(ws)
1017
+
1018
+ if remember:
1019
+ registry_add_repo(
1020
+ {
1021
+ "repo_hash": entry.get("repo_hash", ""),
1022
+ "display_name": entry.get("name", ""),
1023
+ "source": entry.get("source", "filesystem"),
1024
+ "repo_path": entry.get("path", ""),
1025
+ "repo_url": entry.get("repo_url", ""),
1026
+ "ref": entry.get("ref", ""),
1027
+ "mode": entry.get("mode", ""),
1028
+ "private_mode": bool(entry.get("private_mode", False)),
1029
+ "added_at": _now_utc(),
1030
+ "last_opened_at": _now_utc(),
1031
+ },
1032
+ base_dir=GLOBAL_CACHE_DIR,
1033
+ )
1034
+ return {
1035
+ "ok": True,
1036
+ "repo": entry,
1037
+ "repo_hash": entry.get("repo_hash", ""),
1038
+ "persisted": remember,
1039
+ }
1040
+
1041
+
1042
+ @app.post("/api/registry/repos/remove")
1043
+ async def api_registry_repos_remove(request: Request):
1044
+ body = await request.json()
1045
+ payload = body if isinstance(body, dict) else {}
1046
+ repo_hash = str(payload.get("repo_hash", "") or "").strip()
1047
+ if not repo_hash:
1048
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_REPO_HASH"})
1049
+
1050
+ ws = _load_workspaces()
1051
+ repos = ws.get("repos", []) if isinstance(ws.get("repos"), list) else []
1052
+ ws["repos"] = [r for r in repos if not (isinstance(r, dict) and str(r.get("repo_hash", "")) == repo_hash)]
1053
+ if str(ws.get("active_repo_hash", "") or "") == repo_hash:
1054
+ ws["active_repo_hash"] = ws["repos"][0]["repo_hash"] if ws["repos"] else ""
1055
+ _save_workspaces(ws)
1056
+
1057
+ remember = bool(registry_load(base_dir=GLOBAL_CACHE_DIR).get("remember_repos", False))
1058
+ if remember:
1059
+ registry_remove_repo(repo_hash, base_dir=GLOBAL_CACHE_DIR)
1060
+ return {"ok": True, "repo_hash": repo_hash, "persisted": remember}
1061
+
1062
+
1063
+ @app.post("/api/registry/repos/clear")
1064
+ async def api_registry_repos_clear(request: Request):
1065
+ body = await request.json()
1066
+ payload = body if isinstance(body, dict) else {}
1067
+ session_only = bool(payload.get("session_only", False))
1068
+ ws = {"active_repo_hash": "", "repos": []}
1069
+ _save_workspaces(ws)
1070
+ remember = bool(registry_load(base_dir=GLOBAL_CACHE_DIR).get("remember_repos", False))
1071
+ if remember and not session_only:
1072
+ registry_clear_repos(base_dir=GLOBAL_CACHE_DIR)
1073
+ return {"ok": True, "remember_repos": remember, "session_only": session_only}
1074
+
1075
+
1076
+ @app.get("/api/cache/list")
1077
+ def api_cache_list():
1078
+ return _cli_json(["cache", "list"])
1079
+
1080
+
1081
+ @app.post("/api/cache/clear")
1082
+ async def api_cache_clear(request: Request):
1083
+ body = await request.json()
1084
+ payload = body if isinstance(body, dict) else {}
1085
+ args: List[str] = ["cache", "clear"]
1086
+ dry_run = bool(payload.get("dry_run", False))
1087
+
1088
+ if bool(payload.get("all", False)):
1089
+ args.append("--all")
1090
+ elif payload.get("repo_hash"):
1091
+ args.extend(["--repo-hash", str(payload.get("repo_hash"))])
1092
+ elif payload.get("path"):
1093
+ args.extend(["--path", str(payload.get("path"))])
1094
+ elif payload.get("github"):
1095
+ args.extend(["--github", str(payload.get("github"))])
1096
+ if payload.get("ref"):
1097
+ args.extend(["--ref", str(payload.get("ref"))])
1098
+ if payload.get("mode"):
1099
+ args.extend(["--mode", str(payload.get("mode"))])
1100
+ else:
1101
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_TARGET"})
1102
+
1103
+ if dry_run:
1104
+ args.append("--dry-run")
1105
+ else:
1106
+ args.append("--yes")
1107
+ return _cli_json(args)
1108
+
1109
+
1110
+ @app.post("/api/cache/retention")
1111
+ async def api_cache_retention(request: Request):
1112
+ body = await request.json()
1113
+ payload = body if isinstance(body, dict) else {}
1114
+ days = payload.get("days")
1115
+ if days is None:
1116
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_DAYS"})
1117
+ args: List[str] = ["cache", "retention", "--days", str(days), "--yes"]
1118
+
1119
+ if payload.get("repo_hash"):
1120
+ args.extend(["--repo-hash", str(payload.get("repo_hash"))])
1121
+ elif payload.get("path"):
1122
+ args.extend(["--path", str(payload.get("path"))])
1123
+ elif payload.get("github"):
1124
+ args.extend(["--github", str(payload.get("github"))])
1125
+ if payload.get("ref"):
1126
+ args.extend(["--ref", str(payload.get("ref"))])
1127
+ if payload.get("mode"):
1128
+ args.extend(["--mode", str(payload.get("mode"))])
1129
+ else:
1130
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_TARGET"})
1131
+ return _cli_json(args)
1132
+
1133
+
1134
+ @app.post("/api/cache/sweep")
1135
+ async def api_cache_sweep(request: Request):
1136
+ body = await request.json()
1137
+ payload = body if isinstance(body, dict) else {}
1138
+ dry_run = bool(payload.get("dry_run", False))
1139
+ args: List[str] = ["cache", "sweep"]
1140
+ if dry_run:
1141
+ args.append("--dry-run")
1142
+ else:
1143
+ args.append("--yes")
1144
+ return _cli_json(args)
1145
+
1146
+
1147
+ @app.get("/api/data_privacy")
1148
+ def api_data_privacy():
1149
+ cache_payload = _cli_json(["cache", "list"])
1150
+ caches = cache_payload.get("caches", []) if isinstance(cache_payload, dict) else []
1151
+ total_size = sum(int(c.get("size_bytes", 0)) for c in caches)
1152
+ expiring = [
1153
+ c for c in caches
1154
+ if c.get("retention", {}).get("mode") != "pinned"
1155
+ and c.get("retention", {}).get("days_left") is not None
1156
+ and float(c["retention"]["days_left"]) <= 3
1157
+ ]
1158
+ oldest_repo = None
1159
+ largest_repo = None
1160
+ if caches:
1161
+ oldest_repo = sorted(caches, key=lambda x: str(x.get("last_updated", "") or x.get("retention", {}).get("last_accessed_at", "")))[0]
1162
+ largest_repo = sorted(caches, key=lambda x: int(x.get("size_bytes", 0)), reverse=True)[0]
1163
+ policy = {"default_ttl_days": 14, "workspaces_ttl_days": 7, "last_cleanup_iso": ""}
1164
+ return {
1165
+ "ok": True,
1166
+ "policy": policy,
1167
+ "repo_count": len(caches),
1168
+ "total_cache_size_bytes": int(total_size),
1169
+ "last_cleanup_iso": "",
1170
+ "oldest_repo": {
1171
+ "repo_hash": oldest_repo.get("repo_hash"),
1172
+ "repo_path": oldest_repo.get("repo_path"),
1173
+ "last_updated": oldest_repo.get("last_updated"),
1174
+ } if oldest_repo else None,
1175
+ "largest_repo": {
1176
+ "repo_hash": largest_repo.get("repo_hash"),
1177
+ "repo_path": largest_repo.get("repo_path"),
1178
+ "size_bytes": int(largest_repo.get("size_bytes", 0)),
1179
+ } if largest_repo else None,
1180
+ "caches": caches,
1181
+ "expiring_soon": [
1182
+ {
1183
+ "repo_hash": c.get("repo_hash"),
1184
+ "repo_path": c.get("repo_path"),
1185
+ "days_left": c.get("retention", {}).get("days_left"),
1186
+ }
1187
+ for c in expiring
1188
+ ],
1189
+ }
1190
+
1191
+
1192
+ @app.post("/api/data_privacy/policy")
1193
+ async def api_data_privacy_policy(request: Request):
1194
+ body = await request.json()
1195
+ payload = body if isinstance(body, dict) else {}
1196
+ return {
1197
+ "ok": True,
1198
+ "policy": {
1199
+ "default_ttl_days": int(payload.get("default_ttl_days", 14) or 14),
1200
+ "workspaces_ttl_days": int(payload.get("workspaces_ttl_days", 7) or 7),
1201
+ },
1202
+ "message": "Global policy updates are handled by per-repo retention controls.",
1203
+ }
1204
+
1205
+
1206
+ @app.post("/api/data_privacy/cleanup")
1207
+ async def api_data_privacy_cleanup(request: Request):
1208
+ body = await request.json()
1209
+ payload = body if isinstance(body, dict) else {}
1210
+ dry_run = bool(payload.get("dry_run", True))
1211
+ yes = bool(payload.get("yes", False))
1212
+ apply_flag = bool(payload.get("apply", False))
1213
+ if apply_flag:
1214
+ dry_run = False
1215
+ yes = True
1216
+ if not dry_run and not yes:
1217
+ return JSONResponse(
1218
+ status_code=400,
1219
+ content={"ok": False, "error": "CONFIRM_REQUIRED", "message": "Set yes=true to run cleanup."},
1220
+ )
1221
+ args = ["cache", "sweep"]
1222
+ if dry_run:
1223
+ args.append("--dry-run")
1224
+ else:
1225
+ args.append("--yes")
1226
+ return _cli_json(args)
1227
+
1228
+
1229
+ @app.post("/api/data_privacy/delete_repo")
1230
+ async def api_data_privacy_delete_repo(request: Request):
1231
+ body = await request.json()
1232
+ payload = body if isinstance(body, dict) else {}
1233
+ repo_hash = str(payload.get("repo_hash", "") or "").strip()
1234
+ dry_run = bool(payload.get("dry_run", True))
1235
+ yes = bool(payload.get("yes", False))
1236
+ if not repo_hash:
1237
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_REPO_HASH"})
1238
+ if not dry_run and not yes:
1239
+ return JSONResponse(
1240
+ status_code=400,
1241
+ content={"ok": False, "error": "CONFIRM_REQUIRED", "message": "Set yes=true to delete."},
1242
+ )
1243
+ args = ["cache", "clear", "--repo-hash", repo_hash]
1244
+ if dry_run:
1245
+ args.append("--dry-run")
1246
+ else:
1247
+ args.append("--yes")
1248
+ return _cli_json(args)
1249
+
1250
+
1251
+ @app.post("/api/data_privacy/delete_analysis")
1252
+ async def api_data_privacy_delete_analysis(request: Request):
1253
+ body = await request.json()
1254
+ payload = body if isinstance(body, dict) else {}
1255
+ repo_hash = str(payload.get("repo_hash", "") or "").strip()
1256
+ dry_run = bool(payload.get("dry_run", True))
1257
+ yes = bool(payload.get("yes", False))
1258
+ if not repo_hash:
1259
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_REPO_HASH"})
1260
+ if not dry_run and not yes:
1261
+ return JSONResponse(
1262
+ status_code=400,
1263
+ content={"ok": False, "error": "CONFIRM_REQUIRED", "message": "Set yes=true to delete."},
1264
+ )
1265
+ args = ["cache", "clear", "--repo-hash", repo_hash]
1266
+ if dry_run:
1267
+ args.append("--dry-run")
1268
+ else:
1269
+ args.append("--yes")
1270
+ return _cli_json(args)
1271
+
1272
+
1273
+ @app.post("/api/data_privacy/repo_policy")
1274
+ async def api_data_privacy_repo_policy(request: Request):
1275
+ body = await request.json()
1276
+ payload = body if isinstance(body, dict) else {}
1277
+ repo_hash = str(payload.get("repo_hash", "") or "").strip()
1278
+ policy_value = str(payload.get("policy", "") or "").strip().lower()
1279
+ if not repo_hash:
1280
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_REPO_HASH"})
1281
+
1282
+ if policy_value == "never":
1283
+ ttl_days = 0
1284
+ elif policy_value == "24h":
1285
+ ttl_days = 1
1286
+ elif policy_value == "7d":
1287
+ ttl_days = 7
1288
+ elif policy_value == "30d":
1289
+ ttl_days = 30
1290
+ else:
1291
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_POLICY"})
1292
+ return _cli_json(["cache", "retention", "--repo-hash", repo_hash, "--days", str(ttl_days), "--yes"])
1293
+
1294
+
1295
+ @app.post("/api/workspace/add")
1296
+ async def api_workspace_add(request: Request):
1297
+ body = await request.json()
1298
+ repo_path = str((body or {}).get("path", "")).strip()
1299
+ if not repo_path:
1300
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_PATH"})
1301
+ resolved = _resolve_repo_dir(repo_path)
1302
+ if not os.path.isdir(resolved):
1303
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_PATH"})
1304
+
1305
+ entry = _repo_entry(resolved)
1306
+ _upsert_workspace_repo(entry, set_active=True)
1307
+
1308
+ ctx = _repo_ctx_from_dir(resolved)
1309
+ _ensure_ui_state(ctx)
1310
+ SEARCH_INDEX_CACHE.pop(ctx["repo_hash"], None)
1311
+
1312
+ return {"ok": True, "repo_hash": entry["repo_hash"], "path": resolved, "name": entry["name"]}
1313
+
1314
+
1315
+ @app.post("/api/repo_import/local")
1316
+ async def api_repo_import_local(request: Request):
1317
+ body = await request.json()
1318
+ payload = body if isinstance(body, dict) else {}
1319
+ repo_path = str(payload.get("repo_path", "") or "").strip()
1320
+ display_name = str(payload.get("display_name", "") or "").strip()
1321
+ analyze_now = bool(payload.get("analyze", True))
1322
+ open_after_add = bool(payload.get("open_after_add", False))
1323
+ if not repo_path:
1324
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_PATH"})
1325
+ resolved = _resolve_repo_dir(repo_path)
1326
+ if not os.path.isdir(resolved):
1327
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_PATH"})
1328
+
1329
+ entry = _repo_entry_from_payload(
1330
+ {
1331
+ "path": resolved,
1332
+ "name": display_name or os.path.basename(resolved.rstrip("\\/")) or resolved,
1333
+ "source": "filesystem",
1334
+ }
1335
+ )
1336
+ ws_before = _load_workspaces()
1337
+ had_active = bool(str(ws_before.get("active_repo_hash", "") or ""))
1338
+ set_active = bool(open_after_add or not had_active)
1339
+ _upsert_workspace_repo(entry, set_active=set_active)
1340
+ ctx = _repo_ctx_from_dir(resolved)
1341
+ _ensure_ui_state(ctx)
1342
+ SEARCH_INDEX_CACHE.pop(ctx["repo_hash"], None)
1343
+ GRAPH_INDEX_CACHE.pop(ctx["repo_hash"], None)
1344
+
1345
+ if not analyze_now:
1346
+ return {"ok": True, "analyzed": False, "repo_hash": entry["repo_hash"], "repo": entry}
1347
+
1348
+ analyze_result = _cli_json(["analyze", "--path", resolved], timeout_sec=3600)
1349
+ if analyze_result.get("ok"):
1350
+ _upsert_workspace_repo(entry, set_active=set_active)
1351
+ return {
1352
+ "ok": bool(analyze_result.get("ok")),
1353
+ "analyzed": bool(analyze_result.get("ok")),
1354
+ "repo_hash": entry["repo_hash"],
1355
+ "repo": entry,
1356
+ "analyze_result": analyze_result,
1357
+ }
1358
+
1359
+
1360
+ @app.post("/api/repo_import/github_add")
1361
+ async def api_repo_import_github_add(request: Request):
1362
+ from analysis.utils.repo_fetcher import resolve_workspace_paths
1363
+
1364
+ body = await request.json()
1365
+ payload = body if isinstance(body, dict) else {}
1366
+ repo_url = str(payload.get("repo_url", "") or "").strip()
1367
+ ref = str(payload.get("ref", "") or "").strip() or "main"
1368
+ mode = str(payload.get("mode", "") or "zip").strip().lower() or "zip"
1369
+ display_name = str(payload.get("display_name", "") or "").strip()
1370
+ open_after_add = bool(payload.get("open_after_add", False))
1371
+ private_mode = bool(payload.get("private_mode", False))
1372
+ # token is intentionally ignored here; it is never persisted.
1373
+
1374
+ if not repo_url:
1375
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_GITHUB_URL", "message": "GitHub URL is required."})
1376
+ if mode not in {"zip", "git"}:
1377
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_MODE", "message": "Mode must be zip or git."})
1378
+
1379
+ try:
1380
+ ws_paths = resolve_workspace_paths(repo_url, ref, mode)
1381
+ except Exception as e:
1382
+ return JSONResponse(
1383
+ status_code=400,
1384
+ content={"ok": False, "error": "INVALID_GITHUB_URL", "message": redact_secrets(str(e))},
1385
+ )
1386
+
1387
+ repo_dir = ws_paths.get("repo_dir", "")
1388
+ repo_name = display_name or ws_paths.get("repo_name", "") or os.path.basename(str(repo_dir).rstrip("\\/")) or "github_repo"
1389
+ entry = _repo_entry_from_payload(
1390
+ {
1391
+ "path": repo_dir,
1392
+ "name": repo_name,
1393
+ "source": "github",
1394
+ "repo_url": ws_paths.get("normalized_url", repo_url),
1395
+ "ref": ref,
1396
+ "mode": mode,
1397
+ "private_mode": private_mode,
1398
+ }
1399
+ )
1400
+ ws_before = _load_workspaces()
1401
+ had_active = bool(str(ws_before.get("active_repo_hash", "") or ""))
1402
+ set_active = bool(open_after_add or not had_active)
1403
+ _upsert_workspace_repo(entry, set_active=set_active)
1404
+ return {"ok": True, "repo_hash": entry["repo_hash"], "repo": entry, "analyzed": False}
1405
+
1406
+
1407
+ @app.post("/api/repo_import/github")
1408
+ async def api_repo_import_github(request: Request):
1409
+ body = await request.json()
1410
+ payload = body if isinstance(body, dict) else {}
1411
+ repo_url = str(payload.get("repo_url", "") or "").strip()
1412
+ ref = str(payload.get("ref", "") or "").strip() or "main"
1413
+ mode = str(payload.get("mode", "") or "zip").strip().lower() or "zip"
1414
+ token = str(payload.get("token", "") or "")
1415
+ private_repo_mode = bool(token.strip())
1416
+
1417
+ if not repo_url:
1418
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_GITHUB_URL"})
1419
+ if mode not in {"zip", "git"}:
1420
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_MODE"})
1421
+
1422
+ args = ["analyze", "--github", repo_url, "--ref", ref, "--mode", mode]
1423
+ stdin_text = None
1424
+ if token.strip():
1425
+ args.append("--token-stdin")
1426
+ stdin_text = token.strip() + "\n"
1427
+ analyze_result = _cli_json_with_input(args, timeout_sec=3600, stdin_text=stdin_text)
1428
+ token = ""
1429
+ if not analyze_result.get("ok"):
1430
+ return {
1431
+ "ok": False,
1432
+ "error": analyze_result.get("error", "ANALYZE_FAILED"),
1433
+ "message": redact_secrets(analyze_result.get("message", "GitHub analyze failed")),
1434
+ "analyze_result": analyze_result,
1435
+ "private_repo_mode": private_repo_mode,
1436
+ }
1437
+
1438
+ repo_dir = str(analyze_result.get("repo_dir", "") or "").strip()
1439
+ if not repo_dir:
1440
+ return {"ok": False, "error": "MISSING_REPO_DIR", "analyze_result": analyze_result}
1441
+
1442
+ name = os.path.basename(repo_dir.rstrip("\\/")) or repo_dir
1443
+ entry = _repo_entry_from_payload(
1444
+ {
1445
+ "path": repo_dir,
1446
+ "name": name,
1447
+ "source": "github",
1448
+ "repo_url": str(analyze_result.get("repo_url", repo_url) or repo_url),
1449
+ "ref": str(analyze_result.get("ref", ref) or ref),
1450
+ "mode": str(analyze_result.get("mode", mode) or mode),
1451
+ "private_mode": private_repo_mode,
1452
+ }
1453
+ )
1454
+ _upsert_workspace_repo(entry, set_active=True)
1455
+ ctx = _repo_ctx_from_dir(entry["path"])
1456
+ _ensure_ui_state(ctx)
1457
+ SEARCH_INDEX_CACHE.pop(ctx["repo_hash"], None)
1458
+ GRAPH_INDEX_CACHE.pop(ctx["repo_hash"], None)
1459
+ return {
1460
+ "ok": True,
1461
+ "repo_hash": entry["repo_hash"],
1462
+ "repo": entry,
1463
+ "analyze_result": analyze_result,
1464
+ "private_repo_mode": private_repo_mode,
1465
+ }
1466
+
1467
+
1468
+ @app.post("/api/repo_analyze")
1469
+ async def api_repo_analyze(request: Request):
1470
+ body = await request.json()
1471
+ payload = body if isinstance(body, dict) else {}
1472
+ repo_hash = str(payload.get("repo_hash", "") or "").strip()
1473
+ token = str(payload.get("token", "") or "")
1474
+ private_mode_hint = bool(payload.get("private_mode", False)) or bool(token.strip())
1475
+ if not repo_hash:
1476
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_REPO_HASH"})
1477
+
1478
+ ws = _ensure_default_workspace()
1479
+ repos = ws.get("repos", [])
1480
+ target = next((r for r in repos if isinstance(r, dict) and str(r.get("repo_hash", "")) == repo_hash), None)
1481
+ if not target:
1482
+ return JSONResponse(status_code=404, content={"ok": False, "error": "REPO_NOT_FOUND"})
1483
+
1484
+ entry = _repo_entry_from_payload(target)
1485
+ source = entry.get("source", "filesystem")
1486
+ if source == "github":
1487
+ repo_url = str(entry.get("repo_url", "") or "").strip()
1488
+ ref = str(entry.get("ref", "") or "").strip() or "main"
1489
+ mode = str(entry.get("mode", "") or "zip")
1490
+ if not repo_url:
1491
+ return JSONResponse(status_code=400, content={"ok": False, "error": "MISSING_GITHUB_METADATA"})
1492
+ args = ["analyze", "--github", repo_url, "--ref", ref, "--mode", mode]
1493
+ stdin_text = None
1494
+ if token.strip():
1495
+ args.append("--token-stdin")
1496
+ stdin_text = token.strip() + "\n"
1497
+ result = _cli_json_with_input(args=args, timeout_sec=3600, stdin_text=stdin_text)
1498
+ token = ""
1499
+ else:
1500
+ result = _cli_json(["analyze", "--path", entry["path"]], timeout_sec=3600)
1501
+
1502
+ if result.get("ok"):
1503
+ if private_mode_hint:
1504
+ entry["private_mode"] = True
1505
+ _upsert_workspace_repo(entry, set_active=True)
1506
+ return {
1507
+ "ok": bool(result.get("ok")),
1508
+ "repo_hash": repo_hash,
1509
+ "analyze_result": result,
1510
+ "private_repo_mode": bool(entry.get("private_mode", False) or private_mode_hint),
1511
+ }
1512
+
1513
+
1514
+ @app.post("/api/workspace/select")
1515
+ async def api_workspace_select(request: Request):
1516
+ body = await request.json()
1517
+ repo_hash = str((body or {}).get("repo_hash", "")).strip()
1518
+ if not repo_hash:
1519
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_REPO_HASH"})
1520
+ ws = _load_workspaces()
1521
+ repos = ws.get("repos", [])
1522
+ target = next((r for r in repos if r.get("repo_hash") == repo_hash), None)
1523
+ if not target:
1524
+ return JSONResponse(status_code=404, content={"ok": False, "error": "REPO_NOT_FOUND"})
1525
+
1526
+ ws["active_repo_hash"] = repo_hash
1527
+ target["last_opened"] = _now_utc()
1528
+ _save_workspaces(ws)
1529
+
1530
+ ctx = _repo_ctx_from_dir(target["path"])
1531
+ _ensure_ui_state(ctx)
1532
+ return {"ok": True}
1533
+
1534
+
1535
+ @app.post("/api/workspace/remove")
1536
+ async def api_workspace_remove(request: Request):
1537
+ body = await request.json()
1538
+ repo_hash = str((body or {}).get("repo_hash", "")).strip()
1539
+ if not repo_hash:
1540
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_REPO_HASH"})
1541
+ ws = _load_workspaces()
1542
+ repos = ws.get("repos", [])
1543
+ ws["repos"] = [r for r in repos if not (isinstance(r, dict) and str(r.get("repo_hash", "")) == repo_hash)]
1544
+ if str(ws.get("active_repo_hash", "") or "") == repo_hash:
1545
+ ws["active_repo_hash"] = ws["repos"][0]["repo_hash"] if ws["repos"] else ""
1546
+ _save_workspaces(ws)
1547
+ if bool(registry_load(base_dir=GLOBAL_CACHE_DIR).get("remember_repos", False)):
1548
+ registry_remove_repo(repo_hash, base_dir=GLOBAL_CACHE_DIR)
1549
+ return {"ok": True}
1550
+
1551
+
1552
+ @app.get("/api/ui_state")
1553
+ def api_ui_state():
1554
+ ctx = _active_repo_ctx()
1555
+ if not ctx:
1556
+ return _no_active_repo_response()
1557
+
1558
+ state = _normalize_ui_state(_ensure_ui_state(ctx))
1559
+ if state != _load_json(ctx["ui_state_path"], {}):
1560
+ _save_json(ctx["ui_state_path"], state)
1561
+ return {"ok": True, "state": state}
1562
+
1563
+
1564
+ @app.post("/api/ui_state/update")
1565
+ async def api_ui_state_update(request: Request):
1566
+ ctx = _active_repo_ctx()
1567
+ if not ctx:
1568
+ return _no_active_repo_response()
1569
+
1570
+ body = await request.json()
1571
+ payload = body if isinstance(body, dict) else {}
1572
+ state = _normalize_ui_state(_ensure_ui_state(ctx))
1573
+
1574
+ opened_symbol = str(payload.get("opened_symbol", "") or "").strip()
1575
+ opened_file = str(payload.get("opened_file", "") or "").strip()
1576
+ last_symbol = str(payload.get("last_symbol", "") or "").strip()
1577
+
1578
+ if opened_symbol:
1579
+ state["recent_symbols"] = _push_recent(state.get("recent_symbols", []), opened_symbol, limit=20)
1580
+ state["last_symbol"] = opened_symbol
1581
+ elif last_symbol:
1582
+ state["last_symbol"] = last_symbol
1583
+
1584
+ if opened_file:
1585
+ state["recent_files"] = _push_recent(state.get("recent_files", []), opened_file, limit=20)
1586
+
1587
+ state["updated_at"] = _now_utc()
1588
+ _save_json(ctx["ui_state_path"], state)
1589
+ return {"ok": True}
1590
+
1591
+
1592
+ @app.get("/api/meta")
1593
+ def api_meta(repo: Optional[str] = Query(default=None)):
1594
+ ctx = _repo_ctx(repo) if repo else _active_repo_ctx()
1595
+ if not ctx:
1596
+ return _no_active_repo_response()
1597
+ if not _has_analysis_cache(ctx):
1598
+ return _missing_cache_response()
1599
+
1600
+ manifest = _load_json(ctx["manifest_path"], {})
1601
+ explain = _load_json(ctx["explain_path"], {})
1602
+ resolved = _load_json(ctx["resolved_calls_path"], [])
1603
+ metrics = _load_json(ctx["metrics_path"], {})
1604
+ ui_state = _normalize_ui_state(_ensure_ui_state(ctx))
1605
+
1606
+ return {
1607
+ "ok": True,
1608
+ "repo_hash": ctx["repo_hash"],
1609
+ "repo_dir": ctx["repo_dir"],
1610
+ "cache_dir": ctx["cache_dir"],
1611
+ "analyzed_at": manifest.get("updated_at"),
1612
+ "counts": {
1613
+ "symbols": len(explain),
1614
+ "resolved_calls": len(resolved),
1615
+ "critical_apis": len(metrics.get("critical_apis", [])),
1616
+ "orchestrators": len(metrics.get("orchestrators", [])),
1617
+ },
1618
+ "recent_symbols": ui_state.get("recent_symbols", [])[:10],
1619
+ }
1620
+
1621
+
1622
+ @app.get("/api/architecture")
1623
+ def api_architecture(repo: Optional[str] = Query(default=None)):
1624
+ ctx = _repo_ctx(repo) if repo else _active_repo_ctx()
1625
+ if not ctx:
1626
+ return _no_active_repo_response()
1627
+ if not _has_analysis_cache(ctx):
1628
+ return _missing_cache_response()
1629
+
1630
+ architecture_metrics_path = os.path.join(ctx["cache_dir"], "architecture_metrics.json")
1631
+ dependency_cycles_path = os.path.join(ctx["cache_dir"], "dependency_cycles.json")
1632
+
1633
+ missing = []
1634
+ if not os.path.exists(architecture_metrics_path):
1635
+ missing.append("architecture_metrics.json")
1636
+ if not os.path.exists(dependency_cycles_path):
1637
+ missing.append("dependency_cycles.json")
1638
+ if missing:
1639
+ return JSONResponse(
1640
+ status_code=400,
1641
+ content={
1642
+ "ok": False,
1643
+ "error": "MISSING_ARCHITECTURE_CACHE",
1644
+ "message": "Run: python cli.py api analyze --path <repo>",
1645
+ "missing_files": missing,
1646
+ },
1647
+ )
1648
+
1649
+ return {
1650
+ "ok": True,
1651
+ "architecture_metrics": _load_json(architecture_metrics_path, {}),
1652
+ "dependency_cycles": _load_json(dependency_cycles_path, {}),
1653
+ }
1654
+
1655
+
1656
+ @app.get("/api/repo_summary")
1657
+ def api_repo_summary(repo: Optional[str] = Query(default=None)):
1658
+ ctx = _repo_ctx(repo) if repo else _active_repo_ctx()
1659
+ if not ctx:
1660
+ return _no_active_repo_response()
1661
+ if not _has_analysis_cache(ctx):
1662
+ return _missing_cache_response()
1663
+
1664
+ summary_path = _repo_summary_cache_path(ctx["cache_dir"])
1665
+ current_fp = _repo_fingerprint(ctx["repo_dir"], ctx["cache_dir"])
1666
+ _record_ai_fingerprint_source(ctx["repo_hash"], current_fp)
1667
+ current_analysis_version = _analysis_version_from_cache(ctx["cache_dir"])
1668
+
1669
+ if not os.path.exists(summary_path):
1670
+ return {
1671
+ "ok": True,
1672
+ "exists": False,
1673
+ "cached": False,
1674
+ "reason": "STALE_OR_MISSING",
1675
+ "outdated": False,
1676
+ "fingerprint": current_fp,
1677
+ "message": "No cached summary for current analysis. Click Regenerate.",
1678
+ }
1679
+
1680
+ cached = _load_repo_summary_cached(ctx["cache_dir"])
1681
+ cached_fp = str(cached.get("fingerprint", "") or "")
1682
+ cached_version = str(cached.get("analysis_version", "") or "")
1683
+ is_fresh = bool(cached_fp and cached_fp == current_fp and cached_version == current_analysis_version)
1684
+
1685
+ if is_fresh:
1686
+ return {
1687
+ "ok": True,
1688
+ "exists": True,
1689
+ "cached": True,
1690
+ "outdated": False,
1691
+ "repo_summary": cached,
1692
+ }
1693
+
1694
+ return {
1695
+ "ok": True,
1696
+ "exists": False,
1697
+ "cached": False,
1698
+ "reason": "STALE_OR_MISSING",
1699
+ "outdated": True,
1700
+ "fingerprint": current_fp,
1701
+ "repo_summary": cached,
1702
+ "message": "No cached summary for current analysis. Click Regenerate.",
1703
+ }
1704
+
1705
+
1706
+ @app.post("/api/repo_summary/generate")
1707
+ async def api_repo_summary_generate(request: Request, force: int = Query(default=0)):
1708
+ from analysis.explain.repo_summary_generator import generate_repo_summary
1709
+
1710
+ body = await request.json()
1711
+ payload = body if isinstance(body, dict) else {}
1712
+ repo_dir, err = _resolve_repo_dir_from_payload(payload)
1713
+ if err or not repo_dir:
1714
+ return JSONResponse(
1715
+ status_code=400,
1716
+ content={"ok": False, "error": err or "INVALID_REPO", "message": "Provide a valid repo"},
1717
+ )
1718
+
1719
+ ctx = _repo_ctx_from_dir(repo_dir)
1720
+ if not _has_analysis_cache(ctx):
1721
+ return _missing_cache_response()
1722
+ try:
1723
+ touch_last_accessed(ctx["repo_hash"])
1724
+ except Exception:
1725
+ pass
1726
+
1727
+ current_fp = _repo_fingerprint(ctx["repo_dir"], ctx["cache_dir"])
1728
+ current_analysis_version = _analysis_version_from_cache(ctx["cache_dir"])
1729
+ summary_path = _repo_summary_cache_path(ctx["cache_dir"])
1730
+ force_refresh = bool(int(force or 0)) or bool(payload.get("force", False))
1731
+
1732
+ if not force_refresh and os.path.exists(summary_path):
1733
+ cached = _load_repo_summary_cached(ctx["cache_dir"])
1734
+ if (
1735
+ str(cached.get("fingerprint", "") or "") == current_fp
1736
+ and str(cached.get("analysis_version", "") or "") == current_analysis_version
1737
+ ):
1738
+ return {"ok": True, "cached": True, "repo_summary": cached}
1739
+
1740
+ result = generate_repo_summary(ctx["cache_dir"])
1741
+ summary_structured = result.get("summary", {}) if isinstance(result.get("summary"), dict) else {}
1742
+ repo_summary_payload = {
1743
+ "repo_hash": ctx["repo_hash"],
1744
+ "analysis_version": current_analysis_version,
1745
+ "fingerprint": current_fp,
1746
+ "provider": "deterministic",
1747
+ "model": "",
1748
+ "cached_at": _now_utc(),
1749
+ "generated_at": _now_utc(),
1750
+ "content_markdown": _summary_markdown_from_structured(summary_structured),
1751
+ }
1752
+ _save_json(summary_path, repo_summary_payload)
1753
+ return {"ok": True, "cached": False, "repo_summary": repo_summary_payload}
1754
+
1755
+
1756
+ @app.get("/api/risk_radar")
1757
+ def api_risk_radar(repo: Optional[str] = Query(default=None)):
1758
+ ctx = _repo_ctx(repo) if repo else _active_repo_ctx()
1759
+ if not ctx:
1760
+ return _no_active_repo_response()
1761
+ if not _has_analysis_cache(ctx):
1762
+ return _missing_cache_response()
1763
+
1764
+ path = os.path.join(ctx["cache_dir"], "risk_radar.json")
1765
+ if not os.path.exists(path):
1766
+ return JSONResponse(
1767
+ status_code=404,
1768
+ content={
1769
+ "ok": False,
1770
+ "error": "MISSING_RISK_RADAR",
1771
+ "message": "Risk radar not generated yet.",
1772
+ },
1773
+ )
1774
+
1775
+ data = _load_json(path, {})
1776
+ mtime = datetime.fromtimestamp(os.path.getmtime(path), timezone.utc).isoformat()
1777
+ return {
1778
+ "ok": True,
1779
+ "risk_radar": data,
1780
+ "updated_at": mtime,
1781
+ }
1782
+
1783
+
1784
+ @app.get("/api/tree")
1785
+ def api_tree(repo: Optional[str] = Query(default=None)):
1786
+ ctx = _repo_ctx(repo) if repo else _active_repo_ctx()
1787
+ if not ctx:
1788
+ return _no_active_repo_response()
1789
+ if not _has_analysis_cache(ctx):
1790
+ return _missing_cache_response()
1791
+
1792
+ if not os.path.exists(ctx["project_tree_path"]):
1793
+ return JSONResponse(
1794
+ status_code=400,
1795
+ content={"ok": False, "error": "Snapshot not found. Run analyze first."},
1796
+ )
1797
+
1798
+ return {
1799
+ "ok": True,
1800
+ "tree": _load_json(ctx["project_tree_path"], {}),
1801
+ }
1802
+
1803
+
1804
+ @app.get("/api/file")
1805
+ def api_file(path: str = Query(...), repo: Optional[str] = Query(default=None)):
1806
+ ctx = _repo_ctx(repo) if repo else _active_repo_ctx()
1807
+ if not ctx:
1808
+ return _no_active_repo_response()
1809
+ if not _has_analysis_cache(ctx):
1810
+ return _missing_cache_response()
1811
+
1812
+ rel_path = path.replace("\\", "/").lstrip("/")
1813
+ abs_path = os.path.abspath(os.path.join(ctx["repo_dir"], rel_path))
1814
+ if not _norm(abs_path).startswith(_norm(ctx["repo_dir"])):
1815
+ return JSONResponse(status_code=400, content={"ok": False, "error": "INVALID_PATH"})
1816
+
1817
+ data = _load_repo_data(ctx)
1818
+ explain = data["explain"]
1819
+ resolved = data["resolved_calls"]
1820
+ manifest = _load_json(ctx["manifest_path"], {})
1821
+ snapshot = manifest.get("symbol_snapshot", [])
1822
+
1823
+ fqn_to_file: Dict[str, str] = {}
1824
+ for fqn, obj in explain.items():
1825
+ loc_file = (obj.get("location") or {}).get("file")
1826
+ if not loc_file:
1827
+ continue
1828
+ fqn_to_file[fqn] = loc_file
1829
+
1830
+ method_dedupe = {
1831
+ (_norm(s.get("file_path", "")), s.get("name"), int(s.get("start_line", -1)), int(s.get("end_line", -1)))
1832
+ for s in snapshot if s.get("kind") == "method"
1833
+ }
1834
+ classes: Dict[str, List[str]] = {}
1835
+ functions: List[str] = []
1836
+ module_scope_fqn: Optional[str] = None
1837
+ symbol_fqns: List[str] = []
1838
+ for s in snapshot:
1839
+ file_path = s.get("file_path")
1840
+ if _norm(file_path or "") != _norm(abs_path):
1841
+ continue
1842
+ kind = s.get("kind")
1843
+ module = s.get("module", "")
1844
+ qn = s.get("qualified_name", "")
1845
+ fqn = f"{module}.{qn}" if module and qn else ""
1846
+ if not fqn:
1847
+ continue
1848
+
1849
+ if kind == "function":
1850
+ dedupe_key = (_norm(file_path), s.get("name"), int(s.get("start_line", -1)), int(s.get("end_line", -1)))
1851
+ if dedupe_key in method_dedupe:
1852
+ continue
1853
+ functions.append(fqn)
1854
+ symbol_fqns.append(fqn)
1855
+ elif kind == "module":
1856
+ module_scope_fqn = fqn
1857
+ symbol_fqns.append(fqn)
1858
+ elif kind == "class":
1859
+ class_name = s.get("name", "")
1860
+ classes.setdefault(class_name, [])
1861
+ symbol_fqns.append(fqn)
1862
+ elif kind == "method":
1863
+ class_name = s.get("class_name") or qn.split(".")[0]
1864
+ classes.setdefault(class_name, []).append(fqn)
1865
+ symbol_fqns.append(fqn)
1866
+
1867
+ outgoing = [c for c in resolved if _norm(c.get("file", "")) == _norm(abs_path)]
1868
+ incoming = [
1869
+ c for c in resolved
1870
+ if c.get("callee_fqn") and _norm(fqn_to_file.get(c["callee_fqn"], "")) == _norm(abs_path)
1871
+ ]
1872
+
1873
+ top_callers_counter = Counter(
1874
+ (c.get("caller_fqn", ""), c.get("file", ""), int(c.get("line", -1))) for c in incoming
1875
+ )
1876
+ top_callees_counter = Counter(c.get("callee_fqn") for c in outgoing if c.get("callee_fqn"))
1877
+
1878
+ top_callers = [
1879
+ {
1880
+ "caller_fqn": k[0],
1881
+ "file": _rel_file(ctx, k[1]),
1882
+ "line": k[2],
1883
+ "count": v,
1884
+ "hint": f"{_rel_file(ctx, k[1])}:{k[2]}",
1885
+ }
1886
+ for k, v in top_callers_counter.most_common(10)
1887
+ ]
1888
+ top_callees = [{"fqn": k, "count": v} for k, v in top_callees_counter.most_common(10)]
1889
+ module_scope_outgoing_calls_count = 0
1890
+ if module_scope_fqn:
1891
+ module_scope_outgoing_calls_count = len(
1892
+ [c for c in resolved if c.get("caller_fqn") == module_scope_fqn]
1893
+ )
1894
+
1895
+ grouped_classes = [
1896
+ {
1897
+ "name": class_name,
1898
+ "methods": sorted(methods),
1899
+ }
1900
+ for class_name, methods in sorted(classes.items(), key=lambda x: x[0].lower())
1901
+ ]
1902
+
1903
+ return {
1904
+ "ok": True,
1905
+ "file": rel_path,
1906
+ "symbols": {
1907
+ "classes": grouped_classes,
1908
+ "functions": sorted(functions),
1909
+ "module_scope": {
1910
+ "fqn": module_scope_fqn,
1911
+ "outgoing_calls_count": module_scope_outgoing_calls_count,
1912
+ } if module_scope_fqn else None,
1913
+ },
1914
+ "symbol_fqns": sorted(symbol_fqns),
1915
+ "incoming_usages_count": len(incoming),
1916
+ "outgoing_calls_count": len(outgoing),
1917
+ "top_callers": top_callers,
1918
+ "top_callees": top_callees,
1919
+ }
1920
+
1921
+
1922
+ @app.get("/api/symbol")
1923
+ def api_symbol(fqn: str = Query(...), repo: Optional[str] = Query(default=None)):
1924
+ ctx = _repo_ctx(repo) if repo else _active_repo_ctx()
1925
+ if not ctx:
1926
+ return _no_active_repo_response()
1927
+ if not _has_analysis_cache(ctx):
1928
+ return _missing_cache_response()
1929
+
1930
+ explain = _load_json(ctx["explain_path"], {})
1931
+ obj = explain.get(fqn)
1932
+ if not obj:
1933
+ return JSONResponse(status_code=404, content={"ok": False, "error": "NOT_FOUND", "fqn": fqn})
1934
+ resolved_calls = _load_json(ctx["resolved_calls_path"], [])
1935
+ result = dict(obj)
1936
+ result["connections"] = _build_symbol_connections(ctx, fqn, explain, resolved_calls)
1937
+ return {"ok": True, "result": result}
1938
+
1939
+
1940
+ @app.get("/api/usages")
1941
+ def api_usages(fqn: str = Query(...), repo: Optional[str] = Query(default=None)):
1942
+ ctx = _repo_ctx(repo) if repo else _active_repo_ctx()
1943
+ if not ctx:
1944
+ return _no_active_repo_response()
1945
+ if not _has_analysis_cache(ctx):
1946
+ return _missing_cache_response()
1947
+
1948
+ resolved = _load_json(ctx["resolved_calls_path"], [])
1949
+ usages = [
1950
+ {
1951
+ "caller_fqn": c.get("caller_fqn"),
1952
+ "file": _rel_file(ctx, c.get("file", "")),
1953
+ "line": int(c.get("line", -1)),
1954
+ "hint": f"{_rel_file(ctx, c.get('file', ''))}:{int(c.get('line', -1))}",
1955
+ }
1956
+ for c in resolved
1957
+ if c.get("callee_fqn") == fqn
1958
+ ]
1959
+ usages.sort(key=lambda u: (u.get("file", ""), int(u.get("line", -1))))
1960
+ return {"ok": True, "fqn": fqn, "count": len(usages), "usages": usages}
1961
+
1962
+
1963
+ @app.get("/api/search")
1964
+ def api_search(
1965
+ q: str = Query(..., min_length=1),
1966
+ limit: int = Query(default=20, ge=1, le=50),
1967
+ repo: Optional[str] = Query(default=None),
1968
+ ):
1969
+ ctx = _repo_ctx(repo) if repo else _active_repo_ctx()
1970
+ if not ctx:
1971
+ return _no_active_repo_response()
1972
+ if not _has_analysis_cache(ctx):
1973
+ return _missing_cache_response()
1974
+
1975
+ query = q.strip().lower()
1976
+ if not query:
1977
+ return {"ok": True, "query": q, "count": 0, "results": [], "truncated": False}
1978
+
1979
+ index = _build_search_index(ctx)
1980
+ matched = [item for item in index if query in item["_searchable"]]
1981
+ matched.sort(key=lambda i: (i["display"].lower(), i["fqn"].lower()))
1982
+ sliced = matched[:limit]
1983
+
1984
+ results = [
1985
+ {
1986
+ "fqn": i["fqn"],
1987
+ "display": i["display"],
1988
+ "module": i["module"],
1989
+ "file": i["file"],
1990
+ "line": i["line"],
1991
+ }
1992
+ for i in sliced
1993
+ ]
1994
+ return {
1995
+ "ok": True,
1996
+ "query": q,
1997
+ "count": len(matched),
1998
+ "results": results,
1999
+ "truncated": len(matched) > limit,
2000
+ }
2001
+
2002
+
2003
+ @app.get("/api/graph")
2004
+ def api_graph(
2005
+ fqn: Optional[str] = Query(default=None),
2006
+ file: Optional[str] = Query(default=None),
2007
+ depth: int = Query(default=1, ge=1, le=3),
2008
+ hide_builtins: bool = Query(default=True),
2009
+ hide_external: bool = Query(default=True),
2010
+ repo: Optional[str] = Query(default=None),
2011
+ ):
2012
+ ctx = _repo_ctx(repo) if repo else _active_repo_ctx()
2013
+ if not ctx:
2014
+ return _no_active_repo_response()
2015
+ if not _has_analysis_cache(ctx):
2016
+ return _missing_cache_response()
2017
+
2018
+ graph = _build_graph_index(ctx)
2019
+ explain = graph["explain"]
2020
+ callees_map = graph["callees_map"]
2021
+ callers_map = graph["callers_map"]
2022
+ edge_counts = graph["edge_counts"]
2023
+
2024
+ center_fqn = fqn.strip() if isinstance(fqn, str) else ""
2025
+ file_rel = file.replace("\\", "/").lstrip("/") if isinstance(file, str) else ""
2026
+ if not center_fqn and not file_rel:
2027
+ return JSONResponse(status_code=400, content={"ok": False, "error": "MISSING_GRAPH_TARGET"})
2028
+
2029
+ seed_nodes: set = set()
2030
+ mode = "symbol"
2031
+ center = center_fqn
2032
+ if file_rel:
2033
+ mode = "file"
2034
+ center = file_rel
2035
+ target_abs = os.path.abspath(os.path.join(ctx["repo_dir"], file_rel))
2036
+ for sym_fqn, item in explain.items():
2037
+ loc_file = (item.get("location") or {}).get("file", "")
2038
+ if loc_file and _norm(loc_file) == _norm(target_abs):
2039
+ seed_nodes.add(sym_fqn)
2040
+ if not seed_nodes:
2041
+ return {
2042
+ "ok": True,
2043
+ "mode": "file",
2044
+ "center": center,
2045
+ "depth": depth,
2046
+ "seed_nodes": [],
2047
+ "nodes": [],
2048
+ "edges": [],
2049
+ }
2050
+ else:
2051
+ seed_nodes.add(center_fqn)
2052
+
2053
+ visited = set(seed_nodes)
2054
+ frontier = set(seed_nodes)
2055
+ edges: set = set()
2056
+
2057
+ for _ in range(max(1, min(3, depth))):
2058
+ next_frontier = set()
2059
+ for node in frontier:
2060
+ for callee in callees_map.get(node, []):
2061
+ edges.add((node, callee))
2062
+ if callee not in visited:
2063
+ next_frontier.add(callee)
2064
+ for caller in callers_map.get(node, []):
2065
+ edges.add((caller, node))
2066
+ if caller not in visited:
2067
+ next_frontier.add(caller)
2068
+ visited.update(next_frontier)
2069
+ frontier = next_frontier
2070
+ if not frontier:
2071
+ break
2072
+
2073
+ def _include(node_id: str) -> bool:
2074
+ kind = _classify_symbol(node_id, explain)
2075
+ if hide_builtins and kind == "builtin":
2076
+ return False
2077
+ if hide_external and kind == "external":
2078
+ return False
2079
+ return True
2080
+
2081
+ filtered_edges = [(src, dst) for (src, dst) in edges if _include(src) and _include(dst)]
2082
+ node_ids = set()
2083
+ for src, dst in filtered_edges:
2084
+ node_ids.add(src)
2085
+ node_ids.add(dst)
2086
+ for seed in seed_nodes:
2087
+ if _include(seed):
2088
+ node_ids.add(seed)
2089
+
2090
+ nodes = []
2091
+ for node_id in sorted(node_ids):
2092
+ info = explain.get(node_id, {})
2093
+ nodes.append({
2094
+ "id": node_id,
2095
+ "label": _short_label(node_id),
2096
+ "subtitle": info.get("one_liner", ""),
2097
+ "kind": _classify_symbol(node_id, explain),
2098
+ "clickable": node_id in explain,
2099
+ "location": (info.get("location") or {}),
2100
+ })
2101
+
2102
+ edges_payload = [
2103
+ {
2104
+ "from": src,
2105
+ "to": dst,
2106
+ "count": int(edge_counts.get((src, dst), 1)),
2107
+ }
2108
+ for (src, dst) in sorted(filtered_edges, key=lambda x: (x[0], x[1]))
2109
+ ]
2110
+
2111
+ return {
2112
+ "ok": True,
2113
+ "mode": mode,
2114
+ "center": center,
2115
+ "depth": depth,
2116
+ "seed_nodes": sorted(seed_nodes),
2117
+ "nodes": nodes,
2118
+ "edges": edges_payload,
2119
+ }
2120
+
2121
+
2122
+ @app.get("/api/impact")
2123
+ def api_impact(
2124
+ target: str = Query(...),
2125
+ depth: int = Query(default=2, ge=1, le=4),
2126
+ max_nodes: int = Query(default=200, ge=1, le=500),
2127
+ repo: Optional[str] = Query(default=None),
2128
+ ):
2129
+ from analysis.graph.impact_analyzer import compute_impact
2130
+
2131
+ ctx = _repo_ctx(repo) if repo else _active_repo_ctx()
2132
+ if not ctx:
2133
+ return _no_active_repo_response()
2134
+ if not _has_analysis_cache(ctx):
2135
+ return _missing_cache_response()
2136
+
2137
+ architecture_metrics_path = os.path.join(ctx["cache_dir"], "architecture_metrics.json")
2138
+ if not os.path.exists(architecture_metrics_path):
2139
+ return JSONResponse(
2140
+ status_code=400,
2141
+ content={
2142
+ "ok": False,
2143
+ "error": "MISSING_ANALYSIS",
2144
+ "message": MISSING_CACHE_MESSAGE,
2145
+ },
2146
+ )
2147
+
2148
+ try:
2149
+ payload = compute_impact(
2150
+ cache_dir=ctx["cache_dir"],
2151
+ target=target,
2152
+ depth=depth,
2153
+ max_nodes=max_nodes,
2154
+ )
2155
+ except Exception as e:
2156
+ return JSONResponse(
2157
+ status_code=500,
2158
+ content={"ok": False, "error": "IMPACT_FAILED", "message": redact_secrets(str(e))},
2159
+ )
2160
+ return payload