codepulse-cli 0.1.0__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.3/.github/workflows/publish.yml +32 -0
  2. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/PKG-INFO +18 -20
  3. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/README.md +17 -19
  4. codepulse_cli-0.1.3/docs/demo.gif +0 -0
  5. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/pyproject.toml +1 -1
  6. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/src/codepulse/__init__.py +1 -1
  7. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/src/codepulse/analyzers.py +80 -0
  8. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/src/codepulse/cli.py +16 -4
  9. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/src/codepulse/report.py +48 -1
  10. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/tests/test_analyzers.py +15 -0
  11. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/.github/workflows/codepulse.yml +0 -0
  12. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/.github/workflows/tests.yml +0 -0
  13. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/.gitignore +0 -0
  14. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/LICENSE +0 -0
  15. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/action.yml +0 -0
  16. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/demo.tape +0 -0
  17. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/src/codepulse/__main__.py +0 -0
  18. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/src/codepulse/charts.py +0 -0
  19. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/src/codepulse/complexity.py +0 -0
  20. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/src/codepulse/git_data.py +0 -0
  21. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/tests/test_complexity.py +0 -0
  22. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/tests/test_e2e.py +0 -0
  23. {codepulse_cli-0.1.0 → codepulse_cli-0.1.3}/tests/test_git_data.py +0 -0
@@ -0,0 +1,32 @@
1
+ name: publish
2
+
3
+ # Builds and publishes to PyPI whenever a GitHub release is published.
4
+ # Uses PyPI Trusted Publishing (OIDC) — no API token stored anywhere.
5
+ # Remember to bump `version` in pyproject.toml before creating the release.
6
+
7
+ on:
8
+ release:
9
+ types: [published]
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ pypi:
16
+ runs-on: ubuntu-latest
17
+ permissions:
18
+ id-token: write # required for trusted publishing
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: "3.12"
25
+
26
+ - name: Build distributions
27
+ run: |
28
+ python -m pip install --upgrade build
29
+ python -m build
30
+
31
+ - name: Publish to PyPI
32
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codepulse-cli
3
- Version: 0.1.0
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
 
@@ -87,18 +78,24 @@ or email to your team.
87
78
 
88
79
  ---
89
80
 
81
+ ## Install
82
+
83
+ ```bash
84
+ pip install codepulse-cli # installs the `codepulse` command
85
+ ```
86
+
90
87
  ## Quick start
91
88
 
92
89
  ```bash
93
90
  # From a git repository:
94
- python -m codepulse /path/to/repo --open
91
+ codepulse /path/to/repo --open
95
92
 
96
93
  # Common options:
97
- python -m codepulse . --since "6 months ago" --top 30 -o health.html
94
+ codepulse . --since "6 months ago" --top 30 -o health.html
98
95
  ```
99
96
 
100
- > Running from the source checkout? Prefix with `PYTHONPATH=src`, or install it:
101
- > `pip install -e .` then just run `codepulse`.
97
+ > Running from a source checkout instead? Use `python -m codepulse` with
98
+ > `PYTHONPATH=src`, or `pip install -e .`.
102
99
 
103
100
  ### Options
104
101
 
@@ -211,6 +208,7 @@ simple enough to run in one command.
211
208
  - [x] `--who <file>` — who knows this code best
212
209
  - [x] `--badge` — shareable A–F health badge for your README
213
210
  - [x] Orphaned-code section — complex files nobody touches anymore
211
+ - [x] **Recommendations** — prioritized, concrete actions, not just findings
214
212
 
215
213
  ## Development
216
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
 
@@ -61,18 +52,24 @@ or email to your team.
61
52
 
62
53
  ---
63
54
 
55
+ ## Install
56
+
57
+ ```bash
58
+ pip install codepulse-cli # installs the `codepulse` command
59
+ ```
60
+
64
61
  ## Quick start
65
62
 
66
63
  ```bash
67
64
  # From a git repository:
68
- python -m codepulse /path/to/repo --open
65
+ codepulse /path/to/repo --open
69
66
 
70
67
  # Common options:
71
- python -m codepulse . --since "6 months ago" --top 30 -o health.html
68
+ codepulse . --since "6 months ago" --top 30 -o health.html
72
69
  ```
73
70
 
74
- > Running from the source checkout? Prefix with `PYTHONPATH=src`, or install it:
75
- > `pip install -e .` then just run `codepulse`.
71
+ > Running from a source checkout instead? Use `python -m codepulse` with
72
+ > `PYTHONPATH=src`, or `pip install -e .`.
76
73
 
77
74
  ### Options
78
75
 
@@ -185,6 +182,7 @@ simple enough to run in one command.
185
182
  - [x] `--who <file>` — who knows this code best
186
183
  - [x] `--badge` — shareable A–F health badge for your README
187
184
  - [x] Orphaned-code section — complex files nobody touches anymore
185
+ - [x] **Recommendations** — prioritized, concrete actions, not just findings
188
186
 
189
187
  ## Development
190
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.0"
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.0"
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