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.
- package/INSTALL_TOOLKIT.md +163 -0
- package/README.md +269 -2
- package/adl_drift/__init__.py +6 -0
- package/adl_drift/cli/__init__.py +1 -0
- package/adl_drift/cli/commands.py +993 -0
- package/adl_drift/cli/main.py +744 -0
- package/adl_drift/cortex/__init__.py +1 -0
- package/adl_drift/cortex/embeddings.py +96 -0
- package/adl_drift/cortex/memory.py +1749 -0
- package/adl_drift/cortex/patterns.py +89 -0
- package/adl_drift/cortex/telemetry.py +192 -0
- package/adl_drift/detectors/__init__.py +1 -0
- package/adl_drift/detectors/async_patterns.py +115 -0
- package/adl_drift/detectors/base.py +121 -0
- package/adl_drift/detectors/config.py +122 -0
- package/adl_drift/detectors/data_access.py +128 -0
- package/adl_drift/detectors/decorators.py +122 -0
- package/adl_drift/detectors/dynamic_patterns.py +260 -0
- package/adl_drift/detectors/errors.py +134 -0
- package/adl_drift/detectors/imports.py +132 -0
- package/adl_drift/detectors/logging_patterns.py +108 -0
- package/adl_drift/detectors/routes.py +257 -0
- package/adl_drift/detectors/security.py +236 -0
- package/adl_drift/detectors/taint.py +406 -0
- package/adl_drift/detectors/tests.py +95 -0
- package/adl_drift/detectors/type_conventions.py +81 -0
- package/adl_drift/graph/__init__.py +1 -0
- package/adl_drift/graph/builder.py +2670 -0
- package/adl_drift/graph/cfg.py +461 -0
- package/adl_drift/graph/cluster.py +40 -0
- package/adl_drift/graph/edges.py +86 -0
- package/adl_drift/graph/node.py +90 -0
- package/adl_drift/harness/__init__.py +10 -0
- package/adl_drift/harness/setup.py +832 -0
- package/adl_drift/harness/skills/drift-antigravity.md +19 -0
- package/adl_drift/harness/skills/drift-generic.md +19 -0
- package/adl_drift/harness/skills/drift.md +25 -0
- package/adl_drift/harness/watch.py +84 -0
- package/adl_drift/lang/__init__.py +8 -0
- package/adl_drift/lang/base.py +464 -0
- package/adl_drift/lang/java/__init__.py +6 -0
- package/adl_drift/lang/java/backend.py +97 -0
- package/adl_drift/lang/java/cfg.py +1424 -0
- package/adl_drift/lang/java/config.py +707 -0
- package/adl_drift/lang/java/detectors/__init__.py +29 -0
- package/adl_drift/lang/java/detectors/additional.py +445 -0
- package/adl_drift/lang/java/detectors/base.py +154 -0
- package/adl_drift/lang/java/detectors/data_access.py +148 -0
- package/adl_drift/lang/java/detectors/imports.py +53 -0
- package/adl_drift/lang/java/detectors/routes.py +159 -0
- package/adl_drift/lang/java/detectors/security.py +386 -0
- package/adl_drift/lang/java/detectors/tests.py +74 -0
- package/adl_drift/lang/java/extractor.py +582 -0
- package/adl_drift/lang/java/ids.py +99 -0
- package/adl_drift/lang/java/parser.py +49 -0
- package/adl_drift/lang/java/resolution/__init__.py +1 -0
- package/adl_drift/lang/java/resolution/constants.py +1619 -0
- package/adl_drift/lang/java/resolution/constructors.py +278 -0
- package/adl_drift/lang/java/resolution/context.py +305 -0
- package/adl_drift/lang/java/resolution/dispatch.py +540 -0
- package/adl_drift/lang/java/resolution/functional.py +1318 -0
- package/adl_drift/lang/java/resolution/generated.py +95 -0
- package/adl_drift/lang/java/resolution/model.py +153 -0
- package/adl_drift/lang/java/resolution/orchestration.py +866 -0
- package/adl_drift/lang/java/resolution/overloads.py +1275 -0
- package/adl_drift/lang/java/resolution/scopes.py +1111 -0
- package/adl_drift/lang/java/resolution/types.py +2947 -0
- package/adl_drift/lang/java/resolver.py +1401 -0
- package/adl_drift/lang/java/scopes.py +69 -0
- package/adl_drift/lang/java/taint.py +500 -0
- package/adl_drift/lang/java/type_provenance.py +342 -0
- package/adl_drift/lang/python/__init__.py +8 -0
- package/adl_drift/lang/python/backend.py +94 -0
- package/adl_drift/lang/python/cfg.py +537 -0
- package/adl_drift/lang/python/extractor.py +454 -0
- package/adl_drift/lang/python/parser.py +41 -0
- package/adl_drift/lang/python/resolver.py +1789 -0
- package/adl_drift/lang/python/taint.py +365 -0
- package/adl_drift/lang/registry.py +117 -0
- package/adl_drift/parser/__init__.py +1 -0
- package/adl_drift/parser/cache.py +38 -0
- package/adl_drift/parser/core.py +33 -0
- package/adl_drift/parser/extractor.py +17 -0
- package/adl_drift/parser/literal_eval.py +326 -0
- package/adl_drift/scanner/__init__.py +1 -0
- package/adl_drift/scanner/discovery.py +338 -0
- package/adl_drift/scanner/incremental.py +261 -0
- package/adl_drift/scanner/parallel.py +224 -0
- package/adl_drift/server/__init__.py +1 -0
- package/adl_drift/server/handlers.py +2849 -0
- package/adl_drift/server/mcp_server.py +127 -0
- package/adl_drift/server/mcp_stdio.py +335 -0
- package/adl_drift/server/protocol.py +71 -0
- package/adl_drift/server/static/index.html +696 -0
- package/adl_drift/server/web.py +350 -0
- package/adl_drift/utils/__init__.py +1 -0
- package/adl_drift/utils/config.py +77 -0
- package/adl_drift/utils/fswalk.py +52 -0
- package/adl_drift/utils/types.py +345 -0
- package/bin/adl-drift.mjs +35 -0
- package/bin/adl-toolkit.mjs +488 -0
- package/package.json +29 -4
- package/pyproject.toml +49 -0
- 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()
|