codepulse-cli 0.1.2__py3-none-any.whl → 0.1.3__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 CHANGED
@@ -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"
codepulse/analyzers.py CHANGED
@@ -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
codepulse/cli.py CHANGED
@@ -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
codepulse/report.py CHANGED
@@ -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>
@@ -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
 
@@ -0,0 +1,13 @@
1
+ codepulse/__init__.py,sha256=lPF0uJmHQas9B4kiz6hu_EuFFcLudVEwSTFDFwep2d0,92
2
+ codepulse/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ codepulse/analyzers.py,sha256=n0U6JqxkFcxaWul5UQjT-L9Ft2Wu7wjgwnbygq3lECY,19715
4
+ codepulse/charts.py,sha256=wATHjaENoB73HlDHPpC_tzNrdTTAG0-Bq2P2VclCnLc,5252
5
+ codepulse/cli.py,sha256=07YRa3N1TWMAklWYBpPCUKfSrnzRZKGzrBYNo2mJfgc,10826
6
+ codepulse/complexity.py,sha256=ITi8LogGzzlPEfTlF3m1u-z6cX__3-ojsy0pfuDuYAA,1543
7
+ codepulse/git_data.py,sha256=0KIKVjHYLkMopeRx33joHs9xaYUBn0b_FEUMPW7bSiY,3957
8
+ codepulse/report.py,sha256=-r2IHWyCai5ZCtzZj43nNtyluk6aYwFwavLx0AjbLW8,13772
9
+ codepulse_cli-0.1.3.dist-info/METADATA,sha256=D6VBQPI_I-BcHaI2HEzE9K9NJa-aCOlkLRv8Rqp_pR4,7878
10
+ codepulse_cli-0.1.3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ codepulse_cli-0.1.3.dist-info/entry_points.txt,sha256=xRsekBS_9RQ0fx-UagKqWflKPqnxwpJSS2AtWCPIoK0,49
12
+ codepulse_cli-0.1.3.dist-info/licenses/LICENSE,sha256=7eRQbii3vNO5cyKCcZYA4mEET6Ctb4twFp59UettDfs,1067
13
+ codepulse_cli-0.1.3.dist-info/RECORD,,
@@ -1,13 +0,0 @@
1
- codepulse/__init__.py,sha256=iso2q1umqw1YFXbLbdjJUrcMeEGZg_-QFiUbFvvO-tU,92
2
- codepulse/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
- codepulse/analyzers.py,sha256=ud2UwDJgJRu-Rq53QHoS2yBQRvRPjbULf4TGy8likr8,16631
4
- codepulse/charts.py,sha256=wATHjaENoB73HlDHPpC_tzNrdTTAG0-Bq2P2VclCnLc,5252
5
- codepulse/cli.py,sha256=ho1A7WiaWecYQCO4fIHyXkkceVvIsr-agsmyxUWJK_k,10434
6
- codepulse/complexity.py,sha256=ITi8LogGzzlPEfTlF3m1u-z6cX__3-ojsy0pfuDuYAA,1543
7
- codepulse/git_data.py,sha256=0KIKVjHYLkMopeRx33joHs9xaYUBn0b_FEUMPW7bSiY,3957
8
- codepulse/report.py,sha256=pBISRg7BL_ztJ5Y1TTK_BL4ApyY9vVgkFdXT1NkZqGA,11509
9
- codepulse_cli-0.1.2.dist-info/METADATA,sha256=ptLxIRaiKFxmBUsUT-PKVXaMdfc0BWDoiORq8EZ5FpA,8298
10
- codepulse_cli-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
- codepulse_cli-0.1.2.dist-info/entry_points.txt,sha256=xRsekBS_9RQ0fx-UagKqWflKPqnxwpJSS2AtWCPIoK0,49
12
- codepulse_cli-0.1.2.dist-info/licenses/LICENSE,sha256=7eRQbii3vNO5cyKCcZYA4mEET6Ctb4twFp59UettDfs,1067
13
- codepulse_cli-0.1.2.dist-info/RECORD,,