gitmole 0.6.12__tar.gz → 0.6.13__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.12 → gitmole-0.6.13}/PKG-INFO +1 -1
  2. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/__init__.py +1 -1
  3. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/filetypes.py +2 -1
  4. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/findings.py +2 -1
  5. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/identity.py +1 -1
  6. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/leaks.py +10 -3
  7. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/maat.py +6 -5
  8. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/render.py +2 -1
  9. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole.egg-info/PKG-INFO +1 -1
  10. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_filetypes.py +5 -1
  11. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_findings.py +10 -0
  12. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_identity.py +2 -1
  13. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_leaks.py +19 -1
  14. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_maat.py +8 -0
  15. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_render.py +13 -0
  16. {gitmole-0.6.12 → gitmole-0.6.13}/LICENSE +0 -0
  17. {gitmole-0.6.12 → gitmole-0.6.13}/README.md +0 -0
  18. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/__main__.py +0 -0
  19. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/backtest.py +0 -0
  20. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/banner.py +0 -0
  21. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/blame.py +0 -0
  22. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/clean.py +0 -0
  23. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/cli.py +0 -0
  24. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/coupling.py +0 -0
  25. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/functions.py +0 -0
  26. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/hotspots.py +0 -0
  27. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/knowledge.py +0 -0
  28. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/load.py +0 -0
  29. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/loss.py +0 -0
  30. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/run.py +0 -0
  31. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/textfmt.py +0 -0
  32. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/trend.py +0 -0
  33. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole/watch.py +0 -0
  34. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole.egg-info/SOURCES.txt +0 -0
  35. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole.egg-info/dependency_links.txt +0 -0
  36. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole.egg-info/entry_points.txt +0 -0
  37. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole.egg-info/requires.txt +0 -0
  38. {gitmole-0.6.12 → gitmole-0.6.13}/gitmole.egg-info/top_level.txt +0 -0
  39. {gitmole-0.6.12 → gitmole-0.6.13}/pyproject.toml +0 -0
  40. {gitmole-0.6.12 → gitmole-0.6.13}/setup.cfg +0 -0
  41. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_backtest.py +0 -0
  42. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_banner.py +0 -0
  43. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_blame.py +0 -0
  44. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_clean.py +0 -0
  45. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_cli.py +0 -0
  46. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_coupling.py +0 -0
  47. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_functions.py +0 -0
  48. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_golden.py +0 -0
  49. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_hotspots.py +0 -0
  50. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_knowledge.py +0 -0
  51. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_load.py +0 -0
  52. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_loss.py +0 -0
  53. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_packaging.py +0 -0
  54. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_run.py +0 -0
  55. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_textfmt.py +0 -0
  56. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_trend.py +0 -0
  57. {gitmole-0.6.12 → gitmole-0.6.13}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitmole
3
- Version: 0.6.12
3
+ Version: 0.6.13
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.12"
3
+ __version__ = "0.6.13"
@@ -190,6 +190,7 @@ def is_release(path: str, plumbing=frozenset()) -> bool:
190
190
  # What a generated file says about itself in its first lines: protoc, ajv, code generators of every kind.
191
191
  _GENERATED = re.compile(r"auto[- ]?generated|generated (by|from|file|code|automatically|with)|do not (edit|modify)|@generated|code generated", re.I)
192
192
  GENERATED_HEAD_LINES = 5
193
+ _GENERATED_NAME = re.compile(r"\.(min\.js|min\.css|bundle\.js|map)$", re.I) # a build output by name: nobody edits a bundle or a source map
193
194
 
194
195
 
195
196
  def _generated_patterns(repo: str) -> list:
@@ -219,7 +220,7 @@ def generated_files(repo: str, paths: list) -> list:
219
220
  patterns = _generated_patterns(repo)
220
221
  out = []
221
222
  for path in paths:
222
- if any(_attribute_match(path, p) for p in patterns):
223
+ if _GENERATED_NAME.search(path) or any(_attribute_match(path, p) for p in patterns):
223
224
  out.append(path)
224
225
  continue
225
226
  try:
@@ -194,9 +194,10 @@ def tight_coupling(report: dict, min_degree: int = 80, min_revs: int = 5) -> lis
194
194
  """A file and its test are expected to change together, so pairs with a test file on either side are
195
195
  left out; so are pairs where either file is no longer in the tree, which are history, not a dependency,
196
196
  and pairs of release plumbing (two version files, a manifest and its lock file), which are a release."""
