gitmole 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitmole/__init__.py +3 -0
- gitmole/__main__.py +6 -0
- gitmole/backtest.py +82 -0
- gitmole/banner.py +107 -0
- gitmole/blame.py +135 -0
- gitmole/cli.py +439 -0
- gitmole/filetypes.py +78 -0
- gitmole/findings.py +405 -0
- gitmole/functions.py +108 -0
- gitmole/hotspots.py +18 -0
- gitmole/identity.py +66 -0
- gitmole/knowledge.py +55 -0
- gitmole/leaks.py +124 -0
- gitmole/load.py +253 -0
- gitmole/loss.py +44 -0
- gitmole/maat.py +299 -0
- gitmole/render.py +736 -0
- gitmole/run.py +407 -0
- gitmole/textfmt.py +91 -0
- gitmole/trend.py +154 -0
- gitmole/watch.py +172 -0
- gitmole-0.3.0.dist-info/METADATA +469 -0
- gitmole-0.3.0.dist-info/RECORD +27 -0
- gitmole-0.3.0.dist-info/WHEEL +5 -0
- gitmole-0.3.0.dist-info/entry_points.txt +2 -0
- gitmole-0.3.0.dist-info/licenses/LICENSE +21 -0
- gitmole-0.3.0.dist-info/top_level.txt +1 -0
gitmole/findings.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Heuristics that turn a loaded report into a short list of flagged findings."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from . import filetypes, hotspots, knowledge, leaks, loss, textfmt, trend
|
|
7
|
+
|
|
8
|
+
SEVERITIES = ["critical", "warning", "info"]
|
|
9
|
+
|
|
10
|
+
PLACEHOLDER_NAMES = {"your name", "unknown", "root", "user"}
|
|
11
|
+
PLACEHOLDER_EMAIL = re.compile(r"(@example\.(com|org|net)$|^you@|^user@|^root@|@localhost$)")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _f(severity: str, title: str, statement: str, advice: str) -> dict:
|
|
15
|
+
"""A finding: the facts, then the next step. `detail` is the two joined for anyone reading the
|
|
16
|
+
JSON; `advice` says which part is the step so the report can show it on its own line."""
|
|
17
|
+
return {"severity": severity, "title": title, "detail": f"{statement.rstrip()} {advice}", "advice": advice}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _pct(part, whole) -> str:
|
|
21
|
+
return f"{round(100 * part / whole)}%" if whole else "0%"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _plural(n: int, word: str) -> str:
|
|
25
|
+
return f"{n} {word}{'' if n == 1 else 's'}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _secret_statement(groups: list) -> str:
|
|
29
|
+
"""'N distinct values in M places: rule in file (commits), ...' with at most three values named."""
|
|
30
|
+
def one(g):
|
|
31
|
+
others = len(g["files"]) - 1
|
|
32
|
+
where = g["files"][0] + (f" and {_plural(others, 'other file')}" if others else "")
|
|
33
|
+
commits = ", ".join(g["commits"][:2]) + (f" and {len(g['commits']) - 2} more" if len(g["commits"]) > 2 else "")
|
|
34
|
+
return f"{g['rule']} in {where} ({commits})"
|
|
35
|
+
places = sum(g["places"] for g in groups)
|
|
36
|
+
sample = "; ".join(one(g) for g in groups[:3])
|
|
37
|
+
more = f" and {len(groups) - 3} more" if len(groups) > 3 else ""
|
|
38
|
+
return f"{_plural(len(groups), 'distinct value')} in {_plural(places, 'place')}: {sample}{more}."
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def secrets_found(report: dict) -> list:
|
|
42
|
+
"""Secrets grouped by value. A value anywhere in source is critical; one that only ever appears in
|
|
43
|
+
test files (fixtures, saved pages) is a warning, so a critical gate does not trip on test data.
|
|
44
|
+
Version strings and shortened tokens were flagged as placeholders and are not a finding."""
|
|
45
|
+
groups = leaks.group(report.get("secrets") or [])
|
|
46
|
+
source = [g for g in groups if not g["test"]]
|
|
47
|
+
tests = [g for g in groups if g["test"]]
|
|
48
|
+
ignore = "Add the fingerprint of any false positive from secrets.json to .gitleaksignore in the repository."
|
|
49
|
+
out = []
|
|
50
|
+
if source:
|
|
51
|
+
out.append(_f("critical", f"{len(source)} secret(s) in history", _secret_statement(source),
|
|
52
|
+
f"Rotate them; deleting the file does not remove them from git. {ignore}"))
|
|
53
|
+
if tests:
|
|
54
|
+
out.append(_f("warning", f"{len(tests)} secret(s) only in test files", _secret_statement(tests),
|
|
55
|
+
f"Confirm they are fixtures, not live keys. {ignore}"))
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _all_identities(report: dict):
|
|
60
|
+
"""Every identity row plus its aliases, flattened."""
|
|
61
|
+
for i in report["meta"].get("identities") or []:
|
|
62
|
+
yield i
|
|
63
|
+
for a in i.get("aliases") or []:
|
|
64
|
+
yield a
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def placeholder_identity(report: dict, min_share: float = 0.01) -> list:
|
|
68
|
+
"""A placeholder name or mailbox with a real share of the commits. One stray commit in thousands
|
|
69
|
+
is not worth the panel space."""
|
|
70
|
+
total = sum(i["commits"] for i in report["meta"].get("identities") or [])
|
|
71
|
+
out = []
|
|
72
|
+
for i in _all_identities(report):
|
|
73
|
+
if total and i["commits"] / total < min_share:
|
|
74
|
+
continue
|
|
75
|
+
if i["name"].strip().lower() in PLACEHOLDER_NAMES or PLACEHOLDER_EMAIL.search(i["email"].lower()):
|
|
76
|
+
out.append(_f("warning", "Unconfigured git identity",
|
|
77
|
+
f"\"{i['name']} <{i['email']}>\" made {i['commits']} commits ({_pct(i['commits'], total)}).",
|
|
78
|
+
"Set user.name and user.email; consider a .mailmap for history."))
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _source_ownership(report: dict) -> list:
|
|
83
|
+
"""Ownership rows for source files. Test files are left out of every rule that names a next
|
|
84
|
+
step: owning the tests is not the knowledge risk. The default tables leave them out too."""
|
|
85
|
+
return [r for r in report.get("ownership") or [] if not filetypes.is_test_path(r["entity"])]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def bus_factor(report: dict, threshold: float = 0.7, min_lines: int = 200) -> list:
|
|
89
|
+
"""One author owns most of the surviving code (whole history). The areas named in the advice
|
|
90
|
+
come from lines added, which `--since` windows, so the advice says so when it applies."""
|
|
91
|
+
shares = report.get("theseus_authors") or {}
|
|
92
|
+
total = sum(shares.values())
|
|
93
|
+
if not total:
|
|
94
|
+
return []
|
|
95
|
+
name, lines = max(shares.items(), key=lambda kv: kv[1])
|
|
96
|
+
if lines / total <= threshold:
|
|
97
|
+
return []
|
|
98
|
+
theirs = []
|
|
99
|
+
for a in knowledge.areas(_source_ownership(report)):
|
|
100
|
+
owned = dict(a["owners"]).get(name, 0)
|
|
101
|
+
if a["lines"] >= min_lines and owned / a["lines"] >= 0.8:
|
|
102
|
+
theirs.append((a["area"], round(100 * owned / a["lines"])))
|
|
103
|
+
if theirs:
|
|
104
|
+
areas = " and ".join(t[0] for t in theirs[:2])
|
|
105
|
+
shares_ = " and ".join(f"{t[1]}%" for t in theirs[:2])
|
|
106
|
+
since = report["meta"].get("since")
|
|
107
|
+
advice = (f"Pair someone with {name} on {areas} first; {'they are' if len(theirs) > 1 else 'it is'} {shares_} theirs"
|
|
108
|
+
f"{f' since {since}' if since else ''}.")
|
|
109
|
+
else:
|
|
110
|
+
advice = f"Pair someone with {name} before they are unavailable."
|
|
111
|
+
return [_f("warning", "Bus factor of one", f"{name} wrote {_pct(lines, total)} of the code that survives today.", advice)]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _sizer_advice(row: dict) -> str:
|
|
115
|
+
"""The remedy for one git-sizer row, keyed on the loader's "section: metric" name. Big blobs
|
|
116
|
+
want LFS, many refs want pruning, a wide tree wants splitting, a big checkout wants a sparse
|
|
117
|
+
checkout; everything else that grows is history, and a shallow clone is the answer to that."""
|
|
118
|
+
section, _, metric = row["name"].partition(": ")
|
|
119
|
+
if section == "Blobs" and metric in ("Maximum size", "Total size"):
|
|
120
|
+
return "Move large files to Git LFS or rewrite them out of history."
|
|
121
|
+
if section in ("References", "Annotated tags"):
|
|
122
|
+
return "Consider pruning old branches and tags."
|
|
123
|
+
if section == "Biggest checkouts":
|
|
124
|
+
return "Consider a sparse checkout for CI; the tree is the cost."
|
|
125
|
+
if section == "Trees" and metric == "Maximum entries":
|
|
126
|
+
where = row.get("ref") or "the widest directory"
|
|
127
|
+
return f"Split {where} into subdirectories; a directory that wide slows every checkout and diff."
|
|
128
|
+
if section == "Commits" and metric in ("Maximum size", "Maximum parents"):
|
|
129
|
+
return "Look at that commit; oversized commits are usually imports or octopus merges."
|
|
130
|
+
return "Consider a shallow clone for CI; the history is the cost."
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def sizer_concerns(report: dict) -> list:
|
|
134
|
+
out = []
|
|
135
|
+
for row in report.get("sizer") or []:
|
|
136
|
+
sev = "warning" if row["concern"] >= 2 else "info"
|
|
137
|
+
where = f" at {row['ref']}" if row.get("ref") else ""
|
|
138
|
+
out.append(_f(sev, "Repo health", f"{row['name']} is {row['value']}{where}. git-sizer level of concern {row['concern']}.", _sizer_advice(row)))
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def hotspot_dominance(report: dict, ratio: float = 2.0, minimum: int = 20) -> list:
|
|
143
|
+
"""One source file takes most of the churn. Test files are left out: they change with everything."""
|
|
144
|
+
revs = sorted((r for r in report.get("revisions") or [] if not filetypes.is_test_path(r["entity"])), key=lambda r: -r["n-revs"])
|
|
145
|
+
if len(revs) < 2 or revs[0]["n-revs"] < minimum or revs[0]["n-revs"] < ratio * revs[1]["n-revs"]:
|
|
146
|
+
return []
|
|
147
|
+
top, nxt = revs[0], revs[1]
|
|
148
|
+
return [_f("info", "One file dominates the churn",
|
|
149
|
+
f"{top['entity']} changed {top['n-revs']} times, versus {nxt['n-revs']} for the next file ({nxt['entity']}).",
|
|
150
|
+
f"Consider splitting {top['entity']}; every change lands there.")]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def tight_coupling(report: dict, min_degree: int = 80, min_revs: int = 5) -> list:
|
|
154
|
+
"""A file and its test are expected to change together, so pairs with a test file on either side are left out."""
|
|
155
|
+
pairs = [p for p in report.get("coupling") or [] if p["degree"] >= min_degree and p["average-revs"] >= min_revs
|
|
156
|
+
and not (filetypes.is_test_path(p["entity"]) or filetypes.is_test_path(p["coupled"]))]
|
|
157
|
+
if not pairs:
|
|
158
|
+
return []
|
|
159
|
+
pairs.sort(key=lambda p: (-p["degree"], -p["average-revs"]))
|
|
160
|
+
top = "; ".join(f"{p['entity']} + {p['coupled']} ({p['degree']}%)" for p in pairs[:3])
|
|
161
|
+
count = f"{len(pairs)} pair changes" if len(pairs) == 1 else f"{len(pairs)} pairs change"
|
|
162
|
+
first = pairs[0]
|
|
163
|
+
return [_f("info", "Files that always change together",
|
|
164
|
+
f"{count} together at least {min_degree}% of the time, e.g. {top}.",
|
|
165
|
+
f"Review {first['entity']} and {first['coupled']} first: a shared layout or a hidden dependency links them.")]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def stale_files(report: dict, months: int = 12, share: float = 0.3) -> list:
|
|
169
|
+
"""Files still in the tree that nobody has touched. The age table covers every path in the
|
|
170
|
+
history, so paths that were deleted are left out here; they are not dead code, they are gone."""
|
|
171
|
+
age = report.get("age") or []
|
|
172
|
+
tree = (report.get("size") or {}).get("files") or {}
|
|
173
|
+
if tree:
|
|
174
|
+
age = [a for a in age if a["entity"] in tree]
|
|
175
|
+
if not age:
|
|
176
|
+
return []
|
|
177
|
+
stale = [a for a in age if a["age-months"] >= months]
|
|
178
|
+
if len(stale) / len(age) <= share:
|
|
179
|
+
return []
|
|
180
|
+
return [_f("info", "A large share of files is untouched",
|
|
181
|
+
f"{_pct(len(stale), len(age))} of files ({len(stale)}) have not changed in {months} months or more.",
|
|
182
|
+
"Consider deleting what nobody has needed; dead code hides in untouched files.")]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def bug_magnets(report: dict, min_recent: int = 3, warn_at: int = 5) -> list:
|
|
186
|
+
"""Source files with a run of recent fix commits. Test files are left out: they change with every fix."""
|
|
187
|
+
hot = [f for f in report.get("fixes") or [] if f["recent-fixes"] >= min_recent and not filetypes.is_test_path(f["entity"])]
|
|
188
|
+
if not hot:
|
|
189
|
+
return []
|
|
190
|
+
hot.sort(key=lambda f: (-f["recent-fixes"], -f["n-fixes"], f["entity"]))
|
|
191
|
+
sev = "warning" if hot[0]["recent-fixes"] >= warn_at else "info"
|
|
192
|
+
listed = "; ".join(f"{f['entity']} ({f['recent-fixes']} recent, {f['n-fixes']} total)" for f in hot[:5])
|
|
193
|
+
more = f" and {len(hot) - 5} more" if len(hot) > 5 else ""
|
|
194
|
+
first = " and ".join(f["entity"] for f in hot[:2])
|
|
195
|
+
return [_f(sev, "Bug magnets",
|
|
196
|
+
f"{len(hot)} file(s) were fixed {min_recent}+ times in the last six months: {listed}{more}.",
|
|
197
|
+
f"Review {first} before the next release; expect the next bug there.")]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def reverts(report: dict, min_share: float = 0.05, min_count: int = 5, warn_share: float = 0.10) -> list:
|
|
201
|
+
"""Commits backed out with git revert. The file most often reverted is where a check before merge pays."""
|
|
202
|
+
act = report.get("activity") or {}
|
|
203
|
+
n = act.get("revert_commits") or 0
|
|
204
|
+
total = report["meta"].get("commits") or 0
|
|
205
|
+
if not n or not total or (n < min_count and n / total < min_share):
|
|
206
|
+
return []
|
|
207
|
+
sev = "warning" if total and n / total >= warn_share else "info"
|
|
208
|
+
reverted = act.get("reverted") or {}
|
|
209
|
+
# source files lead: a test file at the top of the table would otherwise be the one named first
|
|
210
|
+
items = sorted(reverted.items(), key=lambda kv: filetypes.is_test_path(kv[0]))[:3]
|
|
211
|
+
parts = []
|
|
212
|
+
for i, (p, c) in enumerate(items):
|
|
213
|
+
if i == 0:
|
|
214
|
+
parts.append(f"{p} was reverted {textfmt.times(c)}")
|
|
215
|
+
else:
|
|
216
|
+
parts.append(f"{p} {textfmt.times(c)}")
|
|
217
|
+
listed = ", ".join(parts)
|
|
218
|
+
statement = f"{n} of {total} commits are reverts" + (f"; {listed}." if listed else ".")
|
|
219
|
+
source = [p for p in reverted if not filetypes.is_test_path(p)]
|
|
220
|
+
if source:
|
|
221
|
+
advice = f"Add a check before merge for {source[0]}; it is the file most often backed out."
|
|
222
|
+
else:
|
|
223
|
+
advice = "Look at why they were backed out; only test files were touched."
|
|
224
|
+
return [_f(sev, "Reverts", statement, advice)]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def knowledge_islands(report: dict, min_lines: int = 200, min_share: float = 0.9) -> list:
|
|
228
|
+
areas = knowledge.areas(_source_ownership(report))
|
|
229
|
+
islands = knowledge.islands(areas, min_lines=min_lines, min_share=min_share)
|
|
230
|
+
if not islands:
|
|
231
|
+
return []
|
|
232
|
+
total = sum(a["lines"] for a in areas)
|
|
233
|
+
covered = sum(i["lines"] for i in islands)
|
|
234
|
+
sev = "warning" if total and covered / total > 0.5 else "info"
|
|
235
|
+
listed = "; ".join(f"{i['area']} ({i['owner']} {i['share']}%)" for i in islands[:5])
|
|
236
|
+
more = f" and {len(islands) - 5} more" if len(islands) > 5 else ""
|
|
237
|
+
largest = max(islands, key=lambda i: i["lines"])
|
|
238
|
+
return [_f(sev, "Knowledge islands",
|
|
239
|
+
f"{len(islands)} area(s) with at least {min_lines} lines were written almost entirely by one person: {listed}{more}. "
|
|
240
|
+
f"That is {_pct(covered, total)} of all lines added.",
|
|
241
|
+
f"Pair someone with {largest['owner']} on {largest['area']} first; it is the largest at {largest['lines']:,} lines.")]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
LIVE_MONTHS = 12
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _is_live(area: str, age_rows: list) -> bool:
|
|
248
|
+
"""Has anything in this area changed in the last year? `age` covers every path in the history,
|
|
249
|
+
so an area whose files are all idle is knowledge about code nobody is touching."""
|
|
250
|
+
for row in age_rows:
|
|
251
|
+
if row["age-months"] >= LIVE_MONTHS:
|
|
252
|
+
continue
|
|
253
|
+
entity = row["entity"]
|
|
254
|
+
in_area = "/" not in entity if area == knowledge.ROOT else entity.startswith(area)
|
|
255
|
+
if in_area:
|
|
256
|
+
return True
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _loss_totals(report: dict, names: set, source_rows: list) -> tuple[int, int, dict, str]:
|
|
261
|
+
"""(lost, total, by_person, basis): share of surviving code from the blame pass; when that did
|
|
262
|
+
not run, share of lines added instead, with the basis clause that says so."""
|
|
263
|
+
lost, total = loss.surviving(report, names)
|
|
264
|
+
by_person = {n: v for n, v in (report.get("theseus_authors") or {}).items() if n in names}
|
|
265
|
+
basis = "of the code that survives today"
|
|
266
|
+
if not total:
|
|
267
|
+
areas_all = loss.areas(source_rows, names)
|
|
268
|
+
total = sum(a["lines"] for a in areas_all)
|
|
269
|
+
lost = sum(a["lost"] for a in areas_all)
|
|
270
|
+
by_person = {}
|
|
271
|
+
for r in (r for r in source_rows if r["author"] in names):
|
|
272
|
+
by_person[r["author"]] = by_person.get(r["author"], 0) + r["added"]
|
|
273
|
+
basis = "of all lines added (from lines added, not a blame)"
|
|
274
|
+
return lost, total, by_person, basis
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _loss_people(by_person: dict, total: int) -> str:
|
|
278
|
+
"""The "Bob (25%), Cat (2%) and 3 others (1%)" clause, or "N people at under 1% each" when
|
|
279
|
+
nobody's individual share rounds to 1% or more."""
|
|
280
|
+
people = sorted(by_person.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
281
|
+
named = [(n, v) for n, v in people if round(100 * v / total) >= 1][:3]
|
|
282
|
+
if named:
|
|
283
|
+
named_names = {n for n, _ in named}
|
|
284
|
+
rest = [(n, v) for n, v in people if n not in named_names]
|
|
285
|
+
listed = ", ".join(f"{n} ({_pct(v, total)})" for n, v in named)
|
|
286
|
+
if rest:
|
|
287
|
+
listed += f" and {_plural(len(rest), 'other')} ({_pct(sum(v for _, v in rest), total)})"
|
|
288
|
+
else:
|
|
289
|
+
listed = f"{len(people)} {'person' if len(people) == 1 else 'people'} at under 1% each"
|
|
290
|
+
return listed
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _loss_areas(report: dict, names: set, source_rows: list) -> list:
|
|
294
|
+
"""Areas at 200+ lines where 80%+ of the surviving code is theirs, tagged live or not and
|
|
295
|
+
sorted live-first: that is where the gap bites soonest."""
|
|
296
|
+
theirs = [a for a in loss.areas(source_rows, names) if a["lines"] >= 200 and a["lost_share"] >= 0.8]
|
|
297
|
+
for a in theirs:
|
|
298
|
+
a["live"] = _is_live(a["area"], report.get("age") or [])
|
|
299
|
+
theirs.sort(key=lambda a: (not a["live"], -a["lines"], a["area"])) # a live area first: that is where the gap bites
|
|
300
|
+
return theirs
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def knowledge_loss(report: dict, min_share: float = 0.10, warn_share: float = 0.30) -> list:
|
|
304
|
+
"""Code written by people who have stopped committing. Share of surviving code from the blame
|
|
305
|
+
pass; when that did not run, share of lines added, and the statement says so."""
|
|
306
|
+
months = report["meta"].get("gone_months", loss.DEFAULT_MONTHS)
|
|
307
|
+
gone = loss.gone(report, months)
|
|
308
|
+
if not gone:
|
|
309
|
+
return []
|
|
310
|
+
names = {g["name"] for g in gone}
|
|
311
|
+
source_rows = _source_ownership(report)
|
|
312
|
+
lost, total, by_person, basis = _loss_totals(report, names, source_rows)
|
|
313
|
+
if not total or lost / total < min_share:
|
|
314
|
+
return []
|
|
315
|
+
sev = "warning" if lost / total >= warn_share else "info"
|
|
316
|
+
listed = _loss_people(by_person, total)
|
|
317
|
+
theirs = _loss_areas(report, names, source_rows)
|
|
318
|
+
statement = (f"People with no commits since {loss.cutoff(report, months)} "
|
|
319
|
+
f"wrote {_pct(lost, total)} {basis}: {listed}.")
|
|
320
|
+
if theirs:
|
|
321
|
+
listed_areas = ", ".join(f"{a['area']} ({round(100 * a['lost_share'])}%)" for a in theirs[:3])
|
|
322
|
+
more = f" and {len(theirs) - 3} more" if len(theirs) > 3 else ""
|
|
323
|
+
statement += f" Areas mostly theirs: {listed_areas}{more}."
|
|
324
|
+
if theirs and theirs[0]["live"]:
|
|
325
|
+
advice = f"Pair someone on {theirs[0]['area']} first; nobody who wrote it is around to ask."
|
|
326
|
+
else: # nothing there has been touched in a year: pairing on it would be work nobody has asked for
|
|
327
|
+
top = min(by_person, key=lambda n: (-by_person[n], n))
|
|
328
|
+
advice = f"Pair someone with the people who worked with {top} before the rest of that knowledge goes."
|
|
329
|
+
return [_f(sev, "Knowledge loss", statement, advice)]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _partial_functions(report: dict) -> str:
|
|
333
|
+
"""A sentence when the lizard step stopped part way, so what it measured is not the whole code."""
|
|
334
|
+
status = (report["meta"].get("functions") or {}).get("status")
|
|
335
|
+
reason = {"timeout": "timed out", "failed": "failed"}.get(status)
|
|
336
|
+
return f" Function metrics {reason} part way, so there may be more." if reason else ""
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def brain_methods(report: dict, min_ccn: int = 15, min_lines: int = 100) -> list:
|
|
340
|
+
"""Functions that are both long and complex, in source files. A warning when one sits in a hotspot."""
|
|
341
|
+
big = [f for f in report.get("functions") or [] if f["ccn"] >= min_ccn and f["nloc"] >= min_lines and not filetypes.is_test_path(f["file"])]
|
|
342
|
+
if not big:
|
|
343
|
+
return []
|
|
344
|
+
big.sort(key=lambda f: (-f["ccn"], -f["nloc"], f["file"], f["function"], f["start"]))
|
|
345
|
+
hot = hotspots.top(report)
|
|
346
|
+
sev = "warning" if any(f["file"] in hot for f in big) else "info"
|
|
347
|
+
listed = "; ".join(f"{f['function']} ({f['file']}) complexity {f['ccn']}, {f['nloc']} lines, {f['params']} params" for f in big[:5])
|
|
348
|
+
more = f" and {len(big) - 5} more" if len(big) > 5 else ""
|
|
349
|
+
return [_f(sev, "Brain methods",
|
|
350
|
+
f"{len(big)} function(s) are both long and complex: {listed}{more}.{_partial_functions(report)}",
|
|
351
|
+
f"Split {big[0]['function']} in {big[0]['file']} first, before the next change lands there.")]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def complexity_growth(report: dict, min_growers: int = 3, min_pct: int = 25, top_n: int = 10) -> list:
|
|
355
|
+
"""The top_n source hotspots whose complexity grew over the last year, from the trend samples.
|
|
356
|
+
Test files are left out: a growing test file is not the problem the finding is about."""
|
|
357
|
+
series = (report.get("trend") or {}).get("files") or {}
|
|
358
|
+
last = report["meta"].get("last_date") or ""
|
|
359
|
+
if not series or not last:
|
|
360
|
+
return []
|
|
361
|
+
top = [h["entity"] for h in hotspots.ranked(report)
|
|
362
|
+
if h["code"] is not None and not filetypes.is_test_path(h["entity"])][:top_n]
|
|
363
|
+
grown = []
|
|
364
|
+
for path in top:
|
|
365
|
+
change = trend.change_over_year(series.get(path) or [], last)
|
|
366
|
+
if change.startswith("+") and int(change[1:-1]) >= min_pct:
|
|
367
|
+
grown.append((path, int(change[1:-1])))
|
|
368
|
+
if len(grown) < min_growers:
|
|
369
|
+
return []
|
|
370
|
+
sev = "warning" if top and grown[0][0] == top[0] else "info"
|
|
371
|
+
listed = ", ".join(f"{p} (+{g}%)" for p, g in grown[:5]) + (f" and {len(grown) - 5} more" if len(grown) > 5 else "")
|
|
372
|
+
first = grown[0]
|
|
373
|
+
return [_f(sev, "Hotspots getting more complex",
|
|
374
|
+
f"{len(grown)} of the {len(top)} top source hotspots grew by {min_pct}% or more in a year: {listed}.",
|
|
375
|
+
f"Split {first[0]} before the next change; its complexity grew {first[1]}% in a year.")]
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def duplication(report: dict, min_lines: int = 30) -> list:
|
|
379
|
+
dup = report.get("duplicates") or {}
|
|
380
|
+
blocks = [b for b in dup.get("blocks") or [] if b["lines"] >= min_lines]
|
|
381
|
+
if not blocks:
|
|
382
|
+
return []
|
|
383
|
+
blocks.sort(key=lambda b: (-b["lines"], b["places"]))
|
|
384
|
+
def place(b):
|
|
385
|
+
return " and ".join(f"{p}:{start}" for p, start, _ in b["places"][:3])
|
|
386
|
+
listed = "; ".join(f"{b['lines']} lines in {place(b)}" for b in blocks[:3])
|
|
387
|
+
more = f" and {len(blocks) - 3} more" if len(blocks) > 3 else ""
|
|
388
|
+
rate = f" Overall {dup['rate']}% of lines are duplicated." if dup.get("rate") is not None else ""
|
|
389
|
+
first = blocks[0]
|
|
390
|
+
files = list(dict.fromkeys(p for p, _, _ in first["places"])) # each file once, in place order
|
|
391
|
+
where = f"repeated within {files[0]}" if len(files) == 1 else f"shared by {files[0]} and {files[1]}"
|
|
392
|
+
return [_f("info", "Duplicated code", f"{len(blocks)} block(s) of {min_lines}+ duplicated lines: {listed}{more}.{rate}{_partial_functions(report)}",
|
|
393
|
+
f"Extract the {first['lines']}-line block {where} first.")]
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
RULES = [secrets_found, placeholder_identity, bus_factor, sizer_concerns, hotspot_dominance, bug_magnets, reverts, brain_methods, complexity_growth,
|
|
397
|
+
tight_coupling, duplication, stale_files, knowledge_islands, knowledge_loss]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def evaluate(report: dict) -> list:
|
|
401
|
+
found = []
|
|
402
|
+
for rule in RULES:
|
|
403
|
+
found.extend(rule(report))
|
|
404
|
+
found.sort(key=lambda f: SEVERITIES.index(f["severity"]))
|
|
405
|
+
return found
|
gitmole/functions.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Function-level metrics and duplicated blocks from lizard, over the tracked code files only.
|
|
3
|
+
|
|
4
|
+
Runs as its own process (a pipeline step) and drives lizard through its Python API rather
|
|
5
|
+
than its command line: the file list never touches a shell or a list file, the analysed
|
|
6
|
+
repository is never on sys.path, only files lizard has a reader for are measured, and the
|
|
7
|
+
CSV is streamed so a killed step still leaves what was measured."""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import csv
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import lizard
|
|
16
|
+
from lizard_ext.lizardduplicate import LizardExtension as Duplicates
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from . import blame, filetypes
|
|
20
|
+
except ImportError: # run as a script: the package directory is sys.path[0]
|
|
21
|
+
import blame
|
|
22
|
+
import filetypes
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def select_files(repo: str, ignore=(), types_spec: str = None) -> list:
|
|
26
|
+
"""Tracked text files lizard can parse. Without --file-types that is every language lizard
|
|
27
|
+
knows (a superset of gitmole's default code list, e.g. Fortran); with it, the intersection."""
|
|
28
|
+
types = filetypes.parse(types_spec)
|
|
29
|
+
files = blame.text_files(repo, ignore) if types_spec is None else blame.code_files(repo, ignore, types)
|
|
30
|
+
return [f for f in files if lizard.get_reader_for(f) is not None]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def csv_row(info, fn) -> list:
|
|
34
|
+
"""The columns `lizard --csv` prints, so the loader does not care which produced the file."""
|
|
35
|
+
return [fn.nloc, fn.cyclomatic_complexity, fn.token_count, fn.parameter_count, fn.length,
|
|
36
|
+
f"{fn.name}@{fn.start_line}-{fn.end_line}@{info.filename}", info.filename, fn.name, fn.long_name, fn.start_line, fn.end_line]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write_duplicates(dup: Duplicates, fh) -> None:
|
|
40
|
+
"""The layout `lizard -Eduplicate` prints, snippets in a stable order."""
|
|
41
|
+
fh.write("Duplicates\n===================================\n")
|
|
42
|
+
for block in dup.get_duplicates():
|
|
43
|
+
fh.write("Duplicate block:\n--------------------------\n")
|
|
44
|
+
for s in sorted(block, key=lambda s: (s.file_name, s.start_line)):
|
|
45
|
+
fh.write(f"{s.file_name}:{s.start_line} ~ {s.end_line}\n")
|
|
46
|
+
fh.write("^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n")
|
|
47
|
+
fh.write(f"Total duplicate rate: {(dup.duplicate_rate() or 0.0) * 100:.2f}%\n")
|
|
48
|
+
fh.write(f"Total unique rate: {(dup.unique_rate() or 0.0) * 100:.2f}%\n")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def analyze(files: list, procs: int, exts: list):
|
|
52
|
+
"""lizard.analyze_files, with one step between the per-file analysis and the cross-file
|
|
53
|
+
extensions: a file lizard could not read or finish (gone from disk, RecursionError) comes back
|
|
54
|
+
without the duplicate finder's hash_nodes, which would stop the whole pass at that file."""
|
|
55
|
+
def with_hash_nodes(infos):
|
|
56
|
+
for info in infos:
|
|
57
|
+
if not hasattr(info, "hash_nodes"):
|
|
58
|
+
info.hash_nodes = []
|
|
59
|
+
yield info
|
|
60
|
+
result = with_hash_nodes(lizard.map_files_to_analyzer(files, lizard.FileAnalyzer(exts), procs))
|
|
61
|
+
for ext in exts:
|
|
62
|
+
if hasattr(ext, "cross_file_process"):
|
|
63
|
+
result = ext.cross_file_process(result)
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def measure(repo: str, files: list, out: str, procs: int, duplicates: bool = False) -> int:
|
|
68
|
+
"""Stream functions.csv while lizard runs, then duplicates.txt when the finder was on. Returns 0,
|
|
69
|
+
or 1 when lizard gave up on a file (whatever was measured by then stays on disk)."""
|
|
70
|
+
exts = lizard.get_extensions(["duplicate"] if duplicates else []) # lizard's metric extensions, plus the duplicate finder on request
|
|
71
|
+
dup = next((e for e in exts if isinstance(e, Duplicates)), None)
|
|
72
|
+
rc = 0
|
|
73
|
+
cwd = os.getcwd()
|
|
74
|
+
os.chdir(repo) # lizard opens the paths as given; relative ones keep the CSV repo-relative
|
|
75
|
+
try:
|
|
76
|
+
with open(os.path.join(out, "functions.csv"), "w", encoding="utf-8", newline="") as fh:
|
|
77
|
+
writer = csv.writer(fh, quoting=csv.QUOTE_NONNUMERIC)
|
|
78
|
+
try:
|
|
79
|
+
for info in analyze(files, procs, exts):
|
|
80
|
+
for fn in info.function_list:
|
|
81
|
+
writer.writerow(csv_row(info, fn))
|
|
82
|
+
fh.flush()
|
|
83
|
+
except Exception as e: # lizard re-raises its parse failures; keep what we have
|
|
84
|
+
print(f"lizard stopped: {e!r}", file=sys.stderr)
|
|
85
|
+
rc = 1
|
|
86
|
+
if dup is not None:
|
|
87
|
+
with open(os.path.join(out, "duplicates.txt"), "w", encoding="utf-8") as fh:
|
|
88
|
+
write_duplicates(dup, fh)
|
|
89
|
+
finally:
|
|
90
|
+
os.chdir(cwd)
|
|
91
|
+
return rc
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main(argv=None) -> int:
|
|
95
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
96
|
+
p.add_argument("repo")
|
|
97
|
+
p.add_argument("out")
|
|
98
|
+
p.add_argument("--procs", type=int, default=1)
|
|
99
|
+
p.add_argument("--ignore", action="append", default=[])
|
|
100
|
+
p.add_argument("--types", default=None, help="file types spec as for gitmole --file-types")
|
|
101
|
+
p.add_argument("--duplicates", action="store_true", help="also run the duplicate finder (slow and memory-hungry on a large repo)")
|
|
102
|
+
args = p.parse_args(argv)
|
|
103
|
+
files = select_files(args.repo, args.ignore, args.types)
|
|
104
|
+
return measure(os.path.abspath(args.repo), files, os.path.abspath(args.out), max(1, args.procs), args.duplicates)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
sys.exit(main())
|
gitmole/hotspots.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""One ranking of hotspots, shared by the table and the findings that talk about them."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def ranked(report: dict) -> list:
|
|
6
|
+
"""Files by revisions × lines of code, Tornhill-style. Files no longer in the tree score -1 and sort last."""
|
|
7
|
+
files = (report.get("size") or {}).get("files") or {}
|
|
8
|
+
out = []
|
|
9
|
+
for r in report.get("revisions") or []:
|
|
10
|
+
info = files.get(r["entity"])
|
|
11
|
+
out.append({"entity": r["entity"], "revs": r["n-revs"], "code": info["code"] if info else None,
|
|
12
|
+
"complexity": info["complexity"] if info else None, "score": r["n-revs"] * info["code"] if info else -1})
|
|
13
|
+
out.sort(key=lambda h: (-h["score"], -h["revs"], h["entity"]))
|
|
14
|
+
return out
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def top(report: dict, n: int = 10) -> set:
|
|
18
|
+
return {h["entity"] for h in ranked(report)[:n]}
|
gitmole/identity.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Merge git identities that belong to the same person."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_BOT_WORDS = ("dependabot", "renovate", "github-actions", "github actions")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_bot(name: str, email: str = "") -> bool:
|
|
11
|
+
"""A commit author that is a service, not a person: GitHub's *[bot] suffix, or one of the
|
|
12
|
+
common automation names in the name or the mailbox."""
|
|
13
|
+
n, local = name.strip().lower(), email.strip().lower().split("@")[0]
|
|
14
|
+
if n.endswith("[bot]") or local.endswith("[bot]"):
|
|
15
|
+
return True
|
|
16
|
+
return any(w in n for w in _BOT_WORDS) or any(w in local for w in _BOT_WORDS) or email.strip().lower() == "actions@github.com"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _tokens(name: str) -> set:
|
|
20
|
+
return {t for t in re.split(r"[^a-z0-9]+", name.lower()) if len(t) >= 3}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def same_person(a: dict, b: dict) -> bool:
|
|
24
|
+
return a["email"].lower() == b["email"].lower() or len(_tokens(a["name"]) & _tokens(b["name"])) >= 2
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def merge(identities: list) -> list:
|
|
28
|
+
"""Group identities by shared name tokens or email. Each row keeps the most-committed
|
|
29
|
+
variant's name and email, sums the commits, and lists the other variants as aliases.
|
|
30
|
+
|
|
31
|
+
Grouping is transitive: an identity that matches two groups joins them into one, so
|
|
32
|
+
"Hayden <h@noreply>" and "hay-kot <h@pm.me>" end up together once "hay-kot <h@noreply>"
|
|
33
|
+
shows up to link them. Without that the same person appears twice."""
|
|
34
|
+
groups = []
|
|
35
|
+
for i in identities:
|
|
36
|
+
matched = [g for g in groups if any(same_person(i, j) for j in g)]
|
|
37
|
+
if not matched:
|
|
38
|
+
groups.append([i])
|
|
39
|
+
continue
|
|
40
|
+
first = matched[0]
|
|
41
|
+
first.append(i)
|
|
42
|
+
for other in matched[1:]:
|
|
43
|
+
first.extend(other)
|
|
44
|
+
groups.remove(other)
|
|
45
|
+
merged = []
|
|
46
|
+
for g in groups:
|
|
47
|
+
g = sorted(g, key=lambda x: -x["commits"])
|
|
48
|
+
head = g[0]
|
|
49
|
+
merged.append({
|
|
50
|
+
"name": head["name"],
|
|
51
|
+
"email": head["email"],
|
|
52
|
+
"commits": sum(x["commits"] for x in g),
|
|
53
|
+
"aliases": [{"name": x["name"], "email": x["email"], "commits": x["commits"]} for x in g[1:]],
|
|
54
|
+
})
|
|
55
|
+
merged.sort(key=lambda m: (-m["commits"], m["name"]))
|
|
56
|
+
return merged
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def canonical_names(merged: list) -> dict:
|
|
60
|
+
"""alias name -> merged name, including the merged names themselves."""
|
|
61
|
+
out = {}
|
|
62
|
+
for m in merged:
|
|
63
|
+
out[m["name"]] = m["name"]
|
|
64
|
+
for a in m.get("aliases", []):
|
|
65
|
+
out[a["name"]] = m["name"]
|
|
66
|
+
return out
|
gitmole/knowledge.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Who knows which part of the tree: ownership aggregated by directory."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import Counter, defaultdict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
ROOT = "(root files)"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _area(entity: str, depth: int) -> str:
|
|
11
|
+
dirs = entity.split("/")[:-1]
|
|
12
|
+
if not dirs:
|
|
13
|
+
return ROOT
|
|
14
|
+
return "/".join(dirs[:depth]) + "/"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _aggregate(rows: list, depth: int) -> list:
|
|
18
|
+
lines, per_author = Counter(), defaultdict(Counter)
|
|
19
|
+
for r in rows:
|
|
20
|
+
a = _area(r["entity"], depth)
|
|
21
|
+
lines[a] += r["added"]
|
|
22
|
+
per_author[a][r["author"]] += r["added"]
|
|
23
|
+
out = []
|
|
24
|
+
for a, n in lines.items():
|
|
25
|
+
owners = sorted(per_author[a].items(), key=lambda kv: (-kv[1], kv[0]))
|
|
26
|
+
out.append({"area": a, "lines": n, "authors": len(owners), "owners": owners})
|
|
27
|
+
out.sort(key=lambda x: (-x["lines"], x["area"]))
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def areas(ownership_rows: list, dominant: float = 0.8) -> list:
|
|
32
|
+
"""Areas of the tree by lines added, with per-author ownership.
|
|
33
|
+
|
|
34
|
+
Top-level directories, unless one of them holds `dominant` of all lines
|
|
35
|
+
(a lone src/ or the like), in which case its subdirectories are used."""
|
|
36
|
+
rows = [r for r in ownership_rows if r.get("added", 0) > 0]
|
|
37
|
+
if not rows:
|
|
38
|
+
return []
|
|
39
|
+
top = _aggregate(rows, 1)
|
|
40
|
+
total = sum(a["lines"] for a in top)
|
|
41
|
+
if top[0]["area"] != ROOT and top[0]["lines"] >= dominant * total:
|
|
42
|
+
return _aggregate(rows, 2)
|
|
43
|
+
return top
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def islands(areas_list: list, min_lines: int = 200, min_share: float = 0.9) -> list:
|
|
47
|
+
"""Areas of at least `min_lines` where one author wrote at least `min_share` of them."""
|
|
48
|
+
out = []
|
|
49
|
+
for a in areas_list:
|
|
50
|
+
if a["lines"] < min_lines or not a["owners"]:
|
|
51
|
+
continue
|
|
52
|
+
owner, n = a["owners"][0]
|
|
53
|
+
if n / a["lines"] >= min_share:
|
|
54
|
+
out.append({"area": a["area"], "owner": owner, "share": round(100 * n / a["lines"]), "lines": a["lines"]})
|
|
55
|
+
return out
|