codegraph-engine 3.0.0__1-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 (130) hide show
  1. codegraph/__init__.py +37 -0
  2. codegraph/agent.py +26 -0
  3. codegraph/agent_brain.py +2212 -0
  4. codegraph/agent_capabilities.py +4058 -0
  5. codegraph/agent_rules.py +531 -0
  6. codegraph/api_drift.py +455 -0
  7. codegraph/architecture.py +371 -0
  8. codegraph/async_queue/__init__.py +50 -0
  9. codegraph/async_queue/detectors/__init__.py +48 -0
  10. codegraph/async_queue/detectors/celery.py +160 -0
  11. codegraph/async_queue/detectors/kafka.py +168 -0
  12. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  13. codegraph/async_queue/detectors/redis.py +130 -0
  14. codegraph/async_queue/detectors/sqs.py +135 -0
  15. codegraph/async_queue/models.py +161 -0
  16. codegraph/async_queue/otel_models.py +74 -0
  17. codegraph/async_queue/otel_parser.py +337 -0
  18. codegraph/async_queue/reconciler.py +427 -0
  19. codegraph/async_queue/scanner.py +291 -0
  20. codegraph/async_queue/traversal.py +447 -0
  21. codegraph/audit.py +106 -0
  22. codegraph/binding_resolver.py +616 -0
  23. codegraph/branch_comparison.py +197 -0
  24. codegraph/cache.py +95 -0
  25. codegraph/change_impact.py +547 -0
  26. codegraph/cli.py +2436 -0
  27. codegraph/cli_output.py +205 -0
  28. codegraph/config.py +45 -0
  29. codegraph/constraints.py +238 -0
  30. codegraph/context.py +2182 -0
  31. codegraph/context_freshness.py +351 -0
  32. codegraph/database/__init__.py +72 -0
  33. codegraph/database/extractor.py +3651 -0
  34. codegraph/database/interrogation.py +1494 -0
  35. codegraph/database/models.py +310 -0
  36. codegraph/database/schema_drift.py +862 -0
  37. codegraph/epistemic.py +200 -0
  38. codegraph/errors.py +282 -0
  39. codegraph/evidence/__init__.py +15 -0
  40. codegraph/evidence/citations.py +418 -0
  41. codegraph/evidence_contract.py +486 -0
  42. codegraph/frameworks.py +960 -0
  43. codegraph/frameworks_nestjs.py +502 -0
  44. codegraph/frameworks_nextjs.py +337 -0
  45. codegraph/frameworks_react.py +457 -0
  46. codegraph/freshness.py +335 -0
  47. codegraph/frontend_html.py +109 -0
  48. codegraph/git.py +362 -0
  49. codegraph/git_state.py +545 -0
  50. codegraph/graph/__init__.py +46 -0
  51. codegraph/graph/models.py +56 -0
  52. codegraph/graph/traversal.py +1993 -0
  53. codegraph/indexing/__init__.py +24 -0
  54. codegraph/indexing/classifier.py +564 -0
  55. codegraph/indexing/indexer.py +2578 -0
  56. codegraph/indexing/models.py +427 -0
  57. codegraph/indexing/parser.py +3404 -0
  58. codegraph/indexing/pool.py +136 -0
  59. codegraph/indexing/scanner.py +211 -0
  60. codegraph/indexing/telemetry.py +326 -0
  61. codegraph/indexing/templates.py +418 -0
  62. codegraph/indexing/test_framework.py +116 -0
  63. codegraph/installer.py +1702 -0
  64. codegraph/interrogation.py +2355 -0
  65. codegraph/llm/__init__.py +3 -0
  66. codegraph/llm/base.py +15 -0
  67. codegraph/llm/context.py +20 -0
  68. codegraph/mcp/__init__.py +3 -0
  69. codegraph/mcp/server.py +1476 -0
  70. codegraph/mcp_diagnostics.py +882 -0
  71. codegraph/memory/__init__.py +3 -0
  72. codegraph/memory/store.py +46 -0
  73. codegraph/models.py +310 -0
  74. codegraph/monorepo.py +925 -0
  75. codegraph/observability.py +262 -0
  76. codegraph/optimizer.py +1014 -0
  77. codegraph/planner.py +417 -0
  78. codegraph/process_lifecycle.py +1153 -0
  79. codegraph/py.typed +1 -0
  80. codegraph/query_expansion.py +199 -0
  81. codegraph/ranking.py +408 -0
  82. codegraph/refactor/__init__.py +36 -0
  83. codegraph/refactor/models.py +114 -0
  84. codegraph/refactor/renamer.py +844 -0
  85. codegraph/refactor/transactions.py +414 -0
  86. codegraph/resolver.py +3968 -0
  87. codegraph/resources/__init__.py +46 -0
  88. codegraph/resources/cache.py +117 -0
  89. codegraph/resources/coalescer.py +83 -0
  90. codegraph/resources/debouncer.py +98 -0
  91. codegraph/resources/governor.py +219 -0
  92. codegraph/resources/monitor.py +102 -0
  93. codegraph/resources/policy.py +123 -0
  94. codegraph/retrieval_policy.py +320 -0
  95. codegraph/roblox.py +2174 -0
  96. codegraph/route_composer.py +363 -0
  97. codegraph/route_topology.py +172 -0
  98. codegraph/runtime/__init__.py +51 -0
  99. codegraph/runtime/collector.py +155 -0
  100. codegraph/runtime/db_watcher.py +191 -0
  101. codegraph/runtime/ingestor.py +692 -0
  102. codegraph/runtime/models.py +130 -0
  103. codegraph/runtime/port_inspector.py +304 -0
  104. codegraph/runtime/reconciliation.py +492 -0
  105. codegraph/runtime/runner.py +353 -0
  106. codegraph/runtime/trace_linker.py +175 -0
  107. codegraph/search/__init__.py +25 -0
  108. codegraph/search/hybrid.py +846 -0
  109. codegraph/search/semantic.py +28 -0
  110. codegraph/security/__init__.py +51 -0
  111. codegraph/security/paths.py +189 -0
  112. codegraph/security/redaction.py +633 -0
  113. codegraph/semantic_conflicts.py +270 -0
  114. codegraph/semantic_decorators.py +372 -0
  115. codegraph/structural_diff.py +542 -0
  116. codegraph/styles.py +144 -0
  117. codegraph/symbol_history.py +277 -0
  118. codegraph/target_resolver.py +348 -0
  119. codegraph/task.py +637 -0
  120. codegraph/tool_selection_eval.py +1158 -0
  121. codegraph/tsconfig.py +236 -0
  122. codegraph/ui/__init__.py +6 -0
  123. codegraph/ui/server.py +755 -0
  124. codegraph/watcher.py +323 -0
  125. codegraph_engine-3.0.0.dist-info/METADATA +859 -0
  126. codegraph_engine-3.0.0.dist-info/RECORD +130 -0
  127. codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
  128. codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
  129. codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
  130. codegraph_engine-3.0.0.dist-info/top_level.txt +1 -0
