gitmole 0.6.5__tar.gz → 0.6.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. {gitmole-0.6.5 → gitmole-0.6.6}/PKG-INFO +1 -1
  2. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/__init__.py +1 -1
  3. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/cli.py +3 -1
  4. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/filetypes.py +59 -0
  5. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/findings.py +24 -6
  6. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/identity.py +19 -1
  7. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/load.py +1 -1
  8. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/render.py +24 -2
  9. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole.egg-info/PKG-INFO +1 -1
  10. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_cli.py +22 -0
  11. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_filetypes.py +27 -0
  12. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_findings.py +24 -0
  13. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_identity.py +13 -0
  14. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_load.py +5 -0
  15. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_render.py +30 -0
  16. {gitmole-0.6.5 → gitmole-0.6.6}/LICENSE +0 -0
  17. {gitmole-0.6.5 → gitmole-0.6.6}/README.md +0 -0
  18. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/__main__.py +0 -0
  19. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/backtest.py +0 -0
  20. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/banner.py +0 -0
  21. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/blame.py +0 -0
  22. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/clean.py +0 -0
  23. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/coupling.py +0 -0
  24. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/functions.py +0 -0
  25. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/hotspots.py +0 -0
  26. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/knowledge.py +0 -0
  27. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/leaks.py +0 -0
  28. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/loss.py +0 -0
  29. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/maat.py +0 -0
  30. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/run.py +0 -0
  31. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/textfmt.py +0 -0
  32. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/trend.py +0 -0
  33. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole/watch.py +0 -0
  34. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole.egg-info/SOURCES.txt +0 -0
  35. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole.egg-info/dependency_links.txt +0 -0
  36. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole.egg-info/entry_points.txt +0 -0
  37. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole.egg-info/requires.txt +0 -0
  38. {gitmole-0.6.5 → gitmole-0.6.6}/gitmole.egg-info/top_level.txt +0 -0
  39. {gitmole-0.6.5 → gitmole-0.6.6}/pyproject.toml +0 -0
  40. {gitmole-0.6.5 → gitmole-0.6.6}/setup.cfg +0 -0
  41. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_backtest.py +0 -0
  42. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_banner.py +0 -0
  43. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_blame.py +0 -0
  44. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_clean.py +0 -0
  45. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_coupling.py +0 -0
  46. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_functions.py +0 -0
  47. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_golden.py +0 -0
  48. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_hotspots.py +0 -0
  49. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_knowledge.py +0 -0
  50. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_leaks.py +0 -0
  51. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_loss.py +0 -0
  52. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_maat.py +0 -0
  53. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_packaging.py +0 -0
  54. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_run.py +0 -0
  55. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_textfmt.py +0 -0
  56. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_trend.py +0 -0
  57. {gitmole-0.6.5 → gitmole-0.6.6}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitmole
3
- Version: 0.6.5
3
+ Version: 0.6.6
4
4
  Summary: Offline git repository analysis with a terminal report: hotspots, coupling, ownership, code age, secrets, repo health.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/antvinni/gitmole
@@ -1,3 +1,3 @@
1
1
  """gitmole: offline git repository analysis with a terminal report."""
2
2
 
3
- __version__ = "0.6.5"
3
+ __version__ = "0.6.6"
@@ -15,7 +15,7 @@ from rich.live import Live
15
15
  from rich.spinner import Spinner
16
16
  from rich.text import Text
17
17
 
18
- from . import __version__, banner, filetypes, findings, load, loss, run
18
+ from . import __version__, banner, blame, filetypes, findings, load, loss, run
19
19
 
20
20
 
21
21
  def parse_args(argv):
