codepulse-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codepulse/cli.py ADDED
@@ -0,0 +1,250 @@
1
+ """Command-line entry point for CodePulse.
2
+
3
+ Two modes:
4
+
5
+ * **Report** (default) — full HTML health dashboard for a repo.
6
+ * **Risk gate** (``--changed <ref>``) — score only the files changed since a
7
+ ref, emit JSON or text, and set an exit code so CI can warn or block.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import datetime
14
+ import json
15
+ import os
16
+ import sys
17
+ import webbrowser
18
+ from urllib.parse import quote
19
+
20
+ from . import analyzers
21
+ from .git_data import get_changed_files, get_commits, is_git_repo
22
+ from .report import render
23
+
24
+ _LEVELS = {"LOW": 0, "MEDIUM": 1, "HIGH": 2}
25
+
26
+
27
+ def build_parser() -> argparse.ArgumentParser:
28
+ parser = argparse.ArgumentParser(
29
+ prog="codepulse",
30
+ description="Reveal a codebase's health from its git history.",
31
+ )
32
+ parser.add_argument("repo", nargs="?", default=".", help="path to a git repository (default: current dir)")
33
+ parser.add_argument("-o", "--output", default="codepulse-report.html", help="HTML report output path")
34
+ parser.add_argument("--since", help='only commits after this date, e.g. "6 months ago"')
35
+ parser.add_argument("--max-count", type=int, help="limit to the N most recent commits")
36
+ parser.add_argument("--top", type=int, default=20, help="rows per table (default: 20)")
37
+ parser.add_argument("--open", action="store_true", help="open the HTML report in a browser when done")
38
+ parser.add_argument("--ignore", action="append", metavar="GLOB", default=[],
39
+ help="exclude paths matching this glob (repeatable), e.g. '*.sql'")
40
+ parser.add_argument("--no-default-ignore", action="store_true",
41
+ help="don't exclude the built-in generated/data file patterns")
42
+ parser.add_argument("--who", metavar="PATH",
43
+ help="show who knows a file best (from git history) and exit")
44
+ parser.add_argument("--badge", action="store_true",
45
+ help="print a shields.io health-badge snippet and exit")
46
+
47
+ gate = parser.add_argument_group("risk gate (CI)")
48
+ gate.add_argument("--changed", metavar="REF",
49
+ help='score files changed since REF (e.g. "origin/main" or "HEAD~10..HEAD")')
50
+ gate.add_argument("--format", choices=["text", "json", "markdown"], default="text",
51
+ help="risk-gate output format (default: text)")
52
+ gate.add_argument("--fail-on", choices=["LOW", "MEDIUM", "HIGH"],
53
+ help="exit with code 1 if release risk reaches this level")
54
+ return parser
55
+
56
+
57
+ def _ignore_patterns(args: argparse.Namespace) -> list[str]:
58
+ base = [] if args.no_default_ignore else list(analyzers.DEFAULT_IGNORE)
59
+ return base + list(args.ignore)
60
+
61
+
62
+ def _run_report(repo: str, args: argparse.Namespace) -> int:
63
+ print(f"Reading git history from {repo} ...")
64
+ commits = get_commits(repo, since=args.since, max_count=args.max_count)
65
+ if not commits:
66
+ print("error: no commits found for the given filters.", file=sys.stderr)
67
+ return 1
68
+ commits = analyzers.filter_commits(commits, _ignore_patterns(args))
69
+ print(f" {len(commits)} commits")
70
+
71
+ stats = analyzers.build_file_stats(commits, repo)
72
+ summary = analyzers.summarize(commits, stats)
73
+ hot = analyzers.hotspots(stats, limit=args.top)
74
+ hotspot_paths = {h.path for h in analyzers.hotspots(stats, limit=15)}
75
+ html = render(
76
+ repo_name=os.path.basename(repo.rstrip("/\\")) or repo,
77
+ summary=summary,
78
+ hotspots=hot,
79
+ bus_risks=analyzers.bus_factor_risks(stats, limit=args.top),
80
+ coupling=analyzers.coupling(commits, stats, limit=args.top),
81
+ trend=analyzers.monthly_trend(commits, hotspot_paths),
82
+ stale=analyzers.stale_risks(stats, summary.last_timestamp, limit=args.top),
83
+ )
84
+
85
+ out = os.path.abspath(args.output)
86
+ with open(out, "w", encoding="utf-8") as fh:
87
+ fh.write(html)
88
+ print(f"\nReport written to {out}")
89
+ if args.open:
90
+ webbrowser.open(f"file://{out}")
91
+ return 0
92
+
93
+
94
+ def _run_gate(repo: str, args: argparse.Namespace) -> int:
95
+ commits = get_commits(repo, since=args.since, max_count=args.max_count)
96
+ if not commits:
97
+ print("error: no commits found to build a risk map.", file=sys.stderr)
98
+ return 1
99
+ patterns = _ignore_patterns(args)
100
+ commits = analyzers.filter_commits(commits, patterns)
101
+ stats = analyzers.build_file_stats(commits, repo)
102
+ changed = [p for p in get_changed_files(repo, args.changed) if not analyzers.is_ignored(p, patterns)]
103
+ risk = analyzers.release_risk(changed, stats)
104
+ known_count, reviewers = analyzers.suggested_reviewers(changed, stats)
105
+
106
+ def _reviewer_label(r):
107
+ return f"{r.name} ({r.files_known}/{known_count} fichiers)"
108
+
109
+ icons = {"HIGH": "🔴", "MEDIUM": "🟠", "LOW": "🟢"}
110
+
111
+ if args.format == "json":
112
+ payload = {
113
+ "level": risk.level,
114
+ "score": risk.score,
115
+ "high_risk_count": risk.high_risk_count,
116
+ "changed_files": len(risk.files),
117
+ "files": [
118
+ {"path": f.path, "risk": f.risk, "commits": f.commits, "loc": f.loc, "reasons": f.reasons}
119
+ for f in risk.files
120
+ ],
121
+ "suggested_reviewers": [
122
+ {"name": r.name, "files_known": r.files_known, "commits": r.commits} for r in reviewers
123
+ ],
124
+ }
125
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
126
+ elif args.format == "markdown":
127
+ # A sticky PR comment. The marker lets a CI step find and update it.
128
+ lines = [
129
+ "<!-- codepulse -->",
130
+ f"## 🩺 CodePulse — Risque : {icons[risk.level]} **{risk.level}** ({risk.score}/100)",
131
+ "",
132
+ f"**{len(risk.files)} fichiers modifiés · {risk.high_risk_count} à risque élevé**",
133
+ "",
134
+ ]
135
+ scored = [f for f in risk.files if f.risk > 0]
136
+ if scored:
137
+ lines += ["| Risque | Fichier | Pourquoi |", "|---:|---|---|"]
138
+ for f in scored[:12]:
139
+ lines.append(f"| {f.risk} | `{f.path}` | {', '.join(f.reasons)} |")
140
+ else:
141
+ lines.append("_Aucun fichier à risque touché._ ✅")
142
+ if reviewers:
143
+ lines += ["", "**👥 Relecteurs suggérés** (d'après l'historique) : "
144
+ + ", ".join(_reviewer_label(r) for r in reviewers)]
145
+ lines += [
146
+ "",
147
+ "<sub>Score basé sur l'historique du dépôt. Risque élevé = touche un hotspot connu ou un fichier à auteur unique.</sub>",
148
+ ]
149
+ print("\n".join(lines))
150
+ else:
151
+ print(f"{icons[risk.level]} Release risk: {risk.level} ({risk.score}/100)")
152
+ print(f" {len(risk.files)} changed files · {risk.high_risk_count} high-risk")
153
+ for f in risk.files:
154
+ if f.risk > 0:
155
+ print(f" [{f.risk:3}] {f.path} ({', '.join(f.reasons)})")
156
+ if reviewers:
157
+ print(" 👥 Relecteurs suggérés: " + ", ".join(_reviewer_label(r) for r in reviewers))
158
+
159
+ if args.fail_on and _LEVELS[risk.level] >= _LEVELS[args.fail_on]:
160
+ print(f"\nRisk level {risk.level} >= threshold {args.fail_on} — failing.", file=sys.stderr)
161
+ return 1
162
+ return 0
163
+
164
+
165
+ def _load_stats(repo: str, args: argparse.Namespace):
166
+ commits = get_commits(repo, since=args.since, max_count=args.max_count)
167
+ if not commits:
168
+ return None, None
169
+ commits = analyzers.filter_commits(commits, _ignore_patterns(args))
170
+ return commits, analyzers.build_file_stats(commits, repo)
171
+
172
+
173
+ def _run_who(repo: str, args: argparse.Namespace) -> int:
174
+ commits, stats = _load_stats(repo, args)
175
+ if not commits:
176
+ print("error: no commits found.", file=sys.stderr)
177
+ return 1
178
+ matches = analyzers.find_files(stats, args.who)
179
+ if not matches:
180
+ print(f"Aucun fichier suivi ne correspond à '{args.who}'.", file=sys.stderr)
181
+ return 1
182
+ if len(matches) > 1:
183
+ print(f"Plusieurs fichiers correspondent à '{args.who}' — précise le chemin :")
184
+ for m in matches[:20]:
185
+ print(f" {m}")
186
+ return 1
187
+
188
+ s = stats[matches[0]]
189
+ total = sum(s.authors.values())
190
+ last = datetime.datetime.fromtimestamp(s.last_timestamp).strftime("%Y-%m-%d")
191
+ print(f"{s.path} — {s.commits} commits, {s.num_authors} auteur(s), dernier changement {last}")
192
+ for author, n in s.authors.most_common(8):
193
+ print(f" {round(n / total * 100):>3}% {n:>3} commits {author}")
194
+ if s.num_authors == 1:
195
+ print(" ⚠️ Un seul auteur — risque de bus factor.")
196
+ return 0
197
+
198
+
199
+ def _run_badge(repo: str, args: argparse.Namespace) -> int:
200
+ commits, stats = _load_stats(repo, args)
201
+ if not commits:
202
+ print("error: no commits found.", file=sys.stderr)
203
+ return 1
204
+ summary = analyzers.summarize(commits, stats)
205
+ hotspot_paths = {h.path for h in analyzers.hotspots(stats, limit=15)}
206
+ trend = analyzers.monthly_trend(commits, hotspot_paths)
207
+ health = analyzers.health_grade(stats, summary, trend)
208
+
209
+ message = f"{health.grade} ({health.score})"
210
+ static = f"https://img.shields.io/badge/{quote('code health')}-{quote(message)}-{health.color}"
211
+ print(f"Grade: {health.grade} ({health.score}/100)")
212
+ for r in health.reasons:
213
+ print(f" {r}")
214
+ print("\nMarkdown (copie dans ton README) :")
215
+ print(f"![code health]({static})")
216
+ print("\nJSON (endpoint shields.io, si tu héberges ce fichier) :")
217
+ print(json.dumps({"schemaVersion": 1, "label": "code health", "message": message, "color": health.color},
218
+ ensure_ascii=False))
219
+ return 0
220
+
221
+
222
+ def _force_utf8() -> None:
223
+ # Windows consoles default to cp1252 and choke on emoji/box-drawing output.
224
+ for stream in (sys.stdout, sys.stderr):
225
+ try:
226
+ stream.reconfigure(encoding="utf-8", errors="replace")
227
+ except (AttributeError, ValueError):
228
+ pass
229
+
230
+
231
+ def main(argv: list[str] | None = None) -> int:
232
+ _force_utf8()
233
+ args = build_parser().parse_args(argv)
234
+ repo = os.path.abspath(args.repo)
235
+
236
+ if not is_git_repo(repo):
237
+ print(f"error: '{repo}' is not a git repository.", file=sys.stderr)
238
+ return 2
239
+
240
+ if args.who:
241
+ return _run_who(repo, args)
242
+ if args.badge:
243
+ return _run_badge(repo, args)
244
+ if args.changed:
245
+ return _run_gate(repo, args)
246
+ return _run_report(repo, args)
247
+
248
+
249
+ if __name__ == "__main__":
250
+ raise SystemExit(main())
@@ -0,0 +1,39 @@
1
+ """Language-agnostic complexity estimate.
2
+
3
+ A proper per-language parser is overkill here; what correlates with bug risk is
4
+ the number of *decision points* in a file (McCabe's cyclomatic complexity is
5
+ essentially ``1 + decisions``). We approximate it by counting branch keywords
6
+ and boolean operators that exist across almost every language.
7
+
8
+ It is a proxy, not a compiler: it may miscount tokens inside strings or
9
+ comments. That's fine — it's applied consistently to every file, so the
10
+ *ranking* it produces is what matters, and it beats raw line count handily.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ # Branch keywords shared across C/Java/JS/Python/Rust/Go/Ruby/... plus the
18
+ # boolean operators that add a path. Deliberately excludes the lone "?" (Rust's
19
+ # error-propagation operator would wildly inflate the count) and "=>" (arrow
20
+ # functions in JS are not branches).
21
+ _DECISION = re.compile(
22
+ r"\b(if|elif|else\s+if|for|foreach|while|case|when|match|switch|catch|rescue|except)\b"
23
+ r"|&&|\|\|",
24
+ )
25
+
26
+
27
+ def measure(data: bytes) -> tuple[int, int]:
28
+ """Return (lines, complexity) for a file's raw bytes.
29
+
30
+ Binary files get complexity == lines (a neutral value) so they never look
31
+ artificially hot or cold.
32
+ """
33
+ if not data:
34
+ return 0, 1
35
+ lines = data.count(b"\n") + (0 if data.endswith(b"\n") else 1)
36
+ if b"\x00" in data[:4096]: # looks binary — has no meaningful complexity
37
+ return lines, 1
38
+ text = data.decode("utf-8", "ignore")
39
+ return lines, 1 + len(_DECISION.findall(text))
codepulse/git_data.py ADDED
@@ -0,0 +1,121 @@
1
+ """Extract commit history from a git repository.
2
+
3
+ This module shells out to ``git log`` and parses its output into plain
4
+ dataclasses. It has no third-party dependencies on purpose: anyone with git
5
+ and Python can run CodePulse without installing anything.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ from dataclasses import dataclass, field
12
+
13
+ # Delimiters chosen to be extremely unlikely to appear in commit metadata.
14
+ _UNIT = "\x1f" # separates fields inside a commit header line
15
+ _REC = "--CP--" # marks the start of a new commit record
16
+
17
+
18
+ @dataclass
19
+ class FileChange:
20
+ """A single file touched by a commit."""
21
+
22
+ path: str
23
+ added: int
24
+ deleted: int
25
+
26
+
27
+ @dataclass
28
+ class Commit:
29
+ """One commit and the files it changed."""
30
+
31
+ hash: str
32
+ timestamp: int # unix seconds (author date)
33
+ author: str
34
+ files: list[FileChange] = field(default_factory=list)
35
+
36
+
37
+ def _run_git(repo: str, args: list[str]) -> str:
38
+ result = subprocess.run(
39
+ ["git", "-C", repo, *args],
40
+ capture_output=True,
41
+ text=True,
42
+ encoding="utf-8",
43
+ errors="replace",
44
+ )
45
+ if result.returncode != 0:
46
+ raise RuntimeError(f"git {' '.join(args)} failed: {result.stderr.strip()}")
47
+ return result.stdout
48
+
49
+
50
+ def is_git_repo(repo: str) -> bool:
51
+ try:
52
+ return _run_git(repo, ["rev-parse", "--is-inside-work-tree"]).strip() == "true"
53
+ except Exception:
54
+ return False
55
+
56
+
57
+ def normalize_path(path: str) -> str:
58
+ """Resolve git rename notation to the current path.
59
+
60
+ git numstat can emit renames as ``old => new`` or the compact
61
+ ``dir/{old => new}/file`` form. We keep the *new* path so history
62
+ accumulates onto the file as it exists today.
63
+ """
64
+ if "=>" in path:
65
+ if "{" in path and "}" in path:
66
+ prefix, rest = path.split("{", 1)
67
+ inner, suffix = rest.split("}", 1)
68
+ new = inner.split("=>", 1)[1].strip()
69
+ path = f"{prefix}{new}{suffix}".replace("//", "/")
70
+ else:
71
+ path = path.split("=>", 1)[1].strip()
72
+ return path.strip()
73
+
74
+
75
+ def get_changed_files(repo: str, ref_range: str) -> list[str]:
76
+ """Return files changed for a diff spec.
77
+
78
+ Accepts either a range ("main..HEAD", "main...HEAD") or a single ref
79
+ ("origin/main", "HEAD~5"). A single ref is compared against the current
80
+ working tree, so uncommitted changes count too — which is what a CI job or
81
+ a pre-commit check usually wants.
82
+ """
83
+ out = _run_git(repo, ["diff", "--name-only", ref_range])
84
+ return [normalize_path(line) for line in out.splitlines() if line.strip()]
85
+
86
+
87
+ def get_commits(repo: str, since: str | None = None, max_count: int | None = None) -> list[Commit]:
88
+ """Return commits (newest first), excluding merges.
89
+
90
+ ``since`` is passed straight to ``git --since`` (e.g. "6 months ago").
91
+ """
92
+ fmt = f"{_REC}%H{_UNIT}%at{_UNIT}%aN"
93
+ args = ["log", "--no-merges", "--numstat", f"--pretty=format:{fmt}"]
94
+ if since:
95
+ args.append(f"--since={since}")
96
+ if max_count:
97
+ args.append(f"--max-count={max_count}")
98
+
99
+ out = _run_git(repo, args)
100
+ commits: list[Commit] = []
101
+ current: Commit | None = None
102
+
103
+ for line in out.splitlines():
104
+ if line.startswith(_REC):
105
+ parts = line[len(_REC):].split(_UNIT)
106
+ if len(parts) == 3:
107
+ current = Commit(hash=parts[0], timestamp=int(parts[1]), author=parts[2])
108
+ commits.append(current)
109
+ continue
110
+ if not line.strip() or current is None:
111
+ continue
112
+ cols = line.split("\t")
113
+ if len(cols) != 3:
114
+ continue
115
+ added_s, deleted_s, path = cols
116
+ # Binary files report "-" instead of a count.
117
+ added = int(added_s) if added_s.isdigit() else 0
118
+ deleted = int(deleted_s) if deleted_s.isdigit() else 0
119
+ current.files.append(FileChange(path=normalize_path(path), added=added, deleted=deleted))
120
+
121
+ return commits
codepulse/report.py ADDED
@@ -0,0 +1,275 @@
1
+ """Render analysis results as a single self-contained HTML dashboard.
2
+
3
+ No build step, no CDN: the CSS is inlined so the report opens anywhere and
4
+ survives being emailed around. Colours adapt to light/dark via tokens.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime as _dt
10
+ import html
11
+
12
+ from . import charts
13
+ from .analyzers import CouplingPair, FileStat, StaleFile, Summary, Trend
14
+
15
+
16
+ def _esc(value: object) -> str:
17
+ return html.escape(str(value), quote=True)
18
+
19
+
20
+ def _date(ts: int) -> str:
21
+ if not ts:
22
+ return "—"
23
+ return _dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
24
+
25
+
26
+ def _bar(value: float, maximum: float) -> str:
27
+ pct = 0 if maximum <= 0 else max(2, round(value / maximum * 100))
28
+ return f'<span class="bar" style="width:{pct}%"></span>'
29
+
30
+
31
+ def _risk_badge(share: float) -> str:
32
+ if share >= 0.9:
33
+ cls, label = "high", "élevé"
34
+ elif share >= 0.7:
35
+ cls, label = "mid", "moyen"
36
+ else:
37
+ cls, label = "low", "faible"
38
+ return f'<span class="badge {cls}">{label}</span>'
39
+
40
+
41
+ def _trend_section(trend: Trend) -> str:
42
+ if not trend or not trend.buckets:
43
+ return ""
44
+ commits = [(b.label, b.commits) for b in trend.buckets]
45
+ size = [(b.label, b.cum_net) for b in trend.buckets]
46
+ pressure = [(b.label, b.hotspot_pct) for b in trend.buckets]
47
+ grid = (
48
+ charts.bars(f"Commits par {trend.granularity}", commits, "accent", charts.fmt_int)
49
+ + charts.line("Taille du code (lignes cumulées)", size, "accent", charts.fmt_int)
50
+ + charts.line("Pression hotspots (% commits)", pressure, "hot", charts.fmt_pct)
51
+ )
52
+ return f"""<section>
53
+ <h2>📈 Tendance</h2>
54
+ <p class="hint">L'évolution dans le temps — l'activité, la croissance du code, et surtout si les changements se concentrent sur les fichiers à risque.</p>
55
+ <p class="verdict">{_esc(trend.verdict)}</p>
56
+ <div class="trend-grid">{grid}</div>
57
+ </section>"""
58
+
59
+
60
+ def _stale_section(stale: list[StaleFile]) -> str:
61
+ if not stale:
62
+ return ""
63
+ rows = "\n".join(
64
+ f"""<tr>
65
+ <td class="path">{_esc(s.path)}</td>
66
+ <td class="num">{s.complexity:,}</td>
67
+ <td class="num">{s.days_stale} j</td>
68
+ <td class="owner">{_esc(s.main_owner[0] or '—')}</td>
69
+ </tr>"""
70
+ for s in stale
71
+ )
72
+ return f"""<section>
73
+ <h2>🕸️ Code orphelin</h2>
74
+ <p class="hint">Fichiers complexes que plus personne n'a touchés depuis longtemps — de la logique qui vieillit et que plus grand monde ne comprend.</p>
75
+ <table>
76
+ <thead><tr><th>Fichier</th><th class="num">Complexité</th><th class="num">Sans changement</th><th>Dernier auteur</th></tr></thead>
77
+ <tbody>{rows}</tbody>
78
+ </table>
79
+ </section>"""
80
+
81
+
82
+ def render(
83
+ repo_name: str,
84
+ summary: Summary,
85
+ hotspots: list[FileStat],
86
+ bus_risks: list[FileStat],
87
+ coupling: list[CouplingPair],
88
+ trend: Trend | None = None,
89
+ stale: list[StaleFile] | None = None,
90
+ ) -> str:
91
+ generated = _dt.datetime.now().strftime("%Y-%m-%d %H:%M")
92
+ span = f"{_date(summary.first_timestamp)} → {_date(summary.last_timestamp)}"
93
+
94
+ max_hot = max((h.hotspot_score for h in hotspots), default=1)
95
+ hot_rows = "\n".join(
96
+ f"""<tr>
97
+ <td class="rank">{i}</td>
98
+ <td class="path">{_esc(h.path)}</td>
99
+ <td class="num">{h.commits}</td>
100
+ <td class="num">{h.complexity:,}</td>
101
+ <td class="score">{_bar(h.hotspot_score, max_hot)}<span class="score-val">{h.hotspot_score:,}</span></td>
102
+ </tr>"""
103
+ for i, h in enumerate(hotspots, 1)
104
+ ) or '<tr><td colspan="5" class="empty">Aucun hotspot détecté.</td></tr>'
105
+
106
+ bus_rows = "\n".join(
107
+ f"""<tr>
108
+ <td class="path">{_esc(s.path)}</td>
109
+ <td class="owner">{_esc(s.main_owner[0] or '—')}</td>
110
+ <td class="num">{s.commits}</td>
111
+ <td>{_risk_badge(s.main_owner[1])}</td>
112
+ </tr>"""
113
+ for s in bus_risks
114
+ ) or '<tr><td colspan="4" class="empty">Aucun risque de bus factor. 🎉</td></tr>'
115
+
116
+ coupling_rows = "\n".join(
117
+ f"""<tr>
118
+ <td class="path">{_esc(p.a)}</td>
119
+ <td class="path">{_esc(p.b)}</td>
120
+ <td class="num">{p.shared}</td>
121
+ <td class="num">{round(p.degree * 100)}%</td>
122
+ </tr>"""
123
+ for p in coupling
124
+ ) or '<tr><td colspan="4" class="empty">Aucun couplage significatif.</td></tr>'
125
+
126
+ max_contrib = max((n for _, n in summary.top_contributors), default=1)
127
+ contrib_rows = "\n".join(
128
+ f"""<li><span class="c-name">{_esc(name)}</span>
129
+ <span class="c-bar">{_bar(n, max_contrib)}</span>
130
+ <span class="c-num">{n}</span></li>"""
131
+ for name, n in summary.top_contributors
132
+ ) or '<li class="empty">—</li>'
133
+
134
+ return _TEMPLATE.format(
135
+ repo_name=_esc(repo_name),
136
+ generated=generated,
137
+ span=span,
138
+ chart_css=charts.CHART_CSS,
139
+ trend_section=_trend_section(trend),
140
+ stale_section=_stale_section(stale or []),
141
+ total_commits=f"{summary.total_commits:,}",
142
+ active_files=f"{summary.active_files:,}",
143
+ tracked_files=f"{summary.tracked_files:,}",
144
+ contributors=summary.contributors,
145
+ hot_rows=hot_rows,
146
+ bus_rows=bus_rows,
147
+ coupling_rows=coupling_rows,
148
+ contrib_rows=contrib_rows,
149
+ )
150
+
151
+
152
+ _TEMPLATE = """<!doctype html>
153
+ <html lang="fr">
154
+ <head>
155
+ <meta charset="utf-8">
156
+ <meta name="viewport" content="width=device-width, initial-scale=1">
157
+ <title>CodePulse — {repo_name}</title>
158
+ <style>
159
+ :root {{
160
+ --bg: #f6f7f9; --panel: #ffffff; --ink: #1a1d21; --muted: #6b7280;
161
+ --line: #e5e7eb; --accent: #2563eb; --hot: #ef4444; --hot-soft: #fee2e2;
162
+ --high: #dc2626; --mid: #d97706; --low: #16a34a;
163
+ }}
164
+ @media (prefers-color-scheme: dark) {{
165
+ :root:not([data-theme="light"]) {{
166
+ --bg: #0d1117; --panel: #161b22; --ink: #e6edf3; --muted: #8b949e;
167
+ --line: #30363d; --accent: #58a6ff; --hot: #f85149; --hot-soft: #3d1a1a;
168
+ --high: #f85149; --mid: #e3b341; --low: #3fb950;
169
+ }}
170
+ }}
171
+ :root[data-theme="dark"] {{
172
+ --bg: #0d1117; --panel: #161b22; --ink: #e6edf3; --muted: #8b949e;
173
+ --line: #30363d; --accent: #58a6ff; --hot: #f85149; --hot-soft: #3d1a1a;
174
+ --high: #f85149; --mid: #e3b341; --low: #3fb950;
175
+ }}
176
+ * {{ box-sizing: border-box; }}
177
+ body {{
178
+ margin: 0; background: var(--bg); color: var(--ink);
179
+ font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
180
+ }}
181
+ .wrap {{ max-width: 960px; margin: 0 auto; padding: 32px 16px 64px; }}
182
+ header {{ margin-bottom: 28px; }}
183
+ .logo {{ font-size: 13px; letter-spacing: .14em; text-transform: uppercase; color: var(--accent); font-weight: 700; }}
184
+ h1 {{ margin: 6px 0 4px; font-size: 26px; }}
185
+ .sub {{ color: var(--muted); font-size: 13px; }}
186
+ .cards {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 24px 0 36px; }}
187
+ @media (max-width: 640px) {{ .cards {{ grid-template-columns: repeat(2, 1fr); }} }}
188
+ .card {{ background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; }}
189
+ .card .k {{ font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; }}
190
+ .card .v {{ font-size: 26px; font-weight: 700; margin-top: 4px; }}
191
+ section {{ background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 20px; margin-bottom: 24px; }}
192
+ section h2 {{ margin: 0 0 4px; font-size: 18px; }}
193
+ section .hint {{ color: var(--muted); font-size: 13px; margin: 0 0 16px; }}
194
+ table {{ width: 100%; border-collapse: collapse; font-size: 14px; }}
195
+ th {{ text-align: left; color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; padding: 6px 8px; border-bottom: 1px solid var(--line); }}
196
+ td {{ padding: 8px; border-bottom: 1px solid var(--line); vertical-align: middle; }}
197
+ tr:last-child td {{ border-bottom: none; }}
198
+ .rank {{ color: var(--muted); width: 28px; }}
199
+ .path {{ font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 13px; word-break: break-all; }}
200
+ .num {{ text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }}
201
+ .score {{ position: relative; min-width: 160px; }}
202
+ .bar {{ display: inline-block; height: 8px; border-radius: 4px; background: var(--hot); vertical-align: middle; }}
203
+ .score-val {{ font-variant-numeric: tabular-nums; color: var(--muted); font-size: 12px; margin-left: 8px; }}
204
+ .owner {{ color: var(--ink); }}
205
+ .badge {{ display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; color: #fff; }}
206
+ .badge.high {{ background: var(--high); }}
207
+ .badge.mid {{ background: var(--mid); }}
208
+ .badge.low {{ background: var(--low); }}
209
+ .empty {{ color: var(--muted); text-align: center; padding: 20px; }}
210
+ ul.contrib {{ list-style: none; margin: 0; padding: 0; }}
211
+ ul.contrib li {{ display: grid; grid-template-columns: 180px 1fr 40px; gap: 10px; align-items: center; padding: 5px 0; }}
212
+ ul.contrib .c-name {{ font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
213
+ ul.contrib .c-bar .bar {{ background: var(--accent); }}
214
+ ul.contrib .c-num {{ text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }}
215
+ footer {{ text-align: center; color: var(--muted); font-size: 12px; margin-top: 32px; }}
216
+ footer a {{ color: var(--accent); text-decoration: none; }}
217
+ {chart_css}
218
+ </style>
219
+ </head>
220
+ <body>
221
+ <div class="wrap">
222
+ <header>
223
+ <div class="logo">🩺 CodePulse</div>
224
+ <h1>{repo_name}</h1>
225
+ <div class="sub">Analyse générée le {generated} · historique {span}</div>
226
+ </header>
227
+
228
+ <div class="cards">
229
+ <div class="card"><div class="k">Commits</div><div class="v">{total_commits}</div></div>
230
+ <div class="card"><div class="k">Fichiers actifs</div><div class="v">{active_files}</div></div>
231
+ <div class="card"><div class="k">Fichiers suivis</div><div class="v">{tracked_files}</div></div>
232
+ <div class="card"><div class="k">Contributeurs</div><div class="v">{contributors}</div></div>
233
+ </div>
234
+
235
+ {trend_section}
236
+
237
+ <section>
238
+ <h2>🔥 Hotspots</h2>
239
+ <p class="hint">Fichiers à la fois complexes (beaucoup de chemins de décision) et souvent modifiés — là où les bugs naissent et où refactorer rapporte le plus.</p>
240
+ <table>
241
+ <thead><tr><th></th><th>Fichier</th><th class="num">Commits</th><th class="num">Complexité</th><th>Score (commits × complexité)</th></tr></thead>
242
+ <tbody>{hot_rows}</tbody>
243
+ </table>
244
+ </section>
245
+
246
+ <section>
247
+ <h2>👥 Risque de bus factor</h2>
248
+ <p class="hint">Fichiers activement maintenus par une seule personne. Si elle part, ce savoir part avec elle.</p>
249
+ <table>
250
+ <thead><tr><th>Fichier</th><th>Unique auteur</th><th class="num">Commits</th><th>Risque</th></tr></thead>
251
+ <tbody>{bus_rows}</tbody>
252
+ </table>
253
+ </section>
254
+
255
+ <section>
256
+ <h2>🔗 Couplage caché</h2>
257
+ <p class="hint">Fichiers qui changent presque toujours ensemble — une dépendance que le code ne montre pas.</p>
258
+ <table>
259
+ <thead><tr><th>Fichier A</th><th>Fichier B</th><th class="num">Ensemble</th><th class="num">Degré</th></tr></thead>
260
+ <tbody>{coupling_rows}</tbody>
261
+ </table>
262
+ </section>
263
+
264
+ {stale_section}
265
+
266
+ <section>
267
+ <h2>🏆 Top contributeurs</h2>
268
+ <p class="hint">Par nombre de commits sur la période analysée.</p>
269
+ <ul class="contrib">{contrib_rows}</ul>
270
+ </section>
271
+
272
+ <footer>Généré par <a href="https://github.com/">CodePulse</a> — la santé de votre code, révélée par son historique.</footer>
273
+ </div>
274
+ </body>
275
+ </html>"""