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.
Files changed (80) hide show
  1. codegraph/__init__.py +1 -1
  2. codegraph/agent_brain.py +172 -2
  3. codegraph/agent_capabilities.py +320 -3
  4. codegraph/agent_rules.py +1 -1
  5. codegraph/api_drift.py +455 -0
  6. codegraph/async_queue/__init__.py +50 -0
  7. codegraph/async_queue/detectors/__init__.py +48 -0
  8. codegraph/async_queue/detectors/celery.py +160 -0
  9. codegraph/async_queue/detectors/kafka.py +168 -0
  10. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  11. codegraph/async_queue/detectors/redis.py +130 -0
  12. codegraph/async_queue/detectors/sqs.py +135 -0
  13. codegraph/async_queue/models.py +161 -0
  14. codegraph/async_queue/otel_models.py +74 -0
  15. codegraph/async_queue/otel_parser.py +337 -0
  16. codegraph/async_queue/reconciler.py +427 -0
  17. codegraph/async_queue/scanner.py +291 -0
  18. codegraph/async_queue/traversal.py +447 -0
  19. codegraph/change_impact.py +320 -110
  20. codegraph/cli.py +776 -25
  21. codegraph/cli_output.py +17 -2
  22. codegraph/context.py +38 -11
  23. codegraph/context_freshness.py +2 -0
  24. codegraph/database/__init__.py +14 -0
  25. codegraph/database/extractor.py +712 -17
  26. codegraph/database/interrogation.py +388 -2
  27. codegraph/database/schema_drift.py +862 -0
  28. codegraph/epistemic.py +7 -0
  29. codegraph/evidence_contract.py +21 -0
  30. codegraph/frameworks.py +171 -6
  31. codegraph/frameworks_nestjs.py +502 -0
  32. codegraph/frameworks_nextjs.py +337 -0
  33. codegraph/frameworks_react.py +457 -0
  34. codegraph/freshness.py +2 -0
  35. codegraph/frontend_html.py +109 -0
  36. codegraph/git.py +2 -0
  37. codegraph/git_state.py +2 -0
  38. codegraph/indexing/__init__.py +7 -1
  39. codegraph/indexing/indexer.py +291 -38
  40. codegraph/indexing/models.py +6 -0
  41. codegraph/indexing/parser.py +239 -0
  42. codegraph/indexing/pool.py +136 -0
  43. codegraph/indexing/scanner.py +6 -0
  44. codegraph/indexing/templates.py +418 -0
  45. codegraph/interrogation.py +128 -20
  46. codegraph/mcp/server.py +190 -28
  47. codegraph/models.py +7 -0
  48. codegraph/monorepo.py +3 -0
  49. codegraph/optimizer.py +42 -5
  50. codegraph/process_lifecycle.py +31 -13
  51. codegraph/refactor/__init__.py +36 -0
  52. codegraph/refactor/models.py +114 -0
  53. codegraph/refactor/renamer.py +844 -0
  54. codegraph/refactor/transactions.py +403 -0
  55. codegraph/resolver.py +1202 -159
  56. codegraph/retrieval_policy.py +6 -0
  57. codegraph/route_topology.py +172 -0
  58. codegraph/runtime/__init__.py +19 -0
  59. codegraph/runtime/collector.py +155 -0
  60. codegraph/runtime/db_watcher.py +191 -0
  61. codegraph/runtime/ingestor.py +30 -10
  62. codegraph/runtime/port_inspector.py +304 -0
  63. codegraph/runtime/runner.py +155 -6
  64. codegraph/runtime/trace_linker.py +175 -0
  65. codegraph/search/hybrid.py +26 -0
  66. codegraph/semantic_decorators.py +34 -3
  67. codegraph/structural_diff.py +2 -0
  68. codegraph/styles.py +144 -0
  69. codegraph/symbol_history.py +2 -0
  70. codegraph/tsconfig.py +236 -0
  71. codegraph/ui/__init__.py +6 -0
  72. codegraph/ui/server.py +715 -0
  73. codegraph/watcher.py +323 -0
  74. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/METADATA +150 -38
  75. codegraph_engine-3.0.0.dist-info/RECORD +129 -0
  76. codegraph_engine-2.3.0.dist-info/RECORD +0 -94
  77. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/WHEEL +0 -0
  78. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/entry_points.txt +0 -0
  79. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/licenses/LICENSE +0 -0
  80. {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
- pass
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
- result = [r.as_dict() for r in search_code(con, query, top_k)]
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("git-impact")
1413
- def git_impact_cmd(
1414
- base: Annotated[str, typer.Argument(help="Base Git ref (default: HEAD~1)")] = "HEAD~1",
1415
- head: Annotated[str, typer.Argument(help="Head Git ref (default: HEAD)")] = "HEAD",
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.Option("--path", "-p", help="Repository path")] = None,
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, callees, framework routes, tests, and DB."""
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(repo, con, base=base, head=head, max_depth=depth, max_results=max_results)
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
- cli_echo(f"Deep Change Impact Analysis: {base} -> {head}")
1436
- cli_echo(f"Blast Radius Score: {imp.blast_radius_score} / 100")
1437
- cli_echo(f"Total Impacted Items: {imp.total_impacted_count}")
1438
- cli_echo(f"Direct Callers: {len(imp.direct_callers)}")
1439
- cli_echo(f"Transitive Callers: {len(imp.transitive_callers)}")
1440
- cli_echo(f"Affected Routes: {len(imp.affected_routes)}")
1441
- cli_echo(f"Covering Tests: {len(imp.affected_tests)}")
1442
- cli_echo(f"DB Writers: {len(imp.db_writers)}")
1443
- cli_echo(f"Monorepo Packages: {len(imp.affected_packages)}")
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" └── 📄 [{s.get('change_type', 'MODIFIED')}] {s.get('path', '')}::{s.get('name', '')}")
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" ├── 📞 CALLER: {c.file}::{c.name} (depth 1)")
1687
+ cli_echo(f" ├── CALLER: {c.file}::{c.name} (depth 1)")
1451
1688
  for r in imp.affected_routes[:5]:
1452
- cli_echo(f" ├── 🌐 ROUTE: {r.name} -> {r.file}")
1689
+ cli_echo(f" ├── ROUTE: {r.name} -> {r.file}")
1453
1690
  for t in imp.affected_tests[:5]:
1454
- cli_echo(f" ├── 🧪 TEST: {t.file}::{t.name}")
1691
+ cli_echo(f" ├── TEST: {t.file}::{t.name}")
1455
1692
  for db in imp.db_writers[:5]:
1456
- cli_echo(f" └── 💾 DB: {db.relationship} {db.name} in {db.file}")
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: {'⚠️ CONFLICTS DETECTED' if rep.has_conflicts else '✅ CLEAN (0 conflicts)'}")
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
+