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.
Files changed (102) hide show
  1. frontend/dist/assets/geist-mono-cyrillic-400-normal-BPBWmzPh.woff +0 -0
  2. frontend/dist/assets/geist-mono-cyrillic-400-normal-Ce5q_31Z.woff2 +0 -0
  3. frontend/dist/assets/geist-mono-cyrillic-500-normal-CJBLNVQT.woff2 +0 -0
  4. frontend/dist/assets/geist-mono-cyrillic-500-normal-mNhfPmgl.woff +0 -0
  5. frontend/dist/assets/geist-mono-cyrillic-600-normal-CGND36d7.woff2 +0 -0
  6. frontend/dist/assets/geist-mono-cyrillic-600-normal-DrylrLu6.woff +0 -0
  7. frontend/dist/assets/geist-mono-cyrillic-700-normal-DH5Q319x.woff +0 -0
  8. frontend/dist/assets/geist-mono-cyrillic-700-normal-VCNRadI3.woff2 +0 -0
  9. frontend/dist/assets/geist-mono-latin-400-normal-CoULgQGM.woff +0 -0
  10. frontend/dist/assets/geist-mono-latin-400-normal-LC9RFr9I.woff2 +0 -0
  11. frontend/dist/assets/geist-mono-latin-500-normal-D3o2eNa9.woff2 +0 -0
  12. frontend/dist/assets/geist-mono-latin-500-normal-DOxI7kZ4.woff +0 -0
  13. frontend/dist/assets/geist-mono-latin-600-normal-DQQBcVN0.woff2 +0 -0
  14. frontend/dist/assets/geist-mono-latin-600-normal-DsVeri3b.woff +0 -0
  15. frontend/dist/assets/geist-mono-latin-700-normal-D6izGJRP.woff2 +0 -0
  16. frontend/dist/assets/geist-mono-latin-700-normal-QGw08Lff.woff +0 -0
  17. frontend/dist/assets/geist-mono-latin-ext-400-normal-Cgks_Qgx.woff2 +0 -0
  18. frontend/dist/assets/geist-mono-latin-ext-400-normal-CxNRRMGd.woff +0 -0
  19. frontend/dist/assets/geist-mono-latin-ext-500-normal-CQcGuCNt.woff2 +0 -0
  20. frontend/dist/assets/geist-mono-latin-ext-500-normal-diTenJ8L.woff +0 -0
  21. frontend/dist/assets/geist-mono-latin-ext-600-normal-CJwYYto2.woff2 +0 -0
  22. frontend/dist/assets/geist-mono-latin-ext-600-normal-EvIRCXgu.woff +0 -0
  23. frontend/dist/assets/geist-mono-latin-ext-700-normal-BX9f1BHp.woff +0 -0
  24. frontend/dist/assets/geist-mono-latin-ext-700-normal-YOllDaLV.woff2 +0 -0
  25. frontend/dist/assets/index-AEO_WTHY.js +59 -0
  26. frontend/dist/assets/index-JUssvikZ.css +1 -0
  27. frontend/dist/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
  28. frontend/dist/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
  29. frontend/dist/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
  30. frontend/dist/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
  31. frontend/dist/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
  32. frontend/dist/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
  33. frontend/dist/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
  34. frontend/dist/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
  35. frontend/dist/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
  36. frontend/dist/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
  37. frontend/dist/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
  38. frontend/dist/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
  39. frontend/dist/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
  40. frontend/dist/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
  41. frontend/dist/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
  42. frontend/dist/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
  43. frontend/dist/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
  44. frontend/dist/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
  45. frontend/dist/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
  46. frontend/dist/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
  47. frontend/dist/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
  48. frontend/dist/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
  49. frontend/dist/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
  50. frontend/dist/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
  51. frontend/dist/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
  52. frontend/dist/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
  53. frontend/dist/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
  54. frontend/dist/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
  55. frontend/dist/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
  56. frontend/dist/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
  57. frontend/dist/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
  58. frontend/dist/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
  59. frontend/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  60. frontend/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  61. frontend/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  62. frontend/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  63. frontend/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  64. frontend/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  65. frontend/dist/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
  66. frontend/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  67. frontend/dist/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
  68. frontend/dist/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
  69. frontend/dist/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
  70. frontend/dist/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
  71. frontend/dist/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
  72. frontend/dist/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
  73. frontend/dist/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
  74. frontend/dist/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
  75. frontend/dist/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
  76. frontend/dist/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
  77. frontend/dist/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
  78. frontend/dist/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
  79. frontend/dist/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
  80. frontend/dist/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
  81. frontend/dist/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
  82. frontend/dist/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
  83. frontend/dist/favicon.svg +1 -0
  84. frontend/dist/icons.svg +24 -0
  85. frontend/dist/index.html +15 -0
  86. frontend/dist/logo.svg +1 -0
  87. ruth/__init__.py +3 -0
  88. ruth/annotations/__init__.py +1 -0
  89. ruth/annotations/complexity.py +128 -0
  90. ruth/annotations/coverage.py +106 -0
  91. ruth/cli.py +167 -0
  92. ruth/graph/__init__.py +1 -0
  93. ruth/graph/engine.py +383 -0
  94. ruth/parser/__init__.py +1 -0
  95. ruth/parser/discovery.py +226 -0
  96. ruth/parser/symbols.py +656 -0
  97. ruth/server.py +162 -0
  98. ruth_code-0.1.0.dist-info/METADATA +106 -0
  99. ruth_code-0.1.0.dist-info/RECORD +102 -0
  100. ruth_code-0.1.0.dist-info/WHEEL +4 -0
  101. ruth_code-0.1.0.dist-info/entry_points.txt +2 -0
  102. ruth_code-0.1.0.dist-info/licenses/LICENSE +21 -0
ruth/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Ruth — Interactive Codebase Topology Visualizer."""
2
+
3
+ __version__ = "0.1.0"
@@ -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."""