gitmole 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {gitmole-0.5.0 → gitmole-0.6.0}/PKG-INFO +1 -1
  2. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/__init__.py +1 -1
  3. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/filetypes.py +9 -0
  4. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/findings.py +41 -15
  5. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/leaks.py +20 -7
  6. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/render.py +13 -0
  7. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole.egg-info/PKG-INFO +1 -1
  8. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_filetypes.py +6 -0
  9. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_findings.py +42 -1
  10. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_leaks.py +25 -1
  11. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_render.py +12 -0
  12. {gitmole-0.5.0 → gitmole-0.6.0}/LICENSE +0 -0
  13. {gitmole-0.5.0 → gitmole-0.6.0}/README.md +0 -0
  14. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/__main__.py +0 -0
  15. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/backtest.py +0 -0
  16. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/banner.py +0 -0
  17. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/blame.py +0 -0
  18. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/cli.py +0 -0
  19. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/functions.py +0 -0
  20. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/hotspots.py +0 -0
  21. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/identity.py +0 -0
  22. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/knowledge.py +0 -0
  23. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/load.py +0 -0
  24. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/loss.py +0 -0
  25. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/maat.py +0 -0
  26. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/run.py +0 -0
  27. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/textfmt.py +0 -0
  28. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/trend.py +0 -0
  29. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole/watch.py +0 -0
  30. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole.egg-info/SOURCES.txt +0 -0
  31. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole.egg-info/dependency_links.txt +0 -0
  32. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole.egg-info/entry_points.txt +0 -0
  33. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole.egg-info/requires.txt +0 -0
  34. {gitmole-0.5.0 → gitmole-0.6.0}/gitmole.egg-info/top_level.txt +0 -0
  35. {gitmole-0.5.0 → gitmole-0.6.0}/pyproject.toml +0 -0
  36. {gitmole-0.5.0 → gitmole-0.6.0}/setup.cfg +0 -0
  37. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_backtest.py +0 -0
  38. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_banner.py +0 -0
  39. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_blame.py +0 -0
  40. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_cli.py +0 -0
  41. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_functions.py +0 -0
  42. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_golden.py +0 -0
  43. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_hotspots.py +0 -0
  44. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_identity.py +0 -0
  45. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_knowledge.py +0 -0
  46. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_load.py +0 -0
  47. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_loss.py +0 -0
  48. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_maat.py +0 -0
  49. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_packaging.py +0 -0
  50. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_run.py +0 -0
  51. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_textfmt.py +0 -0
  52. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_trend.py +0 -0
  53. {gitmole-0.5.0 → gitmole-0.6.0}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitmole
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Offline git repository analysis with a terminal report: hotspots, coupling, ownership, code age, secrets, repo health.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/antvinni/gitmole
@@ -1,3 +1,3 @@
1
1
  """gitmole: offline git repository analysis with a terminal report."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.6.0"
@@ -56,6 +56,15 @@ def is_test_path(path: str) -> bool:
56
56
  return bool(_TEST_PATH.search(path))
57
57
 
58
58
 
59
+ _DOC_PATH = re.compile(r"(^|/)docs?(/|$)|\.(md|markdown|rst|txt|adoc)$", re.I)
60
+
61
+
62
+ def is_doc_path(path: str) -> bool:
63
+ """Documentation: prose formats anywhere, or anything under docs/. A key in a planning document
64
+ is far more often a template than a leak."""
65
+ return bool(_DOC_PATH.search(path))
66
+
67
+
59
68
  def key(path: str) -> str:
60
69
  """The lowercased extension, or the whole lowercased name when there is none."""
61
70
  name = path.rsplit("/", 1)[-1].lower()
@@ -40,19 +40,23 @@ 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) is a warning, so a critical gate does not trip on test data.
44
- Version strings and shortened tokens were flagged as placeholders and are not a finding."""
43
+ test files (fixtures, saved pages) or documentation (templates, samples) is a warning, so a
44
+ critical gate does not trip on test data or a planning document. Version strings, template markers
45
+ and key blocks without key material were flagged as placeholders and are not a finding."""
45
46
  groups = leaks.group(report.get("secrets") or [])
46
- source = [g for g in groups if not g["test"]]
47
- tests = [g for g in groups if g["test"]]
47
+
48
+ 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
+ source = [g for g in groups if in_source(g)]
51
+ aside = [g for g in groups if not in_source(g)]
48
52
  ignore = "Add the fingerprint of any false positive from secrets.json to .betterleaksignore in the repository."
49
53
  out = []
50
54
  if source:
51
55
  out.append(_f("critical", f"{len(source)} secret(s) in history", _secret_statement(source),
52
56
  f"Rotate them; deleting the file does not remove them from git. {ignore}"))
53
- if tests:
54
- out.append(_f("warning", f"{len(tests)} secret(s) only in test files", _secret_statement(tests),
55
- f"Confirm they are fixtures, not live keys. {ignore}"))
57
+ if aside:
58
+ out.append(_f("warning", f"{len(aside)} secret(s) only in test or documentation files", _secret_statement(aside),
59
+ f"Confirm they are fixtures or templates, not live keys. {ignore}"))
56
60
  return out
57
61
 
58
62
 
@@ -67,15 +71,24 @@ def _all_identities(report: dict):
67
71
  def placeholder_identity(report: dict, min_share: float = 0.01) -> list:
68
72
  """A placeholder name or mailbox with a real share of the commits. One stray commit in thousands