@@ -294,6 +294,8 @@ def _meta_for_run(repo_dir: str, args, estimate, age_ok: bool, plots_ok: bool, p
294
294
  meta = run.collect_meta(repo_dir, since=args.since_date)
295
295
  meta["file_types"] = types_spec # the loader filters scc's size data the way every other step was filtered
296
296
  meta["gone_months"] = args.gone
297
+ ignore = list(run.DATA_IGNORES if args.ignore_data else []) + list(args.ignore)
298
+ meta["generated"] = filetypes.generated_files(repo_dir, blame.text_files(repo_dir, ignore)) # hidden from the tables, out of the findings
297
299
  if args.since_date and meta["commits"] == 0:
298
300
  raise NoCommits(f"no commits since {args.since_date}; widen --since")
299
301
  if args.now:
@@ -2,6 +2,8 @@
2
2
  Standalone so blame.py and maat.py can import it as scripts."""
3
3
  from __future__ import annotations
4
4
 
5
+ import fnmatch
6
+ import os
5
7
  import re
6
8
  import subprocess
7
9
  from collections import Counter
@@ -83,6 +85,63 @@ def is_vendor_path(path: str) -> bool:
83
85
  return bool(_VENDOR_PATH.search(path))
84
86
 
85
87
 
88
+ _RELEASE_NAMES = {"version", "version.rb", "version.py", "version.go", "version.rs", "version.txt", "__version__.py", "package.json",
89
+ "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "gemfile", "gemfile.lock", "cargo.toml", "cargo.lock",
90
+ "pyproject.toml", "setup.py", "setup.cfg", "poetry.lock", "uv.lock", "go.mod", "go.sum", "composer.json", "composer.lock"}
91
+
92
+
93
+ def is_release_path(path: str) -> bool:
94
+ """Release plumbing: version files, manifests, lock files and changelogs. Two of them changing
95
+ together is a release commit, not a dependency between them."""
96
+ name = path.rsplit("/", 1)[-1].lower()
97
+ return name in _RELEASE_NAMES or name.endswith(".gemspec") or name.startswith(("changelog", "changes.", "history.", "news."))
98
+
99
+
100
+ # What a generated file says about itself in its first lines: protoc, ajv, code generators of every kind.
101
+ _GENERATED = re.compile(r"auto[- ]?generated|generated (by|from|file|code|automatically|with)|do not (edit|modify)|@generated|code generated", re.I)
102
+ GENERATED_HEAD_LINES = 5
103
+
104
+
105
+ def _generated_patterns(repo: str) -> list:
106
+ """The .gitattributes patterns marked linguist-generated at the repository root."""
107
+ try:
108
+ with open(os.path.join(repo, ".gitattributes"), encoding="utf-8", errors="replace") as fh:
109
+ lines = fh.read().splitlines()
110
+ except OSError:
111
+ return []
112
+ out = []
113
+ for line in lines:
114
+ parts = line.split()
115
+ if len(parts) >= 2 and any(p in ("linguist-generated", "linguist-generated=true") for p in parts[1:]):
116
+ out.append(parts[0].lstrip("/"))
117
+ return out
118
+
119
+
120
+ def _attribute_match(path: str, pattern: str) -> bool:
121
+ if "/" in pattern:
122
+ return fnmatch.fnmatchcase(path, pattern) or fnmatch.fnmatchcase(path, pattern.rstrip("/") + "/*")
123
+ return fnmatch.fnmatchcase(path.rsplit("/", 1)[-1], pattern)
124
+
125
+
126
+ def generated_files(repo: str, paths: list) -> list:
127
+ """The tracked files that are generated: marked linguist-generated in .gitattributes, or saying so
128
+ in their first lines. Their complexity and churn are the generator's, not the repository's."""
129
+ patterns = _generated_patterns(repo)
130
+ out = []
131
+ for path in paths:
132
+ if any(_attribute_match(path, p) for p in patterns):
133
+ out.append(path)
134
+ continue
135
+ try:
136
+ with open(os.path.join(repo, path), "rb") as fh:
137
+ head = fh.read(2048)
138
+ except OSError:
139
+ continue
140
+ if any(_GENERATED.search(line) for line in head.decode("utf-8", "replace").splitlines()[:GENERATED_HEAD_LINES]):
141
+ out.append(path)
142
+ return sorted(out)
143
+
144
+
86
145
  def key(path: str) -> str:
87
146
  """The lowercased extension, or the whole lowercased name when there is none."""
88
147
  name = path.rsplit("/", 1)[-1].lower()
@@ -185,10 +185,12 @@ def hotspot_dominance(report: dict, ratio: float = 2.0, minimum: int = 20) -> li
185
185
 
186
186
  def tight_coupling(report: dict, min_degree: int = 80, min_revs: int = 5) -> list:
187
187
  """A file and its test are expected to change together, so pairs with a test file on either side are
188
- left out; so are pairs where either file is no longer in the tree, which are history, not a dependency."""
188
+ left out; so are pairs where either file is no longer in the tree, which are history, not a dependency,
189
+ and pairs of release plumbing (two version files, a manifest and its lock file), which are a release."""
189
190
  tree = _tree(report)
190
191
  pairs = [p for p in report.get("coupling") or [] if p["degree"] >= min_degree and p["average-revs"] >= min_revs
191
192
  and not (filetypes.is_test_path(p["entity"]) or filetypes.is_test_path(p["coupled"]))
193
+ and not (filetypes.is_release_path(p["entity"]) and filetypes.is_release_path(p["coupled"]))
192
194
  and not (tree and (p["entity"] not in tree or p["coupled"] not in tree))]
193
195
  if not pairs:
194
196
  return []
@@ -386,20 +388,36 @@ def _partial_functions(report: dict) -> str:
386
388
 
387
389
 
388
390
  def brain_methods(report: dict, min_ccn: int = 15, min_lines: int = 100) -> list:
389
- """Functions that are both long and complex, in this repository's own source files: test files and
390
- vendored code are left out. A warning when one sits in a hotspot."""
391
+ """Functions that are both long and complex, in this repository's own source files: test files,
392
+ vendored code and generated files are left out. A warning when one sits in a hotspot."""
393
+ generated = _generated(report)
391
394
  big = [f for f in report.get("functions") or [] if f["ccn"] >= min_ccn and f["nloc"] >= min_lines
392
- and not (filetypes.is_test_path(f["file"]) or filetypes.is_vendor_path(f["file"]))]
395
+ and not (filetypes.is_test_path(f["file"]) or filetypes.is_vendor_path(f["file"]) or f["file"] in generated)]
393
396
  if not big:
