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 +3 -0
- codepulse/__main__.py +3 -0
- codepulse/analyzers.py +468 -0
- codepulse/charts.py +126 -0
- codepulse/cli.py +250 -0
- codepulse/complexity.py +39 -0
- codepulse/git_data.py +121 -0
- codepulse/report.py +275 -0
- codepulse_cli-0.1.0.dist-info/METADATA +227 -0
- codepulse_cli-0.1.0.dist-info/RECORD +13 -0
- codepulse_cli-0.1.0.dist-info/WHEEL +4 -0
- codepulse_cli-0.1.0.dist-info/entry_points.txt +2 -0
- codepulse_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
codepulse/__init__.py
ADDED
codepulse/__main__.py
ADDED
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
|
+
"""
|