codegraph/cli.py ADDED
@@ -0,0 +1,2436 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Annotated, Any
8
+
9
+ import typer
10
+
11
+ from codegraph import __version__
12
+ from codegraph.architecture import get_architecture
13
+ from codegraph.cli_output import cli_echo
14
+ from codegraph.config import Settings
15
+ from codegraph.context import get_context
16
+ from codegraph.errors import ErrorCode, SecurityError
17
+ from codegraph.freshness import check_freshness, index_generation
18
+ from codegraph.graph import trace_call
19
+ from codegraph.graph.traversal import get_focused_graph, get_graph_summary
20
+ from codegraph.indexing import Indexer
21
+ from codegraph.indexing.indexer import check_database_health
22
+ from codegraph.interrogation import (
23
+ get_dependents as interrogation_get_dependents,
24
+ )
25
+ from codegraph.interrogation import (
26
+ get_imports as interrogation_get_imports,
27
+ )
28
+ from codegraph.interrogation import (
29
+ get_symbol as interrogation_get_symbol,
30
+ )
31
+ from codegraph.interrogation import (
32
+ resolve_symbol as interrogation_resolve_symbol,
33
+ )
34
+ from codegraph.memory import MemoryStore
35
+ from codegraph.planner import build_retrieval_plan
36
+ from codegraph.resolver import ReferenceResolver
37
+ from codegraph.search import search as search_code
38
+ from codegraph.security import is_sensitive, safe_path
39
+ from codegraph.task import TargetExpressionType, classify_target_expression, normalize_task_spec
40
+
41
+ app = typer.Typer(no_args_is_help=True, help="Evidence-backed local codebase intelligence.")
42
+
43
+
44
+ def _version_callback(value: bool) -> None:
45
+ if value:
46
+ typer.echo(f"codegraph {__version__}")
47
+ raise typer.Exit()
48
+
49
+
50
+ @app.callback()
51
+ def main(
52
+ version: Annotated[
53
+ bool,
54
+ typer.Option("--version", "-v", help="Show version and exit", callback=_version_callback, is_eager=True),
55
+ ] = False,
56
+ ) -> None:
57
+ """Evidence-backed local codebase intelligence for MCP clients and AI agents."""
58
+ if sys.platform == "win32":
59
+ try:
60
+ if hasattr(sys.stdout, "reconfigure"):
61
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
62
+ if hasattr(sys.stderr, "reconfigure"):
63
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
64
+ except Exception:
65
+ pass
66
+
67
+
68
+ def _resolve_repo(
69
+ path: Path | None = None,
70
+ repository: Path | None = None,
71
+ ) -> Path:
72
+ """Resolve repository path with priority: explicit option > positional argument > current directory."""
73
+ target: Path
74
+ if repository is not None:
75
+ target = repository
76
+ elif path is not None:
77
+ target = path
78
+ else:
79
+ target = Path.cwd()
80
+
81
+ if not target.exists():
82
+ typer.echo(
83
+ json.dumps(
84
+ {
85
+ "status": "error",
86
+ "error": {
87
+ "code": ErrorCode.INVALID_PATH.value,
88
+ "message": f"Repository path does not exist: {target}",
89
+ "next_action": {
90
+ "command": "codegraph init .",
91
+ "reason": "Specify an existing directory path.",
92
+ },
93
+ },
94
+ },
95
+ indent=2,
96
+ )
97
+ )
98
+ raise typer.Exit(code=1)
99
+
100
+ if not target.is_dir():
101
+ typer.echo(
102
+ json.dumps(
103
+ {
104
+ "status": "error",
105
+ "error": {
106
+ "code": ErrorCode.INVALID_PATH.value,
107
+ "message": f"Repository path is not a directory: {target}",
108
+ "next_action": {
109
+ "command": "codegraph init .",
110
+ "reason": "Specify a valid directory path.",
111
+ },
112
+ },
113
+ },
114
+ indent=2,
115
+ )
116
+ )
117
+ raise typer.Exit(code=1)
118
+
119
+ return target.resolve()
120
+
121
+
122
+ def _ensure_indexed(repo: Path) -> bool:
123
+ """Check if repository has an index database; if not, output structured INDEX_NOT_FOUND error."""
124
+ db = repo / ".codegraph.sqlite3"
125
+ if not db.exists():
126
+ typer.echo(
127
+ json.dumps(
128
+ {
129
+ "status": "error",
130
+ "error": {
131
+ "code": ErrorCode.INDEX_NOT_FOUND.value,
132
+ "message": "No CodeGraph index exists for this repository.",
133
+ "next_action": {
134
+ "command": "codegraph init",
135
+ "reason": "Initialize the repository before querying it.",
136
+ },
137
+ },
138
+ },
139
+ indent=2,
140
+ )
141
+ )
142
+ return False
143
+ return True
144
+
145
+
146
+ def _indexer(repository: Path, db_path: Path | None = None) -> Indexer:
147
+ return Indexer(repository, Settings(repository=repository, db_path=db_path))
148
+
149
+
150
+ @app.command()
151
+ def index(
152
+ path: Annotated[Path | None, typer.Argument(help="Repository path to index (default: current directory)")] = None,
153
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
154
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress progress and summary output")] = False,
155
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show phase progress, timings, memory, and WAL stats")] = False,
156
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON summary and phase telemetry")] = False,
157
+ ) -> None:
158
+ """Securely index supported source files, skipping unchanged content."""
159
+ from codegraph.indexing.telemetry import IndexingTelemetry, format_progress_block
160
+
161
+ repo = _resolve_repo(path, repository)
162
+ idx_inst = _indexer(repo)
163
+
164
+ _PHASE_LABELS = {
165
+ "repository_scan": "Repository Scan",
166
+ "ast_parsing": "AST Parsing & Extraction",
167
+ "post_processing": "Post-Processing",
168
+ "symbol_resolution": "Symbol Resolution",
169
+ "relationship_resolution": "Relationship Resolution",
170
+ "database_analysis": "Database Analysis",
171
+ "final_commit_checkpoint": "Final Commit & Checkpoint",
172
+ }
173
+
174
+ def _on_progress(phase_key: str, current: int, total: int, tel: IndexingTelemetry) -> None:
175
+ if not verbose or quiet or json_output:
176
+ return
177
+ label = _PHASE_LABELS.get(phase_key, phase_key)
178
+ block = format_progress_block(
179
+ label,
180
+ current,
181
+ total,
182
+ tel.total_elapsed_seconds or (time.perf_counter() - tel.started_at),
183
+ memory_mb=tel.peak_rss_mb,
184
+ )
185
+ typer.echo(block)
186
+
187
+ import time
188
+
189
+ result = idx_inst.index(progress_callback=_on_progress if verbose else None, catch_interrupt=True)
190
+ tel = idx_inst.last_telemetry
191
+
192
+ if json_output:
193
+ payload: dict[str, object] = dict(result)
194
+ if tel is not None:
195
+ payload["telemetry"] = tel.as_dict()
196
+ typer.echo(json.dumps(payload))
197
+ if result.get("interrupted"):
198
+ raise typer.Exit(code=130)
199
+ return
200
+
201
+ if quiet:
202
+ if result.get("interrupted"):
203
+ raise typer.Exit(code=130)
204
+ return
205
+
206
+ if verbose and tel is not None:
207
+ typer.echo(
208
+ f"Summary: elapsed={tel.total_elapsed_seconds:.3f}s "
209
+ f"peak_rss={tel.peak_rss_mb:.1f}MB "
210
+ f"max_wal={tel.max_wal_size_mb:.2f}MB "
211
+ f"final_wal={tel.final_wal_size_mb:.2f}MB "
212
+ f"commits={tel.batch_commits} checkpoints={tel.wal_checkpoints}"
213
+ )
214
+ for p_name, p_metrics in tel.phases.items():
215
+ if p_metrics.elapsed_seconds > 0 or p_metrics.items_processed > 0 or p_metrics.files_processed > 0:
216
+ typer.echo(
217
+ f" [{p_name}] {p_metrics.elapsed_seconds * 1000:.1f}ms "
218
+ f"files={p_metrics.files_processed} items={p_metrics.items_processed} "
219
+ f"writes={p_metrics.db_writes}"
220
+ )
221
+
222
+ typer.echo(" ".join(f"{k}={v}" for k, v in result.items()))
223
+ if result.get("interrupted"):
224
+ raise typer.Exit(code=130)
225
+
226
+
227
+ @app.command()
228
+ def init(
229
+ path: Annotated[Path | None, typer.Argument(help="Repository path to initialize (default: current directory)")] = None,
230
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
231
+ json_output: Annotated[bool, typer.Option("--json")] = False,
232
+ ) -> None:
233
+ """Initialize repository indexing and configuration."""
234
+ target_repo = _resolve_repo(path, repository)
235
+ result = _indexer(target_repo).index()
236
+ if json_output:
237
+ typer.echo(json.dumps({"status": "initialized", "repository": str(target_repo.resolve()), "indexing": result}, indent=2))
238
+ else:
239
+ indexed_cnt = result.get("indexed", result.get("indexed_files", 0))
240
+ typer.echo(f"Initialized CodeGraph repository at {target_repo.resolve()} (indexed {indexed_cnt} files)")
241
+
242
+
243
+ @app.command()
244
+ def watch(
245
+ path: Annotated[Path | None, typer.Argument(help="Repository path to watch (default: current directory)")] = None,
246
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
247
+ debounce_ms: Annotated[int, typer.Option("--debounce", "--debounce-ms", help="Debounce delay in milliseconds")] = 250,
248
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress real-time change logs")] = False,
249
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show verbose reindex logs")] = False,
250
+ ) -> None:
251
+ """Watch repository for changes and update index in real-time (< 15ms per file)."""
252
+ target_repo = _resolve_repo(path, repository)
253
+ from codegraph.watcher import RepositoryWatcher
254
+
255
+ def _on_batch_complete(res: dict[str, Any]) -> None:
256
+ if quiet:
257
+ return
258
+ reindexed = res.get("reindexed", [])
259
+ deleted = res.get("deleted", [])
260
+ skipped = res.get("skipped", [])
261
+ ms = res.get("elapsed_ms", 0.0)
262
+ gen = res.get("generation")
263
+ gen_str = f" [gen {gen}]" if gen is not None else ""
264
+
265
+ parts: list[str] = []
266
+ if reindexed:
267
+ parts.append(f"reindexed {len(reindexed)} file(s): {', '.join(str(p) for p in reindexed[:3])}{'...' if len(reindexed) > 3 else ''}")
268
+ if deleted:
269
+ parts.append(f"removed {len(deleted)} file(s): {', '.join(str(p) for p in deleted[:3])}{'...' if len(deleted) > 3 else ''}")
270
+ if verbose and skipped:
271
+ parts.append(f"skipped {len(skipped)} unchanged file(s)")
272
+
273
+ if parts:
274
+ typer.echo(f"[WATCH] {'; '.join(parts)} in {ms:.1f}ms{gen_str}")
275
+ elif verbose:
276
+ typer.echo(f"[WATCH] Clean flush in {ms:.1f}ms{gen_str}")
277
+
278
+ typer.echo(f"Watching {target_repo.resolve()} for changes (debounce {debounce_ms}ms)... Press Ctrl+C to stop.")
279
+ watcher = RepositoryWatcher(
280
+ target_repo,
281
+ debounce_delay_sec=debounce_ms / 1000.0,
282
+ on_batch_complete=_on_batch_complete,
283
+ )
284
+ watcher.run_forever()
285
+
286
+
287
+ @app.command()
288
+ def ui(
289
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
290
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
291
+ port: Annotated[int, typer.Option("--port", "-p", help="Port for UI dashboard")] = 8765,
292
+ host: Annotated[str, typer.Option("--host", help="Host interface")] = "127.0.0.1",
293
+ no_browser: Annotated[bool, typer.Option("--no-browser", help="Do not automatically open browser")] = False,
294
+ ) -> None:
295
+ """Launch the interactive CodeGraph Knowledge Graph and UI Dashboard in your browser."""
296
+ target_repo = _resolve_repo(path, repository)
297
+ from codegraph.ui import start_ui_server
298
+
299
+ start_ui_server(target_repo, host=host, port=port, open_browser=not no_browser)
300
+
301
+
302
+ @app.command()
303
+ def search(
304
+ query: str,
305
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
306
+ top_k: int = 10,
307
+ ext: Annotated[str | None, typer.Option("--ext", "-e", help="Comma-separated file extensions (e.g. ts,tsx,jsx)")] = None,
308
+ package: Annotated[str | None, typer.Option("--package", "-p", help="Monorepo package scope filter (e.g. dundoo-react)")] = None,
309
+ path_prefix: Annotated[str | None, typer.Option("--path-prefix", help="Subdirectory path prefix filter")] = None,
310
+ json_output: Annotated[bool, typer.Option("--json")] = False,
311
+ ) -> None:
312
+ """Search indexed code with exact evidence."""
313
+ repo = _resolve_repo(repository=repository)
314
+ if not _ensure_indexed(repo):
315
+ return
316
+ if not query.strip():
317
+ typer.echo(
318
+ json.dumps(
319
+ {
320
+ "status": "error",
321
+ "error": {
322
+ "code": ErrorCode.INVALID_ARGUMENT.value,
323
+ "message": "Search query must be non-empty.",
324
+ "next_action": {
325
+ "command": "codegraph search <query>",
326
+ "reason": "Provide a non-empty search query.",
327
+ },
328
+ },
329
+ },
330
+ indent=2,
331
+ )
332
+ )
333
+ return
334
+
335
+ file_types = [e.strip().lstrip(".") for e in ext.split(",") if e.strip()] if ext else None
336
+ effective_path_filter: str | None = path_prefix
337
+ if package:
338
+ from codegraph.monorepo import discover_workspace
339
+ ws = discover_workspace(repo)
340
+ pkg_info = ws.packages.get(package)
341
+ if pkg_info:
342
+ effective_path_filter = pkg_info.root_path
343
+ else:
344
+ effective_path_filter = package
345
+
346
+ with _indexer(repo).session() as con:
347
+ if file_types or effective_path_filter:
348
+ from codegraph.search.hybrid import search_code as search_code_rich
349
+ raw_hits = search_code_rich(
350
+ con,
351
+ query,
352
+ repo_path=repo,
353
+ path_filter=effective_path_filter,
354
+ file_types=file_types,
355
+ top_k=top_k,
356
+ )
357
+ result = [
358
+ {
359
+ "file": str(h.get("path") or h.get("file") or ""),
360
+ "start_line": int(str(h.get("line") or h.get("start_line") or "1")),
361
+ "end_line": int(str(h.get("line") or h.get("end_line") or "1")),
362
+ "symbol": str(h.get("symbol") or ""),
363
+ "score": float(str(h.get("score") or "1.0")),
364
+ "snippet": str(h.get("snippet") or h.get("matched_text") or ""),
365
+ }
366
+ for h in raw_hits
367
+ ]
368
+ else:
369
+ result = [r.as_dict() for r in search_code(con, query, top_k)]
370
+ typer.echo(
371
+ json.dumps(result, indent=2)
372
+ if json_output
373
+ else "\n\n".join(
374
+ f"{r['file']}:{r['start_line']}-{r['end_line']} {r['symbol'] or ''} score={r['score']}\n{r['snippet']}"
375
+ for r in result
376
+ )
377
+ )
378
+
379
+
380
+ @app.command()
381
+ def symbols(
382
+ path: str,
383
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
384
+ ) -> None:
385
+ """Show extracted symbols for an indexed file."""
386
+ repo = _resolve_repo(repository=repository)
387
+ if not _ensure_indexed(repo):
388
+ return
389
+ try:
390
+ safe_path(repo, path)
391
+ except SecurityError:
392
+ typer.echo(
393
+ json.dumps(
394
+ {
395
+ "status": "error",
396
+ "error": {
397
+ "code": ErrorCode.PATH_OUTSIDE_REPOSITORY.value,
398
+ "message": f"Path traversal blocked: '{path}' resides outside repository boundary.",
399
+ "next_action": {
400
+ "command": "codegraph status",
401
+ "reason": "Ensure all queried paths reside within the repository root.",
402
+ },
403
+ },
404
+ },
405
+ indent=2,
406
+ )
407
+ )
408
+ return
409
+ with _indexer(repo).session() as con:
410
+ rows = con.execute(
411
+ "SELECT canonical_id, qualified_name, kind, start_line, end_line FROM symbols WHERE path=? ORDER BY start_line",
412
+ (path,),
413
+ ).fetchall()
414
+ typer.echo(json.dumps([dict(r) for r in rows], indent=2))
415
+
416
+
417
+ @app.command()
418
+ def trace(
419
+ symbol: str,
420
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
421
+ callers: Annotated[bool, typer.Option("--callers", help="Trace caller hierarchy")] = False,
422
+ callees: Annotated[bool, typer.Option("--callees", help="Trace direct callees")] = False,
423
+ both: Annotated[bool, typer.Option("--both", help="Trace both callers and callees")] = False,
424
+ depth: Annotated[int, typer.Option("--depth", "-d", help="Max trace depth (clamped to max 5)")] = 2,
425
+ compact: Annotated[bool, typer.Option("--compact", help="Emit compact human-readable summary")] = False,
426
+ ) -> None:
427
+ """Trace a symbol definition, callers, and callees with explicit confidence labels."""
428
+ repo = _resolve_repo(repository=repository)
429
+ if not _ensure_indexed(repo):
430
+ return
431
+ if depth < 1 or depth > 10:
432
+ typer.echo(
433
+ json.dumps(
434
+ {
435
+ "status": "error",
436
+ "error": {
437
+ "code": ErrorCode.INVALID_DEPTH.value,
438
+ "message": f"Invalid graph traversal depth {depth}; allowed range is 1..5.",
439
+ "next_action": {
440
+ "command": "codegraph trace --depth 2 <symbol>",
441
+ "reason": "Specify a traversal depth between 1 and 5.",
442
+ },
443
+ },
444
+ },
445
+ indent=2,
446
+ )
447
+ )
448
+ return
449
+ bounded_depth = max(1, min(depth, 5))
450
+ effective_callers = callers
451
+ effective_callees = callees
452
+ effective_both = both
453
+ if not callers and not callees and not both:
454
+ effective_both = True
455
+
456
+ with _indexer(repo).session() as con:
457
+ res = trace_call(con, symbol, max_depth=bounded_depth, callers=effective_callers, callees=effective_callees, both=effective_both)
458
+ if not res:
459
+ sym_row = con.execute(
460
+ "SELECT 1 FROM symbols WHERE canonical_id=? OR qualified_name=? OR name=? LIMIT 1",
461
+ (symbol, symbol, symbol),
462
+ ).fetchone()
463
+ if not sym_row:
464
+ typer.echo(
465
+ json.dumps(
466
+ {
467
+ "status": "unknown",
468
+ "error": {
469
+ "code": ErrorCode.SYMBOL_NOT_FOUND.value,
470
+ "message": f"No matching symbol was found for '{symbol}'.",
471
+ "next_action": {
472
+ "command": f"codegraph search {symbol}",
473
+ "reason": "Search with a broader query term.",
474
+ },
475
+ },
476
+ },
477
+ indent=2,
478
+ )
479
+ )
480
+ return
481
+ if compact:
482
+ cli_echo(f"Trace for: {symbol}")
483
+ definitions = [x for x in res if x.get("relationship") == "DEFINES"]
484
+ handlers = [x for x in res if x.get("relationship") == "HANDLED_BY"]
485
+ caller_list = [x for x in res if x.get("relationship") == "CALLER"]
486
+ callee_list = [x for x in res if x.get("relationship") == "CALLEE"]
487
+ if definitions:
488
+ cli_echo(" Definitions:")
489
+ for d in definitions[:5]:
490
+ cli_echo(f" - {d.get('symbol')} ({d.get('file')}:{d.get('start_line')})")
491
+ if handlers:
492
+ cli_echo(" Handled by Routes:")
493
+ for h in handlers[:5]:
494
+ cli_echo(f" - {h.get('symbol')} ({h.get('file')}:{h.get('start_line')})")
495
+ if caller_list:
496
+ cli_echo(f" Callers ({len(caller_list)} total):")
497
+ for c in caller_list[:15]:
498
+ cli_echo(f" - {c.get('symbol')} at {c.get('file')}:{c.get('start_line')} [{c.get('confidence')}]")
499
+ if len(caller_list) > 15:
500
+ cli_echo(f" ... and {len(caller_list) - 15} more")
501
+ if callee_list:
502
+ cli_echo(f" Callees ({len(callee_list)} total):")
503
+ for c in callee_list[:15]:
504
+ cli_echo(f" - {c.get('symbol')} at {c.get('file')}:{c.get('start_line')} [{c.get('confidence')}]")
505
+ if len(callee_list) > 15:
506
+ cli_echo(f" ... and {len(callee_list) - 15} more")
507
+ return
508
+ typer.echo(json.dumps(res, indent=2))
509
+
510
+
511
+ @app.command("get-symbol")
512
+ def get_symbol_cmd(
513
+ symbol: str,
514
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
515
+ json_output: Annotated[bool, typer.Option("--json")] = True,
516
+ ) -> None:
517
+ """Get authoritative AST details for a single symbol by name or canonical ID."""
518
+ repo = _resolve_repo(repository=repository)
519
+ if not _ensure_indexed(repo):
520
+ return
521
+ with _indexer(repo).session() as con:
522
+ res = interrogation_get_symbol(con, repo, symbol)
523
+ typer.echo(json.dumps(res, indent=2))
524
+
525
+
526
+ @app.command("resolve-symbol")
527
+ def resolve_symbol_cmd(
528
+ query: str,
529
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
530
+ json_output: Annotated[bool, typer.Option("--json")] = True,
531
+ ) -> None:
532
+ """Determine whether an exact/canonical symbol exists or return ambiguous candidates."""
533
+ repo = _resolve_repo(repository=repository)
534
+ if not _ensure_indexed(repo):
535
+ return
536
+ with _indexer(repo).session() as con:
537
+ res = interrogation_resolve_symbol(con, repo, query)
538
+ typer.echo(json.dumps(res, indent=2))
539
+
540
+
541
+ @app.command()
542
+ def graph(
543
+ path: Annotated[Path | None, typer.Argument(help="Repository path")] = None,
544
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
545
+ module: Annotated[str | None, typer.Option("--module", "-m", help="Filter focused graph by module")] = None,
546
+ symbol: Annotated[str | None, typer.Option("--symbol", "-s", help="Filter focused graph by symbol")] = None,
547
+ depth: Annotated[int, typer.Option("--depth", "-d", help="Traversal depth for focused graph (max 5)")] = 2,
548
+ ) -> None:
549
+ """Summarize indexed graph nodes, edges, modules, or inspect focused subgraphs."""
550
+ repo = _resolve_repo(path, repository)
551
+ if not _ensure_indexed(repo):
552
+ return
553
+ bounded_depth = max(1, min(depth, 5))
554
+ with _indexer(repo).session() as con:
555
+ if module or symbol:
556
+ result = get_focused_graph(con, module=module, symbol=symbol, depth=bounded_depth)
557
+ else:
558
+ result = get_graph_summary(con)
559
+ typer.echo(json.dumps(result, indent=2))
560
+
561
+
562
+ @app.command()
563
+ def routes(
564
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
565
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
566
+ framework: Annotated[str | None, typer.Option("--framework", "-f", help="Filter by framework (e.g. flask, fastapi)")] = None,
567
+ method: Annotated[str | None, typer.Option("--method", "-m", help="Filter by HTTP method (e.g. GET, POST)")] = None,
568
+ route_path: Annotated[str | None, typer.Option("--path", "-p", help="Filter by route path substring")] = None,
569
+ limit: Annotated[int | None, typer.Option("--limit", "-l", help="Maximum number of routes to return")] = None,
570
+ offset: Annotated[int, typer.Option("--offset", help="Number of routes to skip")] = 0,
571
+ json_output: Annotated[bool, typer.Option("--json", help="Output raw JSON array")] = False,
572
+ ) -> None:
573
+ """List and inspect indexed framework routes."""
574
+ repo = _resolve_repo(path, repository)
575
+ if not _ensure_indexed(repo):
576
+ return
577
+ with _indexer(repo).session() as con:
578
+ query = (
579
+ "SELECT endpoint_id, framework, http_method, route_path, handler_name, "
580
+ "handler_canonical_id, file_path, line, evidence, confidence "
581
+ "FROM framework_routes WHERE 1=1 "
582
+ )
583
+ params: list[str] = []
584
+ if framework:
585
+ query += "AND framework=? "
586
+ params.append(framework.lower())
587
+ if method:
588
+ query += "AND UPPER(http_method)=? "
589
+ params.append(method.upper())
590
+ if route_path:
591
+ query += "AND route_path LIKE ? "
592
+ params.append(f"%{route_path}%")
593
+ query += "ORDER BY route_path, http_method"
594
+
595
+ try:
596
+ rows = con.execute(query, params).fetchall()
597
+ except sqlite3.OperationalError:
598
+ rows = []
599
+
600
+ route_list = [
601
+ {
602
+ "endpoint_id": r["endpoint_id"],
603
+ "http_method": r["http_method"],
604
+ "route_path": r["route_path"],
605
+ "handler": r["handler_name"],
606
+ "canonical_id": r["handler_canonical_id"],
607
+ "file": r["file_path"],
608
+ "line": r["line"],
609
+ "framework": r["framework"],
610
+ "evidence": r["evidence"],
611
+ "confidence": r["confidence"],
612
+ }
613
+ for r in rows
614
+ ]
615
+
616
+ if limit is not None:
617
+ safe_offset = max(0, offset)
618
+ safe_limit = max(0, limit)
619
+ route_list = route_list[safe_offset : safe_offset + safe_limit]
620
+
621
+ if json_output:
622
+ typer.echo(json.dumps(route_list, indent=2))
623
+ else:
624
+ if not route_list:
625
+ typer.echo("No framework routes indexed or matching criteria.")
626
+ return
627
+ typer.echo(f"Found {len(route_list)} route(s):")
628
+ for r in route_list:
629
+ typer.echo(f" [{r['http_method']}] {r['route_path']} -> {r['handler']} ({r['file']}:{r['line']}) [{r['framework']}]")
630
+
631
+
632
+ @app.command("schema-drift")
633
+ def schema_drift(
634
+ route: Annotated[str | None, typer.Option("--route", help="Route path or endpoint to check for schema drift")] = None,
635
+ handler: Annotated[str | None, typer.Option("--handler", "-h", help="Handler symbol to check for schema drift")] = None,
636
+ table: Annotated[str | None, typer.Option("--table", "-t", help="Target table name (optional, inferred from mutation lineage)")] = None,
637
+ schema: Annotated[str | None, typer.Option("--schema", "-s", help="Schema class name (optional, inferred from handler signature)")] = None,
638
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
639
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
640
+ json_output: Annotated[bool, typer.Option("--json", help="Output raw JSON array")] = False,
641
+ ) -> None:
642
+ """Detect Pydantic / Form validation drift against destination database table columns."""
643
+ from codegraph.database.schema_drift import detect_schema_drift
644
+
645
+ repo = _resolve_repo(path, repository)
646
+ if not _ensure_indexed(repo):
647
+ return
648
+ target = route or handler or ""
649
+ if not target:
650
+ typer.echo("Error: Please specify --route or --handler to check for schema drift.")
651
+ raise typer.Exit(code=1)
652
+ with _indexer(repo).session() as con:
653
+ res = detect_schema_drift(con, repo, route_or_handler=target, target_table=table, schema_name=schema)
654
+ if json_output:
655
+ typer.echo(json.dumps(res, indent=2))
656
+ else:
657
+ if res.get("status") == "error":
658
+ typer.echo(f"Error: {res.get('error')}")
659
+ raise typer.Exit(code=1)
660
+ typer.echo(f"Schema Drift Report for {target} -> {res.get('target_table')} ({res.get('schema_name')}):")
661
+ typer.echo(f" Mode: {res.get('schema_mode', 'STATIC_TYPED')}")
662
+ recon = res.get("runtime_reconciliation", {})
663
+ if recon.get("is_observed"):
664
+ typer.echo(f" Runtime SQL: Observed ({recon.get('observation_count')} query executions)")
665
+ if recon.get("last_sql_sample"):
666
+ typer.echo(f" Sample Query: {recon.get('last_sql_sample')}")
667
+ else:
668
+ typer.echo(" Runtime SQL: NOT_OBSERVED_AT_RUNTIME (Run dev server or ingest traces to observe live queries)")
669
+ typer.echo(f" Total Issues: {res.get('drift_count')} (Critical: {res.get('critical_count')}, Warning: {res.get('warning_count')}, Info: {res.get('info_count')})")
670
+ for iss in res.get("issues", []):
671
+ typer.echo(f" [{iss['severity']}] {iss['drift_type']} on '{iss['field_name']}': {iss['message']}")
672
+
673
+
674
+ @app.command("api-drift")
675
+ def api_drift_cmd(
676
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
677
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
678
+ json_output: Annotated[bool, typer.Option("--json", help="Output raw JSON")] = False,
679
+ ) -> None:
680
+ """Detect cross-language client-server API contract drift (404s, 405s, and TypeScript-to-Backend type drift)."""
681
+ from codegraph.api_drift import detect_api_contract_drift
682
+
683
+ repo = _resolve_repo(path, repository)
684
+ if not _ensure_indexed(repo):
685
+ return
686
+
687
+ with _indexer(repo).session() as con:
688
+ report = detect_api_contract_drift(con, repo)
689
+
690
+ if json_output:
691
+ typer.echo(json.dumps(report.as_dict(), indent=2))
692
+ else:
693
+ if not report.has_drift:
694
+ typer.echo("No API contract drift detected.")
695
+ typer.echo(f"Verified {report.total_client_calls} client data fetches against {report.total_backend_routes} backend routes ({report.matched_calls} matched).")
696
+ else:
697
+ typer.echo(f"API Contract Drift Detected ({len(report.drifts)} issues):")
698
+ typer.echo(f"Client Calls: {report.total_client_calls} | Backend Routes: {report.total_backend_routes} | Matched: {report.matched_calls}\n")
699
+ for d in report.drifts:
700
+ typer.echo(f" [{d.severity}] {d.kind}")
701
+ typer.echo(f" Client: {d.client_method} {d.client_route} ({d.client_file}:{d.client_line})")
702
+ if d.backend_route:
703
+ typer.echo(f" Backend: {d.backend_route} -> {d.backend_handler} ({d.backend_file})")
704
+ if d.field_name:
705
+ typer.echo(f" Field: {d.field_name} (Client: {d.client_field_type} vs Backend: {d.backend_field_type})")
706
+ typer.echo(f" Message: {d.message}\n")
707
+
708
+
709
+ @app.command("api_drift", hidden=True)
710
+ def api_drift_alias(
711
+ path: Annotated[Path | None, typer.Argument(help="Repository path")] = None,
712
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
713
+ json_output: Annotated[bool, typer.Option("--json", help="Output raw JSON")] = False,
714
+ ) -> None:
715
+ """Alias for api-drift."""
716
+ api_drift_cmd(path=path, repository=repository, json_output=json_output)
717
+
718
+
719
+ @app.command()
720
+ def imports(
721
+ path: Annotated[str | None, typer.Argument(help="Repository-relative file path to inspect imports for (default: inspect all imports)")] = None,
722
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
723
+ json_output: Annotated[bool, typer.Option("--json", help="Output raw JSON array")] = True,
724
+ ) -> None:
725
+ """Show what a file or module imports."""
726
+ repo = _resolve_repo(repository=repository)
727
+ if not _ensure_indexed(repo):
728
+ return
729
+ with _indexer(repo).session() as con:
730
+ res = interrogation_get_imports(con, repo, file=path)
731
+ typer.echo(json.dumps(res, indent=2))
732
+
733
+
734
+ @app.command()
735
+ def dependents(
736
+ target: Annotated[str, typer.Argument(help="File path or symbol canonical ID to find dependents/importers for")],
737
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
738
+ json_output: Annotated[bool, typer.Option("--json", help="Output raw JSON array")] = True,
739
+ ) -> None:
740
+ """Show what files and symbols import or depend on the target file/symbol."""
741
+ repo = _resolve_repo(repository=repository)
742
+ if not _ensure_indexed(repo):
743
+ return
744
+ with _indexer(repo).session() as con:
745
+ is_path = "/" in target or target.endswith((".py", ".js", ".ts", ".jsx", ".tsx"))
746
+ if is_path:
747
+ res = interrogation_get_dependents(con, repo, file=target)
748
+ else:
749
+ res = interrogation_get_dependents(con, repo, canonical_id=target)
750
+ typer.echo(json.dumps(res, indent=2))
751
+
752
+
753
+ @app.command()
754
+ def architecture(
755
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
756
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
757
+ json_output: Annotated[bool, typer.Option("--json", help="Output full architecture model in JSON")] = True,
758
+ ) -> None:
759
+ """Summarize repository architecture including modules, frameworks, routes, and tests."""
760
+ repo = _resolve_repo(path, repository)
761
+ if not _ensure_indexed(repo):
762
+ return
763
+ with _indexer(repo).session() as con:
764
+ arch = get_architecture(con, repo)
765
+ typer.echo(json.dumps(arch, indent=2))
766
+
767
+
768
+ @app.command()
769
+ def debug(
770
+ question: str,
771
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
772
+ ) -> None:
773
+ """Return source-backed facts and clearly-labelled debugging hypotheses."""
774
+ repo = _resolve_repo(repository=repository)
775
+ if not _ensure_indexed(repo):
776
+ return
777
+ with _indexer(repo).session() as con:
778
+ spec, _ = normalize_task_spec(question, con=con)
779
+ unknown_explicit_targets = []
780
+ for t in spec.targets:
781
+ expr_type = classify_target_expression(t)
782
+ if expr_type == TargetExpressionType.EXPLICIT_SYMBOL_TARGET:
783
+ row = con.execute(
784
+ "SELECT 1 FROM symbols WHERE canonical_id=? OR qualified_name=? OR name=? "
785
+ "UNION SELECT 1 FROM framework_routes WHERE endpoint_id=? OR route_path=? "
786
+ "LIMIT 1",
787
+ (t, t, t, t, t),
788
+ ).fetchone()
789
+ if not row:
790
+ unknown_explicit_targets.append(t)
791
+
792
+ if unknown_explicit_targets:
793
+ unknown_target = unknown_explicit_targets[0]
794
+ lexical_evidence = [item.as_dict() for item in search_code(con, question, 10)]
795
+ typer.echo(
796
+ json.dumps(
797
+ {
798
+ "TARGET": unknown_target,
799
+ "STATUS": "UNKNOWN",
800
+ "MESSAGE": "No repository symbol/route/module matching the explicit target was found.",
801
+ "RELATED_LEXICAL_RESULTS": lexical_evidence,
802
+ },
803
+ indent=2,
804
+ )
805
+ )
806
+ return
807
+
808
+ evidence = [item.as_dict() for item in search_code(con, question, 10)]
809
+ typer.echo(
810
+ json.dumps(
811
+ {
812
+ "CONFIRMED FACT": evidence,
813
+ "LIKELY CAUSE": [
814
+ "A cited branch or raised exception may explain the symptom; this is not confirmed."
815
+ ],
816
+ "POSSIBLE CAUSE": [
817
+ "Other uncited runtime configuration or dependency behavior may contribute."
818
+ ],
819
+ "RECOMMENDATION": ["Reproduce with input that exercises each cited branch."],
820
+ },
821
+ indent=2,
822
+ )
823
+ )
824
+
825
+
826
+ @app.command(
827
+ "run",
828
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
829
+ )
830
+ def run_cmd(
831
+ ctx: typer.Context,
832
+ command: Annotated[list[str] | None, typer.Argument(help="Development command and arguments to execute")] = None,
833
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
834
+ sample_rate: Annotated[float, typer.Option("--sample-rate", help="Sampling rate for trace capture (0.0..1.0)")] = 1.0,
835
+ ) -> None:
836
+ """Execute a development command or server with transparent runtime trace capture."""
837
+ full_cmd = (command or []) + ctx.args
838
+ if not full_cmd:
839
+ cli_echo("Error: Command cannot be empty. Usage: codegraph run <command> [args...]")
840
+ raise typer.Exit(code=1)
841
+
842
+ repo = _resolve_repo(None, repository)
843
+ from codegraph.runtime.runner import run_with_telemetry
844
+
845
+ exit_code = run_with_telemetry(full_cmd, repository=repo, sample_rate=sample_rate)
846
+ raise typer.Exit(code=exit_code)
847
+
848
+
849
+ @app.command("ingest")
850
+ def ingest_cli_cmd(
851
+ source: Annotated[Path, typer.Argument(help="Path to OpenTelemetry JSON/JSONL, SQL query log, or trace file")],
852
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
853
+ format: Annotated[str, typer.Option("--format", "-f", help="Trace format: auto | otel | json | sql_log")] = "auto",
854
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
855
+ ) -> None:
856
+ """Ingest runtime traces (OpenTelemetry, JSON, or SQL query logs) into repository index."""
857
+ repo = _resolve_repo(None, repository)
858
+ if not _ensure_indexed(repo):
859
+ raise typer.Exit(code=1)
860
+
861
+ if not source.exists():
862
+ cli_echo(f"Error: Trace source file '{source}' does not exist.")
863
+ raise typer.Exit(code=1)
864
+
865
+ content = source.read_text(encoding="utf-8", errors="replace")
866
+ indexer = _indexer(repo)
867
+ with indexer.session() as con:
868
+ from codegraph.runtime.ingestor import ingest_runtime_traces
869
+
870
+ res = ingest_runtime_traces(con, content, repository=repo, format=format)
871
+
872
+ if json_output:
873
+ cli_echo(json.dumps(res, indent=2), json_mode=True)
874
+ else:
875
+ cli_echo("Runtime Ingestion Complete:")
876
+ cli_echo(f" Events Ingested: {res.get('events_ingested', 0)}")
877
+ cli_echo(f" Edges Upserted: {res.get('edges_upserted', 0)}")
878
+ cli_echo(f" Generation: {res.get('runtime_generation', 1)}")
879
+
880
+
881
+ def _run_server(
882
+ path: Path | None,
883
+ repository: Path | None,
884
+ profile: str,
885
+ transport: str = "stdio",
886
+ host: str = "127.0.0.1",
887
+ port: int = 8765,
888
+ ) -> None:
889
+ repo = _resolve_repo(path, repository)
890
+ from codegraph.mcp import create_server
891
+ from codegraph.process_lifecycle import run_mcp_stdio_server
892
+
893
+ if transport.lower() == "sse":
894
+ server = create_server(repo, profile=profile, host=host, port=port)
895
+ server.run(transport="sse")
896
+ else:
897
+ server = create_server(repo, profile=profile)
898
+ run_mcp_stdio_server(server, repo, profile=profile)
899
+
900
+
901
+ @app.command()
902
+ def serve(
903
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
904
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
905
+ profile: Annotated[str, typer.Option("--profile", help="Tool profile: core | minimal | developer | full")] = "full",
906
+ transport: Annotated[str, typer.Option("--transport", "-t", help="MCP transport: stdio | sse")] = "stdio",
907
+ host: Annotated[str, typer.Option("--host", help="Host for SSE server")] = "127.0.0.1",
908
+ port: Annotated[int, typer.Option("--port", "-p", help="Port for SSE server")] = 8765,
909
+ ) -> None:
910
+ """Run the CodeGraph MCP server via stdio or SSE transport."""
911
+ _run_server(path, repository, profile, transport=transport, host=host, port=port)
912
+
913
+
914
+ mcp_app = typer.Typer(help="MCP server commands.")
915
+ app.add_typer(mcp_app, name="mcp")
916
+
917
+
918
+ @mcp_app.command("serve")
919
+ def mcp_serve(
920
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
921
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
922
+ profile: Annotated[str, typer.Option("--profile", help="Tool profile: core | graph | minimal | developer | full")] = "full",
923
+ transport: Annotated[str, typer.Option("--transport", "-t", help="MCP transport: stdio | sse")] = "stdio",
924
+ host: Annotated[str, typer.Option("--host", help="Host for SSE server")] = "127.0.0.1",
925
+ port: Annotated[int, typer.Option("--port", "-p", help="Port for SSE server")] = 8765,
926
+ ) -> None:
927
+ """Run the CodeGraph MCP server via stdio or SSE transport."""
928
+ _run_server(path, repository, profile, transport=transport, host=host, port=port)
929
+
930
+
931
+ def _execute_stop(
932
+ path: Path | None,
933
+ repository: Path | None,
934
+ all_processes: bool,
935
+ timeout: float,
936
+ json_output: bool,
937
+ ) -> None:
938
+ from codegraph.process_lifecycle import stop_codegraph_processes
939
+
940
+ target_repo: Path | None = None
941
+ if not all_processes:
942
+ target_repo = _resolve_repo(path, repository)
943
+
944
+ res = stop_codegraph_processes(
945
+ repository=target_repo,
946
+ stop_all=all_processes,
947
+ timeout_sec=timeout,
948
+ )
949
+ if json_output:
950
+ cli_echo(json.dumps(res.as_dict(), indent=2), json_mode=True)
951
+ else:
952
+ cli_echo(res.format_human())
953
+
954
+
955
+ @app.command("stop")
956
+ def stop_cmd(
957
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
958
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
959
+ all_processes: Annotated[bool, typer.Option("--all", "-a", help="Stop all CodeGraph-owned MCP processes across all repositories")] = False,
960
+ timeout: Annotated[float, typer.Option("--timeout", help="Graceful shutdown timeout in seconds before force-kill")] = 3.0,
961
+ json_output: Annotated[bool, typer.Option("--json", help="Output deterministic machine-readable JSON")] = False,
962
+ ) -> None:
963
+ """Safely stop running CodeGraph MCP background processes and release file locks."""
964
+ _execute_stop(path, repository, all_processes, timeout, json_output)
965
+
966
+
967
+ @mcp_app.command("stop")
968
+ def mcp_stop_cmd(
969
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
970
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
971
+ all_processes: Annotated[bool, typer.Option("--all", "-a", help="Stop all CodeGraph-owned MCP processes across all repositories")] = False,
972
+ timeout: Annotated[float, typer.Option("--timeout", help="Graceful shutdown timeout in seconds before force-kill")] = 3.0,
973
+ json_output: Annotated[bool, typer.Option("--json", help="Output deterministic machine-readable JSON")] = False,
974
+ ) -> None:
975
+ """Safely stop running CodeGraph MCP background processes and release file locks."""
976
+ _execute_stop(path, repository, all_processes, timeout, json_output)
977
+
978
+
979
+ @mcp_app.command("kill")
980
+ def mcp_kill_cmd(
981
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
982
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
983
+ all_processes: Annotated[bool, typer.Option("--all", "-a", help="Stop all CodeGraph-owned MCP processes across all repositories")] = False,
984
+ timeout: Annotated[float, typer.Option("--timeout", help="Graceful shutdown timeout in seconds before force-kill")] = 1.0,
985
+ json_output: Annotated[bool, typer.Option("--json", help="Output deterministic machine-readable JSON")] = False,
986
+ ) -> None:
987
+ """Alias for `codegraph stop` to terminate CodeGraph MCP processes and release locks."""
988
+ _execute_stop(path, repository, all_processes, timeout, json_output)
989
+
990
+
991
+ @mcp_app.command("doctor")
992
+ def mcp_doctor(
993
+ profile: Annotated[str, typer.Option("--profile", help="Tool profile to validate")] = "full",
994
+ json_output: Annotated[bool, typer.Option("--json", help="Output deterministic JSON report")] = False,
995
+ ) -> None:
996
+ """Run end-to-end MCP server startup, handshake, tool discovery, and fixture query diagnostics."""
997
+ from codegraph.mcp_diagnostics import run_mcp_doctor
998
+
999
+ report = run_mcp_doctor(profile=profile)
1000
+ if json_output:
1001
+ cli_echo(json.dumps(report.as_dict(), indent=2), json_mode=True)
1002
+ else:
1003
+ cli_echo(report.format_human())
1004
+ if not report.healthy:
1005
+ raise typer.Exit(code=1)
1006
+
1007
+
1008
+ @mcp_app.command("config-check")
1009
+ def mcp_config_check(
1010
+ path: Annotated[Path | None, typer.Argument(help="Workspace path (default: current directory)")] = None,
1011
+ config_file: Annotated[Path | None, typer.Option("--config", "-c", help="Explicit MCP config JSON path")] = None,
1012
+ include_example: Annotated[bool, typer.Option("--include-example", help="Include .agents/mcp_config.json.example")] = False,
1013
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON report")] = True,
1014
+ ) -> None:
1015
+ """Validate MCP configuration read-only without mutating files or exposing secrets."""
1016
+ from codegraph.mcp_diagnostics import check_mcp_configuration
1017
+
1018
+ ws = (path or Path.cwd()).resolve()
1019
+ rep = check_mcp_configuration(workspace_dir=ws, config_file=config_file, include_example=include_example)
1020
+ if json_output:
1021
+ cli_echo(json.dumps(rep.as_dict(), indent=2), json_mode=True)
1022
+ else:
1023
+ cli_echo(f"{rep.status.value}: {rep.message}")
1024
+
1025
+
1026
+ @mcp_app.command("capabilities")
1027
+ def mcp_capabilities() -> None:
1028
+ """Output the deterministic machine-readable CodeGraph capability manifest."""
1029
+ from codegraph.agent_capabilities import get_capability_manifest
1030
+
1031
+ cli_echo(json.dumps(get_capability_manifest(), indent=2), json_mode=True)
1032
+
1033
+
1034
+ @mcp_app.command("rules")
1035
+ def mcp_rules(
1036
+ agent: Annotated[str, typer.Option("--agent", "-a", help="Target agent (antigravity, claude, cursor, gemini, codex, cline, agents)")] = "antigravity",
1037
+ ) -> None:
1038
+ """Render deterministic CodeGraph agent rules for the specified agent."""
1039
+ from codegraph.agent_rules import render_agent_rules
1040
+
1041
+ cli_echo(render_agent_rules(agent=agent), nl=False)
1042
+
1043
+
1044
+ @app.command()
1045
+ def doctor(
1046
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
1047
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1048
+ resources: Annotated[bool, typer.Option("--resources")] = False,
1049
+ database: Annotated[bool, typer.Option("--database", help="Run comprehensive database integrity and schema checks")] = False,
1050
+ processes: Annotated[bool, typer.Option("--processes", help="Inspect active CodeGraph processes, parent state, and stale PID files")] = False,
1051
+ json_output: Annotated[bool, typer.Option("--json")] = False,
1052
+ ) -> None:
1053
+ """Check repository and index readiness, database integrity, process lifecycle, and freshness."""
1054
+ if processes:
1055
+ from codegraph.process_lifecycle import discover_codegraph_processes
1056
+
1057
+ discovery = discover_codegraph_processes(clean_stale=True)
1058
+ if json_output:
1059
+ cli_echo(json.dumps(discovery.as_dict(), indent=2), json_mode=True)
1060
+ else:
1061
+ cli_echo(discovery.format_human())
1062
+ return
1063
+
1064
+ repo = _resolve_repo(path, repository)
1065
+ indexer_inst = _indexer(repo)
1066
+ db = repo / ".codegraph.sqlite3"
1067
+ result: dict[str, object] = {
1068
+ "repository": str(repo.resolve()),
1069
+ "index_exists": db.exists(),
1070
+ "python": "supported",
1071
+ }
1072
+ if db.exists():
1073
+ with indexer_inst.session() as con:
1074
+ health = check_database_health(con, repo)
1075
+ freshness_rep = check_freshness(repo, con)
1076
+ gen = index_generation(con)
1077
+ result["health"] = health
1078
+ result["status"] = (
1079
+ "healthy"
1080
+ if health.get("status") in ("healthy", "OK") and freshness_rep.status.value == "FRESH"
1081
+ else "warning"
1082
+ )
1083
+ result["freshness"] = freshness_rep.status.value
1084
+ result["freshness_detail"] = freshness_rep.detail
1085
+ result["index_generation"] = gen
1086
+ result["counts"] = {
1087
+ "files": con.execute("SELECT count(*) FROM files").fetchone()[0],
1088
+ "symbols": con.execute("SELECT count(*) FROM symbols").fetchone()[0],
1089
+ "chunks": con.execute("SELECT count(*) FROM chunks").fetchone()[0],
1090
+ "framework_routes": con.execute("SELECT count(*) FROM framework_routes").fetchone()[0],
1091
+ "graph_edges": con.execute("SELECT count(*) FROM graph_edges").fetchone()[0],
1092
+ }
1093
+ else:
1094
+ result["status"] = "not_indexed"
1095
+
1096
+ if database:
1097
+ if not db.exists():
1098
+ typer.echo(json.dumps({"status": "not_indexed", "error": "Database does not exist"}, indent=2))
1099
+ return
1100
+ with indexer_inst.session() as con:
1101
+ health = check_database_health(con, repo)
1102
+ typer.echo(json.dumps(health, indent=2))
1103
+ return
1104
+
1105
+ result["resources"] = indexer_inst.governor.get_state().as_dict()
1106
+ typer.echo(json.dumps(result, indent=2))
1107
+
1108
+
1109
+ @app.command()
1110
+ def resolve(
1111
+ symbol: str,
1112
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1113
+ json_output: Annotated[bool, typer.Option("--json")] = False,
1114
+ ) -> None:
1115
+ """Resolve a symbol with authoritative diagnostic, callers, and callees."""
1116
+ repo = _resolve_repo(repository=repository)
1117
+ if not _ensure_indexed(repo):
1118
+ return
1119
+ with _indexer(repo).session() as con:
1120
+ diag = ReferenceResolver.resolve_symbol_diagnostic(con, symbol)
1121
+ typer.echo(json.dumps(diag, indent=2))
1122
+
1123
+
1124
+ @app.command()
1125
+ def privacy(
1126
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
1127
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1128
+ json_output: Annotated[bool, typer.Option("--json")] = False,
1129
+ ) -> None:
1130
+ """Verify repository privacy boundaries and ensure no sensitive data is indexed."""
1131
+ repo = _resolve_repo(path, repository)
1132
+ db = repo / ".codegraph.sqlite3"
1133
+ indexed_files: list[str] = []
1134
+ if db.exists():
1135
+ with _indexer(repo).session() as con:
1136
+ indexed_files = [r[0] for r in con.execute("SELECT path FROM files").fetchall()]
1137
+ sensitive_leaks = [p for p in indexed_files if is_sensitive(Path(p))]
1138
+ report: dict[str, object] = {
1139
+ "repository": str(repo.resolve()),
1140
+ "sensitive_files_indexed": len(sensitive_leaks),
1141
+ "leaks": sensitive_leaks,
1142
+ "clean": len(sensitive_leaks) == 0,
1143
+ "boundary_enforced": True,
1144
+ "source_upload": "DISABLED (100% local execution)",
1145
+ "telemetry": "DISABLED",
1146
+ "network_access": "DISABLED",
1147
+ "shell_execution": "DISABLED",
1148
+ "model_api_required": False,
1149
+ "prompt_injection_boundary": "STRICT (code and repo docs treated as passive DATA)",
1150
+ }
1151
+ typer.echo(json.dumps(report, indent=2))
1152
+
1153
+
1154
+ @app.command()
1155
+ def status(
1156
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
1157
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1158
+ json_output: Annotated[bool, typer.Option("--json")] = False,
1159
+ ) -> None:
1160
+ """Show repository indexing status, freshness, and graph statistics."""
1161
+ repo = _resolve_repo(path, repository)
1162
+ db = repo / ".codegraph.sqlite3"
1163
+ if not db.exists():
1164
+ typer.echo(
1165
+ json.dumps(
1166
+ {
1167
+ "status": "error",
1168
+ "repository": str(repo.resolve()),
1169
+ "error": {
1170
+ "code": ErrorCode.INDEX_NOT_FOUND.value,
1171
+ "message": "No CodeGraph index exists for this repository.",
1172
+ "next_action": {
1173
+ "command": "codegraph init",
1174
+ "reason": "Initialize the repository before querying it.",
1175
+ },
1176
+ },
1177
+ },
1178
+ indent=2,
1179
+ )
1180
+ )
1181
+ return
1182
+ indexer_inst = _indexer(repo)
1183
+ with indexer_inst.session() as con:
1184
+ freshness_rep = check_freshness(repo, con)
1185
+ file_count = con.execute("SELECT count(*) FROM files").fetchone()[0]
1186
+ symbol_count = con.execute("SELECT count(*) FROM symbols").fetchone()[0]
1187
+ chunk_count = con.execute("SELECT count(*) FROM chunks").fetchone()[0]
1188
+ route_count = con.execute("SELECT count(*) FROM framework_routes").fetchone()[0]
1189
+ edge_count = con.execute("SELECT count(*) FROM graph_edges").fetchone()[0]
1190
+ gov_state = indexer_inst.governor.get_state().as_dict()
1191
+ rep = {
1192
+ "repository": str(repo.resolve()),
1193
+ "freshness": freshness_rep.status.value,
1194
+ "freshness_detail": freshness_rep.detail,
1195
+ "files": file_count,
1196
+ "chunks": chunk_count,
1197
+ "symbols": symbol_count,
1198
+ "framework_routes": route_count,
1199
+ "graph_edges": edge_count,
1200
+ "resource_profile": gov_state["policy_profile"],
1201
+ "pressure_level": gov_state["pressure_level"],
1202
+ "activity_mode": gov_state["activity_mode"],
1203
+ "estimated_memory_mb": gov_state["estimated_memory_mb"],
1204
+ "modified_files": freshness_rep.modified_files,
1205
+ "deleted_files": freshness_rep.deleted_files,
1206
+ "parse_failed_files": freshness_rep.parse_failed_files,
1207
+ }
1208
+ typer.echo(json.dumps(rep, indent=2))
1209
+
1210
+
1211
+ @app.command()
1212
+ def context(
1213
+ task: str,
1214
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1215
+ intent: Annotated[str | None, typer.Option("--intent", "-i")] = None,
1216
+ max_tokens: Annotated[int, typer.Option("--max-tokens")] = 20000,
1217
+ top_k: Annotated[int, typer.Option("--top-k")] = 15,
1218
+ mode: Annotated[str, typer.Option("--mode", "-m", help="FAST | BALANCED | DEEP")] = "BALANCED",
1219
+ explain: Annotated[bool, typer.Option("--explain", "-e", help="Explain budget rejections and retrieval details")] = False,
1220
+ json_output: Annotated[bool, typer.Option("--json")] = False,
1221
+ ) -> None:
1222
+ """Compile evidence-backed context packet for an AI agent task."""
1223
+ repo = _resolve_repo(repository=repository)
1224
+ if not _ensure_indexed(repo):
1225
+ return
1226
+ with _indexer(repo).session() as con:
1227
+ packet = get_context(
1228
+ con,
1229
+ repo,
1230
+ task=task,
1231
+ intent=intent,
1232
+ max_tokens=max_tokens,
1233
+ top_k=top_k,
1234
+ mode=mode,
1235
+ explain=explain,
1236
+ )
1237
+ if json_output:
1238
+ typer.echo(json.dumps(packet.as_dict(), indent=2))
1239
+ else:
1240
+ typer.echo(packet.format_human())
1241
+
1242
+
1243
+ @app.command("explain-context")
1244
+ def explain_context(
1245
+ task: str,
1246
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1247
+ intent: Annotated[str | None, typer.Option("--intent", "-i")] = None,
1248
+ max_tokens: Annotated[int, typer.Option("--max-tokens")] = 20000,
1249
+ mode: Annotated[str, typer.Option("--mode", "-m")] = "BALANCED",
1250
+ ) -> None:
1251
+ """Compile context with explicit explainability and budget rejection details."""
1252
+ repo = _resolve_repo(repository=repository)
1253
+ if not _ensure_indexed(repo):
1254
+ return
1255
+ with _indexer(repo).session() as con:
1256
+ packet = get_context(
1257
+ con,
1258
+ repo,
1259
+ task=task,
1260
+ intent=intent,
1261
+ max_tokens=max_tokens,
1262
+ mode=mode,
1263
+ explain=True,
1264
+ )
1265
+ typer.echo(json.dumps(packet.as_dict(), indent=2))
1266
+
1267
+
1268
+ @app.command()
1269
+ def task(
1270
+ prompt: str,
1271
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1272
+ json_output: Annotated[bool, typer.Option("--json")] = False,
1273
+ ) -> None:
1274
+ """Normalize a prompt into a structured TaskSpec and detect ambiguities."""
1275
+ repo = _resolve_repo(repository=repository)
1276
+ if not _ensure_indexed(repo):
1277
+ return
1278
+ with _indexer(repo).session() as con:
1279
+ spec, ambiguities = normalize_task_spec(prompt, con)
1280
+ res = {
1281
+ "task_spec": spec.as_dict(),
1282
+ "ambiguities": [a.as_dict() for a in ambiguities],
1283
+ }
1284
+ typer.echo(json.dumps(res, indent=2))
1285
+
1286
+
1287
+ @app.command()
1288
+ def plan(
1289
+ prompt: str,
1290
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1291
+ max_tokens: Annotated[int, typer.Option("--max-tokens")] = 20000,
1292
+ json_output: Annotated[bool, typer.Option("--json")] = False,
1293
+ ) -> None:
1294
+ """Construct a deterministic RetrievalPlan for a task."""
1295
+ repo = _resolve_repo(repository=repository)
1296
+ if not _ensure_indexed(repo):
1297
+ return
1298
+ with _indexer(repo).session() as con:
1299
+ spec, ambiguities = normalize_task_spec(prompt, con)
1300
+ retrieval_plan = build_retrieval_plan(spec, ambiguities, con, token_budget=max_tokens)
1301
+ typer.echo(json.dumps(retrieval_plan.as_dict(), indent=2))
1302
+
1303
+
1304
+ @app.command()
1305
+ def benchmark(
1306
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
1307
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1308
+ suite: Annotated[Path | None, typer.Option("--suite", "-s")] = None,
1309
+ max_tokens: Annotated[int, typer.Option("--max-tokens")] = 20000,
1310
+ json_output: Annotated[bool, typer.Option("--json")] = False,
1311
+ ) -> None:
1312
+ """Run deterministic codebase intelligence benchmark suite."""
1313
+ repo = _resolve_repo(path, repository)
1314
+ from benchmarks.runner import run_benchmark_suite
1315
+
1316
+ result = run_benchmark_suite(repo, suite_file=suite, max_tokens=max_tokens)
1317
+ typer.echo(json.dumps(result, indent=2))
1318
+
1319
+
1320
+ memory_app = typer.Typer(help="Inspect or clear repository-scoped durable notes.")
1321
+ app.add_typer(memory_app, name="memory")
1322
+
1323
+
1324
+ @memory_app.command("list")
1325
+ def memory_list(
1326
+ path: Annotated[Path | None, typer.Argument(help="Repository path")] = None,
1327
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1328
+ ) -> None:
1329
+ repo = _resolve_repo(path, repository)
1330
+ typer.echo(json.dumps(MemoryStore(repo / ".codegraph.sqlite3").list(), indent=2))
1331
+
1332
+
1333
+ @memory_app.command("clear")
1334
+ def memory_clear(
1335
+ path: Annotated[Path | None, typer.Argument(help="Repository path")] = None,
1336
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1337
+ ) -> None:
1338
+ repo = _resolve_repo(path, repository)
1339
+ MemoryStore(repo / ".codegraph.sqlite3").clear()
1340
+ typer.echo("Repository memory cleared.")
1341
+
1342
+
1343
+ @app.command()
1344
+ def version() -> None:
1345
+ typer.echo(__version__)
1346
+
1347
+
1348
+ @app.command()
1349
+ def install(
1350
+ path: Annotated[Path | None, typer.Argument(help="Project path (default: current directory)")] = None,
1351
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Project path")] = None,
1352
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Confirm installation without interactive prompt")] = False,
1353
+ target: Annotated[
1354
+ str,
1355
+ typer.Option("--target", "-t", help="Target agents: auto | all | claude,cursor,antigravity,codex,gemini,cline"),
1356
+ ] = "auto",
1357
+ location: Annotated[
1358
+ str,
1359
+ typer.Option("--location", "-l", help="Installation scope: local | global"),
1360
+ ] = "local",
1361
+ print_config: Annotated[
1362
+ str | None,
1363
+ typer.Option("--print-config", help="Print MCP configuration and rules for a specific agent without modifying files"),
1364
+ ] = None,
1365
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Show what would change without modifying any files")] = False,
1366
+ repair: Annotated[bool, typer.Option("--repair", help="Back up and repair malformed JSON configuration files")] = False,
1367
+ json_output: Annotated[bool, typer.Option("--json", help="Output deterministic machine-readable JSON")] = False,
1368
+ ) -> None:
1369
+ """Detect supported AI agents, configure CodeGraph MCP, install rules/skills, and verify health."""
1370
+ from codegraph.errors import CodeGraphError
1371
+ from codegraph.installer import (
1372
+ AGENT_ALIASES,
1373
+ format_marker_block,
1374
+ render_agent_mcp_config_snippet,
1375
+ run_install,
1376
+ )
1377
+
1378
+ if print_config is not None:
1379
+ try:
1380
+ canon = AGENT_ALIASES.get(print_config.strip().lower(), print_config.strip().lower())
1381
+ snippet = render_agent_mcp_config_snippet(canon)
1382
+ from codegraph.agent_rules import render_agent_rules
1383
+
1384
+ rules_txt = render_agent_rules(canon)
1385
+ if json_output:
1386
+ cli_echo(
1387
+ json.dumps(
1388
+ {
1389
+ "status": "ok",
1390
+ "agent": canon,
1391
+ "mcp_config": snippet,
1392
+ "instructions_block": format_marker_block(rules_txt),
1393
+ },
1394
+ indent=2,
1395
+ ),
1396
+ json_mode=True,
1397
+ )
1398
+ else:
1399
+ cli_echo(f"# MCP Configuration ({canon})\n")
1400
+ cli_echo(json.dumps(snippet, indent=2), json_mode=True)
1401
+ cli_echo(f"\n# Instructions Block ({canon})\n")
1402
+ cli_echo(format_marker_block(rules_txt), nl=False)
1403
+ return
1404
+ except CodeGraphError as exc:
1405
+ if json_output:
1406
+ cli_echo(json.dumps(exc.to_response(), indent=2), json_mode=True)
1407
+ else:
1408
+ cli_echo(f"Error [{exc.code}]: {exc.message}")
1409
+ if exc.next_action and exc.next_action.get("command"):
1410
+ cli_echo(f"\nTry:\n {exc.next_action['command']}")
1411
+ raise typer.Exit(code=1) from exc
1412
+
1413
+ ws = _resolve_repo(path, repository)
1414
+
1415
+ def _confirm(preview_rep: object) -> bool:
1416
+ from codegraph.installer import InstallerReport
1417
+
1418
+ assert isinstance(preview_rep, InstallerReport)
1419
+ if not json_output:
1420
+ cli_echo(preview_rep.format_preview_human())
1421
+ cli_echo("")
1422
+ cli_echo("Continue? [y/N] ", nl=False)
1423
+ try:
1424
+ ans = input().strip().lower()
1425
+ except EOFError:
1426
+ ans = ""
1427
+ if not json_output:
1428
+ cli_echo("")
1429
+ return ans in ("y", "yes")
1430
+
1431
+ try:
1432
+ report = run_install(
1433
+ workspace=ws,
1434
+ target=target,
1435
+ location=location,
1436
+ dry_run=dry_run,
1437
+ allow_malformed_repair=repair,
1438
+ confirm_fn=None if (yes or dry_run) else _confirm,
1439
+ )
1440
+ except CodeGraphError as exc:
1441
+ if json_output:
1442
+ cli_echo(json.dumps(exc.to_response(), indent=2), json_mode=True)
1443
+ else:
1444
+ cli_echo(f"Error [{exc.code}]: {exc.message}")
1445
+ if exc.next_action and exc.next_action.get("reason"):
1446
+ cli_echo(f"\nExpected:\n {exc.next_action['reason']}")
1447
+ if exc.next_action and exc.next_action.get("command"):
1448
+ cli_echo(f"\nTry:\n {exc.next_action['command']}")
1449
+ raise typer.Exit(code=1) from exc
1450
+
1451
+ if json_output:
1452
+ cli_echo(json.dumps(report.as_dict(), indent=2), json_mode=True)
1453
+ else:
1454
+ if yes and not dry_run:
1455
+ cli_echo(report.format_preview_human())
1456
+ cli_echo("")
1457
+ cli_echo(report.format_human())
1458
+
1459
+ if report.status == "error":
1460
+ raise typer.Exit(code=1)
1461
+
1462
+
1463
+ @app.command()
1464
+ def uninstall(
1465
+ path: Annotated[Path | None, typer.Argument(help="Project path (default: current directory)")] = None,
1466
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Project path")] = None,
1467
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Confirm uninstall without interactive prompt")] = False,
1468
+ target: Annotated[
1469
+ str,
1470
+ typer.Option("--target", "-t", help="Target agents: all | auto | claude,cursor,antigravity,codex,gemini,cline"),
1471
+ ] = "all",
1472
+ location: Annotated[
1473
+ str,
1474
+ typer.Option("--location", "-l", help="Installation scope: local | global"),
1475
+ ] = "local",
1476
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Show what would be removed without changing any files")] = False,
1477
+ json_output: Annotated[bool, typer.Option("--json", help="Output deterministic machine-readable JSON")] = False,
1478
+ ) -> None:
1479
+ """Remove CodeGraph-managed agent integrations while preserving user config and project index."""
1480
+ from codegraph.errors import CodeGraphError
1481
+ from codegraph.installer import InstallerReport, run_uninstall
1482
+
1483
+ ws = _resolve_repo(path, repository)
1484
+
1485
+ def _confirm_uninstall(preview_rep: InstallerReport) -> bool:
1486
+ if not json_output:
1487
+ cli_echo("CodeGraph Uninstaller\n")
1488
+ for c in preview_rep.changes:
1489
+ if c.action in ("remove", "modify"):
1490
+ cli_echo(f" - {c.display_path} ({c.action})")
1491
+ cli_echo("\nContinue? [y/N] ", nl=False)
1492
+ try:
1493
+ ans = input().strip().lower()
1494
+ except EOFError:
1495
+ ans = ""
1496
+ if not json_output:
1497
+ cli_echo("")
1498
+ return ans in ("y", "yes")
1499
+
1500
+ try:
1501
+ report = run_uninstall(
1502
+ workspace=ws,
1503
+ target=target,
1504
+ location=location,
1505
+ dry_run=dry_run,
1506
+ confirm_fn=None if (yes or dry_run) else _confirm_uninstall,
1507
+ )
1508
+ except CodeGraphError as exc:
1509
+ if json_output:
1510
+ cli_echo(json.dumps(exc.to_response(), indent=2), json_mode=True)
1511
+ else:
1512
+ cli_echo(f"Error [{exc.code}]: {exc.message}")
1513
+ if exc.next_action and exc.next_action.get("command"):
1514
+ cli_echo(f"\nTry:\n {exc.next_action['command']}")
1515
+ raise typer.Exit(code=1) from exc
1516
+
1517
+ if json_output:
1518
+ cli_echo(json.dumps(report.as_dict(), indent=2), json_mode=True)
1519
+ else:
1520
+ cli_echo(report.format_human())
1521
+
1522
+
1523
+ @app.command()
1524
+ def uninit(
1525
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
1526
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1527
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Confirm removal without interactive prompt")] = False,
1528
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview index files that would be removed")] = False,
1529
+ json_output: Annotated[bool, typer.Option("--json", help="Output deterministic machine-readable JSON")] = False,
1530
+ ) -> None:
1531
+ """Remove the current project's CodeGraph index (.codegraph.sqlite3 and .codegraph/) without touching source or git."""
1532
+ from codegraph.errors import CodeGraphError
1533
+ from codegraph.installer import InstallerReport, run_uninit
1534
+
1535
+ ws = _resolve_repo(path, repository)
1536
+
1537
+ def _confirm_uninit(preview_rep: InstallerReport) -> bool:
1538
+ if not json_output:
1539
+ cli_echo("Remove CodeGraph project index files?\n")
1540
+ for c in preview_rep.changes:
1541
+ cli_echo(f" - {c.display_path}")
1542
+ cli_echo("\nContinue? [y/N] ", nl=False)
1543
+ try:
1544
+ ans = input().strip().lower()
1545
+ except EOFError:
1546
+ ans = ""
1547
+ if not json_output:
1548
+ cli_echo("")
1549
+ return ans in ("y", "yes")
1550
+
1551
+ try:
1552
+ report = run_uninit(
1553
+ workspace=ws,
1554
+ dry_run=dry_run,
1555
+ confirm_fn=None if (yes or dry_run) else _confirm_uninit,
1556
+ )
1557
+ except CodeGraphError as exc:
1558
+ if json_output:
1559
+ cli_echo(json.dumps(exc.to_response(), indent=2), json_mode=True)
1560
+ else:
1561
+ cli_echo(f"Error [{exc.code}]: {exc.message}")
1562
+ raise typer.Exit(code=1) from exc
1563
+
1564
+ if json_output:
1565
+ cli_echo(json.dumps(report.as_dict(), indent=2), json_mode=True)
1566
+ else:
1567
+ cli_echo(report.format_human())
1568
+
1569
+
1570
+ @app.command("git-state")
1571
+ def git_state_cmd(
1572
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
1573
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1574
+ sync: Annotated[bool, typer.Option("--sync", help="Incrementally synchronize affected graph state if dirty/stale")] = False,
1575
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
1576
+ ) -> None:
1577
+ """Track current Git branch, HEAD, indexed commit, working-tree modifications, and freshness states (CLEAN, DIRTY, STALE, REINDEXING, ERROR)."""
1578
+ from codegraph.git_state import get_git_state, incremental_git_sync
1579
+
1580
+ repo = _resolve_repo(path, repository)
1581
+ indexer = _indexer(repo)
1582
+
1583
+ if sync:
1584
+ with indexer.session() as con:
1585
+ res = incremental_git_sync(repo, con=con)
1586
+ if json_output:
1587
+ cli_echo(json.dumps(res.as_dict(), indent=2), json_mode=True)
1588
+ else:
1589
+ cli_echo(f"Incremental Sync: {res.status.upper()} ({res.duration_ms:.1f}ms) - {res.detail}")
1590
+ return
1591
+
1592
+ with indexer.session() as con:
1593
+ rep = get_git_state(repo, con=con)
1594
+
1595
+ if json_output:
1596
+ cli_echo(json.dumps(rep.as_dict(), indent=2), json_mode=True)
1597
+ else:
1598
+ cli_echo(f"Git Repository: {repo}")
1599
+ cli_echo(f"Branch: {rep.branch or 'N/A'}")
1600
+ cli_echo(f"Current HEAD: {rep.current_head[:8] if rep.current_head else 'N/A'}")
1601
+ cli_echo(f"Indexed HEAD: {rep.indexed_head[:8] if rep.indexed_head else 'Not indexed'}")
1602
+ cli_echo(f"Freshness: {rep.freshness.value}")
1603
+ cli_echo(f"Status Detail: {rep.detail}")
1604
+ if rep.working_tree.is_dirty:
1605
+ cli_echo("\nWorking Tree Changes:")
1606
+ if rep.working_tree.modified_files:
1607
+ cli_echo(f" Modified: {len(rep.working_tree.modified_files)} files ({', '.join(rep.working_tree.modified_files[:3])}...)")
1608
+ if rep.working_tree.staged_files:
1609
+ cli_echo(f" Staged: {len(rep.working_tree.staged_files)} files ({', '.join(rep.working_tree.staged_files[:3])}...)")
1610
+ if rep.working_tree.untracked_files:
1611
+ cli_echo(f" Untracked: {len(rep.working_tree.untracked_files)} files ({', '.join(rep.working_tree.untracked_files[:3])}...)")
1612
+ if rep.working_tree.deleted_files:
1613
+ cli_echo(f" Deleted: {len(rep.working_tree.deleted_files)} files")
1614
+
1615
+
1616
+ @app.command("git-diff")
1617
+ def git_diff_cmd(
1618
+ base: Annotated[str, typer.Argument(help="Base Git ref or commit (default: HEAD~1)")] = "HEAD~1",
1619
+ head: Annotated[str, typer.Argument(help="Head Git ref, commit, or branch (default: HEAD)")] = "HEAD",
1620
+ path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
1621
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1622
+ branch_comparison: Annotated[bool, typer.Option("--branch", "-b", help="Perform hierarchical branch architecture comparison")] = False,
1623
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
1624
+ ) -> None:
1625
+ """Compute structural AST-level difference between Git revisions, commits, or branches."""
1626
+ repo = _resolve_repo(path, repository)
1627
+ indexer = _indexer(repo)
1628
+
1629
+ if branch_comparison:
1630
+ from codegraph.branch_comparison import compare_branch_architecture
1631
+ with indexer.session() as con:
1632
+ b_rep = compare_branch_architecture(repo, base_branch=base, head_branch=head, con=con)
1633
+ if json_output:
1634
+ cli_echo(json.dumps(b_rep.as_dict(), indent=2), json_mode=True)
1635
+ else:
1636
+ cli_echo(f"Branch Architecture Comparison: {base} -> {head}")
1637
+ cli_echo(f"Packages: {b_rep.total_packages} | Symbols: {b_rep.total_symbols} (~{b_rep.estimated_tokens} tokens)")
1638
+ for pkg in b_rep.packages:
1639
+ cli_echo(f"\nPackage: {pkg.package_id}")
1640
+ for mod in pkg.modules:
1641
+ cli_echo(f" Module: {mod.module} ({mod.file_path})")
1642
+ for s in mod.symbols:
1643
+ cli_echo(f" [{s.change_type}] {s.kind} {s.name}")
1644
+ for rel in s.relationships:
1645
+ cli_echo(f" -> {rel}")
1646
+ return
1647
+
1648
+ from codegraph.structural_diff import compare_revisions
1649
+ with indexer.session() as con:
1650
+ s_rep = compare_revisions(repo, base=base, head=head, con=con)
1651
+
1652
+ if json_output:
1653
+ cli_echo(json.dumps(s_rep.as_dict(), indent=2), json_mode=True)
1654
+ else:
1655
+ cli_echo(f"Structural Diff: {base} -> {head}")
1656
+ cli_echo(f"Files Changed: {s_rep.total_file_changes} (Added: {len(s_rep.added_files)}, Modified: {len(s_rep.modified_files)}, Deleted: {len(s_rep.deleted_files)}, Renamed: {len(s_rep.renamed_files)})")
1657
+ cli_echo(f"Symbols Changed: {s_rep.total_symbol_changes} (Added: {len(s_rep.added_symbols)}, Modified: {len(s_rep.changed_symbols)}, Removed: {len(s_rep.removed_symbols)})")
1658
+ if s_rep.changed_routes:
1659
+ cli_echo(f"Changed Routes: {len(s_rep.changed_routes)}")
1660
+ for r in s_rep.changed_routes:
1661
+ cli_echo(f" [{r.change_type}] {r.method} {r.path} -> {r.handler}")
1662
+ if s_rep.affected_tests:
1663
+ cli_echo(f"Affected Tests: {len(s_rep.affected_tests)} ({', '.join(s_rep.affected_tests[:5])})")
1664
+
1665
+
1666
+ @app.command("impact")
1667
+ def impact_cmd(
1668
+ base: Annotated[str, typer.Option("--base", "-b", help="Base Git ref (default: HEAD~1)")] = "HEAD~1",
1669
+ head: Annotated[str, typer.Option("--head", "-h", help="Head Git ref (default: HEAD)")] = "HEAD",
1670
+ symbol: Annotated[str | None, typer.Option("--symbol", "-s", help="Analyze blast radius for a specific symbol on-demand")] = None,
1671
+ depth: Annotated[int, typer.Option("--depth", "-d", help="Max caller traversal depth")] = 2,
1672
+ max_results: Annotated[int, typer.Option("--max-results", "-m", help="Max items per category")] = 30,
1673
+ tree: Annotated[bool, typer.Option("--tree", "-t", help="Display visual impact tree")] = False,
1674
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
1675
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1676
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
1677
+ ) -> None:
1678
+ """Compute deep downstream change impact across callers, frontend UI, framework routes, tests, and DB."""
1679
+ from codegraph.change_impact import get_deep_change_impact
1680
+
1681
+ repo = _resolve_repo(path, repository)
1682
+ indexer = _indexer(repo)
1683
+
1684
+ with indexer.session() as con:
1685
+ imp = get_deep_change_impact(
1686
+ repo, con, base=base, head=head, max_depth=depth, max_results=max_results, symbol=symbol
1687
+ )
1688
+
1689
+ if json_output:
1690
+ cli_echo(json.dumps(imp.as_dict(), indent=2), json_mode=True)
1691
+ else:
1692
+ target_title = f"Symbol '{symbol}'" if symbol else f"Git Diff: {base} -> {head}"
1693
+ cli_echo(f"Semantic Change Impact Analysis: {target_title}")
1694
+ cli_echo(f" PR Risk Level: [{imp.risk_level}] (Score: {imp.blast_radius_score} / 100)")
1695
+ cli_echo(f" Total Impacted Items: {imp.total_impacted_count}")
1696
+ cli_echo(f" Direct Callers: {len(imp.direct_callers)}")
1697
+ cli_echo(f" Transitive Callers: {len(imp.transitive_callers)}")
1698
+ if imp.affected_frontend_components:
1699
+ cli_echo(f" Frontend UI Impact: {len(imp.affected_frontend_components)} components")
1700
+ cli_echo(f" Affected Routes: {len(imp.affected_routes)}")
1701
+ cli_echo(f" Covering Tests: {len(imp.affected_tests)}")
1702
+ cli_echo(f" DB Writers: {len(imp.db_writers)}")
1703
+ cli_echo(f" Monorepo Packages: {len(imp.affected_packages)}")
1704
+
1705
+ if imp.breaking_change_flags:
1706
+ cli_echo(f"\n ⚠️ Breaking Risk Flags: {', '.join(imp.breaking_change_flags)}")
1707
+
1708
+ if imp.recommended_test_command:
1709
+ cli_echo(f"\n 🎯 Recommended Test Run: {imp.recommended_test_command}")
1710
+
1711
+ if tree or imp.total_impacted_count > 0:
1712
+ cli_echo("\nImpact Tree:")
1713
+ for s in imp.changed_symbols[:10]:
1714
+ cli_echo(f" └── [{s.get('change_type', 'MODIFIED')}] {s.get('path', '')}::{s.get('name', '')}")
1715
+ for fe in imp.affected_frontend_components[:5]:
1716
+ cli_echo(f" ├── UI: {fe.file}::{fe.name} (fetches route)")
1717
+ for c in imp.direct_callers[:8]:
1718
+ cli_echo(f" ├── CALLER: {c.file}::{c.name} (depth 1)")
1719
+ for r in imp.affected_routes[:5]:
1720
+ cli_echo(f" ├── ROUTE: {r.name} -> {r.file}")
1721
+ for t in imp.affected_tests[:5]:
1722
+ cli_echo(f" ├── TEST: {t.file}::{t.name}")
1723
+ for db in imp.db_writers[:5]:
1724
+ cli_echo(f" └── DB: {db.relationship} {db.name} in {db.file}")
1725
+
1726
+
1727
+ @app.command("git-impact", hidden=True)
1728
+ def git_impact_alias(
1729
+ base: Annotated[str, typer.Argument(help="Base Git ref (default: HEAD~1)")] = "HEAD~1",
1730
+ head: Annotated[str, typer.Argument(help="Head Git ref (default: HEAD)")] = "HEAD",
1731
+ depth: Annotated[int, typer.Option("--depth", "-d", help="Max caller traversal depth")] = 2,
1732
+ max_results: Annotated[int, typer.Option("--max-results", "-m", help="Max items per category")] = 30,
1733
+ tree: Annotated[bool, typer.Option("--tree", "-t", help="Display visual impact tree")] = False,
1734
+ path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
1735
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1736
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
1737
+ ) -> None:
1738
+ """Backward-compatible alias for codegraph impact."""
1739
+ impact_cmd(
1740
+ base=base,
1741
+ head=head,
1742
+ depth=depth,
1743
+ max_results=max_results,
1744
+ tree=tree,
1745
+ path=path,
1746
+ repository=repository,
1747
+ json_output=json_output,
1748
+ )
1749
+
1750
+
1751
+ @app.command("git-conflicts")
1752
+ def git_conflicts_cmd(
1753
+ base: Annotated[str, typer.Argument(help="Base Git branch (default: main)")] = "main",
1754
+ head: Annotated[str, typer.Argument(help="Head Git branch (default: HEAD)")] = "HEAD",
1755
+ path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
1756
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1757
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
1758
+ ) -> None:
1759
+ """Detect semantic and contract merge conflicts between branches (calling deleted symbols, broken call signatures, or missing route handlers)."""
1760
+ from codegraph.semantic_conflicts import detect_semantic_conflicts
1761
+
1762
+ repo = _resolve_repo(path, repository)
1763
+ indexer = _indexer(repo)
1764
+
1765
+ with indexer.session() as con:
1766
+ rep = detect_semantic_conflicts(repo, base_branch=base, head_branch=head, con=con)
1767
+
1768
+ if json_output:
1769
+ cli_echo(json.dumps(rep.as_dict(), indent=2), json_mode=True)
1770
+ else:
1771
+ cli_echo(f"Semantic Conflict Analysis: {base} <-> {head}")
1772
+ cli_echo(f"Status: {'[!] CONFLICTS DETECTED' if rep.has_conflicts else '[OK] CLEAN (0 conflicts)'}")
1773
+ cli_echo(f"Total Conflicts: {rep.total_conflicts}")
1774
+ cli_echo(f"Summary: {rep.summary}")
1775
+ if rep.conflicts:
1776
+ cli_echo("\nDetected Semantic Conflicts:")
1777
+ for c in rep.conflicts:
1778
+ cli_echo(f"\n [{c.severity.value}] {c.conflict_type.value}: {c.symbol}")
1779
+ cli_echo(f" Description: {c.description}")
1780
+ cli_echo(f" Base ({base}): {c.base_file} ({c.base_evidence})")
1781
+ cli_echo(f" Head ({head}): {c.head_file} ({c.head_evidence})")
1782
+
1783
+
1784
+ @app.command("check-freshness")
1785
+ def check_freshness_cli_cmd(
1786
+ task: Annotated[str, typer.Argument(help="Task prompt to verify context freshness for")] = "",
1787
+ path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
1788
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1789
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
1790
+ ) -> None:
1791
+ """Validate whether compiled context remains VALID, PARTIALLY_STALE, or STALE against repository changes."""
1792
+ from codegraph.context import get_context
1793
+ from codegraph.context_freshness import check_context_freshness
1794
+
1795
+ repo = _resolve_repo(path, repository)
1796
+ indexer = _indexer(repo)
1797
+
1798
+ with indexer.session() as con:
1799
+ # Generate baseline context if task provided
1800
+ ctx = get_context(con, repo, task or "general repository overview", mode="FAST")
1801
+ rep = check_context_freshness(ctx, repo, con=con)
1802
+
1803
+ if json_output:
1804
+ cli_echo(json.dumps(rep.as_dict(), indent=2), json_mode=True)
1805
+ else:
1806
+ cli_echo(f"Context Freshness Status: {rep.status.value}")
1807
+ cli_echo(f"Reason: {rep.reason}")
1808
+ cli_echo(f"Recommended Action: {rep.recommended_action}")
1809
+ if rep.changed_relevant_files:
1810
+ cli_echo(f"Changed Relevant Files: {', '.join(rep.changed_relevant_files)}")
1811
+ if rep.unrelated_changed_files:
1812
+ cli_echo(f"Unrelated Changed Files: {len(rep.unrelated_changed_files)} file(s)")
1813
+
1814
+
1815
+ @app.command("symbol-history")
1816
+ def symbol_history_cli_cmd(
1817
+ symbol: Annotated[str, typer.Argument(help="Symbol name to trace across Git history")],
1818
+ file_path: Annotated[str | None, typer.Option("--file", "-f", help="File path where symbol is located")] = None,
1819
+ max_commits: Annotated[int, typer.Option("--max-commits", "-m", help="Max commits to inspect")] = 15,
1820
+ path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
1821
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1822
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
1823
+ ) -> None:
1824
+ """Trace deterministic symbol evolution across Git history: introduced, modified, moved, renamed, or deleted."""
1825
+ from codegraph.symbol_history import trace_symbol_history
1826
+
1827
+ repo = _resolve_repo(path, repository)
1828
+ indexer = _indexer(repo)
1829
+
1830
+ with indexer.session() as con:
1831
+ rep = trace_symbol_history(repo, symbol=symbol, path=file_path, con=con, max_commits=max_commits)
1832
+
1833
+ if json_output:
1834
+ cli_echo(json.dumps(rep.as_dict(), indent=2), json_mode=True)
1835
+ else:
1836
+ cli_echo(f"Symbol History: {symbol}")
1837
+ cli_echo(f"Status: {rep.status} ({rep.detail})")
1838
+ for ev in rep.events:
1839
+ cli_echo(f"\n [{ev.event_type}] Commit {ev.commit_sha[:8]} on {ev.date} by {ev.author}")
1840
+ cli_echo(f" Message: {ev.message}")
1841
+ cli_echo(f" Location: {ev.path}")
1842
+ cli_echo(f" Evidence: {ev.evidence} (confidence: {ev.confidence})")
1843
+
1844
+
1845
+ refactor_app = typer.Typer(no_args_is_help=True, help="Deterministic, syntax-validated AST refactoring and atomic rollback.")
1846
+ app.add_typer(refactor_app, name="refactor")
1847
+
1848
+
1849
+ @refactor_app.command("rename")
1850
+ def refactor_rename_cli_cmd(
1851
+ target: Annotated[str, typer.Argument(help="Exact symbol name or qualified name to rename")],
1852
+ new_name: Annotated[str, typer.Argument(help="New valid Python identifier name")],
1853
+ apply: Annotated[bool, typer.Option("--apply", help="Apply changes atomically to disk (default is dry-run)")] = False,
1854
+ force: Annotated[bool, typer.Option("--force", help="Force rename despite UNKNOWN/POSSIBLE uncertainty")] = False,
1855
+ path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
1856
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1857
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
1858
+ ) -> None:
1859
+ """Perform deterministic, syntax-validated, transaction-safe AST rename."""
1860
+ from codegraph.refactor.renamer import execute_safe_rename
1861
+
1862
+ repo = _resolve_repo(path, repository)
1863
+ indexer = _indexer(repo)
1864
+
1865
+ with indexer.session() as con:
1866
+ result = execute_safe_rename(
1867
+ repository=repo,
1868
+ con=con,
1869
+ target=target,
1870
+ new_name=new_name,
1871
+ dry_run=not apply,
1872
+ force_uncertain=force,
1873
+ )
1874
+
1875
+ if json_output:
1876
+ cli_echo(json.dumps(result.to_dict(), indent=2), json_mode=True)
1877
+ else:
1878
+ cli_echo(f"Refactor Status: {result.status}")
1879
+ cli_echo(f"Target: {result.target_symbol} -> {result.new_name}")
1880
+ cli_echo(f"Risk: {result.risk}")
1881
+ cli_echo(f"Files Changed: {result.files_changed}")
1882
+ cli_echo(f"Spans Count: {result.spans_count}")
1883
+ if result.rollback_id:
1884
+ cli_echo(f"Rollback ID: {result.rollback_id}")
1885
+ if result.uncertainty_reasons:
1886
+ cli_echo("\nUncertainty Reasons:")
1887
+ for reason in result.uncertainty_reasons:
1888
+ cli_echo(f" - {reason}")
1889
+ if result.tests_affected:
1890
+ cli_echo(f"Affected Tests: {', '.join(result.tests_affected)}")
1891
+ if result.routes_affected:
1892
+ cli_echo(f"Affected Routes: {', '.join(result.routes_affected)}")
1893
+ if result.diffs:
1894
+ cli_echo("\nUnified Diffs:")
1895
+ for p, diff in result.diffs.items():
1896
+ cli_echo(f"--- {p} ---")
1897
+ cli_echo(diff)
1898
+ if result.errors:
1899
+ cli_echo("\nErrors:")
1900
+ for err in result.errors:
1901
+ cli_echo(f" ! {err}")
1902
+
1903
+
1904
+ @refactor_app.command("undo")
1905
+ def refactor_undo_cli_cmd(
1906
+ transaction_id: Annotated[str, typer.Argument(help="Transaction ID to rollback")],
1907
+ path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
1908
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1909
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
1910
+ ) -> None:
1911
+ """Atomically rollback an applied refactoring transaction."""
1912
+ from codegraph.refactor.models import RefactorStatus
1913
+ from codegraph.refactor.transactions import rollback_refactor
1914
+
1915
+ repo = _resolve_repo(path, repository)
1916
+ result = rollback_refactor(repo, transaction_id)
1917
+ if result.status == RefactorStatus.ROLLED_BACK:
1918
+ try:
1919
+ indexer = _indexer(repo)
1920
+ indexer.index()
1921
+ except Exception:
1922
+ pass
1923
+
1924
+ if json_output:
1925
+ cli_echo(json.dumps(result.to_dict(), indent=2), json_mode=True)
1926
+ else:
1927
+ cli_echo(f"Rollback Status: {result.status}")
1928
+ cli_echo(f"Transaction ID: {transaction_id}")
1929
+ cli_echo(f"Files Restored: {result.files_changed}")
1930
+ if result.errors:
1931
+ cli_echo("\nErrors:")
1932
+ for err in result.errors:
1933
+ cli_echo(f" ! {err}")
1934
+
1935
+
1936
+ # ---------------------------------------------------------------------------
1937
+ # Async Queue Subcommands
1938
+ # ---------------------------------------------------------------------------
1939
+ async_app = typer.Typer(
1940
+ no_args_is_help=True,
1941
+ help="Asynchronous message queues, distributed task linking, and cross-service traces.",
1942
+ )
1943
+ app.add_typer(async_app, name="async")
1944
+
1945
+
1946
+ @async_app.command("queues")
1947
+ def async_queues_cli_cmd(
1948
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1949
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
1950
+ ) -> None:
1951
+ """List all detected asynchronous message queues, topics, and task channels."""
1952
+ from codegraph.async_queue.traversal import list_async_queues
1953
+
1954
+ repo = _resolve_repo(None, repository)
1955
+ if not _ensure_indexed(repo):
1956
+ raise typer.Exit(code=1)
1957
+
1958
+ indexer = _indexer(repo)
1959
+ with indexer.session() as con:
1960
+ queues = list_async_queues(con)
1961
+
1962
+ if json_output:
1963
+ cli_echo(json.dumps(queues, indent=2), json_mode=True)
1964
+ else:
1965
+ if not queues:
1966
+ cli_echo("No asynchronous queues, topics, or task channels detected.")
1967
+ return
1968
+
1969
+ cli_echo(f"Found {len(queues)} async queue(s):\n")
1970
+ for q in queues:
1971
+ cli_echo(
1972
+ f" [{q['system'].upper()}] {q['destination']} "
1973
+ f"(producers: {q['producer_count']}, consumers: {q['consumer_count']}, links: {q['link_count']})"
1974
+ )
1975
+
1976
+
1977
+ @async_app.command("trace")
1978
+ def async_trace_cli_cmd(
1979
+ entrypoint: Annotated[str, typer.Argument(help="Entrypoint symbol, handler, or queue topic name")],
1980
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
1981
+ depth: Annotated[int, typer.Option("--depth", "-d", help="Max traversal depth")] = 5,
1982
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
1983
+ ) -> None:
1984
+ """Trace cross-process asynchronous execution flow starting from an entrypoint."""
1985
+ from codegraph.async_queue.traversal import trace_async_flow
1986
+
1987
+ repo = _resolve_repo(None, repository)
1988
+ if not _ensure_indexed(repo):
1989
+ raise typer.Exit(code=1)
1990
+
1991
+ indexer = _indexer(repo)
1992
+ with indexer.session() as con:
1993
+ result = trace_async_flow(con, entrypoint, max_depth=depth)
1994
+
1995
+ if json_output:
1996
+ cli_echo(json.dumps(result, indent=2), json_mode=True)
1997
+ else:
1998
+ if result.get("status") != "ok":
1999
+ cli_echo(f"No async flow found starting from '{entrypoint}'.")
2000
+ return
2001
+
2002
+ cli_echo(f"Async Flow Trace for '{entrypoint}':\n")
2003
+ cli_echo(f" Producers: {result['producers_count']}")
2004
+ cli_echo(f" Consumers: {result['consumers_count']}")
2005
+ cli_echo(f" Downstream DB: {result['downstream_ops_count']}\n")
2006
+ cli_echo("Execution Steps:")
2007
+ for step in result.get("flow_steps", []):
2008
+ cli_echo(
2009
+ f" [{step['step']}] {step['source']} --({step['relationship']})--> {step['target']} "
2010
+ f"[{step.get('evidence_class', 'AST_VERIFIED')}] ({step.get('file', '')}:{step.get('line', '')})"
2011
+ )
2012
+
2013
+
2014
+ @async_app.command("ingest-traces")
2015
+ def async_ingest_traces_cli_cmd(
2016
+ traces_file: Annotated[Path, typer.Argument(help="Path to JSON file containing OpenTelemetry spans (OTLP or flat JSON)")],
2017
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
2018
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
2019
+ ) -> None:
2020
+ """Idempotently ingest OpenTelemetry traces and reconcile distributed links with the static graph."""
2021
+ from codegraph.async_queue.otel_parser import parse_otel_spans
2022
+ from codegraph.async_queue.reconciler import OTelTraceReconciler
2023
+
2024
+ repo = _resolve_repo(None, repository)
2025
+ if not _ensure_indexed(repo):
2026
+ raise typer.Exit(code=1)
2027
+
2028
+ if not traces_file.exists():
2029
+ cli_echo(f"Error: traces file '{traces_file}' not found.")
2030
+ raise typer.Exit(code=1)
2031
+
2032
+ try:
2033
+ content = traces_file.read_text(encoding="utf-8")
2034
+ spans = parse_otel_spans(content)
2035
+ except Exception as exc:
2036
+ cli_echo(f"Failed to read/parse traces file: {exc}")
2037
+ raise typer.Exit(code=1) from exc
2038
+
2039
+ indexer = _indexer(repo)
2040
+ with indexer.session() as con:
2041
+ reconciler = OTelTraceReconciler(con)
2042
+ res = reconciler.reconcile_spans(spans)
2043
+
2044
+ if json_output:
2045
+ cli_echo(json.dumps(res.to_dict(), indent=2), json_mode=True)
2046
+ else:
2047
+ cli_echo("OpenTelemetry Trace Ingestion Summary:\n")
2048
+ cli_echo(f" Spans Ingested: {res.spans_ingested}")
2049
+ cli_echo(f" Spans Deduplicated: {res.spans_deduplicated}")
2050
+ cli_echo(f" Links Upgraded: {res.links_upgraded} (to OTEL_DISTRIBUTED_VERIFIED)")
2051
+ cli_echo(f" Cross-Repo Observed: {res.links_created_runtime_only} (RUNTIME_ONLY_OBSERVED)")
2052
+ if res.details:
2053
+ cli_echo("\nReconciliation Details:")
2054
+ for d in res.details:
2055
+ cli_echo(f" * {d.get('action')}: {d.get('destination')} (p50={d.get('p50_ms')}ms, p95={d.get('p95_ms')}ms)")
2056
+
2057
+
2058
+ # ---------------------------------------------------------------------------
2059
+ # Database Intelligence Subcommands
2060
+ # ---------------------------------------------------------------------------
2061
+ db_app = typer.Typer(
2062
+ no_args_is_help=True,
2063
+ help="Database intelligence: tables, columns, ORM models, schema, and impact analysis.",
2064
+ )
2065
+ app.add_typer(db_app, name="db")
2066
+
2067
+
2068
+ @db_app.command("tables")
2069
+ def db_tables_cli_cmd(
2070
+ path: Annotated[Path | None, typer.Argument(help="Repository path or table name filter")] = None,
2071
+ table: Annotated[str | None, typer.Option("--table", "-t", help="Filter by table name")] = None,
2072
+ dialect: Annotated[str | None, typer.Option("--dialect", "-d", help="Filter by SQL dialect")] = None,
2073
+ schema: Annotated[str | None, typer.Option("--schema", "-s", help="Filter by schema name")] = None,
2074
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
2075
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
2076
+ ) -> None:
2077
+ """Discover database tables and views in the repository."""
2078
+ from codegraph.database.interrogation import find_db_tables
2079
+
2080
+ target_table = table
2081
+ repo_path = path
2082
+ if path is not None and not path.exists() and not table:
2083
+ target_table = str(path)
2084
+ repo_path = None
2085
+ repo = _resolve_repo(repo_path, repository)
2086
+ if not _ensure_indexed(repo):
2087
+ raise typer.Exit(code=1)
2088
+
2089
+ indexer = _indexer(repo)
2090
+ with indexer.session() as con:
2091
+ res = find_db_tables(con, repo, name=target_table, dialect=dialect, schema=schema)
2092
+
2093
+ if json_output:
2094
+ cli_echo(json.dumps(res, indent=2), json_mode=True)
2095
+ else:
2096
+ tables = res.get("tables", [])
2097
+ if not tables:
2098
+ cli_echo("No database tables found.")
2099
+ return
2100
+ cli_echo(f"Found {len(tables)} database table(s):")
2101
+ for t in tables:
2102
+ models = f" -> ORM: {', '.join(t['orm_models'])}" if t.get("orm_models") else ""
2103
+ schema_prefix = f"{t['schema_name']}." if t.get("schema_name") else ""
2104
+ cli_echo(f" • {schema_prefix}{t['table_name']} ({t.get('dialect', 'sql')}) [{t.get('framework', 'db')}]{models} at {t.get('file')}:{t.get('start_line')}")
2105
+
2106
+
2107
+ @db_app.command("columns")
2108
+ def db_columns_cli_cmd(
2109
+ target: Annotated[str | None, typer.Argument(help="Table name, column name, or repository path")] = None,
2110
+ table: Annotated[str | None, typer.Option("--table", "-t", help="Filter by table name")] = None,
2111
+ column: Annotated[str | None, typer.Option("--column", "-c", help="Filter by column name")] = None,
2112
+ path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
2113
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
2114
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
2115
+ ) -> None:
2116
+ """Discover database columns, data types, nullability, PK/FK flags, and ORM field mappings."""
2117
+ from codegraph.database.interrogation import find_db_columns
2118
+
2119
+ target_table = table
2120
+ target_column = column
2121
+ repo_path = path
2122
+ if target is not None:
2123
+ p = Path(target)
2124
+ if p.exists() and p.is_dir() and repo_path is None:
2125
+ repo_path = p
2126
+ elif not target_table:
2127
+ if "." in target:
2128
+ parts = target.split(".", 1)
2129
+ target_table = parts[0]
2130
+ target_column = parts[1]
2131
+ else:
2132
+ target_table = target
2133
+
2134
+ repo = _resolve_repo(repo_path, repository)
2135
+ if not _ensure_indexed(repo):
2136
+ raise typer.Exit(code=1)
2137
+
2138
+ indexer = _indexer(repo)
2139
+ with indexer.session() as con:
2140
+ res = find_db_columns(con, repo, table=target_table, column=target_column)
2141
+
2142
+ if json_output:
2143
+ cli_echo(json.dumps(res, indent=2), json_mode=True)
2144
+ else:
2145
+ cols = res.get("columns", [])
2146
+ if not cols:
2147
+ cli_echo("No database columns found.")
2148
+ return
2149
+ cli_echo(f"Found {len(cols)} database column(s):")
2150
+ for c in cols:
2151
+ pk = " [PK]" if c.get("is_primary_key") else ""
2152
+ fk = f" [FK -> {c.get('target_table')}.{c.get('target_column')}]" if c.get("is_foreign_key") else ""
2153
+ nullable = " NULL" if c.get("nullable") else " NOT NULL"
2154
+ cli_echo(f" • {c.get('table_name')}.{c.get('column_name')}: {c.get('data_type')}{nullable}{pk}{fk} ({c.get('file')}:{c.get('start_line')})")
2155
+
2156
+
2157
+ @db_app.command("models")
2158
+ def db_models_cli_cmd(
2159
+ path: Annotated[Path | None, typer.Argument(help="Repository path or model filter")] = None,
2160
+ model: Annotated[str | None, typer.Option("--model", "-m", help="Filter by model name")] = None,
2161
+ framework: Annotated[str | None, typer.Option("--framework", "-f", help="Filter by ORM framework")] = None,
2162
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
2163
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
2164
+ ) -> None:
2165
+ """Discover ORM models and classes mapped to database tables."""
2166
+ from codegraph.database.interrogation import find_db_models
2167
+
2168
+ target_model = model
2169
+ repo_path = path
2170
+ if path is not None and not path.exists() and not model:
2171
+ target_model = str(path)
2172
+ repo_path = None
2173
+ repo = _resolve_repo(repo_path, repository)
2174
+ if not _ensure_indexed(repo):
2175
+ raise typer.Exit(code=1)
2176
+
2177
+ indexer = _indexer(repo)
2178
+ with indexer.session() as con:
2179
+ res = find_db_models(con, repo, model=target_model, framework=framework)
2180
+
2181
+ if json_output:
2182
+ cli_echo(json.dumps(res, indent=2), json_mode=True)
2183
+ else:
2184
+ models_list = res.get("models", [])
2185
+ if not models_list:
2186
+ cli_echo("No ORM models found.")
2187
+ return
2188
+ cli_echo(f"Found {len(models_list)} ORM model(s):")
2189
+ for m in models_list:
2190
+ tbl = f" -> Table: {m.get('table_name')}" if m.get("table_name") else ""
2191
+ cli_echo(f" • {m.get('name')} [{m.get('framework')}]{tbl} ({m.get('file')}:{m.get('start_line')})")
2192
+
2193
+
2194
+ @db_app.command("schema")
2195
+ def db_schema_cli_cmd(
2196
+ target: Annotated[str | None, typer.Argument(help="Table name to inspect, or repository path")] = None,
2197
+ table: Annotated[str | None, typer.Option("--table", "-t", help="Inspect single table in detail")] = None,
2198
+ dialect: Annotated[str | None, typer.Option("--dialect", "-d", help="Filter by SQL dialect")] = None,
2199
+ schema_name: Annotated[str | None, typer.Option("--schema", "-s", help="Filter by schema name")] = None,
2200
+ path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
2201
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
2202
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
2203
+ ) -> None:
2204
+ """Inspect full database schema overview or deep table metadata."""
2205
+ from codegraph.database.interrogation import get_db_schema, get_db_table
2206
+
2207
+ target_table = table
2208
+ repo_path = path
2209
+ if target is not None:
2210
+ p = Path(target)
2211
+ if p.exists() and p.is_dir() and repo_path is None:
2212
+ repo_path = p
2213
+ elif not target_table:
2214
+ target_table = target
2215
+
2216
+ repo = _resolve_repo(repo_path, repository)
2217
+ if not _ensure_indexed(repo):
2218
+ raise typer.Exit(code=1)
2219
+
2220
+ indexer = _indexer(repo)
2221
+ with indexer.session() as con:
2222
+ if target_table:
2223
+ res = get_db_table(con, repo, table=target_table)
2224
+ else:
2225
+ res = get_db_schema(con, repo, dialect=dialect, schema=schema_name)
2226
+
2227
+ if json_output:
2228
+ cli_echo(json.dumps(res, indent=2), json_mode=True)
2229
+ else:
2230
+ if target_table:
2231
+ tbl_info = res.get("table") or {}
2232
+ cols = res.get("columns", [])
2233
+ cli_echo(f"Table: {target_table} ({tbl_info.get('dialect', 'sql')}) [{tbl_info.get('framework', 'db')}]")
2234
+ cli_echo(f"Location: {tbl_info.get('file')}:{tbl_info.get('start_line')}")
2235
+ if res.get("models"):
2236
+ cli_echo(f"ORM Models: {', '.join(m['name'] for m in res['models'])}")
2237
+ cli_echo(f"\nColumns ({len(cols)}):")
2238
+ for c in cols:
2239
+ pk = " [PK]" if c.get("is_primary_key") else ""
2240
+ fk = f" [FK -> {c.get('target_table')}.{c.get('target_column')}]" if c.get("is_foreign_key") else ""
2241
+ cli_echo(f" • {c.get('column_name')}: {c.get('data_type')}{pk}{fk}")
2242
+ if res.get("readers"):
2243
+ cli_echo(f"\nReaders ({len(res['readers'])}):")
2244
+ for r in res["readers"][:5]:
2245
+ cli_echo(f" • {r.get('symbol')} ({r.get('file')}:{r.get('line')})")
2246
+ if res.get("writers"):
2247
+ cli_echo(f"\nWriters ({len(res['writers'])}):")
2248
+ for w in res["writers"][:5]:
2249
+ cli_echo(f" • {w.get('symbol')} ({w.get('file')}:{w.get('line')})")
2250
+ else:
2251
+ cli_echo("Database Schema Overview:")
2252
+ cli_echo(f" Tables: {len(res.get('tables', []))}")
2253
+ cli_echo(f" Columns: {len(res.get('columns', []))}")
2254
+ cli_echo(f" ORM Models: {len(res.get('models', []))}")
2255
+ cli_echo(f" Relationships: {len(res.get('relationships', []))}")
2256
+ cli_echo(f" Migrations: {len(res.get('migrations', []))}")
2257
+ if res.get("tables"):
2258
+ cli_echo("\nTables:")
2259
+ for t in res["tables"][:15]:
2260
+ cli_echo(f" • {t.get('table_name')} ({t.get('dialect', 'sql')})")
2261
+ if len(res["tables"]) > 15:
2262
+ cli_echo(f" ... and {len(res['tables']) - 15} more tables")
2263
+
2264
+
2265
+ @db_app.command("impact")
2266
+ def db_impact_cli_cmd(
2267
+ target: Annotated[str, typer.Argument(help="Table name or Table.column to analyze downstream impact for")],
2268
+ column: Annotated[str | None, typer.Option("--column", "-c", help="Column name if target is a table")] = None,
2269
+ path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
2270
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
2271
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
2272
+ ) -> None:
2273
+ """Analyze bidirectional downstream impact of table/column modifications on code, routes, and tests."""
2274
+ from codegraph.database.interrogation import get_db_impact
2275
+
2276
+ target_table = target
2277
+ target_column = column
2278
+ if "." in target and not target_column:
2279
+ parts = target.split(".", 1)
2280
+ target_table = parts[0]
2281
+ target_column = parts[1]
2282
+
2283
+ repo = _resolve_repo(path, repository)
2284
+ if not _ensure_indexed(repo):
2285
+ raise typer.Exit(code=1)
2286
+
2287
+ indexer = _indexer(repo)
2288
+ with indexer.session() as con:
2289
+ res = get_db_impact(con, repo, table=target_table, column=target_column)
2290
+
2291
+ if json_output:
2292
+ cli_echo(json.dumps(res, indent=2), json_mode=True)
2293
+ else:
2294
+ cli_echo(f"Database Impact Analysis for {target}:")
2295
+ cli_echo(f" Direct Readers: {len(res.get('readers', []))}")
2296
+ cli_echo(f" Direct Writers: {len(res.get('writers', []))}")
2297
+ cli_echo(f" Reachable Routes: {len(res.get('routes', []))}")
2298
+ cli_echo(f" Impacted Tests: {len(res.get('tests', []))}")
2299
+ if res.get("routes"):
2300
+ cli_echo("\nReachable Routes:")
2301
+ for r in res["routes"]:
2302
+ cli_echo(f" • [{r.get('http_method')}] {r.get('route_path')} -> {r.get('handler')}")
2303
+ if res.get("tests"):
2304
+ cli_echo("\nImpacted Tests:")
2305
+ for t in res["tests"]:
2306
+ cli_echo(f" • {t.get('test_symbol')} ({t.get('file')}:{t.get('line')})")
2307
+
2308
+
2309
+ @db_app.command("drift")
2310
+ def db_drift_cli_cmd(
2311
+ route: Annotated[str | None, typer.Option("--route", help="Route path or endpoint to check for schema drift")] = None,
2312
+ handler: Annotated[str | None, typer.Option("--handler", "-h", help="Handler symbol to check for schema drift")] = None,
2313
+ table: Annotated[str | None, typer.Option("--table", "-t", help="Target table name (optional, inferred from mutation lineage)")] = None,
2314
+ schema: Annotated[str | None, typer.Option("--schema", "-s", help="Schema class name (optional, inferred from handler signature)")] = None,
2315
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
2316
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
2317
+ json_output: Annotated[bool, typer.Option("--json", help="Output raw JSON array")] = False,
2318
+ ) -> None:
2319
+ """Detect Pydantic / Form validation drift against destination database table columns."""
2320
+ schema_drift(route=route, handler=handler, table=table, schema=schema, path=path, repository=repository, json_output=json_output)
2321
+
2322
+
2323
+ @db_app.command("ingest")
2324
+ def db_ingest_cli_cmd(
2325
+ source: Annotated[Path, typer.Argument(help="Path to OpenTelemetry JSON/JSONL, SQL query log, or trace file")],
2326
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
2327
+ format: Annotated[str, typer.Option("--format", "-f", help="Trace format: auto | otel | json | sql_log")] = "auto",
2328
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
2329
+ ) -> None:
2330
+ """Ingest runtime database query logs or OpenTelemetry traces into repository index."""
2331
+ ingest_cli_cmd(source=source, repository=repository, format=format, json_output=json_output)
2332
+
2333
+
2334
+ roblox_app = typer.Typer(no_args_is_help=True, help="Roblox & Luau DataModel, Remote, Module, and Service intelligence.")
2335
+ app.add_typer(roblox_app, name="roblox")
2336
+
2337
+
2338
+ @roblox_app.command("remotes")
2339
+ def roblox_remotes_cli_cmd(
2340
+ path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
2341
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
2342
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
2343
+ ) -> None:
2344
+ """List Roblox RemoteEvents and RemoteFunctions with client dispatchers and server handlers."""
2345
+ from codegraph.roblox import list_roblox_remotes
2346
+
2347
+ repo = _resolve_repo(path, repository)
2348
+ if not _ensure_indexed(repo):
2349
+ raise typer.Exit(code=1)
2350
+
2351
+ indexer = _indexer(repo)
2352
+ with indexer.session() as con:
2353
+ res = list_roblox_remotes(con, repo)
2354
+
2355
+ if json_output:
2356
+ cli_echo(json.dumps(res, indent=2), json_mode=True)
2357
+ else:
2358
+ remotes = res.get("remotes", [])
2359
+ cli_echo(f"Roblox Remotes ({len(remotes)}):")
2360
+ for r in remotes:
2361
+ cli_echo(f" • {r.get('remote')} [{r.get('evidence_class')}]")
2362
+ for c in r.get("client_dispatchers", []):
2363
+ cli_echo(f" Client -> {c.get('symbol')} ({c.get('file')}:{c.get('line')})")
2364
+ for s in r.get("server_handlers", []):
2365
+ cli_echo(f" Server <- {s.get('symbol')} ({s.get('file')}:{s.get('line')})")
2366
+
2367
+
2368
+ @roblox_app.command("modules")
2369
+ def roblox_modules_cli_cmd(
2370
+ path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
2371
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
2372
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
2373
+ ) -> None:
2374
+ """List Luau / Roblox modules, Rojo DataModel paths, and require edges."""
2375
+ from codegraph.roblox import list_roblox_modules
2376
+
2377
+ repo = _resolve_repo(path, repository)
2378
+ if not _ensure_indexed(repo):
2379
+ raise typer.Exit(code=1)
2380
+
2381
+ indexer = _indexer(repo)
2382
+ with indexer.session() as con:
2383
+ res = list_roblox_modules(con, repo)
2384
+
2385
+ if json_output:
2386
+ cli_echo(json.dumps(res, indent=2), json_mode=True)
2387
+ else:
2388
+ modules = res.get("modules", [])
2389
+ cli_echo(f"Roblox / Luau Modules ({len(modules)}):")
2390
+ for m in modules:
2391
+ vpath = m.get("virtual_path") or "unmapped"
2392
+ cli_echo(f" • {m.get('file')} ({vpath}) [{m.get('evidence_class')}]")
2393
+ for req in m.get("requires", []):
2394
+ cli_echo(f" REQUIRES_MODULE -> {req.get('target')} [{req.get('evidence_class')}]")
2395
+
2396
+
2397
+ @roblox_app.command("routes")
2398
+ def roblox_routes_cli_cmd(
2399
+ path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
2400
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
2401
+ json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
2402
+ ) -> None:
2403
+ """List Roblox Client -> Remote -> Server routes, Knit services, and DataStore persistence bindings."""
2404
+ from codegraph.roblox import list_roblox_routes
2405
+
2406
+ repo = _resolve_repo(path, repository)
2407
+ if not _ensure_indexed(repo):
2408
+ raise typer.Exit(code=1)
2409
+
2410
+ indexer = _indexer(repo)
2411
+ with indexer.session() as con:
2412
+ res = list_roblox_routes(con, repo)
2413
+
2414
+ if json_output:
2415
+ cli_echo(json.dumps(res, indent=2), json_mode=True)
2416
+ else:
2417
+ routes = res.get("routes", [])
2418
+ services = res.get("services", [])
2419
+ persistence = res.get("persistence", [])
2420
+ cli_echo(f"Roblox Network Routes ({len(routes)}):")
2421
+ for rt in routes:
2422
+ cli_echo(f" • {rt.get('client')} -> {rt.get('remote')} -> {rt.get('server')} [{rt.get('evidence_class')}]")
2423
+ if services:
2424
+ cli_echo(f"\nKnit / Flamework Services ({len(services)}):")
2425
+ for sv in services:
2426
+ cli_echo(f" • {sv.get('provider')} ({sv.get('relationship')}) -> {sv.get('service')} [{sv.get('evidence_class')}]")
2427
+ if persistence:
2428
+ cli_echo(f"\nRoblox Persistence ({len(persistence)}):")
2429
+ for p in persistence:
2430
+ cli_echo(f" • {p.get('symbol')} ({p.get('relationship')}) -> {p.get('store')} [{p.get('evidence_class')}]")
2431
+
2432
+
2433
+ if __name__ == "__main__":
2434
+ app()
2435
+
2436
+