394
397
  return []
395
398
  big.sort(key=lambda f: (-f["ccn"], -f["nloc"], f["file"], f["function"], f["start"]))
396
399
  hot = hotspots.top(report)
397
400
  sev = "warning" if any(f["file"] in hot for f in big) else "info"
398
- listed = "; ".join(f"{f['function']} ({f['file']}) complexity {f['ccn']}, {f['nloc']} lines, {f['params']} params" for f in big[:5])
401
+ listed = "; ".join(f"{f['function']} ({_place(f)}) complexity {f['ccn']}, {f['nloc']} lines, {f['params']} params" for f in big[:5])
399
402
  more = f" and {len(big) - 5} more" if len(big) > 5 else ""
403
+ first = big[0]
404
+ which = f"the anonymous function at {_place(first)}" if first["function"] == ANONYMOUS else f"{first['function']} in {first['file']}"
400
405
  return [_f(sev, "Brain methods",
401
406
  f"{len(big)} function(s) are both long and complex: {listed}{more}.{_partial_functions(report)}",
402
- f"Split {big[0]['function']} in {big[0]['file']} first, before the next change lands there.")]
407
+ f"Split {which} first, before the next change lands there.")]
408
+
409
+
410
+ ANONYMOUS = "(anonymous)"
411
+
412
+
413
+ def _place(f: dict) -> str:
414
+ """Where a function is: its file, or file:line when it has no name to find it by."""
415
+ return f"{f['file']}:{f['start']}" if f["function"] == ANONYMOUS else f["file"]
416
+
417
+
418
+ def _generated(report: dict) -> set:
419
+ """Files the run found to be generated (a header marker or a linguist-generated attribute)."""
420
+ return set((report.get("meta") or {}).get("generated") or [])
403
421
 
404
422
 
405
423
  def complexity_growth(report: dict, min_growers: int = 3, min_pct: int = 25, top_n: int = 10) -> list:
@@ -20,8 +20,26 @@ def _tokens(name: str) -> set:
20
20
  return {t for t in re.split(r"[^a-z0-9]+", name.lower()) if len(t) >= 3}
21
21
 
22
22
 
