gitmole 0.12.0__tar.gz → 0.13.1__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 (70) hide show
  1. {gitmole-0.12.0 → gitmole-0.13.1}/PKG-INFO +1 -1
  2. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/__init__.py +1 -1
  3. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/evaluate.py +53 -6
  4. gitmole-0.13.1/gitmole/szz.py +135 -0
  5. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole.egg-info/PKG-INFO +1 -1
  6. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole.egg-info/SOURCES.txt +2 -0
  7. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_evaluate.py +32 -1
  8. gitmole-0.13.1/tests/test_szz.py +99 -0
  9. {gitmole-0.12.0 → gitmole-0.13.1}/LICENSE +0 -0
  10. {gitmole-0.12.0 → gitmole-0.13.1}/README.md +0 -0
  11. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/__main__.py +0 -0
  12. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/backtest.py +0 -0
  13. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/banner.py +0 -0
  14. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/blame.py +0 -0
  15. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/classify.py +0 -0
  16. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/clean.py +0 -0
  17. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/cli.py +0 -0
  18. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/compare.py +0 -0
  19. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/coupling.py +0 -0
  20. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/deps.py +0 -0
  21. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/duplicates.py +0 -0
  22. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/filetypes.py +0 -0
  23. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/findings.py +0 -0
  24. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/functions.py +0 -0
  25. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/hotspots.py +0 -0
  26. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/identity.py +0 -0
  27. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/knowledge.py +0 -0
  28. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/leaks.py +0 -0
  29. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/load.py +0 -0
  30. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/loss.py +0 -0
  31. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/maat.py +0 -0
  32. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/render.py +0 -0
  33. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/run.py +0 -0
  34. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/textfmt.py +0 -0
  35. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/trend.py +0 -0
  36. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/watch.py +0 -0
  37. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole.egg-info/dependency_links.txt +0 -0
  38. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole.egg-info/entry_points.txt +0 -0
  39. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole.egg-info/requires.txt +0 -0
  40. {gitmole-0.12.0 → gitmole-0.13.1}/gitmole.egg-info/top_level.txt +0 -0
  41. {gitmole-0.12.0 → gitmole-0.13.1}/pyproject.toml +0 -0
  42. {gitmole-0.12.0 → gitmole-0.13.1}/setup.cfg +0 -0
  43. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_backtest.py +0 -0
  44. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_banner.py +0 -0
  45. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_blame.py +0 -0
  46. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_classify.py +0 -0
  47. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_clean.py +0 -0
  48. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_cli.py +0 -0
  49. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_compare.py +0 -0
  50. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_coupling.py +0 -0
  51. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_deps.py +0 -0
  52. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_duplicates.py +0 -0
  53. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_filetypes.py +0 -0
  54. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_findings.py +0 -0
  55. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_functions.py +0 -0
  56. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_golden.py +0 -0
  57. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_hotspots.py +0 -0
  58. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_identity.py +0 -0
  59. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_knowledge.py +0 -0
  60. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_leaks.py +0 -0
  61. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_load.py +0 -0
  62. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_loss.py +0 -0
  63. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_maat.py +0 -0
  64. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_packaging.py +0 -0
  65. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_render.py +0 -0
  66. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_render_examples.py +0 -0
  67. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_run.py +0 -0
  68. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_textfmt.py +0 -0
  69. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_trend.py +0 -0
  70. {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitmole
3
- Version: 0.12.0
3
+ Version: 0.13.1
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.12.0"
3
+ __version__ = "0.13.1"
@@ -20,7 +20,7 @@ import os
20
20
  import subprocess
21
21
  import sys
22
22
 
23
- from . import backtest, filetypes, identity, load, maat, trend, watch
23
+ from . import backtest, filetypes, identity, load, maat, szz, trend, watch
24
24
 
25
25
 
26
26
  def cutoffs(last_date: str, windows: int, horizon: int) -> list:
@@ -44,6 +44,32 @@ def fixed_between(commits: list, start: str, end: str) -> set:
44
44
  for p, _, _ in c["files"] if not filetypes.is_test_path(p)}
45
45
 
46
46
 
