ravmem-cli 0.1.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.
- ravmem/cli/__init__.py +0 -0
- ravmem/cli/analyze.py +484 -0
- ravmem/cli/auth.py +675 -0
- ravmem/cli/browse.py +144 -0
- ravmem/cli/config_cmd.py +69 -0
- ravmem/cli/diff.py +122 -0
- ravmem/cli/git_local.py +131 -0
- ravmem/cli/impact.py +106 -0
- ravmem/cli/info.py +82 -0
- ravmem/cli/kg.py +787 -0
- ravmem/cli/main.py +96 -0
- ravmem/cli/remote.py +549 -0
- ravmem/cli/search.py +91 -0
- ravmem/cli/serve.py +41 -0
- ravmem/client/__init__.py +3 -0
- ravmem/client/api.py +279 -0
- ravmem/client/config.py +52 -0
- ravmem/config.py +103 -0
- ravmem/config_loader.py +247 -0
- ravmem_cli-0.1.0.dist-info/METADATA +62 -0
- ravmem_cli-0.1.0.dist-info/RECORD +25 -0
- ravmem_cli-0.1.0.dist-info/WHEEL +5 -0
- ravmem_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ravmem_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- ravmem_cli-0.1.0.dist-info/top_level.txt +1 -0
ravmem/cli/browse.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""ravmem clusters / symbols — browse indexed code graph data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _detect_branch(repo_path: Path, explicit: str | None) -> str:
|
|
13
|
+
if explicit:
|
|
14
|
+
return explicit
|
|
15
|
+
try:
|
|
16
|
+
out = subprocess.run(
|
|
17
|
+
["git", "-C", str(repo_path), "rev-parse", "--abbrev-ref", "HEAD"],
|
|
18
|
+
capture_output=True, text=True, check=True,
|
|
19
|
+
).stdout.strip()
|
|
20
|
+
return out if out and out != "HEAD" else "main"
|
|
21
|
+
except Exception:
|
|
22
|
+
return "main"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_client(remote: str | None):
|
|
26
|
+
from ravmem.client.api import RavMemClient
|
|
27
|
+
from ravmem.client.config import load_config
|
|
28
|
+
|
|
29
|
+
url = remote
|
|
30
|
+
api_key = None
|
|
31
|
+
if not url:
|
|
32
|
+
cfg = load_config()
|
|
33
|
+
url = cfg.get("server_url")
|
|
34
|
+
api_key = cfg.get("api_key")
|
|
35
|
+
if not url:
|
|
36
|
+
typer.echo("No server configured. Run: ravmem remote config --url <URL>", err=True)
|
|
37
|
+
raise typer.Exit(1)
|
|
38
|
+
return RavMemClient(base_url=url, api_key=api_key or None)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def clusters_cmd(
|
|
42
|
+
repo: Path = typer.Option(Path("."), "--repo", "-r", help="Repo path for branch auto-detection"),
|
|
43
|
+
remote: Optional[str] = typer.Option(None, "--remote", help="Server URL override"),
|
|
44
|
+
project: Optional[str] = typer.Option(None, "--project", help="Project ID"),
|
|
45
|
+
branch: Optional[str] = typer.Option(None, "--branch", help="Branch name"),
|
|
46
|
+
output_json: bool = typer.Option(False, "--json", help="Raw JSON output"),
|
|
47
|
+
) -> None:
|
|
48
|
+
"""List all clusters for a branch."""
|
|
49
|
+
import json
|
|
50
|
+
from rich.console import Console
|
|
51
|
+
from rich.table import Table
|
|
52
|
+
from rich import box
|
|
53
|
+
|
|
54
|
+
if not project:
|
|
55
|
+
typer.echo("--project is required.", err=True)
|
|
56
|
+
raise typer.Exit(1)
|
|
57
|
+
|
|
58
|
+
branch_name = _detect_branch(repo.resolve(), branch)
|
|
59
|
+
client = _get_client(remote)
|
|
60
|
+
try:
|
|
61
|
+
clusters = client.get_clusters(project, branch_name)
|
|
62
|
+
finally:
|
|
63
|
+
client.close()
|
|
64
|
+
|
|
65
|
+
if output_json:
|
|
66
|
+
typer.echo(json.dumps(clusters, indent=2, default=str))
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
if not clusters:
|
|
70
|
+
typer.echo("No clusters found.")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
console = Console()
|
|
74
|
+
t = Table(box=box.SIMPLE, show_header=True, header_style="bold")
|
|
75
|
+
t.add_column("Cluster ID", width=18)
|
|
76
|
+
t.add_column("Label", width=40)
|
|
77
|
+
t.add_column("Symbols", justify="right", width=8)
|
|
78
|
+
for c in clusters:
|
|
79
|
+
t.add_row(
|
|
80
|
+
str(c.get("id", ""))[:18],
|
|
81
|
+
str(c.get("label", c.get("id", "")))[:40],
|
|
82
|
+
str(c.get("symbol_count", c.get("size", "?"))),
|
|
83
|
+
)
|
|
84
|
+
console.print(t)
|
|
85
|
+
console.print(f"[dim]{len(clusters)} cluster(s) on branch '{branch_name}'[/dim]")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def symbols_cmd(
|
|
89
|
+
path: str = typer.Argument(..., help="File path (exact) or directory prefix"),
|
|
90
|
+
repo: Path = typer.Option(Path("."), "--repo", "-r", help="Repo path for branch auto-detection"),
|
|
91
|
+
remote: Optional[str] = typer.Option(None, "--remote", help="Server URL override"),
|
|
92
|
+
project: Optional[str] = typer.Option(None, "--project", help="Project ID"),
|
|
93
|
+
branch: Optional[str] = typer.Option(None, "--branch", help="Branch name"),
|
|
94
|
+
prefix: bool = typer.Option(False, "--prefix", "-p",
|
|
95
|
+
help="Treat path as a directory prefix (list all files under it)"),
|
|
96
|
+
output_json: bool = typer.Option(False, "--json", help="Raw JSON output"),
|
|
97
|
+
) -> None:
|
|
98
|
+
"""List symbols in a file or directory."""
|
|
99
|
+
import json
|
|
100
|
+
from rich.console import Console
|
|
101
|
+
from rich.table import Table
|
|
102
|
+
from rich import box
|
|
103
|
+
|
|
104
|
+
if not project:
|
|
105
|
+
typer.echo("--project is required.", err=True)
|
|
106
|
+
raise typer.Exit(1)
|
|
107
|
+
|
|
108
|
+
branch_name = _detect_branch(repo.resolve(), branch)
|
|
109
|
+
client = _get_client(remote)
|
|
110
|
+
try:
|
|
111
|
+
result = client._request(
|
|
112
|
+
"GET",
|
|
113
|
+
f"/api/projects/{project}/branches/{branch_name}/files/symbols",
|
|
114
|
+
params={"path": path, "prefix": str(prefix).lower()},
|
|
115
|
+
)
|
|
116
|
+
finally:
|
|
117
|
+
client.close()
|
|
118
|
+
|
|
119
|
+
symbols = result if isinstance(result, list) else result.get("symbols", [])
|
|
120
|
+
|
|
121
|
+
if output_json:
|
|
122
|
+
typer.echo(json.dumps(symbols, indent=2, default=str))
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
if not symbols:
|
|
126
|
+
typer.echo(f"No symbols found for path: {path}")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
console = Console()
|
|
130
|
+
t = Table(box=box.SIMPLE, show_header=True, header_style="bold")
|
|
131
|
+
t.add_column("Name", width=30)
|
|
132
|
+
t.add_column("Kind", width=12)
|
|
133
|
+
t.add_column("File", width=35)
|
|
134
|
+
t.add_column("Line", justify="right", width=6)
|
|
135
|
+
for s in symbols:
|
|
136
|
+
file_short = Path(str(s.get("file", ""))).name
|
|
137
|
+
t.add_row(
|
|
138
|
+
str(s.get("name", ""))[:30],
|
|
139
|
+
str(s.get("kind", "")),
|
|
140
|
+
file_short[:35],
|
|
141
|
+
str(s.get("line_start", "")),
|
|
142
|
+
)
|
|
143
|
+
console.print(t)
|
|
144
|
+
console.print(f"[dim]{len(symbols)} symbol(s)[/dim]")
|
ravmem/cli/config_cmd.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""ravmem config — inspect and validate the resolved configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Inspect or validate the ravmem configuration.", no_args_is_help=True)
|
|
11
|
+
|
|
12
|
+
_SENSITIVE = {"password", "admin_token"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _redact(data: object, path: tuple[str, ...] = ()) -> object:
|
|
16
|
+
"""Recursively redact sensitive keys for display."""
|
|
17
|
+
if isinstance(data, dict):
|
|
18
|
+
return {
|
|
19
|
+
k: ("***REDACTED***" if k in _SENSITIVE else _redact(v, path + (k,)))
|
|
20
|
+
for k, v in data.items()
|
|
21
|
+
}
|
|
22
|
+
return data
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("show")
|
|
26
|
+
def show_cmd(
|
|
27
|
+
section: Optional[str] = typer.Option(None, "--section", "-s", help="Show only this top-level section"),
|
|
28
|
+
config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to ravmem.yaml"),
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Print the resolved configuration as YAML (sensitive values redacted)."""
|
|
31
|
+
from ravmem.config_loader import load_config
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
cfg = load_config(config)
|
|
35
|
+
except ValueError as exc:
|
|
36
|
+
typer.echo(f"[error] {exc}", err=True)
|
|
37
|
+
raise typer.Exit(1)
|
|
38
|
+
|
|
39
|
+
data = cfg.model_dump()
|
|
40
|
+
data = _redact(data)
|
|
41
|
+
|
|
42
|
+
if section:
|
|
43
|
+
if section not in data:
|
|
44
|
+
typer.echo(f"[error] Unknown section '{section}'. Available: {', '.join(data)}", err=True)
|
|
45
|
+
raise typer.Exit(1)
|
|
46
|
+
data = {section: data[section]}
|
|
47
|
+
|
|
48
|
+
typer.echo(yaml.dump(data, default_flow_style=False, sort_keys=False).rstrip())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command("validate")
|
|
52
|
+
def validate_cmd(
|
|
53
|
+
config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to ravmem.yaml"),
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Validate the config file, print any errors, and exit non-zero on failure."""
|
|
56
|
+
from ravmem.config_loader import _find_config_file, load_config
|
|
57
|
+
|
|
58
|
+
found = _find_config_file(config)
|
|
59
|
+
if not found:
|
|
60
|
+
typer.echo("No config file found — using built-in defaults (all valid).")
|
|
61
|
+
raise typer.Exit(0)
|
|
62
|
+
|
|
63
|
+
typer.echo(f"Validating {found} …")
|
|
64
|
+
try:
|
|
65
|
+
load_config(config)
|
|
66
|
+
typer.echo("[ok] Config is valid.")
|
|
67
|
+
except ValueError as exc:
|
|
68
|
+
typer.echo(f"[error] {exc}", err=True)
|
|
69
|
+
raise typer.Exit(1)
|
ravmem/cli/diff.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""ravmem diff-impact — blast radius from a git diff."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _detect_branch(repo_path: Path, explicit: str | None) -> str:
|
|
14
|
+
if explicit:
|
|
15
|
+
return explicit
|
|
16
|
+
try:
|
|
17
|
+
out = subprocess.run(
|
|
18
|
+
["git", "-C", str(repo_path), "rev-parse", "--abbrev-ref", "HEAD"],
|
|
19
|
+
capture_output=True, text=True, check=True,
|
|
20
|
+
).stdout.strip()
|
|
21
|
+
return out if out and out != "HEAD" else "main"
|
|
22
|
+
except Exception:
|
|
23
|
+
return "main"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_client(remote: str | None):
|
|
27
|
+
from ravmem.client.api import RavMemClient
|
|
28
|
+
from ravmem.client.config import load_config
|
|
29
|
+
|
|
30
|
+
url = remote
|
|
31
|
+
api_key = None
|
|
32
|
+
if not url:
|
|
33
|
+
cfg = load_config()
|
|
34
|
+
url = cfg.get("server_url")
|
|
35
|
+
api_key = cfg.get("api_key")
|
|
36
|
+
if not url:
|
|
37
|
+
typer.echo("No server configured. Run: ravmem remote config --url <URL>", err=True)
|
|
38
|
+
raise typer.Exit(1)
|
|
39
|
+
return RavMemClient(base_url=url, api_key=api_key or None)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def diff_impact_cmd(
|
|
43
|
+
diff_path: Optional[Path] = typer.Argument(None,
|
|
44
|
+
help="Path to unified diff file. Reads stdin if omitted."),
|
|
45
|
+
repo: Path = typer.Option(Path("."), "--repo", "-r",
|
|
46
|
+
help="Repo path for branch auto-detection"),
|
|
47
|
+
remote: Optional[str] = typer.Option(None, "--remote", help="Server URL override"),
|
|
48
|
+
project: Optional[str] = typer.Option(None, "--project", help="Project ID"),
|
|
49
|
+
branch: Optional[str] = typer.Option(None, "--branch", help="Branch name"),
|
|
50
|
+
depth: int = typer.Option(3, "--depth", help="Blast radius traversal depth"),
|
|
51
|
+
output_json: bool = typer.Option(False, "--json", help="Output raw JSON"),
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Show blast radius of all symbols changed in a diff.
|
|
54
|
+
|
|
55
|
+
Reads a unified diff from a file argument or stdin.
|
|
56
|
+
|
|
57
|
+
Examples:
|
|
58
|
+
|
|
59
|
+
git diff HEAD~1 | ravmem diff-impact --project my-proj
|
|
60
|
+
|
|
61
|
+
ravmem diff-impact changes.patch --project my-proj --branch main
|
|
62
|
+
"""
|
|
63
|
+
import json
|
|
64
|
+
if not project:
|
|
65
|
+
typer.echo("--project is required.", err=True)
|
|
66
|
+
raise typer.Exit(1)
|
|
67
|
+
|
|
68
|
+
branch_name = _detect_branch(repo.resolve(), branch)
|
|
69
|
+
|
|
70
|
+
# Read diff
|
|
71
|
+
if diff_path:
|
|
72
|
+
diff_text = diff_path.read_text()
|
|
73
|
+
elif not sys.stdin.isatty():
|
|
74
|
+
diff_text = sys.stdin.read()
|
|
75
|
+
else:
|
|
76
|
+
typer.echo("Provide a diff file as argument or pipe it to stdin.", err=True)
|
|
77
|
+
raise typer.Exit(1)
|
|
78
|
+
|
|
79
|
+
if not diff_text.strip():
|
|
80
|
+
typer.echo("Empty diff — nothing to analyze.")
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
client = _get_client(remote)
|
|
84
|
+
try:
|
|
85
|
+
result = client._request(
|
|
86
|
+
"POST",
|
|
87
|
+
f"/api/projects/{project}/branches/{branch_name}/diff-impact",
|
|
88
|
+
json={"diff": diff_text, "depth": depth},
|
|
89
|
+
)
|
|
90
|
+
finally:
|
|
91
|
+
client.close()
|
|
92
|
+
|
|
93
|
+
if output_json:
|
|
94
|
+
typer.echo(json.dumps(result, indent=2, default=str))
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
affected = result.get("affected", [])
|
|
98
|
+
changed_symbols = result.get("changed_symbols", [])
|
|
99
|
+
|
|
100
|
+
if not changed_symbols:
|
|
101
|
+
typer.echo("No recognizable symbols found in the diff.")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
typer.echo(f"\nChanged symbols detected: {', '.join(changed_symbols)}")
|
|
105
|
+
|
|
106
|
+
if not affected:
|
|
107
|
+
typer.echo("No downstream dependents found.")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
typer.echo(f"\n{len(affected)} symbol(s) would be affected:\n")
|
|
111
|
+
for node in sorted(affected, key=lambda n: n.get("depth", 0)):
|
|
112
|
+
d = node.get("depth", 0)
|
|
113
|
+
sym = node.get("symbol", {})
|
|
114
|
+
name = sym.get("name", "")
|
|
115
|
+
kind = sym.get("kind", "")
|
|
116
|
+
file = sym.get("file", "")
|
|
117
|
+
line = sym.get("line_start", "")
|
|
118
|
+
indent = " " + " " * d
|
|
119
|
+
location = f"{Path(file).name}:{line}" if file else ""
|
|
120
|
+
typer.echo(
|
|
121
|
+
f"{indent}depth={d} {typer.style(name, bold=True)} ({kind}) {location}"
|
|
122
|
+
)
|
ravmem/cli/git_local.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""CLI-side git helpers — run on the client machine, not on the server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LocalGitError(Exception):
|
|
10
|
+
"""Raised when a local git command fails."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _run(args: list[str], cwd: Path, timeout: int = 30) -> str:
|
|
14
|
+
result = subprocess.run(
|
|
15
|
+
args, cwd=str(cwd), capture_output=True, text=True, timeout=timeout,
|
|
16
|
+
)
|
|
17
|
+
if result.returncode != 0:
|
|
18
|
+
raise LocalGitError(
|
|
19
|
+
f"git command failed: {' '.join(args)}\nstderr: {result.stderr.strip()}"
|
|
20
|
+
)
|
|
21
|
+
return result.stdout.strip()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def rev_parse(repo_path: Path, ref: str = "HEAD") -> str:
|
|
25
|
+
"""Return the full SHA for *ref*."""
|
|
26
|
+
return _run(["git", "rev-parse", ref], cwd=repo_path)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def current_branch(repo_path: Path) -> str:
|
|
30
|
+
"""Return the current branch name, or empty string if detached HEAD."""
|
|
31
|
+
try:
|
|
32
|
+
return _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_path)
|
|
33
|
+
except LocalGitError:
|
|
34
|
+
return ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def merge_base(repo_path: Path, ref_a: str, ref_b: str) -> str:
|
|
38
|
+
"""Return the merge-base commit SHA of two refs."""
|
|
39
|
+
return _run(["git", "merge-base", ref_a, ref_b], cwd=repo_path)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ls_files(repo_path: Path) -> list[str]:
|
|
43
|
+
"""Return all file paths tracked by git (honours .gitignore, no untracked files).
|
|
44
|
+
|
|
45
|
+
Paths are relative to *repo_path*, forward-slash separated.
|
|
46
|
+
Binary files are NOT excluded here — callers filter by extension as needed.
|
|
47
|
+
"""
|
|
48
|
+
out = _run(["git", "ls-files"], cwd=repo_path)
|
|
49
|
+
return [line for line in out.splitlines() if line]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def diff_name_status(
|
|
53
|
+
repo_path: Path, base_commit: str, target: str = "HEAD",
|
|
54
|
+
) -> tuple[list[str], list[str]]:
|
|
55
|
+
"""Return (modified_paths, deleted_paths) between *base_commit* and *target*.
|
|
56
|
+
|
|
57
|
+
Paths are relative to *repo_path*.
|
|
58
|
+
"""
|
|
59
|
+
out = _run(
|
|
60
|
+
["git", "diff", "--name-status", base_commit, target], cwd=repo_path,
|
|
61
|
+
)
|
|
62
|
+
modified: list[str] = []
|
|
63
|
+
deleted: list[str] = []
|
|
64
|
+
for line in out.splitlines():
|
|
65
|
+
line = line.strip()
|
|
66
|
+
if not line:
|
|
67
|
+
continue
|
|
68
|
+
parts = line.split("\t", 1)
|
|
69
|
+
if len(parts) != 2:
|
|
70
|
+
continue
|
|
71
|
+
status, path = parts[0].strip(), parts[1].strip()
|
|
72
|
+
if status.startswith("D"):
|
|
73
|
+
deleted.append(path)
|
|
74
|
+
else:
|
|
75
|
+
modified.append(path)
|
|
76
|
+
return modified, deleted
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def diff_unified_per_file(
|
|
80
|
+
repo_path: Path,
|
|
81
|
+
base_commit: str,
|
|
82
|
+
target: str = "HEAD",
|
|
83
|
+
context: int = 3,
|
|
84
|
+
) -> list[dict[str, str]]:
|
|
85
|
+
"""Return per-file unified diffs between *base_commit* and *target*.
|
|
86
|
+
|
|
87
|
+
Each item in the returned list is::
|
|
88
|
+
|
|
89
|
+
{"path": "relative/path.py", "diff": "<unified diff text>"}
|
|
90
|
+
|
|
91
|
+
Only files with actual changes are included.
|
|
92
|
+
"""
|
|
93
|
+
out = _run(
|
|
94
|
+
["git", "diff", f"--unified={context}", base_commit, target],
|
|
95
|
+
cwd=repo_path,
|
|
96
|
+
timeout=120,
|
|
97
|
+
)
|
|
98
|
+
return _split_diff_by_file(out)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _split_diff_by_file(raw: str) -> list[dict[str, str]]:
|
|
102
|
+
"""Split a multi-file unified diff into per-file chunks."""
|
|
103
|
+
files: list[dict[str, str]] = []
|
|
104
|
+
current_path: str | None = None
|
|
105
|
+
current_lines: list[str] = []
|
|
106
|
+
|
|
107
|
+
for line in raw.splitlines(keepends=True):
|
|
108
|
+
if line.startswith("diff --git "):
|
|
109
|
+
if current_path is not None and current_lines:
|
|
110
|
+
files.append({"path": current_path, "diff": "".join(current_lines)})
|
|
111
|
+
current_lines = [line]
|
|
112
|
+
current_path = None
|
|
113
|
+
elif line.startswith("+++ b/") and current_path is None:
|
|
114
|
+
current_path = line[6:].rstrip("\n")
|
|
115
|
+
current_lines.append(line)
|
|
116
|
+
else:
|
|
117
|
+
current_lines.append(line)
|
|
118
|
+
|
|
119
|
+
if current_path is not None and current_lines:
|
|
120
|
+
files.append({"path": current_path, "diff": "".join(current_lines)})
|
|
121
|
+
|
|
122
|
+
return files
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_git_repo(path: Path) -> bool:
|
|
126
|
+
"""Return True if *path* is inside a git repository."""
|
|
127
|
+
result = subprocess.run(
|
|
128
|
+
["git", "-C", str(path), "rev-parse", "--git-dir"],
|
|
129
|
+
capture_output=True, timeout=5,
|
|
130
|
+
)
|
|
131
|
+
return result.returncode == 0
|
ravmem/cli/impact.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""ravmem impact — blast radius analysis via the remote server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _detect_branch(repo_path: Path, explicit: str | None) -> str:
|
|
13
|
+
if explicit:
|
|
14
|
+
return explicit
|
|
15
|
+
try:
|
|
16
|
+
out = subprocess.run(
|
|
17
|
+
["git", "-C", str(repo_path), "rev-parse", "--abbrev-ref", "HEAD"],
|
|
18
|
+
capture_output=True, text=True, check=True,
|
|
19
|
+
).stdout.strip()
|
|
20
|
+
return out if out and out != "HEAD" else "main"
|
|
21
|
+
except Exception:
|
|
22
|
+
return "main"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_client(remote: str | None):
|
|
26
|
+
from ravmem.client.api import RavMemClient
|
|
27
|
+
from ravmem.client.config import load_config
|
|
28
|
+
|
|
29
|
+
url = remote
|
|
30
|
+
api_key = None
|
|
31
|
+
if not url:
|
|
32
|
+
cfg = load_config()
|
|
33
|
+
url = cfg.get("server_url")
|
|
34
|
+
api_key = cfg.get("api_key")
|
|
35
|
+
if not url:
|
|
36
|
+
typer.echo(
|
|
37
|
+
"Error: No server configured. "
|
|
38
|
+
"Run `ravmem remote config --url <URL>` or pass --remote <URL>.",
|
|
39
|
+
err=True,
|
|
40
|
+
)
|
|
41
|
+
raise typer.Exit(1)
|
|
42
|
+
return RavMemClient(base_url=url, api_key=api_key or None)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def impact_cmd(
|
|
46
|
+
symbol: str = typer.Argument(..., help="Symbol name or ID to analyze"),
|
|
47
|
+
repo: Path = typer.Option(Path("."), "--repo", "-r", help="Path to the repository (used for branch auto-detection)"),
|
|
48
|
+
depth: int = typer.Option(3, "--depth", "-d", help="Max traversal depth"),
|
|
49
|
+
remote: Optional[str] = typer.Option(None, "--remote", help="Server URL (overrides config)"),
|
|
50
|
+
project: Optional[str] = typer.Option(None, "--project", help="Project ID on the server"),
|
|
51
|
+
branch: Optional[str] = typer.Option(None, "--branch", help="Branch name (auto-detected from git if omitted)"),
|
|
52
|
+
output_json: bool = typer.Option(False, "--json", help="Output raw JSON"),
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Show what breaks if you change a symbol (callers, transitively)."""
|
|
55
|
+
import json
|
|
56
|
+
|
|
57
|
+
repo_path = repo.resolve()
|
|
58
|
+
branch_name = _detect_branch(repo_path, branch)
|
|
59
|
+
|
|
60
|
+
if not project:
|
|
61
|
+
typer.echo("--project is required. Use `ravmem remote add` to register the repo first.", err=True)
|
|
62
|
+
raise typer.Exit(1)
|
|
63
|
+
|
|
64
|
+
client = _get_client(remote)
|
|
65
|
+
try:
|
|
66
|
+
# The impact API expects an MD5 symbol ID, not a name.
|
|
67
|
+
# If the argument looks like a name (not a 16-char hex ID), search for it first.
|
|
68
|
+
import re
|
|
69
|
+
symbol_id = symbol
|
|
70
|
+
if not re.fullmatch(r"[0-9a-f]{16}", symbol):
|
|
71
|
+
hits = client.search(project, branch_name, symbol, limit=1)
|
|
72
|
+
if not hits:
|
|
73
|
+
typer.echo(f"Symbol '{symbol}' not found on branch '{branch_name}'.", err=True)
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
symbol_id = hits[0]["symbol"]["id"]
|
|
76
|
+
resolved_name = hits[0]["symbol"]["name"]
|
|
77
|
+
if resolved_name != symbol:
|
|
78
|
+
typer.echo(f"Resolved '{symbol}' → '{resolved_name}' ({symbol_id})")
|
|
79
|
+
result = client.impact(project, branch_name, symbol_id, depth=depth)
|
|
80
|
+
finally:
|
|
81
|
+
client.close()
|
|
82
|
+
|
|
83
|
+
if output_json:
|
|
84
|
+
typer.echo(json.dumps(result, indent=2, default=str))
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
affected = result.get("affected", [])
|
|
88
|
+
if not affected:
|
|
89
|
+
typer.echo(f"No dependents found for '{symbol}' on branch '{branch_name}' (nothing would break).")
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
typer.echo(f"\nImpact analysis for '{symbol}' on branch '{branch_name}' (depth={depth}):\n")
|
|
93
|
+
typer.echo(f" {len(affected)} symbol(s) would be affected:\n")
|
|
94
|
+
for node in sorted(affected, key=lambda n: n.get("depth", 0)):
|
|
95
|
+
d = node.get("depth", 0)
|
|
96
|
+
sym = node.get("symbol", {})
|
|
97
|
+
name = sym.get("name", "")
|
|
98
|
+
kind = sym.get("kind", "")
|
|
99
|
+
file = sym.get("file", "")
|
|
100
|
+
line = sym.get("line_start", "")
|
|
101
|
+
indent = " " + " " * d
|
|
102
|
+
location = f"{Path(file).name}:{line}" if file else ""
|
|
103
|
+
typer.echo(
|
|
104
|
+
f"{indent}depth={d} {typer.style(name, bold=True)} "
|
|
105
|
+
f"({kind}) {location}"
|
|
106
|
+
)
|
ravmem/cli/info.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""ravmem health / metrics — server status commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get_client(remote: str | None):
|
|
11
|
+
from ravmem.client.api import RavMemClient
|
|
12
|
+
from ravmem.client.config import load_config
|
|
13
|
+
|
|
14
|
+
url = remote
|
|
15
|
+
api_key = None
|
|
16
|
+
if not url:
|
|
17
|
+
cfg = load_config()
|
|
18
|
+
url = cfg.get("server_url")
|
|
19
|
+
api_key = cfg.get("api_key")
|
|
20
|
+
if not url:
|
|
21
|
+
typer.echo("No server configured. Run: ravmem remote config --url <URL>", err=True)
|
|
22
|
+
raise typer.Exit(1)
|
|
23
|
+
return RavMemClient(base_url=url, api_key=api_key or None)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def health_cmd(
|
|
27
|
+
remote: Optional[str] = typer.Option(None, "--remote", help="Server URL override"),
|
|
28
|
+
output_json: bool = typer.Option(False, "--json", help="Output raw JSON"),
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Show server health status and statistics."""
|
|
31
|
+
import json
|
|
32
|
+
from rich.console import Console
|
|
33
|
+
from rich.table import Table
|
|
34
|
+
from rich import box
|
|
35
|
+
|
|
36
|
+
client = _get_client(remote)
|
|
37
|
+
try:
|
|
38
|
+
data = client._request("GET", "/health")
|
|
39
|
+
finally:
|
|
40
|
+
client.close()
|
|
41
|
+
|
|
42
|
+
if output_json:
|
|
43
|
+
typer.echo(json.dumps(data, indent=2, default=str))
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
console = Console()
|
|
47
|
+
t = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))
|
|
48
|
+
t.add_column("Key", style="bold")
|
|
49
|
+
t.add_column("Value")
|
|
50
|
+
t.add_row("Status", data.get("status", "?"))
|
|
51
|
+
t.add_row("Version", str(data.get("version", "?")))
|
|
52
|
+
t.add_row("Uptime", f"{data.get('uptime_seconds', 0):.0f}s")
|
|
53
|
+
t.add_row("Projects", str(data.get("projects_count", "?")))
|
|
54
|
+
t.add_row("Active jobs", str(data.get("active_jobs", 0)))
|
|
55
|
+
t.add_row("Queued jobs", str(data.get("queued_jobs", 0)))
|
|
56
|
+
console.print(t)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def metrics_cmd(
|
|
60
|
+
remote: Optional[str] = typer.Option(None, "--remote", help="Server URL override"),
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Print Prometheus-style server metrics."""
|
|
63
|
+
import httpx
|
|
64
|
+
from ravmem.client.config import load_config
|
|
65
|
+
|
|
66
|
+
url = remote
|
|
67
|
+
api_key = None
|
|
68
|
+
if not url:
|
|
69
|
+
cfg = load_config()
|
|
70
|
+
url = cfg.get("server_url")
|
|
71
|
+
api_key = cfg.get("api_key")
|
|
72
|
+
if not url:
|
|
73
|
+
typer.echo("No server configured. Run: ravmem remote config --url <URL>", err=True)
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
|
|
76
|
+
headers = {}
|
|
77
|
+
if api_key:
|
|
78
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
79
|
+
|
|
80
|
+
resp = httpx.get(f"{url.rstrip('/')}/api/metrics", headers=headers, timeout=10)
|
|
81
|
+
resp.raise_for_status()
|
|
82
|
+
typer.echo(resp.text)
|