23
+ # A bare first name under two emails may be two people; anything else spelled identically is one.
24
+ _COMMON_FIRST_NAMES = {
25
+ "adam", "alex", "alexander", "andrew", "andy", "ann", "anna", "ben", "bob", "chris", "dan", "daniel", "dave", "david", "ed",
26
+ "eric", "frank", "george", "jack", "james", "jan", "jean", "jim", "joe", "john", "jon", "josh", "kevin", "lee", "li", "luke",
27
+ "mark", "martin", "matt", "max", "michael", "mike", "nick", "paul", "pete", "peter", "phil", "rob", "robert", "ryan", "sam",
28
+ "scott", "steve", "tim", "tom", "tony", "will",
29
+ }
30
+
31
+
32
+ def _plain(name: str) -> str:
33
+ return " ".join(name.lower().split())
34
+
35
+
23
36
  def same_person(a: dict, b: dict) -> bool:
24
- return a["email"].lower() == b["email"].lower() or len(_tokens(a["name"]) & _tokens(b["name"])) >= 2
37
+ """Same email, two shared name tokens, or the same name spelled identically (a handle such as
38
+ KaKa under three emails), unless that name is a bare common first name."""
39
+ if a["email"].lower() == b["email"].lower() or len(_tokens(a["name"]) & _tokens(b["name"])) >= 2:
40
+ return True
41
+ name = _plain(a["name"])
42
+ return bool(name) and name == _plain(b["name"]) and name not in _COMMON_FIRST_NAMES
25
43
 
26
44
 
27
45
  def merge(identities: list) -> list:
@@ -151,7 +151,7 @@ def parse_functions(text: str) -> list:
151
151
  for r in csv.reader(io.StringIO(text)):
152
152
  if len(r) < 11:
153
153
  continue
