gitmole 0.6.4__tar.gz → 0.6.5__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.5}/PKG-INFO +1 -1
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/__init__.py +1 -1
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/coupling.py +8 -6
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/findings.py +16 -5
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/knowledge.py +24 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/leaks.py +4 -1
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/render.py +11 -2
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole.egg-info/PKG-INFO +1 -1
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_coupling.py +20 -4
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_findings.py +33 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_knowledge.py +30 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_leaks.py +7 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_render.py +20 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/LICENSE +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/README.md +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/__main__.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/backtest.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/banner.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/blame.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/clean.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/cli.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/filetypes.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/functions.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/hotspots.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/identity.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/load.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/loss.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/maat.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/run.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/textfmt.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/trend.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole/watch.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole.egg-info/SOURCES.txt +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole.egg-info/dependency_links.txt +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole.egg-info/entry_points.txt +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole.egg-info/requires.txt +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/gitmole.egg-info/top_level.txt +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/pyproject.toml +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/setup.cfg +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_backtest.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_banner.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_blame.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_clean.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_cli.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_filetypes.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_functions.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_golden.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_hotspots.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_identity.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_load.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_loss.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_maat.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_packaging.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_run.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_textfmt.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/tests/test_trend.py +0 -0
- {gitmole-0.6.4 → gitmole-0.6.5}/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.5
|
|
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
|
|
@@ -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))})
|
|
@@ -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"])))
|
|
@@ -263,7 +271,9 @@ def reverts(report: dict, min_share: float = 0.05, min_count: int = 5, warn_shar
|
|
|
263
271
|
|
|
264
272
|
|
|
265
273
|
def knowledge_islands(report: dict, min_lines: int = 200, min_share: float = 0.9) -> list:
|
|
266
|
-
|
|
274
|
+
"""Areas of the tree written almost entirely by one person. Areas that no longer exist are left
|
|
275
|
+
out, of the islands and of the total they are measured against."""
|
|
276
|
+
areas = _present_areas(report, _source_ownership(report))
|
|
267
277
|
islands = knowledge.islands(areas, min_lines=min_lines, min_share=min_share)
|
|
268
278
|
if not islands:
|
|
269
279
|
return []
|
|
@@ -329,9 +339,10 @@ def _loss_people(by_person: dict, total: int) -> str:
|
|
|
329
339
|
|
|
330
340
|
|
|
331
341
|
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(
|
|
342
|
+
"""Areas at 200+ lines where 80%+ of the surviving code is theirs, still in the tree, tagged live
|
|
343
|
+
or not and sorted live-first: that is where the gap bites soonest."""
|
|
344
|
+
theirs = [a for a in _present_areas(report, source_rows, build=lambda rows: loss.areas(rows, names))
|
|
345
|
+
if a["lines"] >= 200 and a["lost_share"] >= 0.8]
|
|
335
346
|
for a in theirs:
|
|
336
347
|
a["live"] = _is_live(a["area"], report.get("age") or [])
|
|
337
348
|
theirs.sort(key=lambda a: (not a["live"], -a["lines"], a["area"])) # a live area first: that is where the gap bites
|
|
@@ -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:
|
|
@@ -502,7 +502,16 @@ def knowledge_section(report: dict, full: bool = True, width=None) -> dict:
|
|
|
502
502
|
"""Ownership by area of the tree: who wrote most of each directory, gone owners marked."""
|
|
503
503
|
months = report["meta"].get("gone_months", loss.DEFAULT_MONTHS)
|
|
504
504
|
gone = {g["name"] for g in loss.gone(report, months)}
|
|
505
|
-
|
|
505
|
+
rows_all = report.get("ownership") or [] # every area the map showed before, tests included
|
|
506
|
+
areas = loss.areas(rows_all, gone)
|
|
507
|
+
hidden_note = None
|
|
508
|
+
tree = (report.get("size") or {}).get("files") or {}
|
|
509
|
+
if full is not True and tree:
|
|
510
|
+
# a directory the history knows but HEAD does not is a layout that no longer exists; the rows are
|
|
511
|
+
# filtered before the areas are built so a vanished layout cannot hide that one directory now dominates
|
|
512
|
+
areas = [a for a in loss.areas(knowledge.present_rows(rows_all, tree), gone) if knowledge.in_tree(a["area"], tree)]
|
|
513
|
+
hidden = len({knowledge.top_area(r["entity"]) for r in rows_all if not knowledge.in_tree(knowledge.top_area(r["entity"]), tree)})
|
|
514
|
+
hidden_note = f"{hidden} historical area{'s' if hidden != 1 else ''} hidden{HIDDEN_SUFFIX}" if hidden else None
|
|
506
515
|
limit = _limit("Knowledge map", full)
|
|
507
516
|
rows = []
|
|
508
517
|
for a in areas[:limit]:
|
|
@@ -512,7 +521,7 @@ def knowledge_section(report: dict, full: bool = True, width=None) -> dict:
|
|
|
512
521
|
columns = [("area", PATH), ("lines added", RIGHT), ("authors", RIGHT), ("lost", RIGHT), ("main owner", {}), ("second", {})]
|
|
513
522
|
if full is not True:
|
|
514
523
|
columns, rows = _keep(columns, rows, ["area", "lines added", "main owner", "second"])
|
|
515
|
-
notes = [c for c in (_more(len(areas), limit),) if c]
|
|
524
|
+
notes = [c for c in (_more(len(areas), limit), hidden_note) if c]
|
|
516
525
|
if gone:
|
|
517
526
|
notes.append(f"gone = no commits in the {months} months before {report['meta'].get('last_date')}"
|
|
518
527
|
+ ("; 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.5
|
|
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
|
|
@@ -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)
|
|
@@ -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},
|
|
@@ -438,6 +446,19 @@ class KnowledgeIslands(unittest.TestCase):
|
|
|
438
446
|
self.assertEqual(f[0]["advice"], "Pair someone with Bob on core/ first; it is the largest at 300 lines.")
|
|
439
447
|
self.assertNotIn("tests/", f[0]["detail"])
|
|
440
448
|
|
|
449
|
+
def test_areas_no_longer_in_the_tree_are_not_islands(self):
|
|
450
|
+
own = [{"entity": "src/a.rs", "author": "Ann", "added": 30000, "deleted": 0}, # moved to crates/ years ago
|
|
451
|
+
{"entity": "grep-printer/a.rs", "author": "Ann", "added": 20000, "deleted": 0},
|
|
452
|
+
{"entity": "crates/core/a.rs", "author": "Bob", "added": 300, "deleted": 0}]
|
|
453
|
+
tree = {"files": {"crates/core/a.rs": {"code": 300, "complexity": 1}}}
|
|
454
|
+
f = findings.knowledge_islands(report(ownership=own, size=tree))
|
|
455
|
+
self.assertEqual(f[0]["advice"], "Pair someone with Bob on crates/core/ first; it is the largest at 300 lines.",
|
|
456
|
+
"with the vanished directories gone, crates/ holds everything and the map descends into it")
|
|
457
|
+
self.assertNotIn("src/", f[0]["detail"])
|
|
458
|
+
self.assertIn("100% of all lines added", f[0]["detail"], "lines in vanished directories are not in the denominator")
|
|
459
|
+
f = findings.knowledge_islands(report(ownership=own))
|
|
460
|
+
self.assertIn("src/", f[0]["detail"], "without a tree listing every area counts")
|
|
461
|
+
|
|
441
462
|
def test_vendored_trees_are_not_islands(self):
|
|
442
463
|
own = [{"entity": "vendor/github.com/x/a.go", "author": "Ann", "added": 500000, "deleted": 0},
|
|
443
464
|
{"entity": "web/node_modules/y/b.js", "author": "Ann", "added": 90000, "deleted": 0},
|
|
@@ -511,6 +532,18 @@ class KnowledgeLoss(unittest.TestCase):
|
|
|
511
532
|
self.assertIn("Areas mostly theirs: old/ (100%), docs/ (100%)", f[0]["detail"])
|
|
512
533
|
self.assertEqual(f[0]["advice"], "Pair someone on old/ first; nobody who wrote it is around to ask.")
|
|
513
534
|
|
|
535
|
+
def test_areas_no_longer_in_the_tree_are_not_named(self):
|
|
536
|
+
r = self._report(theseus_authors={"Ann": 60, "Bob": 40},
|
|
537
|
+
age=[{"entity": "flask/a.py", "age-months": 2}, {"entity": "src/x.py", "age-months": 2}],
|
|
538
|
+
ownership=[{"entity": "flask/a.py", "author": "Bob", "added": 8000, "deleted": 0}, # the old layout, all Bob's
|
|
539
|
+
{"entity": "src/x.py", "author": "Bob", "added": 300, "deleted": 0},
|
|
540
|
+
{"entity": "app/b.py", "author": "Ann", "added": 900, "deleted": 0}],
|
|
541
|
+
size={"files": {"src/x.py": {"code": 1, "complexity": 0}, "app/b.py": {"code": 1, "complexity": 0}}})
|
|
542
|
+
f = findings.knowledge_loss(r)
|
|
543
|
+
self.assertIn("Areas mostly theirs: src/ (100%).", f[0]["detail"])
|
|
544
|
+
self.assertNotIn("flask/", f[0]["detail"])
|
|
545
|
+
self.assertEqual(f[0]["advice"], "Pair someone on src/ first; nobody who wrote it is around to ask.")
|
|
546
|
+
|
|
514
547
|
def test_a_live_area_is_preferred_over_a_bigger_idle_one(self):
|
|
515
548
|
r = self._report(theseus_authors={"Ann": 60, "Bob": 40},
|
|
516
549
|
age=[{"entity": "old/a.py", "age-months": 30}, {"entity": "live/b.py", "age-months": 3}],
|
|
@@ -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
|
|
@@ -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):
|
|
@@ -636,6 +637,25 @@ class KnowledgeMap(unittest.TestCase):
|
|
|
636
637
|
r["ownership"] = []
|
|
637
638
|
self.assertIn("no ownership data", rendered(r, []))
|
|
638
639
|
|
|
640
|
+
def test_default_map_hides_areas_no_longer_in_the_tree_and_says_so(self):
|
|
641
|
+
r = sample_report() # the tree holds static/ files only; tests/t.py in the ownership rows is history
|
|
642
|
+
r["ownership"].append({"entity": "flask/app.py", "author": "Ann", "added": 4000, "deleted": 0})
|
|
643
|
+
km = _section_text(rendered(r, [], width=200), "Knowledge map")
|
|
644
|
+
self.assertIn("static/", km)
|
|
645
|
+
self.assertNotIn("flask/", km)
|
|
646
|
+
self.assertNotIn("tests/", km)
|
|
647
|
+
self.assertIn("2 historical areas hidden; --full shows them", km)
|
|
648
|
+
full = _section_text(rendered(r, [], width=200, full=True), "Knowledge map")
|
|
649
|
+
self.assertIn("flask/", full)
|
|
650
|
+
self.assertNotIn("hidden", full)
|
|
651
|
+
|
|
652
|
+
def test_map_without_a_tree_listing_hides_nothing(self):
|
|
653
|
+
r = sample_report()
|
|
654
|
+
r["size"]["files"] = {}
|
|
655
|
+
km = _section_text(rendered(r, [], width=200), "Knowledge map")
|
|
656
|
+
self.assertIn("tests/", km)
|
|
657
|
+
self.assertNotIn("historical", km)
|
|
658
|
+
|
|
639
659
|
|
|
640
660
|
class Timeline(unittest.TestCase):
|
|
641
661
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|