69
73
  is not worth the panel space."""
70
- total = sum(i["commits"] for i in report["meta"].get("identities") or [])
74
+ identities = report["meta"].get("identities") or []
75
+ total = sum(i["commits"] for i in identities)
76
+
77
+ def is_placeholder(i):
78
+ return i["name"].strip().lower() in PLACEHOLDER_NAMES or bool(PLACEHOLDER_EMAIL.search(i["email"].lower()))
79
+ real = max((i for i in identities if not is_placeholder(i)), key=lambda i: i["commits"], default=None)
71
80
  out = []
72
81
  for i in _all_identities(report):
73
82
  if total and i["commits"] / total < min_share:
74
83
  continue
75
- if i["name"].strip().lower() in PLACEHOLDER_NAMES or PLACEHOLDER_EMAIL.search(i["email"].lower()):
84
+ if is_placeholder(i):
85
+ # the busiest real identity is the likely owner; the line is offered, never applied
86
+ advice = (f"Set user.name and user.email. If those commits are {real['name']}'s, add to .mailmap: "
87
+ f"{real['name']} <{real['email']}> {i['name']} <{i['email']}>; the people, bus factor and "
88
+ f"knowledge findings then describe one person." if real
89
+ else "Set user.name and user.email; consider a .mailmap for history.")
76
90
  out.append(_f("warning", "Unconfigured git identity",
77
- f"\"{i['name']} <{i['email']}>\" made {i['commits']} commits ({_pct(i['commits'], total)}).",
78
- "Set user.name and user.email; consider a .mailmap for history."))
91
+ f"\"{i['name']} <{i['email']}>\" made {i['commits']} commits ({_pct(i['commits'], total)}).", advice))
79
92
  return out
80
93
 
81
94
 
@@ -130,12 +143,22 @@ def _sizer_advice(row: dict) -> str:
130
143
  return "Consider a shallow clone for CI; the history is the cost."
131
144
 
132
145
 
146
+ def _tree(report: dict) -> dict:
147
+ """The files at HEAD, from scc, or {} when the run has no size listing to judge by."""
148
+ return (report.get("size") or {}).get("files") or {}
149
+
150
+
133
151
  def sizer_concerns(report: dict) -> list:
152
+ tree = _tree(report)
134
153
  out = []
135
154
  for row in report.get("sizer") or []:
136
155
  sev = "warning" if row["concern"] >= 2 else "info"
137
156
  where = f" at {row['ref']}" if row.get("ref") else ""
138
- out.append(_f(sev, "Repo health", f"{row['name']} is {row['value']}{where}. git-sizer level of concern {row['concern']}.", _sizer_advice(row)))
157
+ advice = _sizer_advice(row)
158
+ if row.get("ref") and tree and row["name"].startswith("Blobs: ") and row["ref"] not in tree:
159
+ where += ", no longer in the tree" # deleting it did not shrink the clone
160
+ advice = "It is already gone from the tree; a history rewrite is only worth it for clone size."
161
+ out.append(_f(sev, "Repo health", f"{row['name']} is {row['value']}{where}. git-sizer level of concern {row['concern']}.", advice))
139
162
  return out
140
163
 
141
164
 
@@ -151,9 +174,12 @@ def hotspot_dominance(report: dict, ratio: float = 2.0, minimum: int = 20) -> li
151
174
 
152
175
 
153
176
  def tight_coupling(report: dict, min_degree: int = 80, min_revs: int = 5) -> list:
154
- """A file and its test are expected to change together, so pairs with a test file on either side are left out."""
177
+ """A file and its test are expected to change together, so pairs with a test file on either side are
178
+ left out; so are pairs where either file is no longer in the tree, which are history, not a dependency."""
179
+ tree = _tree(report)
155
180
  pairs = [p for p in report.get("coupling") or [] if p["degree"] >= min_degree and p["average-revs"] >= min_revs
156
- and not (filetypes.is_test_path(p["entity"]) or filetypes.is_test_path(p["coupled"]))]
181
+ and not (filetypes.is_test_path(p["entity"]) or filetypes.is_test_path(p["coupled"]))
182
+ and not (tree and (p["entity"] not in tree or p["coupled"] not in tree))]
157
183
  if not pairs:
158
184
  return []
159
185
  pairs.sort(key=lambda p: (-p["degree"], -p["average-revs"]))
@@ -169,7 +195,7 @@ def stale_files(report: dict, months: int = 12, share: float = 0.3) -> list:
169
195
  """Files still in the tree that nobody has touched. The age table covers every path in the
