repoglance 0.2.1__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.
repoglance/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """repoglance — instant, gorgeous insight into any code repository."""
2
+
3
+ __version__ = "0.2.1"
repoglance/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
repoglance/badge.py ADDED
@@ -0,0 +1,94 @@
1
+ """Generate a self-contained shields-style SVG badge from scan results.
2
+
3
+ No network, no external service — the SVG is fully static and embeddable in a
4
+ README. Character-width estimation mirrors shields.io closely enough for a
5
+ clean, non-overlapping badge with Verdana/DejaVu.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from .languages import color_for
10
+ from .scanner import ScanResult
11
+
12
+ # Approximate per-character advance width at 11px, keyed by character.
13
+ # Falls back to _AVG for anything unlisted. Good enough for tidy padding.
14
+ _AVG = 7.0
15
+ _WIDTHS = {
16
+ " ": 3.5, "i": 3.0, "l": 3.0, "j": 3.0, "t": 4.0, "f": 4.0, "r": 4.5,
17
+ "I": 3.5, ".": 3.5, ",": 3.5, ":": 3.5, "1": 6.0, "m": 10.0, "w": 9.0,
18
+ "M": 10.0, "W": 11.0, "%": 10.0,
19
+ }
20
+
21
+
22
+ def _text_width(s: str) -> float:
23
+ return sum(_WIDTHS.get(c, _AVG) for c in s)
24
+
25
+
26
+ def _human_loc(n: int) -> str:
27
+ if n >= 1_000_000:
28
+ return f"{n / 1_000_000:.1f}M"
29
+ if n >= 1_000:
30
+ return f"{n / 1_000:.1f}k"
31
+ return str(n)
32
+
33
+
34
+ def _escape(s: str) -> str:
35
+ return (
36
+ s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
37
+ .replace('"', "&quot;")
38
+ )
39
+
40
+
41
+ def render_badge(label: str, message: str, color: str) -> str:
42
+ """Return an SVG string for a two-part badge: ``label`` | ``message``."""
43
+ pad = 10.0
44
+ lw = _text_width(label) + pad * 2
45
+ mw = _text_width(message) + pad * 2
46
+ total = lw + mw
47
+ height = 20
48
+ # Text anchors sit at the horizontal center of each half (x10 for crispness).
49
+ lx = lw / 2 * 10
50
+ mx = (lw + mw / 2) * 10
51
+ label_e, message_e = _escape(label), _escape(message)
52
+ return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{total:.0f}" height="{height}" role="img" aria-label="{label_e}: {message_e}">
53
+ <title>{label_e}: {message_e}</title>
54
+ <linearGradient id="s" x2="0" y2="100%">
55
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
56
+ <stop offset="1" stop-opacity=".1"/>
57
+ </linearGradient>
58
+ <clipPath id="r"><rect width="{total:.0f}" height="{height}" rx="3" fill="#fff"/></clipPath>
59
+ <g clip-path="url(#r)">
60
+ <rect width="{lw:.0f}" height="{height}" fill="#555"/>
61
+ <rect x="{lw:.0f}" width="{mw:.0f}" height="{height}" fill="{color}"/>
62
+ <rect width="{total:.0f}" height="{height}" fill="url(#s)"/>
63
+ </g>
64
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="110" text-rendering="geometricPrecision">
65
+ <text aria-hidden="true" x="{lx:.0f}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="{_text_width(label) * 10:.0f}">{label_e}</text>
66
+ <text x="{lx:.0f}" y="140" transform="scale(.1)" textLength="{_text_width(label) * 10:.0f}">{label_e}</text>
67
+ <text aria-hidden="true" x="{mx:.0f}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="{_text_width(message) * 10:.0f}">{message_e}</text>
68
+ <text x="{mx:.0f}" y="140" transform="scale(.1)" textLength="{_text_width(message) * 10:.0f}">{message_e}</text>
69
+ </g>
70
+ </svg>
71
+ """
72
+
73
+
74
+ def badge_for_scan(res: ScanResult) -> str:
75
+ """Build a repoglance badge summarizing lines of code and top language."""
76
+ agg = res.by_language()
77
+ top_lang = max(agg.items(), key=lambda kv: kv[1]["code"])[0] if agg else "code"
78
+ message = f"{_human_loc(res.total_code)} loc • {top_lang}"
79
+ return render_badge("repoglance", message, color_for(top_lang))
80
+
81
+
82
+ def endpoint_json(res: ScanResult) -> str:
83
+ """Shields.io endpoint schema — commit it, then point a dynamic badge at the
84
+ raw URL: https://img.shields.io/endpoint?url=<raw-url>. No hosting needed."""
85
+ import json
86
+
87
+ agg = res.by_language()
88
+ top_lang = max(agg.items(), key=lambda kv: kv[1]["code"])[0] if agg else "code"
89
+ return json.dumps({
90
+ "schemaVersion": 1,
91
+ "label": "repoglance",
92
+ "message": f"{_human_loc(res.total_code)} loc • {top_lang}",
93
+ "color": "blue",
94
+ })
repoglance/cli.py ADDED
@@ -0,0 +1,90 @@
1
+ """Command-line entry point for repoglance."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from rich.console import Console
9
+
10
+ from . import __version__
11
+ from . import badge as badgemod
12
+ from . import gitinfo, report
13
+ from .scanner import scan
14
+
15
+
16
+ @click.command(context_settings={"help_option_names": ["-h", "--help"]})
17
+ @click.argument("path", type=click.Path(exists=True, file_okay=False, path_type=Path), default=".")
18
+ @click.option("--json", "as_json", is_flag=True, help="Emit machine-readable JSON instead of the terminal report.")
19
+ @click.option("--md", "--markdown", "as_md", is_flag=True, help="Emit a Markdown report (great for PR comments / job summaries).")
20
+ @click.option("--html", "html_path", type=click.Path(dir_okay=False, path_type=Path), help="Write the report to a standalone HTML file.")
21
+ @click.option("--svg", "svg_path", type=click.Path(dir_okay=False, path_type=Path), help="Write the report to a standalone SVG file.")
22
+ @click.option("--badge", "badge_path", type=click.Path(dir_okay=False, path_type=Path), help="Write a shields-style SVG badge (loc + top language).")
23
+ @click.option("--badge-json", "badge_json_path", type=click.Path(dir_okay=False, path_type=Path), help="Write a shields.io endpoint JSON for a self-updating dynamic badge.")
24
+ @click.option("--ci", is_flag=True, help="CI gate mode: exit nonzero if a threshold is exceeded.")
25
+ @click.option("--max-complexity", type=int, default=None, help="Fail (with --ci) if any function exceeds this complexity.")
26
+ @click.option("--max-todos", type=int, default=None, help="Fail (with --ci) if TODO markers exceed this count.")
27
+ @click.option("--fail-under", type=int, default=None, help="Fail (with --ci) if the health score is below this (0-100).")
28
+ @click.option("--no-git", is_flag=True, help="Skip git history analysis.")
29
+ @click.option("--max-bytes", default=2_000_000, show_default=True, help="Skip files larger than this many bytes.")
30
+ @click.option("--ignore", multiple=True, help="Extra directory name to ignore (repeatable).")
31
+ @click.version_option(__version__, "-V", "--version", prog_name="repoglance")
32
+ def main(path, as_json, as_md, html_path, svg_path, badge_path, badge_json_path, ci, max_complexity, max_todos, fail_under, no_git, max_bytes, ignore):
33
+ """Instant, gorgeous insight into any code repository.
34
+
35
+ PATH defaults to the current directory.
36
+ """
37
+ result = scan(path, max_bytes=max_bytes, extra_ignores=set(ignore))
38
+ if not result.files:
39
+ click.echo("No source files found.", err=True)
40
+ sys.exit(1)
41
+
42
+ git_stats = None if no_git else gitinfo.collect(result.root)
43
+
44
+ # Windows legacy consoles / redirected pipes default to cp1252 and choke on
45
+ # the report's box-drawing, emoji and bar glyphs. Prefer real UTF-8 output.
46
+ try:
47
+ sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
48
+ except (AttributeError, ValueError):
49
+ pass
50
+
51
+ # Side artifacts: write on request, report where they landed.
52
+ if badge_path:
53
+ badge_path.write_text(badgemod.badge_for_scan(result), encoding="utf-8")
54
+ click.echo(f"Wrote badge to {badge_path}", err=True)
55
+ if badge_json_path:
56
+ badge_json_path.write_text(badgemod.endpoint_json(result), encoding="utf-8")
57
+ click.echo(f"Wrote shields endpoint JSON to {badge_json_path}", err=True)
58
+ if html_path:
59
+ report.export(result, git_stats, str(html_path), "html")
60
+ click.echo(f"Wrote HTML report to {html_path}", err=True)
61
+ if svg_path:
62
+ report.export(result, git_stats, str(svg_path), "svg")
63
+ click.echo(f"Wrote SVG report to {svg_path}", err=True)
64
+
65
+ # CI gate: evaluate thresholds and exit with a clear status.
66
+ if ci:
67
+ ok, messages = report.evaluate_gate(result, max_complexity, max_todos, fail_under)
68
+ prefix = "OK " if ok else "FAIL"
69
+ for m in messages:
70
+ click.echo(f"[{prefix}] {m}", err=not ok)
71
+ sys.exit(0 if ok else 2)
72
+
73
+ if as_json:
74
+ click.echo(report.to_json(result, git_stats))
75
+ return
76
+
77
+ if as_md:
78
+ click.echo(report.to_markdown(result, git_stats))
79
+ return
80
+
81
+ # Only-artifact runs (badge/html/svg without a console report) finish here.
82
+ if (badge_path or badge_json_path or html_path or svg_path) and not sys.stdout.isatty():
83
+ return
84
+
85
+ console = Console()
86
+ report.render(console, result, git_stats)
87
+
88
+
89
+ if __name__ == "__main__":
90
+ main()
@@ -0,0 +1,96 @@
1
+ """Cyclomatic-complexity estimation for Python via AST.
2
+
3
+ For non-Python files we fall back to a cheap branch-keyword heuristic so the
4
+ report still surfaces likely hotspots without language-specific parsers.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ import re
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import List
13
+
14
+ # AST nodes that each add a decision point (McCabe-style).
15
+ _BRANCH_NODES = (
16
+ ast.If, ast.For, ast.AsyncFor, ast.While, ast.ExceptHandler,
17
+ ast.With, ast.AsyncWith, ast.Assert, ast.comprehension,
18
+ )
19
+
20
+ _BOOLOP_EXTRA = ast.BoolOp # each extra operand adds a path
21
+
22
+ # Keyword heuristic for non-Python source.
23
+ _BRANCH_RE = re.compile(
24
+ r"\b(if|else if|elif|for|while|case|catch|except|&&|\|\||\?)\b|\?\s*[^:]+:"
25
+ )
26
+
27
+ # Markup / data / prose languages have no control flow — never treat as hotspots.
28
+ _NON_CODE_LANGS = {
29
+ "Markdown", "reStructuredText", "TeX", "JSON", "YAML", "TOML", "XML",
30
+ "HTML", "CSS", "SCSS", "Sass", "Less", "SQL", "GraphQL", "Protobuf",
31
+ }
32
+
33
+
34
+ @dataclass
35
+ class FuncScore:
36
+ path: str
37
+ name: str
38
+ line: int
39
+ complexity: int
40
+
41
+
42
+ def python_complexity(source: str, rel_path: str) -> List[FuncScore]:
43
+ """Return per-function complexity for a Python source string."""
44
+ try:
45
+ tree = ast.parse(source)
46
+ except SyntaxError:
47
+ return []
48
+
49
+ scores: List[FuncScore] = []
50
+
51
+ class Visitor(ast.NodeVisitor):
52
+ def _score(self, node) -> int:
53
+ complexity = 1
54
+ for child in ast.walk(node):
55
+ if isinstance(child, _BRANCH_NODES):
56
+ complexity += 1
57
+ elif isinstance(child, _BOOLOP_EXTRA):
58
+ complexity += len(child.values) - 1
59
+ return complexity
60
+
61
+ def visit_FunctionDef(self, node):
62
+ scores.append(
63
+ FuncScore(rel_path, node.name, node.lineno, self._score(node))
64
+ )
65
+ # Do not recurse into nested funcs separately; walk() already covered
66
+ # them for the parent's score. Still visit to catch module-level peers.
67
+ self.generic_visit(node)
68
+
69
+ visit_AsyncFunctionDef = visit_FunctionDef
70
+
71
+ Visitor().visit(tree)
72
+ return scores
73
+
74
+
75
+ def heuristic_complexity(source: str, rel_path: str) -> int:
76
+ """Whole-file branch-keyword count for non-Python languages."""
77
+ return 1 + len(_BRANCH_RE.findall(source))
78
+
79
+
80
+ def rank_hotspots(files, root: Path, top: int = 10) -> List[FuncScore]:
81
+ """Compute complexity across scanned files and return the worst offenders."""
82
+ all_scores: List[FuncScore] = []
83
+ for f in files:
84
+ full = root / f.path
85
+ try:
86
+ src = full.read_text(encoding="utf-8", errors="ignore")
87
+ except OSError:
88
+ continue
89
+ if f.language == "Python":
90
+ all_scores.extend(python_complexity(src, f.path))
91
+ elif f.language not in _NON_CODE_LANGS:
92
+ score = heuristic_complexity(src, f.path)
93
+ if score > 1:
94
+ all_scores.append(FuncScore(f.path, "(file)", 1, score))
95
+ all_scores.sort(key=lambda s: s.complexity, reverse=True)
96
+ return all_scores[:top]
repoglance/gitinfo.py ADDED
@@ -0,0 +1,76 @@
1
+ """Optional git-history insights. Degrades gracefully when git is absent."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import List, Optional
8
+
9
+
10
+ @dataclass
11
+ class GitStats:
12
+ total_commits: int
13
+ contributors: List[tuple] # (name, commit_count), sorted desc
14
+ hot_files: List[tuple] # (path, change_count), sorted desc
15
+ first_commit: str
16
+ last_commit: str
17
+ active_days: int # distinct YYYY-MM-DD with commits
18
+
19
+
20
+ def _run(root: Path, args: List[str]) -> Optional[str]:
21
+ try:
22
+ out = subprocess.run(
23
+ ["git", *args],
24
+ cwd=str(root),
25
+ capture_output=True,
26
+ text=True,
27
+ timeout=15,
28
+ )
29
+ except (OSError, subprocess.TimeoutExpired):
30
+ return None
31
+ if out.returncode != 0:
32
+ return None
33
+ return out.stdout
34
+
35
+
36
+ def collect(root: Path) -> Optional[GitStats]:
37
+ """Return git statistics, or ``None`` if ``root`` is not a git repo."""
38
+ if _run(root, ["rev-parse", "--is-inside-work-tree"]) is None:
39
+ return None
40
+
41
+ count_raw = _run(root, ["rev-list", "--count", "HEAD"])
42
+ total_commits = int(count_raw.strip()) if count_raw and count_raw.strip().isdigit() else 0
43
+ if total_commits == 0:
44
+ return None
45
+
46
+ authors: dict = {}
47
+ for line in (_run(root, ["log", "--format=%an"]) or "").splitlines():
48
+ name = line.strip()
49
+ if name:
50
+ authors[name] = authors.get(name, 0) + 1
51
+ contributors = sorted(authors.items(), key=lambda kv: kv[1], reverse=True)
52
+
53
+ files: dict = {}
54
+ for line in (_run(root, ["log", "--name-only", "--format="]) or "").splitlines():
55
+ p = line.strip()
56
+ if p:
57
+ files[p] = files.get(p, 0) + 1
58
+ hot_files = sorted(files.items(), key=lambda kv: kv[1], reverse=True)
59
+
60
+ days = {
61
+ line.strip()
62
+ for line in (_run(root, ["log", "--format=%cd", "--date=short"]) or "").splitlines()
63
+ if line.strip()
64
+ }
65
+
66
+ first = (_run(root, ["log", "--reverse", "--format=%cd", "--date=short"]) or "").splitlines()
67
+ last = (_run(root, ["log", "-1", "--format=%cd", "--date=short"]) or "").strip()
68
+
69
+ return GitStats(
70
+ total_commits=total_commits,
71
+ contributors=contributors[:10],
72
+ hot_files=hot_files[:10],
73
+ first_commit=first[0].strip() if first else "?",
74
+ last_commit=last or "?",
75
+ active_days=len(days),
76
+ )
@@ -0,0 +1,111 @@
1
+ """Extension-to-language mapping and language color hints."""
2
+ from __future__ import annotations
3
+
4
+ # Map file extension (lowercase, no dot) -> language name.
5
+ EXT_LANG = {
6
+ "py": "Python",
7
+ "pyi": "Python",
8
+ "js": "JavaScript",
9
+ "mjs": "JavaScript",
10
+ "cjs": "JavaScript",
11
+ "jsx": "JavaScript",
12
+ "ts": "TypeScript",
13
+ "tsx": "TypeScript",
14
+ "go": "Go",
15
+ "rs": "Rust",
16
+ "java": "Java",
17
+ "kt": "Kotlin",
18
+ "c": "C",
19
+ "h": "C",
20
+ "cpp": "C++",
21
+ "cc": "C++",
22
+ "cxx": "C++",
23
+ "hpp": "C++",
24
+ "cs": "C#",
25
+ "rb": "Ruby",
26
+ "php": "PHP",
27
+ "swift": "Swift",
28
+ "m": "Objective-C",
29
+ "scala": "Scala",
30
+ "sh": "Shell",
31
+ "bash": "Shell",
32
+ "zsh": "Shell",
33
+ "ps1": "PowerShell",
34
+ "lua": "Lua",
35
+ "r": "R",
36
+ "dart": "Dart",
37
+ "ex": "Elixir",
38
+ "exs": "Elixir",
39
+ "erl": "Erlang",
40
+ "clj": "Clojure",
41
+ "hs": "Haskell",
42
+ "ml": "OCaml",
43
+ "sql": "SQL",
44
+ "html": "HTML",
45
+ "htm": "HTML",
46
+ "css": "CSS",
47
+ "scss": "SCSS",
48
+ "sass": "Sass",
49
+ "less": "Less",
50
+ "vue": "Vue",
51
+ "svelte": "Svelte",
52
+ "json": "JSON",
53
+ "yaml": "YAML",
54
+ "yml": "YAML",
55
+ "toml": "TOML",
56
+ "xml": "XML",
57
+ "md": "Markdown",
58
+ "rst": "reStructuredText",
59
+ "tex": "TeX",
60
+ "proto": "Protobuf",
61
+ "graphql": "GraphQL",
62
+ "tf": "Terraform",
63
+ "dockerfile": "Dockerfile",
64
+ "makefile": "Makefile",
65
+ "cmake": "CMake",
66
+ "gradle": "Gradle",
67
+ "vim": "Vim script",
68
+ "asm": "Assembly",
69
+ "s": "Assembly",
70
+ }
71
+
72
+ # Stable-ish display color per language (rich color names / hex).
73
+ LANG_COLOR = {
74
+ "Python": "#3572A5",
75
+ "JavaScript": "#F1E05A",
76
+ "TypeScript": "#3178C6",
77
+ "Go": "#00ADD8",
78
+ "Rust": "#DEA584",
79
+ "Java": "#B07219",
80
+ "C": "#555555",
81
+ "C++": "#F34B7D",
82
+ "C#": "#178600",
83
+ "Ruby": "#701516",
84
+ "PHP": "#4F5D95",
85
+ "Swift": "#F05138",
86
+ "Shell": "#89E051",
87
+ "HTML": "#E34C26",
88
+ "CSS": "#563D7C",
89
+ "Vue": "#41B883",
90
+ "Markdown": "#083FA1",
91
+ "JSON": "#CB9800",
92
+ "YAML": "#CB171E",
93
+ }
94
+
95
+ DEFAULT_COLOR = "#8B949E"
96
+
97
+
98
+ def lang_for(ext: str, filename: str) -> str:
99
+ """Resolve a language from extension, falling back to well-known filenames."""
100
+ name = filename.lower()
101
+ if name in ("makefile", "gnumakefile"):
102
+ return "Makefile"
103
+ if name.startswith("dockerfile"):
104
+ return "Dockerfile"
105
+ if name == "cmakelists.txt":
106
+ return "CMake"
107
+ return EXT_LANG.get(ext.lower(), "")
108
+
109
+
110
+ def color_for(language: str) -> str:
111
+ return LANG_COLOR.get(language, DEFAULT_COLOR)
repoglance/metrics.py ADDED
@@ -0,0 +1,68 @@
1
+ """Repository health scoring — a transparent 0-100 composite grade.
2
+
3
+ Every sub-score is a simple, explainable function of numbers already gathered
4
+ during the scan, so the grade is auditable rather than a black box.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from typing import List
10
+
11
+ from .complexity import rank_hotspots
12
+ from .scanner import ScanResult
13
+
14
+
15
+ @dataclass
16
+ class Health:
17
+ score: int # 0-100
18
+ grade: str # A-F
19
+ factors: List[tuple] # (name, points, max_points, detail)
20
+
21
+
22
+ def _grade(score: int) -> str:
23
+ if score >= 90:
24
+ return "A"
25
+ if score >= 80:
26
+ return "B"
27
+ if score >= 70:
28
+ return "C"
29
+ if score >= 60:
30
+ return "D"
31
+ return "F"
32
+
33
+
34
+ def compute(res: ScanResult) -> Health:
35
+ """Compute a weighted health score from four cheap, honest signals."""
36
+ factors: List[tuple] = []
37
+
38
+ # 1. Documentation: comment lines as a share of code (capped, 25 pts).
39
+ code = res.total_code or 1
40
+ comments = sum(f.comment_lines for f in res.files)
41
+ ratio = comments / code
42
+ doc_pts = min(25, round(ratio / 0.15 * 25)) # 15%+ comments = full marks
43
+ factors.append(("Documentation", doc_pts, 25, f"{ratio * 100:.1f}% comment ratio"))
44
+
45
+ # 2. Complexity: penalize the worst hotspot (25 pts).
46
+ worst = rank_hotspots(res.files, res.root, top=1)
47
+ top_cx = worst[0].complexity if worst else 0
48
+ # 10 or below = full; 40+ = zero. Linear in between.
49
+ cx_pts = max(0, min(25, round((40 - top_cx) / 30 * 25)))
50
+ factors.append(("Complexity", cx_pts, 25, f"worst function = {top_cx}"))
51
+
52
+ # 3. TODO debt: markers per 1k lines of code (25 pts).
53
+ density = len(res.todos) / (code / 1000)
54
+ # 0 = full; 20+ per kloc = zero.
55
+ todo_pts = max(0, min(25, round((20 - density) / 20 * 25)))
56
+ factors.append(("TODO debt", todo_pts, 25, f"{density:.1f} markers / kloc"))
57
+
58
+ # 4. File size discipline: share of files under 400 code lines (25 pts).
59
+ if res.files:
60
+ small = sum(1 for f in res.files if f.code_lines <= 400)
61
+ share = small / len(res.files)
62
+ else:
63
+ share = 1.0
64
+ size_pts = round(share * 25)
65
+ factors.append(("File size", size_pts, 25, f"{share * 100:.0f}% files <= 400 loc"))
66
+
67
+ score = sum(p for _, p, _, _ in factors)
68
+ return Health(score=score, grade=_grade(score), factors=factors)
repoglance/report.py ADDED
@@ -0,0 +1,310 @@
1
+ """Render a ScanResult into a rich terminal report (or JSON)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from rich.columns import Columns
9
+ from rich.console import Console, Group
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from .complexity import rank_hotspots
15
+ from .gitinfo import GitStats
16
+ from .languages import color_for
17
+ from .metrics import compute as compute_health
18
+ from .scanner import ScanResult
19
+
20
+ _BAR = "█"
21
+
22
+
23
+ def _human_bytes(n: int) -> str:
24
+ step = 1024.0
25
+ for unit in ("B", "KB", "MB", "GB"):
26
+ if n < step:
27
+ return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
28
+ n /= step
29
+ return f"{n:.1f}TB"
30
+
31
+
32
+ def _bar(fraction: float, width: int = 24) -> str:
33
+ filled = int(round(fraction * width))
34
+ return _BAR * filled + " " * (width - filled)
35
+
36
+
37
+ def _header(res: ScanResult, git: Optional[GitStats]) -> Panel:
38
+ name = res.root.name or str(res.root)
39
+ lines = [
40
+ Text.assemble(("📁 ", "bold"), (name, "bold cyan")),
41
+ Text.assemble(
42
+ (f"{len(res.files):,}", "bold white"), (" files scanned ", "dim"),
43
+ (f"{res.total_code:,}", "bold white"), (" lines of code", "dim"),
44
+ ),
45
+ ]
46
+ if git:
47
+ lines.append(
48
+ Text.assemble(
49
+ (f"{git.total_commits:,}", "bold white"), (" commits ", "dim"),
50
+ (f"{len(git.contributors)}", "bold white"), (" contributors ", "dim"),
51
+ (f"{git.first_commit} → {git.last_commit}", "dim"),
52
+ )
53
+ )
54
+ return Panel(Group(*lines), border_style="cyan", title="[bold]repoglance[/]", title_align="left")
55
+
56
+
57
+ def _language_table(res: ScanResult) -> Panel:
58
+ agg = res.by_language()
59
+ total = res.total_code or 1
60
+ rows = sorted(agg.items(), key=lambda kv: kv[1]["code"], reverse=True)
61
+
62
+ table = Table.grid(padding=(0, 1))
63
+ table.add_column(justify="left", no_wrap=True)
64
+ table.add_column(justify="left")
65
+ table.add_column(justify="right")
66
+ table.add_column(justify="right")
67
+
68
+ for lang, d in rows[:12]:
69
+ frac = d["code"] / total
70
+ color = color_for(lang)
71
+ table.add_row(
72
+ Text(lang, style="bold"),
73
+ Text(_bar(frac), style=color),
74
+ Text(f"{frac * 100:4.1f}%", style="white"),
75
+ Text(f"{d['code']:>7,} loc {d['files']:>4} files", style="dim"),
76
+ )
77
+ return Panel(table, title="[bold]Languages[/]", border_style="blue", title_align="left")
78
+
79
+
80
+ def _hotspots_panel(res: ScanResult) -> Panel:
81
+ hotspots = rank_hotspots(res.files, res.root, top=8)
82
+ table = Table.grid(padding=(0, 2))
83
+ table.add_column(justify="right", no_wrap=True)
84
+ table.add_column(justify="left")
85
+ table.add_column(justify="left")
86
+ if not hotspots:
87
+ body = Text("No hotspots found.", style="dim")
88
+ else:
89
+ for s in hotspots:
90
+ sev = "red" if s.complexity >= 20 else "yellow" if s.complexity >= 10 else "green"
91
+ table.add_row(
92
+ Text(str(s.complexity), style=f"bold {sev}"),
93
+ Text(f"{s.name}", style="white"),
94
+ Text(f"{s.path}:{s.line}", style="dim"),
95
+ )
96
+ body = table
97
+ return Panel(body, title="[bold]Complexity hotspots[/]", border_style="magenta", title_align="left")
98
+
99
+
100
+ def _todos_panel(res: ScanResult) -> Panel:
101
+ by_marker: dict = {}
102
+ for t in res.todos:
103
+ by_marker[t.marker] = by_marker.get(t.marker, 0) + 1
104
+ summary = " ".join(f"[bold]{m}[/] {c}" for m, c in sorted(by_marker.items()))
105
+
106
+ table = Table.grid(padding=(0, 2))
107
+ table.add_column(justify="left", no_wrap=True)
108
+ table.add_column(justify="left")
109
+ for t in res.todos[:8]:
110
+ table.add_row(
111
+ Text(t.marker, style="bold yellow"),
112
+ Text.assemble((t.text, "white"), (" " + f"{t.path}:{t.line}", "dim")),
113
+ )
114
+ header = Text.from_markup(summary or "[dim]No TODOs — clean![/]")
115
+ body = Group(header, Text(""), table) if res.todos else header
116
+ return Panel(body, title="[bold]TODO tracker[/]", border_style="yellow", title_align="left")
117
+
118
+
119
+ def _biggest_panel(res: ScanResult) -> Panel:
120
+ biggest = sorted(res.files, key=lambda f: f.code_lines, reverse=True)[:8]
121
+ table = Table.grid(padding=(0, 2))
122
+ table.add_column(justify="right", no_wrap=True)
123
+ table.add_column(justify="left")
124
+ for f in biggest:
125
+ table.add_row(
126
+ Text(f"{f.code_lines:,}", style="bold white"),
127
+ Text.assemble((f.path, "cyan"), (" " + _human_bytes(f.size_bytes), "dim")),
128
+ )
129
+ return Panel(table, title="[bold]Biggest files[/]", border_style="green", title_align="left")
130
+
131
+
132
+ def _git_panel(git: GitStats) -> Panel:
133
+ table = Table.grid(padding=(0, 2))
134
+ table.add_column(justify="left")
135
+ table.add_column(justify="left")
136
+ table.add_row(Text("Top authors", style="bold"), Text("Most-churned files", style="bold"))
137
+ top_authors = git.contributors[:5]
138
+ top_files = git.hot_files[:5]
139
+ for i in range(max(len(top_authors), len(top_files))):
140
+ a = f"{top_authors[i][0]} ({top_authors[i][1]})" if i < len(top_authors) else ""
141
+ h = f"{top_files[i][0]} ×{top_files[i][1]}" if i < len(top_files) else ""
142
+ table.add_row(Text(a, style="white"), Text(h, style="dim"))
143
+ footer = Text(f"\n{git.active_days} active days", style="dim")
144
+ return Panel(Group(table, footer), title="[bold]Git activity[/]", border_style="red", title_align="left")
145
+
146
+
147
+ def _health_panel(res: ScanResult) -> Panel:
148
+ h = compute_health(res)
149
+ grade_color = {"A": "green", "B": "green", "C": "yellow", "D": "yellow", "F": "red"}[h.grade]
150
+ table = Table.grid(padding=(0, 2))
151
+ table.add_column(justify="left", no_wrap=True)
152
+ table.add_column(justify="left")
153
+ table.add_column(justify="right")
154
+ for name, pts, mx, detail in h.factors:
155
+ frac = pts / mx if mx else 0
156
+ bar_color = "green" if frac >= 0.8 else "yellow" if frac >= 0.5 else "red"
157
+ table.add_row(
158
+ Text(name, style="bold"),
159
+ Text(_bar(frac, 16), style=bar_color),
160
+ Text.assemble((f"{pts}/{mx}", "white"), (" " + detail, "dim")),
161
+ )
162
+ headline = Text.assemble(
163
+ (f"{h.score}", f"bold {grade_color}"), ("/100 grade ", "dim"),
164
+ (h.grade, f"bold {grade_color}"),
165
+ )
166
+ return Panel(Group(headline, Text(""), table), title="[bold]Health score[/]", border_style=grade_color, title_align="left")
167
+
168
+
169
+ def render(console: Console, res: ScanResult, git: Optional[GitStats]) -> None:
170
+ console.print(_header(res, git))
171
+ console.print(_health_panel(res))
172
+ console.print(_language_table(res))
173
+ console.print(Columns([_hotspots_panel(res), _biggest_panel(res)], expand=True))
174
+ console.print(_todos_panel(res))
175
+ if git:
176
+ console.print(_git_panel(git))
177
+
178
+
179
+ def export(res: ScanResult, git: Optional[GitStats], path: str, fmt: str) -> None:
180
+ """Render the report into a recording console and write it as HTML or SVG."""
181
+ import io
182
+
183
+ # Record into an off-screen buffer so nothing hits the real terminal and we
184
+ # avoid Windows legacy-console encoding paths.
185
+ console = Console(record=True, file=io.StringIO(), width=100)
186
+ render(console, res, git)
187
+ if fmt == "svg":
188
+ data = console.export_svg(title=f"repoglance · {res.root.name}")
189
+ else:
190
+ data = console.export_html()
191
+ with open(path, "w", encoding="utf-8") as fh:
192
+ fh.write(data)
193
+
194
+
195
+ def evaluate_gate(res: ScanResult, max_complexity: Optional[int], max_todos: Optional[int],
196
+ fail_under: Optional[int] = None):
197
+ """Return (ok, messages) for CI threshold checks. Empty thresholds are skipped."""
198
+ from .complexity import rank_hotspots
199
+
200
+ messages = []
201
+ ok = True
202
+ if fail_under is not None:
203
+ h = compute_health(res)
204
+ if h.score < fail_under:
205
+ ok = False
206
+ messages.append(f"health {h.score} (grade {h.grade}) below min {fail_under}")
207
+ else:
208
+ messages.append(f"health ok ({h.score} >= {fail_under}, grade {h.grade})")
209
+ if max_complexity is not None:
210
+ worst = rank_hotspots(res.files, res.root, top=1)
211
+ top = worst[0].complexity if worst else 0
212
+ if top > max_complexity:
213
+ ok = False
214
+ offender = f"{worst[0].name} ({worst[0].path}:{worst[0].line})" if worst else "?"
215
+ messages.append(f"complexity {top} exceeds max {max_complexity} - {offender}")
216
+ else:
217
+ messages.append(f"complexity ok (worst {top} <= {max_complexity})")
218
+ if max_todos is not None:
219
+ n = len(res.todos)
220
+ if n > max_todos:
221
+ ok = False
222
+ messages.append(f"{n} TODO markers exceed max {max_todos}")
223
+ else:
224
+ messages.append(f"todos ok ({n} <= {max_todos})")
225
+ return ok, messages
226
+
227
+
228
+ def to_markdown(res: ScanResult, git: Optional[GitStats]) -> str:
229
+ """A compact Markdown report, ideal for PR comments and job summaries."""
230
+ h = compute_health(res)
231
+ agg = res.by_language()
232
+ total = res.total_code or 1
233
+ out = []
234
+ out.append("### 🔍 repoglance report")
235
+ out.append("")
236
+ out.append(f"**{len(res.files):,} files** · **{res.total_code:,} lines of code** · "
237
+ f"health **{h.score}/100 ({h.grade})**")
238
+ if git:
239
+ out.append(f"_{git.total_commits:,} commits · {len(git.contributors)} contributors · "
240
+ f"{git.first_commit} → {git.last_commit}_")
241
+ out.append("")
242
+
243
+ out.append("| Language | Code | Share | Files |")
244
+ out.append("|---|--:|--:|--:|")
245
+ for lang, d in sorted(agg.items(), key=lambda kv: kv[1]["code"], reverse=True)[:8]:
246
+ out.append(f"| {lang} | {d['code']:,} | {d['code'] / total * 100:.1f}% | {d['files']} |")
247
+ out.append("")
248
+
249
+ hotspots = rank_hotspots(res.files, res.root, top=5)
250
+ if hotspots:
251
+ out.append("<details><summary>🔥 Complexity hotspots</summary>")
252
+ out.append("")
253
+ out.append("| Complexity | Function | Location |")
254
+ out.append("|--:|---|---|")
255
+ for s in hotspots:
256
+ out.append(f"| {s.complexity} | `{s.name}` | `{s.path}:{s.line}` |")
257
+ out.append("")
258
+ out.append("</details>")
259
+ out.append("")
260
+
261
+ todo_n = len(res.todos)
262
+ out.append(f"**TODO markers:** {todo_n}")
263
+ out.append("")
264
+ out.append("<sub>Generated by [repoglance](https://github.com/SRJ-ai/repolens)</sub>")
265
+ return "\n".join(out)
266
+
267
+
268
+ def to_json(res: ScanResult, git: Optional[GitStats]) -> str:
269
+ agg = res.by_language()
270
+ h = compute_health(res)
271
+ payload = {
272
+ "root": str(res.root),
273
+ "total_files": len(res.files),
274
+ "total_code_lines": res.total_code,
275
+ "total_lines": res.total_lines,
276
+ "health": {
277
+ "score": h.score,
278
+ "grade": h.grade,
279
+ "factors": [
280
+ {"name": n, "points": p, "max": m, "detail": d}
281
+ for n, p, m, d in h.factors
282
+ ],
283
+ },
284
+ "languages": {
285
+ lang: {"code": d["code"], "files": d["files"], "bytes": d["bytes"]}
286
+ for lang, d in sorted(agg.items(), key=lambda kv: kv[1]["code"], reverse=True)
287
+ },
288
+ "hotspots": [
289
+ {"name": s.name, "path": s.path, "line": s.line, "complexity": s.complexity}
290
+ for s in rank_hotspots(res.files, res.root, top=15)
291
+ ],
292
+ "todos": [
293
+ {"marker": t.marker, "path": t.path, "line": t.line, "text": t.text}
294
+ for t in res.todos
295
+ ],
296
+ "biggest_files": [
297
+ {"path": f.path, "code_lines": f.code_lines, "bytes": f.size_bytes}
298
+ for f in sorted(res.files, key=lambda f: f.code_lines, reverse=True)[:15]
299
+ ],
300
+ }
301
+ if git:
302
+ payload["git"] = {
303
+ "total_commits": git.total_commits,
304
+ "contributors": [{"name": n, "commits": c} for n, c in git.contributors],
305
+ "hot_files": [{"path": p, "changes": c} for p, c in git.hot_files],
306
+ "first_commit": git.first_commit,
307
+ "last_commit": git.last_commit,
308
+ "active_days": git.active_days,
309
+ }
310
+ return json.dumps(payload, indent=2)
repoglance/scanner.py ADDED
@@ -0,0 +1,181 @@
1
+ """Repository walker: collects per-file stats while honoring ignore rules."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Dict, List, Optional
8
+
9
+ from .languages import lang_for
10
+
11
+ # Directories never worth scanning.
12
+ IGNORE_DIRS = {
13
+ ".git", ".hg", ".svn", "node_modules", "venv", ".venv", "env",
14
+ "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox",
15
+ "dist", "build", "target", ".next", ".nuxt", "out", "coverage",
16
+ ".idea", ".vscode", ".gradle", "vendor", "bower_components", ".cache",
17
+ "site-packages", ".eggs",
18
+ }
19
+
20
+ # File suffixes that are binary / not source.
21
+ BINARY_EXT = {
22
+ "png", "jpg", "jpeg", "gif", "bmp", "ico", "webp", "svg", "pdf",
23
+ "zip", "gz", "tar", "tgz", "rar", "7z", "jar", "war", "class",
24
+ "exe", "dll", "so", "dylib", "o", "a", "bin", "wasm",
25
+ "mp3", "mp4", "mov", "avi", "wav", "flac", "webm",
26
+ "ttf", "otf", "woff", "woff2", "eot",
27
+ "pyc", "pyo", "lock", "db", "sqlite", "sqlite3",
28
+ }
29
+
30
+ TODO_MARKERS = ("TODO", "FIXME", "HACK", "XXX", "BUG", "OPTIMIZE")
31
+
32
+
33
+ @dataclass
34
+ class FileStat:
35
+ path: str # repo-relative, forward-slash
36
+ language: str
37
+ lines: int
38
+ code_lines: int
39
+ blank_lines: int
40
+ comment_lines: int
41
+ size_bytes: int
42
+
43
+
44
+ @dataclass
45
+ class Todo:
46
+ path: str
47
+ line: int
48
+ marker: str
49
+ text: str
50
+
51
+
52
+ @dataclass
53
+ class ScanResult:
54
+ root: Path
55
+ files: List[FileStat] = field(default_factory=list)
56
+ todos: List[Todo] = field(default_factory=list)
57
+ skipped_binary: int = 0
58
+
59
+ @property
60
+ def total_lines(self) -> int:
61
+ return sum(f.lines for f in self.files)
62
+
63
+ @property
64
+ def total_code(self) -> int:
65
+ return sum(f.code_lines for f in self.files)
66
+
67
+ def by_language(self) -> Dict[str, Dict[str, int]]:
68
+ """Aggregate code lines, file count and bytes per language."""
69
+ agg: Dict[str, Dict[str, int]] = {}
70
+ for f in self.files:
71
+ d = agg.setdefault(f.language, {"code": 0, "files": 0, "bytes": 0})
72
+ d["code"] += f.code_lines
73
+ d["files"] += 1
74
+ d["bytes"] += f.size_bytes
75
+ return agg
76
+
77
+
78
+ # Comment prefixes by language (single-line only; good enough for stats).
79
+ _COMMENT_PREFIX = {
80
+ "Python": ("#",), "Ruby": ("#",), "Shell": ("#",), "YAML": ("#",),
81
+ "PowerShell": ("#",), "R": ("#",), "Makefile": ("#",), "TOML": ("#",),
82
+ "JavaScript": ("//", "/*", "*"), "TypeScript": ("//", "/*", "*"),
83
+ "Go": ("//", "/*", "*"), "Rust": ("//", "/*", "*"), "Java": ("//", "/*", "*"),
84
+ "C": ("//", "/*", "*"), "C++": ("//", "/*", "*"), "C#": ("//", "/*", "*"),
85
+ "PHP": ("//", "#", "/*", "*"), "Swift": ("//", "/*", "*"),
86
+ "CSS": ("/*", "*"), "SCSS": ("//", "/*", "*"),
87
+ }
88
+
89
+
90
+ def _count_lines(text: str, language: str):
91
+ prefixes = _COMMENT_PREFIX.get(language, ())
92
+ total = blank = comment = 0
93
+ for raw in text.splitlines():
94
+ total += 1
95
+ stripped = raw.strip()
96
+ if not stripped:
97
+ blank += 1
98
+ elif prefixes and stripped.startswith(prefixes):
99
+ comment += 1
100
+ code = total - blank - comment
101
+ return total, code, blank, comment
102
+
103
+
104
+ def scan(
105
+ root: os.PathLike,
106
+ max_bytes: int = 2_000_000,
107
+ extra_ignores: Optional[set] = None,
108
+ ) -> ScanResult:
109
+ """Walk ``root`` and gather per-file line/language stats plus TODO markers."""
110
+ root_path = Path(root).resolve()
111
+ result = ScanResult(root=root_path)
112
+ ignore = IGNORE_DIRS | (extra_ignores or set())
113
+
114
+ for dirpath, dirnames, filenames in os.walk(root_path):
115
+ # Prune ignored dirs in place so os.walk skips descending them.
116
+ dirnames[:] = [d for d in dirnames if d not in ignore and not d.startswith(".git")]
117
+ for fn in filenames:
118
+ ext = fn.rsplit(".", 1)[-1] if "." in fn else ""
119
+ if ext.lower() in BINARY_EXT:
120
+ result.skipped_binary += 1
121
+ continue
122
+ language = lang_for(ext, fn)
123
+ if not language:
124
+ continue
125
+ full = Path(dirpath) / fn
126
+ try:
127
+ size = full.stat().st_size
128
+ if size > max_bytes:
129
+ continue
130
+ text = full.read_text(encoding="utf-8", errors="ignore")
131
+ except (OSError, ValueError):
132
+ continue
133
+
134
+ rel = full.relative_to(root_path).as_posix()
135
+ total, code, blank, comment = _count_lines(text, language)
136
+ result.files.append(
137
+ FileStat(rel, language, total, code, blank, comment, size)
138
+ )
139
+ _collect_todos(text, rel, language, result.todos)
140
+
141
+ return result
142
+
143
+
144
+ # Line-comment tokens per language, used to confine TODO scanning to comments so
145
+ # marker *words* in prose or code (e.g. "TODO tracker", a marker list) aren't
146
+ # counted as debt.
147
+ _LINE_COMMENT = {
148
+ "Python": ("#",), "Ruby": ("#",), "Shell": ("#",), "YAML": ("#",),
149
+ "PowerShell": ("#",), "R": ("#",), "TOML": ("#",), "Makefile": ("#",),
150
+ "JavaScript": ("//",), "TypeScript": ("//",), "Go": ("//",), "Rust": ("//",),
151
+ "Java": ("//",), "C": ("//",), "C++": ("//",), "C#": ("//",),
152
+ "PHP": ("//", "#"), "Swift": ("//",), "SCSS": ("//",), "Kotlin": ("//",),
153
+ "Scala": ("//",), "Dart": ("//",), "Lua": ("--",), "SQL": ("--",),
154
+ }
155
+
156
+
157
+ def _collect_todos(text: str, rel: str, language: str, out: List[Todo]) -> None:
158
+ prefixes = _LINE_COMMENT.get(language)
159
+ if not prefixes:
160
+ # No reliable line-comment syntax (markup/data/prose) — skip to avoid
161
+ # false positives from documentation that merely mentions the markers.
162
+ return
163
+ for i, line in enumerate(text.splitlines(), start=1):
164
+ # Only consider the portion of the line inside a line comment.
165
+ starts = [line.find(p) for p in prefixes if line.find(p) != -1]
166
+ if not starts:
167
+ continue
168
+ comment_part = line[min(starts):]
169
+ upper = comment_part.upper()
170
+ for marker in TODO_MARKERS:
171
+ idx = upper.find(marker)
172
+ if idx == -1:
173
+ continue
174
+ # Require non-alphanumeric boundaries so "DEBUG" != "BUG" etc.
175
+ before = upper[idx - 1] if idx > 0 else " "
176
+ after_idx = idx + len(marker)
177
+ after = upper[after_idx] if after_idx < len(upper) else " "
178
+ if before.isalnum() or after.isalnum():
179
+ continue
180
+ out.append(Todo(rel, i, marker, comment_part[idx:].strip()[:120]))
181
+ break
@@ -0,0 +1,252 @@
1
+ Metadata-Version: 2.4
2
+ Name: repoglance
3
+ Version: 0.2.1
4
+ Summary: Instant, gorgeous insight into any code repository — languages, complexity hotspots, TODOs and git activity in one terminal command.
5
+ Project-URL: Homepage, https://github.com/SRJ-ai/repolens
6
+ Project-URL: Repository, https://github.com/SRJ-ai/repolens
7
+ Project-URL: Issues, https://github.com/SRJ-ai/repolens/issues
8
+ Author: repoglance contributors
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,cloc,code-analysis,complexity,developer-tools,git,loc,rich,terminal
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Quality Assurance
22
+ Requires-Python: >=3.9
23
+ Requires-Dist: click>=8.0
24
+ Requires-Dist: rich>=13.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest-cov; extra == 'dev'
27
+ Requires-Dist: pytest>=7.0; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ <div align="center">
31
+
32
+ # 🔍 repoglance
33
+
34
+ ### Instant, gorgeous insight into any code repository — in one command.
35
+
36
+ Point it at any folder. In under a second you get a beautiful terminal report:
37
+ language breakdown, complexity hotspots, TODO tracker, biggest files, and git
38
+ activity. **Zero config. Zero API keys. Zero telemetry.**
39
+
40
+ [![CI](https://github.com/SRJ-ai/repolens/actions/workflows/ci.yml/badge.svg)](https://github.com/SRJ-ai/repolens/actions/workflows/ci.yml)
41
+ [![Python](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org/)
42
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
43
+ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
44
+ [![repoglance](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/SRJ-ai/repolens/main/.repolens-badge.json)](https://github.com/SRJ-ai/repolens)
45
+
46
+ </div>
47
+
48
+ ---
49
+
50
+ ## Why repoglance?
51
+
52
+ You clone an unfamiliar repo. What *is* this thing? How big? What's the messy
53
+ part? Where's the unfinished work? `cloc` gives you a wall of numbers. `tokei`
54
+ is fast but bare. `repoglance` answers the human questions in one glance:
55
+
56
+ ```bash
57
+ repoglance .
58
+ ```
59
+
60
+ <div align="center">
61
+
62
+ ![repoglance demo](assets/demo.svg)
63
+
64
+ </div>
65
+
66
+ ## Seen in the wild
67
+
68
+ repoglance run against well-known projects (click to view the full report):
69
+
70
+ | Project | Files | Lines of code | Health |
71
+ |---|--:|--:|:--:|
72
+ | [flask](assets/showcase/flask.svg) | 200 | 25,030 | D (64) |
73
+ | [httpie](assets/showcase/httpie.svg) | 217 | 19,604 | D (62) |
74
+ | [requests](assets/showcase/requests.svg) | 71 | 13,215 | D (69) |
75
+
76
+ <div align="center">
77
+
78
+ [![flask report](assets/showcase/flask.svg)](assets/showcase/flask.svg)
79
+
80
+ </div>
81
+
82
+ ## Install
83
+
84
+ ```bash
85
+ pip install repoglance
86
+ ```
87
+
88
+ Or run without installing:
89
+
90
+ ```bash
91
+ pipx run repoglance .
92
+ ```
93
+
94
+ ## Usage
95
+
96
+ ```bash
97
+ repoglance # analyze current directory
98
+ repoglance path/to/repo # analyze another repo
99
+ repoglance --json # machine-readable output for scripts / CI
100
+ repoglance --svg report.svg # export a vector report
101
+ repoglance --html report.html # export a browser report
102
+ repoglance --badge badge.svg # export an embeddable badge
103
+ repoglance --ci --max-complexity 25 # fail CI on hotspots
104
+ repoglance --no-git # skip git history
105
+ repoglance --ignore dist --ignore fixtures
106
+ ```
107
+
108
+ ### JSON output
109
+
110
+ Pipe structured data anywhere — dashboards, CI gates, badges:
111
+
112
+ ```bash
113
+ repoglance --json | jq '.languages.Python.code'
114
+ ```
115
+
116
+ ## What it measures
117
+
118
+ | Section | What you get |
119
+ |---|---|
120
+ | **Languages** | Lines of code per language, ranked, with % bars |
121
+ | **Complexity hotspots** | Per-function cyclomatic complexity (Python via AST; heuristic for other langs) |
122
+ | **TODO tracker** | Every `TODO` / `FIXME` / `HACK` / `XXX` / `BUG` with file:line |
123
+ | **Biggest files** | The files most likely to need splitting |
124
+ | **Git activity** | Top authors, most-churned files, active days, project lifespan |
125
+
126
+ Binary files, `node_modules`, `.venv`, build dirs and friends are skipped
127
+ automatically.
128
+
129
+ ## More than a counter
130
+
131
+ `repoglance` isn't just another `cloc`. Tools like `tokei`, `cloc` and `scc`
132
+ answer *"how many lines?"*. repoglance answers *"what should I look at?"* — and
133
+ gives you artifacts you can put in a PR or a README.
134
+
135
+ | | repoglance | tokei | scc | cloc |
136
+ |---|:---:|:---:|:---:|:---:|
137
+ | Lines-of-code by language | ✅ | ✅ | ✅ | ✅ |
138
+ | Complexity hotspots (per function) | ✅ | ❌ | ⚠️ file-level | ❌ |
139
+ | TODO / FIXME tracker | ✅ | ❌ | ❌ | ❌ |
140
+ | Git activity (authors, churn) | ✅ | ❌ | ❌ | ❌ |
141
+ | JSON output | ✅ | ✅ | ✅ | ✅ |
142
+ | **HTML / SVG report export** | ✅ | ❌ | ❌ | ❌ |
143
+ | **Embeddable repo badge** | ✅ | ❌ | ❌ | ❌ |
144
+ | **CI gate (`--max-complexity`)** | ✅ | ❌ | ❌ | ❌ |
145
+ | Zero install deps beyond one `pip` | ✅ | (binary) | (binary) | (perl) |
146
+
147
+ ## Share it: badges & reports
148
+
149
+ Generate a self-contained SVG badge — no shields.io round-trip, no tracking:
150
+
151
+ ```bash
152
+ repoglance --badge assets/badge.svg
153
+ ```
154
+
155
+ ![repoglance badge](assets/badge.svg)
156
+
157
+ Export the full report as a standalone file to drop in a PR or wiki:
158
+
159
+ ```bash
160
+ repoglance --svg report.svg # vector, pixel-perfect
161
+ repoglance --html report.html # opens in any browser
162
+ ```
163
+
164
+ ## Guard your codebase in CI
165
+
166
+ Fail the build when complexity or TODO debt crosses a line:
167
+
168
+ ```bash
169
+ repoglance --ci --max-complexity 25 --max-todos 100
170
+ ```
171
+
172
+ ```yaml
173
+ # .github/workflows/quality.yml
174
+ - run: pip install repoglance
175
+ - run: repoglance --ci --max-complexity 25
176
+ ```
177
+
178
+ Exit code `0` = clean, `2` = a threshold was exceeded.
179
+
180
+ ## Integrations
181
+
182
+ ### GitHub Action — comment on every PR
183
+
184
+ Drop repoglance into any repo. It posts a sticky report comment on pull requests
185
+ and can gate the build:
186
+
187
+ ```yaml
188
+ # .github/workflows/repoglance.yml
189
+ name: repoglance
190
+ on: [pull_request]
191
+ permissions:
192
+ contents: read
193
+ pull-requests: write
194
+ jobs:
195
+ analyze:
196
+ runs-on: ubuntu-latest
197
+ steps:
198
+ - uses: actions/checkout@v4
199
+ - uses: SRJ-ai/repolens@main
200
+ with:
201
+ fail-under: "70" # optional health gate
202
+ max-complexity: "25" # optional complexity gate
203
+ ```
204
+
205
+ The report also lands in the workflow's **job summary** every run.
206
+
207
+ ### pre-commit hook
208
+
209
+ ```yaml
210
+ # .pre-commit-config.yaml
211
+ repos:
212
+ - repo: https://github.com/SRJ-ai/repolens
213
+ rev: v0.2.0
214
+ hooks:
215
+ - id: repoglance
216
+ args: ["--ci", "--fail-under", "70"]
217
+ ```
218
+
219
+ ### Self-updating badge
220
+
221
+ Commit a shields endpoint file and point a dynamic badge at it — the badge
222
+ refreshes itself, no service to run:
223
+
224
+ ```bash
225
+ repoglance --badge-json .repolens-badge.json # commit this file
226
+ ```
227
+
228
+ ```markdown
229
+ ![repoglance](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/SRJ-ai/repolens/main/.repolens-badge.json)
230
+ ```
231
+
232
+ ### Markdown anywhere
233
+
234
+ ```bash
235
+ repoglance --md # paste into a PR, wiki, or Slack
236
+ ```
237
+
238
+ ## Design goals
239
+
240
+ - **Fast** — a single `os.walk`, no external services.
241
+ - **Honest** — no network, no telemetry, no surprise writes.
242
+ - **Pretty** — powered by [rich](https://github.com/Textualize/rich).
243
+ - **Scriptable** — everything the report shows is available as `--json`.
244
+
245
+ ## Contributing
246
+
247
+ Adding a language is a one-line change in `languages.py`. PRs welcome — see
248
+ [CONTRIBUTING.md](CONTRIBUTING.md).
249
+
250
+ ## License
251
+
252
+ [MIT](LICENSE) © repoglance contributors
@@ -0,0 +1,15 @@
1
+ repoglance/__init__.py,sha256=XvcsFZzJQdOXcae7rndmtG9W5o9dGKYNLciAB3iqaKc,96
2
+ repoglance/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
+ repoglance/badge.py,sha256=Zza5CIx6B36S2xfmtXzXCQwsbI6I03mpvtKUl7aBL3E,3898
4
+ repoglance/cli.py,sha256=gGyEaVd8Jtt54o3tM6QRO0BCIZGvpCC3ZV_DjtPy5P0,4435
5
+ repoglance/complexity.py,sha256=2ELGC9jrvg6SOlnd0it_ju9TvPR8CU0KDE0uS5QpbeE,3146
6
+ repoglance/gitinfo.py,sha256=BtINheKz9cv3nBss_lHMiReAMx_MYbLf5Lb1LkMzSlc,2468
7
+ repoglance/languages.py,sha256=21oqrWfsm5Cbi4sEOsPTuRsc0lkBBYC5ehEWB2W6OoY,2548
8
+ repoglance/metrics.py,sha256=R2r6ST6PdLuf6ooDhjvRILP05NhI8JZp2KBLqx352oA,2362
9
+ repoglance/report.py,sha256=LMYsaKoXAq8HEdVOKUPWjRCQ61JpEvXrd2njy_Q9CTw,12170
10
+ repoglance/scanner.py,sha256=weLVSBSw02DtSYLQgOSRonyKm3ljrHWwKDGCfowJdE4,6507
11
+ repoglance-0.2.1.dist-info/METADATA,sha256=NahPRUBIpWB9A5RqGQA1XDyAaL0KxCOyb9j4xhCA0yA,7633
12
+ repoglance-0.2.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
13
+ repoglance-0.2.1.dist-info/entry_points.txt,sha256=n0nnMSVxpjmtBWc5pwLtBfYMkf0Yai0M0Hue84ahM2g,51
14
+ repoglance-0.2.1.dist-info/licenses/LICENSE,sha256=6AO03ephZMWB9cgy9iT-hyJyPEZkuWNPhQbEs03O-eU,1080
15
+ repoglance-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ repoglance = repoglance.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 repoglance contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.