adl-toolkit 0.0.0-stage → 0.1.1

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 (104) hide show
  1. package/INSTALL_TOOLKIT.md +163 -0
  2. package/README.md +269 -2
  3. package/adl_drift/__init__.py +6 -0
  4. package/adl_drift/cli/__init__.py +1 -0
  5. package/adl_drift/cli/commands.py +993 -0
  6. package/adl_drift/cli/main.py +744 -0
  7. package/adl_drift/cortex/__init__.py +1 -0
  8. package/adl_drift/cortex/embeddings.py +96 -0
  9. package/adl_drift/cortex/memory.py +1749 -0
  10. package/adl_drift/cortex/patterns.py +89 -0
  11. package/adl_drift/cortex/telemetry.py +192 -0
  12. package/adl_drift/detectors/__init__.py +1 -0
  13. package/adl_drift/detectors/async_patterns.py +115 -0
  14. package/adl_drift/detectors/base.py +121 -0
  15. package/adl_drift/detectors/config.py +122 -0
  16. package/adl_drift/detectors/data_access.py +128 -0
  17. package/adl_drift/detectors/decorators.py +122 -0
  18. package/adl_drift/detectors/dynamic_patterns.py +260 -0
  19. package/adl_drift/detectors/errors.py +134 -0
  20. package/adl_drift/detectors/imports.py +132 -0
  21. package/adl_drift/detectors/logging_patterns.py +108 -0
  22. package/adl_drift/detectors/routes.py +257 -0
  23. package/adl_drift/detectors/security.py +236 -0
  24. package/adl_drift/detectors/taint.py +406 -0
  25. package/adl_drift/detectors/tests.py +95 -0
  26. package/adl_drift/detectors/type_conventions.py +81 -0
  27. package/adl_drift/graph/__init__.py +1 -0
  28. package/adl_drift/graph/builder.py +2670 -0
  29. package/adl_drift/graph/cfg.py +461 -0
  30. package/adl_drift/graph/cluster.py +40 -0
  31. package/adl_drift/graph/edges.py +86 -0
  32. package/adl_drift/graph/node.py +90 -0
  33. package/adl_drift/harness/__init__.py +10 -0
  34. package/adl_drift/harness/setup.py +832 -0
  35. package/adl_drift/harness/skills/drift-antigravity.md +19 -0
  36. package/adl_drift/harness/skills/drift-generic.md +19 -0
  37. package/adl_drift/harness/skills/drift.md +25 -0
  38. package/adl_drift/harness/watch.py +84 -0
  39. package/adl_drift/lang/__init__.py +8 -0
  40. package/adl_drift/lang/base.py +464 -0
  41. package/adl_drift/lang/java/__init__.py +6 -0
  42. package/adl_drift/lang/java/backend.py +97 -0
  43. package/adl_drift/lang/java/cfg.py +1424 -0
  44. package/adl_drift/lang/java/config.py +707 -0
  45. package/adl_drift/lang/java/detectors/__init__.py +29 -0
  46. package/adl_drift/lang/java/detectors/additional.py +445 -0
  47. package/adl_drift/lang/java/detectors/base.py +154 -0
  48. package/adl_drift/lang/java/detectors/data_access.py +148 -0
  49. package/adl_drift/lang/java/detectors/imports.py +53 -0
  50. package/adl_drift/lang/java/detectors/routes.py +159 -0
  51. package/adl_drift/lang/java/detectors/security.py +386 -0
  52. package/adl_drift/lang/java/detectors/tests.py +74 -0
  53. package/adl_drift/lang/java/extractor.py +582 -0
  54. package/adl_drift/lang/java/ids.py +99 -0
  55. package/adl_drift/lang/java/parser.py +49 -0
  56. package/adl_drift/lang/java/resolution/__init__.py +1 -0
  57. package/adl_drift/lang/java/resolution/constants.py +1619 -0
  58. package/adl_drift/lang/java/resolution/constructors.py +278 -0
  59. package/adl_drift/lang/java/resolution/context.py +305 -0
  60. package/adl_drift/lang/java/resolution/dispatch.py +540 -0
  61. package/adl_drift/lang/java/resolution/functional.py +1318 -0
  62. package/adl_drift/lang/java/resolution/generated.py +95 -0
  63. package/adl_drift/lang/java/resolution/model.py +153 -0
  64. package/adl_drift/lang/java/resolution/orchestration.py +866 -0
  65. package/adl_drift/lang/java/resolution/overloads.py +1275 -0
  66. package/adl_drift/lang/java/resolution/scopes.py +1111 -0
  67. package/adl_drift/lang/java/resolution/types.py +2947 -0
  68. package/adl_drift/lang/java/resolver.py +1401 -0
  69. package/adl_drift/lang/java/scopes.py +69 -0
  70. package/adl_drift/lang/java/taint.py +500 -0
  71. package/adl_drift/lang/java/type_provenance.py +342 -0
  72. package/adl_drift/lang/python/__init__.py +8 -0
  73. package/adl_drift/lang/python/backend.py +94 -0
  74. package/adl_drift/lang/python/cfg.py +537 -0
  75. package/adl_drift/lang/python/extractor.py +454 -0
  76. package/adl_drift/lang/python/parser.py +41 -0
  77. package/adl_drift/lang/python/resolver.py +1789 -0
  78. package/adl_drift/lang/python/taint.py +365 -0
  79. package/adl_drift/lang/registry.py +117 -0
  80. package/adl_drift/parser/__init__.py +1 -0
  81. package/adl_drift/parser/cache.py +38 -0
  82. package/adl_drift/parser/core.py +33 -0
  83. package/adl_drift/parser/extractor.py +17 -0
  84. package/adl_drift/parser/literal_eval.py +326 -0
  85. package/adl_drift/scanner/__init__.py +1 -0
  86. package/adl_drift/scanner/discovery.py +338 -0
  87. package/adl_drift/scanner/incremental.py +261 -0
  88. package/adl_drift/scanner/parallel.py +224 -0
  89. package/adl_drift/server/__init__.py +1 -0
  90. package/adl_drift/server/handlers.py +2849 -0
  91. package/adl_drift/server/mcp_server.py +127 -0
  92. package/adl_drift/server/mcp_stdio.py +335 -0
  93. package/adl_drift/server/protocol.py +71 -0
  94. package/adl_drift/server/static/index.html +696 -0
  95. package/adl_drift/server/web.py +350 -0
  96. package/adl_drift/utils/__init__.py +1 -0
  97. package/adl_drift/utils/config.py +77 -0
  98. package/adl_drift/utils/fswalk.py +52 -0
  99. package/adl_drift/utils/types.py +345 -0
  100. package/bin/adl-drift.mjs +35 -0
  101. package/bin/adl-toolkit.mjs +488 -0
  102. package/package.json +29 -4
  103. package/pyproject.toml +49 -0
  104. package/scripts/install_toolkit.py +3584 -0