47
+ def induced_between(repo: str, commits: list, start: str, end: str, exclude=None) -> set:
48
+ """Source files a commit before `start` made buggy, by R-SZZ over the fixes landing on or after
49
+ `start` and before `end`: the outcome is defect insertion the list could have known about, not
50
+ fix locality. A fix whose bug-inducing commit is inside the horizon is not counted, since no
51
+ list drawn before `start` could have named it."""
52
+ out = set()
53
+ for c in maat.in_window(maat.fix_commits(commits), start, end):
54
+ found = szz.bug_inducing(repo, c["hash"], exclude)
55
+ if found and found["date"] < start:
56
+ out.update(p for p in found["files"] if not filetypes.is_test_path(p))
57
+ return out
58
+
59
+
60
+ def labelled_between(commits: list, labels: dict, start: str, end: str) -> set:
61
+ """Source files an independently labelled bug-inducing commit touched on or after `start` and
62
+ before `end` (ApacheJIT, Defectors: see szz.read_labels): the paths the label names, or every
63
+ source file the commit touched. Either side may be abbreviated."""
64
+ out = set()
65
+ for c in maat.in_window(commits, start, end):
66
+ paths = next((v for k, v in labels.items() if k.startswith(c["hash"]) or c["hash"].startswith(k)), "none")
67
+ if paths == "none":
68
+ continue
69
+ out.update(p for p in (paths if paths is not None else [p for p, _, _ in c["files"]]) if not filetypes.is_test_path(p))
70
+ return out
71
+
72
+
47
73
  def report_at(commits: list, t: str, size: dict, meta: dict, generated: list, vendored: list, ignored: set = frozenset()) -> dict:
