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
cli.py
ADDED
|
@@ -0,0 +1,1728 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
import webbrowser
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Dict, Any, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from security_utils import redact_payload, redact_secrets
|
|
11
|
+
|
|
12
|
+
def print_json(obj) -> None:
|
|
13
|
+
safe_obj = redact_payload(obj)
|
|
14
|
+
sys.stdout.write(json.dumps(safe_obj, indent=2))
|
|
15
|
+
sys.stdout.write("\n")
|
|
16
|
+
|
|
17
|
+
MISSING_ANALYSIS_MESSAGE = "Run: python cli.py api analyze --path <repo>"
|
|
18
|
+
ANALYSIS_VERSION = "2.2"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _print_public_help() -> int:
|
|
22
|
+
sys.stdout.write(
|
|
23
|
+
"usage: codemap [-h] {analyze,dashboard,open,cache} ...\n\n"
|
|
24
|
+
"CodeMap CLI: query cached architecture intelligence\n\n"
|
|
25
|
+
"positional arguments:\n"
|
|
26
|
+
" {analyze,dashboard,open,cache}\n"
|
|
27
|
+
" analyze Analyze filesystem or GitHub repository\n"
|
|
28
|
+
" dashboard Run local CodeMap dashboard server\n"
|
|
29
|
+
" open Open dashboard URL in browser\n"
|
|
30
|
+
" cache Cache management\n\n"
|
|
31
|
+
"options:\n"
|
|
32
|
+
" -h, --help show this help message and exit\n"
|
|
33
|
+
)
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _analysis_root() -> str:
|
|
39
|
+
# cli.py is at project root; analysis/ is sibling
|
|
40
|
+
return os.path.join(os.path.dirname(__file__), "analysis")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _global_cache_root() -> str:
|
|
44
|
+
return os.path.join(os.path.dirname(__file__), ".codemap_cache")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _safe_delete_dir(path: str, allowed_root: str) -> bool:
|
|
48
|
+
if not path:
|
|
49
|
+
return False
|
|
50
|
+
if not os.path.exists(path):
|
|
51
|
+
return False
|
|
52
|
+
real_root = os.path.realpath(allowed_root)
|
|
53
|
+
real_target = os.path.realpath(path)
|
|
54
|
+
try:
|
|
55
|
+
common = os.path.commonpath([real_root, real_target])
|
|
56
|
+
except ValueError:
|
|
57
|
+
return False
|
|
58
|
+
if common != real_root:
|
|
59
|
+
return False
|
|
60
|
+
shutil.rmtree(real_target)
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _dir_size_bytes(path: str) -> int:
|
|
65
|
+
total = 0
|
|
66
|
+
if not os.path.isdir(path):
|
|
67
|
+
return 0
|
|
68
|
+
for root, _dirs, files in os.walk(path):
|
|
69
|
+
for name in files:
|
|
70
|
+
fp = os.path.join(root, name)
|
|
71
|
+
try:
|
|
72
|
+
total += int(os.path.getsize(fp))
|
|
73
|
+
except OSError:
|
|
74
|
+
continue
|
|
75
|
+
return int(total)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _cache_root() -> str:
|
|
79
|
+
root = _global_cache_root()
|
|
80
|
+
os.makedirs(root, exist_ok=True)
|
|
81
|
+
return root
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _load_workspace_registry() -> Dict[str, Any]:
|
|
85
|
+
path = os.path.join(_cache_root(), "workspaces.json")
|
|
86
|
+
if not os.path.exists(path):
|
|
87
|
+
return {"active_repo_hash": "", "repos": []}
|
|
88
|
+
try:
|
|
89
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
90
|
+
data = json.load(f)
|
|
91
|
+
if isinstance(data, dict):
|
|
92
|
+
data.setdefault("repos", [])
|
|
93
|
+
data.setdefault("active_repo_hash", "")
|
|
94
|
+
return data
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
return {"active_repo_hash": "", "repos": []}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_iso_dt(value: Optional[str]) -> Optional[datetime]:
|
|
101
|
+
if not value:
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
105
|
+
except Exception:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _now_utc() -> datetime:
|
|
110
|
+
return datetime.now(timezone.utc)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _parse_duration_days(spec: str) -> Optional[float]:
|
|
114
|
+
raw = str(spec or "").strip().lower()
|
|
115
|
+
if not raw:
|
|
116
|
+
return None
|
|
117
|
+
try:
|
|
118
|
+
if raw.endswith("d"):
|
|
119
|
+
return float(raw[:-1])
|
|
120
|
+
if raw.endswith("h"):
|
|
121
|
+
return float(raw[:-1]) / 24.0
|
|
122
|
+
return float(raw)
|
|
123
|
+
except Exception:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _touch_repo_access_by_dir(repo_dir: Optional[str]) -> None:
|
|
128
|
+
if not repo_dir:
|
|
129
|
+
return
|
|
130
|
+
try:
|
|
131
|
+
from analysis.utils.cache_manager import compute_repo_hash, touch_last_accessed
|
|
132
|
+
touch_last_accessed(compute_repo_hash(repo_dir))
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _resolve_runtime_github_token(args) -> Tuple[Optional[str], str]:
|
|
138
|
+
token_arg = str(getattr(args, "token", "") or "").strip()
|
|
139
|
+
token_stdin = bool(getattr(args, "token_stdin", False))
|
|
140
|
+
env_token = str(os.getenv("GITHUB_TOKEN", "") or "").strip()
|
|
141
|
+
|
|
142
|
+
if token_arg:
|
|
143
|
+
return token_arg, "arg"
|
|
144
|
+
|
|
145
|
+
if token_stdin:
|
|
146
|
+
try:
|
|
147
|
+
stdin_token = str(sys.stdin.readline() or "").strip()
|
|
148
|
+
except Exception:
|
|
149
|
+
stdin_token = ""
|
|
150
|
+
if stdin_token:
|
|
151
|
+
return stdin_token, "stdin"
|
|
152
|
+
|
|
153
|
+
if env_token:
|
|
154
|
+
return env_token, "env"
|
|
155
|
+
return None, "none"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _save_workspace_registry(data: Dict[str, Any]) -> None:
|
|
159
|
+
path = os.path.join(_cache_root(), "workspaces.json")
|
|
160
|
+
data = dict(data or {})
|
|
161
|
+
repos = data.get("repos")
|
|
162
|
+
if not isinstance(repos, list):
|
|
163
|
+
repos = []
|
|
164
|
+
data["repos"] = repos
|
|
165
|
+
if not repos:
|
|
166
|
+
data["active_repo_hash"] = ""
|
|
167
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
168
|
+
json.dump(data, f, indent=2)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _cache_artifact_map(cache_dir: str) -> Dict[str, bool]:
|
|
172
|
+
return {
|
|
173
|
+
"resolved_calls": os.path.exists(os.path.join(cache_dir, "resolved_calls.json")),
|
|
174
|
+
"explain": os.path.exists(os.path.join(cache_dir, "explain.json")),
|
|
175
|
+
"project_tree": os.path.exists(os.path.join(cache_dir, "project_tree.json")),
|
|
176
|
+
"risk_radar": os.path.exists(os.path.join(cache_dir, "risk_radar.json")),
|
|
177
|
+
"dependency_cycles": os.path.exists(os.path.join(cache_dir, "dependency_cycles.json")),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _read_manifest(cache_dir: str) -> Dict[str, Any]:
|
|
182
|
+
path = os.path.join(cache_dir, "manifest.json")
|
|
183
|
+
if not os.path.exists(path):
|
|
184
|
+
return {}
|
|
185
|
+
try:
|
|
186
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
187
|
+
data = json.load(f)
|
|
188
|
+
return data if isinstance(data, dict) else {}
|
|
189
|
+
except Exception:
|
|
190
|
+
return {}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _resolve_cache_target(args) -> Dict[str, Any]:
|
|
194
|
+
from analysis.utils.cache_manager import compute_repo_hash, get_cache_dir
|
|
195
|
+
from analysis.utils.repo_fetcher import resolve_workspace_paths
|
|
196
|
+
|
|
197
|
+
path_value = str(getattr(args, "path", "") or "").strip()
|
|
198
|
+
github_value = str(getattr(args, "github", "") or "").strip()
|
|
199
|
+
ref_value = str(getattr(args, "ref", "") or "").strip() or None
|
|
200
|
+
mode_value = str(getattr(args, "mode", "git") or "git").strip().lower() or "git"
|
|
201
|
+
|
|
202
|
+
if path_value and github_value:
|
|
203
|
+
return {"ok": False, "error": "INVALID_ARGS", "message": "Use either --path or --github, not both."}
|
|
204
|
+
if not path_value and not github_value:
|
|
205
|
+
return {"ok": False, "error": "INVALID_ARGS", "message": "Provide --path <repo> or --github <url>."}
|
|
206
|
+
if mode_value not in {"git", "zip"}:
|
|
207
|
+
return {"ok": False, "error": "INVALID_ARGS", "message": "--mode must be one of: git, zip"}
|
|
208
|
+
|
|
209
|
+
if github_value:
|
|
210
|
+
try:
|
|
211
|
+
ws = resolve_workspace_paths(github_value, ref_value, mode_value)
|
|
212
|
+
except Exception as e:
|
|
213
|
+
return {"ok": False, "error": "INVALID_GITHUB_URL", "message": str(e)}
|
|
214
|
+
repo_dir = ws["repo_dir"]
|
|
215
|
+
cache_dir = get_cache_dir(repo_dir)
|
|
216
|
+
return {
|
|
217
|
+
"ok": True,
|
|
218
|
+
"source": "github",
|
|
219
|
+
"repo_dir": repo_dir,
|
|
220
|
+
"repo_hash": compute_repo_hash(repo_dir),
|
|
221
|
+
"cache_dir": cache_dir,
|
|
222
|
+
"workspace_dir": ws["workspace_dir"],
|
|
223
|
+
"repo_url": ws["normalized_url"],
|
|
224
|
+
"ref": ref_value,
|
|
225
|
+
"mode": mode_value,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
repo_dir = resolve_repo_paths(path_value)["repo_dir"]
|
|
229
|
+
cache_dir = get_cache_dir(repo_dir)
|
|
230
|
+
return {
|
|
231
|
+
"ok": True,
|
|
232
|
+
"source": "filesystem",
|
|
233
|
+
"repo_dir": repo_dir,
|
|
234
|
+
"repo_hash": compute_repo_hash(repo_dir),
|
|
235
|
+
"cache_dir": cache_dir,
|
|
236
|
+
"workspace_dir": None,
|
|
237
|
+
"repo_url": None,
|
|
238
|
+
"ref": None,
|
|
239
|
+
"mode": None,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _retention_from_manifest(manifest: Dict[str, Any]) -> Dict[str, Any]:
|
|
244
|
+
data = manifest if isinstance(manifest, dict) else {}
|
|
245
|
+
if "retention_days" in data:
|
|
246
|
+
days = int(data.get("retention_days", 14) or 14)
|
|
247
|
+
mode = "pinned" if days == 0 else "ttl"
|
|
248
|
+
return {
|
|
249
|
+
"mode": mode,
|
|
250
|
+
"ttl_days": days,
|
|
251
|
+
"created_at": str(data.get("created_at", "") or ""),
|
|
252
|
+
"last_accessed_at": str(data.get("last_accessed_at", "") or ""),
|
|
253
|
+
}
|
|
254
|
+
retention = data.get("retention", {}) if isinstance(data.get("retention"), dict) else {}
|
|
255
|
+
mode = str(retention.get("mode", "ttl") or "ttl")
|
|
256
|
+
if mode not in {"ttl", "session_only", "pinned"}:
|
|
257
|
+
mode = "ttl"
|
|
258
|
+
ttl_days = int(retention.get("ttl_days", 14) or 14)
|
|
259
|
+
if ttl_days < 0:
|
|
260
|
+
ttl_days = 0
|
|
261
|
+
created_at = str(retention.get("created_at") or data.get("updated_at") or "")
|
|
262
|
+
last_accessed_at = str(retention.get("last_accessed_at") or created_at)
|
|
263
|
+
return {"mode": mode, "ttl_days": ttl_days, "created_at": created_at, "last_accessed_at": last_accessed_at}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def api_cache_help(_args) -> int:
|
|
267
|
+
print_json({
|
|
268
|
+
"ok": True,
|
|
269
|
+
"commands": [
|
|
270
|
+
"python cli.py api cache list",
|
|
271
|
+
"python cli.py api cache info --path <repo>",
|
|
272
|
+
"python cli.py api cache info --github <url> --ref <ref> --mode <git|zip>",
|
|
273
|
+
"python cli.py api cache clear --path <repo> [--dry-run] [--yes]",
|
|
274
|
+
"python cli.py api cache clear --repo-hash <hash> [--dry-run] [--yes]",
|
|
275
|
+
"python cli.py api cache clear --all [--dry-run] --yes",
|
|
276
|
+
"python cli.py api cache retention --path <repo> --days 7 --yes",
|
|
277
|
+
"python cli.py api cache retention --repo-hash <hash> --days 30 --yes",
|
|
278
|
+
"python cli.py api cache sweep --dry-run",
|
|
279
|
+
"python cli.py api cache sweep --yes",
|
|
280
|
+
],
|
|
281
|
+
})
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def api_cache_policy_get(_args) -> int:
|
|
286
|
+
from analysis.utils.cache_manager import load_policy
|
|
287
|
+
|
|
288
|
+
print_json({"ok": True, "policy": load_policy()})
|
|
289
|
+
return 0
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def api_cache_policy_set(args) -> int:
|
|
293
|
+
from analysis.utils.cache_manager import load_policy, save_policy
|
|
294
|
+
|
|
295
|
+
current = load_policy()
|
|
296
|
+
default_ttl = getattr(args, "default_ttl_days", None)
|
|
297
|
+
ws_ttl = getattr(args, "workspaces_ttl_days", None)
|
|
298
|
+
|
|
299
|
+
if default_ttl is not None and int(default_ttl) < 0:
|
|
300
|
+
print_json({"ok": False, "error": "INVALID_ARGS", "message": "--default-ttl-days must be >= 0"})
|
|
301
|
+
return 1
|
|
302
|
+
if ws_ttl is not None and int(ws_ttl) < 0:
|
|
303
|
+
print_json({"ok": False, "error": "INVALID_ARGS", "message": "--workspaces-ttl-days must be >= 0"})
|
|
304
|
+
return 1
|
|
305
|
+
|
|
306
|
+
updated = {
|
|
307
|
+
"default_ttl_days": int(default_ttl) if default_ttl is not None else int(current.get("default_ttl_days", 30)),
|
|
308
|
+
"workspaces_ttl_days": int(ws_ttl) if ws_ttl is not None else int(current.get("workspaces_ttl_days", 7)),
|
|
309
|
+
"never_delete_repo_hashes": current.get("never_delete_repo_hashes", []),
|
|
310
|
+
"repo_policies": current.get("repo_policies", {}),
|
|
311
|
+
"last_cleanup_iso": current.get("last_cleanup_iso", ""),
|
|
312
|
+
}
|
|
313
|
+
policy = save_policy(updated)
|
|
314
|
+
print_json({"ok": True, "policy": policy})
|
|
315
|
+
return 0
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def api_cache_list(_args) -> int:
|
|
319
|
+
from analysis.utils.cache_manager import list_caches
|
|
320
|
+
|
|
321
|
+
raw = list_caches()
|
|
322
|
+
caches: List[Dict[str, Any]] = []
|
|
323
|
+
for item in raw:
|
|
324
|
+
expires = item.get("expires", {}) if isinstance(item.get("expires"), dict) else {}
|
|
325
|
+
caches.append({
|
|
326
|
+
"repo_hash": item.get("repo_hash"),
|
|
327
|
+
"cache_dir": item.get("cache_dir"),
|
|
328
|
+
"source": item.get("source", "filesystem"),
|
|
329
|
+
"repo_url": item.get("repo_url") or None,
|
|
330
|
+
"repo_path": item.get("repo_path") or None,
|
|
331
|
+
"ref": item.get("ref") or None,
|
|
332
|
+
"workspace_dir": item.get("workspace_dir") or None,
|
|
333
|
+
"analysis_version": item.get("analysis_version") or None,
|
|
334
|
+
"last_updated": item.get("last_accessed_at") or None,
|
|
335
|
+
"retention": {
|
|
336
|
+
"mode": expires.get("mode", "ttl"),
|
|
337
|
+
"ttl_days": int(item.get("retention_days", 14) or 14),
|
|
338
|
+
"created_at": item.get("created_at"),
|
|
339
|
+
"last_accessed_at": item.get("last_accessed_at"),
|
|
340
|
+
"days_left": expires.get("days_left"),
|
|
341
|
+
"expired": bool(expires.get("expired", False)),
|
|
342
|
+
},
|
|
343
|
+
"private_mode": bool(item.get("private_mode", False)),
|
|
344
|
+
"size_bytes": int(item.get("size_bytes", 0)),
|
|
345
|
+
"has": item.get("has", {}),
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
print_json({"ok": True, "count": len(caches), "caches": caches})
|
|
349
|
+
return 0
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def api_cache_info(args) -> int:
|
|
353
|
+
from analysis.utils.cache_manager import list_caches, touch_last_accessed
|
|
354
|
+
|
|
355
|
+
target = _resolve_cache_target(args)
|
|
356
|
+
if not target.get("ok"):
|
|
357
|
+
print_json({"ok": False, "error": target.get("error"), "message": target.get("message"), "hint": "Use: python cli.py api cache help"})
|
|
358
|
+
return 1
|
|
359
|
+
|
|
360
|
+
cache_item = next((c for c in list_caches() if str(c.get("repo_hash")) == str(target["repo_hash"])), None)
|
|
361
|
+
if not cache_item:
|
|
362
|
+
print_json({
|
|
363
|
+
"ok": False,
|
|
364
|
+
"error": "CACHE_NOT_FOUND",
|
|
365
|
+
"message": f"Cache directory not found: {target['cache_dir']}",
|
|
366
|
+
"hint": "Run: python cli.py api analyze --path <repo>",
|
|
367
|
+
})
|
|
368
|
+
return 1
|
|
369
|
+
|
|
370
|
+
cache_dir = str(cache_item.get("cache_dir", target["cache_dir"]))
|
|
371
|
+
expires = cache_item.get("expires", {}) if isinstance(cache_item.get("expires"), dict) else {}
|
|
372
|
+
retention = {
|
|
373
|
+
"mode": expires.get("mode", "ttl"),
|
|
374
|
+
"ttl_days": int(cache_item.get("retention_days", 14) or 14),
|
|
375
|
+
"created_at": cache_item.get("created_at"),
|
|
376
|
+
"last_accessed_at": cache_item.get("last_accessed_at"),
|
|
377
|
+
"days_left": expires.get("days_left"),
|
|
378
|
+
"expired": bool(expires.get("expired", False)),
|
|
379
|
+
}
|
|
380
|
+
files = {
|
|
381
|
+
"resolved_calls_path": os.path.join(cache_dir, "resolved_calls.json") if os.path.exists(os.path.join(cache_dir, "resolved_calls.json")) else None,
|
|
382
|
+
"explain_path": os.path.join(cache_dir, "explain.json") if os.path.exists(os.path.join(cache_dir, "explain.json")) else None,
|
|
383
|
+
"project_tree_path": os.path.join(cache_dir, "project_tree.json") if os.path.exists(os.path.join(cache_dir, "project_tree.json")) else None,
|
|
384
|
+
"risk_radar_path": os.path.join(cache_dir, "risk_radar.json") if os.path.exists(os.path.join(cache_dir, "risk_radar.json")) else None,
|
|
385
|
+
}
|
|
386
|
+
notes = []
|
|
387
|
+
if files["explain_path"] is None:
|
|
388
|
+
notes.append("missing explain.json; run: python cli.py api analyze --path <repo>")
|
|
389
|
+
if files["resolved_calls_path"] is None:
|
|
390
|
+
notes.append("missing resolved_calls.json; run: python cli.py api analyze --path <repo>")
|
|
391
|
+
|
|
392
|
+
print_json({
|
|
393
|
+
"ok": True,
|
|
394
|
+
"repo_hash": target["repo_hash"],
|
|
395
|
+
"cache_dir": os.path.abspath(cache_dir),
|
|
396
|
+
"workspace_dir": cache_item.get("workspace_dir") or target.get("workspace_dir"),
|
|
397
|
+
"source": cache_item.get("source", target["source"]),
|
|
398
|
+
"analysis_version": cache_item.get("analysis_version"),
|
|
399
|
+
"last_updated": cache_item.get("last_accessed_at"),
|
|
400
|
+
"retention": retention,
|
|
401
|
+
"files": files,
|
|
402
|
+
"size_bytes": int(cache_item.get("size_bytes", 0)),
|
|
403
|
+
"private_mode": bool(cache_item.get("private_mode", False)),
|
|
404
|
+
"notes": notes,
|
|
405
|
+
})
|
|
406
|
+
touch_last_accessed(target["repo_hash"])
|
|
407
|
+
return 0
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def api_cache_clear(args) -> int:
|
|
411
|
+
from analysis.utils.cache_manager import clear_cache, list_caches
|
|
412
|
+
|
|
413
|
+
dry_run = bool(getattr(args, "dry_run", False))
|
|
414
|
+
yes = bool(getattr(args, "yes", False))
|
|
415
|
+
clear_all = bool(getattr(args, "all", False))
|
|
416
|
+
repo_hash_arg = str(getattr(args, "repo_hash", "") or "").strip()
|
|
417
|
+
|
|
418
|
+
if not dry_run and not yes:
|
|
419
|
+
print_json({
|
|
420
|
+
"ok": False,
|
|
421
|
+
"error": "CONFIRM_REQUIRED",
|
|
422
|
+
"message": "Pass --yes for destructive cache clear.",
|
|
423
|
+
"hint": "Use --dry-run to preview deletion.",
|
|
424
|
+
})
|
|
425
|
+
return 1
|
|
426
|
+
|
|
427
|
+
if clear_all:
|
|
428
|
+
targets = [str(c.get("repo_hash", "")) for c in list_caches() if c.get("repo_hash")]
|
|
429
|
+
results = [clear_cache(repo_hash=t, dry_run=dry_run) for t in targets]
|
|
430
|
+
would_delete = [p for r in results for p in r.get("would_delete", [])]
|
|
431
|
+
errors = [e for r in results for e in r.get("errors", [])]
|
|
432
|
+
freed = sum(int(r.get("freed_bytes_estimate", 0)) for r in results)
|
|
433
|
+
print_json({
|
|
434
|
+
"ok": True,
|
|
435
|
+
"all": True,
|
|
436
|
+
"dry_run": dry_run,
|
|
437
|
+
"deleted": bool(False if dry_run else not errors),
|
|
438
|
+
"cache_count": len(targets),
|
|
439
|
+
"would_delete": would_delete,
|
|
440
|
+
"freed_bytes_estimate": int(freed),
|
|
441
|
+
"errors": errors,
|
|
442
|
+
"results": results,
|
|
443
|
+
})
|
|
444
|
+
return 0 if not errors else 1
|
|
445
|
+
|
|
446
|
+
if not repo_hash_arg:
|
|
447
|
+
target = _resolve_cache_target(args)
|
|
448
|
+
if not target.get("ok"):
|
|
449
|
+
print_json({"ok": False, "error": target.get("error"), "message": target.get("message"), "hint": "Use: python cli.py api cache help"})
|
|
450
|
+
return 1
|
|
451
|
+
repo_hash_arg = str(target["repo_hash"])
|
|
452
|
+
|
|
453
|
+
result = clear_cache(repo_hash=repo_hash_arg, dry_run=dry_run)
|
|
454
|
+
print_json(result)
|
|
455
|
+
return 0 if not result.get("errors") else 1
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def api_cache_retention(args) -> int:
|
|
459
|
+
from analysis.utils.cache_manager import set_retention
|
|
460
|
+
|
|
461
|
+
yes = bool(getattr(args, "yes", False))
|
|
462
|
+
if not yes:
|
|
463
|
+
print_json({
|
|
464
|
+
"ok": False,
|
|
465
|
+
"error": "CONFIRM_REQUIRED",
|
|
466
|
+
"message": "Pass --yes to update retention.",
|
|
467
|
+
})
|
|
468
|
+
return 1
|
|
469
|
+
|
|
470
|
+
days = int(getattr(args, "days", 14) or 14)
|
|
471
|
+
if days < 0:
|
|
472
|
+
print_json({"ok": False, "error": "INVALID_ARGS", "message": "--days must be >= 0"})
|
|
473
|
+
return 1
|
|
474
|
+
|
|
475
|
+
repo_hash_arg = str(getattr(args, "repo_hash", "") or "").strip()
|
|
476
|
+
if not repo_hash_arg:
|
|
477
|
+
target = _resolve_cache_target(args)
|
|
478
|
+
if not target.get("ok"):
|
|
479
|
+
print_json({"ok": False, "error": target.get("error"), "message": target.get("message"), "hint": "Use: python cli.py api cache help"})
|
|
480
|
+
return 1
|
|
481
|
+
repo_hash_arg = str(target["repo_hash"])
|
|
482
|
+
|
|
483
|
+
metadata = set_retention(repo_hash=repo_hash_arg, days=days)
|
|
484
|
+
print_json({
|
|
485
|
+
"ok": True,
|
|
486
|
+
"repo_hash": repo_hash_arg,
|
|
487
|
+
"days": int(metadata.get("retention_days", days)),
|
|
488
|
+
"metadata_path": os.path.join(_cache_root(), repo_hash_arg, "metadata.json"),
|
|
489
|
+
})
|
|
490
|
+
return 0
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def api_cache_delete(args) -> int:
|
|
494
|
+
repo_hash_arg = str(getattr(args, "repo_hash", "") or "").strip()
|
|
495
|
+
if repo_hash_arg:
|
|
496
|
+
from analysis.utils.cache_manager import delete_repo as cm_delete_repo
|
|
497
|
+
|
|
498
|
+
dry_run = bool(getattr(args, "dry_run", False))
|
|
499
|
+
yes = bool(getattr(args, "yes", False))
|
|
500
|
+
if not dry_run and not yes:
|
|
501
|
+
confirm = input(f"This will delete all cache artifacts for repo_hash={repo_hash_arg}. Continue? [y/N] ").strip().lower()
|
|
502
|
+
if confirm not in {"y", "yes"}:
|
|
503
|
+
print_json({"ok": False, "error": "ABORTED", "message": "Operation cancelled by user.", "hint": "Pass --yes to skip confirmation."})
|
|
504
|
+
return 1
|
|
505
|
+
result = cm_delete_repo(repo_hash=repo_hash_arg, dry_run=dry_run)
|
|
506
|
+
print_json(result)
|
|
507
|
+
return 0
|
|
508
|
+
|
|
509
|
+
target = _resolve_cache_target(args)
|
|
510
|
+
if not target.get("ok"):
|
|
511
|
+
print_json({"ok": False, "error": target.get("error"), "message": target.get("message"), "hint": "Use: python cli.py api cache help"})
|
|
512
|
+
return 1
|
|
513
|
+
|
|
514
|
+
dry_run = bool(getattr(args, "dry_run", False))
|
|
515
|
+
yes = bool(getattr(args, "yes", False))
|
|
516
|
+
cache_root = _cache_root()
|
|
517
|
+
cache_dir = target["cache_dir"]
|
|
518
|
+
workspace_dir = target.get("workspace_dir")
|
|
519
|
+
|
|
520
|
+
would_delete: List[str] = []
|
|
521
|
+
if os.path.isdir(cache_dir):
|
|
522
|
+
would_delete.append(os.path.abspath(cache_dir))
|
|
523
|
+
if workspace_dir and os.path.isdir(workspace_dir):
|
|
524
|
+
would_delete.append(os.path.abspath(workspace_dir))
|
|
525
|
+
|
|
526
|
+
freed = sum(_dir_size_bytes(p) for p in would_delete if os.path.isdir(p))
|
|
527
|
+
if dry_run:
|
|
528
|
+
print_json({
|
|
529
|
+
"ok": True,
|
|
530
|
+
"dry_run": True,
|
|
531
|
+
"repo_hash": target["repo_hash"],
|
|
532
|
+
"deleted": False,
|
|
533
|
+
"would_delete": would_delete,
|
|
534
|
+
"freed_bytes_estimate": int(freed),
|
|
535
|
+
})
|
|
536
|
+
return 0
|
|
537
|
+
|
|
538
|
+
if not yes:
|
|
539
|
+
confirm = input(f"This will delete all cache artifacts for repo_hash={target['repo_hash']}. Continue? [y/N] ").strip().lower()
|
|
540
|
+
if confirm not in {"y", "yes"}:
|
|
541
|
+
print_json({"ok": False, "error": "ABORTED", "message": "Operation cancelled by user.", "hint": "Pass --yes to skip confirmation."})
|
|
542
|
+
return 1
|
|
543
|
+
|
|
544
|
+
deleted_any = False
|
|
545
|
+
for path in would_delete:
|
|
546
|
+
if _safe_delete_dir(path, cache_root):
|
|
547
|
+
deleted_any = True
|
|
548
|
+
|
|
549
|
+
ws = _load_workspace_registry()
|
|
550
|
+
repos = ws.get("repos", []) if isinstance(ws, dict) else []
|
|
551
|
+
if workspace_dir:
|
|
552
|
+
ws_real = os.path.realpath(workspace_dir)
|
|
553
|
+
repos = [
|
|
554
|
+
r for r in repos
|
|
555
|
+
if not os.path.realpath(str((r or {}).get("path", "") or "")).startswith(ws_real)
|
|
556
|
+
]
|
|
557
|
+
ws["repos"] = repos
|
|
558
|
+
active = str(ws.get("active_repo_hash", "") or "")
|
|
559
|
+
if active and not any(str((r or {}).get("repo_hash", "")) == active for r in repos):
|
|
560
|
+
ws["active_repo_hash"] = ""
|
|
561
|
+
_save_workspace_registry(ws)
|
|
562
|
+
|
|
563
|
+
print_json({
|
|
564
|
+
"ok": True,
|
|
565
|
+
"dry_run": False,
|
|
566
|
+
"repo_hash": target["repo_hash"],
|
|
567
|
+
"deleted": bool(deleted_any),
|
|
568
|
+
"would_delete": would_delete,
|
|
569
|
+
"freed_bytes_estimate": int(freed),
|
|
570
|
+
})
|
|
571
|
+
return 0
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def api_cache_cleanup(args) -> int:
|
|
575
|
+
from analysis.utils.cache_manager import sweep_expired
|
|
576
|
+
|
|
577
|
+
apply_flag = bool(getattr(args, "apply", False))
|
|
578
|
+
dry_run = bool(getattr(args, "dry_run", False)) and not apply_flag
|
|
579
|
+
yes = bool(getattr(args, "yes", False)) or apply_flag
|
|
580
|
+
if not dry_run and not yes:
|
|
581
|
+
print_json({
|
|
582
|
+
"ok": False,
|
|
583
|
+
"error": "CONFIRM_REQUIRED",
|
|
584
|
+
"message": "Pass --yes (or --apply) for cleanup deletion.",
|
|
585
|
+
"hint": "Use --dry-run to preview deletion.",
|
|
586
|
+
})
|
|
587
|
+
return 1
|
|
588
|
+
|
|
589
|
+
result = sweep_expired(dry_run=dry_run)
|
|
590
|
+
print_json(result)
|
|
591
|
+
return 0 if not result.get("errors") else 1
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def api_cache_prune(args) -> int:
|
|
595
|
+
dry_run = bool(getattr(args, "dry_run", False))
|
|
596
|
+
yes = bool(getattr(args, "yes", False))
|
|
597
|
+
older_than_days = _parse_duration_days(str(getattr(args, "older_than", "") or ""))
|
|
598
|
+
if older_than_days is None:
|
|
599
|
+
older_than_days = 0.0
|
|
600
|
+
|
|
601
|
+
cache_root = _cache_root()
|
|
602
|
+
now = _now_utc()
|
|
603
|
+
candidates: List[Dict[str, Any]] = []
|
|
604
|
+
|
|
605
|
+
for name in sorted(os.listdir(cache_root)):
|
|
606
|
+
if name in {"workspaces", "workspaces.json"}:
|
|
607
|
+
continue
|
|
608
|
+
cache_dir = os.path.join(cache_root, name)
|
|
609
|
+
if not os.path.isdir(cache_dir):
|
|
610
|
+
continue
|
|
611
|
+
manifest = _read_manifest(cache_dir)
|
|
612
|
+
retention = _retention_from_manifest(manifest)
|
|
613
|
+
mode = retention["mode"]
|
|
614
|
+
if mode == "pinned":
|
|
615
|
+
continue
|
|
616
|
+
last_access = _parse_iso_dt(retention.get("last_accessed_at")) or _parse_iso_dt(retention.get("created_at")) or _parse_iso_dt(manifest.get("updated_at"))
|
|
617
|
+
if last_access is None:
|
|
618
|
+
last_access = now
|
|
619
|
+
age_days = (now - last_access).total_seconds() / 86400.0
|
|
620
|
+
policy_days = 1.0 if mode == "session_only" else float(retention.get("ttl_days", 14))
|
|
621
|
+
eligible_policy = age_days > policy_days
|
|
622
|
+
eligible_older_than = age_days > older_than_days
|
|
623
|
+
if eligible_policy and eligible_older_than:
|
|
624
|
+
candidates.append({
|
|
625
|
+
"repo_hash": name,
|
|
626
|
+
"cache_dir": os.path.abspath(cache_dir),
|
|
627
|
+
"age_days": round(age_days, 3),
|
|
628
|
+
"retention_mode": mode,
|
|
629
|
+
"ttl_days": policy_days,
|
|
630
|
+
"size_bytes": _dir_size_bytes(cache_dir),
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
would_delete = [c["cache_dir"] for c in candidates]
|
|
634
|
+
freed = sum(int(c["size_bytes"]) for c in candidates)
|
|
635
|
+
if dry_run:
|
|
636
|
+
print_json({
|
|
637
|
+
"ok": True,
|
|
638
|
+
"dry_run": True,
|
|
639
|
+
"deleted": False,
|
|
640
|
+
"count": len(candidates),
|
|
641
|
+
"candidates": candidates,
|
|
642
|
+
"would_delete": would_delete,
|
|
643
|
+
"freed_bytes_estimate": int(freed),
|
|
644
|
+
})
|
|
645
|
+
return 0
|
|
646
|
+
|
|
647
|
+
if not yes:
|
|
648
|
+
confirm = input(f"This will prune {len(candidates)} cache directories. Continue? [y/N] ").strip().lower()
|
|
649
|
+
if confirm not in {"y", "yes"}:
|
|
650
|
+
print_json({"ok": False, "error": "ABORTED", "message": "Operation cancelled by user.", "hint": "Pass --yes to skip confirmation."})
|
|
651
|
+
return 1
|
|
652
|
+
|
|
653
|
+
deleted = []
|
|
654
|
+
for c in candidates:
|
|
655
|
+
if _safe_delete_dir(c["cache_dir"], cache_root):
|
|
656
|
+
deleted.append(c["cache_dir"])
|
|
657
|
+
|
|
658
|
+
print_json({
|
|
659
|
+
"ok": True,
|
|
660
|
+
"dry_run": False,
|
|
661
|
+
"deleted": True,
|
|
662
|
+
"count": len(deleted),
|
|
663
|
+
"would_delete": deleted,
|
|
664
|
+
"freed_bytes_estimate": int(freed),
|
|
665
|
+
})
|
|
666
|
+
return 0
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def api_cache_sweep(args) -> int:
|
|
670
|
+
# Backward-compatible implementation path routed to cleanup behavior.
|
|
671
|
+
return api_cache_cleanup(args)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _build_project_tree_snapshot(repo_dir: str) -> Dict[str, Any]:
|
|
675
|
+
repo_dir = os.path.abspath(repo_dir)
|
|
676
|
+
ignore_dirs = {".git", ".codemap_cache", "__pycache__", ".venv", "venv", "node_modules"}
|
|
677
|
+
root = {
|
|
678
|
+
"name": os.path.basename(repo_dir.rstrip("\\/")) or repo_dir,
|
|
679
|
+
"type": "directory",
|
|
680
|
+
"path": "",
|
|
681
|
+
"children": [],
|
|
682
|
+
}
|
|
683
|
+
nodes: Dict[str, Dict[str, Any]] = {"": root}
|
|
684
|
+
|
|
685
|
+
for current_root, dirs, files in os.walk(repo_dir):
|
|
686
|
+
dirs[:] = sorted([d for d in dirs if d not in ignore_dirs and not d.startswith(".")])
|
|
687
|
+
files = sorted([f for f in files if not f.startswith(".")])
|
|
688
|
+
|
|
689
|
+
rel_root = os.path.relpath(current_root, repo_dir)
|
|
690
|
+
rel_root = "" if rel_root == "." else rel_root.replace("\\", "/")
|
|
691
|
+
parent = nodes[rel_root]
|
|
692
|
+
|
|
693
|
+
for d in dirs:
|
|
694
|
+
rel_path = f"{rel_root}/{d}" if rel_root else d
|
|
695
|
+
node = {"name": d, "type": "directory", "path": rel_path, "children": []}
|
|
696
|
+
parent["children"].append(node)
|
|
697
|
+
nodes[rel_path] = node
|
|
698
|
+
|
|
699
|
+
for f in files:
|
|
700
|
+
rel_path = f"{rel_root}/{f}" if rel_root else f
|
|
701
|
+
parent["children"].append({"name": f, "type": "file", "path": rel_path})
|
|
702
|
+
|
|
703
|
+
return root
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def resolve_repo_paths(repo_dir: Optional[str]) -> Dict[str, str]:
|
|
707
|
+
if not repo_dir:
|
|
708
|
+
output_dir = os.path.join(_analysis_root(), "output")
|
|
709
|
+
return {
|
|
710
|
+
"repo_dir": "",
|
|
711
|
+
"cache_dir": output_dir,
|
|
712
|
+
"explain_path": os.path.join(output_dir, "explain.json"),
|
|
713
|
+
"resolved_calls_path": os.path.join(output_dir, "resolved_calls.json"),
|
|
714
|
+
"llm_cache_path": os.path.join(output_dir, "llm_cache.json"),
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
repo_candidate = os.path.abspath(repo_dir)
|
|
718
|
+
if not os.path.exists(repo_candidate):
|
|
719
|
+
alt_candidate = os.path.abspath(os.path.join(_analysis_root(), repo_dir))
|
|
720
|
+
if os.path.exists(alt_candidate):
|
|
721
|
+
repo_candidate = alt_candidate
|
|
722
|
+
|
|
723
|
+
from analysis.utils.cache_manager import get_cache_dir
|
|
724
|
+
cache_dir = get_cache_dir(repo_candidate)
|
|
725
|
+
return {
|
|
726
|
+
"repo_dir": repo_candidate,
|
|
727
|
+
"cache_dir": cache_dir,
|
|
728
|
+
"explain_path": os.path.join(cache_dir, "explain.json"),
|
|
729
|
+
"resolved_calls_path": os.path.join(cache_dir, "resolved_calls.json"),
|
|
730
|
+
"llm_cache_path": os.path.join(cache_dir, "llm_cache.json"),
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def load_explain_db(repo: Optional[str] = None) -> Dict[str, Any]:
|
|
735
|
+
paths = resolve_repo_paths(repo)
|
|
736
|
+
path = paths["explain_path"]
|
|
737
|
+
if not os.path.exists(path):
|
|
738
|
+
hint = (
|
|
739
|
+
"Run:\n python cli.py api analyze --path <repo>\n"
|
|
740
|
+
"to build repo-scoped cache before querying with --repo."
|
|
741
|
+
if repo else
|
|
742
|
+
"Run:\n python -m analysis.explain.explain_runner\n"
|
|
743
|
+
"after generating resolved_calls.json from Phase-4 runner."
|
|
744
|
+
)
|
|
745
|
+
raise FileNotFoundError(
|
|
746
|
+
f"explain.json not found at:\n {path}\n\n"
|
|
747
|
+
f"{hint}"
|
|
748
|
+
)
|
|
749
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
750
|
+
return json.load(f)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _symbol_payload(item: Dict[str, Any], fallback_fqn: str) -> Dict[str, Any]:
|
|
754
|
+
location = item.get("location") or {}
|
|
755
|
+
return {
|
|
756
|
+
"fqn": item.get("fqn", fallback_fqn),
|
|
757
|
+
"one_liner": item.get("one_liner", ""),
|
|
758
|
+
"details": item.get("details", []),
|
|
759
|
+
"tags": item.get("tags", []),
|
|
760
|
+
"location": {
|
|
761
|
+
"file": location.get("file", ""),
|
|
762
|
+
"start_line": location.get("start_line", -1),
|
|
763
|
+
"end_line": location.get("end_line", -1),
|
|
764
|
+
},
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def suggest_keys(db: Dict[str, Any], query: str, k: int = 5) -> List[str]:
|
|
769
|
+
q = query.lower()
|
|
770
|
+
# simple scoring: substring + shared suffix parts
|
|
771
|
+
scored: List[Tuple[int, str]] = []
|
|
772
|
+
for key in db.keys():
|
|
773
|
+
kl = key.lower()
|
|
774
|
+
score = 0
|
|
775
|
+
if q in kl:
|
|
776
|
+
score += 10
|
|
777
|
+
# bonus for matching last segment(s)
|
|
778
|
+
q_parts = q.split(".")
|
|
779
|
+
k_parts = kl.split(".")
|
|
780
|
+
common_suffix = 0
|
|
781
|
+
while common_suffix < min(len(q_parts), len(k_parts)):
|
|
782
|
+
if q_parts[-1 - common_suffix] == k_parts[-1 - common_suffix]:
|
|
783
|
+
common_suffix += 1
|
|
784
|
+
else:
|
|
785
|
+
break
|
|
786
|
+
score += common_suffix * 3
|
|
787
|
+
if score > 0:
|
|
788
|
+
scored.append((score, key))
|
|
789
|
+
scored.sort(reverse=True, key=lambda x: x[0])
|
|
790
|
+
return [s[1] for s in scored[:k]]
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def cmd_explain(args) -> int:
|
|
794
|
+
db = load_explain_db(args.repo)
|
|
795
|
+
fqn = args.fqn
|
|
796
|
+
|
|
797
|
+
if fqn not in db:
|
|
798
|
+
print(f"\n❌ Not found: {fqn}\n")
|
|
799
|
+
suggestions = suggest_keys(db, fqn, k=8)
|
|
800
|
+
if suggestions:
|
|
801
|
+
print("Did you mean:")
|
|
802
|
+
for s in suggestions:
|
|
803
|
+
print(f" - {s}")
|
|
804
|
+
else:
|
|
805
|
+
print("No similar symbols found. Try: python cli.py search <keyword>")
|
|
806
|
+
print()
|
|
807
|
+
return 1
|
|
808
|
+
|
|
809
|
+
item = db[fqn]
|
|
810
|
+
|
|
811
|
+
print("\n" + "=" * 80)
|
|
812
|
+
print(f"{item.get('fqn', fqn)}")
|
|
813
|
+
print("-" * 80)
|
|
814
|
+
print(item.get("one_liner", ""))
|
|
815
|
+
print()
|
|
816
|
+
|
|
817
|
+
details = item.get("details", [])
|
|
818
|
+
if details:
|
|
819
|
+
for d in details:
|
|
820
|
+
print(f"- {d}")
|
|
821
|
+
|
|
822
|
+
tags = item.get("tags", [])
|
|
823
|
+
if tags:
|
|
824
|
+
print("\nTags: " + ", ".join(tags))
|
|
825
|
+
|
|
826
|
+
print("=" * 80 + "\n")
|
|
827
|
+
if args.repo:
|
|
828
|
+
_touch_repo_access_by_dir(resolve_repo_paths(args.repo)["repo_dir"])
|
|
829
|
+
return 0
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def cmd_search(args) -> int:
|
|
833
|
+
db = load_explain_db(args.repo)
|
|
834
|
+
q = args.query.lower()
|
|
835
|
+
|
|
836
|
+
matches = [k for k in db.keys() if q in k.lower()]
|
|
837
|
+
matches.sort()
|
|
838
|
+
|
|
839
|
+
limit = args.limit
|
|
840
|
+
print(f"\nFound {len(matches)} matches for '{args.query}':\n")
|
|
841
|
+
for k in matches[:limit]:
|
|
842
|
+
print(f"- {k}")
|
|
843
|
+
|
|
844
|
+
if len(matches) > limit:
|
|
845
|
+
print(f"\n...and {len(matches) - limit} more. Use --limit to increase.")
|
|
846
|
+
print()
|
|
847
|
+
if args.repo:
|
|
848
|
+
_touch_repo_access_by_dir(resolve_repo_paths(args.repo)["repo_dir"])
|
|
849
|
+
return 0
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def cmd_list(args) -> int:
|
|
853
|
+
db = load_explain_db(args.repo)
|
|
854
|
+
keys = sorted(db.keys())
|
|
855
|
+
|
|
856
|
+
if args.module:
|
|
857
|
+
prefix = args.module.strip()
|
|
858
|
+
keys = [k for k in keys if k.startswith(prefix)]
|
|
859
|
+
|
|
860
|
+
limit = args.limit
|
|
861
|
+
print(f"\nListing {min(len(keys), limit)} of {len(keys)} symbols:\n")
|
|
862
|
+
for k in keys[:limit]:
|
|
863
|
+
print(f"- {k}")
|
|
864
|
+
|
|
865
|
+
if len(keys) > limit:
|
|
866
|
+
print(f"\n...and {len(keys) - limit} more. Use --limit to increase.")
|
|
867
|
+
print()
|
|
868
|
+
if args.repo:
|
|
869
|
+
_touch_repo_access_by_dir(resolve_repo_paths(args.repo)["repo_dir"])
|
|
870
|
+
return 0
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def api_explain(args) -> int:
|
|
874
|
+
paths = resolve_repo_paths(args.repo)
|
|
875
|
+
if args.repo and not os.path.exists(paths["explain_path"]):
|
|
876
|
+
print_json({
|
|
877
|
+
"ok": False,
|
|
878
|
+
"error": "MISSING_ANALYSIS",
|
|
879
|
+
"message": MISSING_ANALYSIS_MESSAGE,
|
|
880
|
+
})
|
|
881
|
+
return 1
|
|
882
|
+
|
|
883
|
+
db = load_explain_db(args.repo)
|
|
884
|
+
fqn = args.fqn
|
|
885
|
+
|
|
886
|
+
if fqn not in db:
|
|
887
|
+
print_json({
|
|
888
|
+
"ok": False,
|
|
889
|
+
"error": "NOT_FOUND",
|
|
890
|
+
"fqn": fqn,
|
|
891
|
+
})
|
|
892
|
+
return 1
|
|
893
|
+
|
|
894
|
+
print_json({
|
|
895
|
+
"ok": True,
|
|
896
|
+
"result": _symbol_payload(db[fqn], fqn)
|
|
897
|
+
})
|
|
898
|
+
if args.repo:
|
|
899
|
+
_touch_repo_access_by_dir(paths["repo_dir"])
|
|
900
|
+
return 0
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def api_search(args) -> int:
|
|
904
|
+
paths = resolve_repo_paths(args.repo)
|
|
905
|
+
if args.repo and not os.path.exists(paths["explain_path"]):
|
|
906
|
+
print_json({
|
|
907
|
+
"ok": False,
|
|
908
|
+
"error": "MISSING_ANALYSIS",
|
|
909
|
+
"message": MISSING_ANALYSIS_MESSAGE,
|
|
910
|
+
})
|
|
911
|
+
return 1
|
|
912
|
+
|
|
913
|
+
db = load_explain_db(args.repo)
|
|
914
|
+
q = args.query.lower()
|
|
915
|
+
matches = [k for k in db.keys() if q in k.lower()]
|
|
916
|
+
matches.sort()
|
|
917
|
+
results = matches[:args.limit]
|
|
918
|
+
print_json({
|
|
919
|
+
"ok": True,
|
|
920
|
+
"query": args.query,
|
|
921
|
+
"count": len(matches),
|
|
922
|
+
"results": results,
|
|
923
|
+
"truncated": len(matches) > args.limit
|
|
924
|
+
})
|
|
925
|
+
if args.repo:
|
|
926
|
+
_touch_repo_access_by_dir(paths["repo_dir"])
|
|
927
|
+
return 0
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def api_list(args) -> int:
|
|
931
|
+
paths = resolve_repo_paths(args.repo)
|
|
932
|
+
if args.repo and not os.path.exists(paths["explain_path"]):
|
|
933
|
+
print_json({
|
|
934
|
+
"ok": False,
|
|
935
|
+
"error": "MISSING_ANALYSIS",
|
|
936
|
+
"message": MISSING_ANALYSIS_MESSAGE,
|
|
937
|
+
})
|
|
938
|
+
return 1
|
|
939
|
+
|
|
940
|
+
db = load_explain_db(args.repo)
|
|
941
|
+
keys = sorted(db.keys())
|
|
942
|
+
|
|
943
|
+
if args.module:
|
|
944
|
+
prefix = args.module.strip()
|
|
945
|
+
keys = [k for k in keys if k.startswith(prefix)]
|
|
946
|
+
|
|
947
|
+
results = keys[:args.limit]
|
|
948
|
+
print_json({
|
|
949
|
+
"ok": True,
|
|
950
|
+
"module": args.module,
|
|
951
|
+
"count": len(keys),
|
|
952
|
+
"results": results,
|
|
953
|
+
"truncated": len(keys) > args.limit
|
|
954
|
+
})
|
|
955
|
+
if args.repo:
|
|
956
|
+
_touch_repo_access_by_dir(paths["repo_dir"])
|
|
957
|
+
return 0
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def api_status(args) -> int:
|
|
961
|
+
path = resolve_repo_paths(args.repo)["explain_path"]
|
|
962
|
+
if not os.path.exists(path):
|
|
963
|
+
print_json({
|
|
964
|
+
"ok": False,
|
|
965
|
+
"error": "EXPLAIN_JSON_MISSING",
|
|
966
|
+
"path": path
|
|
967
|
+
})
|
|
968
|
+
return 1
|
|
969
|
+
|
|
970
|
+
# lightweight stats (no full load needed, but we can load safely)
|
|
971
|
+
db = load_explain_db(args.repo)
|
|
972
|
+
print_json({
|
|
973
|
+
"ok": True,
|
|
974
|
+
"path": path,
|
|
975
|
+
"symbols": len(db)
|
|
976
|
+
})
|
|
977
|
+
if args.repo:
|
|
978
|
+
_touch_repo_access_by_dir(resolve_repo_paths(args.repo)["repo_dir"])
|
|
979
|
+
return 0
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def api_repo_summary(args) -> int:
|
|
983
|
+
from analysis.explain.repo_summary_generator import generate_repo_summary
|
|
984
|
+
from analysis.utils.cache_manager import compute_repo_hash
|
|
985
|
+
|
|
986
|
+
paths = resolve_repo_paths(args.repo)
|
|
987
|
+
repo_dir = paths["repo_dir"]
|
|
988
|
+
cache_dir = paths["cache_dir"]
|
|
989
|
+
|
|
990
|
+
architecture_metrics_path = os.path.join(cache_dir, "architecture_metrics.json")
|
|
991
|
+
dependency_cycles_path = os.path.join(cache_dir, "dependency_cycles.json")
|
|
992
|
+
if not os.path.exists(architecture_metrics_path) or not os.path.exists(dependency_cycles_path):
|
|
993
|
+
print_json({
|
|
994
|
+
"ok": False,
|
|
995
|
+
"repo": os.path.basename(os.path.abspath(repo_dir).rstrip("\\/")),
|
|
996
|
+
"repo_hash": compute_repo_hash(repo_dir),
|
|
997
|
+
"cached": False,
|
|
998
|
+
"summary": {},
|
|
999
|
+
"error": "Missing architecture cache. Run: python cli.py api analyze --path <repo>",
|
|
1000
|
+
})
|
|
1001
|
+
return 1
|
|
1002
|
+
|
|
1003
|
+
result = generate_repo_summary(repo_cache_dir=cache_dir)
|
|
1004
|
+
final = {
|
|
1005
|
+
"ok": bool(result.get("ok")),
|
|
1006
|
+
"repo": os.path.basename(os.path.abspath(repo_dir).rstrip("\\/")),
|
|
1007
|
+
"repo_hash": compute_repo_hash(repo_dir),
|
|
1008
|
+
"cached": bool(result.get("cached", False)),
|
|
1009
|
+
"summary": result.get("summary", {}),
|
|
1010
|
+
"error": result.get("error"),
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
repo_summary_path = os.path.join(cache_dir, "repo_summary.json")
|
|
1014
|
+
with open(repo_summary_path, "w", encoding="utf-8") as f:
|
|
1015
|
+
json.dump(final, f, indent=2)
|
|
1016
|
+
|
|
1017
|
+
print_json(final)
|
|
1018
|
+
_touch_repo_access_by_dir(repo_dir)
|
|
1019
|
+
return 0 if final["ok"] else 1
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def api_risk_radar(args) -> int:
|
|
1023
|
+
from analysis.architecture.risk_radar import compute_risk_radar
|
|
1024
|
+
from analysis.utils.cache_manager import compute_repo_hash
|
|
1025
|
+
|
|
1026
|
+
paths = resolve_repo_paths(args.repo)
|
|
1027
|
+
repo_dir = paths["repo_dir"]
|
|
1028
|
+
cache_dir = paths["cache_dir"]
|
|
1029
|
+
|
|
1030
|
+
architecture_metrics_path = os.path.join(cache_dir, "architecture_metrics.json")
|
|
1031
|
+
dependency_cycles_path = os.path.join(cache_dir, "dependency_cycles.json")
|
|
1032
|
+
analysis_metrics_path = os.path.join(cache_dir, "analysis_metrics.json")
|
|
1033
|
+
if (
|
|
1034
|
+
not os.path.exists(architecture_metrics_path)
|
|
1035
|
+
or not os.path.exists(dependency_cycles_path)
|
|
1036
|
+
or not os.path.exists(analysis_metrics_path)
|
|
1037
|
+
):
|
|
1038
|
+
print_json({
|
|
1039
|
+
"ok": False,
|
|
1040
|
+
"cached": False,
|
|
1041
|
+
"repo": os.path.basename(os.path.abspath(repo_dir).rstrip("\\/")),
|
|
1042
|
+
"repo_hash": compute_repo_hash(repo_dir),
|
|
1043
|
+
"error": "Run analyze first",
|
|
1044
|
+
})
|
|
1045
|
+
return 1
|
|
1046
|
+
|
|
1047
|
+
try:
|
|
1048
|
+
radar = compute_risk_radar(cache_dir=cache_dir, top_k=25)
|
|
1049
|
+
except Exception as e:
|
|
1050
|
+
print_json({"ok": False, "cached": False, "error": str(e)})
|
|
1051
|
+
return 1
|
|
1052
|
+
|
|
1053
|
+
risk_radar_path = os.path.join(cache_dir, "risk_radar.json")
|
|
1054
|
+
with open(risk_radar_path, "w", encoding="utf-8") as f:
|
|
1055
|
+
json.dump(radar, f, indent=2)
|
|
1056
|
+
|
|
1057
|
+
health = radar.get("repo_health", {})
|
|
1058
|
+
print_json({
|
|
1059
|
+
"ok": True,
|
|
1060
|
+
"cached": False,
|
|
1061
|
+
"risk_radar_path": risk_radar_path,
|
|
1062
|
+
"summary": {
|
|
1063
|
+
"hotspot_symbols": int(health.get("hotspot_symbols", 0)),
|
|
1064
|
+
"risky_files": int(health.get("risky_files", 0)),
|
|
1065
|
+
"dead_symbols": int(health.get("dead_symbols", 0)),
|
|
1066
|
+
"dependency_cycles": int(health.get("dependency_cycles", 0)),
|
|
1067
|
+
"unresolved_ratio": float(health.get("unresolved_ratio", 0.0)),
|
|
1068
|
+
},
|
|
1069
|
+
})
|
|
1070
|
+
_touch_repo_access_by_dir(repo_dir)
|
|
1071
|
+
return 0
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def api_impact(args) -> int:
|
|
1075
|
+
from analysis.graph.impact_analyzer import compute_impact
|
|
1076
|
+
|
|
1077
|
+
paths = resolve_repo_paths(args.repo)
|
|
1078
|
+
cache_dir = paths["cache_dir"]
|
|
1079
|
+
|
|
1080
|
+
resolved_calls_path = os.path.join(cache_dir, "resolved_calls.json")
|
|
1081
|
+
architecture_metrics_path = os.path.join(cache_dir, "architecture_metrics.json")
|
|
1082
|
+
if not os.path.exists(resolved_calls_path) or not os.path.exists(architecture_metrics_path):
|
|
1083
|
+
print_json({
|
|
1084
|
+
"ok": False,
|
|
1085
|
+
"error": "MISSING_ANALYSIS",
|
|
1086
|
+
"message": MISSING_ANALYSIS_MESSAGE,
|
|
1087
|
+
})
|
|
1088
|
+
return 1
|
|
1089
|
+
|
|
1090
|
+
try:
|
|
1091
|
+
payload = compute_impact(
|
|
1092
|
+
cache_dir=cache_dir,
|
|
1093
|
+
target=args.target,
|
|
1094
|
+
depth=args.depth,
|
|
1095
|
+
max_nodes=args.max_nodes,
|
|
1096
|
+
)
|
|
1097
|
+
except Exception as e:
|
|
1098
|
+
print_json({"ok": False, "error": "IMPACT_FAILED", "message": str(e)})
|
|
1099
|
+
return 1
|
|
1100
|
+
|
|
1101
|
+
print_json(payload)
|
|
1102
|
+
_touch_repo_access_by_dir(paths["repo_dir"])
|
|
1103
|
+
return 0
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def api_analyze(args) -> int:
|
|
1107
|
+
from analysis.runners.phase4_runner import run as run_phase4
|
|
1108
|
+
from analysis.explain.explain_runner import run as run_explain
|
|
1109
|
+
from analysis.graph.callgraph_index import CallGraphIndex, CallSite, write_hub_metrics_from_resolved_calls
|
|
1110
|
+
from analysis.indexing.symbol_index import SymbolIndex
|
|
1111
|
+
from analysis.architecture.architecture_engine import compute_architecture_metrics
|
|
1112
|
+
from analysis.architecture.dependency_cycles import compute_dependency_cycle_metrics
|
|
1113
|
+
from analysis.architecture.risk_radar import compute_risk_radar
|
|
1114
|
+
from analysis.utils.repo_fetcher import fetch_public_repo, fetch_public_repo_zip
|
|
1115
|
+
from analysis.utils.cache_manager import (
|
|
1116
|
+
build_manifest,
|
|
1117
|
+
collect_fingerprints,
|
|
1118
|
+
compute_repo_hash,
|
|
1119
|
+
diff_fingerprints,
|
|
1120
|
+
get_cache_dir,
|
|
1121
|
+
load_manifest,
|
|
1122
|
+
upsert_metadata,
|
|
1123
|
+
save_manifest,
|
|
1124
|
+
set_retention,
|
|
1125
|
+
should_rebuild,
|
|
1126
|
+
touch_last_accessed,
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
path_arg = getattr(args, "path", None)
|
|
1130
|
+
github_arg = getattr(args, "github", None)
|
|
1131
|
+
ref_arg = getattr(args, "ref", None)
|
|
1132
|
+
mode_arg = str(getattr(args, "mode", "git") or "git").strip().lower()
|
|
1133
|
+
retention_mode = str(getattr(args, "retention", "ttl") or "ttl").strip().lower()
|
|
1134
|
+
ttl_days_input = getattr(args, "ttl_days", None)
|
|
1135
|
+
ttl_days_arg = int(ttl_days_input) if ttl_days_input is not None else 14
|
|
1136
|
+
refresh_flag = bool(getattr(args, "refresh", False))
|
|
1137
|
+
rebuild_flag = bool(getattr(args, "rebuild", False))
|
|
1138
|
+
clear_cache_flag = bool(getattr(args, "clear_cache", False))
|
|
1139
|
+
force_full_rebuild = bool(rebuild_flag or refresh_flag or retention_mode == "session_only")
|
|
1140
|
+
path_value = str(path_arg).strip() if path_arg is not None else ""
|
|
1141
|
+
github_value = str(github_arg).strip() if github_arg is not None else ""
|
|
1142
|
+
|
|
1143
|
+
if path_value and github_value:
|
|
1144
|
+
print_json({
|
|
1145
|
+
"ok": False,
|
|
1146
|
+
"error": "INVALID_ARGS",
|
|
1147
|
+
"message": "Use either --path <dir> or --github <url>, not both.",
|
|
1148
|
+
})
|
|
1149
|
+
return 1
|
|
1150
|
+
if refresh_flag and not github_value:
|
|
1151
|
+
print_json({
|
|
1152
|
+
"ok": False,
|
|
1153
|
+
"error": "INVALID_ARGS",
|
|
1154
|
+
"message": "--refresh is supported only with --github.",
|
|
1155
|
+
})
|
|
1156
|
+
return 1
|
|
1157
|
+
if mode_arg not in {"git", "zip"}:
|
|
1158
|
+
print_json({
|
|
1159
|
+
"ok": False,
|
|
1160
|
+
"error": "INVALID_ARGS",
|
|
1161
|
+
"message": "--mode must be one of: git, zip",
|
|
1162
|
+
})
|
|
1163
|
+
return 1
|
|
1164
|
+
if mode_arg == "zip" and not github_value:
|
|
1165
|
+
print_json({
|
|
1166
|
+
"ok": False,
|
|
1167
|
+
"error": "INVALID_ARGS",
|
|
1168
|
+
"message": "--mode zip requires --github.",
|
|
1169
|
+
})
|
|
1170
|
+
return 1
|
|
1171
|
+
if retention_mode not in {"ttl", "session_only", "pinned"}:
|
|
1172
|
+
print_json({
|
|
1173
|
+
"ok": False,
|
|
1174
|
+
"error": "INVALID_ARGS",
|
|
1175
|
+
"message": "--retention must be one of: ttl, session_only, pinned",
|
|
1176
|
+
})
|
|
1177
|
+
return 1
|
|
1178
|
+
if ttl_days_arg < 0:
|
|
1179
|
+
print_json({
|
|
1180
|
+
"ok": False,
|
|
1181
|
+
"error": "INVALID_ARGS",
|
|
1182
|
+
"message": "--ttl-days must be >= 0",
|
|
1183
|
+
})
|
|
1184
|
+
return 1
|
|
1185
|
+
|
|
1186
|
+
source = "filesystem"
|
|
1187
|
+
mode = "filesystem"
|
|
1188
|
+
auth = "none"
|
|
1189
|
+
repo_url = None
|
|
1190
|
+
workspace_dir = None
|
|
1191
|
+
fetched = None
|
|
1192
|
+
refreshed = False
|
|
1193
|
+
resolved_ref = None
|
|
1194
|
+
cache_cleared = False
|
|
1195
|
+
downloaded = None
|
|
1196
|
+
zip_url = None
|
|
1197
|
+
token_value: Optional[str] = None
|
|
1198
|
+
private_repo_mode = False
|
|
1199
|
+
|
|
1200
|
+
if github_value:
|
|
1201
|
+
source = "github"
|
|
1202
|
+
token_value, auth = _resolve_runtime_github_token(args)
|
|
1203
|
+
private_repo_mode = bool(token_value)
|
|
1204
|
+
mode = mode_arg
|
|
1205
|
+
if mode_arg == "zip":
|
|
1206
|
+
fetch_result = fetch_public_repo_zip(
|
|
1207
|
+
github_value,
|
|
1208
|
+
ref=str(ref_arg or ""),
|
|
1209
|
+
refresh=refresh_flag,
|
|
1210
|
+
token=token_value,
|
|
1211
|
+
auth=auth,
|
|
1212
|
+
)
|
|
1213
|
+
else:
|
|
1214
|
+
fetch_result = fetch_public_repo(
|
|
1215
|
+
github_value,
|
|
1216
|
+
ref=ref_arg,
|
|
1217
|
+
refresh=refresh_flag,
|
|
1218
|
+
token=token_value,
|
|
1219
|
+
auth=auth,
|
|
1220
|
+
)
|
|
1221
|
+
if not fetch_result.get("ok"):
|
|
1222
|
+
err_code = fetch_result.get("error_code")
|
|
1223
|
+
print_json({
|
|
1224
|
+
"ok": False,
|
|
1225
|
+
"error": err_code or "GITHUB_FETCH_FAILED",
|
|
1226
|
+
"message": redact_secrets(fetch_result.get("error", "Failed to fetch GitHub repository"), extra_secrets=[token_value] if token_value else None),
|
|
1227
|
+
"source": source,
|
|
1228
|
+
"mode": mode,
|
|
1229
|
+
"auth": auth,
|
|
1230
|
+
"repo_url": github_value,
|
|
1231
|
+
"ref": ref_arg,
|
|
1232
|
+
})
|
|
1233
|
+
return 1
|
|
1234
|
+
repo_dir = fetch_result["repo_dir"]
|
|
1235
|
+
repo_url = fetch_result.get("normalized_url")
|
|
1236
|
+
workspace_dir = fetch_result.get("workspace_dir")
|
|
1237
|
+
fetched = bool(fetch_result.get("fetched"))
|
|
1238
|
+
refreshed = bool(fetch_result.get("refreshed", False))
|
|
1239
|
+
resolved_ref = fetch_result.get("ref")
|
|
1240
|
+
downloaded = fetch_result.get("downloaded")
|
|
1241
|
+
zip_url = fetch_result.get("zip_url")
|
|
1242
|
+
token_value = None
|
|
1243
|
+
else:
|
|
1244
|
+
mode = "filesystem"
|
|
1245
|
+
repo_dir_input = path_value or "."
|
|
1246
|
+
repo_dir = resolve_repo_paths(repo_dir_input)["repo_dir"]
|
|
1247
|
+
|
|
1248
|
+
if retention_mode == "pinned":
|
|
1249
|
+
retention_days_effective = 0
|
|
1250
|
+
elif retention_mode == "session_only":
|
|
1251
|
+
retention_days_effective = 1
|
|
1252
|
+
else:
|
|
1253
|
+
retention_days_effective = 7 if (ttl_days_input is None and private_repo_mode) else ttl_days_arg
|
|
1254
|
+
if retention_days_effective < 0:
|
|
1255
|
+
retention_days_effective = 0
|
|
1256
|
+
|
|
1257
|
+
cache_dir = get_cache_dir(repo_dir)
|
|
1258
|
+
if clear_cache_flag:
|
|
1259
|
+
cache_cleared = _safe_delete_dir(cache_dir, _global_cache_root())
|
|
1260
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
1261
|
+
|
|
1262
|
+
resolved_calls_path = os.path.join(cache_dir, "resolved_calls.json")
|
|
1263
|
+
explain_path = os.path.join(cache_dir, "explain.json")
|
|
1264
|
+
analysis_metrics_path = os.path.join(cache_dir, "analysis_metrics.json")
|
|
1265
|
+
architecture_metrics_path = os.path.join(cache_dir, "architecture_metrics.json")
|
|
1266
|
+
dependency_cycles_path = os.path.join(cache_dir, "dependency_cycles.json")
|
|
1267
|
+
risk_radar_path = os.path.join(cache_dir, "risk_radar.json")
|
|
1268
|
+
llm_cache_path = os.path.join(cache_dir, "llm_cache.json")
|
|
1269
|
+
project_tree_path = os.path.join(cache_dir, "project_tree.json")
|
|
1270
|
+
|
|
1271
|
+
previous_manifest = load_manifest(repo_dir)
|
|
1272
|
+
previous_fingerprints = previous_manifest.get("fingerprints", {})
|
|
1273
|
+
current_fingerprints = collect_fingerprints(repo_dir)
|
|
1274
|
+
delta = diff_fingerprints(previous_fingerprints, current_fingerprints)
|
|
1275
|
+
version_mismatch = previous_manifest.get("analysis_version") != ANALYSIS_VERSION
|
|
1276
|
+
|
|
1277
|
+
rebuild_required = bool(force_full_rebuild or should_rebuild(repo_dir, analysis_version=ANALYSIS_VERSION))
|
|
1278
|
+
architecture_missing = not os.path.exists(architecture_metrics_path)
|
|
1279
|
+
dependency_missing = not os.path.exists(dependency_cycles_path)
|
|
1280
|
+
risk_missing = not os.path.exists(risk_radar_path)
|
|
1281
|
+
r1 = {}
|
|
1282
|
+
r2 = {}
|
|
1283
|
+
metrics = {}
|
|
1284
|
+
|
|
1285
|
+
try:
|
|
1286
|
+
if rebuild_required:
|
|
1287
|
+
r1 = run_phase4(
|
|
1288
|
+
repo_dir=repo_dir,
|
|
1289
|
+
output_dir=cache_dir,
|
|
1290
|
+
force_rebuild=bool(version_mismatch or force_full_rebuild),
|
|
1291
|
+
)
|
|
1292
|
+
r2 = run_explain(repo_dir=repo_dir, output_dir=cache_dir)
|
|
1293
|
+
resolved_calls_path = r1.get("resolved_calls_path", resolved_calls_path)
|
|
1294
|
+
metrics = write_hub_metrics_from_resolved_calls(
|
|
1295
|
+
resolved_calls_path=resolved_calls_path,
|
|
1296
|
+
output_path=analysis_metrics_path,
|
|
1297
|
+
)
|
|
1298
|
+
save_manifest(
|
|
1299
|
+
repo_dir,
|
|
1300
|
+
build_manifest(
|
|
1301
|
+
repo_dir,
|
|
1302
|
+
current_fingerprints,
|
|
1303
|
+
metadata={
|
|
1304
|
+
"analysis_version": ANALYSIS_VERSION,
|
|
1305
|
+
"retention": {
|
|
1306
|
+
"mode": retention_mode,
|
|
1307
|
+
"ttl_days": int(retention_days_effective),
|
|
1308
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
1309
|
+
"last_accessed_at": datetime.now(timezone.utc).isoformat(),
|
|
1310
|
+
},
|
|
1311
|
+
"symbol_snapshot": r1.get("symbol_snapshot", []),
|
|
1312
|
+
"imports_snapshot": r1.get("imports_snapshot", {}),
|
|
1313
|
+
"file_module_map": r1.get("file_module_map", {}),
|
|
1314
|
+
"metrics_summary": {
|
|
1315
|
+
"critical_apis": len(metrics.get("critical_apis", [])),
|
|
1316
|
+
"orchestrators": len(metrics.get("orchestrators", [])),
|
|
1317
|
+
},
|
|
1318
|
+
},
|
|
1319
|
+
),
|
|
1320
|
+
)
|
|
1321
|
+
tree_snapshot = _build_project_tree_snapshot(repo_dir)
|
|
1322
|
+
with open(project_tree_path, "w", encoding="utf-8") as f:
|
|
1323
|
+
json.dump(tree_snapshot, f, indent=2)
|
|
1324
|
+
elif os.path.exists(resolved_calls_path):
|
|
1325
|
+
metrics = write_hub_metrics_from_resolved_calls(
|
|
1326
|
+
resolved_calls_path=resolved_calls_path,
|
|
1327
|
+
output_path=analysis_metrics_path,
|
|
1328
|
+
)
|
|
1329
|
+
if not os.path.exists(project_tree_path):
|
|
1330
|
+
tree_snapshot = _build_project_tree_snapshot(repo_dir)
|
|
1331
|
+
with open(project_tree_path, "w", encoding="utf-8") as f:
|
|
1332
|
+
json.dump(tree_snapshot, f, indent=2)
|
|
1333
|
+
|
|
1334
|
+
# Derived architecture outputs:
|
|
1335
|
+
# - regenerate on rebuild
|
|
1336
|
+
# - regenerate if architecture/dependency artifacts are missing
|
|
1337
|
+
if rebuild_required or architecture_missing or dependency_missing:
|
|
1338
|
+
with open(resolved_calls_path, "r", encoding="utf-8") as f:
|
|
1339
|
+
resolved_calls = json.load(f)
|
|
1340
|
+
|
|
1341
|
+
callgraph = CallGraphIndex()
|
|
1342
|
+
for c in resolved_calls:
|
|
1343
|
+
caller_fqn = c.get("caller_fqn")
|
|
1344
|
+
if not caller_fqn:
|
|
1345
|
+
continue
|
|
1346
|
+
callgraph.add_call(
|
|
1347
|
+
CallSite(
|
|
1348
|
+
caller_fqn=caller_fqn,
|
|
1349
|
+
callee_fqn=c.get("callee_fqn"),
|
|
1350
|
+
callee_name=c.get("callee", "<unknown>"),
|
|
1351
|
+
file=c.get("file", ""),
|
|
1352
|
+
line=int(c.get("line", -1)),
|
|
1353
|
+
)
|
|
1354
|
+
)
|
|
1355
|
+
|
|
1356
|
+
symbol_index = SymbolIndex()
|
|
1357
|
+
symbol_snapshot = r1.get("symbol_snapshot") or previous_manifest.get("symbol_snapshot", [])
|
|
1358
|
+
if symbol_snapshot:
|
|
1359
|
+
symbol_index.load_snapshot(symbol_snapshot)
|
|
1360
|
+
|
|
1361
|
+
repo_prefix = os.path.basename(os.path.abspath(repo_dir).rstrip("\\/"))
|
|
1362
|
+
arch_payload = compute_architecture_metrics(
|
|
1363
|
+
callgraph=callgraph,
|
|
1364
|
+
symbol_index=symbol_index,
|
|
1365
|
+
repo_prefix=repo_prefix,
|
|
1366
|
+
)
|
|
1367
|
+
dep_payload = compute_dependency_cycle_metrics(
|
|
1368
|
+
resolved_calls=resolved_calls,
|
|
1369
|
+
repo_prefix=repo_prefix,
|
|
1370
|
+
)
|
|
1371
|
+
|
|
1372
|
+
with open(architecture_metrics_path, "w", encoding="utf-8") as f:
|
|
1373
|
+
json.dump(arch_payload, f, indent=2)
|
|
1374
|
+
with open(dependency_cycles_path, "w", encoding="utf-8") as f:
|
|
1375
|
+
json.dump(dep_payload, f, indent=2)
|
|
1376
|
+
|
|
1377
|
+
# Risk radar derived output:
|
|
1378
|
+
# - regenerate on rebuild
|
|
1379
|
+
# - regenerate when missing (cached analyze run)
|
|
1380
|
+
# - regenerate when architecture/dependency were regenerated
|
|
1381
|
+
if rebuild_required or risk_missing or architecture_missing or dependency_missing:
|
|
1382
|
+
risk_payload = compute_risk_radar(cache_dir=cache_dir, top_k=25)
|
|
1383
|
+
with open(risk_radar_path, "w", encoding="utf-8") as f:
|
|
1384
|
+
json.dump(risk_payload, f, indent=2)
|
|
1385
|
+
repo_hash = compute_repo_hash(repo_dir)
|
|
1386
|
+
upsert_metadata(
|
|
1387
|
+
repo_hash=repo_hash,
|
|
1388
|
+
source=source,
|
|
1389
|
+
repo_path=os.path.abspath(repo_dir),
|
|
1390
|
+
repo_url=repo_url or "",
|
|
1391
|
+
ref=str(resolved_ref or ref_arg or ""),
|
|
1392
|
+
workspace_dir=workspace_dir or "",
|
|
1393
|
+
analysis_version=ANALYSIS_VERSION,
|
|
1394
|
+
private_mode=bool(private_repo_mode),
|
|
1395
|
+
)
|
|
1396
|
+
set_retention(
|
|
1397
|
+
repo_hash=repo_hash,
|
|
1398
|
+
days=int(retention_days_effective),
|
|
1399
|
+
)
|
|
1400
|
+
touch_last_accessed(repo_hash)
|
|
1401
|
+
except Exception as e:
|
|
1402
|
+
print_json({"ok": False, "error": "ANALYZE_FAILED", "message": redact_secrets(str(e))})
|
|
1403
|
+
return 1
|
|
1404
|
+
|
|
1405
|
+
print_json({
|
|
1406
|
+
"ok": True,
|
|
1407
|
+
"source": source,
|
|
1408
|
+
"mode": mode,
|
|
1409
|
+
"auth": auth,
|
|
1410
|
+
"private_repo_mode": private_repo_mode,
|
|
1411
|
+
"cached": not rebuild_required,
|
|
1412
|
+
"rebuilt": rebuild_flag,
|
|
1413
|
+
"cache_cleared": cache_cleared,
|
|
1414
|
+
"refreshed": refreshed,
|
|
1415
|
+
"changed_files": delta["changed_files"],
|
|
1416
|
+
"incremental": False if version_mismatch else r1.get("incremental", False),
|
|
1417
|
+
"reindexed_files": r1.get("reindexed_files", 0),
|
|
1418
|
+
"impacted_files": r1.get("impacted_files", 0),
|
|
1419
|
+
"analysis_version": ANALYSIS_VERSION,
|
|
1420
|
+
"version_mismatch_rebuild": bool(version_mismatch and rebuild_required),
|
|
1421
|
+
"cache_dir": cache_dir,
|
|
1422
|
+
"resolved_calls_path": r1.get("resolved_calls_path", resolved_calls_path),
|
|
1423
|
+
"explain_path": r2.get("explain_path", explain_path),
|
|
1424
|
+
"analysis_metrics_path": analysis_metrics_path,
|
|
1425
|
+
"architecture_metrics_path": architecture_metrics_path,
|
|
1426
|
+
"dependency_cycles_path": dependency_cycles_path,
|
|
1427
|
+
"risk_radar_path": risk_radar_path,
|
|
1428
|
+
"llm_cache_path": llm_cache_path,
|
|
1429
|
+
"project_tree_path": project_tree_path,
|
|
1430
|
+
"critical_apis": len(metrics.get("critical_apis", [])),
|
|
1431
|
+
"orchestrators": len(metrics.get("orchestrators", [])),
|
|
1432
|
+
"repo_url": repo_url,
|
|
1433
|
+
"ref": resolved_ref,
|
|
1434
|
+
"workspace_dir": workspace_dir,
|
|
1435
|
+
"fetched": fetched,
|
|
1436
|
+
"downloaded": downloaded,
|
|
1437
|
+
"zip_url": zip_url,
|
|
1438
|
+
"repo_dir": repo_dir,
|
|
1439
|
+
"retention": {
|
|
1440
|
+
"mode": retention_mode,
|
|
1441
|
+
"ttl_days": int(retention_days_effective),
|
|
1442
|
+
},
|
|
1443
|
+
})
|
|
1444
|
+
return 0
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def cmd_ui(args) -> int:
|
|
1448
|
+
host = str(getattr(args, "host", "127.0.0.1") or "127.0.0.1").strip() or "127.0.0.1"
|
|
1449
|
+
port = int(getattr(args, "port", 8000) or 8000)
|
|
1450
|
+
reload_flag = bool(getattr(args, "reload", False))
|
|
1451
|
+
|
|
1452
|
+
try:
|
|
1453
|
+
import uvicorn
|
|
1454
|
+
except Exception as e:
|
|
1455
|
+
sys.stderr.write(f"Failed to import uvicorn: {e}\n")
|
|
1456
|
+
return 1
|
|
1457
|
+
|
|
1458
|
+
sys.stdout.write(f"Starting CodeMap UI at http://{host}:{port} (local-only)\n")
|
|
1459
|
+
sys.stdout.flush()
|
|
1460
|
+
try:
|
|
1461
|
+
uvicorn.run("ui.app:app", host=host, port=port, reload=reload_flag)
|
|
1462
|
+
except KeyboardInterrupt:
|
|
1463
|
+
return 130
|
|
1464
|
+
return 0
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def cmd_dashboard(args) -> int:
|
|
1468
|
+
return cmd_ui(args)
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def cmd_open(args) -> int:
|
|
1472
|
+
host = str(getattr(args, "host", "127.0.0.1") or "127.0.0.1").strip() or "127.0.0.1"
|
|
1473
|
+
port = int(getattr(args, "port", 8000) or 8000)
|
|
1474
|
+
url = f"http://{host}:{port}"
|
|
1475
|
+
try:
|
|
1476
|
+
ok = webbrowser.open(url)
|
|
1477
|
+
except Exception as e:
|
|
1478
|
+
sys.stderr.write(f"Failed to open browser: {e}\n")
|
|
1479
|
+
return 1
|
|
1480
|
+
if not ok:
|
|
1481
|
+
sys.stderr.write(f"Failed to open browser automatically. Open {url} manually.\n")
|
|
1482
|
+
return 1
|
|
1483
|
+
sys.stdout.write(f"Opened {url}\n")
|
|
1484
|
+
return 0
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
def cmd_analyze(args) -> int:
|
|
1488
|
+
return api_analyze(args)
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1494
|
+
parser = argparse.ArgumentParser(
|
|
1495
|
+
prog="codemap",
|
|
1496
|
+
description="CodeMap CLI: query cached architecture intelligence"
|
|
1497
|
+
)
|
|
1498
|
+
|
|
1499
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1500
|
+
|
|
1501
|
+
p_analyze = sub.add_parser("analyze", help="Analyze filesystem or GitHub repository")
|
|
1502
|
+
p_analyze.add_argument("--path", default=None, help="Repository directory to analyze")
|
|
1503
|
+
p_analyze.add_argument("--github", default=None, help="Public GitHub repository URL (https://github.com/<org>/<repo>)")
|
|
1504
|
+
p_analyze.add_argument("--ref", default=None, help="Optional Git branch or tag when using --github")
|
|
1505
|
+
p_analyze.add_argument("--mode", default="git", choices=["git", "zip"], help="GitHub fetch mode")
|
|
1506
|
+
p_analyze.add_argument("--token", default=None, help="GitHub personal access token (optional)")
|
|
1507
|
+
p_analyze.add_argument("--token-stdin", action="store_true", help="Read GitHub token from stdin")
|
|
1508
|
+
p_analyze.add_argument("--refresh", action="store_true", help="GitHub only: delete workspace clone and fetch again")
|
|
1509
|
+
p_analyze.add_argument("--rebuild", action="store_true", help="Force full analysis rebuild even if cache is valid")
|
|
1510
|
+
p_analyze.add_argument("--clear-cache", action="store_true", help="Delete analysis cache directory before analyze")
|
|
1511
|
+
p_analyze.add_argument("--retention", default="ttl", choices=["ttl", "session_only", "pinned"], help="Retention policy mode")
|
|
1512
|
+
p_analyze.add_argument("--ttl-days", type=int, default=None, help="TTL in days when retention mode is ttl (0 = never)")
|
|
1513
|
+
p_analyze.set_defaults(func=cmd_analyze)
|
|
1514
|
+
|
|
1515
|
+
p_dashboard = sub.add_parser("dashboard", help="Run local CodeMap dashboard server")
|
|
1516
|
+
p_dashboard.add_argument("--host", default="127.0.0.1", help="Host to bind (default: 127.0.0.1)")
|
|
1517
|
+
p_dashboard.add_argument("--port", type=int, default=8000, help="Port to bind (default: 8000)")
|
|
1518
|
+
p_dashboard.add_argument("--reload", action="store_true", help="Enable autoreload for development")
|
|
1519
|
+
p_dashboard.set_defaults(func=cmd_dashboard)
|
|
1520
|
+
|
|
1521
|
+
p_open = sub.add_parser("open", help="Open dashboard URL in browser")
|
|
1522
|
+
p_open.add_argument("--host", default="127.0.0.1", help="Host to open (default: 127.0.0.1)")
|
|
1523
|
+
p_open.add_argument("--port", type=int, default=8000, help="Port to open (default: 8000)")
|
|
1524
|
+
p_open.set_defaults(func=cmd_open)
|
|
1525
|
+
|
|
1526
|
+
p_cache = sub.add_parser("cache", help="Cache management")
|
|
1527
|
+
cache_pub_sub = p_cache.add_subparsers(dest="cache_command", required=True)
|
|
1528
|
+
p_cache_list = cache_pub_sub.add_parser("list", help="List cache directories")
|
|
1529
|
+
p_cache_list.set_defaults(func=api_cache_list)
|
|
1530
|
+
p_cache_info = cache_pub_sub.add_parser("info", help="Inspect one cache target")
|
|
1531
|
+
p_cache_info.add_argument("--path", default=None, help="Local repository path")
|
|
1532
|
+
p_cache_info.add_argument("--github", default=None, help="GitHub repository URL")
|
|
1533
|
+
p_cache_info.add_argument("--ref", default=None, help="GitHub ref")
|
|
1534
|
+
p_cache_info.add_argument("--mode", default="git", choices=["git", "zip"], help="GitHub mode")
|
|
1535
|
+
p_cache_info.set_defaults(func=api_cache_info)
|
|
1536
|
+
p_cache_clear = cache_pub_sub.add_parser("clear", help="Clear one cache target safely")
|
|
1537
|
+
p_cache_clear.add_argument("--all", action="store_true", help="Clear every known cache")
|
|
1538
|
+
p_cache_clear.add_argument("--repo_hash", "--repo-hash", default=None, help="Direct repo hash target")
|
|
1539
|
+
p_cache_clear.add_argument("--path", default=None, help="Local repository path")
|
|
1540
|
+
p_cache_clear.add_argument("--github", default=None, help="GitHub repository URL")
|
|
1541
|
+
p_cache_clear.add_argument("--ref", default=None, help="GitHub ref")
|
|
1542
|
+
p_cache_clear.add_argument("--mode", default="git", choices=["git", "zip"], help="GitHub mode")
|
|
1543
|
+
p_cache_clear.add_argument("--dry-run", action="store_true", help="Show what would be deleted")
|
|
1544
|
+
p_cache_clear.add_argument("--yes", action="store_true", help="Skip confirmation prompt")
|
|
1545
|
+
p_cache_clear.add_argument("--include-workspace", action="store_true", help="Also remove workspace even if shared")
|
|
1546
|
+
p_cache_clear.set_defaults(func=api_cache_clear)
|
|
1547
|
+
p_cache_retention = cache_pub_sub.add_parser("retention", help="Set per-repo retention in days")
|
|
1548
|
+
p_cache_retention.add_argument("--repo_hash", "--repo-hash", default=None, help="Direct repo hash target")
|
|
1549
|
+
p_cache_retention.add_argument("--path", default=None, help="Local repository path")
|
|
1550
|
+
p_cache_retention.add_argument("--github", default=None, help="GitHub repository URL")
|
|
1551
|
+
p_cache_retention.add_argument("--ref", default=None, help="GitHub ref")
|
|
1552
|
+
p_cache_retention.add_argument("--mode", default="git", choices=["git", "zip"], help="GitHub mode")
|
|
1553
|
+
p_cache_retention.add_argument("--days", type=int, required=True, help="Retention days (0 means never auto-delete)")
|
|
1554
|
+
p_cache_retention.add_argument("--yes", action="store_true", help="Confirm retention update")
|
|
1555
|
+
p_cache_retention.set_defaults(func=api_cache_retention)
|
|
1556
|
+
p_cache_sweep = cache_pub_sub.add_parser("sweep", help="Sweep expired caches by per-repo retention metadata")
|
|
1557
|
+
p_cache_sweep.add_argument("--dry-run", action="store_true", help="Show what would be deleted")
|
|
1558
|
+
p_cache_sweep.add_argument("--yes", action="store_true", help="Confirm deletion")
|
|
1559
|
+
p_cache_sweep.set_defaults(func=api_cache_sweep)
|
|
1560
|
+
|
|
1561
|
+
p_explain = sub.add_parser("explain", help=argparse.SUPPRESS)
|
|
1562
|
+
p_explain.add_argument("fqn", help="Fully-qualified symbol name (e.g. testing_repo.test.Student.display)")
|
|
1563
|
+
p_explain.add_argument("--repo", default=None, help="Repository directory to read repo-scoped cached explain.json")
|
|
1564
|
+
p_explain.set_defaults(func=cmd_explain)
|
|
1565
|
+
|
|
1566
|
+
p_search = sub.add_parser("search", help=argparse.SUPPRESS)
|
|
1567
|
+
p_search.add_argument("query", help="Search keyword (case-insensitive)")
|
|
1568
|
+
p_search.add_argument("--repo", default=None, help="Repository directory to read repo-scoped cached explain.json")
|
|
1569
|
+
p_search.add_argument("--limit", type=int, default=30, help="Max results to show")
|
|
1570
|
+
p_search.set_defaults(func=cmd_search)
|
|
1571
|
+
|
|
1572
|
+
p_list = sub.add_parser("list", help=argparse.SUPPRESS)
|
|
1573
|
+
p_list.add_argument("--module", default=None, help="Module prefix filter (e.g. testing_repo.test)")
|
|
1574
|
+
p_list.add_argument("--repo", default=None, help="Repository directory to read repo-scoped cached explain.json")
|
|
1575
|
+
p_list.add_argument("--limit", type=int, default=50, help="Max results to show")
|
|
1576
|
+
p_list.set_defaults(func=cmd_list)
|
|
1577
|
+
|
|
1578
|
+
p_ui = sub.add_parser("ui", help=argparse.SUPPRESS)
|
|
1579
|
+
p_ui.add_argument("--host", default="127.0.0.1", help="Host to bind (default: 127.0.0.1)")
|
|
1580
|
+
p_ui.add_argument("--port", type=int, default=8000, help="Port to bind (default: 8000)")
|
|
1581
|
+
p_ui.add_argument("--reload", action="store_true", help="Enable autoreload for development")
|
|
1582
|
+
p_ui.set_defaults(func=cmd_ui)
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
# -------------------------
|
|
1587
|
+
# API (JSON stdout) commands
|
|
1588
|
+
# -------------------------
|
|
1589
|
+
p_api = sub.add_parser("api", help=argparse.SUPPRESS)
|
|
1590
|
+
api_sub = p_api.add_subparsers(dest="api_command", required=True)
|
|
1591
|
+
|
|
1592
|
+
p_api_help = api_sub.add_parser("help", help="Show API command help (JSON)")
|
|
1593
|
+
p_api_help.set_defaults(func=api_cache_help)
|
|
1594
|
+
|
|
1595
|
+
p_api_explain = api_sub.add_parser("explain", help="Return JSON explanation for one symbol")
|
|
1596
|
+
p_api_explain.add_argument("fqn", help="Fully-qualified symbol name")
|
|
1597
|
+
p_api_explain.add_argument("--repo", default=None, help="Repository directory to read repo-scoped cached explain.json")
|
|
1598
|
+
p_api_explain.set_defaults(func=api_explain)
|
|
1599
|
+
|
|
1600
|
+
p_api_search = api_sub.add_parser("search", help="Search symbols by substring (JSON)")
|
|
1601
|
+
p_api_search.add_argument("query", help="Search keyword")
|
|
1602
|
+
p_api_search.add_argument("--repo", default=None, help="Repository directory to read repo-scoped cached explain.json")
|
|
1603
|
+
p_api_search.add_argument("--limit", type=int, default=50)
|
|
1604
|
+
p_api_search.set_defaults(func=api_search)
|
|
1605
|
+
|
|
1606
|
+
p_api_list = api_sub.add_parser("list", help="List all symbols (JSON)")
|
|
1607
|
+
p_api_list.add_argument("--module", default=None)
|
|
1608
|
+
p_api_list.add_argument("--repo", default=None, help="Repository directory to read repo-scoped cached explain.json")
|
|
1609
|
+
p_api_list.add_argument("--limit", type=int, default=200)
|
|
1610
|
+
p_api_list.set_defaults(func=api_list)
|
|
1611
|
+
|
|
1612
|
+
p_api_status = api_sub.add_parser("status", help="Explain DB status (JSON)")
|
|
1613
|
+
p_api_status.add_argument("--repo", default=None, help="Repository directory to read repo-scoped cached explain.json")
|
|
1614
|
+
p_api_status.set_defaults(func=api_status)
|
|
1615
|
+
|
|
1616
|
+
p_api_repo_summary = api_sub.add_parser("repo_summary", help="Deterministic repo-level architecture summary")
|
|
1617
|
+
p_api_repo_summary.add_argument("--repo", required=True, help="Repository directory to summarize")
|
|
1618
|
+
p_api_repo_summary.set_defaults(func=api_repo_summary)
|
|
1619
|
+
|
|
1620
|
+
p_api_risk_radar = api_sub.add_parser("risk_radar", help="Repo-level risk radar from cached architecture artifacts")
|
|
1621
|
+
p_api_risk_radar.add_argument("--repo", required=True, help="Repository directory")
|
|
1622
|
+
p_api_risk_radar.set_defaults(func=api_risk_radar)
|
|
1623
|
+
|
|
1624
|
+
p_api_impact = api_sub.add_parser("impact", help="Change impact preview for symbol or file target")
|
|
1625
|
+
p_api_impact.add_argument("target", help="Symbol FQN or repo-relative file path")
|
|
1626
|
+
p_api_impact.add_argument("--repo", required=True, help="Repository directory")
|
|
1627
|
+
p_api_impact.add_argument("--depth", type=int, default=2, help="Traversal depth")
|
|
1628
|
+
p_api_impact.add_argument("--max_nodes", type=int, default=200, help="Node cap per direction")
|
|
1629
|
+
p_api_impact.set_defaults(func=api_impact)
|
|
1630
|
+
|
|
1631
|
+
p_api_cache = api_sub.add_parser("cache", help="Manage analysis caches")
|
|
1632
|
+
cache_sub = p_api_cache.add_subparsers(dest="cache_command", required=True)
|
|
1633
|
+
|
|
1634
|
+
p_api_cache_list = cache_sub.add_parser("list", help="List cache directories")
|
|
1635
|
+
p_api_cache_list.set_defaults(func=api_cache_list)
|
|
1636
|
+
|
|
1637
|
+
p_api_cache_policy = cache_sub.add_parser("policy", help="Get or set cache retention policy")
|
|
1638
|
+
cache_policy_sub = p_api_cache_policy.add_subparsers(dest="cache_policy_command", required=True)
|
|
1639
|
+
p_api_cache_policy_get = cache_policy_sub.add_parser("get", help="Show retention policy")
|
|
1640
|
+
p_api_cache_policy_get.set_defaults(func=api_cache_policy_get)
|
|
1641
|
+
p_api_cache_policy_set = cache_policy_sub.add_parser("set", help="Update retention policy")
|
|
1642
|
+
p_api_cache_policy_set.add_argument("--default-ttl-days", type=int, default=None, help="Default TTL for repo cache dirs")
|
|
1643
|
+
p_api_cache_policy_set.add_argument("--workspaces-ttl-days", type=int, default=None, help="Default TTL for unreferenced workspaces")
|
|
1644
|
+
p_api_cache_policy_set.set_defaults(func=api_cache_policy_set)
|
|
1645
|
+
|
|
1646
|
+
p_api_cache_info = cache_sub.add_parser("info", help="Inspect one cache target")
|
|
1647
|
+
p_api_cache_info.add_argument("--path", default=None, help="Local repository path")
|
|
1648
|
+
p_api_cache_info.add_argument("--github", default=None, help="GitHub repository URL")
|
|
1649
|
+
p_api_cache_info.add_argument("--ref", default=None, help="GitHub ref")
|
|
1650
|
+
p_api_cache_info.add_argument("--mode", default="git", choices=["git", "zip"], help="GitHub mode")
|
|
1651
|
+
p_api_cache_info.set_defaults(func=api_cache_info)
|
|
1652
|
+
|
|
1653
|
+
p_api_cache_clear = cache_sub.add_parser("clear", help="Clear one cache target safely")
|
|
1654
|
+
p_api_cache_clear.add_argument("--all", action="store_true", help="Clear every known cache")
|
|
1655
|
+
p_api_cache_clear.add_argument("--repo_hash", "--repo-hash", default=None, help="Direct repo hash target")
|
|
1656
|
+
p_api_cache_clear.add_argument("--path", default=None, help="Local repository path")
|
|
1657
|
+
p_api_cache_clear.add_argument("--github", default=None, help="GitHub repository URL")
|
|
1658
|
+
p_api_cache_clear.add_argument("--ref", default=None, help="GitHub ref")
|
|
1659
|
+
p_api_cache_clear.add_argument("--mode", default="git", choices=["git", "zip"], help="GitHub mode")
|
|
1660
|
+
p_api_cache_clear.add_argument("--dry-run", action="store_true", help="Show what would be deleted")
|
|
1661
|
+
p_api_cache_clear.add_argument("--yes", action="store_true", help="Skip confirmation prompt")
|
|
1662
|
+
p_api_cache_clear.add_argument("--include-workspace", action="store_true", help="Also remove workspace even if shared")
|
|
1663
|
+
p_api_cache_clear.set_defaults(func=api_cache_clear)
|
|
1664
|
+
|
|
1665
|
+
p_api_cache_retention = cache_sub.add_parser("retention", help="Set per-repo retention in days")
|
|
1666
|
+
p_api_cache_retention.add_argument("--repo_hash", "--repo-hash", default=None, help="Direct repo hash target")
|
|
1667
|
+
p_api_cache_retention.add_argument("--path", default=None, help="Local repository path")
|
|
1668
|
+
p_api_cache_retention.add_argument("--github", default=None, help="GitHub repository URL")
|
|
1669
|
+
p_api_cache_retention.add_argument("--ref", default=None, help="GitHub ref")
|
|
1670
|
+
p_api_cache_retention.add_argument("--mode", default="git", choices=["git", "zip"], help="GitHub mode")
|
|
1671
|
+
p_api_cache_retention.add_argument("--days", type=int, required=True, help="Retention days (0 means never auto-delete)")
|
|
1672
|
+
p_api_cache_retention.add_argument("--yes", action="store_true", help="Confirm retention update")
|
|
1673
|
+
p_api_cache_retention.set_defaults(func=api_cache_retention)
|
|
1674
|
+
|
|
1675
|
+
p_api_cache_cleanup = cache_sub.add_parser("cleanup", help="Delete expired cache/workspace data using retention policy")
|
|
1676
|
+
p_api_cache_cleanup.add_argument("--apply", action="store_true", help="Apply cleanup immediately (alias for --yes)")
|
|
1677
|
+
p_api_cache_cleanup.add_argument("--dry-run", action="store_true", help="Show what would be deleted")
|
|
1678
|
+
p_api_cache_cleanup.add_argument("--yes", action="store_true", help="Skip confirmation prompt")
|
|
1679
|
+
p_api_cache_cleanup.set_defaults(func=api_cache_cleanup)
|
|
1680
|
+
|
|
1681
|
+
p_api_cache_sweep = cache_sub.add_parser("sweep", help="Sweep expired caches by per-repo retention metadata")
|
|
1682
|
+
p_api_cache_sweep.add_argument("--dry-run", action="store_true", help="Show what would be deleted")
|
|
1683
|
+
p_api_cache_sweep.add_argument("--yes", action="store_true", help="Confirm deletion")
|
|
1684
|
+
p_api_cache_sweep.set_defaults(func=api_cache_sweep)
|
|
1685
|
+
|
|
1686
|
+
p_api_cache_delete = cache_sub.add_parser("delete", help="Delete all artifacts for one repo cache target")
|
|
1687
|
+
p_api_cache_delete.add_argument("--repo_hash", "--repo-hash", default=None, help="Direct repo hash target")
|
|
1688
|
+
p_api_cache_delete.add_argument("--path", default=None, help="Local repository path")
|
|
1689
|
+
p_api_cache_delete.add_argument("--github", default=None, help="GitHub repository URL")
|
|
1690
|
+
p_api_cache_delete.add_argument("--ref", default=None, help="GitHub ref")
|
|
1691
|
+
p_api_cache_delete.add_argument("--mode", default="git", choices=["git", "zip"], help="GitHub mode")
|
|
1692
|
+
p_api_cache_delete.add_argument("--dry-run", action="store_true", help="Show what would be deleted")
|
|
1693
|
+
p_api_cache_delete.add_argument("--yes", action="store_true", help="Skip confirmation prompt")
|
|
1694
|
+
p_api_cache_delete.set_defaults(func=api_cache_delete)
|
|
1695
|
+
|
|
1696
|
+
p_api_cache_prune = cache_sub.add_parser("prune", help="Prune stale caches by retention policy")
|
|
1697
|
+
p_api_cache_prune.add_argument("--older-than", default="0d", help="Additional age filter (e.g. 14d, 36h)")
|
|
1698
|
+
p_api_cache_prune.add_argument("--dry-run", action="store_true", help="Show what would be deleted")
|
|
1699
|
+
p_api_cache_prune.add_argument("--yes", action="store_true", help="Skip confirmation prompt")
|
|
1700
|
+
p_api_cache_prune.set_defaults(func=api_cache_prune)
|
|
1701
|
+
|
|
1702
|
+
p_api_analyze = api_sub.add_parser("analyze", help="Run Phase-4 and explain generation")
|
|
1703
|
+
p_api_analyze.add_argument("--path", default=None, help="Repository directory to analyze")
|
|
1704
|
+
p_api_analyze.add_argument("--github", default=None, help="Public GitHub repository URL (https://github.com/<org>/<repo>)")
|
|
1705
|
+
p_api_analyze.add_argument("--ref", default=None, help="Optional Git branch or tag when using --github")
|
|
1706
|
+
p_api_analyze.add_argument("--mode", default="git", choices=["git", "zip"], help="GitHub fetch mode")
|
|
1707
|
+
p_api_analyze.add_argument("--token", default=None, help="GitHub personal access token (optional)")
|
|
1708
|
+
p_api_analyze.add_argument("--token-stdin", action="store_true", help="Read GitHub token from stdin")
|
|
1709
|
+
p_api_analyze.add_argument("--refresh", action="store_true", help="GitHub only: delete workspace clone and fetch again")
|
|
1710
|
+
p_api_analyze.add_argument("--rebuild", action="store_true", help="Force full analysis rebuild even if cache is valid")
|
|
1711
|
+
p_api_analyze.add_argument("--clear-cache", action="store_true", help="Delete analysis cache directory before analyze")
|
|
1712
|
+
p_api_analyze.add_argument("--retention", default="ttl", choices=["ttl", "session_only", "pinned"], help="Retention policy mode")
|
|
1713
|
+
p_api_analyze.add_argument("--ttl-days", type=int, default=None, help="TTL in days when retention mode is ttl (0 = never)")
|
|
1714
|
+
p_api_analyze.set_defaults(func=api_analyze)
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
return parser
|
|
1718
|
+
def main() -> int:
|
|
1719
|
+
argv = sys.argv[1:]
|
|
1720
|
+
if not argv or (len(argv) == 1 and argv[0] in {"-h", "--help"}):
|
|
1721
|
+
return _print_public_help()
|
|
1722
|
+
parser = build_parser()
|
|
1723
|
+
args = parser.parse_args()
|
|
1724
|
+
return args.func(args)
|
|
1725
|
+
|
|
1726
|
+
|
|
1727
|
+
if __name__ == "__main__":
|
|
1728
|
+
raise SystemExit(main())
|