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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CodePulse — reveal a codebase's health from its git history."""
2
+
3
+ __version__ = "0.1.0"
codepulse/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
codepulse/analyzers.py ADDED
@@ -0,0 +1,468 @@
1
+ """Turn raw commit history into code-health signals.
2
+
3
+ The headline idea: history reveals things static linters can't. A file that
4
+ is both *complex* and *changed constantly* is where bugs are born. A file only
5
+ one person ever touches is a staffing risk. Files that always change together
6
+ are secretly coupled.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime as _dt
12
+ import os
13
+ from collections import Counter, defaultdict
14
+ from dataclasses import dataclass, field
15
+ from fnmatch import fnmatch
16
+
17
+ from . import complexity as _complexity
18
+ from .git_data import Commit
19
+
20
+ # Files that are generated, vendored, or pure data — real churn, but not code
21
+ # whose health tells you anything. Excluded by default so they don't crowd out
22
+ # the files that actually matter.
23
+ DEFAULT_IGNORE: tuple[str, ...] = (
24
+ "*.lock", "Cargo.lock", "package-lock.json", "yarn.lock",
25
+ "pnpm-lock.yaml", "poetry.lock", "composer.lock", "Gemfile.lock",
26
+ "*.tsv", "*.csv",
27
+ "*.min.js", "*.min.css", "*.map", "*.snap",
28
+ # Binary assets: real churn, but no code health to measure.
29
+ "*.ttf", "*.otf", "*.woff", "*.woff2", "*.eot",
30
+ "*.png", "*.jpg", "*.jpeg", "*.gif", "*.ico", "*.svg", "*.webp", "*.pdf",
31
+ "*.zip", "*.gz", "*.jar", "*.mp3", "*.mp4", "*.wav",
32
+ "node_modules/*", "*/node_modules/*",
33
+ "dist/*", "*/dist/*", "build/*", "*/build/*",
34
+ "vendor/*", "*/vendor/*", "*/.next/*",
35
+ )
36
+
37
+
38
+ def is_ignored(path: str, patterns) -> bool:
39
+ """True if the path matches any glob pattern (whole path or basename)."""
40
+ base = path.rsplit("/", 1)[-1]
41
+ return any(fnmatch(path, p) or fnmatch(base, p) for p in patterns)
42
+
43
+
44
+ def filter_commits(commits: list[Commit], patterns) -> list[Commit]:
45
+ """Drop ignored files from each commit, keeping the commit itself.
46
+
47
+ The commit still counts toward totals and author activity; only its ignored
48
+ file changes are removed, so hotspots/coupling/bus-factor ignore them too.
49
+ """
50
+ if not patterns:
51
+ return commits
52
+ out: list[Commit] = []
53
+ for c in commits:
54
+ kept = [fc for fc in c.files if not is_ignored(fc.path, patterns)]
55
+ out.append(Commit(hash=c.hash, timestamp=c.timestamp, author=c.author, files=kept))
56
+ return out
57
+
58
+
59
+ @dataclass
60
+ class FileStat:
61
+ """Accumulated history for a single file path."""
62
+
63
+ path: str
64
+ commits: int = 0
65
+ authors: Counter = field(default_factory=Counter) # author -> commits
66
+ added: int = 0
67
+ deleted: int = 0
68
+ last_timestamp: int = 0
69
+ loc: int = 0 # current line count in the working tree
70
+ complexity: int = 0 # estimated decision points (see complexity.py)
71
+ exists: bool = True # still present on disk?
72
+
73
+ @property
74
+ def num_authors(self) -> int:
75
+ return len(self.authors)
76
+
77
+ @property
78
+ def main_owner(self) -> tuple[str | None, float]:
79
+ """Top contributor and their share of this file's commits."""
80
+ if not self.authors:
81
+ return None, 0.0
82
+ owner, count = self.authors.most_common(1)[0]
83
+ total = sum(self.authors.values())
84
+ return owner, count / total
85
+
86
+ @property
87
+ def hotspot_score(self) -> int:
88
+ """Change frequency x complexity. High = complex AND churned."""
89
+ return self.commits * self.complexity
90
+
91
+
92
+ def _measure_file(path: str) -> tuple[int, int]:
93
+ try:
94
+ with open(path, "rb") as fh:
95
+ return _complexity.measure(fh.read())
96
+ except Exception:
97
+ return 0, 0
98
+
99
+
100
+ def build_file_stats(commits: list[Commit], repo: str) -> dict[str, FileStat]:
101
+ stats: dict[str, FileStat] = {}
102
+ for commit in commits:
103
+ for change in commit.files:
104
+ stat = stats.get(change.path)
105
+ if stat is None:
106
+ stat = FileStat(path=change.path)
107
+ stats[change.path] = stat
108
+ stat.commits += 1
109
+ stat.authors[commit.author] += 1
110
+ stat.added += change.added
111
+ stat.deleted += change.deleted
112
+ stat.last_timestamp = max(stat.last_timestamp, commit.timestamp)
113
+
114
+ for stat in stats.values():
115
+ full = os.path.join(repo, stat.path.replace("/", os.sep))
116
+ if os.path.isfile(full):
117
+ stat.loc, stat.complexity = _measure_file(full)
118
+ stat.exists = True
119
+ else:
120
+ stat.exists = False
121
+
122
+ return stats
123
+
124
+
125
+ def hotspots(stats: dict[str, FileStat], limit: int = 20) -> list[FileStat]:
126
+ present = [s for s in stats.values() if s.exists and s.loc > 0]
127
+ present.sort(key=lambda s: s.hotspot_score, reverse=True)
128
+ return present[:limit]
129
+
130
+
131
+ def bus_factor_risks(stats: dict[str, FileStat], limit: int = 20, min_commits: int = 3) -> list[FileStat]:
132
+ """Files actively maintained by exactly one person."""
133
+ risky = [
134
+ s for s in stats.values()
135
+ if s.exists and s.commits >= min_commits and s.num_authors == 1
136
+ ]
137
+ risky.sort(key=lambda s: s.commits, reverse=True)
138
+ return risky[:limit]
139
+
140
+
141
+ @dataclass
142
+ class CouplingPair:
143
+ a: str
144
+ b: str
145
+ shared: int # commits where both changed
146
+ degree: float # shared / min(individual commit counts)
147
+
148
+
149
+ def coupling(
150
+ commits: list[Commit],
151
+ stats: dict[str, FileStat],
152
+ limit: int = 20,
153
+ min_shared: int = 3,
154
+ max_files_per_commit: int = 30,
155
+ ) -> list[CouplingPair]:
156
+ """Pairs of files that tend to change together.
157
+
158
+ Commits touching a huge number of files (bulk reformat, vendoring) are
159
+ skipped so they don't drown real coupling in noise.
160
+ """
161
+ pair_counts: Counter = Counter()
162
+ file_commits: Counter = Counter()
163
+
164
+ for commit in commits:
165
+ paths = sorted({fc.path for fc in commit.files})
166
+ if len(paths) > max_files_per_commit:
167
+ continue
168
+ for path in paths:
169
+ file_commits[path] += 1
170
+ for i in range(len(paths)):
171
+ for j in range(i + 1, len(paths)):
172
+ pair_counts[(paths[i], paths[j])] += 1
173
+
174
+ results: list[CouplingPair] = []
175
+ for (a, b), shared in pair_counts.items():
176
+ if shared < min_shared:
177
+ continue
178
+ sa, sb = stats.get(a), stats.get(b)
179
+ if not sa or not sb or not sa.exists or not sb.exists:
180
+ continue
181
+ degree = shared / min(file_commits[a], file_commits[b])
182
+ results.append(CouplingPair(a=a, b=b, shared=shared, degree=degree))
183
+
184
+ results.sort(key=lambda p: (p.degree, p.shared), reverse=True)
185
+ return results[:limit]
186
+
187
+
188
+ @dataclass
189
+ class ChangedFileRisk:
190
+ path: str
191
+ risk: int # 0-100
192
+ commits: int
193
+ loc: int
194
+ reasons: list[str]
195
+
196
+
197
+ @dataclass
198
+ class ReleaseRisk:
199
+ """Risk of shipping a specific set of changed files.
200
+
201
+ A release is treated as roughly as risky as its riskiest file, with the
202
+ count of high-risk files raising the overall level — this maps directly to
203
+ DORA's change-failure-rate concern.
204
+ """
205
+
206
+ files: list[ChangedFileRisk]
207
+ score: int # 0-100, overall
208
+ level: str # LOW | MEDIUM | HIGH
209
+ high_risk_count: int
210
+
211
+
212
+ _HIGH, _MEDIUM = 70, 40
213
+
214
+
215
+ def _level(score: int) -> str:
216
+ if score >= _HIGH:
217
+ return "HIGH"
218
+ if score >= _MEDIUM:
219
+ return "MEDIUM"
220
+ return "LOW"
221
+
222
+
223
+ def release_risk(changed: list[str], stats: dict[str, FileStat]) -> ReleaseRisk:
224
+ """Score a set of changed paths against the repo's accumulated risk map."""
225
+ max_score = max((s.hotspot_score for s in stats.values() if s.exists), default=0)
226
+
227
+ files: list[ChangedFileRisk] = []
228
+ for path in changed:
229
+ stat = stats.get(path)
230
+ if stat is None or not stat.exists:
231
+ # New or untracked file: no history to judge it by.
232
+ files.append(ChangedFileRisk(path=path, risk=0, commits=0, loc=0, reasons=["nouveau / sans historique"]))
233
+ continue
234
+
235
+ risk = round(stat.hotspot_score / max_score * 100) if max_score else 0
236
+ reasons: list[str] = []
237
+ if risk >= _HIGH:
238
+ reasons.append("hotspot majeur")
239
+ elif risk >= _MEDIUM:
240
+ reasons.append("zone active")
241
+ if stat.commits >= 3 and stat.num_authors == 1:
242
+ owner = stat.main_owner[0]
243
+ reasons.append(f"auteur unique ({owner})")
244
+ risk = min(100, risk + 10) # single-owner changes are riskier to review
245
+ files.append(ChangedFileRisk(path=path, risk=risk, commits=stat.commits, loc=stat.loc, reasons=reasons or ["—"]))
246
+
247
+ files.sort(key=lambda f: f.risk, reverse=True)
248
+ score = files[0].risk if files else 0
249
+ high = sum(1 for f in files if f.risk >= _HIGH)
250
+ # Several risky files in one release pushes the ceiling up a tier.
251
+ if high >= 2:
252
+ score = max(score, _HIGH)
253
+ return ReleaseRisk(files=files, score=score, level=_level(score), high_risk_count=high)
254
+
255
+
256
+ @dataclass
257
+ class Reviewer:
258
+ name: str
259
+ files_known: int # how many of the changed files this person has touched
260
+ commits: int # their total commits across those files
261
+
262
+
263
+ def suggested_reviewers(changed: list[str], stats: dict[str, FileStat], limit: int = 3):
264
+ """Who knows the changed code best, from git history.
265
+
266
+ Returns (known_file_count, reviewers). Ranks by how many of the changed
267
+ files a person has touched, then by their commit depth on them — exactly
268
+ what you want when picking a reviewer for a PR.
269
+ """
270
+ known = [p for p in changed if p in stats and stats[p].exists and stats[p].authors]
271
+ commits_by_author: Counter = Counter()
272
+ files_by_author: dict[str, set] = defaultdict(set)
273
+ for path in known:
274
+ for author, n in stats[path].authors.items():
275
+ commits_by_author[author] += n
276
+ files_by_author[author].add(path)
277
+
278
+ reviewers = [
279
+ Reviewer(name=a, files_known=len(files_by_author[a]), commits=commits_by_author[a])
280
+ for a in commits_by_author
281
+ ]
282
+ reviewers.sort(key=lambda r: (r.files_known, r.commits), reverse=True)
283
+ return len(known), reviewers[:limit]
284
+
285
+
286
+ @dataclass
287
+ class StaleFile:
288
+ path: str
289
+ complexity: int
290
+ days_stale: int
291
+ main_owner: tuple
292
+
293
+
294
+ def stale_risks(stats: dict[str, FileStat], now_ts: int, limit: int = 15) -> list[StaleFile]:
295
+ """Complex files nobody has touched in a long time.
296
+
297
+ Important logic that is quietly aging: still complex, but in the older half
298
+ of the repo's recency range — the kind of code that breaks when someone
299
+ finally has to change it and no one remembers how it works.
300
+ """
301
+ present = [s for s in stats.values() if s.exists and s.complexity > 1 and s.last_timestamp]
302
+ if len(present) < 4:
303
+ return []
304
+ comps = sorted(s.complexity for s in present)
305
+ median_cx = comps[len(comps) // 2]
306
+ max_days = max((now_ts - s.last_timestamp) for s in present) / 86400 or 1
307
+
308
+ out: list[StaleFile] = []
309
+ for s in present:
310
+ days = (now_ts - s.last_timestamp) / 86400
311
+ if s.complexity >= median_cx and days >= max_days * 0.5:
312
+ out.append(StaleFile(s.path, s.complexity, int(days), s.main_owner))
313
+ out.sort(key=lambda f: f.complexity * (f.days_stale + 1), reverse=True)
314
+ return out[:limit]
315
+
316
+
317
+ @dataclass
318
+ class Health:
319
+ score: int
320
+ grade: str # A..F
321
+ color: str # shields.io colour name
322
+ reasons: list[str]
323
+
324
+
325
+ def health_grade(stats: dict[str, FileStat], summary, trend) -> Health:
326
+ """A single A–F health grade, from transparent penalties."""
327
+ score = 100
328
+ reasons: list[str] = []
329
+
330
+ active = summary.active_files or 1
331
+ bus = bus_factor_risks(stats, limit=10 ** 9)
332
+ pen_bus = min(30, round(len(bus) / active * 100))
333
+ if pen_bus:
334
+ score -= pen_bus
335
+ reasons.append(f"-{pen_bus} bus factor ({len(bus)} fichiers à auteur unique)")
336
+
337
+ if trend and trend.direction == "up":
338
+ score -= 15
339
+ reasons.append("-15 pression hotspots en hausse")
340
+ elif trend and trend.direction == "down":
341
+ score = min(100, score + 5)
342
+ reasons.append("+5 pression hotspots en baisse")
343
+
344
+ score = max(0, min(100, score))
345
+ grade = ("A" if score >= 85 else "B" if score >= 70 else "C" if score >= 55
346
+ else "D" if score >= 40 else "F")
347
+ color = {"A": "brightgreen", "B": "green", "C": "yellow", "D": "orange", "F": "red"}[grade]
348
+ return Health(score=score, grade=grade, color=color, reasons=reasons)
349
+
350
+
351
+ def find_files(stats: dict[str, FileStat], query: str) -> list[str]:
352
+ """Resolve a user-typed path to tracked file paths (exact, then suffix/basename)."""
353
+ q = query.replace("\\", "/").lstrip("./").lower()
354
+ if query in stats:
355
+ return [query]
356
+ exact = [p for p in stats if p.lower() == q]
357
+ if exact:
358
+ return exact
359
+ return sorted(p for p in stats if p.lower().endswith("/" + q) or p.split("/")[-1].lower() == q)
360
+
361
+
362
+ @dataclass
363
+ class Summary:
364
+ total_commits: int
365
+ tracked_files: int
366
+ active_files: int # still present on disk
367
+ contributors: int
368
+ first_timestamp: int
369
+ last_timestamp: int
370
+ top_contributors: list[tuple[str, int]]
371
+
372
+
373
+ @dataclass
374
+ class TrendBucket:
375
+ label: str # bucket key (day, week, or month depending on span)
376
+ commits: int = 0
377
+ added: int = 0
378
+ deleted: int = 0
379
+ hotspot_commits: int = 0 # commits in this month touching a top hotspot
380
+ cum_net: int = 0 # cumulative (added - deleted) up to this month
381
+
382
+ @property
383
+ def churn(self) -> int:
384
+ return self.added + self.deleted
385
+
386
+ @property
387
+ def hotspot_pct(self) -> float:
388
+ return self.hotspot_commits / self.commits if self.commits else 0.0
389
+
390
+
391
+ @dataclass
392
+ class Trend:
393
+ buckets: list[TrendBucket]
394
+ verdict: str # human sentence: is hotspot pressure rising or falling?
395
+ direction: str # "up" | "down" | "flat"
396
+ granularity: str # "jour" | "semaine" | "mois"
397
+
398
+
399
+ def _bucket_key(span_days: float):
400
+ """Pick day/week/month buckets so short and long histories both read well."""
401
+ if span_days <= 31:
402
+ return "jour", lambda ts: _dt.datetime.fromtimestamp(ts).strftime("%m-%d")
403
+ if span_days <= 180:
404
+ return "semaine", lambda ts: _dt.datetime.fromtimestamp(ts).strftime("%Y-W%V")
405
+ return "mois", lambda ts: _dt.datetime.fromtimestamp(ts).strftime("%Y-%m")
406
+
407
+
408
+ def monthly_trend(commits: list[Commit], hotspot_paths: set[str], max_buckets: int = 24) -> Trend:
409
+ """Bucket history over time (granularity auto-picked) and track activity,
410
+ code size and hotspot pressure."""
411
+ timestamps = [c.timestamp for c in commits] or [0]
412
+ span_days = (max(timestamps) - min(timestamps)) / 86400
413
+ granularity, keyfn = _bucket_key(span_days)
414
+
415
+ buckets: dict[str, TrendBucket] = defaultdict(lambda: TrendBucket(label=""))
416
+ for c in commits:
417
+ key = keyfn(c.timestamp)
418
+ b = buckets[key]
419
+ b.label = key
420
+ b.commits += 1
421
+ touched_hot = False
422
+ for fc in c.files:
423
+ b.added += fc.added
424
+ b.deleted += fc.deleted
425
+ if fc.path in hotspot_paths:
426
+ touched_hot = True
427
+ if touched_hot:
428
+ b.hotspot_commits += 1
429
+
430
+ ordered = [buckets[k] for k in sorted(buckets)]
431
+ running = 0
432
+ for b in ordered:
433
+ running += b.added - b.deleted
434
+ b.cum_net = running
435
+ ordered = ordered[-max_buckets:]
436
+
437
+ direction, verdict = _hotspot_pressure_trend(ordered)
438
+ return Trend(buckets=ordered, verdict=verdict, direction=direction, granularity=granularity)
439
+
440
+
441
+ def _hotspot_pressure_trend(buckets: list[TrendBucket]) -> tuple[str, str]:
442
+ """Compare recent hotspot pressure to earlier, as a debt signal."""
443
+ active = [b for b in buckets if b.commits > 0]
444
+ if len(active) < 4:
445
+ return "flat", "Pas assez d'historique pour dégager une tendance."
446
+ half = len(active) // 2
447
+ early = sum(b.hotspot_pct for b in active[:half]) / half
448
+ late = sum(b.hotspot_pct for b in active[half:]) / (len(active) - half)
449
+ delta = late - early
450
+ if delta > 0.08:
451
+ return "up", f"⚠️ La pression sur les hotspots augmente ({round(early*100)}% → {round(late*100)}% des commits). La dette se concentre."
452
+ if delta < -0.08:
453
+ return "down", f"✅ La pression sur les hotspots diminue ({round(early*100)}% → {round(late*100)}% des commits). Bon signe."
454
+ return "flat", f"La pression sur les hotspots est stable (~{round(late*100)}% des commits)."
455
+
456
+
457
+ def summarize(commits: list[Commit], stats: dict[str, FileStat]) -> Summary:
458
+ author_commits: Counter = Counter(c.author for c in commits)
459
+ timestamps = [c.timestamp for c in commits] or [0]
460
+ return Summary(
461
+ total_commits=len(commits),
462
+ tracked_files=len(stats),
463
+ active_files=sum(1 for s in stats.values() if s.exists),
464
+ contributors=len(author_commits),
465
+ first_timestamp=min(timestamps),
466
+ last_timestamp=max(timestamps),
467
+ top_contributors=author_commits.most_common(8),
468
+ )
codepulse/charts.py ADDED
@@ -0,0 +1,126 @@
1
+ """Tiny dependency-free SVG charts for the trend section.
2
+
3
+ One series per chart (small multiples), never a dual axis. Marks are thin, axes
4
+ recessive, bar ends rounded, x-labels selective (first/last), and every mark
5
+ carries a native <title> tooltip. Colours come from the report's CSS tokens so
6
+ light/dark both work.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+
13
+ # viewBox geometry (unitless; the SVG scales to its container via width:100%).
14
+ _W, _H = 360, 180
15
+ _L, _R, _T, _B = 8, 10, 14, 24
16
+ _PLOT_W = _W - _L - _R
17
+ _PLOT_H = _H - _T - _B
18
+ _BASE = _T + _PLOT_H # baseline y
19
+
20
+
21
+ def _frame(title: str, body: str, ymax_label: str) -> str:
22
+ return f"""<figure class="chart">
23
+ <figcaption>{title}</figcaption>
24
+ <svg viewBox="0 0 {_W} {_H}" role="img" preserveAspectRatio="xMidYMid meet">
25
+ <line class="axis" x1="{_L}" y1="{_BASE}" x2="{_W - _R}" y2="{_BASE}"/>
26
+ <text class="ymax" x="{_L}" y="{_T - 3}">{ymax_label}</text>
27
+ {body}
28
+ </svg>
29
+ </figure>"""
30
+
31
+
32
+ def _xlabels(labels: list[str], xs: list[float]) -> str:
33
+ if not labels:
34
+ return ""
35
+ parts = [f'<text class="xlab" x="{xs[0]:.1f}" y="{_BASE + 15}" text-anchor="start">{labels[0]}</text>']
36
+ if len(labels) > 1:
37
+ parts.append(f'<text class="xlab" x="{xs[-1]:.1f}" y="{_BASE + 15}" text-anchor="end">{labels[-1]}</text>')
38
+ return "\n ".join(parts)
39
+
40
+
41
+ def bars(title: str, data: list[tuple[str, float]], cls: str, fmt: Callable[[float], str]) -> str:
42
+ if not data:
43
+ return _frame(title, '<text class="empty-svg" x="180" y="90" text-anchor="middle">pas de données</text>', "")
44
+ labels = [d[0] for d in data]
45
+ values = [d[1] for d in data]
46
+ vmax = max(values) or 1
47
+ n = len(data)
48
+ slot = _PLOT_W / n
49
+ bw = min(slot * 0.68, 26)
50
+ rects, xs = [], []
51
+ for i, (label, v) in enumerate(data):
52
+ x = _L + i * slot + (slot - bw) / 2
53
+ xs.append(x + bw / 2)
54
+ h = (v / vmax) * _PLOT_H
55
+ y = _BASE - h
56
+ r = min(3, bw / 2)
57
+ if h <= r:
58
+ path = f'M{x:.1f},{_BASE} h{bw:.1f} V{y:.1f} h{-bw:.1f} Z'
59
+ else:
60
+ path = (f'M{x:.1f},{_BASE} V{y + r:.1f} q0,{-r} {r},{-r} '
61
+ f'h{bw - 2 * r:.1f} q{r},0 {r},{r} V{_BASE} Z')
62
+ rects.append(f'<path class="mark {cls}" d="{path}"><title>{label}: {fmt(v)}</title></path>')
63
+ body = "\n ".join(rects) + "\n " + _xlabels(labels, xs)
64
+ return _frame(title, body, fmt(vmax))
65
+
66
+
67
+ def line(title: str, data: list[tuple[str, float]], cls: str, fmt: Callable[[float], str], area: bool = True) -> str:
68
+ if not data:
69
+ return _frame(title, '<text class="empty-svg" x="180" y="90" text-anchor="middle">pas de données</text>', "")
70
+ labels = [d[0] for d in data]
71
+ values = [d[1] for d in data]
72
+ vmax = max(values) or 1
73
+ vmin = min(values)
74
+ span = (vmax - vmin) or 1
75
+ n = len(data)
76
+ xs = [_L + (i / (n - 1) * _PLOT_W if n > 1 else _PLOT_W / 2) for i in range(n)]
77
+ ys = [_BASE - ((v - vmin) / span) * _PLOT_H for v in values]
78
+ pts = " ".join(f"{x:.1f},{y:.1f}" for x, y in zip(xs, ys))
79
+
80
+ body = []
81
+ if area:
82
+ body.append(f'<polygon class="area {cls}" points="{xs[0]:.1f},{_BASE} {pts} {xs[-1]:.1f},{_BASE}"/>')
83
+ body.append(f'<polyline class="line {cls}" points="{pts}"/>')
84
+ # Transparent hit targets so every point has a hover tooltip.
85
+ for (x, y), label, v in zip(zip(xs, ys), labels, values):
86
+ body.append(f'<circle class="hit" cx="{x:.1f}" cy="{y:.1f}" r="7"><title>{label}: {fmt(v)}</title></circle>')
87
+ body.append(f'<circle class="dot {cls}" cx="{xs[-1]:.1f}" cy="{ys[-1]:.1f}" r="3"/>')
88
+ body.append(_xlabels(labels, xs))
89
+ return _frame(title, "\n ".join(body), fmt(vmax))
90
+
91
+
92
+ # Formatters ---------------------------------------------------------------
93
+
94
+ def fmt_int(v: float) -> str:
95
+ return f"{int(round(v)):,}"
96
+
97
+
98
+ def fmt_pct(v: float) -> str:
99
+ return f"{round(v * 100)}%"
100
+
101
+
102
+ CHART_CSS = """
103
+ .trend-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
104
+ @media (max-width: 680px) { .trend-grid { grid-template-columns: 1fr; } }
105
+ .chart { margin: 0; }
106
+ .chart figcaption { font-size: 12px; color: var(--muted); margin-bottom: 4px; font-weight: 600; }
107
+ .chart svg { width: 100%; height: auto; display: block; }
108
+ .chart .axis { stroke: var(--line); stroke-width: 1; }
109
+ .chart .ymax { fill: var(--muted); font-size: 10px; font-variant-numeric: tabular-nums; }
110
+ .chart .xlab { fill: var(--muted); font-size: 10px; }
111
+ .chart .empty-svg { fill: var(--muted); font-size: 12px; }
112
+ .chart .mark.accent { fill: var(--accent); }
113
+ .chart .mark.hot { fill: var(--hot); }
114
+ .chart .mark { transition: opacity .1s; }
115
+ .chart .mark:hover { opacity: .75; }
116
+ .chart .line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
117
+ .chart .line.accent { stroke: var(--accent); }
118
+ .chart .line.hot { stroke: var(--hot); }
119
+ .chart .area.accent { fill: var(--accent); opacity: .12; }
120
+ .chart .area.hot { fill: var(--hot); opacity: .12; }
121
+ .chart .dot.accent { fill: var(--accent); }
122
+ .chart .dot.hot { fill: var(--hot); }
123
+ .chart .hit { fill: transparent; }
124
+ .verdict { font-size: 14px; margin: 0 0 16px; padding: 10px 14px; border-radius: 10px;
125
+ background: var(--bg); border: 1px solid var(--line); }
126
+ """