gitmole 0.8.0__tar.gz → 0.9.0__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 (64) hide show
  1. {gitmole-0.8.0 → gitmole-0.9.0}/PKG-INFO +6 -6
  2. {gitmole-0.8.0 → gitmole-0.9.0}/README.md +5 -5
  3. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/__init__.py +1 -1
  4. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/cli.py +1 -1
  5. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/evaluate.py +49 -4
  6. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/findings.py +1 -1
  7. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/load.py +2 -6
  8. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/render.py +38 -10
  9. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/textfmt.py +5 -0
  10. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/trend.py +3 -0
  11. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/watch.py +25 -64
  12. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole.egg-info/PKG-INFO +6 -6
  13. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_evaluate.py +34 -0
  14. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_render.py +102 -45
  15. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_textfmt.py +11 -0
  16. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_watch.py +32 -35
  17. {gitmole-0.8.0 → gitmole-0.9.0}/LICENSE +0 -0
  18. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/__main__.py +0 -0
  19. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/backtest.py +0 -0
  20. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/banner.py +0 -0
  21. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/blame.py +0 -0
  22. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/clean.py +0 -0
  23. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/coupling.py +0 -0
  24. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/deps.py +0 -0
  25. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/duplicates.py +0 -0
  26. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/filetypes.py +0 -0
  27. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/functions.py +0 -0
  28. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/hotspots.py +0 -0
  29. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/identity.py +0 -0
  30. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/knowledge.py +0 -0
  31. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/leaks.py +0 -0
  32. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/loss.py +0 -0
  33. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/maat.py +0 -0
  34. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/run.py +0 -0
  35. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole.egg-info/SOURCES.txt +0 -0
  36. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole.egg-info/dependency_links.txt +0 -0
  37. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole.egg-info/entry_points.txt +0 -0
  38. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole.egg-info/requires.txt +0 -0
  39. {gitmole-0.8.0 → gitmole-0.9.0}/gitmole.egg-info/top_level.txt +0 -0
  40. {gitmole-0.8.0 → gitmole-0.9.0}/pyproject.toml +0 -0
  41. {gitmole-0.8.0 → gitmole-0.9.0}/setup.cfg +0 -0
  42. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_backtest.py +0 -0
  43. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_banner.py +0 -0
  44. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_blame.py +0 -0
  45. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_clean.py +0 -0
  46. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_cli.py +0 -0
  47. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_coupling.py +0 -0
  48. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_deps.py +0 -0
  49. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_duplicates.py +0 -0
  50. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_filetypes.py +0 -0
  51. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_findings.py +0 -0
  52. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_functions.py +0 -0
  53. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_golden.py +0 -0
  54. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_hotspots.py +0 -0
  55. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_identity.py +0 -0
  56. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_knowledge.py +0 -0
  57. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_leaks.py +0 -0
  58. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_load.py +0 -0
  59. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_loss.py +0 -0
  60. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_maat.py +0 -0
  61. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_packaging.py +0 -0
  62. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_render_examples.py +0 -0
  63. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_run.py +0 -0
  64. {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_trend.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitmole
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Offline git repository analysis with a terminal report: hotspots, coupling, ownership, code age, secrets, repo health.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/antvinni/gitmole
@@ -108,11 +108,11 @@ ranks by revisions × lines of code: measured at six cut-offs on three
108
108
  repositories
109
109
  ([validation](https://github.com/antvinni/gitmole/blob/main/docs/validation.md)),
110
110
  that named more of the files fixed next than churn alone, size alone or a
111
- weighted product of fixes, complexity and ownership. Between the header and
112
- that list the full report puts its findings, 16 for react (1 critical, 6
113
- warnings, 9 notes); below it, tables for people, the knowledge map, the
114
- timeline, hotspots with their complexity trend, change coupling, complex
115
- functions and repo health. Every section is explained in
111
+ weighted product of fixes, complexity and ownership. Between the header and that
112
+ list the full report puts its findings, 16 for react (1 critical, 6 warnings, 9
113
+ notes); below it, tables for people, the knowledge map, the timeline, change
114
+ coupling, complex functions and repo health; `--full` adds the hotspots table
115
+ behind the list, size, activity and code age. Every section is explained in
116
116
  [docs/output.md](https://github.com/antvinni/gitmole/blob/main/docs/output.md).
117
117
 
118
118
  Reports on repositories you know, each at a pinned commit with a fixed
@@ -88,11 +88,11 @@ ranks by revisions × lines of code: measured at six cut-offs on three
88
88
  repositories
89
89
  ([validation](https://github.com/antvinni/gitmole/blob/main/docs/validation.md)),
90
90
  that named more of the files fixed next than churn alone, size alone or a
91
- weighted product of fixes, complexity and ownership. Between the header and
92
- that list the full report puts its findings, 16 for react (1 critical, 6
93
- warnings, 9 notes); below it, tables for people, the knowledge map, the
94
- timeline, hotspots with their complexity trend, change coupling, complex
95
- functions and repo health. Every section is explained in
91
+ weighted product of fixes, complexity and ownership. Between the header and that
92
+ list the full report puts its findings, 16 for react (1 critical, 6 warnings, 9
93
+ notes); below it, tables for people, the knowledge map, the timeline, change
94
+ coupling, complex functions and repo health; `--full` adds the hotspots table
95
+ behind the list, size, activity and code age. Every section is explained in
96
96
  [docs/output.md](https://github.com/antvinni/gitmole/blob/main/docs/output.md).
97
97
 
98
98
  Reports on repositories you know, each at a pinned commit with a fixed
@@ -1,3 +1,3 @@
1
1
  """gitmole: offline git repository analysis with a terminal report."""
2
2
 
3
- __version__ = "0.8.0"
3
+ __version__ = "0.9.0"
@@ -38,7 +38,7 @@ def parse_args(argv):
38
38
  p.add_argument("--clean", action="store_true", help="list the directories gitmole created (temp clones, analysis-* under the target) and delete them after a y/N question, then exit")
39
39
  p.add_argument("--yes", action="store_true", help="with --clean: delete without asking")
40
40
  p.add_argument("--duplicates", action="store_true", help=argparse.SUPPRESS) # duplicates always run now; kept so older scripts still parse
41
- p.add_argument("--full", action="store_true", help="every column and every row in the terminal report, and the test files the default tables hide (the default is the tighter, readable one)")
41
+ p.add_argument("--full", action="store_true", help="every section, column and row in the terminal report: adds hotspots, size, activity and code age, and the test files the default tables hide (the default is the tighter, readable one)")
42
42
  p.add_argument("--json", metavar="PATH", help="write the report and findings as JSON to PATH, or - for stdout")
43
43
  p.add_argument("--markdown", metavar="PATH", help="write the report as Markdown to PATH, or - for stdout")
44
44
  p.add_argument("--fail-on", choices=findings.SEVERITIES, help="exit 3 if any finding is at this severity or worse")
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  """How the watch list would have done at several cut-off dates, next to the factor products it
3
- replaced and the simpler baselines.
3
+ replaced (computed here, over the rows watch.risks returns) and the simpler baselines.
4
4
 
5
5
  A development tool, not a pipeline step: `python -m gitmole.evaluate REPO OUT_DIR [--windows 6]
6
6
  [--horizon 6] [--top 15]`, where OUT_DIR is a finished gitmole output directory for REPO (its log.txt
@@ -13,6 +13,7 @@ column per cut-off, and the total; then how many commits `--all` adds to HEAD's.
13
13
  from __future__ import annotations
14
14
 
15
15
  import argparse
16
+ import bisect
16
17
  import calendar
17
18
  import datetime as dt
18
19
  import os
@@ -52,12 +53,56 @@ def report_at(commits: list, t: str, size: dict, meta: dict) -> dict:
52
53
  "fixes": maat.fixes(past, now=t), "coupling": [], "functions": []}
53
54
 
54
55
 
56
+ SOLO_WEIGHT = 1.5 # how much single ownership lifts a factor product
57
+
58
+
59
+ def _by_max(values: list, inclusive: bool):
60
+ """x as a share of the largest value: the scaling gitmole 0.7 shipped. One outlier moves everyone;
61
+ inclusive is ignored here, since a share of the largest value has no edge to choose."""
62
+ top = max(values)
63
+ return lambda x: x / top if top else 0.0
64
+
65
+
66
+ def _by_rank(values: list, inclusive: bool):
67
+ """x as the share of the scored files at or below it (inclusive), or strictly below it. Churn is
68
+ inclusive, so the most-changed file is 1 and no file is 0; fixes and complexity are strict, so a
69
+ file with none of either gets no lift, as under _by_max. An outlier is one more file, not a new
70
+ scale."""
71
+ ordered = sorted(values)
72
+ cut = bisect.bisect_right if inclusive else bisect.bisect_left
73
+ return lambda x: cut(ordered, x) / len(ordered)
74
+
75
+
76
+ SCALINGS = {"max": _by_max, "rank": _by_rank}
77
+
78
+
79
+ def factor_scores(rows: list, scaling: str) -> dict:
80
+ """file -> churn × (1 + recent fixes) × (1 + complexity) × (1.5 if single-owned), what the watch
81
+ list ranked by before 0.8, over the rows watch.risks returns. Kept here, not in watch.py, because
82
+ only this comparison still needs it."""
83
+ if not rows:
84
+ return {}
85
+ scale = SCALINGS[scaling]
86
+ churn = scale([r["revs"] for r in rows], True)
87
+ fixed = scale([r["recent_fixes"] for r in rows], False)
88
+ cplx = scale([r["complexity"] for r in rows], False)
89
+ return {r["file"]: churn(r["revs"]) * (1 + fixed(r["recent_fixes"])) * (1 + cplx(r["complexity"])) * (SOLO_WEIGHT if r["solo"] else 1)
90
+ for r in rows}
91
+
92
+
93
+ def factor_product(rows: list, scaling: str) -> list:
94
+ """File names by factor_scores, best first; ties by revisions, then by name, as the list itself breaks them."""
95
+ scores = factor_scores(rows, scaling)
96
+ return [r["file"] for r in sorted(rows, key=lambda r: (-scores[r["file"]], -r["revs"], r["file"]))]
97
+
98
+
55
99
  def variants(report: dict) -> dict:
56
- """variant -> file names, best first, every one drawn from the pool the watch list draws from."""
100
+ """variant -> file names, best first, every one drawn from the pool the watch list draws from. The
101
+ factor products it used to be ranked by are computed here, next to the watch list's own ranking."""
57
102
  rows = watch.risks(report)
58
103
  out = {"watch list (hotspot)": [r["file"] for r in rows],
59
- "factor product (max-scaled)": [r["file"] for r in watch.risks(report, scoring="max")],
60
- "factor product (rank-scaled)": [r["file"] for r in watch.risks(report, scoring="rank")]}
104
+ "factor product (max-scaled)": factor_product(rows, "max"),
105
+ "factor product (rank-scaled)": factor_product(rows, "rank")}
61
106
  for name, key in watch.BASELINES.items():
62
107
  out[name] = watch.ranked_by(rows, key)
63
108
  out["recent fixes"] = watch.ranked_by(rows, lambda r: (r["recent_fixes"], r["revs"]))
@@ -521,7 +521,7 @@ def _generated(report: dict) -> set:
521
521
  return hotspots.derived(report)
522
522
 
523
523
 
524
- def complexity_growth(report: dict, min_growers: int = 3, min_pct: int = 25, top_n: int = 10) -> list:
524
+ def complexity_growth(report: dict, min_growers: int = 3, min_pct: int = trend.GROWTH_FLOOR, top_n: int = 10) -> list:
525
525
  """The top_n source hotspots whose complexity grew over the last year, from the trend samples.
526
526
  Test files are left out: a growing test file is not the problem the finding is about."""
527
527
  series = (report.get("trend") or {}).get("files") or {}
@@ -9,7 +9,7 @@ import os
9
9
  import re
10
10
  from collections import Counter, OrderedDict
11
11
 
12
- from . import filetypes, identity, leaks
12
+ from . import filetypes, identity, leaks, textfmt
13
13
 
14
14
 
15
15
  def _rel(path: str) -> str:
@@ -168,10 +168,6 @@ NAME_CAP = 200 # a function name a table can show; deeply nested fixtures give
168
168
  csv.field_size_limit(min(sys.maxsize, 2**31 - 1)) # an older functions.csv may still carry such a name
169
169
 
170
170
 
171
- def _cut(name: str, cap: int = NAME_CAP) -> str:
172
- return name if len(name) <= cap else name[:cap - 1] + "…"
173
-
174
-
175
171
  def parse_functions(text: str) -> list:
176
172
  """lizard --csv rows: nloc, ccn, tokens, params, length, location, file, function, long name, start, end;
177
173
  then, from gitmole's own step, a label for a nameless function (its start line) and why the span
@@ -183,7 +179,7 @@ def parse_functions(text: str) -> list:
183
179
  continue
184
180
  name, label, suspect = r[7], r[11] if len(r) > 11 else "", r[12] if len(r) > 12 else ""
185
181
  anonymous = name in ("", "(anonymous)")
186
- rows.append({"file": _rel(r[6]), "function": _cut(label if anonymous and label else name) or "(anonymous)", "anonymous": anonymous,
182
+ rows.append({"file": _rel(r[6]), "function": textfmt.cut(label if anonymous and label else name, NAME_CAP) or "(anonymous)", "anonymous": anonymous,
187
183
  "ccn": _num(r[1]), "nloc": _num(r[0]), "params": _num(r[3]), "start": _num(r[9]), "end": _num(r[10]), "suspect": suspect})
188
184
  return rows
189
185
 
@@ -26,6 +26,18 @@ WARM = "#ff9ee0" # values worth a glance
26
26
  ROW_STYLES = ["", "on #1c2230"]
27
27
  SIDE_BY_SIDE_MIN_WIDTH = 100
28
28
 
29
+ # the Timeline's month columns: each is 3 characters wide plus 2 of column padding, plus the 1-column
30
+ # gap rich reserves between every pair of columns even with the box's edges hidden (verified against
31
+ # rich.table.Table._calculate_column_widths, whose "n columns - 1" extra width cancels the gap saved
32
+ # on the last column, leaving a clean 6 per month). The section itself is indented by 2. FLOOR is the
33
+ # fewest months shown even when a name leaves almost no room. Once FLOOR is reached the months keep
34
+ # their full width and the name gives way instead, cut to whatever room is left; NAME_FLOOR is the
35
+ # fewest characters of a name still shown before the ellipsis, even if the months leave less room than
36
+ # that (eight is enough to keep most short names, and the start of longer ones, still recognisable).
37
+ # The section needs INDENT + NAME_FLOOR + FLOOR × MONTH_WIDTH = 28 columns; below that rich starves
38
+ # the month cells, which no real terminal reaches.
39
+ MONTH_WIDTH, INDENT, FLOOR, NAME_FLOOR = 6, 2, 3, 8
40
+
29
41
  SYMBOLS = {"Size by language": "▤", "People": "◉", "Activity": "◔", "Timeline": "▦", "Hotspots": "◆", "Change coupling": "⟷",
30
42
  "Surviving code by year written": "◷", "Net lines added by year": "◷", "Paths in history by year last changed": "◷",
31
43
  "Knowledge map": "⌂", "Repo health": "✚", "Portfolio": "▣", "File types": "▥", "Complex functions": "λ", "Watch list": "◎",
@@ -40,8 +52,9 @@ RIGHT = {"justify": "right"}
40
52
  FOLD = {"overflow": "fold"}
41
53
  PATH = {"overflow": "fold", "no_wrap": False}
42
54
 
43
- # rows shown by default; `full` lifts the caps. Markdown gets a looser cap of its own.
44
- CAPS = {"People": 6, "Hotspots": 8, "Change coupling": 5, "Knowledge map": 6, "Size by language": 8, "Timeline": 8, "Complex functions": 8}
55
+ # rows shown by default; `full` lifts the caps. Markdown gets a looser cap of its own. Hotspots has
56
+ # no entry: it is `--full`/Markdown only now, so its row count is never decided by this table.
57
+ CAPS = {"People": 6, "Change coupling": 5, "Knowledge map": 6, "Size by language": 8, "Timeline": 8, "Complex functions": 8}
45
58
  MARKDOWN_CAP = 50
46
59
  TREND_TOP = 10 # the trend step's own --top default: only those files have samples
47
60
  WATCH_CAP, WATCH_FULL = 5, 15 # the watch list is a short list by design; `full` and Markdown get a longer one, never all files
@@ -394,6 +407,12 @@ def _month_label(ym: str) -> str:
394
407
 
395
408
 
396
409
  def timeline_section(report: dict, full: bool = True, width=None, months: int = 12) -> dict:
410
+ """Commits per author, one column per month. Names never fold: when the year does not fit the
411
+ terminal width, the oldest months are dropped (down to FLOOR) instead. If a name is still too long
412
+ for the room FLOOR leaves, the name gives way, not the months: it is shown cut with an ellipsis
413
+ (never fewer than NAME_FLOOR characters), so the months a reader came for stay full width. The
414
+ title names the months actually shown; ranking, bots filtering and the row's key are all still the
415
+ real name, only the displayed cell is cut. With no width (the Markdown export) nothing is trimmed."""
397
416
  tl = (report.get("activity") or {}).get("timeline") or {}
398
417
  if not tl:
399
418
  return _section("Timeline", [("author", {})], [], note="no timeline data")
@@ -402,18 +421,25 @@ def timeline_section(report: dict, full: bool = True, width=None, months: int =
402
421
  since = report["meta"].get("since")
403
422
  if since:
404
423
  span = [m for m in span if m >= since[:7]] or span[-1:]
405
- columns = [("author", {"overflow": "fold"})] + [(MONTHS[int(m[5:7]) - 1], RIGHT) for m in span]
406
424
  in_window = {a: sum(per.get(m, 0) for m in span) for a, per in tl.items()}
407
425
  # the run decided who is a bot from name and email; the timeline only has the name, so it asks the run
408
426
  bots = {b["name"] for b in report["meta"].get("bots") or []}
409
427
  ranked = [a for a in sorted(in_window, key=lambda a: -in_window[a]) if in_window[a] > 0 and a not in bots and not identity.is_bot(a)]
410
428
  limit = _limit("Timeline", full)
411
- rows = [(a, *[tl[a].get(m) or "·" for m in span]) for a in ranked[:limit]]
429
+ if width:
430
+ name = max([len("author")] + [len(a) for a in ranked[:limit]])
431
+ span = span[-max(FLOOR, min(len(span), (width - INDENT - name) // MONTH_WIDTH)):]
432
+ columns = [("author", {"no_wrap": True})] + [(MONTHS[int(m[5:7]) - 1], RIGHT) for m in span]
433
+ room = width - INDENT - MONTH_WIDTH * len(span) if width else None
434
+ rows = [(textfmt.cut(a, max(NAME_FLOOR, room)) if width else a, *[tl[a].get(m) or "·" for m in span]) for a in ranked[:limit]]
412
435
  return _section(f"Timeline ({_month_label(span[0])} → {_month_label(span[-1])})", columns, rows, caption=_more(len(ranked), limit))
413
436
 
414
437
 
415
438
  def hotspots_section(report: dict, full: bool = True, width=None) -> dict:
416
- """Change frequency times size, Tornhill-style. Files no longer in the tree sort last."""
439
+ """Change frequency times size, Tornhill-style. Files no longer in the tree sort last. Drawn
440
+ under `--full` and in the Markdown export only; the default terminal report leaves it to the
441
+ watch list, which ranks the same files. Built only for those two, it has no row cap of its
442
+ own outside Markdown's."""
417
443
  authors = {a["entity"]: a["n-authors"] for a in report.get("authors") or []}
418
444
  ages = {a["entity"]: a["age-months"] for a in report.get("age") or []}
419
445
  fixes = {f["entity"]: f["n-fixes"] for f in report.get("fixes") or []}
@@ -443,10 +469,9 @@ def hotspots_section(report: dict, full: bool = True, width=None) -> dict:
443
469
  ("trend", RIGHT)]
444
470
  if full is not True:
445
471
  columns, rows = _keep(columns, rows, ["file", "revs", "lines", "fixes", "authors", "trend"])
446
- rows = _shorten(rows, width, columns)
447
472
  note = None if rows else _empty_note(None, hidden_note, "no source hotspots")
448
473
  notes = [c for c in (_more(len(scored), limit), None if note else hidden_note) if c]
449
- if series and full is not False: # the tight report keeps its captions short
474
+ if series:
450
475
  notes.append(f"trend sampled for the top {TREND_TOP} hotspots") # the rest of the column is empty by design
451
476
  return _section(title, columns, rows, note=note, caption="; ".join(notes) or None)
452
477
 
@@ -602,16 +627,19 @@ def health_section(report: dict, full: bool = True, width=None) -> dict:
602
627
 
603
628
  BUILDERS = [watch_section, size_section, people_section, knowledge_section, activity_section, timeline_section,
604
629
  hotspots_section, coupling_section, age_section, functions_section, health_section]
605
- DESCRIPTIVE = {"size", "activity", "age"} # interesting once, rarely change what you do next: `--full` only
630
+ # `--full` and Markdown only: Size, Activity and Code age are interesting once and rarely change what you
631
+ # do next; Hotspots ranks the files the watch list already leads with, by the same product.
632
+ FULL_ONLY = {"size", "activity", "age", "hotspots"}
606
633
 
607
634
 
608
635
  def sections(report: dict, full: bool = True, width=None) -> list:
609
636
  """Every section as a dict with an `id` (the builder's name without _section). The default terminal
610
- report (`full` False) leaves the descriptive ones out; `full` True and Markdown keep them."""
637
+ report (`full` False) leaves out the sections in FULL_ONLY (size, activity, code age and
638
+ hotspots); `full` True and Markdown keep them."""
611
639
  out = []
612
640
  for b in BUILDERS:
613
641
  sid = b.__name__[:-len("_section")]
614
- if full is False and sid in DESCRIPTIVE:
642
+ if full is False and sid in FULL_ONLY:
615
643
  continue
616
644
  sec = b(report, full, width)
617
645
  sec["id"] = sid
@@ -22,6 +22,11 @@ def shorten_path(path: str, max_len: int) -> str:
22
22
  return candidates[-1]
23
23
 
24
24
 
25
+ def cut(name: str, cap: int) -> str:
26
+ """`name`, unchanged if it fits in `cap` characters, else cut to exactly `cap` ending in the ellipsis."""
27
+ return name if len(name) <= cap else name[:cap - 1] + ELLIPSIS
28
+
29
+
25
30
  def times(n: int) -> str:
26
31
  """How often something happened, in words for the small numbers: once, twice, 3 times."""
27
32
  return {1: "once", 2: "twice"}.get(n, f"{n} times")
@@ -33,6 +33,9 @@ def sample_dates(first: str, last: str, n: int) -> list:
33
33
  return out
34
34
 
35
35
 
36
+ GROWTH_FLOOR = 25 # percent in a year: below it a hotspot's complexity is not said to be growing
37
+
38
+
36
39
  def change_over_year(series: list, last_date: str) -> str:
37
40
  if len(series) < 2:
38
41
  return "-"
@@ -4,32 +4,23 @@ Each source file that is still in the tree and changed more than once is ranked
4
4
  of code, the product the Hotspots table uses: measured at six cut-offs on three repositories
5
5
  (docs/validation.md), that product named more of the files fixed in the following six months than
6
6
  any weighting of fixes, complexity and ownership did. Those signals are the reasons printed beside
7
- each file: how often it was fixed lately, who alone owns it, its most complex function, what it
8
- always changes with. A file's score is its share, in percent, of all scored files' revisions × lines
9
- of code, so the scores of the whole list add up to 100 and a change's `--risk` total is the share of
10
- that mass the change touches; one enormous file takes a large share, as it should, and lowers the
11
- others' only by what it adds to the whole. The two factor-product scorings the list used to rank by,
12
- churn × (1 + recent fixes) × (1 + complexity) × (1.5 if single-owned) with each factor a share of the
13
- largest value ("max") or a rank among the scored files ("rank"), stay selectable so gitmole.evaluate
14
- can keep comparing them.
7
+ each file: how often it was fixed lately, who alone owns it, its most complex function, how much its
8
+ complexity grew in the last year, what it always changes with. A file's score is its share, in
9
+ percent, of all scored files' revisions × lines of code, so the scores of the whole list add up to
10
+ 100 and a change's `--risk` total is the share of that mass the change touches; one enormous file
11
+ takes a large share, as it should, and lowers the others' only by what it adds to the whole. The
12
+ factor products the list used to rank by live in gitmole.evaluate, which still compares them with it.
15
13
  Complexity is scc's per-file total, which exists for every file on one scale; lizard's most complex
16
14
  function in the file is what the reasons name, since lizard has no reader for shell, Terraform,
17
15
  Makefiles and the like."""
18
16
  from __future__ import annotations
19
17
 
20
- import bisect
21
18
  from collections import Counter, defaultdict
22
19
 
23
- try:
24
- from . import filetypes, hotspots, textfmt
25
- except ImportError: # pragma: no cover - not run as a script, but keep the package pattern
26
- import filetypes
27
- import hotspots
28
- import textfmt
20
+ from . import filetypes, hotspots, textfmt, trend
29
21
 
30
22
  CCN_FLOOR = 10 # lizard's own "complex" threshold: below it a function is not worth naming
31
23
  SOLO_SHARE = 0.9 # one author wrote at least this much of the file: single ownership
32
- SOLO_WEIGHT = 1.5 # how much single ownership lifts the factor-product scores
33
24
  COMPANION_DEGREE = 50 # a coupling worth mentioning
34
25
  COMPANION_REVS = 5 # ...over enough shared revisions to be a pattern
35
26
 
@@ -67,39 +58,16 @@ def _worst_function(report: dict) -> dict:
67
58
  return worst
68
59
 
69
60
 
70
- def _by_max(values: list, inclusive: bool):
71
- """x as a share of the largest value: the scaling the factor product first shipped with. One outlier
72
- moves everyone; inclusive is ignored here, since a share of the largest value has no edge to choose."""
73
- top = max(values)
74
- return lambda x: x / top if top else 0.0
75
-
76
-
77
- def _by_rank(values: list, inclusive: bool):
78
- """x as the share of the scored files at or below it (inclusive), or strictly below it. Churn is
79
- inclusive, so the most-changed file is 1 and no file is 0; fixes and complexity are strict, so a
80
- file with none of either gets no lift, as under _by_max. An outlier is one more file, not a new
81
- scale."""
82
- ordered = sorted(values)
83
- cut = bisect.bisect_right if inclusive else bisect.bisect_left
84
- return lambda x: cut(ordered, x) / len(ordered)
85
-
86
-
87
- SCALINGS = {"max": _by_max, "rank": _by_rank}
88
- SCORINGS = ("hotspot", *SCALINGS)
89
-
90
-
91
- def risks(report: dict, min_revs: int = 2, scoring: str = "hotspot") -> list:
92
- """The watch list: every scored file with its reasons, worst first. `scoring` is "hotspot" (the
93
- default: each file's score is its percentage share of the pool's revisions × lines of code), or
94
- one of the two factor products, "rank" and "max", kept so gitmole.evaluate can compare them with
95
- it."""
96
- if scoring not in SCORINGS:
97
- raise ValueError(f"scoring must be one of {', '.join(SCORINGS)}, got {scoring!r}")
61
+ def risks(report: dict, min_revs: int = 2) -> list:
62
+ """The watch list: every scored file with its reasons, worst first. A file's score is its
63
+ percentage share of the pool's revisions × lines of code."""
98
64
  owners = _owners(report)
99
65
  companions = _companions(report)
100
66
  worst = _worst_function(report)
101
67
  fixes = {f["entity"]: f for f in report.get("fixes") or []}
102
68
  n_authors = {a["entity"]: a["n-authors"] for a in report.get("authors") or []}
69
+ series = (report.get("trend") or {}).get("files") or {}
70
+ last = (report.get("meta") or {}).get("last_date") or ""
103
71
 
104
72
  plumb, derived = filetypes.plumbing_paths(report), hotspots.derived(report)
105
73
  rows = []
@@ -115,34 +83,24 @@ def risks(report: dict, min_revs: int = 2, scoring: str = "hotspot") -> list:
115
83
  rows.append({"file": h["entity"], "revs": h["revs"], "recent_fixes": fx.get("recent-fixes", 0), "fixes": fx.get("n-fixes", 0),
116
84
  "authors": n_authors.get(h["entity"]), "owner": owner, "owner_share": share,
117
85
  "complexity": h["complexity"] or 0, "code": h["code"],
118
- "function": fn, "companions": companions.get(h["entity"], [])})
86
+ "function": fn, "companions": companions.get(h["entity"], []),
87
+ "trend": trend.change_over_year(series[h["entity"]], last) if last and h["entity"] in series else None})
119
88
  if not rows:
120
89
  return []
121
90
 
122
- if scoring == "hotspot":
123
- pool = sum(r["revs"] * r["code"] for r in rows)
124
-
125
- def score(r):
126
- return 100 * (r["revs"] * r["code"]) / pool if pool else 0.0
127
- else:
128
- scale = SCALINGS[scoring]
129
- churn = scale([r["revs"] for r in rows], True)
130
- fixed = scale([r["recent_fixes"] for r in rows], False)
131
- cplx = scale([r["complexity"] for r in rows], False)
132
-
133
- def score(r):
134
- return churn(r["revs"]) * (1 + fixed(r["recent_fixes"])) * (1 + cplx(r["complexity"])) * (SOLO_WEIGHT if r["solo"] else 1)
91
+ pool = sum(r["revs"] * r["code"] for r in rows)
135
92
  for r in rows:
136
93
  r["solo"] = r["authors"] == 1 or r["owner_share"] >= SOLO_SHARE
137
- r["score"] = score(r)
94
+ r["score"] = 100 * (r["revs"] * r["code"]) / pool if pool else 0.0
138
95
  r["reasons"] = _reasons(r)
139
96
  rows.sort(key=lambda r: (-r["score"], -r["revs"], r["file"]))
140
97
  return rows
141
98
 
142
99
 
143
100
  def why_empty(report: dict, min_revs: int = 2) -> str:
144
- """Why risks() came back empty, for the report's one-line note: the honest reason, since
145
- "nothing changed" above a hotspots table full of revisions would be a lie."""
101
+ """Why risks() came back empty, for the report's one-line note: the honest reason, since files
102
+ can well have changed even though none of them scored, and a flat "nothing changed" would be
103
+ a lie about them."""
146
104
  churned = [h for h in hotspots.ranked(report) if h["revs"] >= min_revs]
147
105
  if not churned:
148
106
  return "nothing changed more than once"
@@ -167,6 +125,9 @@ def _reasons(r: dict) -> list:
167
125
  if fn and fn["ccn"] >= CCN_FLOOR:
168
126
  named = f"the function at line {fn['start']}" if fn.get("anonymous") else f"{fn['function']}()"
169
127
  out.append(f"{named} complexity {fn['ccn']}")
128
+ grown = r.get("trend") or ""
129
+ if grown.startswith("+") and int(grown[1:-1]) >= trend.GROWTH_FLOOR:
130
+ out.append(f"complexity {grown} in a year") # the Hotspots table's trend column, which the default report no longer shows
170
131
  if r["companions"]:
171
132
  other, degree = r["companions"][0]
172
133
  more = len(r["companions"]) - 1
@@ -179,9 +140,9 @@ WATCH_TOP = 15 # the same cap the report's --full watch list uses
179
140
 
180
141
 
181
142
  def change_risk(report: dict, files: list) -> dict:
182
- """The watch score of each touched file, and their sum: under the default "hotspot" scoring, that
183
- total is a percentage of the repository's revisions × lines of code. Files the watch list never
184
- scored get 0 and one reason saying why."""
143
+ """The watch score of each touched file, and their sum: that total is a percentage of the
144
+ repository's revisions × lines of code. Files the watch list never scored get 0 and one reason
145
+ saying why."""
185
146
  ranked = risks(report)
186
147
  by_file = {r["file"]: r for r in ranked}
187
148
  watched = {r["file"] for r in ranked[:WATCH_TOP]}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitmole
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Offline git repository analysis with a terminal report: hotspots, coupling, ownership, code age, secrets, repo health.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/antvinni/gitmole
@@ -108,11 +108,11 @@ ranks by revisions × lines of code: measured at six cut-offs on three
108
108
  repositories
109
109
  ([validation](https://github.com/antvinni/gitmole/blob/main/docs/validation.md)),
110
110
  that named more of the files fixed next than churn alone, size alone or a
111
- weighted product of fixes, complexity and ownership. Between the header and
112
- that list the full report puts its findings, 16 for react (1 critical, 6
113
- warnings, 9 notes); below it, tables for people, the knowledge map, the
114
- timeline, hotspots with their complexity trend, change coupling, complex
115
- functions and repo health. Every section is explained in
111
+ weighted product of fixes, complexity and ownership. Between the header and that
112
+ list the full report puts its findings, 16 for react (1 critical, 6 warnings, 9
113
+ notes); below it, tables for people, the knowledge map, the timeline, change
114
+ coupling, complex functions and repo health; `--full` adds the hotspots table
115
+ behind the list, size, activity and code age. Every section is explained in
116
116
  [docs/output.md](https://github.com/antvinni/gitmole/blob/main/docs/output.md).
117
117
 
118
118
  Reports on repositories you know, each at a pinned commit with a fixed
@@ -54,6 +54,40 @@ class Score(unittest.TestCase):
54
54
  self.assertEqual(evaluate.report_at(commits, "2025-06-01", SIZE, {})["ownership"], [])
55
55
 
56
56
 
57
+ ROWS = [
58
+ {"file": "core/parser.py", "revs": 40, "recent_fixes": 5, "complexity": 40, "solo": True, "code": 800},
59
+ {"file": "web/index.html", "revs": 60, "recent_fixes": 0, "complexity": 0, "solo": False, "code": 4000},
60
+ {"file": "core/util.py", "revs": 30, "recent_fixes": 0, "complexity": 5, "solo": True, "code": 200},
61
+ ]
62
+
63
+
64
+ class FactorProduct(unittest.TestCase):
65
+ def test_both_scalings_lead_with_the_fixed_complex_single_owned_file(self):
66
+ for scaling in ("max", "rank"):
67
+ self.assertEqual(evaluate.factor_product(ROWS, scaling), ["core/parser.py", "web/index.html", "core/util.py"], scaling)
68
+
69
+ def test_a_file_never_fixed_gets_no_lift_from_fixes_under_either_scaling(self):
70
+ for scaling in ("max", "rank"):
71
+ self.assertEqual(evaluate.factor_scores(ROWS, scaling)["web/index.html"], 1.0, f"{scaling}: most changed, no fixes, no complexity, shared")
72
+
73
+ def test_under_rank_scaling_an_outlier_does_not_rescale_the_other_files(self):
74
+ def util(outlier_revs, scaling):
75
+ big = {"file": "core/big.py", "revs": outlier_revs, "recent_fixes": 0, "complexity": 0, "solo": False, "code": 10}
76
+ return evaluate.factor_scores(ROWS + [big], scaling)["core/util.py"]
77
+ self.assertEqual(util(100, "rank"), util(10000, "rank"))
78
+ self.assertNotEqual(util(100, "max"), util(10000, "max"), "what the rank scaling is for")
79
+
80
+ def test_two_files_that_differ_only_in_complexity(self):
81
+ pair = [{"file": "ops/deploy.sh", "revs": 40, "recent_fixes": 0, "complexity": 80, "solo": False, "code": 300},
82
+ {"file": "ops/plain.sh", "revs": 40, "recent_fixes": 0, "complexity": 0, "solo": False, "code": 300}]
83
+ for scaling in ("max", "rank"):
84
+ scores = evaluate.factor_scores(ROWS + pair, scaling)
85
+ self.assertGreater(scores["ops/deploy.sh"], scores["ops/plain.sh"], f"{scaling}: complexity lifts the factor product")
86
+
87
+ def test_no_rows_is_no_list(self):
88
+ self.assertEqual(evaluate.factor_product([], "rank"), [])
89
+
90
+
57
91
  class Table(unittest.TestCase):
58
92
  def test_one_row_per_variant_one_column_per_cut_off_and_a_total(self):
59
93
  text = evaluate.table([("2025-02-28", 3, 40, {"churn": 1, "random (expected)": 0.4}),
@@ -52,6 +52,15 @@ def rendered(report, findings, width=120, full=False):
52
52
  return console.export_text()
53
53
 
54
54
 
55
+ def _rendered_section(sec: dict, width=120) -> str:
56
+ """One section drawn on its own, the way `rendered` draws a whole report: for Hotspots, which
57
+ the default terminal report no longer carries, but whose drawing (folding, eliding, hiding) is
58
+ still worth checking directly."""
59
+ console = Console(file=io.StringIO(), width=width, record=True, force_terminal=False, color_system=None)
60
+ render.print_section(console, sec)
61
+ return console.export_text()
62
+
63
+
55
64
  class Report(unittest.TestCase):
56
65
  def test_header_shows_name_commits_span_and_languages(self):
57
66
  text = rendered(sample_report(), [])
@@ -260,23 +269,23 @@ class Report(unittest.TestCase):
260
269
  r["meta"]["last_date"] = "2026-09-10"
261
270
  r["trend"] = {"samples": ["2025-09-10", "2026-03-10", "2026-09-10"],
262
271
  "files": {"static/index.html": [["2025-09-10", 10, 4000], ["2026-03-10", 12, 4000], ["2026-09-10", 16, 4000]]}}
263
- text = rendered(r, [])
272
+ text = _rendered_section(render.hotspots_section(r, full="markdown", width=120))
264
273
  self.assertRegex(text, r"file\s+revs\s+lines\s+fixes\s+authors\s+trend")
265
274
  self.assertRegex(text, r"static/index\.html\s+51\s+4,000\s+0\s+-\s+\+60%")
266
275
  self.assertRegex(text, r"static/apps-metadata\.json\s+128\s+800\s+9\s+4\s+-")
267
276
  self.assertRegex(rendered(r, [], full=True), r"static/index\.html.*▁▃█")
268
- self.assertRegex(rendered(sample_report(), []), r"static/index\.html\s+51\s+4,000\s+0\s+-\s+-")
277
+ self.assertRegex(_rendered_section(render.hotspots_section(sample_report(), full="markdown", width=120)),
278
+ r"static/index\.html\s+51\s+4,000\s+0\s+-\s+-")
269
279
 
270
280
  def test_full_hotspots_say_the_trend_column_covers_the_top_ten(self):
271
281
  r = sample_report()
272
282
  r["meta"]["last_date"] = "2026-09-10"
273
283
  def caption(rep, full):
274
- return next(x for x in render.sections(rep, full=full) if x["id"] == "hotspots")["caption"]
284
+ return render.hotspots_section(rep, full=full, width=None)["caption"]
275
285
  self.assertIsNone(caption(r, True), "no trend data, nothing to explain")
276
286
  r["trend"] = {"samples": ["2025-09-10", "2026-09-10"],
277
287
  "files": {"static/index.html": [["2025-09-10", 10, 4000], ["2026-09-10", 16, 4000]]}}
278
288
  self.assertEqual(caption(r, True), "trend sampled for the top 10 hotspots")
279
- self.assertIsNone(caption(r, False), "the tight report keeps its captions short")
280
289
  r["revisions"] = [{"entity": f"f{i}.py", "n-revs": 100 - i} for i in range(60)]
281
290
  r["size"]["files"].update({f"f{i}.py": {"code": 10, "complexity": 0} for i in range(60)}) # in the tree, so not hidden as deleted
282
291
  self.assertEqual(caption(r, "markdown"), "and 10 more; trend sampled for the top 10 hotspots")
@@ -311,12 +320,11 @@ class Report(unittest.TestCase):
311
320
  self.assertIn("ranked by revisions × lines of code; the reasons say what else counts against each file; commits since 2026-01-01", caption)
312
321
  self.assertTrue(caption.endswith("the 2 most changed would name 1); whole history"), caption)
313
322
 
314
- def test_default_hotspots_hide_test_files_and_say_so(self):
323
+ def test_markdown_hotspots_hide_test_files_and_say_so(self):
315
324
  r = sample_report()
316
325
  r["revisions"].append({"entity": "tests/test_a.py", "n-revs": 200})
317
326
  r["size"]["files"]["tests/test_a.py"] = {"code": 50, "complexity": 1}
318
- text = rendered(r, [])
319
- hot = text[text.index("◆ Hotspots"):]
327
+ hot = _rendered_section(render.hotspots_section(r, full="markdown", width=120))
320
328
  self.assertNotIn("tests/test_a.py", hot)
321
329
  self.assertIn("1 test file hidden; --full shows them", hot)
322
330
  full_text = rendered(r, [], full=True)
@@ -371,14 +379,14 @@ class Report(unittest.TestCase):
371
379
  full = _section_text(rendered(r, [], width=200, full=True), "Complex functions")
372
380
  self.assertIn("vendor/github.com/x/y.go", full)
373
381
 
374
- def test_default_tables_hide_generated_files_and_say_so(self):
382
+ def test_markdown_tables_hide_generated_files_and_say_so(self):
375
383
  r = sample_report()
376
384
  r["meta"]["generated"] = ["lib/config-validator.js"]
377
385
  r["size"]["files"]["lib/config-validator.js"] = {"code": 1153, "complexity": 373}
378
386
  r["revisions"].append({"entity": "lib/config-validator.js", "n-revs": 8})
379
387
  r["functions"].append({"file": "lib/config-validator.js", "function": "validate10", "ccn": 373, "nloc": 1150, "params": 5, "start": 1, "end": 1150})
380
388
  text = rendered(r, [], width=200)
381
- hot = text[text.index("◆ Hotspots"):text.index("Change coupling")]
389
+ hot = _rendered_section(render.hotspots_section(r, full="markdown", width=200), width=200)
382
390
  self.assertNotIn("config-validator", hot)
383
391
  self.assertIn("1 generated file hidden; --full shows them", hot)
384
392
  fn = _section_text(text, "Complex functions")
@@ -387,24 +395,22 @@ class Report(unittest.TestCase):
387
395
  full = rendered(r, [], width=200, full=True)
388
396
  self.assertIn("validate10", full)
389
397
 
390
- def test_default_hotspots_hide_release_plumbing_and_say_so(self):
398
+ def test_markdown_hotspots_hide_release_plumbing_and_say_so(self):
391
399
  r = sample_report()
392
400
  r["size"]["files"].update({"setup.py": {"code": 6, "complexity": 0}, "version.go": {"code": 2, "complexity": 0}})
393
401
  r["revisions"] += [{"entity": "setup.py", "n-revs": 184}, {"entity": "version.go", "n-revs": 29}]
394
- hot = rendered(r, [], width=200)
395
- hot = hot[hot.index("◆ Hotspots"):hot.index("Change coupling")]
402
+ hot = _rendered_section(render.hotspots_section(r, full="markdown", width=200), width=200)
396
403
  self.assertNotIn("setup.py", hot)
397
404
  self.assertIn("2 release files hidden; --full shows them", hot)
398
405
  full = rendered(r, [], width=200, full=True)
399
406
  self.assertIn("setup.py", full[full.index("◆ Hotspots"):])
400
407
 
401
- def test_default_hotspots_hide_files_the_change_log_shows_as_plumbing(self):
408
+ def test_markdown_hotspots_hide_files_the_change_log_shows_as_plumbing(self):
402
409
  r = sample_report()
403
410
  r["size"]["files"]["pkg/__init__.py"] = {"code": 40, "complexity": 0}
404
411
  r["revisions"].append({"entity": "pkg/__init__.py", "n-revs": 331})
405
412
  r["plumbing"] = [{"entity": "pkg/__init__.py", "n-revs": 331, "tiny-revs": 300}]
406
- hot = rendered(r, [], width=200)
407
- hot = hot[hot.index("◆ Hotspots"):hot.index("Change coupling")]
413
+ hot = _rendered_section(render.hotspots_section(r, full="markdown", width=200), width=200)
408
414
  self.assertNotIn("pkg/__init__.py", hot)
409
415
  self.assertIn("1 release file hidden; --full shows them", hot)
410
416
 
@@ -475,7 +481,7 @@ class Report(unittest.TestCase):
475
481
  r = sample_report()
476
482
  r["revisions"] = [{"entity": "tests/test_a.py", "n-revs": 200}]
477
483
  r["size"]["files"] = {"tests/test_a.py": {"code": 50, "complexity": 1}}
478
- hot = _section_text(rendered(r, [], width=200), "\u25c6 Hotspots")
484
+ hot = _rendered_section(render.hotspots_section(r, full="markdown", width=200), width=200)
479
485
  self.assertIn("no source hotspots; 1 test file hidden; --full shows them", hot)
480
486
 
481
487
  def test_default_coupling_hides_pairs_of_deleted_files_and_says_so(self):
@@ -490,10 +496,10 @@ class Report(unittest.TestCase):
490
496
  self.assertIn("static/tax.html", full)
491
497
  self.assertNotIn("hidden", full)
492
498
 
493
- def test_default_hotspots_hide_deleted_files_and_say_so(self):
499
+ def test_markdown_hotspots_hide_deleted_files_and_say_so(self):
494
500
  r = sample_report() # the tree holds static/index.html and static/apps-metadata.json only
495
501
  r["revisions"].append({"entity": "src/sizes/old.go", "n-revs": 40})
496
- hot = _section_text(rendered(r, [], width=200), "◆ Hotspots")
502
+ hot = _rendered_section(render.hotspots_section(r, full="markdown", width=200), width=200)
497
503
  self.assertNotIn("src/sizes/old.go", hot)
498
504
  self.assertIn("1 deleted file hidden; --full shows them", hot)
499
505
  full = _section_text(rendered(r, [], width=200, full=True), "◆ Hotspots")
@@ -503,7 +509,7 @@ class Report(unittest.TestCase):
503
509
  def test_hotspots_without_a_tree_listing_hide_nothing(self):
504
510
  r = sample_report()
505
511
  r["size"]["files"] = {}
506
- hot = _section_text(rendered(r, [], width=200), "◆ Hotspots")
512
+ hot = _rendered_section(render.hotspots_section(r, full="markdown", width=200), width=200)
507
513
  self.assertIn("static/index.html", hot)
508
514
  self.assertNotIn("deleted", hot)
509
515
 
@@ -745,7 +751,7 @@ class WatchList(unittest.TestCase):
745
751
  self.assertEqual(sec["caption"], "ranked by revisions × lines of code; the reasons say what else counts against each file; commits since 2025-01-01")
746
752
 
747
753
 
748
- class DescriptiveTables(unittest.TestCase):
754
+ class FullOnlySections(unittest.TestCase):
749
755
  def test_default_report_leaves_them_out_and_full_brings_them_back(self):
750
756
  text = rendered(sample_report(), [])
751
757
  for title in ("Size by language", "Activity", "Surviving code by year written"):
@@ -754,6 +760,14 @@ class DescriptiveTables(unittest.TestCase):
754
760
  for title in ("Size by language", "Activity", "Surviving code by year written"):
755
761
  self.assertIn(title, full, title)
756
762
 
763
+ def test_hotspots_moved_to_full_and_markdown_alongside_the_other_descriptive_tables(self):
764
+ self.assertIn("hotspots", render.FULL_ONLY)
765
+ text = rendered(sample_report(), [])
766
+ self.assertNotIn("◆ Hotspots", text)
767
+ full = rendered(sample_report(), [], full=True)
768
+ self.assertIn("◆ Hotspots", full)
769
+ self.assertIn("## Hotspots", render.markdown(sample_report(), []))
770
+
757
771
  def test_header_keeps_one_line_of_them(self):
758
772
  r = sample_report()
759
773
  r["activity"]["fix_commits"] = 58
@@ -869,7 +883,7 @@ class Timeline(unittest.TestCase):
869
883
  r["meta"]["bots"] = [{"name": "GitHub", "commits": 12}] # actions@github.com: a bot by its address, not its name
870
884
  r["activity"]["timeline"]["GitHub"] = {"2026-08": 30, "2026-09": 40}
871
885
  text = rendered(r, [], width=120)
872
- timeline = text.split("▦ Timeline")[1].split("◆ Hotspots")[0]
886
+ timeline = text.split("▦ Timeline")[1].split("⟷ Change coupling")[0]
873
887
  self.assertNotIn("GitHub", timeline)
874
888
  self.assertIn("Ann", timeline)
875
889
 
@@ -895,6 +909,50 @@ class Timeline(unittest.TestCase):
895
909
  r["activity"] = {}
896
910
  self.assertIn("no timeline data", rendered(r, []))
897
911
 
912
+ def test_a_name_is_never_folded_the_oldest_months_go_instead(self):
913
+ r = sample_report()
914
+ r["activity"]["timeline"] = {"antvinni": {f"2025-{m:02d}": 3 for m in range(10, 13)} | {f"2026-{m:02d}": 3 for m in range(1, 10)}}
915
+ text = rendered(r, [], width=80)
916
+ body = _section_text(text, "Timeline")
917
+ self.assertIn("antvinni", body, "the name on one line")
918
+ sec = next(s for s in render.sections(r, full=False, width=80) if s["id"] == "timeline")
919
+ self.assertLess(len(sec["columns"]) - 1, 12, "fewer months than the year, since the year does not fit")
920
+ self.assertTrue(sec["title"].endswith("→ Sep 2026)"), sec["title"])
921
+ self.assertNotIn("Oct 2025", sec["title"], "the title names the months shown")
922
+ wide = next(s for s in render.sections(r, full=False, width=120) if s["id"] == "timeline")
923
+ self.assertEqual(len(wide["columns"]) - 1, 12, "room for the whole year at 120")
924
+
925
+ def test_a_very_long_name_still_leaves_at_least_three_months(self):
926
+ r = sample_report()
927
+ name = "a" * 70 # long enough that even the floor does not leave room for the whole name
928
+ r["activity"]["timeline"] = {name: {f"2025-{m:02d}": 3 for m in range(10, 13)} | {f"2026-{m:02d}": 3 for m in range(1, 10)}}
929
+ text = rendered(r, [], width=80)
930
+ body = _section_text(text, "Timeline")
931
+ sec = next(s for s in render.sections(r, full=False, width=80) if s["id"] == "timeline")
932
+ self.assertEqual(len(sec["columns"]) - 1, 3, "the floor: three months even though the name leaves almost no room")
933
+ self.assertEqual(sec["title"], "Timeline (Jul 2026 → Sep 2026)")
934
+ section_text = body.split("\n\n", 1)[0]
935
+ for month in ("Jul", "Aug", "Sep"):
936
+ self.assertIn(month, section_text, f"the {month} column header is fully visible, not starved to nothing")
937
+ self.assertIn("3", section_text, "the counts under the shown months are visible")
938
+ self.assertNotIn(name, body, "the full 70-character name does not fit even at the floor")
939
+ self.assertIn("…", section_text, "the name gives way, cut with an ellipsis, rather than the months")
940
+ self.assertEqual(len(section_text.splitlines()), 4, "one row, not a name folded onto a second line")
941
+ for line in section_text.splitlines():
942
+ self.assertLessEqual(len(line), 80, "no line wider than the terminal")
943
+
944
+ def test_a_name_just_over_the_floors_room_still_leaves_full_month_headers(self):
945
+ r = sample_report()
946
+ name = "a" * 62 # over the 60-character room the floor leaves (width 80, 3 months): headers used to starve first
947
+ r["activity"]["timeline"] = {name: {f"2025-{m:02d}": 3 for m in range(10, 13)} | {f"2026-{m:02d}": 3 for m in range(1, 10)}}
948
+ text = rendered(r, [], width=80)
949
+ body = _section_text(text, "Timeline")
950
+ section_text = body.split("\n\n", 1)[0]
951
+ for month in ("Jul", "Aug", "Sep"):
952
+ self.assertIn(month, section_text, f"the {month} header is whole, not truncated to a letter and an ellipsis")
953
+ sec = next(s for s in render.sections(r, full=False, width=80) if s["id"] == "timeline")
954
+ self.assertEqual(len(sec["columns"]) - 1, 3)
955
+
898
956
 
899
957
  class Layout(unittest.TestCase):
900
958
  def test_header_carries_the_findings_tally(self):
@@ -915,7 +973,8 @@ class Layout(unittest.TestCase):
915
973
  def test_sections_open_with_a_symbol_and_a_title(self):
916
974
  text = rendered(sample_report(), [], width=80)
917
975
  self.assertRegex(text, r"\n\n◉ People\n")
918
- self.assertRegex(text, r"\n\n◆ Hotspots\n")
976
+ full = rendered(sample_report(), [], width=80, full=True)
977
+ self.assertRegex(full, r"\n\n◆ Hotspots \(score = revisions × lines of code\)\n")
919
978
  self.assertNotIn("─────", text.split("◉ People")[1].split("\n")[0], "no rule across the width")
920
979
 
921
980
  def test_small_tables_sit_side_by_side_on_wide_terminals(self):
@@ -952,9 +1011,11 @@ class Layout(unittest.TestCase):
952
1011
  def test_default_columns_are_the_ones_you_read(self):
953
1012
  secs = {x["title"]: x for x in render.sections(sample_report(), full=False)}
954
1013
  self.assertNotIn("Size by language", secs)
1014
+ self.assertNotIn("Hotspots", secs, "hotspots is --full and Markdown only")
955
1015
  self.assertEqual(secs["People"]["columns"], ["author", "commits", "share", "surviving code"])
956
- self.assertEqual([x for x in secs if x.startswith("Hotspots")], ["Hotspots"])
957
- self.assertEqual(secs["Hotspots"]["columns"], ["file", "revs", "lines", "fixes", "authors", "trend"])
1016
+ hot = render.hotspots_section(sample_report(), full="markdown", width=None)
1017
+ self.assertEqual(hot["title"], "Hotspots")
1018
+ self.assertEqual(hot["columns"], ["file", "revs", "lines", "fixes", "authors", "trend"])
958
1019
  self.assertEqual(secs["Change coupling"]["columns"], ["file", "changes with", "degree"])
959
1020
  self.assertEqual(secs["Knowledge map"]["columns"], ["area", "lines added", "main owner", "second"])
960
1021
 
@@ -966,26 +1027,21 @@ class Layout(unittest.TestCase):
966
1027
  self.assertIn("avg revs", secs["Change coupling"]["columns"])
967
1028
 
968
1029
  def test_row_caps_and_the_more_line(self):
1030
+ # the 8-row default cap with "and N more" is pinned for Timeline instead
1031
+ # (test_full_lifts_the_timeline_cap): Hotspots has no default-report row cap of its own any
1032
+ # more, since it only ships under --full and Markdown. Under --full it shows every row.
969
1033
  r = sample_report()
970
1034
  r["revisions"] = [{"entity": f"f{i}.py", "n-revs": 100 - i} for i in range(12)]
971
1035
  r["size"]["files"] = {f"f{i}.py": {"code": 10, "complexity": 0} for i in range(12)}
972
- compact = {x["title"]: x for x in render.sections(r, full=False)}["Hotspots"]
973
- self.assertEqual(len(compact["rows"]), 8)
974
- self.assertEqual(compact["caption"], "and 4 more")
975
1036
  full = {x["title"]: x for x in render.sections(r, full=True)}["Hotspots (score = revisions × lines of code)"]
976
1037
  self.assertEqual(len(full["rows"]), 12)
977
1038
  self.assertIsNone(full["caption"])
978
1039
 
979
- def test_long_paths_are_elided_not_folded(self):
980
- r = sample_report()
981
- long = "packages/core/src/repowise/core/pipeline/persist_and_more_words.py"
982
- r["revisions"] = [{"entity": long, "n-revs": 50}]
983
- r["size"]["files"] = {long: {"code": 100, "complexity": 1}}
984
- text = rendered(r, [], width=80)
985
- self.assertIn("…/pipeline/persist_and_more_words.py", text)
986
- self.assertNotIn(long, text)
987
- hot = text[text.index("\n◆ Hotspots"):]
988
- self.assertNotRegex(hot, r"\n\s*[a-z_]+\.py\s*\n", "no folded file-name tails")
1040
+ # test_long_paths_are_elided_not_folded removed: it pinned Hotspots eliding long paths at a
1041
+ # narrow width, which no longer happens in any shipped mode (Hotspots only ships under --full,
1042
+ # which restores every column and never elides, and Markdown, which never passes a width). The
1043
+ # same "elided, not folded" behaviour is already pinned for Complex functions, which stays in
1044
+ # the default report, by test_long_paths_are_elided_like_every_other_table above.
989
1045
 
990
1046
  def test_threshold_styles(self):
991
1047
  self.assertIsNone(render.cell_style("degree", "70%"))
@@ -1012,16 +1068,16 @@ class ReviewFixes(unittest.TestCase):
1012
1068
  self.assertEqual((len(full["rows"]), full["caption"]), (12, None))
1013
1069
 
1014
1070
  def test_paths_fit_next_to_wide_numbers_at_narrow_widths(self):
1071
+ # re-pointed at Complex functions: Hotspots no longer elides paths in any shipped mode, so
1072
+ # this narrow-width edge case is pinned on a table that still elides in the default report.
1015
1073
  r = sample_report()
1016
1074
  long = "services/payments/adapters/stripe_webhook_handler_v2.py"
1017
- r["revisions"] = [{"entity": long, "n-revs": 12345}]
1018
- r["size"]["files"] = {long: {"code": 1234567, "complexity": 9}}
1019
- # 75 is the narrowest width where the 31-character file name fits beside these numbers
1020
- for width in (75, 76, 84):
1021
- text = rendered(r, [], width=width)
1022
- hot = text[text.index("\n◆ Hotspots"):]
1023
- self.assertNotRegex(hot, r"\n\s*[a-z_0-9]+\.py\s*\n", f"folded tail at width {width}")
1024
- self.assertNotRegex(hot, r"\.p\s*\n", f"file name cut at width {width}")
1075
+ r["functions"] = [{"file": long, "function": "handle", "ccn": 12345, "nloc": 1234567, "params": 9, "start": 1, "end": 2}]
1076
+ # 67 is the narrowest width where the 28-character file name fits beside these numbers
1077
+ for width in (67, 68, 84):
1078
+ fn = _section_text(rendered(r, [], width=width), "Complex functions")
1079
+ self.assertNotRegex(fn, r"\n\s*[a-z_0-9]+\.py\s*\n", f"folded tail at width {width}")
1080
+ self.assertNotRegex(fn, r"\.p\s*\n", f"file name cut at width {width}")
1025
1081
 
1026
1082
  def test_markdown_rows_are_capped_unless_full(self):
1027
1083
  r = sample_report()
@@ -1129,6 +1185,7 @@ class Json(unittest.TestCase):
1129
1185
  self.assertIn("cohorts", d)
1130
1186
  self.assertEqual(d["watch"][0]["file"], "static/index.html") # 51 × 4000 beats 128 × 800
1131
1187
  self.assertIn("reasons", d["watch"][0])
1188
+ self.assertIn("trend", d["watch"][0])
1132
1189
 
1133
1190
  def test_the_nested_backtest_sub_report_is_left_out(self):
1134
1191
  r = sample_report()
@@ -23,6 +23,17 @@ class ShortenPath(unittest.TestCase):
23
23
  self.assertEqual(textfmt.shorten_path("Makefile", 5), "Makefile")
24
24
 
25
25
 
26
+ class Cut(unittest.TestCase):
27
+ def test_short_string_is_unchanged(self):
28
+ self.assertEqual(textfmt.cut("gitmole/cli.py", 30), "gitmole/cli.py")
29
+
30
+ def test_long_string_is_cut_to_exactly_cap_characters_ending_in_the_ellipsis(self):
31
+ name = "a" * 500
32
+ cut = textfmt.cut(name, 10)
33
+ self.assertEqual(len(cut), 10)
34
+ self.assertTrue(cut.endswith(textfmt.ELLIPSIS))
35
+
36
+
26
37
  class GroupFindings(unittest.TestCase):
27
38
  def test_same_title_findings_merge_into_one_with_a_list(self):
28
39
  found = [
@@ -56,10 +56,6 @@ class Risks(unittest.TestCase):
56
56
  ranked = watch.risks(r)
57
57
  self.assertEqual([x["score"] for x in ranked], [0.0, 0.0, 0.0], "revs × 0 is 0 for every row, so the sum is 0 and every score falls back to 0.0")
58
58
 
59
- def test_the_factor_products_stay_selectable_for_the_evaluation(self):
60
- for scoring in ("rank", "max"):
61
- self.assertEqual(watch.risks(report(), scoring=scoring)[0]["file"], "core/parser.py", scoring)
62
-
63
59
  def test_reasons_in_plain_words(self):
64
60
  top = {r["file"]: r for r in watch.risks(report())}["core/parser.py"]
65
61
  self.assertEqual(top["reasons"], ["changed 40 times", "fixed 5 times in six months",
@@ -131,9 +127,9 @@ class Risks(unittest.TestCase):
131
127
  r = report()
132
128
  r["size"]["files"]["ops/deploy.sh"] = {"code": 300, "complexity": 80}
133
129
  r["revisions"].append({"entity": "ops/deploy.sh", "n-revs": 40})
134
- # ops/plain.sh matches deploy.sh in everything the factor product reads except
135
- # complexity: same revisions, same lines of code, no fixes or ownership rows for
136
- # either. So any score difference between the two is complexity's doing, nothing else.
130
+ # ops/plain.sh differs from deploy.sh only in complexity: same revisions, same lines of
131
+ # code, no fixes or ownership rows for either. The test below compares only the list's
132
+ # own ranking, which is revisions × lines of code and does not read complexity at all.
137
133
  r["size"]["files"]["ops/plain.sh"] = {"code": 300, "complexity": 0}
138
134
  r["revisions"].append({"entity": "ops/plain.sh", "n-revs": 40})
139
135
  by = {x["file"]: x for x in watch.risks(r)}
@@ -143,35 +139,36 @@ class Risks(unittest.TestCase):
143
139
  # revs × code is 40 × 300 for both, so the hotspot rank does not see complexity at all.
144
140
  self.assertEqual(by["ops/deploy.sh"]["score"], by["ops/plain.sh"]["score"],
145
141
  "complexity does not enter the hotspot rank")
146
- by_rank = {x["file"]: x for x in watch.risks(r, scoring="rank")}
147
- self.assertGreater(by_rank["ops/deploy.sh"]["score"], by_rank["ops/plain.sh"]["score"],
148
- "the two differ only in complexity, and the factor product lifts the more complex one")
149
-
150
-
151
- def test_rank_scaling_keeps_the_order_of_the_synthetic_repo(self):
152
- self.assertEqual([r["file"] for r in watch.risks(report(), scoring="rank")], ["core/parser.py", "web/index.html", "core/util.py"])
153
-
154
- def test_under_rank_scaling_an_outlier_does_not_rescale_the_other_files(self):
155
- def scores(outlier_revs, scoring):
156
- r = report()
157
- r["size"]["files"]["core/big.py"] = {"code": 10, "complexity": 0}
158
- r["revisions"].append({"entity": "core/big.py", "n-revs": outlier_revs})
159
- return {x["file"]: x["score"] for x in watch.risks(r, scoring=scoring)}
160
- self.assertEqual(scores(100, "rank")["core/util.py"], scores(10000, "rank")["core/util.py"])
161
- self.assertNotEqual(scores(100, "max")["core/util.py"], scores(10000, "max")["core/util.py"], "what the rank scaling is for")
162
142
 
163
- def test_a_file_never_fixed_gets_no_lift_from_fixes_under_either_scaling(self):
164
- for scoring in ("max", "rank"):
165
- by = {r["file"]: r for r in watch.risks(report(), scoring=scoring)}
166
- self.assertEqual(by["web/index.html"]["score"], 1.0, f"{scoring}: most changed, no fixes, no complexity, shared")
167
-
168
- def test_an_unknown_scaling_is_refused(self):
169
- with self.assertRaises(ValueError):
170
- watch.risks(report(), scoring="median")
171
-
172
- def test_hotspot_is_the_default_scoring(self):
173
- r = report()
174
- self.assertEqual([x["score"] for x in watch.risks(r)], [x["score"] for x in watch.risks(r, scoring="hotspot")])
143
+ def test_there_is_one_ranking_and_no_scoring_to_choose(self):
144
+ import inspect
145
+ self.assertEqual(list(inspect.signature(watch.risks).parameters), ["report", "min_revs"])
146
+
147
+ def test_a_year_of_growing_complexity_is_a_reason_and_anything_less_is_not(self):
148
+ def reasons(series):
149
+ r = report(trend={"samples": [], "files": {"core/parser.py": series}})
150
+ r["meta"]["last_date"] = "2026-09-10"
151
+ return {x["file"]: x for x in watch.risks(r)}["core/parser.py"]
152
+ grown = reasons([["2025-09-01", 10, 300], ["2026-09-01", 32, 800]])
153
+ self.assertEqual(grown["trend"], "+220%")
154
+ self.assertIn("complexity +220% in a year", grown["reasons"])
155
+ self.assertEqual(grown["reasons"].index("complexity +220% in a year"), grown["reasons"].index("parse() complexity 41") + 1, "right after the function it is about")
156
+ for series in ([["2025-09-01", 10, 300], ["2026-09-01", 12, 800]], # +20%: under the floor
157
+ [["2025-09-01", 40, 300], ["2026-09-01", 10, 800]], # shrinking is not a reason
158
+ []): # sampled, but with nothing to compare
159
+ self.assertFalse([x for x in reasons(series)["reasons"] if "in a year" in x], series)
160
+
161
+ def test_a_file_the_trend_step_did_not_sample_has_no_trend(self):
162
+ by = {x["file"]: x for x in watch.risks(report())}
163
+ self.assertIsNone(by["core/parser.py"]["trend"])
164
+
165
+ def test_the_floor_is_inclusive_at_exactly_25_percent(self):
166
+ def reasons(now):
167
+ r = report(trend={"samples": [], "files": {"core/parser.py": [["2025-09-01", 100, 300], ["2026-09-01", now, 800]]}})
168
+ r["meta"]["last_date"] = "2026-09-10"
169
+ return {x["file"]: x for x in watch.risks(r)}["core/parser.py"]["reasons"]
170
+ self.assertIn("complexity +25% in a year", reasons(125), "125 is a 25% rise over 100: right at the floor")
171
+ self.assertFalse([x for x in reasons(124) if "in a year" in x], "124 is a 24% rise over 100: just under the floor")
175
172
 
176
173
 
177
174
  class WhyEmpty(unittest.TestCase):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes