gitmole 0.6.4__tar.gz → 0.6.6__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.6.4 → gitmole-0.6.6}/PKG-INFO +1 -1
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/__init__.py +1 -1
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/cli.py +3 -1
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/coupling.py +8 -6
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/filetypes.py +59 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/findings.py +40 -11
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/identity.py +19 -1
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/knowledge.py +24 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/leaks.py +4 -1
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/load.py +1 -1
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/render.py +35 -4
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole.egg-info/PKG-INFO +1 -1
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_cli.py +22 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_coupling.py +20 -4
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_filetypes.py +27 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_findings.py +57 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_identity.py +13 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_knowledge.py +30 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_leaks.py +7 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_load.py +5 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_render.py +50 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/LICENSE +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/README.md +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/__main__.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/backtest.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/banner.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/blame.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/clean.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/functions.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/hotspots.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/loss.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/maat.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/run.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/textfmt.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/trend.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole/watch.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole.egg-info/SOURCES.txt +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole.egg-info/dependency_links.txt +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole.egg-info/entry_points.txt +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole.egg-info/requires.txt +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/gitmole.egg-info/top_level.txt +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/pyproject.toml +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/setup.cfg +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_backtest.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_banner.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_blame.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_clean.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_functions.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_golden.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_hotspots.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_loss.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_maat.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_packaging.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_run.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_textfmt.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_trend.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.6}/tests/test_watch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitmole
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.6
|
|
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
|
|
@@ -15,7 +15,7 @@ from rich.live import Live
|
|
|
15
15
|
from rich.spinner import Spinner
|
|
16
16
|
from rich.text import Text
|
|
17
17
|
|
|
18
|
-
from . import __version__, banner, filetypes, findings, load, loss, run
|
|
18
|
+
from . import __version__, banner, blame, filetypes, findings, load, loss, run
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def parse_args(argv):
|
|
@@ -294,6 +294,8 @@ def _meta_for_run(repo_dir: str, args, estimate, age_ok: bool, plots_ok: bool, p
|
|
|
294
294
|
meta = run.collect_meta(repo_dir, since=args.since_date)
|
|
295
295
|
meta["file_types"] = types_spec # the loader filters scc's size data the way every other step was filtered
|
|
296
296
|
meta["gone_months"] = args.gone
|
|
297
|
+
ignore = list(run.DATA_IGNORES if args.ignore_data else []) + list(args.ignore)
|
|
298
|
+
meta["generated"] = filetypes.generated_files(repo_dir, blame.text_files(repo_dir, ignore)) # hidden from the tables, out of the findings
|
|
297
299
|
if args.since_date and meta["commits"] == 0:
|
|
298
300
|
raise NoCommits(f"no commits since {args.since_date}; widen --since")
|
|
299
301
|
if args.now:
|
|
@@ -32,12 +32,13 @@ def _components(pairs: list) -> list:
|
|
|
32
32
|
return list(groups.values())
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
def clusters(pairs: list, min_files: int = 4) -> tuple:
|
|
35
|
+
def clusters(pairs: list, min_files: int = 4, min_density: float = 0.8) -> tuple:
|
|
36
36
|
"""Split `pairs` into (groups, rest). Pairs whose two files share a directory are gathered per
|
|
37
|
-
directory and then into connected groups; a group
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
pairs in one directory
|
|
37
|
+
directory and then into connected groups; a group of at least `min_files` distinct files with at
|
|
38
|
+
least `min_density` of the possible pairs present becomes one cluster with the file and pair
|
|
39
|
+
counts, the weakest degree and the mean of the pairs' average revisions. Every other pair comes
|
|
40
|
+
back unchanged, in its original order. Two unrelated pairs in one directory, or a chain of pairs,
|
|
41
|
+
are not a cluster: "each other" has to be true. Clusters are largest first."""
|
|
41
42
|
by_dir = defaultdict(list)
|
|
42
43
|
for p in pairs:
|
|
43
44
|
if _dir(p["entity"]) == _dir(p["coupled"]):
|
|
@@ -46,7 +47,8 @@ def clusters(pairs: list, min_files: int = 4) -> tuple:
|
|
|
46
47
|
for directory, ps in by_dir.items():
|
|
47
48
|
for component in _components(ps):
|
|
48
49
|
files = {p["entity"] for p in component} | {p["coupled"] for p in component}
|
|
49
|
-
|
|
50
|
+
possible = len(files) * (len(files) - 1) / 2
|
|
51
|
+
if len(files) < min_files or len(component) < min_density * possible:
|
|
50
52
|
continue
|
|
51
53
|
groups.append({"dir": directory, "files": len(files), "pairs": len(component), "degree": min(p["degree"] for p in component),
|
|
52
54
|
"average-revs": round(sum(p["average-revs"] for p in component) / len(component))})
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Standalone so blame.py and maat.py can import it as scripts."""
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import fnmatch
|
|
6
|
+
import os
|
|
5
7
|
import re
|
|
6
8
|
import subprocess
|
|
7
9
|
from collections import Counter
|
|
@@ -83,6 +85,63 @@ def is_vendor_path(path: str) -> bool:
|
|
|
83
85
|
return bool(_VENDOR_PATH.search(path))
|
|
84
86
|
|
|
85
87
|
|
|
88
|
+
_RELEASE_NAMES = {"version", "version.rb", "version.py", "version.go", "version.rs", "version.txt", "__version__.py", "package.json",
|
|
89
|
+
"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "gemfile", "gemfile.lock", "cargo.toml", "cargo.lock",
|
|
90
|
+
"pyproject.toml", "setup.py", "setup.cfg", "poetry.lock", "uv.lock", "go.mod", "go.sum", "composer.json", "composer.lock"}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_release_path(path: str) -> bool:
|
|
94
|
+
"""Release plumbing: version files, manifests, lock files and changelogs. Two of them changing
|
|
95
|
+
together is a release commit, not a dependency between them."""
|
|
96
|
+
name = path.rsplit("/", 1)[-1].lower()
|
|
97
|
+
return name in _RELEASE_NAMES or name.endswith(".gemspec") or name.startswith(("changelog", "changes.", "history.", "news."))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# What a generated file says about itself in its first lines: protoc, ajv, code generators of every kind.
|
|
101
|
+
_GENERATED = re.compile(r"auto[- ]?generated|generated (by|from|file|code|automatically|with)|do not (edit|modify)|@generated|code generated", re.I)
|
|
102
|
+
GENERATED_HEAD_LINES = 5
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _generated_patterns(repo: str) -> list:
|
|
106
|
+
"""The .gitattributes patterns marked linguist-generated at the repository root."""
|
|
107
|
+
try:
|
|
108
|
+
with open(os.path.join(repo, ".gitattributes"), encoding="utf-8", errors="replace") as fh:
|
|
109
|
+
lines = fh.read().splitlines()
|
|
110
|
+
except OSError:
|
|
111
|
+
return []
|
|
112
|
+
out = []
|
|
113
|
+
for line in lines:
|
|
114
|
+
parts = line.split()
|
|
115
|
+
if len(parts) >= 2 and any(p in ("linguist-generated", "linguist-generated=true") for p in parts[1:]):
|
|
116
|
+
out.append(parts[0].lstrip("/"))
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _attribute_match(path: str, pattern: str) -> bool:
|
|
121
|
+
if "/" in pattern:
|
|
122
|
+
return fnmatch.fnmatchcase(path, pattern) or fnmatch.fnmatchcase(path, pattern.rstrip("/") + "/*")
|
|
123
|
+
return fnmatch.fnmatchcase(path.rsplit("/", 1)[-1], pattern)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def generated_files(repo: str, paths: list) -> list:
|
|
127
|
+
"""The tracked files that are generated: marked linguist-generated in .gitattributes, or saying so
|
|
128
|
+
in their first lines. Their complexity and churn are the generator's, not the repository's."""
|
|
129
|
+
patterns = _generated_patterns(repo)
|
|
130
|
+
out = []
|
|
131
|
+
for path in paths:
|
|
132
|
+
if any(_attribute_match(path, p) for p in patterns):
|
|
133
|
+
out.append(path)
|
|
134
|
+
continue
|
|
135
|
+
try:
|
|
136
|
+
with open(os.path.join(repo, path), "rb") as fh:
|
|
137
|
+
head = fh.read(2048)
|
|
138
|
+
except OSError:
|
|
139
|
+
continue
|
|
140
|
+
if any(_GENERATED.search(line) for line in head.decode("utf-8", "replace").splitlines()[:GENERATED_HEAD_LINES]):
|
|
141
|
+
out.append(path)
|
|
142
|
+
return sorted(out)
|
|
143
|
+
|
|
144
|
+
|
|
86
145
|
def key(path: str) -> str:
|
|
87
146
|
"""The lowercased extension, or the whole lowercased name when there is none."""
|
|
88
147
|
name = path.rsplit("/", 1)[-1].lower()
|
|
@@ -100,6 +100,14 @@ def _source_ownership(report: dict) -> list:
|
|
|
100
100
|
return [r for r in report.get("ownership") or [] if not (filetypes.is_test_path(r["entity"]) or filetypes.is_vendor_path(r["entity"]))]
|
|
101
101
|
|
|
102
102
|
|
|
103
|
+
def _present_areas(report: dict, rows: list, build=knowledge.areas) -> list:
|
|
104
|
+
"""Areas built from the ownership rows of directories that still exist, then only those areas that
|
|
105
|
+
exist themselves: a directory the history knows but HEAD does not (the layout before a move to
|
|
106
|
+
src/ or crates/) is nowhere to pair anyone on. `build` is knowledge.areas or a wrapper of it."""
|
|
107
|
+
tree = _tree(report)
|
|
108
|
+
return [a for a in build(knowledge.present_rows(rows, tree)) if knowledge.in_tree(a["area"], tree)]
|
|
109
|
+
|
|
110
|
+
|
|
103
111
|
def bus_factor(report: dict, threshold: float = 0.7, min_lines: int = 200) -> list:
|
|
104
112
|
"""One author owns most of the surviving code (whole history). The areas named in the advice
|
|
105
113
|
come from lines added, which `--since` windows, so the advice says so when it applies."""
|
|
@@ -111,7 +119,7 @@ def bus_factor(report: dict, threshold: float = 0.7, min_lines: int = 200) -> li
|
|
|
111
119
|
if lines / total <= threshold:
|
|
112
120
|
return []
|
|
113
121
|
theirs = []
|
|
114
|
-
for a in
|
|
122
|
+
for a in _present_areas(report, _source_ownership(report)):
|
|
115
123
|
owned = dict(a["owners"]).get(name, 0)
|
|
116
124
|
if a["lines"] >= min_lines and owned / a["lines"] >= 0.8:
|
|
117
125
|
theirs.append((a["area"], round(100 * owned / a["lines"])))
|
|
@@ -177,10 +185,12 @@ def hotspot_dominance(report: dict, ratio: float = 2.0, minimum: int = 20) -> li
|
|
|
177
185
|
|
|
178
186
|
def tight_coupling(report: dict, min_degree: int = 80, min_revs: int = 5) -> list:
|
|
179
187
|
"""A file and its test are expected to change together, so pairs with a test file on either side are
|
|
180
|
-
left out; so are pairs where either file is no longer in the tree, which are history, not a dependency
|
|
188
|
+
left out; so are pairs where either file is no longer in the tree, which are history, not a dependency,
|
|
189
|
+
and pairs of release plumbing (two version files, a manifest and its lock file), which are a release."""
|
|
181
190
|
tree = _tree(report)
|
|
182
191
|
pairs = [p for p in report.get("coupling") or [] if p["degree"] >= min_degree and p["average-revs"] >= min_revs
|
|
183
192
|
and not (filetypes.is_test_path(p["entity"]) or filetypes.is_test_path(p["coupled"]))
|
|
193
|
+
and not (filetypes.is_release_path(p["entity"]) and filetypes.is_release_path(p["coupled"]))
|
|
184
194
|
and not (tree and (p["entity"] not in tree or p["coupled"] not in tree))]
|
|
185
195
|
if not pairs:
|
|
186
196
|
return []
|
|
@@ -263,7 +273,9 @@ def reverts(report: dict, min_share: float = 0.05, min_count: int = 5, warn_shar
|
|
|
263
273
|
|
|
264
274
|
|
|
265
275
|
def knowledge_islands(report: dict, min_lines: int = 200, min_share: float = 0.9) -> list:
|
|
266
|
-
|
|
276
|
+
"""Areas of the tree written almost entirely by one person. Areas that no longer exist are left
|
|
277
|
+
out, of the islands and of the total they are measured against."""
|
|
278
|
+
areas = _present_areas(report, _source_ownership(report))
|
|
267
279
|
islands = knowledge.islands(areas, min_lines=min_lines, min_share=min_share)
|
|
268
280
|
if not islands:
|
|
269
281
|
return []
|
|
@@ -329,9 +341,10 @@ def _loss_people(by_person: dict, total: int) -> str:
|
|
|
329
341
|
|
|
330
342
|
|
|
331
343
|
def _loss_areas(report: dict, names: set, source_rows: list) -> list:
|
|
332
|
-
"""Areas at 200+ lines where 80%+ of the surviving code is theirs,
|
|
333
|
-
sorted live-first: that is where the gap bites soonest."""
|
|
334
|
-
theirs = [a for a in loss.areas(
|
|
344
|
+
"""Areas at 200+ lines where 80%+ of the surviving code is theirs, still in the tree, tagged live
|
|
345
|
+
or not and sorted live-first: that is where the gap bites soonest."""
|
|
346
|
+
theirs = [a for a in _present_areas(report, source_rows, build=lambda rows: loss.areas(rows, names))
|
|
347
|
+
if a["lines"] >= 200 and a["lost_share"] >= 0.8]
|
|
335
348
|
for a in theirs:
|
|
336
349
|
a["live"] = _is_live(a["area"], report.get("age") or [])
|
|
337
350
|
theirs.sort(key=lambda a: (not a["live"], -a["lines"], a["area"])) # a live area first: that is where the gap bites
|
|
@@ -375,20 +388,36 @@ def _partial_functions(report: dict) -> str:
|
|
|
375
388
|
|
|
376
389
|
|
|
377
390
|
def brain_methods(report: dict, min_ccn: int = 15, min_lines: int = 100) -> list:
|
|
378
|
-
"""Functions that are both long and complex, in this repository's own source files: test files
|
|
379
|
-
vendored code are left out. A warning when one sits in a hotspot."""
|
|
391
|
+
"""Functions that are both long and complex, in this repository's own source files: test files,
|
|
392
|
+
vendored code and generated files are left out. A warning when one sits in a hotspot."""
|
|
393
|
+
generated = _generated(report)
|
|
380
394
|
big = [f for f in report.get("functions") or [] if f["ccn"] >= min_ccn and f["nloc"] >= min_lines
|
|
381
|
-
and not (filetypes.is_test_path(f["file"]) or filetypes.is_vendor_path(f["file"]))]
|
|
395
|
+
and not (filetypes.is_test_path(f["file"]) or filetypes.is_vendor_path(f["file"]) or f["file"] in generated)]
|
|
382
396
|
if not big:
|
|
383
397
|
return []
|
|
384
398
|
big.sort(key=lambda f: (-f["ccn"], -f["nloc"], f["file"], f["function"], f["start"]))
|
|
385
399
|
hot = hotspots.top(report)
|
|
386
400
|
sev = "warning" if any(f["file"] in hot for f in big) else "info"
|
|
387
|
-
listed = "; ".join(f"{f['function']} ({f
|
|
401
|
+
listed = "; ".join(f"{f['function']} ({_place(f)}) complexity {f['ccn']}, {f['nloc']} lines, {f['params']} params" for f in big[:5])
|
|
388
402
|
more = f" and {len(big) - 5} more" if len(big) > 5 else ""
|
|
403
|
+
first = big[0]
|
|
404
|
+
which = f"the anonymous function at {_place(first)}" if first["function"] == ANONYMOUS else f"{first['function']} in {first['file']}"
|
|
389
405
|
return [_f(sev, "Brain methods",
|
|
390
406
|
f"{len(big)} function(s) are both long and complex: {listed}{more}.{_partial_functions(report)}",
|
|
391
|
-
f"Split {
|
|
407
|
+
f"Split {which} first, before the next change lands there.")]
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
ANONYMOUS = "(anonymous)"
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _place(f: dict) -> str:
|
|
414
|
+
"""Where a function is: its file, or file:line when it has no name to find it by."""
|
|
415
|
+
return f"{f['file']}:{f['start']}" if f["function"] == ANONYMOUS else f["file"]
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _generated(report: dict) -> set:
|
|
419
|
+
"""Files the run found to be generated (a header marker or a linguist-generated attribute)."""
|
|
420
|
+
return set((report.get("meta") or {}).get("generated") or [])
|
|
392
421
|
|
|
393
422
|
|
|
394
423
|
def complexity_growth(report: dict, min_growers: int = 3, min_pct: int = 25, top_n: int = 10) -> list:
|
|
@@ -20,8 +20,26 @@ def _tokens(name: str) -> set:
|
|
|
20
20
|
return {t for t in re.split(r"[^a-z0-9]+", name.lower()) if len(t) >= 3}
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
# A bare first name under two emails may be two people; anything else spelled identically is one.
|
|
24
|
+
_COMMON_FIRST_NAMES = {
|
|
25
|
+
"adam", "alex", "alexander", "andrew", "andy", "ann", "anna", "ben", "bob", "chris", "dan", "daniel", "dave", "david", "ed",
|
|
26
|
+
"eric", "frank", "george", "jack", "james", "jan", "jean", "jim", "joe", "john", "jon", "josh", "kevin", "lee", "li", "luke",
|
|
27
|
+
"mark", "martin", "matt", "max", "michael", "mike", "nick", "paul", "pete", "peter", "phil", "rob", "robert", "ryan", "sam",
|
|
28
|
+
"scott", "steve", "tim", "tom", "tony", "will",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _plain(name: str) -> str:
|
|
33
|
+
return " ".join(name.lower().split())
|
|
34
|
+
|
|
35
|
+
|
|
23
36
|
def same_person(a: dict, b: dict) -> bool:
|
|
24
|
-
|
|
37
|
+
"""Same email, two shared name tokens, or the same name spelled identically (a handle such as
|
|
38
|
+
KaKa under three emails), unless that name is a bare common first name."""
|
|
39
|
+
if a["email"].lower() == b["email"].lower() or len(_tokens(a["name"]) & _tokens(b["name"])) >= 2:
|
|
40
|
+
return True
|
|
41
|
+
name = _plain(a["name"])
|
|
42
|
+
return bool(name) and name == _plain(b["name"]) and name not in _COMMON_FIRST_NAMES
|
|
25
43
|
|
|
26
44
|
|
|
27
45
|
def merge(identities: list) -> list:
|
|
@@ -7,6 +7,16 @@ from collections import Counter, defaultdict
|
|
|
7
7
|
ROOT = "(root files)"
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
def in_tree(area: str, tree: dict) -> bool:
|
|
11
|
+
"""Whether any tracked file sits under `area` (a directory prefix ending in "/", or ROOT). With
|
|
12
|
+
no tree listing every area counts: there is nothing to judge by."""
|
|
13
|
+
if not tree:
|
|
14
|
+
return True
|
|
15
|
+
if area == ROOT:
|
|
16
|
+
return any("/" not in path for path in tree)
|
|
17
|
+
return any(path.startswith(area) for path in tree)
|
|
18
|
+
|
|
19
|
+
|
|
10
20
|
def _area(entity: str, depth: int) -> str:
|
|
11
21
|
dirs = entity.split("/")[:-1]
|
|
12
22
|
if not dirs:
|
|
@@ -14,6 +24,20 @@ def _area(entity: str, depth: int) -> str:
|
|
|
14
24
|
return "/".join(dirs[:depth]) + "/"
|
|
15
25
|
|
|
16
26
|
|
|
27
|
+
def top_area(entity: str) -> str:
|
|
28
|
+
"""The top-level directory of a path, or ROOT."""
|
|
29
|
+
return _area(entity, 1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def present_rows(rows: list, tree: dict) -> list:
|
|
33
|
+
"""Ownership rows for files whose top-level directory still exists in the tree. Filtering the rows
|
|
34
|
+
before areas are built keeps a vanished layout (the src/ before a move to crates/) from inflating
|
|
35
|
+
the total and hiding that one directory now holds almost everything."""
|
|
36
|
+
if not tree:
|
|
37
|
+
return rows
|
|
38
|
+
return [r for r in rows if in_tree(top_area(r["entity"]), tree)]
|
|
39
|
+
|
|
40
|
+
|
|
17
41
|
def _aggregate(rows: list, depth: int) -> list:
|
|
18
42
|
lines, per_author = Counter(), defaultdict(Counter)
|
|
19
43
|
for r in rows:
|
|
@@ -33,10 +33,13 @@ RAW_FIELDS = ("Secret", "Match", "Line", "Message", "Attributes")
|
|
|
33
33
|
|
|
34
34
|
# Shapes that cannot be a live secret: a version string (5.0.0-1667386184.dfbbb54), a token shortened
|
|
35
35
|
# with an ellipsis, a whole-value template marker (your-project-id, <your-token>, XXXX-XXXX, changeme),
|
|
36
|
+
# a whole value that is one of the words every example uses (`password: 'hello'` in a doc comment),
|
|
36
37
|
# and a key block whose body holds no key material. Every rule is about the whole value; nothing is
|
|
37
38
|
# skipped by prefix, since a public and a private key of the same service often share one.
|
|
38
39
|
_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
|
|
39
40
|
_MARKER = re.compile(r"^(<[^<>]+>|x+|(?:x{2,}[-_ ]?)+|your[-_][\w-]+|change[-_]?me|replace[-_]?me)$", re.I)
|
|
41
|
+
_EXAMPLE_WORDS = {"password", "passwd", "pass", "secret", "hello", "hey", "test", "example", "sample", "dummy", "foo", "bar",
|
|
42
|
+
"baz", "admin", "root", "user", "123456", "12345678", "123456789", "abc123", "qwerty", "letmein", "welcome"}
|
|
40
43
|
_KEY_BLOCK = re.compile(r"-----BEGIN [A-Z ]*KEY-----(.*?)-----END [A-Z ]*KEY-----", re.S)
|
|
41
44
|
_KEY_MATERIAL = 64 # a real body is hundreds of base64 characters; a template has dots or a few x's
|
|
42
45
|
|
|
@@ -54,7 +57,7 @@ def digest(value: str, key: bytes) -> str:
|
|
|
54
57
|
|
|
55
58
|
def is_placeholder(value: str) -> bool:
|
|
56
59
|
value = (value or "").strip()
|
|
57
|
-
if _VERSION.match(value) or value.endswith("...") or value.endswith("…") or _MARKER.match(value):
|
|
60
|
+
if _VERSION.match(value) or value.endswith("...") or value.endswith("…") or _MARKER.match(value) or value.lower() in _EXAMPLE_WORDS:
|
|
58
61
|
return True
|
|
59
62
|
m = _KEY_BLOCK.search(value)
|
|
60
63
|
if m:
|
|
@@ -151,7 +151,7 @@ def parse_functions(text: str) -> list:
|
|
|
151
151
|
for r in csv.reader(io.StringIO(text)):
|
|
152
152
|
if len(r) < 11:
|
|
153
153
|
continue
|
|
154
|
-
rows.append({"file": _rel(r[6]), "function": r[7], "ccn": _num(r[1]), "nloc": _num(r[0]), "params": _num(r[3]),
|
|
154
|
+
rows.append({"file": _rel(r[6]), "function": r[7] or "(anonymous)", "ccn": _num(r[1]), "nloc": _num(r[0]), "params": _num(r[3]),
|
|
155
155
|
"start": _num(r[9]), "end": _num(r[10])})
|
|
156
156
|
return rows
|
|
157
157
|
|
|
@@ -111,6 +111,24 @@ def _hide_vendor(rows: list, path_of, full, noun="file in vendored code", plural
|
|
|
111
111
|
return _hide_rows(rows, path_of, full, filetypes.is_vendor_path, noun, plural)
|
|
112
112
|
|
|
113
113
|
|
|
114
|
+
def _hide_generated(rows: list, path_of, report: dict, full, noun="generated file", plural=None) -> tuple:
|
|
115
|
+
"""Generated files (a header marker or a linguist-generated attribute, found at run time): the
|
|
116
|
+
generator's churn and complexity, not the repository's."""
|
|
117
|
+
generated = set((report.get("meta") or {}).get("generated") or [])
|
|
118
|
+
return _hide_rows(rows, path_of, full, lambda p: p in generated, noun, plural)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _hide_release(pairs: list, full) -> tuple:
|
|
122
|
+
"""Coupled pairs where both files are release plumbing (version files, manifests, lock files,
|
|
123
|
+
changelogs): they change together because a release touches them all, not because one depends on
|
|
124
|
+
the other. A version file paired with real code stays."""
|
|
125
|
+
if full is True:
|
|
126
|
+
return pairs, None
|
|
127
|
+
kept = [p for p in pairs if not (filetypes.is_release_path(p["entity"]) and filetypes.is_release_path(p["coupled"]))]
|
|
128
|
+
hidden = len(pairs) - len(kept)
|
|
129
|
+
return kept, (f"{hidden} release pair{'s' if hidden != 1 else ''} hidden{HIDDEN_SUFFIX}" if hidden else None)
|
|
130
|
+
|
|
131
|
+
|
|
114
132
|
def _join_hidden(*notes) -> str:
|
|
115
133
|
"""Several hidden-row notes as one caption phrase: 'A hidden; B hidden; --full shows them'."""
|
|
116
134
|
parts = [n[:-len(HIDDEN_SUFFIX)] if n.endswith(HIDDEN_SUFFIX) else n for n in notes if n]
|
|
@@ -376,7 +394,8 @@ def hotspots_section(report: dict, full: bool = True, width=None) -> dict:
|
|
|
376
394
|
scored = hotspots.ranked(report)
|
|
377
395
|
scored, hidden_note = _hide_tests(scored, lambda h: h["entity"], full)
|
|
378
396
|
scored, deleted_note = _hide_deleted(scored, report, full)
|
|
379
|
-
|
|
397
|
+
scored, generated_note = _hide_generated(scored, lambda h: h["entity"], report, full)
|
|
398
|
+
hidden_note = _join_hidden(hidden_note, deleted_note, generated_note)
|
|
380
399
|
title = "Hotspots (score = revisions × lines of code)" if full is True else "Hotspots"
|
|
381
400
|
limit = _limit("Hotspots", full)
|
|
382
401
|
series = (report.get("trend") or {}).get("files") or {}
|
|
@@ -408,6 +427,8 @@ def coupling_section(report: dict, full: bool = True, width=None) -> dict:
|
|
|
408
427
|
pairs = sorted((p for p in report.get("coupling") or [] if p["average-revs"] >= 5), key=lambda p: (-p["degree"], -p["average-revs"]))
|
|
409
428
|
pairs, hidden_note = _hide_tests(pairs, lambda p: (p["entity"], p["coupled"]), full, noun="test pair")
|
|
410
429
|
pairs, gone_note = _hide_gone(pairs, report, full)
|
|
430
|
+
pairs, release_note = _hide_release(pairs, full)
|
|
431
|
+
gone_note = _join_hidden(gone_note, release_note)
|
|
411
432
|
groups, cluster_note = [], None
|
|
412
433
|
if full is not True:
|
|
413
434
|
# a directory whose files all change together is one row; --full lists every pair
|
|
@@ -474,7 +495,8 @@ def functions_section(report: dict, full: bool = True, width=None) -> dict:
|
|
|
474
495
|
funcs = sorted((f for f in measured if f["ccn"] >= CCN_FLOOR), key=lambda f: (-f["ccn"], -f["nloc"], f["file"], f["function"], f["start"]))
|
|
475
496
|
funcs, hidden_note = _hide_tests(funcs, lambda f: f["file"], full, noun="function in a test file", plural="functions in test files")
|
|
476
497
|
funcs, vendor_note = _hide_vendor(funcs, lambda f: f["file"], full, noun="function in vendored code", plural="functions in vendored code")
|
|
477
|
-
|
|
498
|
+
funcs, generated_note = _hide_generated(funcs, lambda f: f["file"], report, full, noun="function in a generated file", plural="functions in generated files")
|
|
499
|
+
hidden_note = _join_hidden(hidden_note, vendor_note, generated_note)
|
|
478
500
|
limit = _limit("Complex functions", full)
|
|
479
501
|
rows = [(f["function"], f["file"], f["ccn"], f["nloc"], f["params"]) for f in funcs[:limit]]
|
|
480
502
|
columns = [("function", {"overflow": "fold"}), ("file", PATH), ("ccn", RIGHT), ("lines", RIGHT), ("params", RIGHT)]
|
|
@@ -502,7 +524,16 @@ def knowledge_section(report: dict, full: bool = True, width=None) -> dict:
|
|
|
502
524
|
"""Ownership by area of the tree: who wrote most of each directory, gone owners marked."""
|
|
503
525
|
months = report["meta"].get("gone_months", loss.DEFAULT_MONTHS)
|
|
504
526
|
gone = {g["name"] for g in loss.gone(report, months)}
|
|
505
|
-
|
|
527
|
+
rows_all = report.get("ownership") or [] # every area the map showed before, tests included
|
|
528
|
+
areas = loss.areas(rows_all, gone)
|
|
529
|
+
hidden_note = None
|
|
530
|
+
tree = (report.get("size") or {}).get("files") or {}
|
|
531
|
+
if full is not True and tree:
|
|
532
|
+
# a directory the history knows but HEAD does not is a layout that no longer exists; the rows are
|
|
533
|
+
# filtered before the areas are built so a vanished layout cannot hide that one directory now dominates
|
|
534
|
+
areas = [a for a in loss.areas(knowledge.present_rows(rows_all, tree), gone) if knowledge.in_tree(a["area"], tree)]
|
|
535
|
+
hidden = len({knowledge.top_area(r["entity"]) for r in rows_all if not knowledge.in_tree(knowledge.top_area(r["entity"]), tree)})
|
|
536
|
+
hidden_note = f"{hidden} historical area{'s' if hidden != 1 else ''} hidden{HIDDEN_SUFFIX}" if hidden else None
|
|
506
537
|
limit = _limit("Knowledge map", full)
|
|
507
538
|
rows = []
|
|
508
539
|
for a in areas[:limit]:
|
|
@@ -512,7 +543,7 @@ def knowledge_section(report: dict, full: bool = True, width=None) -> dict:
|
|
|
512
543
|
columns = [("area", PATH), ("lines added", RIGHT), ("authors", RIGHT), ("lost", RIGHT), ("main owner", {}), ("second", {})]
|
|
513
544
|
if full is not True:
|
|
514
545
|
columns, rows = _keep(columns, rows, ["area", "lines added", "main owner", "second"])
|
|
515
|
-
notes = [c for c in (_more(len(areas), limit),) if c]
|
|
546
|
+
notes = [c for c in (_more(len(areas), limit), hidden_note) if c]
|
|
516
547
|
if gone:
|
|
517
548
|
notes.append(f"gone = no commits in the {months} months before {report['meta'].get('last_date')}"
|
|
518
549
|
+ ("; gone and lost are measured over the whole history" if report["meta"].get("since") else ""))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitmole
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.6
|
|
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
|
|
@@ -592,6 +592,28 @@ class Arguments(unittest.TestCase):
|
|
|
592
592
|
self.assertNotIn("jar", text)
|
|
593
593
|
|
|
594
594
|
|
|
595
|
+
class GeneratedFiles(unittest.TestCase):
|
|
596
|
+
def test_a_run_records_the_generated_files_in_meta(self):
|
|
597
|
+
with tempfile.TemporaryDirectory() as d:
|
|
598
|
+
_tiny_repo(d)
|
|
599
|
+
os.makedirs(os.path.join(d, "lib"))
|
|
600
|
+
with open(os.path.join(d, "lib", "validator.js"), "w") as fh:
|
|
601
|
+
fh.write("// This file is autogenerated by build/build.js, do not edit\nmodule.exports = 1\n")
|
|
602
|
+
with open(os.path.join(d, "lib", "app.js"), "w") as fh:
|
|
603
|
+
fh.write("module.exports = 2\n")
|
|
604
|
+
import subprocess
|
|
605
|
+
subprocess.run(["git", "-C", d, "add", "-A"], check=True)
|
|
606
|
+
subprocess.run(["git", "-C", d, "-c", "user.name=T", "-c", "user.email=t@x.com", "commit", "-q", "-m", "files"], check=True)
|
|
607
|
+
out = os.path.join(d, "out")
|
|
608
|
+
planner = lambda repo, o, branch="HEAD", **kw: [{"name": "q", "argv": ["true"], "stdout": None, "deps": []}]
|
|
609
|
+
rc = cli.main([d, "--out", out], console=console(), tool_check=lambda **kw: [], planner=planner,
|
|
610
|
+
estimator=lambda repo, interval, **kw: {"files": 2, "samples": 1, "blames": 2})
|
|
611
|
+
with open(os.path.join(out, "meta.json")) as fh:
|
|
612
|
+
meta = json.load(fh)
|
|
613
|
+
self.assertEqual(rc, 0)
|
|
614
|
+
self.assertEqual(meta["generated"], ["lib/validator.js"])
|
|
615
|
+
|
|
616
|
+
|
|
595
617
|
class Clean(unittest.TestCase):
|
|
596
618
|
"""--clean lists what gitmole left behind and deletes on a yes. TMPDIR is pointed at a scratch dir so the
|
|
597
619
|
real temp folder is never listed or touched."""
|
|
@@ -45,8 +45,24 @@ class Clusters(unittest.TestCase):
|
|
|
45
45
|
self.assertEqual([g["dir"] for g in groups], ["lib/", "(root files)"])
|
|
46
46
|
self.assertEqual(rest, [])
|
|
47
47
|
|
|
48
|
-
def
|
|
49
|
-
pairs = [pair("d/a", "d/b"), pair("d/b", "d/c"), pair("d/c", "d/e")] # 4 files, 3 pairs, not
|
|
50
|
-
|
|
51
|
-
self.assertEqual(
|
|
48
|
+
def test_a_chain_is_not_a_cluster(self):
|
|
49
|
+
pairs = [pair("d/a", "d/b"), pair("d/b", "d/c"), pair("d/c", "d/e")] # 4 files, 3 of 6 possible pairs: connected, not a clique
|
|
50
|
+
groups, rest = coupling.clusters(pairs)
|
|
51
|
+
self.assertEqual(groups, [])
|
|
52
|
+
self.assertEqual(rest, pairs)
|
|
53
|
+
|
|
54
|
+
def test_a_near_complete_clique_is_a_cluster(self):
|
|
55
|
+
pairs = clique("d", ["a", "b", "c", "e", "f"]) # 5 files, 10 pairs
|
|
56
|
+
eight = pairs[:8]
|
|
57
|
+
[g], rest = coupling.clusters(eight)
|
|
58
|
+
self.assertEqual((g["files"], g["pairs"]), (5, 8))
|
|
52
59
|
self.assertEqual(rest, [])
|
|
60
|
+
groups, rest = coupling.clusters(pairs[:7]) # 7 of 10 is under the 80% floor
|
|
61
|
+
self.assertEqual(groups, [])
|
|
62
|
+
self.assertEqual(len(rest), 7)
|
|
63
|
+
|
|
64
|
+
def test_two_unrelated_pairs_in_one_directory_are_not_a_cluster(self):
|
|
65
|
+
pairs = [pair("a", "b"), pair("c", "d")]
|
|
66
|
+
groups, rest = coupling.clusters(pairs)
|
|
67
|
+
self.assertEqual(groups, [])
|
|
68
|
+
self.assertEqual(rest, pairs)
|
|
@@ -104,6 +104,33 @@ class TestPaths(unittest.TestCase):
|
|
|
104
104
|
for path in ("app/settings.py", "examplesite/app.py", "src/rulesets/a.go", "sampler/x.py", "config/betterleaks.toml"):
|
|
105
105
|
self.assertFalse(filetypes.is_sample_path(path), path)
|
|
106
106
|
|
|
107
|
+
def test_release_plumbing_files(self):
|
|
108
|
+
for path in ("lib/sinatra/version.rb", "VERSION", "src/pkg/__version__.py", "package.json", "package-lock.json", "Gemfile.lock",
|
|
109
|
+
"Cargo.toml", "pyproject.toml", "go.sum", "CHANGELOG.md", "CHANGES.rst", "sinatra.gemspec", "uv.lock"):
|
|
110
|
+
self.assertTrue(filetypes.is_release_path(path), path)
|
|
111
|
+
for path in ("lib/version_check.py", "src/app.py", "docs/versions.md", "Makefile", "lib/sinatra/base.rb"):
|
|
112
|
+
self.assertFalse(filetypes.is_release_path(path), path)
|
|
113
|
+
|
|
114
|
+
def test_generated_files_by_header_marker_or_attribute(self):
|
|
115
|
+
with tempfile.TemporaryDirectory() as d:
|
|
116
|
+
files = {
|
|
117
|
+
"lib/config-validator.js": "// This file is autogenerated by build/build-validation.js, do not edit\n'use strict'\n",
|
|
118
|
+
"pb/api.pb.go": "// Code generated by protoc-gen-go. DO NOT EDIT.\npackage pb\n",
|
|
119
|
+
"gen/schema.py": "# @generated\nx = 1\n",
|
|
120
|
+
"src/app.py": "# the app; it generated reports once\ndef main():\n pass\n",
|
|
121
|
+
"docs/notes.md": "generated notes are the best notes\n",
|
|
122
|
+
"dist/bundle.js": "var a = 1;\n",
|
|
123
|
+
"src/late.py": "\n" * 10 + "# generated by hand, do not edit\n", # a marker past the first lines does not count
|
|
124
|
+
}
|
|
125
|
+
for path, text in files.items():
|
|
126
|
+
os.makedirs(os.path.join(d, os.path.dirname(path)), exist_ok=True)
|
|
127
|
+
with open(os.path.join(d, path), "w") as fh:
|
|
128
|
+
fh.write(text)
|
|
129
|
+
with open(os.path.join(d, ".gitattributes"), "w") as fh:
|
|
130
|
+
fh.write("* text=auto\ndist/* linguist-generated=true\n*.min.js linguist-generated\n")
|
|
131
|
+
found = filetypes.generated_files(d, sorted(files))
|
|
132
|
+
self.assertEqual(found, ["dist/bundle.js", "gen/schema.py", "lib/config-validator.js", "pb/api.pb.go"])
|
|
133
|
+
|
|
107
134
|
def test_vendored_trees(self):
|
|
108
135
|
for path in ("vendor/github.com/x/y.go", "web/node_modules/a/index.js", "third_party/z/a.c", "thirdparty/a.c", "_vendor/a.py",
|
|
109
136
|
"external/lib/a.cpp"):
|
|
@@ -145,6 +145,14 @@ class BusFactor(unittest.TestCase):
|
|
|
145
145
|
self.assertEqual(findings.bus_factor(r)[0]["advice"], "Pair someone with Ann on core/ first; it is 95% theirs since 2025-01-01.",
|
|
146
146
|
"ownership is windowed while the headline share is not")
|
|
147
147
|
|
|
148
|
+
def test_areas_no_longer_in_the_tree_are_not_named_in_the_advice(self):
|
|
149
|
+
own = [{"entity": "flask/a.py", "author": "Ann", "added": 5000, "deleted": 0}, # the pre-src/ layout
|
|
150
|
+
{"entity": "src/a.py", "author": "Ann", "added": 900, "deleted": 0},
|
|
151
|
+
{"entity": "src/b.py", "author": "Bob", "added": 50, "deleted": 0}]
|
|
152
|
+
tree = {"files": {"src/a.py": {"code": 1, "complexity": 0}, "src/b.py": {"code": 1, "complexity": 0}}}
|
|
153
|
+
f = findings.bus_factor(report(theseus_authors={"Ann": 79, "Bob": 21}, ownership=own, size=tree))
|
|
154
|
+
self.assertEqual(f[0]["advice"], "Pair someone with Ann on src/ first; it is 95% theirs.")
|
|
155
|
+
|
|
148
156
|
def test_vendored_trees_are_not_named_in_the_advice(self):
|
|
149
157
|
own = [{"entity": "vendor/github.com/x/a.go", "author": "Ann", "added": 500000, "deleted": 0},
|
|
150
158
|
{"entity": "core/a.py", "author": "Ann", "added": 900, "deleted": 0},
|
|
@@ -246,6 +254,15 @@ class TightCoupling(unittest.TestCase):
|
|
|
246
254
|
f = findings.tight_coupling(report(coupling=pairs))
|
|
247
255
|
self.assertIn("2 pairs", f[0]["detail"], "without a tree listing every pair counts")
|
|
248
256
|
|
|
257
|
+
def test_release_plumbing_pairs_are_not_a_dependency(self):
|
|
258
|
+
pairs = [{"entity": "lib/sinatra/version.rb", "coupled": "rack-protection/lib/rack/protection/version.rb", "degree": 100, "average-revs": 60},
|
|
259
|
+
{"entity": "package.json", "coupled": "package-lock.json", "degree": 95, "average-revs": 40},
|
|
260
|
+
{"entity": "lib/sinatra/version.rb", "coupled": "lib/sinatra/base.rb", "degree": 85, "average-revs": 10}]
|
|
261
|
+
f = findings.tight_coupling(report(coupling=pairs))
|
|
262
|
+
self.assertIn("1 pair changes together", f[0]["detail"], "a version file paired with real code still counts")
|
|
263
|
+
self.assertNotIn("package.json", f[0]["detail"])
|
|
264
|
+
self.assertEqual(findings.tight_coupling(report(coupling=pairs[:2])), [])
|
|
265
|
+
|
|
249
266
|
def test_single_pair_reads_grammatically(self):
|
|
250
267
|
pairs = [{"entity": "a", "coupled": "b", "degree": 100, "average-revs": 10}]
|
|
251
268
|
f = findings.tight_coupling(report(coupling=pairs))
|
|
@@ -366,6 +383,21 @@ class BrainMethods(unittest.TestCase):
|
|
|
366
383
|
self.assertNotIn("test_all", f[0]["detail"])
|
|
367
384
|
self.assertEqual(findings.brain_methods(report(functions=fns[:1])), [])
|
|
368
385
|
|
|
386
|
+
def test_an_anonymous_function_is_named_by_its_place(self):
|
|
387
|
+
fns = [{"file": "completions.go", "function": "(anonymous)", "ccn": 47, "nloc": 136, "params": 1, "start": 316, "end": 585}]
|
|
388
|
+
f = findings.brain_methods(report(functions=fns))
|
|
389
|
+
self.assertIn("(anonymous) (completions.go:316) complexity 47, 136 lines, 1 params", f[0]["detail"])
|
|
390
|
+
self.assertEqual(f[0]["advice"], "Split the anonymous function at completions.go:316 first, before the next change lands there.")
|
|
391
|
+
|
|
392
|
+
def test_generated_files_are_not_brain_methods(self):
|
|
393
|
+
fns = [{"file": "lib/config-validator.js", "function": "validate10", "ccn": 373, "nloc": 1150, "params": 5, "start": 1, "end": 1150},
|
|
394
|
+
{"file": "lib/reply.js", "function": "onSendEnd", "ccn": 34, "nloc": 180, "params": 2, "start": 1, "end": 180}]
|
|
395
|
+
r = report(functions=fns)
|
|
396
|
+
r["meta"]["generated"] = ["lib/config-validator.js"]
|
|
397
|
+
f = findings.brain_methods(r)
|
|
398
|
+
self.assertEqual(f[0]["advice"], "Split onSendEnd in lib/reply.js first, before the next change lands there.")
|
|
399
|
+
self.assertNotIn("validate10", f[0]["detail"])
|
|
400
|
+
|
|
369
401
|
def test_vendored_functions_are_not_brain_methods(self):
|
|
370
402
|
fns = [{"file": "vendor/github.com/google/jsonschema-go/jsonschema/validate.go", "function": "validate", "ccn": 179, "nloc": 424, "params": 3, "start": 1, "end": 424},
|
|
371
403
|
{"file": "processor/workers.go", "function": "countLoopGeneric", "ccn": 56, "nloc": 164, "params": 8, "start": 1, "end": 164}]
|
|
@@ -438,6 +470,19 @@ class KnowledgeIslands(unittest.TestCase):
|
|
|
438
470
|
self.assertEqual(f[0]["advice"], "Pair someone with Bob on core/ first; it is the largest at 300 lines.")
|
|
439
471
|
self.assertNotIn("tests/", f[0]["detail"])
|
|
440
472
|
|
|
473
|
+
def test_areas_no_longer_in_the_tree_are_not_islands(self):
|
|
474
|
+
own = [{"entity": "src/a.rs", "author": "Ann", "added": 30000, "deleted": 0}, # moved to crates/ years ago
|
|
475
|
+
{"entity": "grep-printer/a.rs", "author": "Ann", "added": 20000, "deleted": 0},
|
|
476
|
+
{"entity": "crates/core/a.rs", "author": "Bob", "added": 300, "deleted": 0}]
|
|
477
|
+
tree = {"files": {"crates/core/a.rs": {"code": 300, "complexity": 1}}}
|
|
478
|
+
f = findings.knowledge_islands(report(ownership=own, size=tree))
|
|
479
|
+
self.assertEqual(f[0]["advice"], "Pair someone with Bob on crates/core/ first; it is the largest at 300 lines.",
|
|
480
|
+
"with the vanished directories gone, crates/ holds everything and the map descends into it")
|
|
481
|
+
self.assertNotIn("src/", f[0]["detail"])
|
|
482
|
+
self.assertIn("100% of all lines added", f[0]["detail"], "lines in vanished directories are not in the denominator")
|
|
483
|
+
f = findings.knowledge_islands(report(ownership=own))
|
|
484
|
+
self.assertIn("src/", f[0]["detail"], "without a tree listing every area counts")
|
|
485
|
+
|
|
441
486
|
def test_vendored_trees_are_not_islands(self):
|
|
442
487
|
own = [{"entity": "vendor/github.com/x/a.go", "author": "Ann", "added": 500000, "deleted": 0},
|
|
443
488
|
{"entity": "web/node_modules/y/b.js", "author": "Ann", "added": 90000, "deleted": 0},
|
|
@@ -511,6 +556,18 @@ class KnowledgeLoss(unittest.TestCase):
|
|
|
511
556
|
self.assertIn("Areas mostly theirs: old/ (100%), docs/ (100%)", f[0]["detail"])
|
|
512
557
|
self.assertEqual(f[0]["advice"], "Pair someone on old/ first; nobody who wrote it is around to ask.")
|
|
513
558
|
|
|
559
|
+
def test_areas_no_longer_in_the_tree_are_not_named(self):
|
|
560
|
+
r = self._report(theseus_authors={"Ann": 60, "Bob": 40},
|
|
561
|
+
age=[{"entity": "flask/a.py", "age-months": 2}, {"entity": "src/x.py", "age-months": 2}],
|
|
562
|
+
ownership=[{"entity": "flask/a.py", "author": "Bob", "added": 8000, "deleted": 0}, # the old layout, all Bob's
|
|
563
|
+
{"entity": "src/x.py", "author": "Bob", "added": 300, "deleted": 0},
|
|
564
|
+
{"entity": "app/b.py", "author": "Ann", "added": 900, "deleted": 0}],
|
|
565
|
+
size={"files": {"src/x.py": {"code": 1, "complexity": 0}, "app/b.py": {"code": 1, "complexity": 0}}})
|
|
566
|
+
f = findings.knowledge_loss(r)
|
|
567
|
+
self.assertIn("Areas mostly theirs: src/ (100%).", f[0]["detail"])
|
|
568
|
+
self.assertNotIn("flask/", f[0]["detail"])
|
|
569
|
+
self.assertEqual(f[0]["advice"], "Pair someone on src/ first; nobody who wrote it is around to ask.")
|
|
570
|
+
|
|
514
571
|
def test_a_live_area_is_preferred_over_a_bigger_idle_one(self):
|
|
515
572
|
r = self._report(theseus_authors={"Ann": 60, "Bob": 40},
|
|
516
573
|
age=[{"entity": "old/a.py", "age-months": 30}, {"entity": "live/b.py", "age-months": 3}],
|
|
@@ -18,6 +18,19 @@ class Merge(unittest.TestCase):
|
|
|
18
18
|
names = [m["name"] for m in merged]
|
|
19
19
|
self.assertEqual(names, ["Bob", "Grzegorz Bankosz", "Ann"])
|
|
20
20
|
|
|
21
|
+
def test_an_identical_handle_under_several_emails_is_one_person(self):
|
|
22
|
+
ids = [{"name": "KaKa", "email": "kaka@a.com", "commits": 57}, {"name": "KaKa", "email": "23028015+climba@users.noreply.github.com", "commits": 56},
|
|
23
|
+
{"name": "kaka", "email": "climba@b.com", "commits": 10}, {"name": "namusyaka", "email": "n@a.com", "commits": 180},
|
|
24
|
+
{"name": "namusyaka", "email": "n@b.com", "commits": 8}, {"name": "Li Yu", "email": "li@a.com", "commits": 5},
|
|
25
|
+
{"name": "Li Yu", "email": "li@b.com", "commits": 3}]
|
|
26
|
+
merged = {m["name"]: m["commits"] for m in identity.merge(ids)}
|
|
27
|
+
self.assertEqual(merged, {"KaKa": 123, "namusyaka": 188, "Li Yu": 8})
|
|
28
|
+
|
|
29
|
+
def test_a_bare_common_first_name_is_not_enough(self):
|
|
30
|
+
ids = [{"name": "Jean", "email": "jean@a.com", "commits": 24}, {"name": "Jean", "email": "jean@b.com", "commits": 18},
|
|
31
|
+
{"name": "Alex", "email": "alex@a.com", "commits": 3}, {"name": "alex", "email": "alex@b.com", "commits": 2}]
|
|
32
|
+
self.assertEqual(len(identity.merge(ids)), 4, "two Jeans and two Alexes may be four people")
|
|
33
|
+
|
|
21
34
|
def test_merged_row_sums_commits_and_lists_aliases(self):
|
|
22
35
|
merged = {m["name"]: m for m in identity.merge(IDS)}
|
|
23
36
|
self.assertEqual(merged["Grzegorz Bankosz"]["commits"], 41)
|
|
@@ -48,6 +48,36 @@ class OddNames(unittest.TestCase):
|
|
|
48
48
|
self.assertEqual(areas[0]["lines"], 1000)
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
class InTree(unittest.TestCase):
|
|
52
|
+
TREE = {"crates/core/a.rs": {}, "crates/ignore/src/b.rs": {}, "build.rs": {}}
|
|
53
|
+
|
|
54
|
+
def test_an_area_is_in_the_tree_when_any_tracked_file_sits_under_it(self):
|
|
55
|
+
self.assertTrue(knowledge.in_tree("crates/", self.TREE))
|
|
56
|
+
self.assertTrue(knowledge.in_tree("crates/ignore/", self.TREE))
|
|
57
|
+
self.assertFalse(knowledge.in_tree("src/", self.TREE))
|
|
58
|
+
self.assertFalse(knowledge.in_tree("crate/", self.TREE), "a prefix of a directory name is not that directory")
|
|
59
|
+
|
|
60
|
+
def test_root_files_are_in_the_tree_when_any_file_has_no_directory(self):
|
|
61
|
+
self.assertTrue(knowledge.in_tree(knowledge.ROOT, self.TREE))
|
|
62
|
+
self.assertFalse(knowledge.in_tree(knowledge.ROOT, {"crates/core/a.rs": {}}))
|
|
63
|
+
|
|
64
|
+
def test_no_tree_listing_means_every_area_counts(self):
|
|
65
|
+
self.assertTrue(knowledge.in_tree("src/", {}))
|
|
66
|
+
|
|
67
|
+
def test_present_rows_drop_vanished_top_level_directories_so_the_survivor_can_dominate(self):
|
|
68
|
+
rows = [{"entity": "src/a.rs", "author": "Ann", "added": 30000, "deleted": 0}, # the layout before crates/
|
|
69
|
+
{"entity": "crates/core/a.rs", "author": "Ann", "added": 9000, "deleted": 0},
|
|
70
|
+
{"entity": "crates/ignore/b.rs", "author": "Bob", "added": 900, "deleted": 0},
|
|
71
|
+
{"entity": "ci/x.sh", "author": "Ann", "added": 500, "deleted": 0}]
|
|
72
|
+
tree = {"crates/core/a.rs": {}, "crates/ignore/b.rs": {}, "ci/x.sh": {}}
|
|
73
|
+
kept = knowledge.present_rows(rows, tree)
|
|
74
|
+
self.assertEqual([r["entity"] for r in kept], ["crates/core/a.rs", "crates/ignore/b.rs", "ci/x.sh"])
|
|
75
|
+
self.assertEqual([a["area"] for a in knowledge.areas(kept)], ["crates/core/", "crates/ignore/", "ci/"],
|
|
76
|
+
"with src/ gone, crates/ holds over 80% and the map descends into it")
|
|
77
|
+
self.assertEqual([a["area"] for a in knowledge.areas(rows)], ["src/", "crates/", "ci/"], "the vanished src/ used to hide that")
|
|
78
|
+
self.assertEqual(knowledge.present_rows(rows, {}), rows)
|
|
79
|
+
|
|
80
|
+
|
|
51
81
|
class Islands(unittest.TestCase):
|
|
52
82
|
def test_areas_dominated_by_one_author(self):
|
|
53
83
|
areas = knowledge.areas(OWNERSHIP)
|
|
@@ -59,6 +59,13 @@ class Placeholder(unittest.TestCase):
|
|
|
59
59
|
for value in ["AKIA" + "X" * 16, "6L" + "x" * 38, "ghp_" + "a1" * 18, "yourkey" + "9" * 20]:
|
|
60
60
|
self.assertFalse(leaks.is_placeholder(value), "a marker inside real-looking material is not enough: " + value)
|
|
61
61
|
|
|
62
|
+
def test_common_example_words_are_placeholders(self):
|
|
63
|
+
# `password: 'hello'` in a doc comment, `secret` in a sample config: the words every example uses
|
|
64
|
+
for value in ["hello", "Hello", "secret", "password", "PASSWORD", "example", "123456", "qwerty", "letmein", "foo", "dummy"]:
|
|
65
|
+
self.assertTrue(leaks.is_placeholder(value), value)
|
|
66
|
+
for value in ["hello123", "secret-9f8a7b6c5d4e", "s3cr3t!Passw0rd", "foobarbaz2024"]:
|
|
67
|
+
self.assertFalse(leaks.is_placeholder(value), "a word inside other material is not a placeholder: " + value)
|
|
68
|
+
|
|
62
69
|
def test_anything_else_is_taken_seriously(self):
|
|
63
70
|
# built at runtime: a literal in these shapes would trip secret scanners on this very file
|
|
64
71
|
key_id, long_key = "AKIA" + "X" * 16, "6L" + "x" * 38
|
|
@@ -138,6 +138,11 @@ class ParseFunctions(unittest.TestCase):
|
|
|
138
138
|
def test_empty(self):
|
|
139
139
|
self.assertEqual(load.parse_functions(""), [])
|
|
140
140
|
|
|
141
|
+
def test_a_nameless_function_is_called_anonymous(self):
|
|
142
|
+
# lizard names Go function literals with an empty string where it names JavaScript's "(anonymous)"
|
|
143
|
+
rows = load.parse_functions('136,47,926,1,270,"@316-585@completions.go","completions.go",""," c * Command",316,585\n')
|
|
144
|
+
self.assertEqual((rows[0]["function"], rows[0]["start"]), ("(anonymous)", 316))
|
|
145
|
+
|
|
141
146
|
def test_a_row_cut_short_by_a_killed_step_does_not_abort_the_report(self):
|
|
142
147
|
rows = load.parse_functions(self.CSV + '5,3,40,1,5,"g@1-5@a.py","a.py","g","g( )",1,\n')
|
|
143
148
|
self.assertEqual(len(rows), 3)
|
|
@@ -212,6 +212,7 @@ class Report(unittest.TestCase):
|
|
|
212
212
|
def test_knowledge_map_caption_says_gone_is_measured_over_the_whole_history(self):
|
|
213
213
|
r = sample_report()
|
|
214
214
|
r["meta"].update({"last_date": "2026-09-10", "bots": []})
|
|
215
|
+
r["size"]["files"]["tests/t.py"] = {"code": 1, "complexity": 0} # every area in the tree, so nothing is hidden
|
|
215
216
|
r["activity"]["authors"] = {"Ann": {"commits": 1, "added": 0, "deleted": 0, "first": "2025-01-01", "last": "2026-09-01"},
|
|
216
217
|
"Bob": {"commits": 1, "added": 0, "deleted": 0, "first": "2025-01-01", "last": "2025-01-01"}}
|
|
217
218
|
def caption(rep):
|
|
@@ -317,6 +318,36 @@ class Report(unittest.TestCase):
|
|
|
317
318
|
full = _section_text(rendered(r, [], width=200, full=True), "Complex functions")
|
|
318
319
|
self.assertIn("vendor/github.com/x/y.go", full)
|
|
319
320
|
|
|
321
|
+
def test_default_tables_hide_generated_files_and_say_so(self):
|
|
322
|
+
r = sample_report()
|
|
323
|
+
r["meta"]["generated"] = ["lib/config-validator.js"]
|
|
324
|
+
r["size"]["files"]["lib/config-validator.js"] = {"code": 1153, "complexity": 373}
|
|
325
|
+
r["revisions"].append({"entity": "lib/config-validator.js", "n-revs": 8})
|
|
326
|
+
r["functions"].append({"file": "lib/config-validator.js", "function": "validate10", "ccn": 373, "nloc": 1150, "params": 5, "start": 1, "end": 1150})
|
|
327
|
+
text = rendered(r, [], width=200)
|
|
328
|
+
hot = text[text.index("◆ Hotspots"):text.index("Change coupling")]
|
|
329
|
+
self.assertNotIn("config-validator", hot)
|
|
330
|
+
self.assertIn("1 generated file hidden; --full shows them", hot)
|
|
331
|
+
fn = _section_text(text, "Complex functions")
|
|
332
|
+
self.assertNotIn("validate10", fn)
|
|
333
|
+
self.assertIn("1 function in a generated file hidden; --full shows them", fn)
|
|
334
|
+
full = rendered(r, [], width=200, full=True)
|
|
335
|
+
self.assertIn("validate10", full)
|
|
336
|
+
|
|
337
|
+
def test_default_coupling_hides_release_plumbing_pairs_and_says_so(self):
|
|
338
|
+
r = sample_report()
|
|
339
|
+
for f in ("lib/version.rb", "contrib/version.rb", "Gemfile", "Gemfile.lock"):
|
|
340
|
+
r["size"]["files"][f] = {"code": 3, "complexity": 0}
|
|
341
|
+
r["coupling"] = [{"entity": "lib/version.rb", "coupled": "contrib/version.rb", "degree": 64, "average-revs": 60},
|
|
342
|
+
{"entity": "Gemfile", "coupled": "Gemfile.lock", "degree": 90, "average-revs": 20},
|
|
343
|
+
{"entity": "static/index.html", "coupled": "static/apps-metadata.json", "degree": 90, "average-revs": 11}]
|
|
344
|
+
coupling = _section_text(rendered(r, [], width=200), "Change coupling")
|
|
345
|
+
self.assertIn("static/index.html", coupling)
|
|
346
|
+
self.assertNotIn("version.rb", coupling)
|
|
347
|
+
self.assertIn("2 release pairs hidden; --full shows them", coupling)
|
|
348
|
+
full = _section_text(rendered(r, [], width=200, full=True), "Change coupling")
|
|
349
|
+
self.assertIn("version.rb", full)
|
|
350
|
+
|
|
320
351
|
def test_hotspots_with_only_test_files_say_what_was_hidden(self):
|
|
321
352
|
r = sample_report()
|
|
322
353
|
r["revisions"] = [{"entity": "tests/test_a.py", "n-revs": 200}]
|
|
@@ -636,6 +667,25 @@ class KnowledgeMap(unittest.TestCase):
|
|
|
636
667
|
r["ownership"] = []
|
|
637
668
|
self.assertIn("no ownership data", rendered(r, []))
|
|
638
669
|
|
|
670
|
+
def test_default_map_hides_areas_no_longer_in_the_tree_and_says_so(self):
|
|
671
|
+
r = sample_report() # the tree holds static/ files only; tests/t.py in the ownership rows is history
|
|
672
|
+
r["ownership"].append({"entity": "flask/app.py", "author": "Ann", "added": 4000, "deleted": 0})
|
|
673
|
+
km = _section_text(rendered(r, [], width=200), "Knowledge map")
|
|
674
|
+
self.assertIn("static/", km)
|
|
675
|
+
self.assertNotIn("flask/", km)
|
|
676
|
+
self.assertNotIn("tests/", km)
|
|
677
|
+
self.assertIn("2 historical areas hidden; --full shows them", km)
|
|
678
|
+
full = _section_text(rendered(r, [], width=200, full=True), "Knowledge map")
|
|
679
|
+
self.assertIn("flask/", full)
|
|
680
|
+
self.assertNotIn("hidden", full)
|
|
681
|
+
|
|
682
|
+
def test_map_without_a_tree_listing_hides_nothing(self):
|
|
683
|
+
r = sample_report()
|
|
684
|
+
r["size"]["files"] = {}
|
|
685
|
+
km = _section_text(rendered(r, [], width=200), "Knowledge map")
|
|
686
|
+
self.assertIn("tests/", km)
|
|
687
|
+
self.assertNotIn("historical", km)
|
|
688
|
+
|
|
639
689
|
|
|
640
690
|
class Timeline(unittest.TestCase):
|
|
641
691
|
def test_last_twelve_months_per_author_with_dots_for_zero(self):
|
|
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
|