170
196
  history, so paths that were deleted are left out here; they are not dead code, they are gone."""
171
197
  age = report.get("age") or []
172
- tree = (report.get("size") or {}).get("files") or {}
198
+ tree = _tree(report)
173
199
  if tree:
174
200
  age = [a for a in age if a["entity"] in tree]
175
201
  if not age:
@@ -31,9 +31,14 @@ ARGV = ["betterleaks", "git", "--no-banner", "--report-format", "json", "--repor
31
31
  # the value, the text around it, and the commit message, which can quote it; Attributes repeats the message
32
32
  RAW_FIELDS = ("Secret", "Match", "Line", "Message", "Attributes")
33
33
 
34
- # A version string (5.0.0-1667386184.dfbbb54) and a token shortened with an ellipsis are the only shapes
35
- # skipped. Nothing is skipped by prefix: a public and a private key of the same service often share one.
34
+ # Shapes that cannot be a live secret: a version string (5.0.0-1667386184.dfbbb54), a token shortened
35
+ # with an ellipsis, a whole-value template marker (your-project-id, <your-token>, XXXX-XXXX, changeme),
36
+ # and a key block whose body holds no key material. Every rule is about the whole value; nothing is
37
+ # skipped by prefix, since a public and a private key of the same service often share one.
36
38
  _VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
39
+ _MARKER = re.compile(r"^(<[^<>]+>|x+|(?:x{2,}[-_ ]?)+|your[-_][\w-]+|change[-_]?me|replace[-_]?me)$", re.I)
40
+ _KEY_BLOCK = re.compile(r"-----BEGIN [A-Z ]*KEY-----(.*?)-----END [A-Z ]*KEY-----", re.S)
41
+ _KEY_MATERIAL = 64 # a real body is hundreds of base64 characters; a template has dots or a few x's
37
42
 
38
43
 
39
44
  def new_key() -> bytes:
@@ -48,8 +53,14 @@ def digest(value: str, key: bytes) -> str:
48
53
 
49
54
 
50
55
  def is_placeholder(value: str) -> bool:
51
- value = value or ""
52
- return bool(_VERSION.match(value)) or value.endswith("...") or value.endswith("…")
56
+ value = (value or "").strip()
57
+ if _VERSION.match(value) or value.endswith("...") or value.endswith("…") or _MARKER.match(value):
58
+ return True
59
+ m = _KEY_BLOCK.search(value)
60
+ if m:
61
+ body = re.sub(r"\s|\.|…|\\n", "", m.group(1)) # literal \n sequences appear in JSON samples
62
+ return len(body) < _KEY_MATERIAL
63
+ return False
53
64
 
54
65
 
55
66
  def sanitise(rows: list) -> list:
@@ -66,15 +77,16 @@ def sanitise(rows: list) -> list:
66
77
 
67
78
  def group(rows: list) -> list:
68
79
  """One entry per distinct secret value (placeholders left out): its rule, the files and commits it
