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 +3 -0
- repoglance/__main__.py +4 -0
- repoglance/badge.py +94 -0
- repoglance/cli.py +90 -0
- repoglance/complexity.py +96 -0
- repoglance/gitinfo.py +76 -0
- repoglance/languages.py +111 -0
- repoglance/metrics.py +68 -0
- repoglance/report.py +310 -0
- repoglance/scanner.py +181 -0
- repoglance-0.2.1.dist-info/METADATA +252 -0
- repoglance-0.2.1.dist-info/RECORD +15 -0
- repoglance-0.2.1.dist-info/WHEEL +4 -0
- repoglance-0.2.1.dist-info/entry_points.txt +2 -0
- repoglance-0.2.1.dist-info/licenses/LICENSE +21 -0
repoglance/__init__.py
ADDED
repoglance/__main__.py
ADDED
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("&", "&").replace("<", "<").replace(">", ">")
|
|
37
|
+
.replace('"', """)
|
|
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()
|
repoglance/complexity.py
ADDED
|
@@ -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
|
+
)
|
repoglance/languages.py
ADDED
|
@@ -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
|
+
[](https://github.com/SRJ-ai/repolens/actions/workflows/ci.yml)
|
|
41
|
+
[](https://www.python.org/)
|
|
42
|
+
[](LICENSE)
|
|
43
|
+
[](CONTRIBUTING.md)
|
|
44
|
+
[](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
|
+

|
|
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
|
+
[](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
|
+

|
|
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
|
+

|
|
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,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.
|