@@ -0,0 +1,744 @@
1
+ """Main CLI entry point.
2
+
3
+ Entry point: `adl-drift`
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Optional
11
+
12
+ import click
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+ from rich.panel import Panel
16
+ from rich.tree import Tree
17
+ from rich.progress import (
18
+ Progress, SpinnerColumn, TextColumn, BarColumn,
19
+ MofNCompleteColumn, TimeElapsedColumn,
20
+ )
21
+
22
+ from adl_drift.cli.commands import DriftCommands
23
+
24
+ if sys.platform == "win32":
25
+ for stream in (sys.stdout, sys.stderr):
26
+ try:
27
+ stream.reconfigure(encoding="utf-8")
28
+ except (AttributeError, OSError):
29
+ pass
30
+
31
+ console = Console()
32
+
33
+
34
+ @click.group()
35
+ @click.option("--project", "-p", type=click.Path(exists=True), default=".",
36
+ help="Project root directory")
37
+ @click.pass_context
38
+ def cli(ctx: click.Context, project: str) -> None:
39
+ """ADL Drift - Static analysis for Python and Java codebases."""
40
+ ctx.ensure_object(dict)
41
+ ctx.obj["project_path"] = Path(project)
42
+ ctx.obj["commands"] = DriftCommands(ctx.obj["project_path"])
43
+
44
+
45
+ # --more-info display helpers
46
+ _OBJECT_KEYS = (
47
+ "handler", "function", "decorator", "name", "class",
48
+ "key", "key_name", "route_path", "module", "type", "role",
49
+ )
50
+ _SEVERITY_STYLES = {"CRITICAL": "bold white on red", "HIGH": "bold red",
51
+ "MEDIUM": "yellow", "LOW": "dim"}
52
+ _MORE_INFO_ROW_CAP = 50
53
+
54
+
55
+ def _confidence_word(conf: float) -> str:
56
+ """Translate a 0–1 confidence into a plain word."""
57
+ if conf >= 0.85:
58
+ return "Certain"
59
+ if conf >= 0.6:
60
+ return "Likely"
61
+ return "Guess"
62
+
63
+
64
+ def _result_object(r: dict) -> str:
65
+ md = r.get("metadata") or {}
66
+ return next((str(md[k]) for k in _OBJECT_KEYS if md.get(k)), "")
67
+
68
+
69
+ def _baseline_dict(ctx: Optional[Any]) -> Optional[Dict[str, Any]]:
70
+ """Convert a ProjectContext into a JSON-serializable dict."""
71
+ if ctx is None:
72
+ return None
73
+ return {
74
+ "files_analyzed": ctx.files_analyzed,
75
+ "lines_of_code": ctx.lines_of_code,
76
+ "non_blank_lines": ctx.non_blank_lines,
77
+ "total_symbols": ctx.total_symbols,
78
+ "total_functions": ctx.total_functions,
79
+ "total_classes": ctx.total_classes,
80
+ "total_variables": ctx.total_variables,
81
+ "total_modules": ctx.total_modules,
82
+ "entry_points": ctx.entry_points,
83
+ "calls_edges": ctx.calls_edges,
84
+ "total_patterns": ctx.total_patterns,
85
+ "frameworks": ctx.frameworks,
86
+ "patterns_by_category": ctx.patterns_by_category,
87
+ "excluded_files": ctx.excluded_files,
88
+ }
89
+
90
+
91
+ def _print_scan_summary(results: list, files_scanned: int, errors: int = 0,
92
+ baseline: Optional[Any] = None) -> None:
93
+ """One-line overview + category counts (the part a non-dev reads)."""
94
+ sec = [r for r in results if r["category"] in ("SECURITY", "TAINT")]
95
+ sev_counts: dict = {}
96
+ for r in sec:
97
+ sev = str((r.get("metadata") or {}).get("severity", "")).upper()
98
+ if sev:
99
+ sev_counts[sev] = sev_counts.get(sev, 0) + 1
100
+
101
+ parts = [f"Scanned [bold]{files_scanned}[/bold] files",
102
+ f"[bold]{len(results)}[/bold] findings"]
103
+ if baseline:
104
+ parts.append(f"[bold]{baseline.lines_of_code:,}[/bold] LOC")
105
+ parts.append(f"[bold]{baseline.total_symbols}[/bold] symbols")
106
+ if baseline.entry_points:
107
+ parts.append(f"[bold]{baseline.entry_points}[/bold] entry points")
108
+ if sec:
109
+ sev_bits = ", ".join(
110
+ f"[{_SEVERITY_STYLES.get(s, 'white')}]{n} {s}[/]"
111
+ for s, n in sorted(sev_counts.items())
112
+ )
113
+ parts.append(f"{len(sec)} security ({sev_bits})" if sev_bits else f"{len(sec)} security")
114
+ if errors:
115
+ parts.append(f"[red]{errors} errors[/red]")
116
+ excluded = getattr(baseline, "excluded_files", None) or []
117
+ if excluded:
118
+ by_reason: dict = {}
119
+ for e in excluded:
120
+ by_reason[e["reason"]] = by_reason.get(e["reason"], 0) + 1
121
+ reason_bits = ", ".join(f"{n} {r}" for r, n in sorted(by_reason.items()))
122
+ parts.append(f"[dim]{len(excluded)} excluded ({reason_bits})[/dim]")
123
+ console.print(" · ".join(parts))
124
+
125
+ cat_counts: dict = {}
126
+ for r in results:
127
+ cat_counts[r["category"]] = cat_counts.get(r["category"], 0) + 1
128
+ console.print("[dim]" + " · ".join(
129
+ f"{c} {n}" for c, n in sorted(cat_counts.items(), key=lambda x: -x[1])
130
+ ) + "[/dim]\n")
131
+
132
+
133
+ def _print_scan_errors(errors: list) -> None:
134
+ """P2.5: per-file scan failures, shown after the results table."""
135
+ if not errors:
136
+ return
137
+ console.print(f"[red]{len(errors)} file(s) failed to scan:[/red]")
138
+ for e in errors:
139
+ console.print(f" [dim]{e['file']}[/dim] — {e['error']}")
140
+
141
+
142
+ def _print_more_info_table(results: list, full: bool) -> None:
143
+ shown = results if full else results[:_MORE_INFO_ROW_CAP]
144
+
145
+ table = Table(title=f"Detected Patterns ({len(results)})")
146
+ table.add_column("Category", style="cyan")
147
+ table.add_column("Object", style="white")
148
+ table.add_column("What was found", style="green", max_width=60)
149
+ table.add_column("Severity")
150
+ table.add_column("File", style="blue")
151
+ table.add_column("Line", style="yellow")
152
+ table.add_column("Confidence", style="magenta")
153
+
154
+ for r in shown:
155
+ md = r.get("metadata") or {}
156
+ sev = str(md.get("severity", "")).upper()
157
+ sev_cell = f"[{_SEVERITY_STYLES[sev]}]{sev}[/]" if sev in _SEVERITY_STYLES else ""
158
+ table.add_row(
159
+ r["category"],
160
+ _result_object(r),
161
+ r.get("description") or r["name"],
162
+ sev_cell,
163
+ r["file"],
164
+ str(r["line"]),
165
+ _confidence_word(r.get("confidence", 0.0)),
166
+ )
167
+ console.print(table)
168
+
169
+ hidden = len(results) - len(shown)
170
+ if hidden > 0:
171
+ console.print(
172
+ f"[dim]… {hidden} more findings not shown — "
173
+ f"use --full to see everything, or -c <category> to filter.[/dim]"
174
+ )
175
+
176
+
177
+ @cli.command()
178
+ @click.argument("path", type=click.Path(exists=True), default=".")
179
+ @click.option("--category", "-c", help="Filter by pattern category")
180
+ @click.option("--format", "-f", type=click.Choice(["table", "json", "github"]), default="table")
181
+ @click.option("--incremental", "-i", is_flag=True, help="Only re-parse changed files")
182
+ @click.option("--lang", "language", type=click.Choice(["python", "java"]),
183
+ help="Scan one language only; default auto-detects all backends")
184
+ @click.option("--more-info", "more_info", is_flag=True,
185
+ help="Beginner-friendly output: summary line, plain-language descriptions, "
186
+ "severity colors, worded confidence, capped row count")
187
+ @click.option("--full", is_flag=True,
188
+ help=f"With --more-info: show all rows instead of the first {_MORE_INFO_ROW_CAP}")
189
+ @click.option("--baseline", "show_baseline", is_flag=True,
190
+ help="Print project baseline JSON (files, LOC, symbols) and exit.")
191
+ @click.pass_context
192
+ def scan(ctx: click.Context, path: str, category: Optional[str], format: str,
193
+ incremental: bool, language: Optional[str], more_info: bool, full: bool,
194
+ show_baseline: bool) -> None:
195
+ """Scan files or directories for patterns."""
196
+ commands = ctx.obj["commands"]
197
+
198
+ use_progress = sys.stdout.isatty() and format == "table" and not show_baseline
199
+
200
+ if use_progress:
201
+ progress_ctx = Progress(
202
+ SpinnerColumn(),
203
+ TextColumn("[progress.description]{task.description}"),
204
+ BarColumn(),
205
+ MofNCompleteColumn(),
206
+ TimeElapsedColumn(),
207
+ console=console,
208
+ transient=True,
209
+ )
210
+ with progress_ctx as progress:
211
+ task = progress.add_task("scanning", total=None)
212
+
213
+ def _progress_cb(done: int, total: int, detail: str) -> None:
214
+ if detail.startswith("PHASE:"):
215
+ phase = detail[7:].strip()
216
+ progress.update(task, description=phase, total=None, completed=0)
217
+ else:
218
+ name = Path(detail).name
219
+ progress.update(task, advance=1, total=total,
220
+ description=f"scanning [dim]{name}[/dim]")
221
+
222
+ results = commands.scan(Path(path), category, incremental=incremental,
223
+ progress_callback=_progress_cb, language=language)
224
+ else:
225
+ results = commands.scan(Path(path), category, incremental=incremental, language=language)
226
+
227
+ scan_errors = commands.last_scan_errors
228
+ baseline_ctx = commands.last_scan_context
229
+ baseline = _baseline_dict(baseline_ctx)
230
+ if baseline is None:
231
+ baseline = commands.memory.get_scan_baseline(Path(path)) or {}
232
+
233
+ if show_baseline:
234
+ import json
235
+ print(json.dumps(baseline, indent=2))
236
+ return
237
+
238
+ if format == "json":
239
+ import json
240
+ print(json.dumps({"results": results, "errors": scan_errors, "baseline": baseline}, indent=2))
241
+ elif format == "github":
242
+ # GitHub Actions annotation format
243
+ for r in results:
244
+ level = "warning" if r.get("confidence", 0) > 0.7 else "notice"
245
+ click.echo(
246
+ f"::{level} file={r['file']},line={r['line']}::{r['name']}: {r['description']}"
247
+ )
248
+ for e in scan_errors:
249
+ click.echo(f"::error file={e['file']}::scan failed: {e['error']}")
250
+ elif more_info:
251
+ files_scanned = len(commands._find_files(Path(path)))
252
+ _print_scan_summary(results, files_scanned, errors=len(scan_errors), baseline=baseline_ctx)
253
+ _print_more_info_table(results, full)
254
+ _print_scan_errors(scan_errors)
255
+ else:
256
+ table = Table(title=f"Detected Patterns ({len(results)})")
257
+ table.add_column("Category", style="cyan")
258
+ table.add_column("Name", style="green")
259
+ table.add_column("Object", style="white")
260
+ table.add_column("File", style="blue")
261
+ table.add_column("Line", style="yellow")
262
+ table.add_column("Confidence", style="magenta")
263
+
264
+ for r in results:
265
+ table.add_row(
266
+ r["category"],
267
+ r["name"],
268
+ _result_object(r),
269
+ r["file"],
270
+ str(r["line"]),
271
+ f"{r['confidence']:.2f}",
272
+ )
273
+ console.print(table)
274
+ _print_scan_errors(scan_errors)
275
+
276
+
277
+ @cli.command()
278
+ @click.argument("pattern_id")
279
+ @click.pass_context
280
+ def approve(ctx: click.Context, pattern_id: str) -> None:
281
+ """Approve a detected pattern."""
282
+ commands = ctx.obj["commands"]
283
+ commands.approve(pattern_id)
284
+ console.print(f"[green]Approved pattern: {pattern_id}[/green]")
285
+
286
+
287
+ @cli.command()
288
+ @click.pass_context
289
+ def patterns(ctx: click.Context) -> None:
290
+ """List all detected patterns."""
291
+ commands = ctx.obj["commands"]
292
+ patterns = commands.list_patterns()
293
+
294
+ table = Table(title="Patterns")
295
+ table.add_column("ID", style="dim")
296
+ table.add_column("Name", style="green")
297
+ table.add_column("Category", style="cyan")
298
+ table.add_column("Approved", style="yellow")
299
+ table.add_column("Confidence", style="magenta")
300
+
301
+ for p in patterns:
302
+ table.add_row(
303
+ p["id"][:16],
304
+ p["name"],
305
+ p["category"],
306
+ "✓" if p["is_approved"] else "",
307
+ f"{p['confidence']:.2f}",
308
+ )
309
+ console.print(table)
310
+
311
+
312
+ @cli.command()
313
+ @click.argument("topic")
314
+ @click.argument("content", nargs=-1)
315
+ @click.pass_context
316
+ def learn(ctx: click.Context, topic: str, content: tuple[str, ...]) -> None:
317
+ """Add tribal knowledge."""
318
+ text = " ".join(content).strip()
319
+ if not text:
320
+ if not sys.stdin.isatty():
321
+ text = sys.stdin.read().strip()
322
+ else:
323
+ template = (
324
+ f"# Tribal knowledge for topic: {topic}\n"
325
+ "# Write the note below. Lines starting with '#' are ignored.\n"
326
+ "# Save and close to submit. Close without saving to abort.\n\n"
327
+ )
328
+ console.print(f"[dim]Opening editor for topic '{topic}'...[/dim]")
329
+ edited = click.edit(template)
330
+ if edited is None:
331
+ raise click.UsageError(
332
+ "Editor aborted or no editor configured. "
333
+ "Set $env:EDITOR (e.g. 'notepad'), pass content as args, "
334
+ "or pipe via stdin."
335
+ )
336
+ text = "\n".join(
337
+ ln for ln in edited.splitlines() if not ln.lstrip().startswith("#")
338
+ ).strip()
339
+ if not text:
340
+ raise click.UsageError("Content empty after stripping comments. Nothing saved.")
341
+ commands = ctx.obj["commands"]
342
+ kid = commands.add_knowledge(topic, text)
343
+ console.print(f"[green]Added knowledge: {kid}[/green]")
344
+
345
+
346
+ @cli.command()
347
+ @click.argument("topic")
348
+ @click.pass_context
349
+ def why(ctx: click.Context, topic: str) -> None:
350
+ """Query tribal knowledge."""
351
+ commands = ctx.obj["commands"]
352
+ knowledge = commands.get_knowledge(topic)
353
+
354
+ if not knowledge:
355
+ console.print(f"[yellow]No knowledge stored for topic '{topic}'.[/yellow]")
356
+ return
357
+ for k in knowledge:
358
+ console.print(Panel(
359
+ k["content"],
360
+ title=f"[bold]{k['topic']}[/bold]",
361
+ subtitle=k["updated_at"],
362
+ width=min(console.width, 100),
363
+ expand=False,
364
+ ))
365
+
366
+
367
+ @cli.command()
368
+ @click.option("--god-nodes", is_flag=True, default=False, help="Show top-N highest-degree nodes (architectural linchpins).")
369
+ @click.option("--top-n", default=10, show_default=True, help="Number of god nodes to show (with --god-nodes).")
370
+ @click.option("--surprising", is_flag=True, default=False, help="Show cross-community CALLS/INSTANTIATES edges (accidental coupling).")
371
+ @click.pass_context
372
+ def graph(ctx: click.Context, god_nodes: bool, top_n: int, surprising: bool) -> None:
373
+ """Show call graph statistics (nodes, edges by type, entry points)."""
374
+ commands = ctx.obj["commands"]
375
+ console.print("[dim]Building call graph...[/dim]")
376
+
377
+ if god_nodes:
378
+ nodes = commands.god_nodes(top_n)
379
+ table = Table(title=f"Top-{top_n} God Nodes (highest degree)")
380
+ table.add_column("Rank", style="dim")
381
+ table.add_column("ID", style="cyan")
382
+ table.add_column("Type", style="yellow")
383
+ table.add_column("Degree", style="green")
384
+ table.add_column("In", style="dim green")
385
+ table.add_column("Out", style="dim green")
386
+ table.add_column("File", style="dim")
387
+ for i, node in enumerate(nodes, 1):
388
+ file_str = f"{node['file']}:{node['line']}" if node.get("file") else ""
389
+ table.add_row(str(i), node["id"], node["type"], str(node["degree"]),
390
+ str(node["in_degree"]), str(node["out_degree"]), file_str)
391
+ console.print(table)
392
+ return
393
+
394
+ if surprising:
395
+ edges = commands.surprising_connections()
396
+ if not edges:
397
+ console.print("[green]No surprising cross-community connections found.[/green]")
398
+ return
399
+ table = Table(title=f"Surprising Cross-Community Connections ({len(edges)} total)")
400
+ table.add_column("From", style="cyan")
401
+ table.add_column("To", style="cyan")
402
+ table.add_column("Type", style="yellow")
403
+ table.add_column("Conf", style="green")
404
+ table.add_column("From Comm", style="dim")
405
+ table.add_column("To Comm", style="dim")
406
+ for edge in edges[:50]:
407
+ table.add_row(edge["from"], edge["to"], edge["edge_type"],
408
+ f"{edge['confidence']:.2f}",
409
+ str(edge["from_community"]), str(edge["to_community"]))
410
+ if len(edges) > 50:
411
+ console.print(f"[dim](showing 50 of {len(edges)})[/dim]")
412
+ console.print(table)
413
+ return
414
+
415
+ stats = commands.graph_stats()
416
+ table = Table(title="Call Graph Stats")
417
+ table.add_column("Metric", style="cyan")
418
+ table.add_column("Value", style="green")
419
+ table.add_row("Total nodes", str(stats["total_nodes"]))
420
+ table.add_row("Total edges", str(stats["total_edges"]))
421
+ table.add_row("Entry points", str(stats["entry_points"]))
422
+ for k, v in stats["node_types"].items():
423
+ table.add_row(f" node:{k}", str(v))
424
+ for k, v in stats["edge_types"].items():
425
+ table.add_row(f" edge:{k}", str(v))
426
+ console.print(table)
427
+
428
+
429
+ @cli.command()
430
+ @click.argument("symbol")
431
+ @click.option("--format", "-f", type=click.Choice(["tree", "table", "json"]), default="tree")
432
+ @click.option(
433
+ "--include-initializers",
434
+ is_flag=True,
435
+ help="Count synthetic Java <init>/<clinit> executable contexts as callers.",
436
+ )
437
+ @click.pass_context
438
+ def impact(
439
+ ctx: click.Context, symbol: str, format: str, include_initializers: bool,
440
+ ) -> None:
441
+ """Blast-radius analysis: who calls SYMBOL (direct + transitive)."""
442
+ commands = ctx.obj["commands"]
443
+ # Keep human chatter on stderr so `-f json` stdout is machine-parseable (P2.24).
444
+ click.echo(f"Building call graph and resolving '{symbol}'...", err=True)
445
+ report = commands.impact(symbol, include_initializers=include_initializers)
446
+
447
+ if "error" in report:
448
+ console.print(f"[red]{report['error']}: '{symbol}'[/red]")
449
+ sys.exit(1)
450
+
451
+ if format == "json":
452
+ import json
453
+ print(json.dumps(report, indent=2, default=str))
454
+ return
455
+
456
+ if format == "table":
457
+ t = Table(title=f"Impact: {report['target']}")
458
+ t.add_column("Field", style="cyan")
459
+ t.add_column("Value", style="green")
460
+ for k in ("target_file", "affected_member_count", "initializer_caller_count",
461
+ "transitive_initializer_caller_count",
462
+ "direct_caller_count", "instantiator_count",
463
+ "transitive_caller_count", "transitive_instantiator_count",
464
+ "affected_entry_count",
465
+ "affected_file_count", "user_facing", "max_depth"):
466
+ t.add_row(k, str(report[k]))
467
+ if report["ambiguous_matches"]:
468
+ t.add_row(
469
+ "[yellow]ambiguous_matches[/yellow]",
470
+ f"[yellow]{len(report['ambiguous_matches'])} distinct targets merged into the counts above[/yellow]",
471
+ )
472
+ console.print(t)
473
+ return
474
+
475
+ # tree
476
+ tree = Tree(f"[bold]{report['target']}[/bold] [dim]({report['target_file']})[/dim]")
477
+ if report["ambiguous_matches"]:
478
+ # `get_impact_analysis` merges every distinct target's callers into
479
+ # the counts below — it does not "pick first" (bugfix: the old
480
+ # wording claimed the opposite of what the code does, so a merged/
481
+ # inflated count could pass for a single clean target's result).
482
+ warn = tree.add(
483
+ f"[yellow]ambiguous: {len(report['ambiguous_matches'])} matches, "
484
+ f"merged into the counts below[/yellow]"
485
+ )
486
+ for m in report["ambiguous_matches"]:
487
+ warn.add(m)
488
+ if report.get("affected_member_count"):
489
+ mem_branch = tree.add(
490
+ f"[bold]Container members[/bold] ({report['affected_member_count']}) "
491
+ f"[dim]impact includes their callers[/dim]"
492
+ )
493
+ for m in report["affected_members"][:20]:
494
+ mem_branch.add(m)
495
+ if report["affected_member_count"] > 20:
496
+ mem_branch.add(f"[dim]... +{report['affected_member_count'] - 20} more[/dim]")
497
+ if report.get("initializer_caller_count"):
498
+ init_branch = tree.add(
499
+ f"[bold]Initializer callers[/bold] ({report['initializer_caller_count']}) "
500
+ f"[dim]{'included in caller counts' if report['include_initializers'] else 'reported separately'}[/dim]"
501
+ )
502
+ for caller in report["initializer_callers"]:
503
+ init_branch.add(caller)
504
+ if report.get("transitive_initializer_caller_count"):
505
+ trans_init_branch = tree.add(
506
+ f"[bold]Transitive initializer callers[/bold] "
507
+ f"({report['transitive_initializer_caller_count']})"
508
+ )
509
+ for caller, depth in sorted(
510
+ report["transitive_initializer_callers"].items(),
511
+ key=lambda item: (item[1], item[0]),
512
+ ):
513
+ trans_init_branch.add(f"[dim]d={depth}[/dim] {caller}")
514
+ direct_label = f"[bold]Direct callers[/bold] ({report['direct_caller_count']})"
515
+ instantiator_count = report.get("instantiator_count", 0)
516
+ if instantiator_count != report["direct_caller_count"]:
517
+ # Class targets: direct_caller_count includes TYPE_OF/RETURNS
518
+ # dependents, not just `Target(...)` constructor call sites.
519
+ direct_label += f" [dim]— {instantiator_count} construct it[/dim]"
520
+ direct_branch = tree.add(direct_label)
521
+ for c in report["direct_callers"]:
522
+ direct_branch.add(c)
523
+ trans_label = f"[bold]Transitive callers[/bold] ({report['transitive_caller_count']})"
524
+ trans_inst_count = report.get("transitive_instantiator_count", 0)
525
+ if trans_inst_count != report["transitive_caller_count"]:
526
+ # Class targets: transitive_callers includes TYPE_OF/RETURNS fan-out,
527
+ # not just construction chains — transitive_instantiator_count is the
528
+ # transitive counterpart of "X construct it" above.
529
+ trans_label += f" [dim]— {trans_inst_count} reachable via construction[/dim]"
530
+ trans_branch = tree.add(trans_label)
531
+ for c, depth in sorted(report["transitive_callers"].items(), key=lambda x: x[1]):
532
+ trans_branch.add(f"[dim]d={depth}[/dim] {c}")
533
+ ep_branch = tree.add(
534
+ f"[bold]Affected entry points[/bold] ({report['affected_entry_count']}) "
535
+ f"{'[green]user-facing[/green]' if report['user_facing'] else '[dim]internal only[/dim]'}"
536
+ )
537
+ for c in report["affected_entry_points"]:
538
+ ep_branch.add(c)
539
+ files_branch = tree.add(f"[bold]Affected files[/bold] ({report['affected_file_count']})")
540
+ for f in report["affected_files"]:
541
+ files_branch.add(f)
542
+ console.print(tree)
543
+
544
+
545
+ @cli.command()
546
+ @click.option("--port", default=3000, help="Server port")
547
+ @click.pass_context
548
+ def server(ctx: click.Context, port: int) -> None:
549
+ """Start MCP server."""
550
+ from adl_drift.server.mcp_server import DriftMCPServer
551
+
552
+ project_path = ctx.obj["project_path"]
553
+ console.print(f"[blue]Starting MCP server on port {port}...[/blue]")
554
+ console.print(f"[dim]Project: {project_path}[/dim]")
555
+
556
+ mcp_server = DriftMCPServer(project_path, port=port)
557
+ mcp_server.run()
558
+
559
+
560
+ @cli.command()
561
+ @click.option("--host", default="127.0.0.1", help="Bind host")
562
+ @click.option("--port", default=8765, help="Bind port")
563
+ @click.pass_context
564
+ def viz(ctx: click.Context, host: str, port: int) -> None:
565
+ """Start interactive graph visualization web server."""
566
+ from adl_drift.server.web import serve
567
+
568
+ project_path = ctx.obj["project_path"]
569
+ serve(project_path, host=host, port=port)
570
+
571
+
572
+ @cli.group()
573
+ def mcp() -> None:
574
+ """MCP server + client configuration (serve / setup / doctor)."""
575
+
576
+
577
+ @mcp.command("serve")
578
+ @click.pass_context
579
+ def mcp_serve(ctx: click.Context) -> None:
580
+ """Run MCP server over stdio (for Claude Code / Cursor / Claude Desktop / Antigravity / Codex VS Code)."""
581
+ from adl_drift.server.mcp_stdio import run_stdio
582
+
583
+ project_path = ctx.obj["project_path"]
584
+ run_stdio(project_path)
585
+
586
+
587
+ @mcp.command("setup")
588
+ @click.option("--claude", is_flag=True, help="Configure Claude Code (.mcp.json + skill)")
589
+ @click.option("--cursor", is_flag=True, help="Configure Cursor (.cursor/mcp.json + rule)")
590
+ @click.option("--antigravity", is_flag=True, help="Configure Antigravity IDE (~/.gemini/.../mcp_config.json)")
591
+ @click.option("--codex", is_flag=True, help="Configure Codex (.codex/config.toml + AGENTS.md workflow)")
592
+ @click.option("--print", "dry_run", is_flag=True, help="Dry run: show what would be written")
593
+ @click.pass_context
594
+ def mcp_setup(ctx: click.Context, claude: bool, cursor: bool, antigravity: bool, codex: bool, dry_run: bool) -> None:
595
+ """Write MCP client configs + install the skill file (merging, never clobbering)."""
596
+ from adl_drift.harness.setup import setup
597
+
598
+ project_path = ctx.obj["project_path"]
599
+ try:
600
+ actions = setup(project_path, claude=claude, cursor=cursor, antigravity=antigravity, codex=codex, dry_run=dry_run)
601
+ except ValueError as e:
602
+ console.print(f"[red]{e}[/red]")
603
+ sys.exit(1)
604
+
605
+ if dry_run:
606
+ console.print("[dim]dry run — nothing written[/dim]")
607
+ styles = {"created": "green", "updated": "yellow", "unchanged": "dim"}
608
+ for a in actions:
609
+ console.print(f" [{styles[a['action']]}]{a['action']:9}[/] {a['path']}")
610
+ if not dry_run:
611
+ console.print("\n[green]Done.[/green] Restart your MCP client, then verify with "
612
+ "[bold]adl-drift mcp doctor[/bold]")
613
+
614
+
615
+ @mcp.command("uninstall")
616
+ @click.option("--claude", is_flag=True, help="Remove Claude Code config + skill")
617
+ @click.option("--cursor", is_flag=True, help="Remove Cursor config + rule")
618
+ @click.option("--antigravity", is_flag=True, help="Remove Antigravity config + AGENTS.md section")
619
+ @click.option("--codex", is_flag=True, help="Remove Codex config and managed AGENTS.md workflow")
620
+ @click.option("--print", "dry_run", is_flag=True, help="Dry run: show what would be removed")
621
+ @click.pass_context
622
+ def mcp_uninstall(ctx: click.Context, claude: bool, cursor: bool, antigravity: bool, codex: bool, dry_run: bool) -> None:
623
+ """Remove MCP client configs and skill files installed by `mcp setup`."""
624
+ from adl_drift.harness.setup import uninstall
625
+
626
+ project_path = ctx.obj["project_path"]
627
+ actions = uninstall(project_path, claude=claude, cursor=cursor, antigravity=antigravity, codex=codex, dry_run=dry_run)
628
+
629
+ if dry_run:
630
+ console.print("[dim]dry run — nothing removed[/dim]")
631
+ styles = {"removed": "green", "unchanged": "dim"}
632
+ for a in actions:
633
+ console.print(f" [{styles.get(a['action'], 'yellow')}]{a['action']:9}[/] {a['path']}")
634
+
635
+
636
+ @mcp.command("doctor")
637
+ @click.pass_context
638
+ def mcp_doctor(ctx: click.Context) -> None:
639
+ """Verify the wiring: config, interpreter, mcp SDK, live stdio handshake."""
640
+ from adl_drift.harness.setup import doctor
641
+
642
+ project_path = ctx.obj["project_path"]
643
+ report = doctor(project_path)
644
+ for c in report["checks"]:
645
+ mark = "[green]ok[/green]" if c["ok"] else (
646
+ "[red]FAIL[/red]" if c["required"] else "[yellow]warn[/yellow]")
647
+ console.print(f" {mark:18} {c['name']:12} {c['detail']}")
648
+ if report["ok"]:
649
+ console.print("\n[green]All required checks passed.[/green]")
650
+ else:
651
+ console.print("\n[red]Doctor found problems — see FAIL lines above.[/red]")
652
+ sys.exit(1)
653
+
654
+
655
+ @cli.command()
656
+ @click.option("--interval", default=2.0, show_default=True, help="Poll interval in seconds")
657
+ @click.option("--lang", "language", type=click.Choice(["python", "java"]),
658
+ help="Watch one language only; default auto-detects all backends")
659
+ @click.pass_context
660
+ def watch(ctx: click.Context, interval: float, language: Optional[str]) -> None:
661
+ """Watch for file changes and keep the index fresh (hash-based, incremental)."""
662
+ from adl_drift.harness.watch import watch as run_watch
663
+
664
+ project_path = ctx.obj["project_path"]
665
+ try:
666
+ run_watch(project_path, interval=interval, language=language,
667
+ on_event=lambda msg: console.print(f"[dim]{msg}[/dim]"))
668
+ except KeyboardInterrupt:
669
+ console.print("[dim]watch stopped[/dim]")
670
+
671
+
672
+ @cli.command()
673
+ @click.option("--json", "as_json", is_flag=True,
674
+ help="Emit {violations, new_patterns, scan_errors, high_security}; "
675
+ "exit 1 on HIGH-severity security findings or scan errors")
676
+ @click.pass_context
677
+ def gate(ctx: click.Context, as_json: bool) -> None:
678
+ """CI gate - check for unapproved patterns."""
679
+ import json as json_module
680
+
681
+ commands = ctx.obj["commands"]
682
+
683
+ if as_json:
684
+ report = commands.gate_report()
685
+ click.echo(json_module.dumps(report, indent=2))
686
+ sys.exit(1 if report["failed"] else 0)
687
+
688
+ violations = commands.check_gate()
689
+ if violations:
690
+ console.print("[red]Gate failed - unapproved patterns found:[/red]")
691
+ for v in violations:
692
+ console.print(f" - {v['pattern']} at {v['file']}:{v['line']}")
693
+ sys.exit(1)
694
+ else:
695
+ console.print("[green]Gate passed - all patterns approved[/green]")
696
+
697
+
698
+ @cli.command()
699
+ @click.option("--aggressive", is_flag=True, default=False,
700
+ help="Also sweep patterns for live files the detector no longer "
701
+ "emits (runs a full scan first). Skips approved rows.")
702
+ @click.option("--include-approved", is_flag=True, default=False,
703
+ help="With --aggressive, also sweep approved stale patterns.")
704
+ @click.option("--dry-run", is_flag=True, default=False,
705
+ help="Report what would be deleted without writing.")
706
+ @click.pass_context
707
+ def prune(ctx: click.Context, aggressive: bool, include_approved: bool,
708
+ dry_run: bool) -> None:
709
+ """Reclaim space from cortex.db: reap dead projects/files, then VACUUM."""
710
+ commands = ctx.obj["commands"]
711
+ if aggressive and not dry_run:
712
+ console.print("[dim]Full scan to refresh detected patterns...[/dim]")
713
+ report = commands.prune(
714
+ aggressive=aggressive,
715
+ include_approved=include_approved,
716
+ dry_run=dry_run,
717
+ )
718
+
719
+ table = Table(title="Prune" + (" (dry run)" if dry_run else ""))
720
+ table.add_column("Table", style="cyan")
721
+ table.add_column("Rows deleted", style="magenta", justify="right")
722
+ for tbl, n in sorted(report["deleted"].items()):
723
+ table.add_row(tbl, str(n))
724
+ if not report["deleted"]:
725
+ table.add_row("[dim]nothing[/dim]", "0")
726
+ console.print(table)
727
+ console.print(
728
+ f"[dim]dead projects: {report['dead_projects']} · "
729
+ f"dead files: {report['dead_files']} · "
730
+ f"stale patterns: {report['stale_patterns']}[/dim]"
731
+ )
732
+ if not dry_run:
733
+ console.print(
734
+ f"[green]Freed {report['bytes_freed'] / 1e6:.2f} MB[/green]"
735
+ )
736
+
737
+
738
+ def main() -> None:
739
+ """Entry point for `adl-drift` command."""
740
+ cli()
741
+
742
+
743
+ if __name__ == "__main__":
744
+ main()