69
- appears in, the number of distinct places (commit, file, line), and whether every place is a test
70
- file. Values that appear in source come first, then the most widespread."""
80
+ appears in, the number of distinct places (commit, file, line), whether every place is a test
81
+ file, and whether every place is a documentation file. Values that appear in source come first,
82
+ then the most widespread."""
71
83
  groups, order = {}, []
72
84
  for i, r in enumerate(rows):
73
85
  if r.get("placeholder"):
74
86
  continue
75
87
  key = r.get("value") or ("row", i)
76
88
  if key not in groups:
77
- groups[key] = {"value": r.get("value"), "rule": r["rule"], "files": [], "commits": [], "_places": set(), "test": True}
89
+ groups[key] = {"value": r.get("value"), "rule": r["rule"], "files": [], "commits": [], "_places": set(), "test": True, "docs": True}
78
90
  order.append(key)
79
91
  g = groups[key]
80
92
  if r["file"] not in g["files"]:
@@ -83,6 +95,7 @@ def group(rows: list) -> list:
83
95
  g["commits"].append(r["commit"])
84
96
  g["_places"].add((r["commit"], r["file"], r.get("line")))
85
97
  g["test"] = g["test"] and filetypes.is_test_path(r["file"])
98
+ g["docs"] = g["docs"] and filetypes.is_doc_path(r["file"])
86
99
  out = []
87
100
  for key in order:
88
101
  g = groups[key]
@@ -98,6 +98,17 @@ def _hide_tests(rows: list, path_of, full, noun="test file", plural=None) -> tup
98
98
  return kept, note
99
99
 
100
100
 
101
+ def _hide_gone(pairs: list, report: dict, full) -> tuple:
102
+ """Drop coupled pairs where either file is no longer in the tree, unless `full` is True: they
103
+ describe a layout that no longer exists. Returns (pairs, note) like _hide_tests."""
104
+ tree = (report.get("size") or {}).get("files") or {}
105
+ if full is True or not tree:
106
+ return pairs, None
107
+ kept = [p for p in pairs if p["entity"] in tree and p["coupled"] in tree]
108
+ hidden = len(pairs) - len(kept)
109
+ return kept, (f"{hidden} historical pair{'s' if hidden != 1 else ''} hidden; --full shows them" if hidden else None)
110
+
111
+
101
112
  def _empty_note(base, hidden_note, source_base=None) -> str:
102
113
  """The note that replaces a table with no rows left. When test rows were hidden the note has to
103
114
  carry the count, since the caption goes with the table, and what is left is the source rows."""
@@ -364,6 +375,8 @@ def hotspots_section(report: dict, full: bool = True, width=None) -> dict:
364
375
  def coupling_section(report: dict, full: bool = True, width=None) -> dict:
365
376
  pairs = sorted((p for p in report.get("coupling") or [] if p["average-revs"] >= 5), key=lambda p: (-p["degree"], -p["average-revs"]))
366
377
  pairs, hidden_note = _hide_tests(pairs, lambda p: (p["entity"], p["coupled"]), full, noun="test pair")
378
+ pairs, gone_note = _hide_gone(pairs, report, full)
379
+ hidden_note = "; ".join(n for n in (hidden_note, gone_note) if n) or None
367
380
  limit = _limit("Change coupling", full)