197
- tree, vendored = _tree(report), filetypes.vendor_dirs(report)
197
+ tree, vendored, derived = _tree(report), filetypes.vendor_dirs(report), _generated(report)
198
198
  pairs = [p for p in report.get("coupling") or [] if p["degree"] >= min_degree and p["average-revs"] >= min_revs
199
199
  and not (filetypes.is_test_path(p["entity"]) or filetypes.is_test_path(p["coupled"]))
200
+ and not (p["entity"] in derived or p["coupled"] in derived)
200
201
  and not (filetypes.is_release_path(p["entity"]) and filetypes.is_release_path(p["coupled"]))
201
202
  and not filetypes.is_header_pair(p["entity"], p["coupled"])
202
203
  and not (filetypes.is_vendored(p["entity"], vendored) or filetypes.is_vendored(p["coupled"], vendored))
@@ -5,7 +5,7 @@ import re
5
5
 
6
6
 
7
7
  _BOT_WORDS = ("dependabot", "renovate", "github-actions", "github actions", "copilot", "cursor agent", "cursor-agent", "cursoragent")
8
- _BOT_NAME = re.compile(r"\bbot\b|\bci\b|deploy|automation", re.I) # "Deploy from CI", "Release Bot", "Homebrew Automation"
8
+ _BOT_NAME = re.compile(r"\bbot\b|\bci\b|deploy|automation|releaser|release-bot", re.I) # "Deploy from CI", "Release Bot", "hugoreleaser"
9
9
 
10
10
 
11
11
  def is_bot(name: str, email: str = "") -> bool:
@@ -39,7 +39,8 @@ RAW_FIELDS = ("Secret", "Match", "Line", "Message", "Attributes")
39
39
  _VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
40
40
  _MARKER = re.compile(r"^(<[^<>]+>|x+|(?:x{2,}[-_ ]?)+|your[-_][\w-]+|change[-_]?me|replace[-_]?me)$", re.I)
41
41
  _EXAMPLE_WORDS = {"password", "passwd", "pass", "secret", "hello", "hey", "test", "example", "sample", "dummy", "foo", "bar",
42
- "baz", "admin", "root", "user", "123456", "12345678", "123456789", "abc123", "qwerty", "letmein", "welcome"}
42
+ "baz", "admin", "root", "user", "123456", "12345678", "123456789", "abc123", "qwerty", "letmein", "welcome",
43
+ "x-oauth-basic", "x-access-token", "x-token-auth"} # documented literals for the password slot of token auth
43
44
  # A whole value that refers to an environment variable or a template field is where the secret will be
44
45
  # read from, not the secret: `@env:AC_PASSWORD`, `${DB_PASSWORD}`, `{{.Env.X}}`, `process.env.X`, `<%= ENV['X'] %>`.
45
46
  _ENV_REF = re.compile(r"^(@env:\w+|\$\{[^{}]+\}|\$[A-Za-z_]\w*|\$\([^()]+\)|%[A-Za-z_]\w*%|\{\{.*\}\}|<%=?.*%>"
@@ -78,14 +79,20 @@ def _made_up(value: str) -> bool:
78
79
  return letters == sorted(letters) and digits == sorted(digits) and len(set(letters)) >= 4 or (not letters and digits == sorted(digits))
79
80
 
80
81
 
82
+ # A template field anywhere inside the value: {token}, ${X}, ${{ X }}, %(name)s, {{ x }}, <user>.
83
+ _TEMPLATE_FIELD = re.compile(r"\{[A-Za-z_][\w.]*\}|\$\{[^{}]*\}|\$\{\{.*?\}\}|%\([A-Za-z_]\w*\)s|\{\{.*?\}\}|<[A-Za-z_][\w-]*>")
84
+ # Five or more words of letters: a description of the secret, not the secret.
85
+ _PROSE = re.compile(r"^[A-Za-z][A-Za-z'-]*(\s+[A-Za-z][A-Za-z'-]*){4,}\s*$")
86
+
87
+
81
88
  def is_placeholder(value: str, line: str = "") -> bool:
82
89
  """Whether `value` has a shape that cannot be a live secret. `line` is the source line the value
83
90
  sat on, read from the clone at scan time and never written."""
84
91
  value = (value or "").strip()
85
92
  if (_VERSION.match(value) or value.endswith("...") or value.endswith("…") or _MARKER.match(value) or value.lower() in _EXAMPLE_WORDS
86
- or _ENV_REF.match(value) or _made_up(value)):
93
+ or _ENV_REF.match(value) or _made_up(value) or _TEMPLATE_FIELD.search(value) or _PROSE.match(value)):
87
94
  return True
