gitmole 0.6.3__tar.gz → 0.6.4__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.3 → gitmole-0.6.4}/PKG-INFO +1 -1
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/__init__.py +1 -1
- gitmole-0.6.4/gitmole/coupling.py +55 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/filetypes.py +18 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/findings.py +26 -12
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/render.py +50 -7
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole.egg-info/PKG-INFO +1 -1
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole.egg-info/SOURCES.txt +2 -0
- gitmole-0.6.4/tests/test_coupling.py +52 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_filetypes.py +14 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_findings.py +48 -2
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_render.py +49 -2
- {gitmole-0.6.3 → gitmole-0.6.4}/LICENSE +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/README.md +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/__main__.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/backtest.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/banner.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/blame.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/clean.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/cli.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/functions.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/hotspots.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/identity.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/knowledge.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/leaks.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/load.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/loss.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/maat.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/run.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/textfmt.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/trend.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole/watch.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole.egg-info/dependency_links.txt +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole.egg-info/entry_points.txt +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole.egg-info/requires.txt +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/gitmole.egg-info/top_level.txt +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/pyproject.toml +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/setup.cfg +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_backtest.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_banner.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_blame.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_clean.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_cli.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_functions.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_golden.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_hotspots.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_identity.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_knowledge.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_leaks.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_load.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_loss.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_maat.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_packaging.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_run.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_textfmt.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/tests/test_trend.py +0 -0
- {gitmole-0.6.3 → gitmole-0.6.4}/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.4
|
|
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
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Change-coupling pairs grouped into clusters: a directory whose files all change together (generated
|
|
2
|
+
tables, one-per-version data files) is one fact, not a page of pairs."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
|
|
8
|
+
ROOT = "(root files)"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _dir(path: str) -> str:
|
|
12
|
+
head = os.path.dirname(path)
|
|
13
|
+
return head + "/" if head else ROOT
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _components(pairs: list) -> list:
|
|
17
|
+
"""Connected groups of files among `pairs` (union-find), each as the list of its pairs."""
|
|
18
|
+
parent = {}
|
|
19
|
+
|
|
20
|
+
def find(x):
|
|
21
|
+
parent.setdefault(x, x)
|
|
22
|
+
while parent[x] != x:
|
|
23
|
+
parent[x] = parent[parent[x]]
|
|
24
|
+
x = parent[x]
|
|
25
|
+
return x
|
|
26
|
+
|
|
27
|
+
for p in pairs:
|
|
28
|
+
parent[find(p["entity"])] = find(p["coupled"])
|
|
29
|
+
groups = defaultdict(list)
|
|
30
|
+
for p in pairs:
|
|
31
|
+
groups[find(p["entity"])].append(p)
|
|
32
|
+
return list(groups.values())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def clusters(pairs: list, min_files: int = 4) -> tuple:
|
|
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 touching at least `min_files` distinct files
|
|
38
|
+
becomes one cluster with the file and pair counts, the weakest degree and the mean of the pairs'
|
|
39
|
+
average revisions. Every other pair comes back unchanged, in its original order. Two unrelated
|
|
40
|
+
pairs in one directory are not a cluster. Clusters are largest first."""
|
|
41
|
+
by_dir = defaultdict(list)
|
|
42
|
+
for p in pairs:
|
|
43
|
+
if _dir(p["entity"]) == _dir(p["coupled"]):
|
|
44
|
+
by_dir[_dir(p["entity"])].append(p)
|
|
45
|
+
groups, taken = [], set()
|
|
46
|
+
for directory, ps in by_dir.items():
|
|
47
|
+
for component in _components(ps):
|
|
48
|
+
files = {p["entity"] for p in component} | {p["coupled"] for p in component}
|
|
49
|
+
if len(files) < min_files:
|
|
50
|
+
continue
|
|
51
|
+
groups.append({"dir": directory, "files": len(files), "pairs": len(component), "degree": min(p["degree"] for p in component),
|
|
52
|
+
"average-revs": round(sum(p["average-revs"] for p in component) / len(component))})
|
|
53
|
+
taken.update(id(p) for p in component)
|
|
54
|
+
groups.sort(key=lambda g: (-g["files"], -g["degree"], g["dir"]))
|
|
55
|
+
return groups, [p for p in pairs if id(p) not in taken]
|
|
@@ -65,6 +65,24 @@ def is_doc_path(path: str) -> bool:
|
|
|
65
65
|
return bool(_DOC_PATH.search(path))
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
_SAMPLE_PATH = re.compile(r"(^|/)(examples?|samples?|fixtures?|testdata|demos?|rules)(/|$)", re.I)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_sample_path(path: str) -> bool:
|
|
72
|
+
"""Example, sample, fixture, demo and rule directories: a value there is a specimen (a language
|
|
73
|
+
sample, a scanner's own rule definitions), not a credential in use."""
|
|
74
|
+
return bool(_SAMPLE_PATH.search(path))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_VENDOR_PATH = re.compile(r"(^|/)(_?vendor|node_modules|third_?party|external)(/|$)", re.I)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_vendor_path(path: str) -> bool:
|
|
81
|
+
"""Vendored and third-party trees: somebody else's code, so its complexity and its single
|
|
82
|
+
importer are not this repository's risk."""
|
|
83
|
+
return bool(_VENDOR_PATH.search(path))
|
|
84
|
+
|
|
85
|
+
|
|
68
86
|
def key(path: str) -> str:
|
|
69
87
|
"""The lowercased extension, or the whole lowercased name when there is none."""
|
|
70
88
|
name = path.rsplit("/", 1)[-1].lower()
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import re
|
|
5
5
|
|
|
6
|
-
from . import filetypes, hotspots, knowledge, leaks, loss, textfmt, trend
|
|
6
|
+
from . import coupling, filetypes, hotspots, knowledge, leaks, loss, textfmt, trend
|
|
7
7
|
|
|
8
8
|
SEVERITIES = ["critical", "warning", "info"]
|
|
9
9
|
|
|
@@ -40,13 +40,14 @@ def _secret_statement(groups: list) -> str:
|
|
|
40
40
|
|
|
41
41
|
def secrets_found(report: dict) -> list:
|
|
42
42
|
"""Secrets grouped by value. A value anywhere in source is critical; one that only ever appears in
|
|
43
|
-
test files (fixtures, saved pages) or
|
|
44
|
-
critical gate does not trip on test data or a
|
|
45
|
-
and key blocks without key material were
|
|
43
|
+
test files (fixtures, saved pages), example or rule directories (language samples, a scanner's own
|
|
44
|
+
rules) or documentation (templates) is a warning, so a critical gate does not trip on test data or a
|
|
45
|
+
planning document. Version strings, template markers and key blocks without key material were
|
|
46
|
+
flagged as placeholders and are not a finding."""
|
|
46
47
|
groups = leaks.group(report.get("secrets") or [])
|
|
47
48
|
|
|
48
49
|
def in_source(g):
|
|
49
|
-
return any(not (filetypes.is_test_path(f) or filetypes.is_doc_path(f)) for f in g["files"])
|
|
50
|
+
return any(not (filetypes.is_test_path(f) or filetypes.is_doc_path(f) or filetypes.is_sample_path(f)) for f in g["files"])
|
|
50
51
|
source = [g for g in groups if in_source(g)]
|
|
51
52
|
aside = [g for g in groups if not in_source(g)]
|
|
52
53
|
ignore = "Add the fingerprint of any false positive from secrets.json to .betterleaksignore in the repository."
|
|
@@ -55,7 +56,7 @@ def secrets_found(report: dict) -> list:
|
|
|
55
56
|
out.append(_f("critical", f"{len(source)} secret(s) in history", _secret_statement(source),
|
|
56
57
|
f"Rotate them; deleting the file does not remove them from git. {ignore}"))
|
|
57
58
|
if aside:
|
|
58
|
-
out.append(_f("warning", f"{len(aside)} secret(s) only in test or documentation files", _secret_statement(aside),
|
|
59
|
+
out.append(_f("warning", f"{len(aside)} secret(s) only in test, example or documentation files", _secret_statement(aside),
|
|
59
60
|
f"Confirm they are fixtures or templates, not live keys. {ignore}"))
|
|
60
61
|
return out
|
|
61
62
|
|
|
@@ -93,9 +94,10 @@ def placeholder_identity(report: dict, min_share: float = 0.01) -> list:
|
|
|
93
94
|
|
|
94
95
|
|
|
95
96
|
def _source_ownership(report: dict) -> list:
|
|
96
|
-
"""Ownership rows for source files. Test files are left out of every rule that
|
|
97
|
-
step: owning the tests is not the knowledge risk
|
|
98
|
-
|
|
97
|
+
"""Ownership rows for source files. Test files and vendored trees are left out of every rule that
|
|
98
|
+
names a next step: owning the tests is not the knowledge risk, and whoever imported vendor/ did
|
|
99
|
+
not write it. The default tables leave test files out too."""
|
|
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"]))]
|
|
99
101
|
|
|
100
102
|
|
|
101
103
|
def bus_factor(report: dict, threshold: float = 0.7, min_lines: int = 200) -> list:
|
|
@@ -183,11 +185,21 @@ def tight_coupling(report: dict, min_degree: int = 80, min_revs: int = 5) -> lis
|
|
|
183
185
|
if not pairs:
|
|
184
186
|
return []
|
|
185
187
|
pairs.sort(key=lambda p: (-p["degree"], -p["average-revs"]))
|
|
188
|
+
groups, pairs = coupling.clusters(pairs)
|
|
189
|
+
when = f"together at least {min_degree}% of the time"
|
|
186
190
|
top = "; ".join(f"{p['entity']} + {p['coupled']} ({p['degree']}%)" for p in pairs[:3])
|
|
191
|
+
if groups:
|
|
192
|
+
# a directory of files that change as one is a generator or a shared layout, said once
|
|
193
|
+
named = ", ".join(f"{g['files']} files in {g['dir']}" for g in groups[:2]) + (f" and {len(groups) - 2} more directories" if len(groups) > 2 else "")
|
|
194
|
+
rest = (f", and {_plural(len(pairs), 'more pair')} {'does' if len(pairs) == 1 else 'do'}: {top}." if pairs
|
|
195
|
+
else f", {_plural(sum(g['pairs'] for g in groups), 'pair')} in all.")
|
|
196
|
+
first = groups[0]
|
|
197
|
+
return [_f("info", "Files that always change together", f"{named} change {when}{rest}",
|
|
198
|
+
f"Review {first['dir']} first: {first['files']} files change as one; a generator or a shared layout links them.")]
|
|
187
199
|
count = f"{len(pairs)} pair changes" if len(pairs) == 1 else f"{len(pairs)} pairs change"
|
|
188
200
|
first = pairs[0]
|
|
189
201
|
return [_f("info", "Files that always change together",
|
|
190
|
-
f"{count}
|
|
202
|
+
f"{count} {when}, e.g. {top}.",
|
|
191
203
|
f"Review {first['entity']} and {first['coupled']} first: a shared layout or a hidden dependency links them.")]
|
|
192
204
|
|
|
193
205
|
|
|
@@ -363,8 +375,10 @@ def _partial_functions(report: dict) -> str:
|
|
|
363
375
|
|
|
364
376
|
|
|
365
377
|
def brain_methods(report: dict, min_ccn: int = 15, min_lines: int = 100) -> list:
|
|
366
|
-
"""Functions that are both long and complex, in
|
|
367
|
-
|
|
378
|
+
"""Functions that are both long and complex, in this repository's own source files: test files and
|
|
379
|
+
vendored code are left out. A warning when one sits in a hotspot."""
|
|
380
|
+
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"]))]
|
|
368
382
|
if not big:
|
|
369
383
|
return []
|
|
370
384
|
big.sort(key=lambda f: (-f["ccn"], -f["nloc"], f["file"], f["function"], f["start"]))
|
|
@@ -13,7 +13,7 @@ from rich.panel import Panel
|
|
|
13
13
|
from rich.table import Table
|
|
14
14
|
from rich.text import Text
|
|
15
15
|
|
|
16
|
-
from . import filetypes, hotspots, identity, knowledge, leaks, loss, textfmt, trend, watch
|
|
16
|
+
from . import coupling, filetypes, hotspots, identity, knowledge, leaks, loss, textfmt, trend, watch
|
|
17
17
|
|
|
18
18
|
SEVERITY_STYLE = {"critical": "bold red", "warning": "yellow", "info": "cyan"}
|
|
19
19
|
|
|
@@ -78,8 +78,11 @@ def _more(total: int, limit) -> str:
|
|
|
78
78
|
return f"and {total - limit} more" if limit is not None and total > limit else None
|
|
79
79
|
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
HIDDEN_SUFFIX = "; --full shows them"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _hide_rows(rows: list, path_of, full, pred, noun: str, plural=None) -> tuple:
|
|
85
|
+
"""Drop rows whose path (or any of whose paths) satisfies `pred`, unless `full` is True.
|
|
83
86
|
`path_of(row)` returns a single path or a tuple of paths to check. `noun` names one hidden row
|
|
84
87
|
and `plural` names several, `noun + "s"` by default (a multi-word noun gives its own plural:
|
|
85
88
|
"function in a test file" -> "functions in test files"). Returns (rows, note), `note` being
|
|
@@ -90,14 +93,41 @@ def _hide_tests(rows: list, path_of, full, noun="test file", plural=None) -> tup
|
|
|
90
93
|
for row in rows:
|
|
91
94
|
paths = path_of(row)
|
|
92
95
|
paths = (paths,) if isinstance(paths, str) else paths
|
|
93
|
-
if any(
|
|
96
|
+
if any(pred(p) for p in paths):
|
|
94
97
|
hidden += 1
|
|
95
98
|
else:
|
|
96
99
|
kept.append(row)
|
|
97
|
-
note = f"{hidden} {noun if hidden == 1 else plural or noun + 's'} hidden
|
|
100
|
+
note = f"{hidden} {noun if hidden == 1 else plural or noun + 's'} hidden{HIDDEN_SUFFIX}" if hidden else None
|
|
98
101
|
return kept, note
|
|
99
102
|
|
|
100
103
|
|
|
104
|
+
def _hide_tests(rows: list, path_of, full, noun="test file", plural=None) -> tuple:
|
|
105
|
+
"""Test files: they change with every fix, so they are not a signal on their own."""
|
|
106
|
+
return _hide_rows(rows, path_of, full, filetypes.is_test_path, noun, plural)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _hide_vendor(rows: list, path_of, full, noun="file in vendored code", plural="files in vendored code") -> tuple:
|
|
110
|
+
"""Vendored trees: somebody else's code, not this repository's risk."""
|
|
111
|
+
return _hide_rows(rows, path_of, full, filetypes.is_vendor_path, noun, plural)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _join_hidden(*notes) -> str:
|
|
115
|
+
"""Several hidden-row notes as one caption phrase: 'A hidden; B hidden; --full shows them'."""
|
|
116
|
+
parts = [n[:-len(HIDDEN_SUFFIX)] if n.endswith(HIDDEN_SUFFIX) else n for n in notes if n]
|
|
117
|
+
return "; ".join(parts) + HIDDEN_SUFFIX if parts else None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _hide_deleted(rows: list, report: dict, full) -> tuple:
|
|
121
|
+
"""Drop hotspot rows for files no longer in the tree, unless `full` is True or there is no tree
|
|
122
|
+
listing to judge by: a deleted file's churn is history. Returns (rows, note) like _hide_tests."""
|
|
123
|
+
tree = (report.get("size") or {}).get("files") or {}
|
|
124
|
+
if full is True or not tree:
|
|
125
|
+
return rows, None
|
|
126
|
+
kept = [h for h in rows if h["code"] is not None]
|
|
127
|
+
hidden = len(rows) - len(kept)
|
|
128
|
+
return kept, (f"{hidden} deleted file{'s' if hidden != 1 else ''} hidden{HIDDEN_SUFFIX}" if hidden else None)
|
|
129
|
+
|
|
130
|
+
|
|
101
131
|
def _hide_gone(pairs: list, report: dict, full) -> tuple:
|
|
102
132
|
"""Drop coupled pairs where either file is no longer in the tree, unless `full` is True: they
|
|
103
133
|
describe a layout that no longer exists. Returns (pairs, note) like _hide_tests."""
|
|
@@ -345,6 +375,8 @@ def hotspots_section(report: dict, full: bool = True, width=None) -> dict:
|
|
|
345
375
|
fixes = {f["entity"]: f["n-fixes"] for f in report.get("fixes") or []}
|
|
346
376
|
scored = hotspots.ranked(report)
|
|
347
377
|
scored, hidden_note = _hide_tests(scored, lambda h: h["entity"], full)
|
|
378
|
+
scored, deleted_note = _hide_deleted(scored, report, full)
|
|
379
|
+
hidden_note = _join_hidden(hidden_note, deleted_note)
|
|
348
380
|
title = "Hotspots (score = revisions × lines of code)" if full is True else "Hotspots"
|
|
349
381
|
limit = _limit("Hotspots", full)
|
|
350
382
|
series = (report.get("trend") or {}).get("files") or {}
|
|
@@ -376,9 +408,18 @@ def coupling_section(report: dict, full: bool = True, width=None) -> dict:
|
|
|
376
408
|
pairs = sorted((p for p in report.get("coupling") or [] if p["average-revs"] >= 5), key=lambda p: (-p["degree"], -p["average-revs"]))
|
|
377
409
|
pairs, hidden_note = _hide_tests(pairs, lambda p: (p["entity"], p["coupled"]), full, noun="test pair")
|
|
378
410
|
pairs, gone_note = _hide_gone(pairs, report, full)
|
|
379
|
-
|
|
411
|
+
groups, cluster_note = [], None
|
|
412
|
+
if full is not True:
|
|
413
|
+
# a directory whose files all change together is one row; --full lists every pair
|
|
414
|
+
groups, pairs = coupling.clusters(pairs)
|
|
415
|
+
if groups:
|
|
416
|
+
n_pairs, n_dirs = sum(g["pairs"] for g in groups), len(groups)
|
|
417
|
+
cluster_note = (f"{n_pairs} pairs in {n_dirs} director{'y' if n_dirs == 1 else 'ies'} shown as "
|
|
418
|
+
f"{'one row' if n_dirs == 1 else 'one row each'}{HIDDEN_SUFFIX}")
|
|
419
|
+
hidden_note = _join_hidden(hidden_note, gone_note, cluster_note)
|
|
380
420
|
limit = _limit("Change coupling", full)
|
|
381
|
-
rows = [(
|
|
421
|
+
rows = [(f"{g['dir']} ({g['files']} files)", "each other", f"≥{g['degree']}%", g["average-revs"]) for g in groups]
|
|
422
|
+
rows += [(p["entity"], p["coupled"], f"{p['degree']}%", p["average-revs"]) for p in pairs[:max(limit - len(groups), 0) if limit else None]]
|
|
382
423
|
columns = [("file", PATH), ("changes with", PATH), ("degree", RIGHT), ("avg revs", RIGHT)]
|
|
383
424
|
if full is not True:
|
|
384
425
|
columns, rows = _keep(columns, rows, ["file", "changes with", "degree"])
|
|
@@ -432,6 +473,8 @@ def functions_section(report: dict, full: bool = True, width=None) -> dict:
|
|
|
432
473
|
measured = report.get("functions") or []
|
|
433
474
|
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"]))
|
|
434
475
|
funcs, hidden_note = _hide_tests(funcs, lambda f: f["file"], full, noun="function in a test file", plural="functions in test files")
|
|
476
|
+
funcs, vendor_note = _hide_vendor(funcs, lambda f: f["file"], full, noun="function in vendored code", plural="functions in vendored code")
|
|
477
|
+
hidden_note = _join_hidden(hidden_note, vendor_note)
|
|
435
478
|
limit = _limit("Complex functions", full)
|
|
436
479
|
rows = [(f["function"], f["file"], f["ccn"], f["nloc"], f["params"]) for f in funcs[:limit]]
|
|
437
480
|
columns = [("function", {"overflow": "fold"}), ("file", PATH), ("ccn", RIGHT), ("lines", RIGHT), ("params", RIGHT)]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitmole
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.4
|
|
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
|
|
@@ -8,6 +8,7 @@ gitmole/banner.py
|
|
|
8
8
|
gitmole/blame.py
|
|
9
9
|
gitmole/clean.py
|
|
10
10
|
gitmole/cli.py
|
|
11
|
+
gitmole/coupling.py
|
|
11
12
|
gitmole/filetypes.py
|
|
12
13
|
gitmole/findings.py
|
|
13
14
|
gitmole/functions.py
|
|
@@ -34,6 +35,7 @@ tests/test_banner.py
|
|
|
34
35
|
tests/test_blame.py
|
|
35
36
|
tests/test_clean.py
|
|
36
37
|
tests/test_cli.py
|
|
38
|
+
tests/test_coupling.py
|
|
37
39
|
tests/test_filetypes.py
|
|
38
40
|
tests/test_findings.py
|
|
39
41
|
tests/test_functions.py
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from gitmole import coupling
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def pair(a, b, degree=100, revs=10):
|
|
7
|
+
return {"entity": a, "coupled": b, "degree": degree, "average-revs": revs}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def clique(directory, names, degree=100, revs=10):
|
|
11
|
+
files = [f"{directory}/{n}" if directory else n for n in names]
|
|
12
|
+
return [pair(a, b, degree, revs) for i, a in enumerate(files) for b in files[i + 1:]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Clusters(unittest.TestCase):
|
|
16
|
+
def test_a_directory_of_four_or_more_files_becomes_one_cluster(self):
|
|
17
|
+
pairs = clique("rich/_unicode_data", ["u10.py", "u11.py", "u12.py", "u13.py"]) + [pair("a.py", "b.py", 90)]
|
|
18
|
+
groups, rest = coupling.clusters(pairs)
|
|
19
|
+
self.assertEqual(groups, [{"dir": "rich/_unicode_data/", "files": 4, "pairs": 6, "degree": 100, "average-revs": 10}])
|
|
20
|
+
self.assertEqual(rest, [pair("a.py", "b.py", 90)])
|
|
21
|
+
|
|
22
|
+
def test_the_cluster_degree_is_the_weakest_pair(self):
|
|
23
|
+
pairs = clique("d", ["a", "b", "c", "e"], degree=100)
|
|
24
|
+
pairs[2]["degree"] = 83
|
|
25
|
+
pairs[4]["average-revs"] = 40
|
|
26
|
+
[g], _ = coupling.clusters(pairs)
|
|
27
|
+
self.assertEqual(g["degree"], 83)
|
|
28
|
+
self.assertEqual(g["average-revs"], 15) # the mean of the pairs' averages, rounded
|
|
29
|
+
|
|
30
|
+
def test_three_files_stay_as_pairs(self):
|
|
31
|
+
pairs = clique("d", ["a", "b", "c"])
|
|
32
|
+
groups, rest = coupling.clusters(pairs)
|
|
33
|
+
self.assertEqual(groups, [])
|
|
34
|
+
self.assertEqual(rest, pairs)
|
|
35
|
+
|
|
36
|
+
def test_pairs_across_directories_never_cluster(self):
|
|
37
|
+
pairs = [pair("x/a", "y/b"), pair("x/c", "y/d"), pair("x/e", "y/f"), pair("x/g", "y/h")]
|
|
38
|
+
groups, rest = coupling.clusters(pairs)
|
|
39
|
+
self.assertEqual(groups, [])
|
|
40
|
+
self.assertEqual(len(rest), 4)
|
|
41
|
+
|
|
42
|
+
def test_root_files_cluster_under_their_own_label_and_largest_cluster_first(self):
|
|
43
|
+
pairs = clique("", ["a", "b", "c", "d"]) + clique("lib", ["p", "q", "r", "s", "t"], degree=90)
|
|
44
|
+
groups, rest = coupling.clusters(pairs)
|
|
45
|
+
self.assertEqual([g["dir"] for g in groups], ["lib/", "(root files)"])
|
|
46
|
+
self.assertEqual(rest, [])
|
|
47
|
+
|
|
48
|
+
def test_a_partial_clique_counts_the_files_it_touches(self):
|
|
49
|
+
pairs = [pair("d/a", "d/b"), pair("d/b", "d/c"), pair("d/c", "d/e")] # 4 files, 3 pairs, not every pair present
|
|
50
|
+
[g], rest = coupling.clusters(pairs)
|
|
51
|
+
self.assertEqual((g["files"], g["pairs"]), (4, 3))
|
|
52
|
+
self.assertEqual(rest, [])
|
|
@@ -97,6 +97,20 @@ class TestPaths(unittest.TestCase):
|
|
|
97
97
|
for path in ("app/settings.py", "static/index.html", "docsite/app.js", "mdx/a.py", "config.yaml"):
|
|
98
98
|
self.assertFalse(filetypes.is_doc_path(path), path)
|
|
99
99
|
|
|
100
|
+
def test_example_fixture_and_rule_directories(self):
|
|
101
|
+
for path in ("examples/language/bru.bru", "example/app.py", "samples/x.json", "sample/x.json", "fixtures/keys.pem",
|
|
102
|
+
"src/fixture/a.txt", "pkg/testdata/creds.yaml", "demo/x.py", "demos/x.py", "config/generate/rules/slack.go"):
|
|
103
|
+
self.assertTrue(filetypes.is_sample_path(path), path)
|
|
104
|
+
for path in ("app/settings.py", "examplesite/app.py", "src/rulesets/a.go", "sampler/x.py", "config/betterleaks.toml"):
|
|
105
|
+
self.assertFalse(filetypes.is_sample_path(path), path)
|
|
106
|
+
|
|
107
|
+
def test_vendored_trees(self):
|
|
108
|
+
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
|
+
"external/lib/a.cpp"):
|
|
110
|
+
self.assertTrue(filetypes.is_vendor_path(path), path)
|
|
111
|
+
for path in ("vendors.py", "src/vendoring/a.py", "node/a.js", "externals.txt", "app/main.go"):
|
|
112
|
+
self.assertFalse(filetypes.is_vendor_path(path), path)
|
|
113
|
+
|
|
100
114
|
|
|
101
115
|
if __name__ == "__main__":
|
|
102
116
|
unittest.main()
|
|
@@ -47,7 +47,7 @@ class SecretsFound(unittest.TestCase):
|
|
|
47
47
|
self.assertIn("1 distinct value in 2 places: generic-api-key in app/settings.py (c1, c2)", crit["detail"])
|
|
48
48
|
self.assertIn("Rotate", crit["advice"])
|
|
49
49
|
self.assertIn(".betterleaksignore", crit["advice"])
|
|
50
|
-
self.assertEqual(warn["title"], "2 secret(s) only in test or documentation files")
|
|
50
|
+
self.assertEqual(warn["title"], "2 secret(s) only in test, example or documentation files")
|
|
51
51
|
self.assertIn("2 distinct values in 3 places", warn["detail"])
|
|
52
52
|
self.assertIn("tests/data/a.html and 1 other file", warn["detail"])
|
|
53
53
|
self.assertIn(".betterleaksignore", warn["advice"])
|
|
@@ -56,11 +56,21 @@ class SecretsFound(unittest.TestCase):
|
|
|
56
56
|
r = report(secrets=[self.row("h3", "tests/t.py")])
|
|
57
57
|
self.assertEqual([f["severity"] for f in findings.secrets_found(r)], ["warning"])
|
|
58
58
|
|
|
59
|
+
def test_a_value_only_in_an_example_fixture_or_rules_directory_is_a_warning(self):
|
|
60
|
+
r = report(secrets=[self.row("h1", "examples/language/bru.bru", "57d82e9", rule="generic-password"),
|
|
61
|
+
self.row("h2", "config/generate/rules/slack.go", "04bdee4", rule="slack-bot-token"),
|
|
62
|
+
self.row("h3", "pkg/testdata/creds.yaml", "c3")])
|
|
63
|
+
f = findings.secrets_found(r)
|
|
64
|
+
self.assertEqual([x["severity"] for x in f], ["warning"])
|
|
65
|
+
self.assertEqual(f[0]["title"], "3 secret(s) only in test, example or documentation files")
|
|
66
|
+
r = report(secrets=[self.row("h1", "examples/app.py", "c1"), self.row("h1", "app/config.py", "c2")])
|
|
67
|
+
self.assertEqual([x["severity"] for x in findings.secrets_found(r)], ["critical"], "the same value in source is a leak")
|
|
68
|
+
|
|
59
69
|
def test_a_value_only_in_documentation_is_a_warning_that_says_template(self):
|
|
60
70
|
r = report(secrets=[self.row("h1", "docs/GA4-API-INTEGRATION.md", "e8c0508")])
|
|
61
71
|
f = findings.secrets_found(r)
|
|
62
72
|
self.assertEqual([x["severity"] for x in f], ["warning"])
|
|
63
|
-
self.assertEqual(f[0]["title"], "1 secret(s) only in test or documentation files")
|
|
73
|
+
self.assertEqual(f[0]["title"], "1 secret(s) only in test, example or documentation files")
|
|
64
74
|
self.assertIn("fixtures or templates", f[0]["advice"])
|
|
65
75
|
r = report(secrets=[self.row("h1", "docs/GA4-API-INTEGRATION.md", "e8c0508"), self.row("h1", "app/config.py", "c2")])
|
|
66
76
|
self.assertEqual([x["severity"] for x in findings.secrets_found(r)], ["critical"], "the same value in source is a leak")
|
|
@@ -135,6 +145,13 @@ class BusFactor(unittest.TestCase):
|
|
|
135
145
|
self.assertEqual(findings.bus_factor(r)[0]["advice"], "Pair someone with Ann on core/ first; it is 95% theirs since 2025-01-01.",
|
|
136
146
|
"ownership is windowed while the headline share is not")
|
|
137
147
|
|
|
148
|
+
def test_vendored_trees_are_not_named_in_the_advice(self):
|
|
149
|
+
own = [{"entity": "vendor/github.com/x/a.go", "author": "Ann", "added": 500000, "deleted": 0},
|
|
150
|
+
{"entity": "core/a.py", "author": "Ann", "added": 900, "deleted": 0},
|
|
151
|
+
{"entity": "core/b.py", "author": "Bob", "added": 50, "deleted": 0}]
|
|
152
|
+
f = findings.bus_factor(report(theseus_authors={"Ann": 79, "Bob": 21}, ownership=own))
|
|
153
|
+
self.assertEqual(f[0]["advice"], "Pair someone with Ann on core/ first; it is 95% theirs.")
|
|
154
|
+
|
|
138
155
|
def test_nothing_when_spread(self):
|
|
139
156
|
self.assertEqual(findings.bus_factor(report()), [])
|
|
140
157
|
|
|
@@ -234,6 +251,17 @@ class TightCoupling(unittest.TestCase):
|
|
|
234
251
|
f = findings.tight_coupling(report(coupling=pairs))
|
|
235
252
|
self.assertIn("1 pair changes together", f[0]["detail"])
|
|
236
253
|
|
|
254
|
+
def test_a_directory_of_files_that_change_as_one_is_one_cluster(self):
|
|
255
|
+
files = [f"rich/_unicode_data/unicode{n}.py" for n in ("10", "11", "12", "13")]
|
|
256
|
+
pairs = [{"entity": a, "coupled": b, "degree": 100, "average-revs": 5} for i, a in enumerate(files) for b in files[i + 1:]]
|
|
257
|
+
pairs.append({"entity": "rich/a.py", "coupled": "rich/b.py", "degree": 90, "average-revs": 8})
|
|
258
|
+
f = findings.tight_coupling(report(coupling=pairs))
|
|
259
|
+
self.assertIn("4 files in rich/_unicode_data/ change together at least 80% of the time, and 1 more pair does: rich/a.py + rich/b.py (90%).", f[0]["detail"])
|
|
260
|
+
self.assertNotIn("unicode10", f[0]["detail"])
|
|
261
|
+
self.assertTrue(f[0]["detail"].endswith("Review rich/_unicode_data/ first: 4 files change as one; a generator or a shared layout links them."), f[0]["detail"])
|
|
262
|
+
f = findings.tight_coupling(report(coupling=pairs[:-1]))
|
|
263
|
+
self.assertIn("4 files in rich/_unicode_data/ change together at least 80% of the time, 6 pairs in all.", f[0]["detail"])
|
|
264
|
+
|
|
237
265
|
def test_nothing_when_no_tight_pairs(self):
|
|
238
266
|
self.assertEqual(findings.tight_coupling(report()), [])
|
|
239
267
|
|
|
@@ -338,6 +366,14 @@ class BrainMethods(unittest.TestCase):
|
|
|
338
366
|
self.assertNotIn("test_all", f[0]["detail"])
|
|
339
367
|
self.assertEqual(findings.brain_methods(report(functions=fns[:1])), [])
|
|
340
368
|
|
|
369
|
+
def test_vendored_functions_are_not_brain_methods(self):
|
|
370
|
+
fns = [{"file": "vendor/github.com/google/jsonschema-go/jsonschema/validate.go", "function": "validate", "ccn": 179, "nloc": 424, "params": 3, "start": 1, "end": 424},
|
|
371
|
+
{"file": "processor/workers.go", "function": "countLoopGeneric", "ccn": 56, "nloc": 164, "params": 8, "start": 1, "end": 164}]
|
|
372
|
+
f = findings.brain_methods(report(functions=fns))
|
|
373
|
+
self.assertEqual(f[0]["advice"], "Split countLoopGeneric in processor/workers.go first, before the next change lands there.")
|
|
374
|
+
self.assertNotIn("vendor/", f[0]["detail"])
|
|
375
|
+
self.assertEqual(findings.brain_methods(report(functions=fns[:1])), [])
|
|
376
|
+
|
|
341
377
|
def test_a_partial_run_says_there_may_be_more(self):
|
|
342
378
|
r = report(functions=self.FUNCS)
|
|
343
379
|
self.assertNotIn("part way", findings.brain_methods(r)[0]["detail"])
|
|
@@ -402,6 +438,16 @@ class KnowledgeIslands(unittest.TestCase):
|
|
|
402
438
|
self.assertEqual(f[0]["advice"], "Pair someone with Bob on core/ first; it is the largest at 300 lines.")
|
|
403
439
|
self.assertNotIn("tests/", f[0]["detail"])
|
|
404
440
|
|
|
441
|
+
def test_vendored_trees_are_not_islands(self):
|
|
442
|
+
own = [{"entity": "vendor/github.com/x/a.go", "author": "Ann", "added": 500000, "deleted": 0},
|
|
443
|
+
{"entity": "web/node_modules/y/b.js", "author": "Ann", "added": 90000, "deleted": 0},
|
|
444
|
+
{"entity": "core/a.py", "author": "Bob", "added": 300, "deleted": 0}]
|
|
445
|
+
f = findings.knowledge_islands(report(ownership=own))
|
|
446
|
+
self.assertEqual(f[0]["advice"], "Pair someone with Bob on core/ first; it is the largest at 300 lines.")
|
|
447
|
+
self.assertNotIn("vendor/", f[0]["detail"])
|
|
448
|
+
self.assertNotIn("web/", f[0]["detail"])
|
|
449
|
+
self.assertIn("100% of all lines added", f[0]["detail"], "vendored lines are not in the denominator either")
|
|
450
|
+
|
|
405
451
|
def test_nothing_when_shared(self):
|
|
406
452
|
self.assertEqual(findings.knowledge_islands(report(ownership=self.OWN[2:])), [])
|
|
407
453
|
self.assertEqual(findings.knowledge_islands(report()), [])
|
|
@@ -244,6 +244,7 @@ class Report(unittest.TestCase):
|
|
|
244
244
|
self.assertEqual(caption(r, True), "trend sampled for the top 10 hotspots")
|
|
245
245
|
self.assertIsNone(caption(r, False), "the tight report keeps its captions short")
|
|
246
246
|
r["revisions"] = [{"entity": f"f{i}.py", "n-revs": 100 - i} for i in range(60)]
|
|
247
|
+
r["size"]["files"].update({f"f{i}.py": {"code": 10, "complexity": 0} for i in range(60)}) # in the tree, so not hidden as deleted
|
|
247
248
|
self.assertEqual(caption(r, "markdown"), "and 10 more; trend sampled for the top 10 hotspots")
|
|
248
249
|
|
|
249
250
|
def test_watch_list_caption_reports_the_backtest_or_why_not(self):
|
|
@@ -289,10 +290,10 @@ class Report(unittest.TestCase):
|
|
|
289
290
|
def test_default_coupling_hides_test_pairs_and_says_so(self):
|
|
290
291
|
r = sample_report()
|
|
291
292
|
r["coupling"].append({"entity": "static/tax.html", "coupled": "tests/test_tax.py", "degree": 100, "average-revs": 11})
|
|
292
|
-
text = rendered(r, [])
|
|
293
|
+
text = rendered(r, [], width=200)
|
|
293
294
|
coupling = text[text.index("Change coupling"):]
|
|
294
295
|
self.assertNotIn("tests/test_tax.py", coupling)
|
|
295
|
-
self.assertIn("1 test pair hidden; --full shows them", coupling)
|
|
296
|
+
self.assertIn("1 test pair hidden; 1 historical pair hidden; --full shows them", coupling, "one suffix for every hidden count")
|
|
296
297
|
full_text = rendered(r, [], full=True)
|
|
297
298
|
self.assertIn("tests/test_tax.py", full_text[full_text.index("Change coupling"):])
|
|
298
299
|
|
|
@@ -306,6 +307,16 @@ class Report(unittest.TestCase):
|
|
|
306
307
|
full_text = rendered(r, [], full=True)
|
|
307
308
|
self.assertIn("tests/test_a.py", full_text[full_text.index("Complex functions"):])
|
|
308
309
|
|
|
310
|
+
def test_default_complex_functions_hide_vendored_code_and_say_so(self):
|
|
311
|
+
r = sample_report()
|
|
312
|
+
r["functions"].append({"file": "vendor/github.com/x/y.go", "function": "validate", "ccn": 179, "nloc": 424, "params": 3, "start": 1, "end": 424})
|
|
313
|
+
r["functions"].append({"file": "tests/test_a.py", "function": "test_thing", "ccn": 40, "nloc": 50, "params": 0, "start": 1, "end": 50})
|
|
314
|
+
fn = _section_text(rendered(r, [], width=200), "Complex functions")
|
|
315
|
+
self.assertNotIn("vendor/", fn)
|
|
316
|
+
self.assertIn("1 function in a test file hidden; 1 function in vendored code hidden; --full shows them", fn)
|
|
317
|
+
full = _section_text(rendered(r, [], width=200, full=True), "Complex functions")
|
|
318
|
+
self.assertIn("vendor/github.com/x/y.go", full)
|
|
319
|
+
|
|
309
320
|
def test_hotspots_with_only_test_files_say_what_was_hidden(self):
|
|
310
321
|
r = sample_report()
|
|
311
322
|
r["revisions"] = [{"entity": "tests/test_a.py", "n-revs": 200}]
|
|
@@ -325,6 +336,41 @@ class Report(unittest.TestCase):
|
|
|
325
336
|
self.assertIn("static/tax.html", full)
|
|
326
337
|
self.assertNotIn("hidden", full)
|
|
327
338
|
|
|
339
|
+
def test_default_hotspots_hide_deleted_files_and_say_so(self):
|
|
340
|
+
r = sample_report() # the tree holds static/index.html and static/apps-metadata.json only
|
|
341
|
+
r["revisions"].append({"entity": "src/sizes/old.go", "n-revs": 40})
|
|
342
|
+
hot = _section_text(rendered(r, [], width=200), "◆ Hotspots")
|
|
343
|
+
self.assertNotIn("src/sizes/old.go", hot)
|
|
344
|
+
self.assertIn("1 deleted file hidden; --full shows them", hot)
|
|
345
|
+
full = _section_text(rendered(r, [], width=200, full=True), "◆ Hotspots")
|
|
346
|
+
self.assertIn("src/sizes/old.go", full)
|
|
347
|
+
self.assertNotIn("hidden", full)
|
|
348
|
+
|
|
349
|
+
def test_hotspots_without_a_tree_listing_hide_nothing(self):
|
|
350
|
+
r = sample_report()
|
|
351
|
+
r["size"]["files"] = {}
|
|
352
|
+
hot = _section_text(rendered(r, [], width=200), "◆ Hotspots")
|
|
353
|
+
self.assertIn("static/index.html", hot)
|
|
354
|
+
self.assertNotIn("deleted", hot)
|
|
355
|
+
|
|
356
|
+
def test_default_coupling_collapses_a_directory_that_changes_as_one(self):
|
|
357
|
+
r = sample_report()
|
|
358
|
+
files = [f"rich/_unicode_data/unicode{n}.py" for n in ("10", "11", "12", "13")]
|
|
359
|
+
for f in files:
|
|
360
|
+
r["size"]["files"][f] = {"code": 600, "complexity": 0}
|
|
361
|
+
r["coupling"] = [{"entity": a, "coupled": b, "degree": 100, "average-revs": 5} for i, a in enumerate(files) for b in files[i + 1:]]
|
|
362
|
+
r["coupling"].append({"entity": "static/index.html", "coupled": "static/apps-metadata.json", "degree": 90, "average-revs": 11})
|
|
363
|
+
coupling = _section_text(rendered(r, [], width=200), "Change coupling")
|
|
364
|
+
self.assertIn("rich/_unicode_data/ (4 files)", coupling)
|
|
365
|
+
self.assertIn("each other", coupling)
|
|
366
|
+
self.assertIn("≥100%", coupling)
|
|
367
|
+
self.assertNotIn("unicode10", coupling)
|
|
368
|
+
self.assertIn("static/index.html", coupling)
|
|
369
|
+
self.assertIn("6 pairs in 1 directory shown as one row; --full shows them", coupling)
|
|
370
|
+
full = _section_text(rendered(r, [], width=200, full=True), "Change coupling")
|
|
371
|
+
self.assertIn("unicode10", full)
|
|
372
|
+
self.assertNotIn("each other", full)
|
|
373
|
+
|
|
328
374
|
def test_coupling_with_only_test_pairs_says_what_was_hidden(self):
|
|
329
375
|
r = sample_report()
|
|
330
376
|
r["coupling"] = [{"entity": "static/tax.html", "coupled": "tests/test_tax.py", "degree": 100, "average-revs": 11}]
|
|
@@ -866,6 +912,7 @@ class Markdown(unittest.TestCase):
|
|
|
866
912
|
r = sample_report()
|
|
867
913
|
r["coupling"] = []
|
|
868
914
|
r["revisions"] = [{"entity": "weird|name.py", "n-revs": 3}]
|
|
915
|
+
r["size"]["files"]["weird|name.py"] = {"code": 5, "complexity": 0} # in the tree, so not hidden as deleted
|
|
869
916
|
md = render.markdown(r, [])
|
|
870
917
|
self.assertIn("weird\\|name.py", md)
|
|
871
918
|
self.assertIn("_no pairs with 5+ shared revisions_", md)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|