368
381
  rows = [(p["entity"], p["coupled"], f"{p['degree']}%", p["average-revs"]) for p in pairs[:limit]]
369
382
  columns = [("file", PATH), ("changes with", PATH), ("degree", RIGHT), ("avg revs", RIGHT)]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitmole
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Offline git repository analysis with a terminal report: hotspots, coupling, ownership, code age, secrets, repo health.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/antvinni/gitmole
@@ -91,6 +91,12 @@ class TestPaths(unittest.TestCase):
91
91
  for path in ("src/contest.py", "gitmole/render.py", "attest/x.py", "latest.md"):
92
92
  self.assertFalse(filetypes.is_test_path(path), path)
93
93
 
94
+ def test_documentation_files_and_directories(self):
95
+ for path in ("README.md", "docs/GA4-API-INTEGRATION.md", "doc/guide.rst", "NOTES.txt", "a/b/CHANGELOG.markdown", "docs/conf.py", "x.adoc"):
96
+ self.assertTrue(filetypes.is_doc_path(path), path)
97
+ for path in ("app/settings.py", "static/index.html", "docsite/app.js", "mdx/a.py", "config.yaml"):
98
+ self.assertFalse(filetypes.is_doc_path(path), path)
99
+
94
100
 
95
101
  if __name__ == "__main__":
96
102
  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 files")
50
+ self.assertEqual(warn["title"], "2 secret(s) only in test 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,6 +56,15 @@ 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_documentation_is_a_warning_that_says_template(self):
60
+ r = report(secrets=[self.row("h1", "docs/GA4-API-INTEGRATION.md", "e8c0508")])
61
+ f = findings.secrets_found(r)
62
+ self.assertEqual([x["severity"] for x in f], ["warning"])
63
+ self.assertEqual(f[0]["title"], "1 secret(s) only in test or documentation files")
64
+ self.assertIn("fixtures or templates", f[0]["advice"])
65
+ r = report(secrets=[self.row("h1", "docs/GA4-API-INTEGRATION.md", "e8c0508"), self.row("h1", "app/config.py", "c2")])
66
+ self.assertEqual([x["severity"] for x in findings.secrets_found(r)], ["critical"], "the same value in source is a leak")
67
+
59
68
  def test_placeholder_shapes_are_not_a_finding(self):
60
69
  r = report(secrets=[self.row("h4", "web/package.json", placeholder=True)])
61
70
  self.assertEqual(findings.secrets_found(r), [])
@@ -75,6 +84,15 @@ class PlaceholderIdentity(unittest.TestCase):
75
84
  f = findings.placeholder_identity(r)
76
85
  self.assertEqual(f[0]["severity"], "warning")
77
86
  self.assertIn("64%", f[0]["detail"])
87
+ self.assertIn("Bob <bob@x.com> Your Name <you@example.com>", f[0]["advice"], "the .mailmap line, with the top real identity as the likely owner")
88
+ self.assertIn(".mailmap", f[0]["advice"])
89
+
90
+ def test_advice_without_a_real_identity_to_map_to(self):
91
+ r = report()
92
+ r["meta"]["identities"] = [{"name": "Your Name", "email": "you@example.com", "commits": 64}]
93
+ f = findings.placeholder_identity(r)
94
+ self.assertIn("Set user.name and user.email", f[0]["advice"])
95
+ self.assertNotIn("<you@example.com>", f[0]["advice"], "no real identity to suggest, so no invented line")
78
96
 
79
97
  def test_nothing_for_real_identities(self):
80
98
  self.assertEqual(findings.placeholder_identity(report()), [])
@@ -133,6 +151,19 @@ class SizerConcerns(unittest.TestCase):
133
151
  self.assertEqual(f[0]["advice"], "Move large files to Git LFS or rewrite them out of history.")
134
152
  self.assertEqual(f[1]["advice"], "Split the widest directory into subdirectories; a directory that wide slows every checkout and diff.")
135
153
 
154
+ def test_a_big_blob_that_left_the_tree_says_so(self):
155
+ r = report(sizer=[{"name": "Blobs: Maximum size", "value": "21.3 MiB", "concern": 2, "ref": "static/old.mp4"}],
156
+ size={"files": {"static/coming-soon.mp4": {"code": 0, "complexity": 0}}})
157
+ f = findings.sizer_concerns(r)[0]
158
+ self.assertIn("static/old.mp4, no longer in the tree", f["detail"])
159
+ self.assertIn("a history rewrite is only worth it for clone size", f["advice"])
160
+ r["size"]["files"]["static/old.mp4"] = {"code": 0, "complexity": 0}
161
+ f = findings.sizer_concerns(r)[0]
162
+ self.assertNotIn("no longer", f["detail"])
163
+ self.assertEqual(f["advice"], "Move large files to Git LFS or rewrite them out of history.")
164
+ r["size"] = {}
165
+ self.assertNotIn("no longer", findings.sizer_concerns(r)[0]["detail"], "without a tree listing nothing is claimed")
166
+
136
167
  def test_advice_per_kind_of_concern(self):
137
168
  # keyed on the loader's "section: metric" names, which are the only ones that occur
138
169
  def advice(name, ref=""):
@@ -188,6 +219,16 @@ class TightCoupling(unittest.TestCase):
188
219
  {"entity": "src/a.js", "coupled": "src/a.test.js", "degree": 100, "average-revs": 9}]