48
74
  """The report watch.risks reads, from the commits before `t` (less the sweeps and the declared, as
49
75
  the pipeline leaves them out), scc's listing of the tree at `t`, and that tree's own generated and
@@ -122,10 +148,11 @@ def score(report: dict, fixed: set, top: int) -> dict:
122
148
  return out
123
149
 
124
150
 
125
- def table(results: list) -> str:
126
- """results: [(t, files fixed that were in the pool, pool size, {variant: hits})], oldest first -> Markdown."""
151
+ def table(results: list, noun: str = "fixed") -> str:
152
+ """results: [(t, files fixed that were in the pool, pool size, {variant: hits})], oldest first -> Markdown.
153
+ `noun` says what the outcome is: fixed, bug-inducing (R-SZZ) or labelled."""
127
154
  names = list(results[0][3]) if results else []
128
- head = "| variant | " + " | ".join(f"{t} ({fixed} of {pool} fixed)" for t, fixed, pool, _ in results) + " | total |"
155
+ head = "| variant | " + " | ".join(f"{t} ({fixed} of {pool} {noun})" for t, fixed, pool, _ in results) + " | total |"
129
156
  rule = "|---|" + "---:|" * (len(results) + 1)
130
157
  rows = []
131
158
  for name in names:
@@ -152,6 +179,8 @@ def main(argv=None) -> int:
152
179
  p.add_argument("--windows", type=int, default=6)
153
180
  p.add_argument("--horizon", type=int, default=6, metavar="MONTHS")
154
181
  p.add_argument("--top", type=int, default=watch.WATCH_TOP)
182
+ p.add_argument("--szz", action="store_true", help="also score against R-SZZ bug-inducing commits (one git blame per fix and file: minutes)")
183
+ p.add_argument("--labels", metavar="CSV", help="also score against independent bug-inducing labels (ApacheJIT's CSV, Defectors' file rows, or one hash per line)")
155
184
  args = p.parse_args(argv)
156
185
  meta = load._read_json(args.out, "meta.json", {})
157
186
  log_path = os.path.join(args.out, "log.txt")
@@ -165,7 +194,11 @@ def main(argv=None) -> int:
165
194
  from . import run
166
195
  declared = maat.read_ignore_revs(run.ignore_revs_files(args.repo))
167
196
  ignored = {c["hash"] for c in commits if declared and maat.is_ignored(c["hash"], declared)}
168
- results = []
197
+ labels = szz.read_labels(args.labels) if args.labels else None
198
+ if args.labels and not labels:
199
+ print(f"evaluate: no bug-inducing commits read from {args.labels}", file=sys.stderr)
200
+ return 2
201
+ results, induced_results, labelled_results = [], [], []
169
202
  for t in cutoffs(meta["last_date"], args.windows, args.horizon):
170
203
  rev = trend.rev_before(args.repo, t, end_of_day=False)
171
204
  if not rev:
@@ -173,9 +206,17 @@ def main(argv=None) -> int:
173
206
  size_json, generated, vendored = backtest.snapshot_at(args.repo, rev, args.out)
174
207
  size = load.parse_scc(size_json, types)
175
208
  report = report_at(commits, t, size, meta, generated, vendored, ignored)
176
- fixed = fixed_between(commits, t, months_after(t, args.horizon))
209
+ end = months_after(t, args.horizon)
210
+ fixed = fixed_between(commits, t, end)
177
211
  pool = set(variants(report)["churn"])
178
212
  results.append((t, len(fixed & pool), len(pool), score(report, fixed, args.top)))
213
+ if args.szz:
214
+ vendor = tuple(vendored)
215
+ induced = induced_between(args.repo, commits, t, end, exclude=lambda p: p in generated or filetypes.is_vendored(p, vendor) or filetypes.is_sample_path(p))
216
+ induced_results.append((t, len(induced & pool), len(pool), score(report, induced, args.top)))
217
+ if labels:
218
+ marked = labelled_between(commits, labels, t, end)
219
+ labelled_results.append((t, len(marked & pool), len(pool), score(report, marked, args.top)))
179
220
  print(f"evaluate: {t} done", file=sys.stderr)
180
221
  if not results:
181
222
  print("evaluate: no cut-off falls inside the history", file=sys.stderr)
@@ -183,6 +224,12 @@ def main(argv=None) -> int:
183
224
  spread = ref_spread(args.repo)
184
225
  print(f"### {meta.get('name', args.repo)}, top {args.top}, {args.horizon}-month horizon\n")
185
226
  print(table(results))
227
+ if induced_results:
228
+ print(f"\nAgainst the files a commit before the cut-off made buggy, by R-SZZ over the fixes that followed (the most recent commit each fix's removed lines blame to):\n")
229
+ print(table(induced_results, noun="bug-inducing"))
230
+ if labelled_results:
231
+ print(f"\nAgainst the files the bug-inducing commits labelled in {os.path.basename(args.labels)} touched inside each window:\n")
232
+ print(table(labelled_results, noun="labelled"))
186
233
  print(f"\n`--all` exports {spread['all']:,} commits ({spread['fix_all']:,} fixes); HEAD reaches {spread['head']:,} ({spread['fix_head']:,} fixes).")
187
234
  return 0
188
235
 
@@ -0,0 +1,135 @@
1
+ """Bug-inducing commits by R-SZZ, from git alone, for the backtest's second outcome.
2
+
3
+ The watch list's backtest scores "a fix-labelled commit touched the file in the six months after
4
+ the cut-off": fix locality, which a changelog or a config file scores without ever being wrong.
5
+ The SZZ family gives the other label, defect insertion: the commit that wrote the lines a fix
6
+ removed. Rosa et al. (ICSE 2021, JSS 2023) measured the git-only variants against a
7
+ developer-informed oracle and found R-SZZ, which keeps only the most recent candidate per fix,
8
+ the best of them at precision 0.66 against 0.39 for keeping every candidate. This is that variant:
9
+
10
+ 1. the lines a fix removed or changed, per modified file, from `git diff -U0 fix^ fix`;
11
+ 2. `git blame -w -C -C` of the parent at those lines, so whitespace and moved code do not mislead;
12
+ 3. files the caller excludes (tests, vendored, generated) are not candidates;
13
+ 4. of the commits blamed, the most recent by author date is the bug-inducing one, with the files
14
+ whose blamed lines it wrote.
15
+
16
+ An insert-only fix blames nothing and R-SZZ finds nothing for it; that is the known blind spot.
17
+ A development tool behind `python -m gitmole.evaluate --szz`, not a pipeline step: one blame per
18
+ (fix, file) is minutes over a large history, and the report's own backtest stays the cheap one."""
19
+ from __future__ import annotations
20
+
21
+ import csv
22
+ import datetime as dt
23
+ import re
24
+ import subprocess
25
+
26
+ from . import filetypes
27
+
28
+ _HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@")
29
+ _HEADER = re.compile(r"^([0-9a-f]{40,64}) \d+ \d+(?: \d+)?$")
30
+ _HASH = re.compile(r"^[0-9a-f]{7,64}$")
31
+
32
+
33
+ def _git(repo: str, *args) -> str:
34
+ return subprocess.run([*filetypes.GIT, *args], cwd=repo, capture_output=True, check=True).stdout.decode("utf-8", "replace")
35
+
36
+
37
+ def deleted_ranges(repo: str, fix: str) -> dict:
38
+ """{path: [(start, count)]} of the parent-side lines a fix removed or changed, for every file it
39
+ modified (added and deleted files have no parent version to blame). An empty list is a file
40
+ that only gained lines."""
41
+ out, path = {}, None
42
+ text = _git(repo, "diff", "-U0", "--diff-filter=M", "--no-color", f"{fix}^", fix)
43
+ for line in text.split("\n"):
44
+ if line.startswith("diff --git "):
45
+ path = None
46
+ elif line.startswith("+++ b/"):
47
+ path = filetypes.unquote(line[6:])
48
+ out.setdefault(path, [])
49
+ elif path is not None and line.startswith("@@"):
50
+ m = _HUNK.match(line)
51
+ if not m:
52
+ continue
53
+ start, count = int(m.group(1)), int(m.group(2)) if m.group(2) is not None else 1
54
+ if count:
55
+ out[path].append((start, count))
56
+ return out
57
+
58
+
59
+ def blame_lines(repo: str, rev: str, path: str, ranges: list) -> dict:
60
+ """{commit: {"time": author epoch, "lines": n}} for the given parent-side line ranges, blamed
61
+ with whitespace ignored and moves followed (-w -C -C: AG-SZZ's robustness for free)."""
62
+ if not ranges:
63
+ return {}
64
+ argv = ["blame", "-w", "-C", "-C", "--line-porcelain"]
65
+ for start, count in ranges:
66
+ argv += ["-L", f"{start},{start + count - 1}"]
67
+ try:
68
+ text = _git(repo, *argv, rev, "--", path)
69
+ except subprocess.CalledProcessError:
70
+ return {}
71
+ out, current = {}, None
72
+ for line in text.split("\n"):
73
+ m = _HEADER.match(line)
74
+ if m:
75
+ current = out.setdefault(m.group(1), {"time": 0, "lines": 0})
76
+ current["lines"] += 1
77
+ elif current is not None and line.startswith("author-time "):
78
+ current["time"] = int(line[12:])
79
+ return out
80
+
81
+
82
+ def bug_inducing(repo: str, fix: str, exclude=None):
83
+ """{"commit", "date", "files"} for the R-SZZ pick of a fix: the most recent commit its deleted
84
+ lines blame to, and the files whose lines it wrote; None when nothing blames (an insert-only
85
+ fix, or every touched file excluded). `exclude(path)` says which files are not candidates; test
86
+ files never are."""
87
+ candidates = {}
88
+ for path, ranges in deleted_ranges(repo, fix).items():
89
+ if filetypes.is_test_path(path) or (exclude and exclude(path)):
90
+ continue
91
+ for commit, info in blame_lines(repo, f"{fix}^", path, ranges).items():
92
+ c = candidates.setdefault(commit, {"time": info["time"], "files": set()})
93
+ c["time"] = max(c["time"], info["time"])
94
+ c["files"].add(path)
95
+ if not candidates:
96
+ return None
97
+ commit = max(candidates, key=lambda c: (candidates[c]["time"], c))
98
+ when = dt.datetime.fromtimestamp(candidates[commit]["time"], dt.timezone.utc).date().isoformat()
99
+ return {"commit": commit, "date": when, "files": sorted(candidates[commit]["files"])}
100
+
101
+
102
+ _TRUE = {"true", "1", "yes", "buggy"}
103
+ _COMMIT_COLUMNS = ("commit_id", "commit", "hash", "sha", "bug_commit", "bic")
104
+ _PATH_COLUMNS = ("path", "file", "filepath", "file_path", "filename")
105
+
106
+
107
+ def read_labels(path: str) -> dict:
108
+ """Independent bug-inducing labels: {commit: [paths] or None}. Three shapes: ApacheJIT's CSV
109
+ (a `commit_id` column and a `buggy` flag; only the buggy rows count), Defectors' file-level
110
+ rows (a commit column and a path column), or a bare list of hashes, one per line."""
111
+ out = {}
112
+ with open(path, encoding="utf-8", errors="replace", newline="") as fh:
113
+ rows = list(csv.reader(fh))
114
+ if not rows:
115
+ return out
116
+ head = [h.strip().lower() for h in rows[0]]
117
+ commit_col = next((i for i, h in enumerate(head) if h in _COMMIT_COLUMNS), None)
118
+ if commit_col is None:
119
+ for row in rows:
120
+ if row and _HASH.match(row[0].strip()):
121
+ out[row[0].strip()] = None
122
+ return out
123
+ buggy_col = next((i for i, h in enumerate(head) if h == "buggy"), None)
124
+ path_col = next((i for i, h in enumerate(head) if h in _PATH_COLUMNS), None)
125
+ for row in rows[1:]:
126
+ if len(row) <= commit_col or not row[commit_col].strip():
127
+ continue
128
+ if buggy_col is not None and (len(row) <= buggy_col or row[buggy_col].strip().lower() not in _TRUE):
129
+ continue
130
+ commit = row[commit_col].strip()
131
+ if path_col is not None and len(row) > path_col and row[path_col].strip():
132
+ out[commit] = (out.get(commit) or []) + [row[path_col].strip()]
133
+ else:
134
+ out.setdefault(commit, None)
135
+ return out
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitmole
3
- Version: 0.12.0
3
+ Version: 0.13.1
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
@@ -26,6 +26,7 @@ gitmole/loss.py
26
26
  gitmole/maat.py
