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.
- codegraph/__init__.py +37 -0
- codegraph/agent.py +26 -0
- codegraph/agent_brain.py +2212 -0
- codegraph/agent_capabilities.py +4058 -0
- codegraph/agent_rules.py +531 -0
- codegraph/api_drift.py +455 -0
- codegraph/architecture.py +371 -0
- codegraph/async_queue/__init__.py +50 -0
- codegraph/async_queue/detectors/__init__.py +48 -0
- codegraph/async_queue/detectors/celery.py +160 -0
- codegraph/async_queue/detectors/kafka.py +168 -0
- codegraph/async_queue/detectors/rabbitmq.py +116 -0
- codegraph/async_queue/detectors/redis.py +130 -0
- codegraph/async_queue/detectors/sqs.py +135 -0
- codegraph/async_queue/models.py +161 -0
- codegraph/async_queue/otel_models.py +74 -0
- codegraph/async_queue/otel_parser.py +337 -0
- codegraph/async_queue/reconciler.py +427 -0
- codegraph/async_queue/scanner.py +291 -0
- codegraph/async_queue/traversal.py +447 -0
- codegraph/audit.py +106 -0
- codegraph/binding_resolver.py +616 -0
- codegraph/branch_comparison.py +197 -0
- codegraph/cache.py +95 -0
- codegraph/change_impact.py +547 -0
- codegraph/cli.py +2436 -0
- codegraph/cli_output.py +205 -0
- codegraph/config.py +45 -0
- codegraph/constraints.py +238 -0
- codegraph/context.py +2182 -0
- codegraph/context_freshness.py +351 -0
- codegraph/database/__init__.py +72 -0
- codegraph/database/extractor.py +3651 -0
- codegraph/database/interrogation.py +1494 -0
- codegraph/database/models.py +310 -0
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +200 -0
- codegraph/errors.py +282 -0
- codegraph/evidence/__init__.py +15 -0
- codegraph/evidence/citations.py +418 -0
- codegraph/evidence_contract.py +486 -0
- codegraph/frameworks.py +960 -0
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +335 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +362 -0
- codegraph/git_state.py +545 -0
- codegraph/graph/__init__.py +46 -0
- codegraph/graph/models.py +56 -0
- codegraph/graph/traversal.py +1993 -0
- codegraph/indexing/__init__.py +24 -0
- codegraph/indexing/classifier.py +564 -0
- codegraph/indexing/indexer.py +2578 -0
- codegraph/indexing/models.py +427 -0
- codegraph/indexing/parser.py +3404 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +211 -0
- codegraph/indexing/telemetry.py +326 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/indexing/test_framework.py +116 -0
- codegraph/installer.py +1702 -0
- codegraph/interrogation.py +2355 -0
- codegraph/llm/__init__.py +3 -0
- codegraph/llm/base.py +15 -0
- codegraph/llm/context.py +20 -0
- codegraph/mcp/__init__.py +3 -0
- codegraph/mcp/server.py +1476 -0
- codegraph/mcp_diagnostics.py +882 -0
- codegraph/memory/__init__.py +3 -0
- codegraph/memory/store.py +46 -0
- codegraph/models.py +310 -0
- codegraph/monorepo.py +925 -0
- codegraph/observability.py +262 -0
- codegraph/optimizer.py +1014 -0
- codegraph/planner.py +417 -0
- codegraph/process_lifecycle.py +1153 -0
- codegraph/py.typed +1 -0
- codegraph/query_expansion.py +199 -0
- codegraph/ranking.py +408 -0
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +414 -0
- codegraph/resolver.py +3968 -0
- codegraph/resources/__init__.py +46 -0
- codegraph/resources/cache.py +117 -0
- codegraph/resources/coalescer.py +83 -0
- codegraph/resources/debouncer.py +98 -0
- codegraph/resources/governor.py +219 -0
- codegraph/resources/monitor.py +102 -0
- codegraph/resources/policy.py +123 -0
- codegraph/retrieval_policy.py +320 -0
- codegraph/roblox.py +2174 -0
- codegraph/route_composer.py +363 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +51 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +692 -0
- codegraph/runtime/models.py +130 -0
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/reconciliation.py +492 -0
- codegraph/runtime/runner.py +353 -0
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/__init__.py +25 -0
- codegraph/search/hybrid.py +846 -0
- codegraph/search/semantic.py +28 -0
- codegraph/security/__init__.py +51 -0
- codegraph/security/paths.py +189 -0
- codegraph/security/redaction.py +633 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/semantic_decorators.py +372 -0
- codegraph/structural_diff.py +542 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +277 -0
- codegraph/target_resolver.py +348 -0
- codegraph/task.py +637 -0
- codegraph/tool_selection_eval.py +1158 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +755 -0
- codegraph/watcher.py +323 -0
- codegraph_engine-3.0.0.dist-info/METADATA +859 -0
- codegraph_engine-3.0.0.dist-info/RECORD +130 -0
- codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
- codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
- codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
|