88
- if line and _EXAMPLE_LINE.search(line):
95
+ if line and (_EXAMPLE_LINE.search(line) or _TEMPLATE_FIELD.search(line)): # the line is an example, or a template being filled in
89
96
  return True
90
97
  m = _KEY_BLOCK.search(value)
91
98
  if m:
@@ -221,15 +221,16 @@ def activity(commits: list) -> dict:
221
221
  "reverted": dict(sorted(reverted.items(), key=lambda kv: (-kv[1], kv[0])))}
222
222
 
223
223
 
224
- def plumbing(commits: list, min_revs: int = 20, share: float = 0.8, max_lines: int = 2) -> list:
225
- """Files whose commits nearly always change a line or two: a version constant in __init__.py, a
226
- date in a header. Their churn is the release cadence, not where the next bug lands. Needs enough
227
- commits to judge by."""
224
+ def plumbing(commits: list, min_revs: int = 20, share: float = 0.8, max_lines: int = 3) -> list:
225
+ """Files whose commits nearly always swap a few lines for as many: a version constant in
226
+ __init__.py, the three fields of a version struct. Their churn is the release cadence, not where
227
+ the next bug lands. A commit that adds lines without removing any is growth, not a bump. Needs
228
+ enough commits to judge by."""
228
229
  revs, tiny = Counter(), Counter()
229
230
  for c in commits:
230
231
  for path, added, deleted in c["files"]:
231
232
  revs[path] += 1
232
- if added + deleted <= max_lines:
233
+ if added == deleted and added <= max_lines:
233
234
  tiny[path] += 1
234
235
  return [{"entity": e, "n-revs": n, "tiny-revs": tiny[e]} for e, n in sorted(revs.items()) if n >= min_revs and tiny[e] / n >= share]
235
236
 
@@ -443,7 +443,8 @@ def coupling_section(report: dict, full: bool = True, width=None) -> dict:
443
443
  pairs, release_note = _hide_release(pairs, full)
444
444
  pairs, header_note = _hide_header_pairs(pairs, full)
445
445
  pairs, vendor_note = _hide_vendor(pairs, lambda p: (p["entity"], p["coupled"]), full, noun="vendored pair", plural="vendored pairs", report=report)
446
- gone_note = _join_hidden(gone_note, release_note, header_note, vendor_note)
446
+ pairs, generated_note = _hide_generated(pairs, lambda p: (p["entity"], p["coupled"]), report, full, noun="generated pair", plural="generated pairs")
447
+ gone_note = _join_hidden(gone_note, release_note, header_note, vendor_note, generated_note)
447
448
  groups, cluster_note = [], None
448
449
  if full is not True:
449
450
  # a directory whose files all change together is one row; --full lists every pair
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitmole
3
- Version: 0.6.12
3
+ Version: 0.6.13
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
@@ -133,6 +133,9 @@ class TestPaths(unittest.TestCase):
133
133
  "docs/notes.md": "generated notes are the best notes\n",
134
134
  "dist/bundle.js": "var a = 1;\n",
135
135
  "src/late.py": "\n" * 10 + "# generated by hand, do not edit\n", # a marker past the first lines does not count
136
+ "internal/js/renderkatex.bundle.js": "var a=1;\n", # a bundle, a minified file or a source map is a build output by name
137
+ "static/app.min.css": "a{b:c}\n",
138
+ "static/app.js.map": "{}\n",
136
139
  }
137
140
  for path, text in files.items():
138
141
  os.makedirs(os.path.join(d, os.path.dirname(path)), exist_ok=True)
@@ -141,7 +144,8 @@ class TestPaths(unittest.TestCase):
141
144
  with open(os.path.join(d, ".gitattributes"), "w") as fh:
142
145
  fh.write("* text=auto\ndist/* linguist-generated=true\n*.min.js linguist-generated\n")
143
146
  found = filetypes.generated_files(d, sorted(files))
144
- self.assertEqual(found, ["dist/bundle.js", "gen/schema.py", "lib/config-validator.js", "pb/api.pb.go"])
147
+ self.assertEqual(found, ["dist/bundle.js", "gen/schema.py", "internal/js/renderkatex.bundle.js", "lib/config-validator.js", "pb/api.pb.go",
148
+ "static/app.js.map", "static/app.min.css"])
145
149
 
