codemap-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. analysis/__init__.py +1 -0
  2. analysis/architecture/__init__.py +1 -0
  3. analysis/architecture/architecture_engine.py +155 -0
  4. analysis/architecture/dependency_cycles.py +103 -0
  5. analysis/architecture/risk_radar.py +220 -0
  6. analysis/call_graph/__init__.py +1 -0
  7. analysis/call_graph/call_extractor.py +91 -0
  8. analysis/call_graph/call_graph_builder.py +1 -0
  9. analysis/call_graph/call_resolver.py +56 -0
  10. analysis/call_graph/context_models.py +1 -0
  11. analysis/call_graph/cross_file_resolver.py +122 -0
  12. analysis/call_graph/execution_tracker.py +1 -0
  13. analysis/call_graph/flow_builder.py +1 -0
  14. analysis/call_graph/models.py +1 -0
  15. analysis/core/__init__.py +1 -0
  16. analysis/core/ast_context.py +1 -0
  17. analysis/core/ast_parser.py +8 -0
  18. analysis/core/class_extractor.py +35 -0
  19. analysis/core/function_extractor.py +16 -0
  20. analysis/core/import_extractor.py +43 -0
  21. analysis/explain/__init__.py +1 -0
  22. analysis/explain/docstring_extractor.py +45 -0
  23. analysis/explain/explain_runner.py +177 -0
  24. analysis/explain/repo_summary_generator.py +138 -0
  25. analysis/explain/return_analyzer.py +114 -0
  26. analysis/explain/risk_flags.py +1 -0
  27. analysis/explain/signature_extractor.py +104 -0
  28. analysis/explain/summary_generator.py +282 -0
  29. analysis/graph/__init__.py +1 -0
  30. analysis/graph/callgraph_index.py +117 -0
  31. analysis/graph/entrypoint_detector.py +1 -0
  32. analysis/graph/impact_analyzer.py +210 -0
  33. analysis/indexing/__init__.py +1 -0
  34. analysis/indexing/import_resolver.py +156 -0
  35. analysis/indexing/symbol_index.py +150 -0
  36. analysis/runners/__init__.py +1 -0
  37. analysis/runners/phase4_runner.py +137 -0
  38. analysis/utils/__init__.py +1 -0
  39. analysis/utils/ast_helpers.py +1 -0
  40. analysis/utils/cache_manager.py +659 -0
  41. analysis/utils/path_resolver.py +1 -0
  42. analysis/utils/repo_fetcher.py +469 -0
  43. cli.py +1728 -0
  44. codemap_cli.py +11 -0
  45. codemap_python-0.1.0.dist-info/METADATA +399 -0
  46. codemap_python-0.1.0.dist-info/RECORD +58 -0
  47. codemap_python-0.1.0.dist-info/WHEEL +5 -0
  48. codemap_python-0.1.0.dist-info/entry_points.txt +2 -0
  49. codemap_python-0.1.0.dist-info/top_level.txt +5 -0
  50. security_utils.py +51 -0
  51. ui/__init__.py +1 -0
  52. ui/app.py +2160 -0
  53. ui/device_id.py +27 -0
  54. ui/static/app.js +2703 -0
  55. ui/static/styles.css +1268 -0
  56. ui/templates/index.html +231 -0
  57. ui/utils/__init__.py +1 -0
  58. ui/utils/registry_manager.py +190 -0
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())