27
27
  gitmole/render.py
28
28
  gitmole/run.py
29
+ gitmole/szz.py
29
30
  gitmole/textfmt.py
30
31
  gitmole/trend.py
31
32
  gitmole/watch.py
@@ -61,6 +62,7 @@ tests/test_packaging.py
61
62
  tests/test_render.py
62
63
  tests/test_render_examples.py
63
64
  tests/test_run.py
65
+ tests/test_szz.py
64
66
  tests/test_textfmt.py
65
67
  tests/test_trend.py
66
68
  tests/test_watch.py
@@ -1,6 +1,10 @@
1
+ import os
2
+ import subprocess
3
+ import tempfile
1
4
  import unittest
2
5
 
3
- from gitmole import evaluate
6
+ from gitmole import evaluate, maat
7
+ from tests.test_szz import make_repo
4
8
 
5
9
 
6
10
  def commit(date, subject, *files):
@@ -39,6 +43,32 @@ class Outcome(unittest.TestCase):
39
43
  self.assertEqual(evaluate.fixed_between(commits, "2025-07-01", "2025-09-01"), {"core/b.py"}, "2,000 lines over the 99th percentile: tangled by size, credits nothing")
40
44
 
41
45
 
46
+ class Induced(unittest.TestCase):
47
+ def _log(self, d):
48
+ text = subprocess.run(["git", "log", "HEAD", "--numstat", "--date=iso-strict", "-M", "--pretty=format:--%h--%ad--%aN--%s"],
49
+ cwd=d, capture_output=True, text=True, check=True).stdout
50
+ return maat.parse_log(text)
51
+
52
+ def test_files_a_commit_before_the_cut_off_made_buggy_as_a_fix_inside_the_horizon_shows(self):
53
+ with tempfile.TemporaryDirectory() as d:
54
+ make_repo(d)
55
+ commits = self._log(d)
56
+ self.assertEqual(evaluate.induced_between(d, commits, "2025-03-15", "2025-09-15"), {"core/f.py"},
57
+ "the April fix blames the February commit, which is before the cut-off")
58
+ self.assertEqual(evaluate.induced_between(d, commits, "2025-01-15", "2025-09-15"), set(),
59
+ "the same fix, but the bug was planted after this cut-off: not something the list could have known")
60
+ self.assertEqual(evaluate.induced_between(d, commits, "2025-04-15", "2025-09-15"), set(), "the May fix only adds lines: R-SZZ finds nothing")
61
+
62
+ def test_labelled_commits_inside_the_horizon_name_their_source_files_or_the_paths_given(self):
63
+ with tempfile.TemporaryDirectory() as d:
64
+ by = make_repo(d)
65
+ commits = self._log(d)
66
+ labels = {by["plant it"][:9]: None, by["change d, add f, touch g"]: ["core/g.py", "docs/x.md"], by["fix: add z"]: None}
67
+ self.assertEqual(evaluate.labelled_between(commits, labels, "2025-01-15", "2025-02-15"), {"core/f.py"}, "an abbreviated label still matches")
68
+ self.assertEqual(evaluate.labelled_between(commits, labels, "2025-02-15", "2025-03-15"), {"core/g.py", "docs/x.md"}, "the paths the label names")
69
+ self.assertEqual(evaluate.labelled_between(commits, labels, "2025-04-15", "2025-06-15"), {"core/g.py"})
70
+
71
+
42
72
  class Score(unittest.TestCase):
