codepulse-cli 0.1.2__tar.gz → 0.1.3__tar.gz

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.
Files changed (23) hide show
  1. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/PKG-INFO +8 -16
  2. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/README.md +7 -15
  3. codepulse_cli-0.1.3/docs/demo.gif +0 -0
  4. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/pyproject.toml +1 -1
  5. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/src/codepulse/__init__.py +1 -1
  6. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/src/codepulse/analyzers.py +80 -0
  7. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/src/codepulse/cli.py +16 -4
  8. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/src/codepulse/report.py +48 -1
  9. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/tests/test_analyzers.py +15 -0
  10. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/.github/workflows/codepulse.yml +0 -0
  11. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/.github/workflows/publish.yml +0 -0
  12. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/.github/workflows/tests.yml +0 -0
  13. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/.gitignore +0 -0
  14. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/LICENSE +0 -0
  15. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/action.yml +0 -0
  16. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/demo.tape +0 -0
  17. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/src/codepulse/__main__.py +0 -0
  18. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/src/codepulse/charts.py +0 -0
  19. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/src/codepulse/complexity.py +0 -0
  20. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/src/codepulse/git_data.py +0 -0
  21. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/tests/test_complexity.py +0 -0
  22. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/tests/test_e2e.py +0 -0
  23. {codepulse_cli-0.1.2 → codepulse_cli-0.1.3}/tests/test_git_data.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codepulse-cli
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Reveal a codebase's health from its git history — hotspots, bus factor, hidden coupling, and a CI risk gate.
5
5
  Project-URL: Homepage, https://github.com/Mehouelley/codepulse
6
6
  Project-URL: Repository, https://github.com/Mehouelley/codepulse
@@ -45,31 +45,22 @@ with **zero dependencies** beyond git and Python.
45
45
 
46
46
  ## Demo
47
47
 
48
- <!-- Render docs/demo.gif from demo.tape with `vhs demo.tape`, then:
49
- ![demo](docs/demo.gif) -->
48
+ ![CodePulse demo](docs/demo.gif)
50
49
 
51
50
  ```console
52
51
  $ codepulse . --badge
53
52
  Grade: B (83/100)
54
- -2 bus factor (9 fichiers à auteur unique)
55
- -15 pression hotspots en hausse
56
- ![code health](https://img.shields.io/badge/code%20health-B%20(83)-green)
57
53
 
58
54
  $ codepulse . --changed origin/main
59
- 🔴 Release risk: HIGH (100/100)
60
- 56 changed files · 2 high-risk
61
- [100] crates/ui-egui/src/lib.rs (hotspot majeur)
62
- [ 70] crates/layout/src/para.rs (zone active, auteur unique)
63
- 👥 Relecteurs suggérés: Valentin Klein (29/37 fichiers), Dmitrii Khlapov (22/37)
55
+ Release risk: HIGH (100/100)
56
+ [100] crates/ui-egui/src/lib.rs (major hotspot)
57
+ Reviewers: Valentin Klein (29/37), Dmitrii Khlapov (22/37)
64
58
 
65
59
  $ codepulse . --who crates/ui-egui/src/canvas.rs
66
- crates/ui-egui/src/canvas.rs — 64 commits, 22 auteur(s), dernier changement 2026-10-12
67
- 45% 29 commits Valentin Klein
68
- 11% 7 commits Dmitrii Khlapov
60
+ canvas.rs — 64 commits, 22 authors — 45% Valentin Klein
69
61
  ```
70
62
 
