codegraph-engine 2.3.0__py3-none-any.whl → 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codegraph/__init__.py +1 -1
- codegraph/agent_brain.py +172 -2
- codegraph/agent_capabilities.py +320 -3
- codegraph/agent_rules.py +1 -1
- codegraph/api_drift.py +455 -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/change_impact.py +320 -110
- codegraph/cli.py +776 -25
- codegraph/cli_output.py +17 -2
- codegraph/context.py +38 -11
- codegraph/context_freshness.py +2 -0
- codegraph/database/__init__.py +14 -0
- codegraph/database/extractor.py +712 -17
- codegraph/database/interrogation.py +388 -2
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +7 -0
- codegraph/evidence_contract.py +21 -0
- codegraph/frameworks.py +171 -6
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +2 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +2 -0
- codegraph/git_state.py +2 -0
- codegraph/indexing/__init__.py +7 -1
- codegraph/indexing/indexer.py +291 -38
- codegraph/indexing/models.py +6 -0
- codegraph/indexing/parser.py +239 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +6 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/interrogation.py +128 -20
- codegraph/mcp/server.py +190 -28
- codegraph/models.py +7 -0
- codegraph/monorepo.py +3 -0
- codegraph/optimizer.py +42 -5
- codegraph/process_lifecycle.py +31 -13
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +403 -0
- codegraph/resolver.py +1202 -159
- codegraph/retrieval_policy.py +6 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +19 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +30 -10
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/runner.py +155 -6
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/hybrid.py +26 -0
- codegraph/semantic_decorators.py +34 -3
- codegraph/structural_diff.py +2 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +2 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +715 -0
- codegraph/watcher.py +323 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/METADATA +150 -38
- codegraph_engine-3.0.0.dist-info/RECORD +129 -0
- codegraph_engine-2.3.0.dist-info/RECORD +0 -94
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/WHEEL +0 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/entry_points.txt +0 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/licenses/LICENSE +0 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/top_level.txt +0 -0
codegraph/cli.py
CHANGED
|
@@ -2,8 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import sqlite3
|
|
5
|
+
import sys
|
|
5
6
|
from pathlib import Path
|
|
6
|
-
from typing import Annotated
|
|
7
|
+
from typing import Annotated, Any
|
|
7
8
|
|
|
8
9
|
import typer
|
|
9
10
|
|
|
@@ -54,7 +55,14 @@ def main(
|
|
|
54
55
|
] = False,
|
|
55
56
|
) -> None:
|
|
56
57
|
"""Evidence-backed local codebase intelligence for MCP clients and AI agents."""
|
|
57
|
-
|
|
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
|
|
58
66
|
|
|
59
67
|
|
|
60
68
|
def _resolve_repo(
|
|
@@ -232,11 +240,73 @@ def init(
|
|
|
232
240
|
typer.echo(f"Initialized CodeGraph repository at {target_repo.resolve()} (indexed {indexed_cnt} files)")
|
|
233
241
|
|
|
234
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
|
+
|
|
235
302
|
@app.command()
|
|
236
303
|
def search(
|
|
237
304
|
query: str,
|
|
238
305
|
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
239
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,
|
|
240
310
|
json_output: Annotated[bool, typer.Option("--json")] = False,
|
|
241
311
|
) -> None:
|
|
242
312
|
"""Search indexed code with exact evidence."""
|
|
@@ -261,8 +331,42 @@ def search(
|
|
|
261
331
|
)
|
|
262
332
|
)
|
|
263
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
|
+
|
|
264
346
|
with _indexer(repo).session() as con:
|
|
265
|
-
|
|
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)]
|
|
266
370
|
typer.echo(
|
|
267
371
|
json.dumps(result, indent=2)
|
|
268
372
|
if json_output
|
|
@@ -497,6 +601,93 @@ def routes(
|
|
|
497
601
|
typer.echo(f" [{r['http_method']}] {r['route_path']} -> {r['handler']} ({r['file']}:{r['line']}) [{r['framework']}]")
|
|
498
602
|
|
|
499
603
|
|
|
604
|
+
@app.command("schema-drift")
|
|
605
|
+
def schema_drift(
|
|
606
|
+
route: Annotated[str | None, typer.Option("--route", help="Route path or endpoint to check for schema drift")] = None,
|
|
607
|
+
handler: Annotated[str | None, typer.Option("--handler", "-h", help="Handler symbol to check for schema drift")] = None,
|
|
608
|
+
table: Annotated[str | None, typer.Option("--table", "-t", help="Target table name (optional, inferred from mutation lineage)")] = None,
|
|
609
|
+
schema: Annotated[str | None, typer.Option("--schema", "-s", help="Schema class name (optional, inferred from handler signature)")] = None,
|
|
610
|
+
path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
|
|
611
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
612
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output raw JSON array")] = False,
|
|
613
|
+
) -> None:
|
|
614
|
+
"""Detect Pydantic / Form validation drift against destination database table columns."""
|
|
615
|
+
from codegraph.database.schema_drift import detect_schema_drift
|
|
616
|
+
|
|
617
|
+
repo = _resolve_repo(path, repository)
|
|
618
|
+
if not _ensure_indexed(repo):
|
|
619
|
+
return
|
|
620
|
+
target = route or handler or ""
|
|
621
|
+
if not target:
|
|
622
|
+
typer.echo("Error: Please specify --route or --handler to check for schema drift.")
|
|
623
|
+
raise typer.Exit(code=1)
|
|
624
|
+
with _indexer(repo).session() as con:
|
|
625
|
+
res = detect_schema_drift(con, repo, route_or_handler=target, target_table=table, schema_name=schema)
|
|
626
|
+
if json_output:
|
|
627
|
+
typer.echo(json.dumps(res, indent=2))
|
|
628
|
+
else:
|
|
629
|
+
if res.get("status") == "error":
|
|
630
|
+
typer.echo(f"Error: {res.get('error')}")
|
|
631
|
+
raise typer.Exit(code=1)
|
|
632
|
+
typer.echo(f"Schema Drift Report for {target} -> {res.get('target_table')} ({res.get('schema_name')}):")
|
|
633
|
+
typer.echo(f" Mode: {res.get('schema_mode', 'STATIC_TYPED')}")
|
|
634
|
+
recon = res.get("runtime_reconciliation", {})
|
|
635
|
+
if recon.get("is_observed"):
|
|
636
|
+
typer.echo(f" Runtime SQL: Observed ({recon.get('observation_count')} query executions)")
|
|
637
|
+
if recon.get("last_sql_sample"):
|
|
638
|
+
typer.echo(f" Sample Query: {recon.get('last_sql_sample')}")
|
|
639
|
+
else:
|
|
640
|
+
typer.echo(" Runtime SQL: NOT_OBSERVED_AT_RUNTIME (Run dev server or ingest traces to observe live queries)")
|
|
641
|
+
typer.echo(f" Total Issues: {res.get('drift_count')} (Critical: {res.get('critical_count')}, Warning: {res.get('warning_count')}, Info: {res.get('info_count')})")
|
|
642
|
+
for iss in res.get("issues", []):
|
|
643
|
+
typer.echo(f" [{iss['severity']}] {iss['drift_type']} on '{iss['field_name']}': {iss['message']}")
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@app.command("api-drift")
|
|
647
|
+
def api_drift_cmd(
|
|
648
|
+
path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
|
|
649
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
650
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output raw JSON")] = False,
|
|
651
|
+
) -> None:
|
|
652
|
+
"""Detect cross-language client-server API contract drift (404s, 405s, and TypeScript-to-Backend type drift)."""
|
|
653
|
+
from codegraph.api_drift import detect_api_contract_drift
|
|
654
|
+
|
|
655
|
+
repo = _resolve_repo(path, repository)
|
|
656
|
+
if not _ensure_indexed(repo):
|
|
657
|
+
return
|
|
658
|
+
|
|
659
|
+
with _indexer(repo).session() as con:
|
|
660
|
+
report = detect_api_contract_drift(con, repo)
|
|
661
|
+
|
|
662
|
+
if json_output:
|
|
663
|
+
typer.echo(json.dumps(report.as_dict(), indent=2))
|
|
664
|
+
else:
|
|
665
|
+
if not report.has_drift:
|
|
666
|
+
typer.echo("No API contract drift detected.")
|
|
667
|
+
typer.echo(f"Verified {report.total_client_calls} client data fetches against {report.total_backend_routes} backend routes ({report.matched_calls} matched).")
|
|
668
|
+
else:
|
|
669
|
+
typer.echo(f"API Contract Drift Detected ({len(report.drifts)} issues):")
|
|
670
|
+
typer.echo(f"Client Calls: {report.total_client_calls} | Backend Routes: {report.total_backend_routes} | Matched: {report.matched_calls}\n")
|
|
671
|
+
for d in report.drifts:
|
|
672
|
+
typer.echo(f" [{d.severity}] {d.kind}")
|
|
673
|
+
typer.echo(f" Client: {d.client_method} {d.client_route} ({d.client_file}:{d.client_line})")
|
|
674
|
+
if d.backend_route:
|
|
675
|
+
typer.echo(f" Backend: {d.backend_route} -> {d.backend_handler} ({d.backend_file})")
|
|
676
|
+
if d.field_name:
|
|
677
|
+
typer.echo(f" Field: {d.field_name} (Client: {d.client_field_type} vs Backend: {d.backend_field_type})")
|
|
678
|
+
typer.echo(f" Message: {d.message}\n")
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
@app.command("api_drift", hidden=True)
|
|
682
|
+
def api_drift_alias(
|
|
683
|
+
path: Annotated[Path | None, typer.Argument(help="Repository path")] = None,
|
|
684
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
685
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output raw JSON")] = False,
|
|
686
|
+
) -> None:
|
|
687
|
+
"""Alias for api-drift."""
|
|
688
|
+
api_drift_cmd(path=path, repository=repository, json_output=json_output)
|
|
689
|
+
|
|
690
|
+
|
|
500
691
|
@app.command()
|
|
501
692
|
def imports(
|
|
502
693
|
path: Annotated[str | None, typer.Argument(help="Repository-relative file path to inspect imports for (default: inspect all imports)")] = None,
|
|
@@ -627,6 +818,38 @@ def run_cmd(
|
|
|
627
818
|
raise typer.Exit(code=exit_code)
|
|
628
819
|
|
|
629
820
|
|
|
821
|
+
@app.command("ingest")
|
|
822
|
+
def ingest_cli_cmd(
|
|
823
|
+
source: Annotated[Path, typer.Argument(help="Path to OpenTelemetry JSON/JSONL, SQL query log, or trace file")],
|
|
824
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
825
|
+
format: Annotated[str, typer.Option("--format", "-f", help="Trace format: auto | otel | json | sql_log")] = "auto",
|
|
826
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
|
|
827
|
+
) -> None:
|
|
828
|
+
"""Ingest runtime traces (OpenTelemetry, JSON, or SQL query logs) into repository index."""
|
|
829
|
+
repo = _resolve_repo(None, repository)
|
|
830
|
+
if not _ensure_indexed(repo):
|
|
831
|
+
raise typer.Exit(code=1)
|
|
832
|
+
|
|
833
|
+
if not source.exists():
|
|
834
|
+
cli_echo(f"Error: Trace source file '{source}' does not exist.")
|
|
835
|
+
raise typer.Exit(code=1)
|
|
836
|
+
|
|
837
|
+
content = source.read_text(encoding="utf-8", errors="replace")
|
|
838
|
+
indexer = _indexer(repo)
|
|
839
|
+
with indexer.session() as con:
|
|
840
|
+
from codegraph.runtime.ingestor import ingest_runtime_traces
|
|
841
|
+
|
|
842
|
+
res = ingest_runtime_traces(con, content, repository=repo, format=format)
|
|
843
|
+
|
|
844
|
+
if json_output:
|
|
845
|
+
cli_echo(json.dumps(res, indent=2), json_mode=True)
|
|
846
|
+
else:
|
|
847
|
+
cli_echo("Runtime Ingestion Complete:")
|
|
848
|
+
cli_echo(f" Events Ingested: {res.get('events_ingested', 0)}")
|
|
849
|
+
cli_echo(f" Edges Upserted: {res.get('edges_upserted', 0)}")
|
|
850
|
+
cli_echo(f" Generation: {res.get('runtime_generation', 1)}")
|
|
851
|
+
|
|
852
|
+
|
|
630
853
|
def _run_server(
|
|
631
854
|
path: Path | None,
|
|
632
855
|
repository: Path | None,
|
|
@@ -1409,51 +1632,89 @@ def git_diff_cmd(
|
|
|
1409
1632
|
cli_echo(f"Affected Tests: {len(s_rep.affected_tests)} ({', '.join(s_rep.affected_tests[:5])})")
|
|
1410
1633
|
|
|
1411
1634
|
|
|
1412
|
-
@app.command("
|
|
1413
|
-
def
|
|
1414
|
-
base: Annotated[str, typer.
|
|
1415
|
-
head: Annotated[str, typer.
|
|
1635
|
+
@app.command("impact")
|
|
1636
|
+
def impact_cmd(
|
|
1637
|
+
base: Annotated[str, typer.Option("--base", "-b", help="Base Git ref (default: HEAD~1)")] = "HEAD~1",
|
|
1638
|
+
head: Annotated[str, typer.Option("--head", "-h", help="Head Git ref (default: HEAD)")] = "HEAD",
|
|
1639
|
+
symbol: Annotated[str | None, typer.Option("--symbol", "-s", help="Analyze blast radius for a specific symbol on-demand")] = None,
|
|
1416
1640
|
depth: Annotated[int, typer.Option("--depth", "-d", help="Max caller traversal depth")] = 2,
|
|
1417
1641
|
max_results: Annotated[int, typer.Option("--max-results", "-m", help="Max items per category")] = 30,
|
|
1418
1642
|
tree: Annotated[bool, typer.Option("--tree", "-t", help="Display visual impact tree")] = False,
|
|
1419
|
-
path: Annotated[Path | None, typer.
|
|
1643
|
+
path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
|
|
1420
1644
|
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
1421
1645
|
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
1422
1646
|
) -> None:
|
|
1423
|
-
"""Compute deep downstream change impact across callers,
|
|
1647
|
+
"""Compute deep downstream change impact across callers, frontend UI, framework routes, tests, and DB."""
|
|
1424
1648
|
from codegraph.change_impact import get_deep_change_impact
|
|
1425
1649
|
|
|
1426
1650
|
repo = _resolve_repo(path, repository)
|
|
1427
1651
|
indexer = _indexer(repo)
|
|
1428
1652
|
|
|
1429
1653
|
with indexer.session() as con:
|
|
1430
|
-
imp = get_deep_change_impact(
|
|
1654
|
+
imp = get_deep_change_impact(
|
|
1655
|
+
repo, con, base=base, head=head, max_depth=depth, max_results=max_results, symbol=symbol
|
|
1656
|
+
)
|
|
1431
1657
|
|
|
1432
1658
|
if json_output:
|
|
1433
1659
|
cli_echo(json.dumps(imp.as_dict(), indent=2), json_mode=True)
|
|
1434
1660
|
else:
|
|
1435
|
-
|
|
1436
|
-
cli_echo(f"
|
|
1437
|
-
cli_echo(f"
|
|
1438
|
-
cli_echo(f"
|
|
1439
|
-
cli_echo(f"
|
|
1440
|
-
cli_echo(f"
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
cli_echo(f"
|
|
1661
|
+
target_title = f"Symbol '{symbol}'" if symbol else f"Git Diff: {base} -> {head}"
|
|
1662
|
+
cli_echo(f"Semantic Change Impact Analysis: {target_title}")
|
|
1663
|
+
cli_echo(f" PR Risk Level: [{imp.risk_level}] (Score: {imp.blast_radius_score} / 100)")
|
|
1664
|
+
cli_echo(f" Total Impacted Items: {imp.total_impacted_count}")
|
|
1665
|
+
cli_echo(f" Direct Callers: {len(imp.direct_callers)}")
|
|
1666
|
+
cli_echo(f" Transitive Callers: {len(imp.transitive_callers)}")
|
|
1667
|
+
if imp.affected_frontend_components:
|
|
1668
|
+
cli_echo(f" Frontend UI Impact: {len(imp.affected_frontend_components)} components")
|
|
1669
|
+
cli_echo(f" Affected Routes: {len(imp.affected_routes)}")
|
|
1670
|
+
cli_echo(f" Covering Tests: {len(imp.affected_tests)}")
|
|
1671
|
+
cli_echo(f" DB Writers: {len(imp.db_writers)}")
|
|
1672
|
+
cli_echo(f" Monorepo Packages: {len(imp.affected_packages)}")
|
|
1673
|
+
|
|
1674
|
+
if imp.breaking_change_flags:
|
|
1675
|
+
cli_echo(f"\n ⚠️ Breaking Risk Flags: {', '.join(imp.breaking_change_flags)}")
|
|
1676
|
+
|
|
1677
|
+
if imp.recommended_test_command:
|
|
1678
|
+
cli_echo(f"\n 🎯 Recommended Test Run: {imp.recommended_test_command}")
|
|
1444
1679
|
|
|
1445
1680
|
if tree or imp.total_impacted_count > 0:
|
|
1446
1681
|
cli_echo("\nImpact Tree:")
|
|
1447
1682
|
for s in imp.changed_symbols[:10]:
|
|
1448
|
-
cli_echo(f" └──
|
|
1683
|
+
cli_echo(f" └── [{s.get('change_type', 'MODIFIED')}] {s.get('path', '')}::{s.get('name', '')}")
|
|
1684
|
+
for fe in imp.affected_frontend_components[:5]:
|
|
1685
|
+
cli_echo(f" ├── UI: {fe.file}::{fe.name} (fetches route)")
|
|
1449
1686
|
for c in imp.direct_callers[:8]:
|
|
1450
|
-
cli_echo(f" ├──
|
|
1687
|
+
cli_echo(f" ├── CALLER: {c.file}::{c.name} (depth 1)")
|
|
1451
1688
|
for r in imp.affected_routes[:5]:
|
|
1452
|
-
cli_echo(f" ├──
|
|
1689
|
+
cli_echo(f" ├── ROUTE: {r.name} -> {r.file}")
|
|
1453
1690
|
for t in imp.affected_tests[:5]:
|
|
1454
|
-
cli_echo(f" ├──
|
|
1691
|
+
cli_echo(f" ├── TEST: {t.file}::{t.name}")
|
|
1455
1692
|
for db in imp.db_writers[:5]:
|
|
1456
|
-
cli_echo(f" └──
|
|
1693
|
+
cli_echo(f" └── DB: {db.relationship} {db.name} in {db.file}")
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
@app.command("git-impact", hidden=True)
|
|
1697
|
+
def git_impact_alias(
|
|
1698
|
+
base: Annotated[str, typer.Argument(help="Base Git ref (default: HEAD~1)")] = "HEAD~1",
|
|
1699
|
+
head: Annotated[str, typer.Argument(help="Head Git ref (default: HEAD)")] = "HEAD",
|
|
1700
|
+
depth: Annotated[int, typer.Option("--depth", "-d", help="Max caller traversal depth")] = 2,
|
|
1701
|
+
max_results: Annotated[int, typer.Option("--max-results", "-m", help="Max items per category")] = 30,
|
|
1702
|
+
tree: Annotated[bool, typer.Option("--tree", "-t", help="Display visual impact tree")] = False,
|
|
1703
|
+
path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
|
|
1704
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
1705
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
1706
|
+
) -> None:
|
|
1707
|
+
"""Backward-compatible alias for codegraph impact."""
|
|
1708
|
+
impact_cmd(
|
|
1709
|
+
base=base,
|
|
1710
|
+
head=head,
|
|
1711
|
+
depth=depth,
|
|
1712
|
+
max_results=max_results,
|
|
1713
|
+
tree=tree,
|
|
1714
|
+
path=path,
|
|
1715
|
+
repository=repository,
|
|
1716
|
+
json_output=json_output,
|
|
1717
|
+
)
|
|
1457
1718
|
|
|
1458
1719
|
|
|
1459
1720
|
@app.command("git-conflicts")
|
|
@@ -1477,7 +1738,7 @@ def git_conflicts_cmd(
|
|
|
1477
1738
|
cli_echo(json.dumps(rep.as_dict(), indent=2), json_mode=True)
|
|
1478
1739
|
else:
|
|
1479
1740
|
cli_echo(f"Semantic Conflict Analysis: {base} <-> {head}")
|
|
1480
|
-
cli_echo(f"Status: {'
|
|
1741
|
+
cli_echo(f"Status: {'[!] CONFLICTS DETECTED' if rep.has_conflicts else '[OK] CLEAN (0 conflicts)'}")
|
|
1481
1742
|
cli_echo(f"Total Conflicts: {rep.total_conflicts}")
|
|
1482
1743
|
cli_echo(f"Summary: {rep.summary}")
|
|
1483
1744
|
if rep.conflicts:
|
|
@@ -1550,6 +1811,496 @@ def symbol_history_cli_cmd(
|
|
|
1550
1811
|
cli_echo(f" Evidence: {ev.evidence} (confidence: {ev.confidence})")
|
|
1551
1812
|
|
|
1552
1813
|
|
|
1814
|
+
refactor_app = typer.Typer(no_args_is_help=True, help="Deterministic, syntax-validated AST refactoring and atomic rollback.")
|
|
1815
|
+
app.add_typer(refactor_app, name="refactor")
|
|
1816
|
+
|
|
1817
|
+
|
|
1818
|
+
@refactor_app.command("rename")
|
|
1819
|
+
def refactor_rename_cli_cmd(
|
|
1820
|
+
target: Annotated[str, typer.Argument(help="Exact symbol name or qualified name to rename")],
|
|
1821
|
+
new_name: Annotated[str, typer.Argument(help="New valid Python identifier name")],
|
|
1822
|
+
apply: Annotated[bool, typer.Option("--apply", help="Apply changes atomically to disk (default is dry-run)")] = False,
|
|
1823
|
+
force: Annotated[bool, typer.Option("--force", help="Force rename despite UNKNOWN/POSSIBLE uncertainty")] = False,
|
|
1824
|
+
path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
|
|
1825
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
1826
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
1827
|
+
) -> None:
|
|
1828
|
+
"""Perform deterministic, syntax-validated, transaction-safe AST rename."""
|
|
1829
|
+
from codegraph.refactor.renamer import execute_safe_rename
|
|
1830
|
+
|
|
1831
|
+
repo = _resolve_repo(path, repository)
|
|
1832
|
+
indexer = _indexer(repo)
|
|
1833
|
+
|
|
1834
|
+
with indexer.session() as con:
|
|
1835
|
+
result = execute_safe_rename(
|
|
1836
|
+
repository=repo,
|
|
1837
|
+
con=con,
|
|
1838
|
+
target=target,
|
|
1839
|
+
new_name=new_name,
|
|
1840
|
+
dry_run=not apply,
|
|
1841
|
+
force_uncertain=force,
|
|
1842
|
+
)
|
|
1843
|
+
|
|
1844
|
+
if json_output:
|
|
1845
|
+
cli_echo(json.dumps(result.to_dict(), indent=2), json_mode=True)
|
|
1846
|
+
else:
|
|
1847
|
+
cli_echo(f"Refactor Status: {result.status}")
|
|
1848
|
+
cli_echo(f"Target: {result.target_symbol} -> {result.new_name}")
|
|
1849
|
+
cli_echo(f"Risk: {result.risk}")
|
|
1850
|
+
cli_echo(f"Files Changed: {result.files_changed}")
|
|
1851
|
+
cli_echo(f"Spans Count: {result.spans_count}")
|
|
1852
|
+
if result.rollback_id:
|
|
1853
|
+
cli_echo(f"Rollback ID: {result.rollback_id}")
|
|
1854
|
+
if result.uncertainty_reasons:
|
|
1855
|
+
cli_echo("\nUncertainty Reasons:")
|
|
1856
|
+
for reason in result.uncertainty_reasons:
|
|
1857
|
+
cli_echo(f" - {reason}")
|
|
1858
|
+
if result.tests_affected:
|
|
1859
|
+
cli_echo(f"Affected Tests: {', '.join(result.tests_affected)}")
|
|
1860
|
+
if result.routes_affected:
|
|
1861
|
+
cli_echo(f"Affected Routes: {', '.join(result.routes_affected)}")
|
|
1862
|
+
if result.diffs:
|
|
1863
|
+
cli_echo("\nUnified Diffs:")
|
|
1864
|
+
for p, diff in result.diffs.items():
|
|
1865
|
+
cli_echo(f"--- {p} ---")
|
|
1866
|
+
cli_echo(diff)
|
|
1867
|
+
if result.errors:
|
|
1868
|
+
cli_echo("\nErrors:")
|
|
1869
|
+
for err in result.errors:
|
|
1870
|
+
cli_echo(f" ! {err}")
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
@refactor_app.command("undo")
|
|
1874
|
+
def refactor_undo_cli_cmd(
|
|
1875
|
+
transaction_id: Annotated[str, typer.Argument(help="Transaction ID to rollback")],
|
|
1876
|
+
path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
|
|
1877
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
1878
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
1879
|
+
) -> None:
|
|
1880
|
+
"""Atomically rollback an applied refactoring transaction."""
|
|
1881
|
+
from codegraph.refactor.models import RefactorStatus
|
|
1882
|
+
from codegraph.refactor.transactions import rollback_refactor
|
|
1883
|
+
|
|
1884
|
+
repo = _resolve_repo(path, repository)
|
|
1885
|
+
result = rollback_refactor(repo, transaction_id)
|
|
1886
|
+
if result.status == RefactorStatus.ROLLED_BACK:
|
|
1887
|
+
try:
|
|
1888
|
+
indexer = _indexer(repo)
|
|
1889
|
+
indexer.index()
|
|
1890
|
+
except Exception:
|
|
1891
|
+
pass
|
|
1892
|
+
|
|
1893
|
+
if json_output:
|
|
1894
|
+
cli_echo(json.dumps(result.to_dict(), indent=2), json_mode=True)
|
|
1895
|
+
else:
|
|
1896
|
+
cli_echo(f"Rollback Status: {result.status}")
|
|
1897
|
+
cli_echo(f"Transaction ID: {transaction_id}")
|
|
1898
|
+
cli_echo(f"Files Restored: {result.files_changed}")
|
|
1899
|
+
if result.errors:
|
|
1900
|
+
cli_echo("\nErrors:")
|
|
1901
|
+
for err in result.errors:
|
|
1902
|
+
cli_echo(f" ! {err}")
|
|
1903
|
+
|
|
1904
|
+
|
|
1905
|
+
# ---------------------------------------------------------------------------
|
|
1906
|
+
# Async Queue Subcommands
|
|
1907
|
+
# ---------------------------------------------------------------------------
|
|
1908
|
+
async_app = typer.Typer(
|
|
1909
|
+
no_args_is_help=True,
|
|
1910
|
+
help="Asynchronous message queues, distributed task linking, and cross-service traces.",
|
|
1911
|
+
)
|
|
1912
|
+
app.add_typer(async_app, name="async")
|
|
1913
|
+
|
|
1914
|
+
|
|
1915
|
+
@async_app.command("queues")
|
|
1916
|
+
def async_queues_cli_cmd(
|
|
1917
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
1918
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
|
|
1919
|
+
) -> None:
|
|
1920
|
+
"""List all detected asynchronous message queues, topics, and task channels."""
|
|
1921
|
+
from codegraph.async_queue.traversal import list_async_queues
|
|
1922
|
+
|
|
1923
|
+
repo = _resolve_repo(None, repository)
|
|
1924
|
+
if not _ensure_indexed(repo):
|
|
1925
|
+
raise typer.Exit(code=1)
|
|
1926
|
+
|
|
1927
|
+
indexer = _indexer(repo)
|
|
1928
|
+
with indexer.session() as con:
|
|
1929
|
+
queues = list_async_queues(con)
|
|
1930
|
+
|
|
1931
|
+
if json_output:
|
|
1932
|
+
cli_echo(json.dumps(queues, indent=2), json_mode=True)
|
|
1933
|
+
else:
|
|
1934
|
+
if not queues:
|
|
1935
|
+
cli_echo("No asynchronous queues, topics, or task channels detected.")
|
|
1936
|
+
return
|
|
1937
|
+
|
|
1938
|
+
cli_echo(f"Found {len(queues)} async queue(s):\n")
|
|
1939
|
+
for q in queues:
|
|
1940
|
+
cli_echo(
|
|
1941
|
+
f" [{q['system'].upper()}] {q['destination']} "
|
|
1942
|
+
f"(producers: {q['producer_count']}, consumers: {q['consumer_count']}, links: {q['link_count']})"
|
|
1943
|
+
)
|
|
1944
|
+
|
|
1945
|
+
|
|
1946
|
+
@async_app.command("trace")
|
|
1947
|
+
def async_trace_cli_cmd(
|
|
1948
|
+
entrypoint: Annotated[str, typer.Argument(help="Entrypoint symbol, handler, or queue topic name")],
|
|
1949
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
1950
|
+
depth: Annotated[int, typer.Option("--depth", "-d", help="Max traversal depth")] = 5,
|
|
1951
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
|
|
1952
|
+
) -> None:
|
|
1953
|
+
"""Trace cross-process asynchronous execution flow starting from an entrypoint."""
|
|
1954
|
+
from codegraph.async_queue.traversal import trace_async_flow
|
|
1955
|
+
|
|
1956
|
+
repo = _resolve_repo(None, repository)
|
|
1957
|
+
if not _ensure_indexed(repo):
|
|
1958
|
+
raise typer.Exit(code=1)
|
|
1959
|
+
|
|
1960
|
+
indexer = _indexer(repo)
|
|
1961
|
+
with indexer.session() as con:
|
|
1962
|
+
result = trace_async_flow(con, entrypoint, max_depth=depth)
|
|
1963
|
+
|
|
1964
|
+
if json_output:
|
|
1965
|
+
cli_echo(json.dumps(result, indent=2), json_mode=True)
|
|
1966
|
+
else:
|
|
1967
|
+
if result.get("status") != "ok":
|
|
1968
|
+
cli_echo(f"No async flow found starting from '{entrypoint}'.")
|
|
1969
|
+
return
|
|
1970
|
+
|
|
1971
|
+
cli_echo(f"Async Flow Trace for '{entrypoint}':\n")
|
|
1972
|
+
cli_echo(f" Producers: {result['producers_count']}")
|
|
1973
|
+
cli_echo(f" Consumers: {result['consumers_count']}")
|
|
1974
|
+
cli_echo(f" Downstream DB: {result['downstream_ops_count']}\n")
|
|
1975
|
+
cli_echo("Execution Steps:")
|
|
1976
|
+
for step in result.get("flow_steps", []):
|
|
1977
|
+
cli_echo(
|
|
1978
|
+
f" [{step['step']}] {step['source']} --({step['relationship']})--> {step['target']} "
|
|
1979
|
+
f"[{step.get('evidence_class', 'AST_VERIFIED')}] ({step.get('file', '')}:{step.get('line', '')})"
|
|
1980
|
+
)
|
|
1981
|
+
|
|
1982
|
+
|
|
1983
|
+
@async_app.command("ingest-traces")
|
|
1984
|
+
def async_ingest_traces_cli_cmd(
|
|
1985
|
+
traces_file: Annotated[Path, typer.Argument(help="Path to JSON file containing OpenTelemetry spans (OTLP or flat JSON)")],
|
|
1986
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
1987
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
|
|
1988
|
+
) -> None:
|
|
1989
|
+
"""Idempotently ingest OpenTelemetry traces and reconcile distributed links with the static graph."""
|
|
1990
|
+
from codegraph.async_queue.otel_parser import parse_otel_spans
|
|
1991
|
+
from codegraph.async_queue.reconciler import OTelTraceReconciler
|
|
1992
|
+
|
|
1993
|
+
repo = _resolve_repo(None, repository)
|
|
1994
|
+
if not _ensure_indexed(repo):
|
|
1995
|
+
raise typer.Exit(code=1)
|
|
1996
|
+
|
|
1997
|
+
if not traces_file.exists():
|
|
1998
|
+
cli_echo(f"Error: traces file '{traces_file}' not found.")
|
|
1999
|
+
raise typer.Exit(code=1)
|
|
2000
|
+
|
|
2001
|
+
try:
|
|
2002
|
+
content = traces_file.read_text(encoding="utf-8")
|
|
2003
|
+
spans = parse_otel_spans(content)
|
|
2004
|
+
except Exception as exc:
|
|
2005
|
+
cli_echo(f"Failed to read/parse traces file: {exc}")
|
|
2006
|
+
raise typer.Exit(code=1) from exc
|
|
2007
|
+
|
|
2008
|
+
indexer = _indexer(repo)
|
|
2009
|
+
with indexer.session() as con:
|
|
2010
|
+
reconciler = OTelTraceReconciler(con)
|
|
2011
|
+
res = reconciler.reconcile_spans(spans)
|
|
2012
|
+
|
|
2013
|
+
if json_output:
|
|
2014
|
+
cli_echo(json.dumps(res.to_dict(), indent=2), json_mode=True)
|
|
2015
|
+
else:
|
|
2016
|
+
cli_echo("OpenTelemetry Trace Ingestion Summary:\n")
|
|
2017
|
+
cli_echo(f" Spans Ingested: {res.spans_ingested}")
|
|
2018
|
+
cli_echo(f" Spans Deduplicated: {res.spans_deduplicated}")
|
|
2019
|
+
cli_echo(f" Links Upgraded: {res.links_upgraded} (to OTEL_DISTRIBUTED_VERIFIED)")
|
|
2020
|
+
cli_echo(f" Cross-Repo Observed: {res.links_created_runtime_only} (RUNTIME_ONLY_OBSERVED)")
|
|
2021
|
+
if res.details:
|
|
2022
|
+
cli_echo("\nReconciliation Details:")
|
|
2023
|
+
for d in res.details:
|
|
2024
|
+
cli_echo(f" * {d.get('action')}: {d.get('destination')} (p50={d.get('p50_ms')}ms, p95={d.get('p95_ms')}ms)")
|
|
2025
|
+
|
|
2026
|
+
|
|
2027
|
+
# ---------------------------------------------------------------------------
|
|
2028
|
+
# Database Intelligence Subcommands
|
|
2029
|
+
# ---------------------------------------------------------------------------
|
|
2030
|
+
db_app = typer.Typer(
|
|
2031
|
+
no_args_is_help=True,
|
|
2032
|
+
help="Database intelligence: tables, columns, ORM models, schema, and impact analysis.",
|
|
2033
|
+
)
|
|
2034
|
+
app.add_typer(db_app, name="db")
|
|
2035
|
+
|
|
2036
|
+
|
|
2037
|
+
@db_app.command("tables")
|
|
2038
|
+
def db_tables_cli_cmd(
|
|
2039
|
+
path: Annotated[Path | None, typer.Argument(help="Repository path or table name filter")] = None,
|
|
2040
|
+
table: Annotated[str | None, typer.Option("--table", "-t", help="Filter by table name")] = None,
|
|
2041
|
+
dialect: Annotated[str | None, typer.Option("--dialect", "-d", help="Filter by SQL dialect")] = None,
|
|
2042
|
+
schema: Annotated[str | None, typer.Option("--schema", "-s", help="Filter by schema name")] = None,
|
|
2043
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
2044
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
|
|
2045
|
+
) -> None:
|
|
2046
|
+
"""Discover database tables and views in the repository."""
|
|
2047
|
+
from codegraph.database.interrogation import find_db_tables
|
|
2048
|
+
|
|
2049
|
+
target_table = table
|
|
2050
|
+
repo_path = path
|
|
2051
|
+
if path is not None and not path.exists() and not table:
|
|
2052
|
+
target_table = str(path)
|
|
2053
|
+
repo_path = None
|
|
2054
|
+
repo = _resolve_repo(repo_path, repository)
|
|
2055
|
+
if not _ensure_indexed(repo):
|
|
2056
|
+
raise typer.Exit(code=1)
|
|
2057
|
+
|
|
2058
|
+
indexer = _indexer(repo)
|
|
2059
|
+
with indexer.session() as con:
|
|
2060
|
+
res = find_db_tables(con, repo, name=target_table, dialect=dialect, schema=schema)
|
|
2061
|
+
|
|
2062
|
+
if json_output:
|
|
2063
|
+
cli_echo(json.dumps(res, indent=2), json_mode=True)
|
|
2064
|
+
else:
|
|
2065
|
+
tables = res.get("tables", [])
|
|
2066
|
+
if not tables:
|
|
2067
|
+
cli_echo("No database tables found.")
|
|
2068
|
+
return
|
|
2069
|
+
cli_echo(f"Found {len(tables)} database table(s):")
|
|
2070
|
+
for t in tables:
|
|
2071
|
+
models = f" -> ORM: {', '.join(t['orm_models'])}" if t.get("orm_models") else ""
|
|
2072
|
+
schema_prefix = f"{t['schema_name']}." if t.get("schema_name") else ""
|
|
2073
|
+
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')}")
|
|
2074
|
+
|
|
2075
|
+
|
|
2076
|
+
@db_app.command("columns")
|
|
2077
|
+
def db_columns_cli_cmd(
|
|
2078
|
+
target: Annotated[str | None, typer.Argument(help="Table name, column name, or repository path")] = None,
|
|
2079
|
+
table: Annotated[str | None, typer.Option("--table", "-t", help="Filter by table name")] = None,
|
|
2080
|
+
column: Annotated[str | None, typer.Option("--column", "-c", help="Filter by column name")] = None,
|
|
2081
|
+
path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
|
|
2082
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
2083
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
|
|
2084
|
+
) -> None:
|
|
2085
|
+
"""Discover database columns, data types, nullability, PK/FK flags, and ORM field mappings."""
|
|
2086
|
+
from codegraph.database.interrogation import find_db_columns
|
|
2087
|
+
|
|
2088
|
+
target_table = table
|
|
2089
|
+
target_column = column
|
|
2090
|
+
repo_path = path
|
|
2091
|
+
if target is not None:
|
|
2092
|
+
p = Path(target)
|
|
2093
|
+
if p.exists() and p.is_dir() and repo_path is None:
|
|
2094
|
+
repo_path = p
|
|
2095
|
+
elif not target_table:
|
|
2096
|
+
if "." in target:
|
|
2097
|
+
parts = target.split(".", 1)
|
|
2098
|
+
target_table = parts[0]
|
|
2099
|
+
target_column = parts[1]
|
|
2100
|
+
else:
|
|
2101
|
+
target_table = target
|
|
2102
|
+
|
|
2103
|
+
repo = _resolve_repo(repo_path, repository)
|
|
2104
|
+
if not _ensure_indexed(repo):
|
|
2105
|
+
raise typer.Exit(code=1)
|
|
2106
|
+
|
|
2107
|
+
indexer = _indexer(repo)
|
|
2108
|
+
with indexer.session() as con:
|
|
2109
|
+
res = find_db_columns(con, repo, table=target_table, column=target_column)
|
|
2110
|
+
|
|
2111
|
+
if json_output:
|
|
2112
|
+
cli_echo(json.dumps(res, indent=2), json_mode=True)
|
|
2113
|
+
else:
|
|
2114
|
+
cols = res.get("columns", [])
|
|
2115
|
+
if not cols:
|
|
2116
|
+
cli_echo("No database columns found.")
|
|
2117
|
+
return
|
|
2118
|
+
cli_echo(f"Found {len(cols)} database column(s):")
|
|
2119
|
+
for c in cols:
|
|
2120
|
+
pk = " [PK]" if c.get("is_primary_key") else ""
|
|
2121
|
+
fk = f" [FK -> {c.get('target_table')}.{c.get('target_column')}]" if c.get("is_foreign_key") else ""
|
|
2122
|
+
nullable = " NULL" if c.get("nullable") else " NOT NULL"
|
|
2123
|
+
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')})")
|
|
2124
|
+
|
|
2125
|
+
|
|
2126
|
+
@db_app.command("models")
|
|
2127
|
+
def db_models_cli_cmd(
|
|
2128
|
+
path: Annotated[Path | None, typer.Argument(help="Repository path or model filter")] = None,
|
|
2129
|
+
model: Annotated[str | None, typer.Option("--model", "-m", help="Filter by model name")] = None,
|
|
2130
|
+
framework: Annotated[str | None, typer.Option("--framework", "-f", help="Filter by ORM framework")] = None,
|
|
2131
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
2132
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
|
|
2133
|
+
) -> None:
|
|
2134
|
+
"""Discover ORM models and classes mapped to database tables."""
|
|
2135
|
+
from codegraph.database.interrogation import find_db_models
|
|
2136
|
+
|
|
2137
|
+
target_model = model
|
|
2138
|
+
repo_path = path
|
|
2139
|
+
if path is not None and not path.exists() and not model:
|
|
2140
|
+
target_model = str(path)
|
|
2141
|
+
repo_path = None
|
|
2142
|
+
repo = _resolve_repo(repo_path, repository)
|
|
2143
|
+
if not _ensure_indexed(repo):
|
|
2144
|
+
raise typer.Exit(code=1)
|
|
2145
|
+
|
|
2146
|
+
indexer = _indexer(repo)
|
|
2147
|
+
with indexer.session() as con:
|
|
2148
|
+
res = find_db_models(con, repo, model=target_model, framework=framework)
|
|
2149
|
+
|
|
2150
|
+
if json_output:
|
|
2151
|
+
cli_echo(json.dumps(res, indent=2), json_mode=True)
|
|
2152
|
+
else:
|
|
2153
|
+
models_list = res.get("models", [])
|
|
2154
|
+
if not models_list:
|
|
2155
|
+
cli_echo("No ORM models found.")
|
|
2156
|
+
return
|
|
2157
|
+
cli_echo(f"Found {len(models_list)} ORM model(s):")
|
|
2158
|
+
for m in models_list:
|
|
2159
|
+
tbl = f" -> Table: {m.get('table_name')}" if m.get("table_name") else ""
|
|
2160
|
+
cli_echo(f" • {m.get('name')} [{m.get('framework')}]{tbl} ({m.get('file')}:{m.get('start_line')})")
|
|
2161
|
+
|
|
2162
|
+
|
|
2163
|
+
@db_app.command("schema")
|
|
2164
|
+
def db_schema_cli_cmd(
|
|
2165
|
+
target: Annotated[str | None, typer.Argument(help="Table name to inspect, or repository path")] = None,
|
|
2166
|
+
table: Annotated[str | None, typer.Option("--table", "-t", help="Inspect single table in detail")] = None,
|
|
2167
|
+
dialect: Annotated[str | None, typer.Option("--dialect", "-d", help="Filter by SQL dialect")] = None,
|
|
2168
|
+
schema_name: Annotated[str | None, typer.Option("--schema", "-s", help="Filter by schema name")] = None,
|
|
2169
|
+
path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
|
|
2170
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
2171
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
|
|
2172
|
+
) -> None:
|
|
2173
|
+
"""Inspect full database schema overview or deep table metadata."""
|
|
2174
|
+
from codegraph.database.interrogation import get_db_schema, get_db_table
|
|
2175
|
+
|
|
2176
|
+
target_table = table
|
|
2177
|
+
repo_path = path
|
|
2178
|
+
if target is not None:
|
|
2179
|
+
p = Path(target)
|
|
2180
|
+
if p.exists() and p.is_dir() and repo_path is None:
|
|
2181
|
+
repo_path = p
|
|
2182
|
+
elif not target_table:
|
|
2183
|
+
target_table = target
|
|
2184
|
+
|
|
2185
|
+
repo = _resolve_repo(repo_path, repository)
|
|
2186
|
+
if not _ensure_indexed(repo):
|
|
2187
|
+
raise typer.Exit(code=1)
|
|
2188
|
+
|
|
2189
|
+
indexer = _indexer(repo)
|
|
2190
|
+
with indexer.session() as con:
|
|
2191
|
+
if target_table:
|
|
2192
|
+
res = get_db_table(con, repo, table=target_table)
|
|
2193
|
+
else:
|
|
2194
|
+
res = get_db_schema(con, repo, dialect=dialect, schema=schema_name)
|
|
2195
|
+
|
|
2196
|
+
if json_output:
|
|
2197
|
+
cli_echo(json.dumps(res, indent=2), json_mode=True)
|
|
2198
|
+
else:
|
|
2199
|
+
if target_table:
|
|
2200
|
+
tbl_info = res.get("table") or {}
|
|
2201
|
+
cols = res.get("columns", [])
|
|
2202
|
+
cli_echo(f"Table: {target_table} ({tbl_info.get('dialect', 'sql')}) [{tbl_info.get('framework', 'db')}]")
|
|
2203
|
+
cli_echo(f"Location: {tbl_info.get('file')}:{tbl_info.get('start_line')}")
|
|
2204
|
+
if res.get("models"):
|
|
2205
|
+
cli_echo(f"ORM Models: {', '.join(m['name'] for m in res['models'])}")
|
|
2206
|
+
cli_echo(f"\nColumns ({len(cols)}):")
|
|
2207
|
+
for c in cols:
|
|
2208
|
+
pk = " [PK]" if c.get("is_primary_key") else ""
|
|
2209
|
+
fk = f" [FK -> {c.get('target_table')}.{c.get('target_column')}]" if c.get("is_foreign_key") else ""
|
|
2210
|
+
cli_echo(f" • {c.get('column_name')}: {c.get('data_type')}{pk}{fk}")
|
|
2211
|
+
if res.get("readers"):
|
|
2212
|
+
cli_echo(f"\nReaders ({len(res['readers'])}):")
|
|
2213
|
+
for r in res["readers"][:5]:
|
|
2214
|
+
cli_echo(f" • {r.get('symbol')} ({r.get('file')}:{r.get('line')})")
|
|
2215
|
+
if res.get("writers"):
|
|
2216
|
+
cli_echo(f"\nWriters ({len(res['writers'])}):")
|
|
2217
|
+
for w in res["writers"][:5]:
|
|
2218
|
+
cli_echo(f" • {w.get('symbol')} ({w.get('file')}:{w.get('line')})")
|
|
2219
|
+
else:
|
|
2220
|
+
cli_echo("Database Schema Overview:")
|
|
2221
|
+
cli_echo(f" Tables: {len(res.get('tables', []))}")
|
|
2222
|
+
cli_echo(f" Columns: {len(res.get('columns', []))}")
|
|
2223
|
+
cli_echo(f" ORM Models: {len(res.get('models', []))}")
|
|
2224
|
+
cli_echo(f" Relationships: {len(res.get('relationships', []))}")
|
|
2225
|
+
cli_echo(f" Migrations: {len(res.get('migrations', []))}")
|
|
2226
|
+
if res.get("tables"):
|
|
2227
|
+
cli_echo("\nTables:")
|
|
2228
|
+
for t in res["tables"][:15]:
|
|
2229
|
+
cli_echo(f" • {t.get('table_name')} ({t.get('dialect', 'sql')})")
|
|
2230
|
+
if len(res["tables"]) > 15:
|
|
2231
|
+
cli_echo(f" ... and {len(res['tables']) - 15} more tables")
|
|
2232
|
+
|
|
2233
|
+
|
|
2234
|
+
@db_app.command("impact")
|
|
2235
|
+
def db_impact_cli_cmd(
|
|
2236
|
+
target: Annotated[str, typer.Argument(help="Table name or Table.column to analyze downstream impact for")],
|
|
2237
|
+
column: Annotated[str | None, typer.Option("--column", "-c", help="Column name if target is a table")] = None,
|
|
2238
|
+
path: Annotated[Path | None, typer.Option("--path", "-p", help="Repository path")] = None,
|
|
2239
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
2240
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
|
|
2241
|
+
) -> None:
|
|
2242
|
+
"""Analyze bidirectional downstream impact of table/column modifications on code, routes, and tests."""
|
|
2243
|
+
from codegraph.database.interrogation import get_db_impact
|
|
2244
|
+
|
|
2245
|
+
target_table = target
|
|
2246
|
+
target_column = column
|
|
2247
|
+
if "." in target and not target_column:
|
|
2248
|
+
parts = target.split(".", 1)
|
|
2249
|
+
target_table = parts[0]
|
|
2250
|
+
target_column = parts[1]
|
|
2251
|
+
|
|
2252
|
+
repo = _resolve_repo(path, repository)
|
|
2253
|
+
if not _ensure_indexed(repo):
|
|
2254
|
+
raise typer.Exit(code=1)
|
|
2255
|
+
|
|
2256
|
+
indexer = _indexer(repo)
|
|
2257
|
+
with indexer.session() as con:
|
|
2258
|
+
res = get_db_impact(con, repo, table=target_table, column=target_column)
|
|
2259
|
+
|
|
2260
|
+
if json_output:
|
|
2261
|
+
cli_echo(json.dumps(res, indent=2), json_mode=True)
|
|
2262
|
+
else:
|
|
2263
|
+
cli_echo(f"Database Impact Analysis for {target}:")
|
|
2264
|
+
cli_echo(f" Direct Readers: {len(res.get('readers', []))}")
|
|
2265
|
+
cli_echo(f" Direct Writers: {len(res.get('writers', []))}")
|
|
2266
|
+
cli_echo(f" Reachable Routes: {len(res.get('routes', []))}")
|
|
2267
|
+
cli_echo(f" Impacted Tests: {len(res.get('tests', []))}")
|
|
2268
|
+
if res.get("routes"):
|
|
2269
|
+
cli_echo("\nReachable Routes:")
|
|
2270
|
+
for r in res["routes"]:
|
|
2271
|
+
cli_echo(f" • [{r.get('http_method')}] {r.get('route_path')} -> {r.get('handler')}")
|
|
2272
|
+
if res.get("tests"):
|
|
2273
|
+
cli_echo("\nImpacted Tests:")
|
|
2274
|
+
for t in res["tests"]:
|
|
2275
|
+
cli_echo(f" • {t.get('test_symbol')} ({t.get('file')}:{t.get('line')})")
|
|
2276
|
+
|
|
2277
|
+
|
|
2278
|
+
@db_app.command("drift")
|
|
2279
|
+
def db_drift_cli_cmd(
|
|
2280
|
+
route: Annotated[str | None, typer.Option("--route", help="Route path or endpoint to check for schema drift")] = None,
|
|
2281
|
+
handler: Annotated[str | None, typer.Option("--handler", "-h", help="Handler symbol to check for schema drift")] = None,
|
|
2282
|
+
table: Annotated[str | None, typer.Option("--table", "-t", help="Target table name (optional, inferred from mutation lineage)")] = None,
|
|
2283
|
+
schema: Annotated[str | None, typer.Option("--schema", "-s", help="Schema class name (optional, inferred from handler signature)")] = None,
|
|
2284
|
+
path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
|
|
2285
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
2286
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output raw JSON array")] = False,
|
|
2287
|
+
) -> None:
|
|
2288
|
+
"""Detect Pydantic / Form validation drift against destination database table columns."""
|
|
2289
|
+
schema_drift(route=route, handler=handler, table=table, schema=schema, path=path, repository=repository, json_output=json_output)
|
|
2290
|
+
|
|
2291
|
+
|
|
2292
|
+
@db_app.command("ingest")
|
|
2293
|
+
def db_ingest_cli_cmd(
|
|
2294
|
+
source: Annotated[Path, typer.Argument(help="Path to OpenTelemetry JSON/JSONL, SQL query log, or trace file")],
|
|
2295
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
2296
|
+
format: Annotated[str, typer.Option("--format", "-f", help="Trace format: auto | otel | json | sql_log")] = "auto",
|
|
2297
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON result")] = False,
|
|
2298
|
+
) -> None:
|
|
2299
|
+
"""Ingest runtime database query logs or OpenTelemetry traces into repository index."""
|
|
2300
|
+
ingest_cli_cmd(source=source, repository=repository, format=format, json_output=json_output)
|
|
2301
|
+
|
|
2302
|
+
|
|
1553
2303
|
|
|
1554
2304
|
if __name__ == "__main__":
|
|
1555
2305
|
app()
|
|
2306
|
+
|