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.
- analysis/__init__.py +1 -0
- analysis/architecture/__init__.py +1 -0
- analysis/architecture/architecture_engine.py +155 -0
- analysis/architecture/dependency_cycles.py +103 -0
- analysis/architecture/risk_radar.py +220 -0
- analysis/call_graph/__init__.py +1 -0
- analysis/call_graph/call_extractor.py +91 -0
- analysis/call_graph/call_graph_builder.py +1 -0
- analysis/call_graph/call_resolver.py +56 -0
- analysis/call_graph/context_models.py +1 -0
- analysis/call_graph/cross_file_resolver.py +122 -0
- analysis/call_graph/execution_tracker.py +1 -0
- analysis/call_graph/flow_builder.py +1 -0
- analysis/call_graph/models.py +1 -0
- analysis/core/__init__.py +1 -0
- analysis/core/ast_context.py +1 -0
- analysis/core/ast_parser.py +8 -0
- analysis/core/class_extractor.py +35 -0
- analysis/core/function_extractor.py +16 -0
- analysis/core/import_extractor.py +43 -0
- analysis/explain/__init__.py +1 -0
- analysis/explain/docstring_extractor.py +45 -0
- analysis/explain/explain_runner.py +177 -0
- analysis/explain/repo_summary_generator.py +138 -0
- analysis/explain/return_analyzer.py +114 -0
- analysis/explain/risk_flags.py +1 -0
- analysis/explain/signature_extractor.py +104 -0
- analysis/explain/summary_generator.py +282 -0
- analysis/graph/__init__.py +1 -0
- analysis/graph/callgraph_index.py +117 -0
- analysis/graph/entrypoint_detector.py +1 -0
- analysis/graph/impact_analyzer.py +210 -0
- analysis/indexing/__init__.py +1 -0
- analysis/indexing/import_resolver.py +156 -0
- analysis/indexing/symbol_index.py +150 -0
- analysis/runners/__init__.py +1 -0
- analysis/runners/phase4_runner.py +137 -0
- analysis/utils/__init__.py +1 -0
- analysis/utils/ast_helpers.py +1 -0
- analysis/utils/cache_manager.py +659 -0
- analysis/utils/path_resolver.py +1 -0
- analysis/utils/repo_fetcher.py +469 -0
- cli.py +1728 -0
- codemap_cli.py +11 -0
- codemap_python-0.1.0.dist-info/METADATA +399 -0
- codemap_python-0.1.0.dist-info/RECORD +58 -0
- codemap_python-0.1.0.dist-info/WHEEL +5 -0
- codemap_python-0.1.0.dist-info/entry_points.txt +2 -0
- codemap_python-0.1.0.dist-info/top_level.txt +5 -0
- security_utils.py +51 -0
- ui/__init__.py +1 -0
- ui/app.py +2160 -0
- ui/device_id.py +27 -0
- ui/static/app.js +2703 -0
- ui/static/styles.css +1268 -0
- ui/templates/index.html +231 -0
- ui/utils/__init__.py +1 -0
- 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
|