146
150
  def test_a_nested_licence_with_other_copyright_holders_marks_a_vendored_tree(self):
147
151
  with tempfile.TemporaryDirectory() as d:
@@ -261,6 +261,16 @@ class TightCoupling(unittest.TestCase):
261
261
  f = findings.tight_coupling(report(coupling=pairs))
262
262
  self.assertIn("2 pairs", f[0]["detail"], "without a tree listing every pair counts")
263
263
 
264
+ def test_generated_pairs_are_the_generators_coupling(self):
265
+ pairs = [{"entity": "js/greet.bundle.js", "coupled": "js/renderkatex.bundle.js", "degree": 83, "average-revs": 10},
266
+ {"entity": "js/renderkatex.bundle.js", "coupled": "js/renderkatex.js", "degree": 83, "average-revs": 10},
267
+ {"entity": "src/a.js", "coupled": "src/b.js", "degree": 85, "average-revs": 10}]
268
+ r = report(coupling=pairs)
269
+ r["meta"]["generated"] = ["js/greet.bundle.js", "js/renderkatex.bundle.js"]
270
+ f = findings.tight_coupling(r)
271
+ self.assertIn("1 pair changes together", f[0]["detail"])
272
+ self.assertIn("src/a.js", f[0]["detail"])
273
+
264
274
  def test_vendored_pairs_are_somebody_elses_coupling(self):
265
275
  pairs = [{"entity": "deps/hiredis/adapters/ae.h", "coupled": "deps/hiredis/adapters/libev.h", "degree": 84, "average-revs": 10},
266
276
  {"entity": "src/ae.c", "coupled": "deps/hiredis/net.h", "degree": 85, "average-revs": 10},
@@ -98,7 +98,8 @@ class IsBot(unittest.TestCase):
98
98
  ("dependabot[bot]", "support@github.com"), ("Dependabot", "dependabot@example.com"),
99
99
  ("Renovate Bot", "bot@renovateapp.com"), ("GitHub Actions", "actions@github.com"),
100
100
  ("Copilot", "198982749+Copilot@users.noreply.github.com"), ("Cursor Agent", "cursoragent@cursor.com"),
101
- ("Deploy from CI", ""), ("Release Bot", "release@x.com"), ("CI", "ci@x.com"), ("Homebrew Automation", "a@x.com")]:
101
+ ("Deploy from CI", ""), ("Release Bot", "release@x.com"), ("CI", "ci@x.com"), ("Homebrew Automation", "a@x.com"),
102
+ ("hugoreleaser", "hugoreleaser@x.com"), ("semantic-release-bot", "s@x.com")]:
102
103
  self.assertTrue(identity.is_bot(name, email), (name, email))
103
104
 
104
105
  def test_people_are_not_bots(self):
@@ -61,8 +61,11 @@ class Placeholder(unittest.TestCase):
61
61
 
62
62
  def test_common_example_words_are_placeholders(self):
63
63
  # `password: 'hello'` in a doc comment, `secret` in a sample config: the words every example uses
64
- for value in ["hello", "Hello", "secret", "password", "PASSWORD", "example", "123456", "qwerty", "letmein", "foo", "dummy"]:
64
+ for value in ["hello", "Hello", "secret", "password", "PASSWORD", "example", "123456", "qwerty", "letmein", "foo", "dummy",
65
+ "x-oauth-basic", "x-access-token"]: # GitHub's documented literals for the password slot of token auth
65
66
  self.assertTrue(leaks.is_placeholder(value), value)
67
+ self.assertTrue(leaks.is_placeholder("hunter2", line='url = f"https://{token}:hunter2@github.com/{SLUG}.git"'),
68
+ "a line with a template field is a template being filled in")
66
69
  for value in ["hello123", "secret-9f8a7b6c5d4e", "s3cr3t!Passw0rd", "foobarbaz2024"]:
67
70
  self.assertFalse(leaks.is_placeholder(value), "a word inside other material is not a placeholder: " + value)
68
71
 
@@ -110,6 +113,21 @@ class Placeholder(unittest.TestCase):
110
113
  for value in ["nz5ej2kypkvcw0rn5cvhs6qxtm", "abcdefg", "zyxwvutsrq", "ghp_" + "a1" * 18]:
111
114
  self.assertFalse(leaks.is_placeholder(value), value)
112
115
 
