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.
- {gitmole-0.8.0 → gitmole-0.9.0}/PKG-INFO +6 -6
- {gitmole-0.8.0 → gitmole-0.9.0}/README.md +5 -5
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/__init__.py +1 -1
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/cli.py +1 -1
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/evaluate.py +49 -4
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/findings.py +1 -1
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/load.py +2 -6
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/render.py +38 -10
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/textfmt.py +5 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/trend.py +3 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/watch.py +25 -64
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole.egg-info/PKG-INFO +6 -6
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_evaluate.py +34 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_render.py +102 -45
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_textfmt.py +11 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_watch.py +32 -35
- {gitmole-0.8.0 → gitmole-0.9.0}/LICENSE +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/__main__.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/backtest.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/banner.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/blame.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/clean.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/coupling.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/deps.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/duplicates.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/filetypes.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/functions.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/hotspots.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/identity.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/knowledge.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/leaks.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/loss.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/maat.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole/run.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole.egg-info/SOURCES.txt +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole.egg-info/dependency_links.txt +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole.egg-info/entry_points.txt +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole.egg-info/requires.txt +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/gitmole.egg-info/top_level.txt +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/pyproject.toml +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/setup.cfg +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_backtest.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_banner.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_blame.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_clean.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_cli.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_coupling.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_deps.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_duplicates.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_filetypes.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_findings.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_functions.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_golden.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_hotspots.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_identity.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_knowledge.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_leaks.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_load.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_loss.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_maat.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_packaging.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_render_examples.py +0 -0
- {gitmole-0.8.0 → gitmole-0.9.0}/tests/test_run.py +0 -0
- {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.
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
@@ -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
|
|
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)":
|
|
60
|
-
"factor product (rank-scaled)":
|
|
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 =
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
8
|
-
always changes with. A file's score is its share, in
|
|
9
|
-
of code, so the scores of the whole list add up to
|
|
10
|
-
|
|
11
|
-
others' only by what it adds to the whole. The
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
71
|
-
"""
|
|
72
|
-
|
|
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
|
-
|
|
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"] =
|
|
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
|
-
|
|
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:
|
|
183
|
-
|
|
184
|
-
|
|
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.
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
957
|
-
self.assertEqual(
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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["
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
|
135
|
-
#
|
|
136
|
-
#
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
self.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|