43
73
  def test_the_report_at_t_knows_nothing_after_t(self):
44
74
  r = evaluate.report_at(COMMITS, "2025-06-01", SIZE, {"bots": []}, [], [])
@@ -103,6 +133,7 @@ class Table(unittest.TestCase):
103
133
  "| churn | 1 | 2 | 3 |",
104
134
  "| random (expected) | 0.4 | 0.6 | 1.0 |",
105
135
  ])
136
+ self.assertIn("(3 of 40 bug-inducing)", evaluate.table([("2025-02-28", 3, 40, {"churn": 1})], noun="bug-inducing"))
106
137
 
107
138
 
108
139
  if __name__ == "__main__":
@@ -0,0 +1,99 @@
1
+ import os
2
+ import subprocess
3
+ import tempfile
4
+ import unittest
5
+
6
+ from gitmole import szz
7
+
8
+
9
+ def make_repo(d):
10
+ """A file with a bug planted in February, an unrelated edit in March, the fix in April; a test
11
+ file the fix also touches; and an insert-only fix in May."""
12
+ def git(*args, date):
13
+ env = dict(os.environ, GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null", GIT_AUTHOR_NAME="Ann", GIT_AUTHOR_EMAIL="a@x",
14
+ GIT_COMMITTER_NAME="Ann", GIT_COMMITTER_EMAIL="a@x", GIT_AUTHOR_DATE=f"{date}T00:00:00", GIT_COMMITTER_DATE=f"{date}T00:00:00")
15
+ subprocess.run(["git", *args], cwd=d, check=True, capture_output=True, env=env)
16
+
17
+ def write(path, text):
18
+ os.makedirs(os.path.dirname(os.path.join(d, path)) or d, exist_ok=True)
19
+ with open(os.path.join(d, path), "w") as fh:
20
+ fh.write(text)
21
+ git("init", "-q", "-b", "main", date="2025-01-01")
22
+ write("core/f.py", "a\nb\nc\nd\ne\n")
23
+ write("core/g.py", "x\ny\n")
24
+ write("tests/test_f.py", "t\n")
25
+ git("add", "-A", date="2025-01-01")
26
+ git("commit", "-q", "-m", "one", date="2025-01-01")
27
+ write("core/f.py", "a\nB\nc\nd\ne\n")
28
+ git("commit", "-q", "-am", "plant it", date="2025-02-01")
29
+ write("core/f.py", "a\nB\nc\nD\ne\nf\n")
30
+ write("core/g.py", "x\nY\n")
31
+ git("commit", "-q", "-am", "change d, add f, touch g", date="2025-03-01")
32
+ write("core/f.py", "a\nb\nc\nD\ne\nf\n")
33
+ write("tests/test_f.py", "t\nu\n")
34
+ git("commit", "-q", "-am", "fix: b", date="2025-04-01")
35
+ write("core/g.py", "x\nY\nz\n")
36
+ git("commit", "-q", "-am", "fix: add z", date="2025-05-01")
37
+ return {line.split(" ", 1)[1]: line.split(" ", 1)[0] for line in
38
+ subprocess.run(["git", "log", "--format=%H %s", "HEAD"], cwd=d, capture_output=True, text=True, check=True).stdout.splitlines()
39
+ if " " in line}
40
+
41
+
42
+ class DeletedRanges(unittest.TestCase):
43
+ def test_the_parent_side_lines_a_fix_removed_or_changed_per_modified_file(self):
44
+ with tempfile.TemporaryDirectory() as d:
45
+ by = make_repo(d)
46
+ self.assertEqual(szz.deleted_ranges(d, by["fix: b"]), {"core/f.py": [(2, 1)], "tests/test_f.py": []},
47
+ "line 2 of the parent's f.py was changed; the test file only gained a line")
48
+ self.assertEqual(szz.deleted_ranges(d, by["plant it"]), {"core/f.py": [(2, 1)]})
49
+ self.assertEqual(szz.deleted_ranges(d, by["change d, add f, touch g"]), {"core/f.py": [(4, 1)], "core/g.py": [(2, 1)]}, "an added line has no parent side")
50
+
51
+
52
+ class BugInducing(unittest.TestCase):
53
+ def test_the_most_recent_commit_blamed_for_a_fixs_deleted_lines_with_its_files(self):
54
+ with tempfile.TemporaryDirectory() as d:
55
+ by = make_repo(d)
56
+ found = szz.bug_inducing(d, by["fix: b"])
57
+ self.assertEqual(found, {"commit": by["plant it"], "date": "2025-02-01", "files": ["core/f.py"]},
58
+ "the fix removed line 2, which the February commit wrote; the March commit is not blamed")
59
+ self.assertIsNone(szz.bug_inducing(d, by["fix: add z"]), "an insert-only fix blames nothing: R-SZZ finds nothing for it")
60
+
61
+ def test_files_the_caller_excludes_are_not_candidates(self):
62
+ with tempfile.TemporaryDirectory() as d:
63
+ by = make_repo(d)
64
+ self.assertIsNone(szz.bug_inducing(d, by["fix: b"], exclude=lambda p: p.startswith("core/")))
65
+
66
+ def test_the_most_recent_of_several_candidates_wins(self):
67
+ # R-SZZ: of the commits a fix's deleted lines blame to, only the latest is kept; Rosa et al. measured
68
+ # that rule at precision 0.66 against 0.39 for keeping them all
69
+ with tempfile.TemporaryDirectory() as d:
70
+ by = make_repo(d)
71
+ def git(*args, date):
72
+ env = dict(os.environ, GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null", GIT_AUTHOR_NAME="Ann", GIT_AUTHOR_EMAIL="a@x",
73
+ GIT_COMMITTER_NAME="Ann", GIT_COMMITTER_EMAIL="a@x", GIT_AUTHOR_DATE=f"{date}T00:00:00", GIT_COMMITTER_DATE=f"{date}T00:00:00")
74
+ subprocess.run(["git", *args], cwd=d, check=True, capture_output=True, env=env)
75
+ with open(os.path.join(d, "core/f.py"), "w") as fh:
76
+ fh.write("a\nb\nc\nd\ne\nF\n") # rewrites line 4 (March's D) and line 6 (March's f) and line 1 (January's a)... only b and F
77
+ git("commit", "-q", "-am", "fix: d and f", date="2025-06-01")
78
+ fix = subprocess.run(["git", "rev-parse", "HEAD"], cwd=d, capture_output=True, text=True).stdout.strip()
79
+ found = szz.bug_inducing(d, fix)
80
+ self.assertEqual((found["commit"], found["date"], found["files"]), (by["change d, add f, touch g"], "2025-03-01", ["core/f.py"]))
81
+
82
+
83
+ class Labels(unittest.TestCase):
84
+ def test_a_csv_of_buggy_commits_with_or_without_paths(self):
85
+ with tempfile.TemporaryDirectory() as d:
86
+ path = os.path.join(d, "labels.csv")
87
+ with open(path, "w") as fh:
88
+ fh.write("commit_id,project,buggy\nabc1234,x,True\ndef5678,x,False\n0123abc,x,1\n")
89
+ self.assertEqual(szz.read_labels(path), {"abc1234": None, "0123abc": None}, "ApacheJIT's shape: a buggy flag per commit")
90
+ with open(path, "w") as fh:
91
+ fh.write("commit,path\nabc1234,src/a.py\nabc1234,src/b.py\n")
92
+ self.assertEqual(szz.read_labels(path), {"abc1234": ["src/a.py", "src/b.py"]}, "Defectors' shape: the files a buggy commit made buggy")
93
+ with open(path, "w") as fh:
94
+ fh.write("abc1234\n0123abc\n")
95
+ self.assertEqual(szz.read_labels(path), {"abc1234": None, "0123abc": None}, "a bare list of hashes")
96
+
97
+
98
+ if __name__ == "__main__":
99
+ unittest.main()
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
File without changes