189
220
  self.assertEqual(findings.tight_coupling(report(coupling=pairs)), [])
190
221
 
222
+ def test_pairs_of_files_no_longer_in_the_tree_are_history(self):
223
+ pairs = [{"entity": "static/financial-reporting.html", "coupled": "static/internal-audit.html", "degree": 100, "average-revs": 10},
224
+ {"entity": "sections/a.html", "coupled": "sections/b.html", "degree": 90, "average-revs": 8}]
225
+ tree = {"files": {"sections/a.html": {"code": 1, "complexity": 0}, "sections/b.html": {"code": 1, "complexity": 0}}}
226
+ f = findings.tight_coupling(report(coupling=pairs, size=tree))
227
+ self.assertIn("1 pair changes", f[0]["detail"])
228
+ self.assertNotIn("financial-reporting", f[0]["detail"])
229
+ f = findings.tight_coupling(report(coupling=pairs))
230
+ self.assertIn("2 pairs", f[0]["detail"], "without a tree listing every pair counts")
231
+
191
232
  def test_single_pair_reads_grammatically(self):
192
233
  pairs = [{"entity": "a", "coupled": "b", "degree": 100, "average-revs": 10}]
193
234
  f = findings.tight_coupling(report(coupling=pairs))
@@ -45,6 +45,20 @@ class Placeholder(unittest.TestCase):
45
45
  for value in [VERSION, "1.2.3", "10.4.0+build.7", "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", "abcdef…"]:
46
46
  self.assertTrue(leaks.is_placeholder(value), value)
47
47
 
48
+ def test_a_key_block_without_key_material_is_a_template(self):
49
+ # Google's service-account sample: header, a dotted body, footer; a real body is hundreds of base64 characters
50
+ for body in ["...", "\\n...\\n", "", "…", "xxxx"]:
51
+ self.assertTrue(leaks.is_placeholder(f"-----BEGIN PRIVATE KEY-----{body}-----END PRIVATE KEY-----"), body)
52
+ self.assertTrue(leaks.is_placeholder("-----BEGIN RSA PRIVATE KEY-----\\n...\\n-----END RSA PRIVATE KEY-----\\n"))
53
+ real = "-----BEGIN PRIVATE KEY-----\\n" + "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC" * 4 + "\\n-----END PRIVATE KEY-----"
54
+ self.assertFalse(leaks.is_placeholder(real))
55
+
56
+ def test_template_markers_are_placeholders(self):
57
+ for value in ["your-project-id", "YOUR_API_KEY", "<your-token-here>", "xxxxxxxxxxxxxxxxxxxx", "XXXX-XXXX-XXXX", "changeme", "CHANGE_ME", "replace-me"]:
58
+ self.assertTrue(leaks.is_placeholder(value), value)
59
+ for value in ["AKIA" + "X" * 16, "6L" + "x" * 38, "ghp_" + "a1" * 18, "yourkey" + "9" * 20]:
60
+ self.assertFalse(leaks.is_placeholder(value), "a marker inside real-looking material is not enough: " + value)
61
+
48
62
  def test_anything_else_is_taken_seriously(self):