154
- rows.append({"file": _rel(r[6]), "function": r[7], "ccn": _num(r[1]), "nloc": _num(r[0]), "params": _num(r[3]),
154
+ rows.append({"file": _rel(r[6]), "function": r[7] or "(anonymous)", "ccn": _num(r[1]), "nloc": _num(r[0]), "params": _num(r[3]),
155
155
  "start": _num(r[9]), "end": _num(r[10])})
156
156
  return rows
157
157
 
@@ -111,6 +111,24 @@ def _hide_vendor(rows: list, path_of, full, noun="file in vendored code", plural
111
111
  return _hide_rows(rows, path_of, full, filetypes.is_vendor_path, noun, plural)
112
112
 
113
113
 
114
+ def _hide_generated(rows: list, path_of, report: dict, full, noun="generated file", plural=None) -> tuple:
115
+ """Generated files (a header marker or a linguist-generated attribute, found at run time): the
116
+ generator's churn and complexity, not the repository's."""
117
+ generated = set((report.get("meta") or {}).get("generated") or [])
118
+ return _hide_rows(rows, path_of, full, lambda p: p in generated, noun, plural)
119
+
120
+
121
+ def _hide_release(pairs: list, full) -> tuple:
122
+ """Coupled pairs where both files are release plumbing (version files, manifests, lock files,
123
+ changelogs): they change together because a release touches them all, not because one depends on
124
+ the other. A version file paired with real code stays."""
125
+ if full is True:
126
+ return pairs, None
127
+ kept = [p for p in pairs if not (filetypes.is_release_path(p["entity"]) and filetypes.is_release_path(p["coupled"]))]
128
+ hidden = len(pairs) - len(kept)
129
+ return kept, (f"{hidden} release pair{'s' if hidden != 1 else ''} hidden{HIDDEN_SUFFIX}" if hidden else None)
130
+
131
+
114
132
  def _join_hidden(*notes) -> str:
115
133
  """Several hidden-row notes as one caption phrase: 'A hidden; B hidden; --full shows them'."""
116
134
  parts = [n[:-len(HIDDEN_SUFFIX)] if n.endswith(HIDDEN_SUFFIX) else n for n in notes if n]
@@ -376,7 +394,8 @@ def hotspots_section(report: dict, full: bool = True, width=None) -> dict:
376
394
  scored = hotspots.ranked(report)
377
395
  scored, hidden_note = _hide_tests(scored, lambda h: h["entity"], full)
378
396
  scored, deleted_note = _hide_deleted(scored, report, full)
379
- hidden_note = _join_hidden(hidden_note, deleted_note)
397
+ scored, generated_note = _hide_generated(scored, lambda h: h["entity"], report, full)
398
+ hidden_note = _join_hidden(hidden_note, deleted_note, generated_note)
380
399
  title = "Hotspots (score = revisions × lines of code)" if full is True else "Hotspots"
381
400
  limit = _limit("Hotspots", full)
382
401
  series = (report.get("trend") or {}).get("files") or {}
@@ -408,6 +427,8 @@ def coupling_section(report: dict, full: bool = True, width=None) -> dict:
408
427
  pairs = sorted((p for p in report.get("coupling") or [] if p["average-revs"] >= 5), key=lambda p: (-p["degree"], -p["average-revs"]))
409
428
  pairs, hidden_note = _hide_tests(pairs, lambda p: (p["entity"], p["coupled"]), full, noun="test pair")
410
429
  pairs, gone_note = _hide_gone(pairs, report, full)
430
+ pairs, release_note = _hide_release(pairs, full)
431
+ gone_note = _join_hidden(gone_note, release_note)
411
432
  groups, cluster_note = [], None
412
433
  if full is not True:
413
434
  # a directory whose files all change together is one row; --full lists every pair
@@ -474,7 +495,8 @@ def functions_section(report: dict, full: bool = True, width=None) -> dict:
474
495
  funcs = sorted((f for f in measured if f["ccn"] >= CCN_FLOOR), key=lambda f: (-f["ccn"], -f["nloc"], f["file"], f["function"], f["start"]))
475
496
  funcs, hidden_note = _hide_tests(funcs, lambda f: f["file"], full, noun="function in a test file", plural="functions in test files")
476
497
  funcs, vendor_note = _hide_vendor(funcs, lambda f: f["file"], full, noun="function in vendored code", plural="functions in vendored code")
477
- hidden_note = _join_hidden(hidden_note, vendor_note)
498
+ funcs, generated_note = _hide_generated(funcs, lambda f: f["file"], report, full, noun="function in a generated file", plural="functions in generated files")
499
+ hidden_note = _join_hidden(hidden_note, vendor_note, generated_note)
478
500
  limit = _limit("Complex functions", full)
479
501
  rows = [(f["function"], f["file"], f["ccn"], f["nloc"], f["params"]) for f in funcs[:limit]]
480
502
  columns = [("function", {"overflow": "fold"}), ("file", PATH), ("ccn", RIGHT), ("lines", RIGHT), ("params", RIGHT)]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitmole
3
- Version: 0.6.5
3
+ Version: 0.6.6
4
4
  Summary: Offline git repository analysis with a terminal report: hotspots, coupling, ownership, code age, secrets, repo health.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/antvinni/gitmole
@@ -592,6 +592,28 @@ class Arguments(unittest.TestCase):
592
592
  self.assertNotIn("jar", text)
593
593
 
594
594
 
595
+ class GeneratedFiles(unittest.TestCase):
596
+ def test_a_run_records_the_generated_files_in_meta(self):
597
+ with tempfile.TemporaryDirectory() as d:
598
+ _tiny_repo(d)
599
+ os.makedirs(os.path.join(d, "lib"))
600
+ with open(os.path.join(d, "lib", "validator.js"), "w") as fh:
601
+ fh.write("// This file is autogenerated by build/build.js, do not edit\nmodule.exports = 1\n")
602
+ with open(os.path.join(d, "lib", "app.js"), "w") as fh:
603
+ fh.write("module.exports = 2\n")
604
+ import subprocess
605
+ subprocess.run(["git", "-C", d, "add", "-A"], check=True)
606
+ subprocess.run(["git", "-C", d, "-c", "user.name=T", "-c", "user.email=t@x.com", "commit", "-q", "-m", "files"], check=True)
607
+ out = os.path.join(d, "out")
608
+ planner = lambda repo, o, branch="HEAD", **kw: [{"name": "q", "argv": ["true"], "stdout": None, "deps": []}]
609
+ rc = cli.main([d, "--out", out], console=console(), tool_check=lambda **kw: [], planner=planner,
610
+ estimator=lambda repo, interval, **kw: {"files": 2, "samples": 1, "blames": 2})
611
+ with open(os.path.join(out, "meta.json")) as fh:
612
+ meta = json.load(fh)
613
+ self.assertEqual(rc, 0)
614
+ self.assertEqual(meta["generated"], ["lib/validator.js"])
615
+
616
+
595
617
  class Clean(unittest.TestCase):
596
618
  """--clean lists what gitmole left behind and deletes on a yes. TMPDIR is pointed at a scratch dir so the
597
619
  real temp folder is never listed or touched."""
@@ -104,6 +104,33 @@ class TestPaths(unittest.TestCase):
104
104
  for path in ("app/settings.py", "examplesite/app.py", "src/rulesets/a.go", "sampler/x.py", "config/betterleaks.toml"):
105
105
  self.assertFalse(filetypes.is_sample_path(path), path)
106
106
 
107
+ def test_release_plumbing_files(self):
108
+ for path in ("lib/sinatra/version.rb", "VERSION", "src/pkg/__version__.py", "package.json", "package-lock.json", "Gemfile.lock",
109
+ "Cargo.toml", "pyproject.toml", "go.sum", "CHANGELOG.md", "CHANGES.rst", "sinatra.gemspec", "uv.lock"):
110
+ self.assertTrue(filetypes.is_release_path(path), path)
111
+ for path in ("lib/version_check.py", "src/app.py", "docs/versions.md", "Makefile", "lib/sinatra/base.rb"):
112
+ self.assertFalse(filetypes.is_release_path(path), path)
113
+
114
+ def test_generated_files_by_header_marker_or_attribute(self):
115
+ with tempfile.TemporaryDirectory() as d:
116
+ files = {
117
+ "lib/config-validator.js": "// This file is autogenerated by build/build-validation.js, do not edit\n'use strict'\n",
118
+ "pb/api.pb.go": "// Code generated by protoc-gen-go. DO NOT EDIT.\npackage pb\n",
119
+ "gen/schema.py": "# @generated\nx = 1\n",
120
+ "src/app.py": "# the app; it generated reports once\ndef main():\n pass\n",
121
+ "docs/notes.md": "generated notes are the best notes\n",
122
+ "dist/bundle.js": "var a = 1;\n",
123
+ "src/late.py": "\n" * 10 + "# generated by hand, do not edit\n", # a marker past the first lines does not count
124
+ }
125
+ for path, text in files.items():
126
+ os.makedirs(os.path.join(d, os.path.dirname(path)), exist_ok=True)
127
+ with open(os.path.join(d, path), "w") as fh:
128
+ fh.write(text)
129
+ with open(os.path.join(d, ".gitattributes"), "w") as fh:
130
+ fh.write("* text=auto\ndist/* linguist-generated=true\n*.min.js linguist-generated\n")
131
+ found = filetypes.generated_files(d, sorted(files))
132
+ self.assertEqual(found, ["dist/bundle.js", "gen/schema.py", "lib/config-validator.js", "pb/api.pb.go"])
133
+
107
134
  def test_vendored_trees(self):
108
135
  for path in ("vendor/github.com/x/y.go", "web/node_modules/a/index.js", "third_party/z/a.c", "thirdparty/a.c", "_vendor/a.py",
109
136
  "external/lib/a.cpp"):
@@ -254,6 +254,15 @@ class TightCoupling(unittest.TestCase):
254
254
  f = findings.tight_coupling(report(coupling=pairs))
255
255
  self.assertIn("2 pairs", f[0]["detail"], "without a tree listing every pair counts")
256
256
 
257
+ def test_release_plumbing_pairs_are_not_a_dependency(self):
258
+ pairs = [{"entity": "lib/sinatra/version.rb", "coupled": "rack-protection/lib/rack/protection/version.rb", "degree": 100, "average-revs": 60},
259
+ {"entity": "package.json", "coupled": "package-lock.json", "degree": 95, "average-revs": 40},
260
+ {"entity": "lib/sinatra/version.rb", "coupled": "lib/sinatra/base.rb", "degree": 85, "average-revs": 10}]
261
+ f = findings.tight_coupling(report(coupling=pairs))
262
+ self.assertIn("1 pair changes together", f[0]["detail"], "a version file paired with real code still counts")
263
+ self.assertNotIn("package.json", f[0]["detail"])
264
+ self.assertEqual(findings.tight_coupling(report(coupling=pairs[:2])), [])
265
+
257
266
  def test_single_pair_reads_grammatically(self):
258
267
  pairs = [{"entity": "a", "coupled": "b", "degree": 100, "average-revs": 10}]
259
268
  f = findings.tight_coupling(report(coupling=pairs))
@@ -374,6 +383,21 @@ class BrainMethods(unittest.TestCase):
374
383
  self.assertNotIn("test_all", f[0]["detail"])
375
384
  self.assertEqual(findings.brain_methods(report(functions=fns[:1])), [])
376
385
 
386
+ def test_an_anonymous_function_is_named_by_its_place(self):
387
+ fns = [{"file": "completions.go", "function": "(anonymous)", "ccn": 47, "nloc": 136, "params": 1, "start": 316, "end": 585}]
388
+ f = findings.brain_methods(report(functions=fns))
389
+ self.assertIn("(anonymous) (completions.go:316) complexity 47, 136 lines, 1 params", f[0]["detail"])
390
+ self.assertEqual(f[0]["advice"], "Split the anonymous function at completions.go:316 first, before the next change lands there.")
391
+
392
+ def test_generated_files_are_not_brain_methods(self):
393
+ fns = [{"file": "lib/config-validator.js", "function": "validate10", "ccn": 373, "nloc": 1150, "params": 5, "start": 1, "end": 1150},
394
+ {"file": "lib/reply.js", "function": "onSendEnd", "ccn": 34, "nloc": 180, "params": 2, "start": 1, "end": 180}]
395
+ r = report(functions=fns)
396
+ r["meta"]["generated"] = ["lib/config-validator.js"]
397
+ f = findings.brain_methods(r)
398
+ self.assertEqual(f[0]["advice"], "Split onSendEnd in lib/reply.js first, before the next change lands there.")
399
+ self.assertNotIn("validate10", f[0]["detail"])
400
+
377
401
  def test_vendored_functions_are_not_brain_methods(self):
378
402
  fns = [{"file": "vendor/github.com/google/jsonschema-go/jsonschema/validate.go", "function": "validate", "ccn": 179, "nloc": 424, "params": 3, "start": 1, "end": 424},
379
403
  {"file": "processor/workers.go", "function": "countLoopGeneric", "ccn": 56, "nloc": 164, "params": 8, "start": 1, "end": 164}]
@@ -18,6 +18,19 @@ class Merge(unittest.TestCase):
18
18
  names = [m["name"] for m in merged]
19
19
  self.assertEqual(names, ["Bob", "Grzegorz Bankosz", "Ann"])
20
20
 
21
+ def test_an_identical_handle_under_several_emails_is_one_person(self):
22
+ ids = [{"name": "KaKa", "email": "kaka@a.com", "commits": 57}, {"name": "KaKa", "email": "23028015+climba@users.noreply.github.com", "commits": 56},
23
+ {"name": "kaka", "email": "climba@b.com", "commits": 10}, {"name": "namusyaka", "email": "n@a.com", "commits": 180},
24
+ {"name": "namusyaka", "email": "n@b.com", "commits": 8}, {"name": "Li Yu", "email": "li@a.com", "commits": 5},
25
+ {"name": "Li Yu", "email": "li@b.com", "commits": 3}]
26
+ merged = {m["name"]: m["commits"] for m in identity.merge(ids)}
27
+ self.assertEqual(merged, {"KaKa": 123, "namusyaka": 188, "Li Yu": 8})
28
+
29
+ def test_a_bare_common_first_name_is_not_enough(self):
30
+ ids = [{"name": "Jean", "email": "jean@a.com", "commits": 24}, {"name": "Jean", "email": "jean@b.com", "commits": 18},
31
+ {"name": "Alex", "email": "alex@a.com", "commits": 3}, {"name": "alex", "email": "alex@b.com", "commits": 2}]
32
+ self.assertEqual(len(identity.merge(ids)), 4, "two Jeans and two Alexes may be four people")
33
+
21
34
  def test_merged_row_sums_commits_and_lists_aliases(self):
22
35
  merged = {m["name"]: m for m in identity.merge(IDS)}
23
36
  self.assertEqual(merged["Grzegorz Bankosz"]["commits"], 41)
@@ -138,6 +138,11 @@ class ParseFunctions(unittest.TestCase):
138
138
  def test_empty(self):
139
139
  self.assertEqual(load.parse_functions(""), [])
140
140
 
141
+ def test_a_nameless_function_is_called_anonymous(self):
142
+ # lizard names Go function literals with an empty string where it names JavaScript's "(anonymous)"
143
+ rows = load.parse_functions('136,47,926,1,270,"@316-585@completions.go","completions.go",""," c * Command",316,585\n')
144
+ self.assertEqual((rows[0]["function"], rows[0]["start"]), ("(anonymous)", 316))
145
+
141
146
  def test_a_row_cut_short_by_a_killed_step_does_not_abort_the_report(self):
142
147
  rows = load.parse_functions(self.CSV + '5,3,40,1,5,"g@1-5@a.py","a.py","g","g( )",1,\n')
143
148
  self.assertEqual(len(rows), 3)
@@ -318,6 +318,36 @@ class Report(unittest.TestCase):
318
318
  full = _section_text(rendered(r, [], width=200, full=True), "Complex functions")
319
319
  self.assertIn("vendor/github.com/x/y.go", full)
320
320
 
321
+ def test_default_tables_hide_generated_files_and_say_so(self):
322
+ r = sample_report()
323
+ r["meta"]["generated"] = ["lib/config-validator.js"]
324
+ r["size"]["files"]["lib/config-validator.js"] = {"code": 1153, "complexity": 373}
325
+ r["revisions"].append({"entity": "lib/config-validator.js", "n-revs": 8})
326
+ r["functions"].append({"file": "lib/config-validator.js", "function": "validate10", "ccn": 373, "nloc": 1150, "params": 5, "start": 1, "end": 1150})
327
+ text = rendered(r, [], width=200)
328
+ hot = text[text.index("◆ Hotspots"):text.index("Change coupling")]
329
+ self.assertNotIn("config-validator", hot)
330
+ self.assertIn("1 generated file hidden; --full shows them", hot)
331
+ fn = _section_text(text, "Complex functions")
332
+ self.assertNotIn("validate10", fn)
333
+ self.assertIn("1 function in a generated file hidden; --full shows them", fn)
334
+ full = rendered(r, [], width=200, full=True)
335
+ self.assertIn("validate10", full)
336
+
337
+ def test_default_coupling_hides_release_plumbing_pairs_and_says_so(self):
338
+ r = sample_report()
339
+ for f in ("lib/version.rb", "contrib/version.rb", "Gemfile", "Gemfile.lock"):
340
+ r["size"]["files"][f] = {"code": 3, "complexity": 0}
341
+ r["coupling"] = [{"entity": "lib/version.rb", "coupled": "contrib/version.rb", "degree": 64, "average-revs": 60},
342
+ {"entity": "Gemfile", "coupled": "Gemfile.lock", "degree": 90, "average-revs": 20},
343
+ {"entity": "static/index.html", "coupled": "static/apps-metadata.json", "degree": 90, "average-revs": 11}]
344
+ coupling = _section_text(rendered(r, [], width=200), "Change coupling")
345
+ self.assertIn("static/index.html", coupling)
346
+ self.assertNotIn("version.rb", coupling)
347
+ self.assertIn("2 release pairs hidden; --full shows them", coupling)
348
+ full = _section_text(rendered(r, [], width=200, full=True), "Change coupling")
349
+ self.assertIn("version.rb", full)
350
+
321
351
  def test_hotspots_with_only_test_files_say_what_was_hidden(self):
322
352
  r = sample_report()
323
353
  r["revisions"] = [{"entity": "tests/test_a.py", "n-revs": 200}]
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