ruth-code 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.
- frontend/dist/assets/geist-mono-cyrillic-400-normal-BPBWmzPh.woff +0 -0
- frontend/dist/assets/geist-mono-cyrillic-400-normal-Ce5q_31Z.woff2 +0 -0
- frontend/dist/assets/geist-mono-cyrillic-500-normal-CJBLNVQT.woff2 +0 -0
- frontend/dist/assets/geist-mono-cyrillic-500-normal-mNhfPmgl.woff +0 -0
- frontend/dist/assets/geist-mono-cyrillic-600-normal-CGND36d7.woff2 +0 -0
- frontend/dist/assets/geist-mono-cyrillic-600-normal-DrylrLu6.woff +0 -0
- frontend/dist/assets/geist-mono-cyrillic-700-normal-DH5Q319x.woff +0 -0
- frontend/dist/assets/geist-mono-cyrillic-700-normal-VCNRadI3.woff2 +0 -0
- frontend/dist/assets/geist-mono-latin-400-normal-CoULgQGM.woff +0 -0
- frontend/dist/assets/geist-mono-latin-400-normal-LC9RFr9I.woff2 +0 -0
- frontend/dist/assets/geist-mono-latin-500-normal-D3o2eNa9.woff2 +0 -0
- frontend/dist/assets/geist-mono-latin-500-normal-DOxI7kZ4.woff +0 -0
- frontend/dist/assets/geist-mono-latin-600-normal-DQQBcVN0.woff2 +0 -0
- frontend/dist/assets/geist-mono-latin-600-normal-DsVeri3b.woff +0 -0
- frontend/dist/assets/geist-mono-latin-700-normal-D6izGJRP.woff2 +0 -0
- frontend/dist/assets/geist-mono-latin-700-normal-QGw08Lff.woff +0 -0
- frontend/dist/assets/geist-mono-latin-ext-400-normal-Cgks_Qgx.woff2 +0 -0
- frontend/dist/assets/geist-mono-latin-ext-400-normal-CxNRRMGd.woff +0 -0
- frontend/dist/assets/geist-mono-latin-ext-500-normal-CQcGuCNt.woff2 +0 -0
- frontend/dist/assets/geist-mono-latin-ext-500-normal-diTenJ8L.woff +0 -0
- frontend/dist/assets/geist-mono-latin-ext-600-normal-CJwYYto2.woff2 +0 -0
- frontend/dist/assets/geist-mono-latin-ext-600-normal-EvIRCXgu.woff +0 -0
- frontend/dist/assets/geist-mono-latin-ext-700-normal-BX9f1BHp.woff +0 -0
- frontend/dist/assets/geist-mono-latin-ext-700-normal-YOllDaLV.woff2 +0 -0
- frontend/dist/assets/index-AEO_WTHY.js +59 -0
- frontend/dist/assets/index-JUssvikZ.css +1 -0
- frontend/dist/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
- frontend/dist/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
- frontend/dist/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
- frontend/dist/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
- frontend/dist/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
- frontend/dist/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
- frontend/dist/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
- frontend/dist/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
- frontend/dist/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
- frontend/dist/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
- frontend/dist/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
- frontend/dist/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
- frontend/dist/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
- frontend/dist/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
- frontend/dist/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
- frontend/dist/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
- frontend/dist/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
- frontend/dist/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
- frontend/dist/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
- frontend/dist/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
- frontend/dist/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
- frontend/dist/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
- frontend/dist/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
- frontend/dist/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
- frontend/dist/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
- frontend/dist/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
- frontend/dist/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
- frontend/dist/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
- frontend/dist/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
- frontend/dist/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
- frontend/dist/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
- frontend/dist/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
- frontend/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- frontend/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- frontend/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- frontend/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- frontend/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- frontend/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- frontend/dist/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- frontend/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- frontend/dist/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
- frontend/dist/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
- frontend/dist/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
- frontend/dist/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
- frontend/dist/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
- frontend/dist/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
- frontend/dist/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
- frontend/dist/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
- frontend/dist/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
- frontend/dist/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
- frontend/dist/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
- frontend/dist/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
- frontend/dist/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
- frontend/dist/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
- frontend/dist/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
- frontend/dist/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
- frontend/dist/favicon.svg +1 -0
- frontend/dist/icons.svg +24 -0
- frontend/dist/index.html +15 -0
- frontend/dist/logo.svg +1 -0
- ruth/__init__.py +3 -0
- ruth/annotations/__init__.py +1 -0
- ruth/annotations/complexity.py +128 -0
- ruth/annotations/coverage.py +106 -0
- ruth/cli.py +167 -0
- ruth/graph/__init__.py +1 -0
- ruth/graph/engine.py +383 -0
- ruth/parser/__init__.py +1 -0
- ruth/parser/discovery.py +226 -0
- ruth/parser/symbols.py +656 -0
- ruth/server.py +162 -0
- ruth_code-0.1.0.dist-info/METADATA +106 -0
- ruth_code-0.1.0.dist-info/RECORD +102 -0
- ruth_code-0.1.0.dist-info/WHEEL +4 -0
- ruth_code-0.1.0.dist-info/entry_points.txt +2 -0
- ruth_code-0.1.0.dist-info/licenses/LICENSE +21 -0
ruth/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Ruth annotations package — complexity scoring, coverage, vulnerability overlays."""
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Cyclomatic complexity scoring for source code.
|
|
2
|
+
|
|
3
|
+
Uses a regex-based approach to count decision points:
|
|
4
|
+
- if, elif, else, for, while, except, with, case (Python)
|
|
5
|
+
- if, else if, for, while, catch, switch, case, ternary (JS/TS)
|
|
6
|
+
- match, if, loop, while, for (Rust)
|
|
7
|
+
- etc.
|
|
8
|
+
|
|
9
|
+
Returns a normalized 0-100 score where:
|
|
10
|
+
0-20: Low complexity
|
|
11
|
+
20-40: Moderate
|
|
12
|
+
40-60: High
|
|
13
|
+
60-80: Very High
|
|
14
|
+
80-100: Critical
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from typing import Callable
|
|
21
|
+
|
|
22
|
+
# ── Language-specific decision point patterns ──────────────────────────
|
|
23
|
+
|
|
24
|
+
_PYTHON_BRANCHES = re.compile(
|
|
25
|
+
r"\b(if|elif|for|while|except|with|and|or|assert)\b", re.MULTILINE
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_JS_BRANCHES = re.compile(
|
|
29
|
+
r"\b(if|else\s+if|for|while|do|catch|case|switch|\?\s*|&&|\|\|)\b", re.MULTILINE
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_RUST_BRANCHES = re.compile(
|
|
33
|
+
r"\b(if|else\s+if|match|for|while|loop|=>|&&|\|\|)\b", re.MULTILINE
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_GO_BRANCHES = re.compile(
|
|
37
|
+
r"\b(if|else\s+if|for|switch|case|select|&&|\|\|)\b", re.MULTILINE
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_JAVA_BRANCHES = re.compile(
|
|
41
|
+
r"\b(if|else\s+if|for|while|do|catch|case|switch|\?|&&|\|\|)\b", re.MULTILINE
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_C_BRANCHES = re.compile(
|
|
45
|
+
r"\b(if|else\s+if|for|while|do|case|switch|\?|&&|\|\|)\b", re.MULTILINE
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
_RUBY_BRANCHES = re.compile(
|
|
49
|
+
r"\b(if|elsif|unless|while|until|for|rescue|when|and|or)\b", re.MULTILINE
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
BRANCH_PATTERNS: dict[str, re.Pattern] = {
|
|
54
|
+
"python": _PYTHON_BRANCHES,
|
|
55
|
+
"typescript": _JS_BRANCHES,
|
|
56
|
+
"javascript": _JS_BRANCHES,
|
|
57
|
+
"rust": _RUST_BRANCHES,
|
|
58
|
+
"go": _GO_BRANCHES,
|
|
59
|
+
"java": _JAVA_BRANCHES,
|
|
60
|
+
"c": _C_BRANCHES,
|
|
61
|
+
"cpp": _C_BRANCHES,
|
|
62
|
+
"ruby": _RUBY_BRANCHES,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _strip_comments(content: str, language: str) -> str:
|
|
67
|
+
"""Remove comments and strings to avoid false positives."""
|
|
68
|
+
# Remove single-line comments
|
|
69
|
+
if language in ("python", "ruby"):
|
|
70
|
+
content = re.sub(r"#.*$", "", content, flags=re.MULTILINE)
|
|
71
|
+
else:
|
|
72
|
+
content = re.sub(r"//.*$", "", content, flags=re.MULTILINE)
|
|
73
|
+
|
|
74
|
+
# Remove multi-line comments
|
|
75
|
+
if language == "python":
|
|
76
|
+
content = re.sub(r'""".*?"""', "", content, flags=re.DOTALL)
|
|
77
|
+
content = re.sub(r"'''.*?'''", "", content, flags=re.DOTALL)
|
|
78
|
+
elif language in ("c", "cpp", "java", "javascript", "typescript", "go", "rust"):
|
|
79
|
+
content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL)
|
|
80
|
+
|
|
81
|
+
# Remove strings (simplified — avoids counting keywords in strings)
|
|
82
|
+
content = re.sub(r'"(?:[^"\\]|\\.)*"', '""', content)
|
|
83
|
+
content = re.sub(r"'(?:[^'\\]|\\.)*'", "''", content)
|
|
84
|
+
|
|
85
|
+
return content
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def compute_complexity(content: str, language: str) -> int | None:
|
|
89
|
+
"""Compute a normalized complexity score for a source file.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
content: The file content.
|
|
93
|
+
language: The programming language.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
An integer 0-100, or None if language is unsupported.
|
|
97
|
+
"""
|
|
98
|
+
pattern = BRANCH_PATTERNS.get(language)
|
|
99
|
+
if pattern is None:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
cleaned = _strip_comments(content, language)
|
|
103
|
+
line_count = cleaned.count("\n") + 1
|
|
104
|
+
|
|
105
|
+
if line_count == 0:
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
# Count decision points
|
|
109
|
+
branch_count = len(pattern.findall(cleaned))
|
|
110
|
+
|
|
111
|
+
# Base complexity = 1 (every function has at least one path)
|
|
112
|
+
# Cyclomatic = branches + 1
|
|
113
|
+
cyclomatic = branch_count + 1
|
|
114
|
+
|
|
115
|
+
# Normalize to 0-100 scale
|
|
116
|
+
# Heuristic: density of branches per 100 lines
|
|
117
|
+
density = (branch_count / max(line_count, 1)) * 100
|
|
118
|
+
|
|
119
|
+
# Combine raw count and density
|
|
120
|
+
# A 50-line file with 20 branches is more complex than a 500-line file with 20 branches
|
|
121
|
+
raw_score = min(100, int(density * 2.5 + cyclomatic * 0.3))
|
|
122
|
+
|
|
123
|
+
return max(0, min(100, raw_score))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def compute_function_complexity(body: str, language: str) -> int | None:
|
|
127
|
+
"""Compute complexity for a single function body."""
|
|
128
|
+
return compute_complexity(body, language)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Coverage ingestion for Ruth.
|
|
2
|
+
|
|
3
|
+
Reads coverage data from standard formats:
|
|
4
|
+
- lcov.info (from Istanbul/nyc, gcov, etc.)
|
|
5
|
+
- coverage.json (Python coverage.py)
|
|
6
|
+
- cobertura XML
|
|
7
|
+
|
|
8
|
+
Maps coverage percentages back to source file paths.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_coverage(project_root: Path) -> dict[str, float]:
|
|
20
|
+
"""Auto-detect and load coverage data from a project.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Dict mapping relative file paths to coverage percentage (0-100).
|
|
24
|
+
"""
|
|
25
|
+
coverage: dict[str, float] = {}
|
|
26
|
+
|
|
27
|
+
# Try lcov
|
|
28
|
+
lcov_paths = [
|
|
29
|
+
project_root / "coverage" / "lcov.info",
|
|
30
|
+
project_root / "lcov.info",
|
|
31
|
+
project_root / "coverage" / "lcov" / "lcov.info",
|
|
32
|
+
]
|
|
33
|
+
for p in lcov_paths:
|
|
34
|
+
if p.exists():
|
|
35
|
+
coverage.update(_parse_lcov(p, project_root))
|
|
36
|
+
return coverage
|
|
37
|
+
|
|
38
|
+
# Try coverage.json (Python)
|
|
39
|
+
cov_json_paths = [
|
|
40
|
+
project_root / "coverage.json",
|
|
41
|
+
project_root / "htmlcov" / "status.json",
|
|
42
|
+
]
|
|
43
|
+
for p in cov_json_paths:
|
|
44
|
+
if p.exists():
|
|
45
|
+
coverage.update(_parse_coverage_json(p, project_root))
|
|
46
|
+
return coverage
|
|
47
|
+
|
|
48
|
+
# Try .coverage (Python coverage.py SQLite DB — just detect it)
|
|
49
|
+
dot_coverage = project_root / ".coverage"
|
|
50
|
+
if dot_coverage.exists():
|
|
51
|
+
# Can't parse SQLite easily without coverage lib, skip
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
return coverage
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parse_lcov(path: Path, project_root: Path) -> dict[str, float]:
|
|
58
|
+
"""Parse lcov.info format."""
|
|
59
|
+
coverage: dict[str, float] = {}
|
|
60
|
+
current_file = None
|
|
61
|
+
lines_hit = 0
|
|
62
|
+
lines_found = 0
|
|
63
|
+
|
|
64
|
+
for line in path.read_text(errors="ignore").splitlines():
|
|
65
|
+
line = line.strip()
|
|
66
|
+
if line.startswith("SF:"):
|
|
67
|
+
current_file = line[3:]
|
|
68
|
+
lines_hit = 0
|
|
69
|
+
lines_found = 0
|
|
70
|
+
elif line.startswith("LH:"):
|
|
71
|
+
lines_hit = int(line[3:])
|
|
72
|
+
elif line.startswith("LF:"):
|
|
73
|
+
lines_found = int(line[3:])
|
|
74
|
+
elif line == "end_of_record" and current_file:
|
|
75
|
+
pct = (lines_hit / lines_found * 100) if lines_found > 0 else 0
|
|
76
|
+
try:
|
|
77
|
+
rel = str(Path(current_file).resolve().relative_to(project_root.resolve()))
|
|
78
|
+
except ValueError:
|
|
79
|
+
rel = current_file
|
|
80
|
+
coverage[rel] = round(pct, 1)
|
|
81
|
+
current_file = None
|
|
82
|
+
|
|
83
|
+
return coverage
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_coverage_json(path: Path, project_root: Path) -> dict[str, float]:
|
|
87
|
+
"""Parse Python coverage.py JSON report."""
|
|
88
|
+
try:
|
|
89
|
+
data = json.loads(path.read_text())
|
|
90
|
+
except (json.JSONDecodeError, OSError):
|
|
91
|
+
return {}
|
|
92
|
+
|
|
93
|
+
coverage: dict[str, float] = {}
|
|
94
|
+
|
|
95
|
+
# coverage.py format
|
|
96
|
+
files = data.get("files", {})
|
|
97
|
+
for file_path, file_data in files.items():
|
|
98
|
+
summary = file_data.get("summary", {})
|
|
99
|
+
pct = summary.get("percent_covered", 0)
|
|
100
|
+
try:
|
|
101
|
+
rel = str(Path(file_path).resolve().relative_to(project_root.resolve()))
|
|
102
|
+
except ValueError:
|
|
103
|
+
rel = file_path
|
|
104
|
+
coverage[rel] = round(pct, 1)
|
|
105
|
+
|
|
106
|
+
return coverage
|
ruth/cli.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Ruth CLI — entry point for `ruth serve` and related commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group()
|
|
13
|
+
@click.version_option(package_name="ruth")
|
|
14
|
+
def cli():
|
|
15
|
+
"""Ruth — Interactive Codebase Topology Visualizer.
|
|
16
|
+
|
|
17
|
+
Parses your codebase, builds a dependency/call graph, overlays code quality
|
|
18
|
+
and security metadata, then renders it as an explorable visual map.
|
|
19
|
+
"""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@cli.command()
|
|
24
|
+
@click.argument("path", default=".", type=click.Path(exists=True))
|
|
25
|
+
@click.option("--port", "-p", default=4150, help="Port to serve the dashboard on.")
|
|
26
|
+
@click.option("--host", "-h", default="127.0.0.1", help="Host to bind to.")
|
|
27
|
+
@click.option("--no-open", is_flag=True, help="Don't auto-open the browser.")
|
|
28
|
+
def serve(path: str, port: int, host: str, no_open: bool):
|
|
29
|
+
"""Start Ruth server and launch the visualization dashboard.
|
|
30
|
+
|
|
31
|
+
PATH is the root directory of the codebase to analyze (defaults to current dir).
|
|
32
|
+
"""
|
|
33
|
+
import webbrowser
|
|
34
|
+
from pathlib import Path as P
|
|
35
|
+
from ruth.server import create_app
|
|
36
|
+
from ruth.parser.discovery import discover_files
|
|
37
|
+
|
|
38
|
+
project_root = P(path).resolve()
|
|
39
|
+
|
|
40
|
+
console.print()
|
|
41
|
+
console.print("[bold cyan]◈ ruth[/bold cyan] [dim]v0.1.0[/dim]")
|
|
42
|
+
console.print()
|
|
43
|
+
|
|
44
|
+
# Quick scan to show stats
|
|
45
|
+
with console.status("[dim]Scanning project...[/dim]"):
|
|
46
|
+
discovery = discover_files(project_root)
|
|
47
|
+
|
|
48
|
+
# Stats table
|
|
49
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
50
|
+
table.add_column(style="dim")
|
|
51
|
+
table.add_column(style="bold")
|
|
52
|
+
table.add_row("Project", str(project_root))
|
|
53
|
+
table.add_row("Files", str(len(discovery.files)))
|
|
54
|
+
table.add_row("Lines", f"{discovery.total_lines:,}")
|
|
55
|
+
table.add_row("Languages", ", ".join(sorted(discovery.languages)) or "—")
|
|
56
|
+
table.add_row("Directories", str(len(discovery.directories)))
|
|
57
|
+
if discovery.skipped:
|
|
58
|
+
table.add_row("Skipped", str(discovery.skipped))
|
|
59
|
+
console.print(table)
|
|
60
|
+
console.print()
|
|
61
|
+
console.print(f" [dim]Dashboard:[/dim] [link=http://{host}:{port}]http://{host}:{port}[/link]")
|
|
62
|
+
console.print()
|
|
63
|
+
|
|
64
|
+
app = create_app(path)
|
|
65
|
+
|
|
66
|
+
if not no_open:
|
|
67
|
+
webbrowser.open(f"http://{host}:{port}")
|
|
68
|
+
|
|
69
|
+
import uvicorn
|
|
70
|
+
uvicorn.run(app, host=host, port=port, log_level="warning")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@cli.command()
|
|
74
|
+
@click.argument("path", default=".", type=click.Path(exists=True))
|
|
75
|
+
@click.option("--output", "-o", default=None, help="Output JSON file for the graph.")
|
|
76
|
+
@click.option("--granularity", "-g", default="module",
|
|
77
|
+
type=click.Choice(["module", "class", "function"]),
|
|
78
|
+
help="Node granularity level.")
|
|
79
|
+
def analyze(path: str, output: str | None, granularity: str):
|
|
80
|
+
"""Analyze a codebase and output the dependency graph as JSON.
|
|
81
|
+
|
|
82
|
+
PATH is the root directory of the codebase to analyze.
|
|
83
|
+
"""
|
|
84
|
+
import json
|
|
85
|
+
from pathlib import Path as P
|
|
86
|
+
from ruth.parser.discovery import discover_files
|
|
87
|
+
from ruth.graph.engine import build_graph
|
|
88
|
+
from ruth.annotations.coverage import load_coverage
|
|
89
|
+
|
|
90
|
+
project_root = P(path).resolve()
|
|
91
|
+
|
|
92
|
+
with console.status("[dim]Analyzing codebase...[/dim]"):
|
|
93
|
+
discovery = discover_files(project_root)
|
|
94
|
+
graph = build_graph(discovery, project_root, granularity=granularity)
|
|
95
|
+
coverage_data = load_coverage(project_root)
|
|
96
|
+
if coverage_data:
|
|
97
|
+
for node in graph["nodes"]:
|
|
98
|
+
rel_path = node["data"]["filePath"]
|
|
99
|
+
if rel_path in coverage_data:
|
|
100
|
+
node["data"]["annotations"]["coverage"] = coverage_data[rel_path]
|
|
101
|
+
|
|
102
|
+
# Summary
|
|
103
|
+
console.print()
|
|
104
|
+
console.print("[bold cyan]◈ ruth[/bold cyan] analysis complete")
|
|
105
|
+
console.print()
|
|
106
|
+
console.print(f" [dim]Files:[/dim] {len(discovery.files)}")
|
|
107
|
+
console.print(f" [dim]Nodes:[/dim] {len(graph['nodes'])}")
|
|
108
|
+
console.print(f" [dim]Edges:[/dim] {len(graph['edges'])}")
|
|
109
|
+
console.print(f" [dim]Languages:[/dim] {', '.join(graph['languages']) or '—'}")
|
|
110
|
+
console.print()
|
|
111
|
+
|
|
112
|
+
if output:
|
|
113
|
+
with open(output, "w") as f:
|
|
114
|
+
json.dump(graph, f, indent=2)
|
|
115
|
+
console.print(f" [green]✓[/green] Graph written to [bold]{output}[/bold]")
|
|
116
|
+
else:
|
|
117
|
+
console.print_json(json.dumps(graph, indent=2))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@cli.command()
|
|
121
|
+
@click.argument("path", default=".", type=click.Path(exists=True))
|
|
122
|
+
def scan(path: str):
|
|
123
|
+
"""Quick scan — show project stats without building the full graph."""
|
|
124
|
+
from pathlib import Path as P
|
|
125
|
+
from ruth.parser.discovery import discover_files
|
|
126
|
+
|
|
127
|
+
project_root = P(path).resolve()
|
|
128
|
+
|
|
129
|
+
with console.status("[dim]Scanning...[/dim]"):
|
|
130
|
+
discovery = discover_files(project_root)
|
|
131
|
+
|
|
132
|
+
console.print()
|
|
133
|
+
console.print("[bold cyan]◈ ruth[/bold cyan] scan")
|
|
134
|
+
console.print()
|
|
135
|
+
|
|
136
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
137
|
+
table.add_column(style="dim")
|
|
138
|
+
table.add_column(style="bold")
|
|
139
|
+
table.add_row("Project", project_root.name)
|
|
140
|
+
table.add_row("Files", str(len(discovery.files)))
|
|
141
|
+
table.add_row("Lines", f"{discovery.total_lines:,}")
|
|
142
|
+
table.add_row("Languages", ", ".join(sorted(discovery.languages)) or "—")
|
|
143
|
+
table.add_row("Directories", str(len(discovery.directories)))
|
|
144
|
+
if discovery.skipped:
|
|
145
|
+
table.add_row("Skipped", str(discovery.skipped))
|
|
146
|
+
console.print(table)
|
|
147
|
+
console.print()
|
|
148
|
+
|
|
149
|
+
# Per-language breakdown
|
|
150
|
+
lang_counts: dict[str, int] = {}
|
|
151
|
+
lang_lines: dict[str, int] = {}
|
|
152
|
+
for f in discovery.files:
|
|
153
|
+
lang_counts[f.language] = lang_counts.get(f.language, 0) + 1
|
|
154
|
+
lang_lines[f.language] = lang_lines.get(f.language, 0) + f.line_count
|
|
155
|
+
|
|
156
|
+
if lang_counts:
|
|
157
|
+
lang_table = Table(title="Language Breakdown")
|
|
158
|
+
lang_table.add_column("Language", style="bold")
|
|
159
|
+
lang_table.add_column("Files", justify="right")
|
|
160
|
+
lang_table.add_column("Lines", justify="right")
|
|
161
|
+
for lang in sorted(lang_counts, key=lambda l: lang_lines[l], reverse=True):
|
|
162
|
+
lang_table.add_row(lang, str(lang_counts[lang]), f"{lang_lines[lang]:,}")
|
|
163
|
+
console.print(lang_table)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
cli()
|
ruth/graph/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Ruth graph package — builds dependency/call graphs from parsed symbols."""
|