116
+ def test_a_value_with_a_template_field_inside_it_is_a_template(self):
117
+ # pytest's release script: oauth_url = f"https://{token}:x-oauth-basic@github.com/{SLUG}.git"
118
+ for value in ["https://{token}:x-oauth-basic@github.com/pytest-dev/pytest.git", "Bearer ${TOKEN}", "key-%(api_key)s", "sk_live_{{ secret }}",
119
+ "${{ secrets.CODECOV_TOKEN }}", "https://<user>:<pass>@host/db"]:
120
+ self.assertTrue(leaks.is_placeholder(value), value)
121
+ for value in ["https://x:hunter2@host/db", "ghp_" + "a1" * 18, "{" + "a1" * 10]:
122
+ self.assertFalse(leaks.is_placeholder(value), value)
123
+
124
+ def test_a_sentence_of_prose_is_a_description_not_a_secret(self):
125
+ # pytest's devpi task: 'password': 'user password on devpi to stage the generated package '
126
+ for value in ["user password on devpi to stage the generated package", "The API key used to talk to the billing service"]:
127
+ self.assertTrue(leaks.is_placeholder(value), value)
128
+ for value in ["correct horse battery", "nz5ej2kypkvcw0rn5cvhs6qxtm", "one two 3 four five"]:
129
+ self.assertFalse(leaks.is_placeholder(value), "under five words, or a digit among them: not prose")
130
+
113
131
  def test_anything_else_is_taken_seriously(self):
114
132
  # built at runtime: a literal in these shapes would trip secret scanners on this very file
115
133
  key_id, long_key = "AKIA" + "X" * 16, "6L" + "x" * 38
@@ -111,6 +111,14 @@ class Plumbing(unittest.TestCase):
111
111
  self.assertEqual(rows, [{"entity": "fastapi/__init__.py", "n-revs": 331, "tiny-revs": 300}],
112
112
  "routing.py has real edits; VERSION has too few commits to judge")
113
113
 
114
+ def test_a_bump_that_swaps_three_lines_is_tiny_but_growth_is_not(self):
115
+ # hugo's version_current.go: every release edits Major, Minor and PatchLevel, 3 lines out and 3 in
116
+ bumps = [{"hash": f"b{i}", "date": "2026-01-01", "time": "", "author": "A", "subject": "release", "files": [("common/hugo/version_current.go", 3, 3)]}
117
+ for i in range(30)]
118
+ growth = [{"hash": f"g{i}", "date": "2026-01-01", "time": "", "author": "A", "subject": "add", "files": [("lib/list.py", 3, 0)]} for i in range(30)]
119
+ rows = maat.plumbing(bumps + growth)
120
+ self.assertEqual([r["entity"] for r in rows], ["common/hugo/version_current.go"], "three lines added with nothing removed is growth, not a bump")
121
+
114
122
  def test_written_alongside_the_other_analyses(self):
115
123
  with tempfile.TemporaryDirectory() as d:
116
124
  log = os.path.join(d, "log.txt")
@@ -368,6 +368,19 @@ class Report(unittest.TestCase):
368
368
  self.assertNotIn("deps/", coupling)
369
369
  self.assertIn("2 vendored pairs hidden; --full shows them", coupling)
370
370
 
371
+ def test_default_coupling_hides_generated_pairs_and_says_so(self):
372
+ r = sample_report()
373
+ r["meta"]["generated"] = ["js/a.bundle.js", "js/b.bundle.js"]
374
+ for f in ("js/a.bundle.js", "js/b.bundle.js", "js/b.js", "src/x.js", "src/y.js"):
375
+ r["size"]["files"][f] = {"code": 30, "complexity": 1}
376
+ r["coupling"] = [{"entity": "js/a.bundle.js", "coupled": "js/b.bundle.js", "degree": 83, "average-revs": 20},
377
+ {"entity": "js/b.bundle.js", "coupled": "js/b.js", "degree": 83, "average-revs": 20},
378
+ {"entity": "src/x.js", "coupled": "src/y.js", "degree": 60, "average-revs": 9}]
379
+ coupling = _section_text(rendered(r, [], width=200), "Change coupling")
380
+ self.assertIn("src/y.js", coupling)
381
+ self.assertNotIn("bundle", coupling)
382
+ self.assertIn("2 generated pairs hidden; --full shows them", coupling)
383
+
371
384
  def test_default_coupling_hides_header_pairs_and_says_so(self):
372
385
  r = sample_report()
373
386
  for f in ("src/vector.c", "src/vector.h", "src/list.c"):
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