71
- > A recordable [`demo.tape`](demo.tape) (for [VHS](https://github.com/charmbracelet/vhs))
72
- > generates an animated `docs/demo.gif` of the above.
63
+ > The GIF is generated from [`demo.tape`](demo.tape) ([VHS](https://github.com/charmbracelet/vhs)).
73
64
 
74
65
  ---
75
66
 
@@ -217,6 +208,7 @@ simple enough to run in one command.
217
208
  - [x] `--who <file>` — who knows this code best
218
209
  - [x] `--badge` — shareable A–F health badge for your README
219
210
  - [x] Orphaned-code section — complex files nobody touches anymore
211
+ - [x] **Recommendations** — prioritized, concrete actions, not just findings
220
212
 
221
213
  ## Development
222
214
 
@@ -19,31 +19,22 @@ with **zero dependencies** beyond git and Python.
19
19
 
20
20
  ## Demo
21
21
 
22
- <!-- Render docs/demo.gif from demo.tape with `vhs demo.tape`, then:
23
- ![demo](docs/demo.gif) -->
22
+ ![CodePulse demo](docs/demo.gif)
24
23
 
25
24
  ```console
26
25
  $ codepulse . --badge
27
26
  Grade: B (83/100)
28
- -2 bus factor (9 fichiers à auteur unique)
29
- -15 pression hotspots en hausse
30
- ![code health](https://img.shields.io/badge/code%20health-B%20(83)-green)
31
27
 
32
28
  $ codepulse . --changed origin/main
33
- 🔴 Release risk: HIGH (100/100)
34
- 56 changed files · 2 high-risk
35
- [100] crates/ui-egui/src/lib.rs (hotspot majeur)
36
- [ 70] crates/layout/src/para.rs (zone active, auteur unique)
37
- 👥 Relecteurs suggérés: Valentin Klein (29/37 fichiers), Dmitrii Khlapov (22/37)
29
+ Release risk: HIGH (100/100)
30
+ [100] crates/ui-egui/src/lib.rs (major hotspot)
31
+ Reviewers: Valentin Klein (29/37), Dmitrii Khlapov (22/37)
38
32
 
39
33
  $ codepulse . --who crates/ui-egui/src/canvas.rs
40
- crates/ui-egui/src/canvas.rs — 64 commits, 22 auteur(s), dernier changement 2026-10-12
41
- 45% 29 commits Valentin Klein
42
- 11% 7 commits Dmitrii Khlapov
34
+ canvas.rs — 64 commits, 22 authors — 45% Valentin Klein
43
35
  ```
44
36
 
45
- > A recordable [`demo.tape`](demo.tape) (for [VHS](https://github.com/charmbracelet/vhs))
46
- > generates an animated `docs/demo.gif` of the above.
37
+ > The GIF is generated from [`demo.tape`](demo.tape) ([VHS](https://github.com/charmbracelet/vhs)).
47
38
 
48
39
  ---
49
40
 
@@ -191,6 +182,7 @@ simple enough to run in one command.
191
182
  - [x] `--who <file>` — who knows this code best
192
183
  - [x] `--badge` — shareable A–F health badge for your README
193
184
  - [x] Orphaned-code section — complex files nobody touches anymore
185
+ - [x] **Recommendations** — prioritized, concrete actions, not just findings
194
186
 
195
187
  ## Development
196
188
 
Binary file
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codepulse-cli"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Reveal a codebase's health from its git history — hotspots, bus factor, hidden coupling, and a CI risk gate."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """CodePulse — reveal a codebase's health from its git history."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.3"
@@ -314,6 +314,86 @@ def stale_risks(stats: dict[str, FileStat], now_ts: int, limit: int = 15) -> lis
314
314
  return out[:limit]
315
315
 
316
316
 
317
+ @dataclass
318
+ class Recommendation:
319
+ priority: int # 1 = most urgent
320
+ icon: str
321
+ title: str
322
+ action: str
323
+
324
+
325
+ def _duplicate_authors(commits: list[Commit]) -> list[tuple[str, ...]]:
326
+ """Author names that differ only by case/spacing — likely one person."""
327
+ groups: dict[str, set] = defaultdict(set)
328
+ for c in commits:
329
+ groups[c.author.strip().lower()].add(c.author.strip())
330
+ return [tuple(sorted(v)) for v in groups.values() if len(v) > 1]
331
+
332
+
333
+ def recommendations(
334
+ commits: list[Commit],
335
+ hotspots: list[FileStat],
336
+ bus_risks: list[FileStat],
337
+ coupling: list[CouplingPair],
338
+ trend,
339
+ stale: list[StaleFile],
340
+ limit: int = 8,
341
+ ) -> list[Recommendation]:
342
+ """Turn the findings into concrete, prioritized actions to take."""
343
+ recs: list[Recommendation] = []
344
+
345
+ for h in hotspots[:2]:
346
+ recs.append(Recommendation(
347
+ 1, "🔥", f"Refactorer `{h.path}`",
348
+ f"{h.commits} commits et une complexité de {h.complexity} : c'est un point chaud majeur, "
349
+ f"là où les bugs naissent. Découpez-le en unités plus petites et couvrez-le de tests avant d'y retoucher.",
350
+ ))
351
+
352
+ if trend and trend.direction == "up":
353
+ recs.append(Recommendation(
354
+ 1, "📈", "Freiner la dette technique",
355
+ "La part des commits touchant les hotspots augmente. Gelez les nouvelles fonctionnalités sur ces "
356
+ "fichiers et refactorez-les d'abord, sinon les bugs vont s'accumuler.",
357
+ ))
358
+
359
+ if bus_risks:
360
+ top = bus_risks[0]
361
+ owner = top.main_owner[0] or "une seule personne"
362
+ recs.append(Recommendation(
363
+ 2, "👥", "Partager la connaissance",
364
+ f"{len(bus_risks)} fichiers n'ont qu'un seul auteur (ex. `{top.path}`, par {owner}). "
365
+ "Documentez-les ou passez-les en revue à deux : si cette personne part, ce savoir disparaît.",
366
+ ))
367
+
368
+ dupes = _duplicate_authors(commits)
369
+ if dupes:
370
+ a, b = dupes[0][0], dupes[0][1]
371
+ recs.append(Recommendation(
372
+ 2, "🪪", "Unifier les identités git",
373
+ f"« {a} » et « {b} » semblent être la même personne (casse différente). Ajoutez un fichier "
374
+ "`.mailmap` pour fusionner les statistiques et compter les contributeurs correctement.",
375
+ ))
376
+
377
+ if coupling:
378
+ p = coupling[0]
379
+ recs.append(Recommendation(
380
+ 3, "🔗", "Clarifier un couplage caché",
381
+ f"`{p.a}` et `{p.b}` changent presque toujours ensemble ({p.shared}×). Vérifiez cette dépendance : "
382
+ "à expliciter, documenter, ou factoriser si elle est accidentelle.",
383
+ ))
384
+
385
+ if stale:
386
+ s = stale[0]
387
+ recs.append(Recommendation(
388
+ 3, "🕸️", "Revoir du code qui vieillit",
389
+ f"`{s.path}` est complexe (complexité {s.complexity}) mais intact depuis {s.days_stale} jours. "
390
+ "Vérifiez qu'il est encore utile, à jour et documenté avant que personne ne s'en souvienne.",
391
+ ))
392
+
393
+ recs.sort(key=lambda r: r.priority)
394
+ return recs[:limit]
395
+
396
+
317
397
  @dataclass
318
398
  class Health:
319
399
  score: int
@@ -71,21 +71,33 @@ def _run_report(repo: str, args: argparse.Namespace) -> int:
71
71
  stats = analyzers.build_file_stats(commits, repo)
72
72
  summary = analyzers.summarize(commits, stats)
73
73
  hot = analyzers.hotspots(stats, limit=args.top)
74
+ bus = analyzers.bus_factor_risks(stats, limit=args.top)
74
75
  hotspot_paths = {h.path for h in analyzers.hotspots(stats, limit=15)}
76
+ trend = analyzers.monthly_trend(commits, hotspot_paths)
77
+ coupled = analyzers.coupling(commits, stats, limit=args.top)
78
+ stale = analyzers.stale_risks(stats, summary.last_timestamp, limit=args.top)
79
+ recs = analyzers.recommendations(commits, hot, bus, coupled, trend, stale)
80
+
75
81
  html = render(
76
82
  repo_name=os.path.basename(repo.rstrip("/\\")) or repo,
77
83
  summary=summary,
78
84
  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),
85
+ bus_risks=bus,
86
+ coupling=coupled,
87
+ trend=trend,
88
+ stale=stale,
89
+ recommendations=recs,
83
90
  )
84
91
 
85
92
  out = os.path.abspath(args.output)
86
93
  with open(out, "w", encoding="utf-8") as fh:
87
94
  fh.write(html)
88
95
  print(f"\nReport written to {out}")
96
+ if recs:
97
+ print("\nRecommandations :")
98
+ for i, r in enumerate(recs, 1):
99
+ print(f" {i}. {r.icon} {r.title.replace('`', '')}")
100
+ print(f" {r.action.replace('`', '')}")
89
101
  if args.open:
90
102
  webbrowser.open(f"file://{out}")
91
103
  return 0
@@ -10,7 +10,7 @@ import datetime as _dt
10
10
  import html
11
11
 
12
12
  from . import charts
13
- from .analyzers import CouplingPair, FileStat, StaleFile, Summary, Trend
13
+ from .analyzers import CouplingPair, FileStat, Recommendation, StaleFile, Summary, Trend
14
14
 
15
15
 
16
16
  def _esc(value: object) -> str:
@@ -79,6 +79,32 @@ def _stale_section(stale: list[StaleFile]) -> str:
79
79
  </section>"""
80
80
 
81
81
 
82
+ def _code_spans(text: str) -> str:
83
+ """Escape, then turn `backtick` spans into <code>."""
84
+ out, code = [], False
85
+ for part in _esc(text).split("`"):
86
+ out.append(f"<code>{part}</code>" if code else part)
87
+ code = not code
88
+ return "".join(out)
89
+
90
+
91
+ def _rec_section(recs: list[Recommendation]) -> str:
92
+ if not recs:
93
+ return ""
94
+ items = "\n".join(
95
+ f"""<li class="rec p{min(r.priority, 3)}">
96
+ <div class="rec-head">{r.icon} <strong>{_esc(r.title)}</strong></div>
97
+ <div class="rec-action">{_code_spans(r.action)}</div>
98
+ </li>"""
99
+ for r in recs
100
+ )
101
+ return f"""<section class="recs-section">
102
+ <h2>🎯 Recommandations</h2>
103
+ <p class="hint">Les actions concrètes à prendre, de la plus urgente à la moins urgente.</p>
104
+ <ol class="recs">{items}</ol>
105
+ </section>"""
106
+
107
+
82
108
  def render(
83
109
  repo_name: str,
84
110
  summary: Summary,
@@ -87,6 +113,7 @@ def render(
87
113
  coupling: list[CouplingPair],
88
114
  trend: Trend | None = None,
89
115
  stale: list[StaleFile] | None = None,
116
+ recommendations: list[Recommendation] | None = None,
90
117
  ) -> str:
91
118
  generated = _dt.datetime.now().strftime("%Y-%m-%d %H:%M")
92
119
  span = f"{_date(summary.first_timestamp)} → {_date(summary.last_timestamp)}"
@@ -136,6 +163,7 @@ def render(
136
163
  generated=generated,
137
164
  span=span,
138
165
  chart_css=charts.CHART_CSS,
166
+ rec_section=_rec_section(recommendations or []),
139
167
  trend_section=_trend_section(trend),
140
168
  stale_section=_stale_section(stale or []),
141
169
  total_commits=f"{summary.total_commits:,}",
@@ -207,6 +235,23 @@ tr:last-child td {{ border-bottom: none; }}
207
235
  .badge.mid {{ background: var(--mid); }}
208
236
  .badge.low {{ background: var(--low); }}
209
237
  .empty {{ color: var(--muted); text-align: center; padding: 20px; }}
238
+ .recs-section {{ border-color: var(--accent); }}
239
+ ol.recs {{ list-style: none; counter-reset: rec; margin: 0; padding: 0; }}
240
+ ol.recs .rec {{ counter-increment: rec; position: relative; padding: 12px 12px 12px 44px; margin-bottom: 10px;
241
+ border: 1px solid var(--line); border-left: 4px solid var(--muted); border-radius: 10px; background: var(--bg); }}
242
+ ol.recs .rec::before {{ content: counter(rec); position: absolute; left: 12px; top: 12px; width: 22px; height: 22px;
243
+ border-radius: 50%; background: var(--muted); color: #fff; font-size: 12px; font-weight: 700;
244
+ display: flex; align-items: center; justify-content: center; }}
245
+ ol.recs .rec.p1 {{ border-left-color: var(--hot); }}
246
+ ol.recs .rec.p1::before {{ background: var(--hot); }}
247
+ ol.recs .rec.p2 {{ border-left-color: var(--mid); }}
248
+ ol.recs .rec.p2::before {{ background: var(--mid); }}
249
+ ol.recs .rec.p3 {{ border-left-color: var(--low); }}
250
+ ol.recs .rec.p3::before {{ background: var(--low); }}
251
+ .rec-head {{ font-size: 15px; margin-bottom: 3px; }}
252
+ .rec-action {{ color: var(--muted); font-size: 14px; }}
253
+ .rec-action code {{ font-family: ui-monospace, Consolas, monospace; font-size: 12px; color: var(--ink);
254
+ background: var(--panel); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); }}
210
255
  ul.contrib {{ list-style: none; margin: 0; padding: 0; }}
211
256
  ul.contrib li {{ display: grid; grid-template-columns: 180px 1fr 40px; gap: 10px; align-items: center; padding: 5px 0; }}
212
257
  ul.contrib .c-name {{ font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
@@ -232,6 +277,8 @@ footer a {{ color: var(--accent); text-decoration: none; }}
232
277
  <div class="card"><div class="k">Contributeurs</div><div class="v">{contributors}</div></div>
233
278
  </div>
234
279
 
280
+ {rec_section}
281
+
235
282
  {trend_section}
236
283
 
237
284
  <section>
@@ -90,6 +90,21 @@ def test_release_risk_high_for_hotspot_single_owner():
90
90
  assert any("auteur unique" in r for r in risk.files[0].reasons)
91
91
 
92
92
 
93
+ def test_recommendations_prioritize_and_detect_duplicate_authors():
94
+ commits = [
95
+ Commit(str(i), i * DAY, "Alice" if i % 2 else "alice",
96
+ files=[FileChange("hot.py", 1, 0)])
97
+ for i in range(6)
98
+ ]
99
+ hot = [stat("hot.py", commits=6, authors={"Alice": 3, "alice": 3}, complexity=40)]
100
+ bus = [stat("solo.py", commits=4, authors={"Alice": 4})]
101
+ recs = analyzers.recommendations(commits, hot, bus, [], None, [])
102
+ titles = " ".join(r.title for r in recs)
103
+ assert "hot.py" in titles # top hotspot recommended
104
+ assert any("identit" in r.title.lower() for r in recs) # duplicate Alice/alice detected
105
+ assert recs == sorted(recs, key=lambda r: r.priority) # sorted by urgency
106
+
107
+
93
108
  def test_suggested_reviewers_ranks_by_coverage_then_depth():
94
109
  stats = {
95
110
  "a.py": stat("a.py", 5, {"Alice": 4, "Bob": 1}),
File without changes
File without changes
File without changes
File without changes