49
63
  # built at runtime: a literal in these shapes would trip secret scanners on this very file
50
64
  key_id, long_key = "AKIA" + "X" * 16, "6L" + "x" * 38
@@ -136,12 +150,22 @@ class Group(unittest.TestCase):
136
150
  groups = leaks.group(rows)
137
151
  self.assertEqual([g["value"] for g in groups], ["h1", "h2", "h3"], "source first, then by places; placeholders left out")
138
152
  self.assertEqual(groups[0], {"value": "h1", "rule": "generic-api-key", "files": ["app/settings.py"], "commits": ["c1", "c2"],
139
- "places": 2, "test": False})
153
+ "places": 2, "test": False, "docs": False})
140
154
  self.assertEqual(groups[1]["files"], ["tests/data/a.html", "app/tests/data/a.html"])
141
155
  self.assertEqual(groups[1]["places"], 2)
142
156
  self.assertTrue(groups[1]["test"])
143
157
  self.assertEqual(leaks.placeholders(rows), 1)
144
158
 
159
+ def test_a_value_only_in_documentation_is_flagged_as_such(self):
160
+ groups = leaks.group([self.row("h1", "docs/GA4-API-INTEGRATION.md", "c1"), self.row("h1", "README.md", "c2")])
161
+ self.assertTrue(groups[0]["docs"])
162
+ self.assertFalse(groups[0]["test"])
163
+ groups = leaks.group([self.row("h1", "docs/setup.md", "c1"), self.row("h1", "app/a.py", "c2")])
164
+ self.assertFalse(groups[0]["docs"], "one place in source is enough")
165
+ groups = leaks.group([self.row("h1", "tests/t.py", "c1")])
166
+ self.assertFalse(groups[0]["docs"])
167
+ self.assertTrue(groups[0]["test"])
168
+
145
169
  def test_a_value_seen_in_source_and_tests_counts_as_source(self):
146
170
  groups = leaks.group([self.row("h1", "tests/t.py", "c1"), self.row("h1", "app/a.py", "c2")])
147
171
  self.assertFalse(groups[0]["test"])
@@ -313,6 +313,18 @@ class Report(unittest.TestCase):
313
313
  hot = _section_text(rendered(r, [], width=200), "\u25c6 Hotspots")
314
314
  self.assertIn("no source hotspots; 1 test file hidden; --full shows them", hot)
315
315
 
316
+ def test_default_coupling_hides_pairs_of_deleted_files_and_says_so(self):
317
+ r = sample_report() # the tree holds static/index.html and static/apps-metadata.json only
318
+ r["coupling"] = [{"entity": "static/index.html", "coupled": "static/apps-metadata.json", "degree": 90, "average-revs": 11},
319
+ {"entity": "static/tax.html", "coupled": "static/treasury.html", "degree": 85, "average-revs": 11}]
320
+ coupling = _section_text(rendered(r, [], width=200), "Change coupling")
321
+ self.assertIn("static/index.html", coupling)
322
+ self.assertNotIn("static/tax.html", coupling)
323
+ self.assertIn("1 historical pair hidden; --full shows them", coupling)
324
+ full = _section_text(rendered(r, [], width=200, full=True), "Change coupling")
325
+ self.assertIn("static/tax.html", full)
326
+ self.assertNotIn("hidden", full)
327
+
316
328
  def test_coupling_with_only_test_pairs_says_what_was_hidden(self):
317
329
  r = sample_report()
318
330
  r["coupling"] = [{"entity": "static/tax.html", "coupled": "tests/test_tax.py", "degree": 100, "average-revs": 11}]
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