bug-age 2.0.0__py3-none-any.whl

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.
bug_age/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ # SPDX-License-Identifier: MIT OR Apache-2.0
2
+ """bug-age: measure how long bugs lived in a git repository, using only repository data.
3
+
4
+ bug-age does not discover new bugs. It starts from bugs the project already fixed:
5
+
6
+ 1. `catalog` lists the commits whose subject says they fix something (rule-based, auditable).
7
+ 2. `szz` (Method A) blames the lines each fix changed to nominate the commit that introduced the
8
+ bug, then measures the time, commits, releases and churn in between.
9
+ 3. `sweep` (Method B) replays a regression test for one bug across history to find the first
10
+ commit where the bug's behaviour appears.
11
+ 4. `bundle` assembles everything into one blameless, schema-checked JSON file.
12
+ """
13
+ __version__ = "2.0.0"
bug_age/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: MIT OR Apache-2.0
2
+ """`python -m bug_age`: the same command line as the installed `bug-age` command."""
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
bug_age/_compat.py ADDED
@@ -0,0 +1,155 @@
1
+ # SPDX-License-Identifier: MIT OR Apache-2.0
2
+ """Exact text and number behaviour that bug-age's published results depend on.
3
+
4
+ bug-age 1.x was written in Rust; its outputs are the reference for every number this package
5
+ writes. Python differs from Rust in a few places that change bytes, so this module pins them:
6
+
7
+ - JSON text: object keys sorted, compact or two-space pretty, and floats written the way
8
+ serde_json writes them (shortest round-trip digits, `1e+16`, `1e-7`, `12.0`).
9
+ - Rounding: halfway cases away from zero (Rust's `f64::round`), not to even (Python's `round`).
10
+ - Whitespace: Rust's `trim` and `split_whitespace` use Unicode White_Space, which excludes the
11
+ separators 0x1C-0x1F that Python's `str.strip` and `str.split` also remove.
12
+ - Lines: Rust's `lines` splits on `\\n` only and drops one trailing `\\r`; `str.splitlines` also
13
+ splits on 0x1C-0x1E, 0x85 and U+2028/U+2029.
14
+ """
15
+ import json
16
+ import math
17
+
18
+ # Unicode White_Space, as Rust's char::is_whitespace defines it.
19
+ WHITESPACE = ("\t\n\x0b\x0c\r \x85\xa0        "
20
+ "    

   ")
21
+
22
+
23
+ def trim(text):
24
+ """Rust's str::trim: strip Unicode White_Space from both ends."""
25
+ return text.strip(WHITESPACE)
26
+
27
+
28
+ def split_whitespace(text):
29
+ """Rust's str::split_whitespace: split on runs of Unicode White_Space, no empty parts."""
30
+ out, word = [], []
31
+ for ch in text:
32
+ if ch in WHITESPACE:
33
+ if word:
34
+ out.append("".join(word))
35
+ word = []
36
+ else:
37
+ word.append(ch)
38
+ if word:
39
+ out.append("".join(word))
40
+ return out
41
+
42
+
43
+ def lines(text):
44
+ """Rust's str::lines: split on LF, drop one trailing CR per line, no final empty line."""
45
+ parts = text.split("\n")
46
+ if parts and parts[-1] == "":
47
+ parts.pop()
48
+ return [p[:-1] if p.endswith("\r") else p for p in parts]
49
+
50
+
51
+ def round_half_away(x):
52
+ """Rust's f64::round: the nearest integer, halfway cases away from zero."""
53
+ if not math.isfinite(x):
54
+ return x
55
+ t = math.trunc(x)
56
+ if abs(x - t) >= 0.5:
57
+ t += math.copysign(1.0, x)
58
+ return math.copysign(float(t), x)
59
+
60
+
61
+ def total_order(x):
62
+ """Sort key matching Rust's f64::total_cmp for numbers: -0.0 sorts before 0.0."""
63
+ return (x, math.copysign(1.0, x))
64
+
65
+
66
+ def round1(x):
67
+ """`(x * 10.0).round() / 10.0`: one decimal place, as bug-age reports days."""
68
+ return round_half_away(x * 10.0) / 10.0
69
+
70
+
71
+ def fmt_float(x):
72
+ """serde_json's text for an f64; NaN and the infinities are written as null."""
73
+ if not math.isfinite(x):
74
+ return "null"
75
+ if x == 0:
76
+ return "-0.0" if math.copysign(1.0, x) < 0 else "0.0"
77
+ # repr gives the shortest digits that round-trip; only the layout differs from serde_json.
78
+ mant, _, exp = repr(abs(x)).partition("e")
79
+ ip, _, fp = mant.partition(".")
80
+ digits = ip + fp
81
+ point = len(ip) + (int(exp) if exp else 0)
82
+ stripped = digits.lstrip("0")
83
+ point -= len(digits) - len(stripped)
84
+ digits = stripped.rstrip("0") or "0"
85
+ n, kk = len(digits), point
86
+ if 0 <= kk - n and kk <= 16:
87
+ out = digits + "0" * (kk - n) + ".0"
88
+ elif 0 < kk <= 16:
89
+ out = digits[:kk] + "." + digits[kk:]
90
+ elif -5 < kk <= 0:
91
+ out = "0." + "0" * (-kk) + digits
92
+ else:
93
+ ex = kk - 1
94
+ out = (digits if n == 1 else digits[0] + "." + digits[1:]) + "e" + ("+" if ex > 0 else "") + str(ex)
95
+ return ("-" if x < 0 else "") + out
96
+
97
+
98
+ def _string(s):
99
+ """A JSON string as serde_json writes it: only quote, backslash and C0 controls escaped."""
100
+ return json.dumps(s, ensure_ascii=False)
101
+
102
+
103
+ def dumps(value, pretty=False, _indent=0):
104
+ """serde_json text of `value`: keys sorted; `pretty` is to_string_pretty (two spaces)."""
105
+ if value is None:
106
+ return "null"
107
+ if value is True:
108
+ return "true"
109
+ if value is False:
110
+ return "false"
111
+ if isinstance(value, int):
112
+ return str(value)
113
+ if isinstance(value, float):
114
+ return fmt_float(value)
115
+ if isinstance(value, str):
116
+ return _string(value)
117
+ if isinstance(value, dict):
118
+ items = sorted(value.items())
119
+ if not items:
120
+ return "{}"
121
+ if not pretty:
122
+ return "{" + ",".join(_string(k) + ":" + dumps(v) for k, v in items) + "}"
123
+ pad = " " * (_indent + 1)
124
+ body = ",\n".join(pad + _string(k) + ": " + dumps(v, True, _indent + 1) for k, v in items)
125
+ return "{\n" + body + "\n" + " " * _indent + "}"
126
+ if isinstance(value, (list, tuple)):
127
+ if not value:
128
+ return "[]"
129
+ if not pretty:
130
+ return "[" + ",".join(dumps(v) for v in value) + "]"
131
+ pad = " " * (_indent + 1)
132
+ return "[\n" + ",\n".join(pad + dumps(v, True, _indent + 1) for v in value) + "\n" + " " * _indent + "]"
133
+ raise TypeError(f"cannot write {type(value).__name__} as JSON")
134
+
135
+
136
+ def loads(text):
137
+ """Parse JSON; integers stay int and numbers with a fraction or exponent become float, as in serde_json."""
138
+ return json.loads(text)
139
+
140
+
141
+ def as_f64(value):
142
+ """serde_json's Value::as_f64: any JSON number as a float, anything else None."""
143
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
144
+ return None
145
+ return float(value)
146
+
147
+
148
+ def get(value, *keys):
149
+ """Index like serde_json's Value[...]: a missing key or index, or a non-container, gives None."""
150
+ for k in keys:
151
+ if isinstance(k, int):
152
+ value = value[k] if isinstance(value, list) and 0 <= k < len(value) else None
153
+ else:
154
+ value = value.get(k) if isinstance(value, dict) else None
155
+ return value
bug_age/bundle.py ADDED
@@ -0,0 +1,381 @@
1
+ # SPDX-License-Identifier: MIT OR Apache-2.0
2
+ """Bundle builder.
3
+
4
+ Canonical data bundle: every number the explorer page and the article use, assembled from script
5
+ outputs only (catalog, Method A, Method B sweeps, A-checks, feasibility runs).
6
+
7
+ Blameless by construction: commits carry SHA, date and a short subject; every author and committer
8
+ name/email in the repository history is redacted from subjects, and a gate scans every string in
9
+ the bundle for emails, @handles and those names. A hit fails the build.
10
+ """
11
+ import os
12
+ import re
13
+ from pathlib import Path
14
+
15
+ from . import __version__
16
+ from ._compat import as_f64, dumps, get, lines, loads, round1, total_order, trim
17
+ from .catalog import sanitize_subject
18
+ from .git import commit_time, git, is_ancestor
19
+
20
+ SCHEMA_VERSION = "1.1.0"
21
+ REDACTED = "[redacted]"
22
+ _PLAIN = re.compile(r"[A-Za-z0-9_./:=,@+\-~]+")
23
+
24
+
25
+ class BundleError(RuntimeError):
26
+ """The bundle cannot be built, or failed its blameless gate."""
27
+
28
+
29
+ def public_command(args, cwd, home):
30
+ """The command line as it may be published: paths under cwd relative, under home as ~, shell-quoted."""
31
+ def under(a, root):
32
+ root = root.rstrip("/")
33
+ if not root:
34
+ return None
35
+ if a == root:
36
+ return ""
37
+ if a.startswith(root + "/"):
38
+ return a[len(root) + 1:]
39
+ return None
40
+ out = []
41
+ for a in args:
42
+ rel, hrel = under(a, cwd), under(a, home)
43
+ if rel is not None:
44
+ a = rel or "."
45
+ elif hrel is not None:
46
+ a = f"~/{hrel}" if hrel else "~"
47
+ out.append(a if _PLAIN.fullmatch(a) else "'" + a.replace("'", "'\\''") + "'")
48
+ return " ".join(out)
49
+
50
+
51
+ def people(repo):
52
+ """Every author/committer name and email in the history, lowercased, longest first."""
53
+ log = git(repo, ["log", "--all", "--format=%an%n%ae%n%cn%n%ce"])
54
+ found = set()
55
+ for line in (trim(l) for l in lines(log)):
56
+ # Lengths are UTF-8 bytes, as Rust measures them.
57
+ if len(line.encode()) < 3:
58
+ continue
59
+ found.add(line.lower())
60
+ local, at, _ = line.partition("@")
61
+ if at:
62
+ user = local.rsplit("+", 1)[-1]
63
+ if len(user.encode()) >= 3:
64
+ found.add(user.lower())
65
+ for generic in ("github", "noreply", "web-flow", "users.noreply.github.com"):
66
+ found.discard(generic)
67
+ # Byte order first (as a BTreeSet), then longest first, so full names win over their parts.
68
+ ordered = sorted(found, key=lambda s: s.encode())
69
+ return sorted(ordered, key=lambda s: -len(s.encode()))
70
+
71
+
72
+ class Identities:
73
+ """Name, email and @handle patterns for redaction and for the blameless gate."""
74
+
75
+ def __init__(self, names):
76
+ # Rust's `$` matches only at the very end; Python's also before a final newline, hence \Z.
77
+ alternation = [re.escape(p) for p in names]
78
+ self.names = re.compile(r"(?i)(^|[^\w])(" + "|".join(alternation) + r")(\Z|[^\w])") if alternation else None
79
+ self.email = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
80
+ self.handle = re.compile(r"(^|\s)@[A-Za-z0-9][A-Za-z0-9_-]+")
81
+
82
+ def redact(self, text):
83
+ """Replace every known name in `text`, repeating until none is left (matches can overlap)."""
84
+ if self.names is None:
85
+ return text
86
+ while self.names.search(text):
87
+ text = self.names.sub(lambda m: m.group(1) + REDACTED + m.group(3), text)
88
+ return text
89
+
90
+ def hit(self, s):
91
+ """A description of the first identity found in `s`, or None."""
92
+ if self.email.search(s) or self.handle.search(s):
93
+ return f"email/handle in {s!r}"
94
+ m = self.names.search(s) if self.names else None
95
+ return f"person {m.group(2)!r} in {s!r}" if m else None
96
+
97
+
98
+ def scan(value, ids):
99
+ """Every identity hit in every string of `value`."""
100
+ hits, stack = [], [value]
101
+ while stack:
102
+ x = stack.pop()
103
+ if isinstance(x, str):
104
+ h = ids.hit(x)
105
+ if h:
106
+ hits.append(h)
107
+ elif isinstance(x, list):
108
+ stack.extend(x)
109
+ elif isinstance(x, dict):
110
+ stack.extend(x.values())
111
+ return hits
112
+
113
+
114
+ def _read(path):
115
+ """A JSON file as a value."""
116
+ try:
117
+ return loads(Path(path).read_text(encoding="utf-8"))
118
+ except (OSError, ValueError) as exc:
119
+ raise BundleError(f"{path}: {exc}") from exc
120
+
121
+
122
+ def _pct(v, q):
123
+ """Value at rank floor(q * n) of sorted `v`, capped at the last."""
124
+ return v[min(int(q * float(len(v))), len(v) - 1)]
125
+
126
+
127
+ def _median(v):
128
+ """Median of sorted `v`; the mean of the middle two for an even count."""
129
+ n = len(v)
130
+ return v[n // 2] if n % 2 == 1 else (v[n // 2 - 1] + v[n // 2]) / 2.0
131
+
132
+
133
+ def method_a_days(bug):
134
+ """Committer days of a bug, or author days when a rebase compressed committer dates."""
135
+ life = bug.get("lifetime")
136
+ flags = bug.get("flags")
137
+ if not isinstance(life, dict) or not isinstance(flags, list):
138
+ return None
139
+ compressed = any(isinstance(f, str) and f.startswith("committer-dates-compressed") for f in flags)
140
+ return as_f64(life.get("calendar_days_author" if compressed else "calendar_days"))
141
+
142
+
143
+ def _stat(v):
144
+ """Median, p90, max and count of sorted floats, or None when empty."""
145
+ if not v:
146
+ return None
147
+ return {"median": _median(v), "p90": _pct(v, 0.9), "max": v[-1], "n": len(v)}
148
+
149
+
150
+ def method_a_summary(szz):
151
+ """Headline Method A statistics over every bug with a lifetime."""
152
+ bugs = get(szz, "bugs") if isinstance(get(szz, "bugs"), list) else []
153
+ days = sorted((d for d in map(method_a_days, bugs) if d is not None), key=total_order)
154
+ commits = sorted((c for c in (as_f64(get(b, "lifetime", "commits_elapsed")) for b in bugs) if c is not None),
155
+ key=total_order)
156
+ releases = sorted((float(len(r)) for r in (get(b, "lifetime", "releases_spanned") for b in bugs)
157
+ if isinstance(r, list)), key=total_order)
158
+ return {
159
+ "fixes": len(bugs),
160
+ "with_introducer": len(days),
161
+ "in_window": get(szz, "summary", "in_window"),
162
+ "window_start": get(szz, "summary", "window_start"),
163
+ "calendar_days": _stat(days),
164
+ "commits_elapsed": _stat(commits),
165
+ "releases_shipped_bug": _stat(releases),
166
+ }
167
+
168
+
169
+ def acheck_label(outcomes):
170
+ """What the A-check (test at the nominee's parent and at the nominee) says about the nominee."""
171
+ return {("PASS", "FAIL"): "confirmed", ("FAIL", "FAIL"): "too-late", ("PASS", "PASS"): "refuted",
172
+ ("UNTESTABLE", "FAIL"): "plausible", ("UNTESTABLE", "UNTESTABLE"): "untested"}.get(tuple(outcomes), "inconclusive")
173
+
174
+
175
+ def _days_between(repo, a, b):
176
+ """Committer-time difference from `a` to `b`, in days rounded to one decimal."""
177
+ return round1((commit_time(repo, b) - commit_time(repo, a)) / 86400.0)
178
+
179
+
180
+ def _items(value):
181
+ """The elements of a JSON array, or none."""
182
+ return value if isinstance(value, list) else []
183
+
184
+
185
+ def _read_text_or_empty(path):
186
+ """A UTF-8 file's text, or "" when it is missing or not UTF-8 (as Rust's read_to_string().unwrap_or_default())."""
187
+ try:
188
+ return Path(path).read_text(encoding="utf-8")
189
+ except (OSError, UnicodeDecodeError):
190
+ return ""
191
+
192
+
193
+ def method_b(repo, d, szz):
194
+ """One entry per recorded sweep in `d` (sorted by directory name), with its A-check."""
195
+ out = []
196
+ d = Path(d)
197
+ if not d.is_dir():
198
+ return out # no repros for this target: Method B is reported NOT TESTED
199
+ for name in sorted(os.listdir(d), key=lambda n: os.fsencode(n)):
200
+ entry = d / name
201
+ if not (entry / "sweep.json").is_file():
202
+ continue
203
+ s = _read(entry / "sweep.json")
204
+ fix = get(s, "fix") if isinstance(get(s, "fix"), str) else ""
205
+ a_bug = next((b for b in _items(get(szz, "bugs")) if get(b, "fix") == fix), None)
206
+ nominee = get(a_bug, "nominees", 0, "sha") if a_bug is not None else None
207
+ nominee = nominee if isinstance(nominee, str) else None
208
+ verdict = get(s, "verdict") if isinstance(get(s, "verdict"), str) else ""
209
+ first = get(s, "first_failing") if isinstance(get(s, "first_failing"), str) else None
210
+ b_days = _days_between(repo, first, fix) if first is not None else None
211
+ if (entry / "acheck.json").is_file():
212
+ a = _read(entry / "acheck.json")
213
+ outs = [o for o in (get(r, "outcome") for r in _items(get(a, "results"))) if isinstance(o, str)]
214
+ acheck = {"outcomes": outs, "label": acheck_label(outs)}
215
+ else:
216
+ acheck = {"outcomes": [], "label": "not-run"}
217
+ if nominee is not None:
218
+ # A pass after the nominee, on its own line of history, means the nominee did not break the test.
219
+ passes_after = [r["sha"] for r in _items(get(s, "results"))
220
+ if get(r, "outcome") == "PASS" and get(r, "sha") != fix and get(r, "sha") != nominee
221
+ and isinstance(get(r, "sha"), str)]
222
+ passes_after = [sha for sha in passes_after if is_ancestor(repo, nominee, sha)]
223
+ if passes_after:
224
+ acheck["label"] = "refuted"
225
+ acheck["evidence"] = f"test passes at {passes_after[-1][:12]} after the nominee"
226
+ results = [{"sha": get(r, "sha"), "outcome": get(r, "outcome"), "reason": get(r, "reason"),
227
+ "toolchain": get(r, "toolchain"),
228
+ "steps": [{"name": get(st, "name"), "exit": get(st, "exit"), "seconds": get(st, "seconds"),
229
+ "network": get(st, "network")} for st in _items(get(r, "steps"))]}
230
+ for r in _items(get(s, "results"))]
231
+ out.append({
232
+ "id": name,
233
+ "fix": fix,
234
+ "verdict": verdict,
235
+ "reason": get(s, "reason"),
236
+ "first_failing": first,
237
+ "last_passing": get(s, "last_passing"),
238
+ "days": b_days,
239
+ "days_is_lower_bound": verdict != "located",
240
+ "untestable_between": get(s, "untestable_between"),
241
+ "non_monotonic": get(s, "non_monotonic"),
242
+ "range_start": get(s, "start"),
243
+ "range_commits": get(s, "range_commits"),
244
+ "commits_tested": get(s, "commits_tested"),
245
+ "repeats": get(s, "repeats"),
246
+ "test_command": get(s, "test"),
247
+ "method_a_nominee": nominee,
248
+ "method_a_days": method_a_days(a_bug) if a_bug is not None else None,
249
+ "a_check": acheck,
250
+ "results": results,
251
+ "repro_test": _read_text_or_empty(entry / "overlay" / "tests" / "bugage_repro.rs"),
252
+ })
253
+ return out
254
+
255
+
256
+ def feasibility(path):
257
+ """The fields of a feasibility report the bundle publishes, or None when it was not run."""
258
+ if not Path(path).is_file():
259
+ return None
260
+ r = _read(path)
261
+ return {
262
+ "verdict": get(r, "verdict"),
263
+ "built_and_tested_share": get(r, "built_and_tested_share"),
264
+ "counts": get(r, "counts"),
265
+ "method": get(r, "method"),
266
+ "samples": [{"sha": get(s, "sha"), "date": get(s, "date"), "toolchain": get(s, "toolchain"),
267
+ "status": get(s, "status"), "reason": get(s, "reason"),
268
+ "lockfile_committed": get(s, "lockfile_committed")} for s in _items(get(r, "results"))],
269
+ }
270
+
271
+
272
+ def coverage(a, b, feas, tt, out_of_scope):
273
+ """What each stage proves and does not prove: COVERED, PARTIAL or NOT TESTED."""
274
+ swept = len(b)
275
+ fixes = a["fixes"] if isinstance(a["fixes"], int) else 0
276
+
277
+ def share(f):
278
+ # Rust formats the JSON value itself, so a number prints as JSON and a missing one as null.
279
+ return "not run" if f is None else dumps(get(f, "built_and_tested_share"))
280
+
281
+ if feas is None and tt is None:
282
+ buildability = {"stage": "Historical buildability", "status": "NOT TESTED",
283
+ "evidence": "no feasibility report", "proves": "nothing",
284
+ "does_not_prove": "that any historical commit builds"}
285
+ else:
286
+ first = next(f for f in (feas, tt) if f is not None)
287
+ samples = len(first["samples"]) if isinstance(get(first, "samples"), list) else 0
288
+ buildability = {"stage": "Historical buildability", "status": "COVERED",
289
+ "evidence": f"{samples} sampled commits; {share(feas)} with today's registry, "
290
+ f"{share(tt)} with time-travel dependencies",
291
+ "proves": "sampled commits build and pass their unit tests offline in a date-matched toolchain",
292
+ "does_not_prove": "that every commit builds; integration tests needing downloaded fixtures were not run"}
293
+ if swept == 0:
294
+ mb = {"stage": "Method B (behavioural sweep)", "status": "NOT TESTED",
295
+ "evidence": f"0 of {fixes} fixes have a regression test", "proves": "nothing",
296
+ "does_not_prove": "any bug's behavioural lifetime"}
297
+ else:
298
+ mb = {"stage": "Method B (behavioural sweep)", "status": "PARTIAL",
299
+ "evidence": f"{swept} of {fixes} fixes have a regression test and a sweep",
300
+ "proves": "where each tested bug's regression test starts failing, or a lower bound when censored",
301
+ "does_not_prove": "anything about the fixes without a regression test (NOT TESTED)"}
302
+ refuted = any(get(s, "a_check", "label") == "refuted" for s in b)
303
+ rows = [buildability,
304
+ {"stage": "Method A (SZZ)", "status": "PARTIAL",
305
+ "evidence": f"{fixes} fixes from a rule-based catalog, {dumps(a['with_introducer'])} with a nominated introducer",
306
+ "proves": "which earlier commit last touched the lines each fix changed",
307
+ "does_not_prove": ("that the nominee introduced the bug (see the refuted case in Method B); fixes missed by the subject rules"
308
+ if refuted else "that the nominee introduced the bug; fixes missed by the subject rules")},
309
+ mb]
310
+ if out_of_scope is not None:
311
+ stage, evidence, not_proved = out_of_scope
312
+ rows.append({"stage": stage, "status": "NOT TESTED", "evidence": evidence, "proves": "nothing",
313
+ "does_not_prove": not_proved})
314
+ return rows
315
+
316
+
317
+ def build(repo, results, spike, repo_url, out_of_scope):
318
+ """Assemble the bundle for one target; raises BundleError if the blameless gate finds anyone."""
319
+ results, spike = Path(results), Path(spike)
320
+ catalog = _read(results / "catalog.json")
321
+ szz = _read(results / "szz.json")
322
+ feas = feasibility(spike / "results" / "report.json")
323
+ tt = feasibility(spike / "results-timetravel" / "report.json")
324
+ a_summary = method_a_summary(szz)
325
+ b = method_b(repo, results / "repros", szz)
326
+ ids = Identities(people(repo))
327
+ shas = set()
328
+
329
+ def add(v):
330
+ if isinstance(v, str) and len(v.encode()) == 40:
331
+ shas.add(v)
332
+ for bug in _items(get(szz, "bugs")):
333
+ add(get(bug, "fix"))
334
+ for n in _items(get(bug, "nominees")):
335
+ add(get(n, "sha"))
336
+ for s in b:
337
+ for k in ("fix", "first_failing", "last_passing", "range_start", "method_a_nominee"):
338
+ add(s[k])
339
+ for r in s["results"]:
340
+ add(r["sha"])
341
+ for f in (feas, tt):
342
+ for s in _items(get(f, "samples")):
343
+ add(get(s, "sha"))
344
+ commits = {}
345
+ for sha in sorted(shas, key=lambda s: s.encode()):
346
+ line = git(repo, ["show", "-s", "--format=%cs%x1f%s", sha])
347
+ date, sep, subject = trim(line).partition("\x1f")
348
+ if not sep:
349
+ date, subject = "", ""
350
+ commits[sha] = {"date": date, "subject": ids.redact(sanitize_subject(subject))}
351
+ method_a = []
352
+ for bug in _items(get(szz, "bugs")):
353
+ row = dict(bug)
354
+ row.pop("subject", None)
355
+ row["days"] = method_a_days(bug)
356
+ method_a.append(row)
357
+ verdicts = {}
358
+ for s in b:
359
+ verdicts[s["verdict"]] = verdicts.get(s["verdict"], 0) + 1
360
+ bundle = {
361
+ "schema_version": SCHEMA_VERSION,
362
+ "generator": f"bug-age {__version__}",
363
+ "subject": {"repo_url": repo_url, "crate_path": get(szz, "path"), "head": get(szz, "head")},
364
+ "summary": {"method_a": a_summary,
365
+ "method_b": {"swept": len(b), "located": sum(1 for s in b if s["verdict"] == "located"),
366
+ "a_refuted": sum(1 for s in b if s["a_check"]["label"] == "refuted"),
367
+ "verdicts": verdicts},
368
+ "feasibility": {"baseline_share": get(feas, "built_and_tested_share"),
369
+ "time_travel_share": get(tt, "built_and_tested_share")}},
370
+ "catalog": {"include_rule": get(catalog, "include_rule"), "scanned": get(catalog, "scanned"),
371
+ "excluded": [{"sha": get(e, "sha"), "rule": get(e, "rule")} for e in _items(get(catalog, "excluded"))]},
372
+ "method_a": method_a,
373
+ "method_b": b,
374
+ "feasibility": {"baseline": feas, "time_travel": tt},
375
+ "coverage": coverage(a_summary, b, feas, tt, out_of_scope),
376
+ "commits": commits,
377
+ }
378
+ hits = scan(bundle, ids)
379
+ if hits:
380
+ raise BundleError(f"blameless gate failed ({len(hits)} hits), first: {hits[0]}")
381
+ return bundle
bug_age/catalog.py ADDED
@@ -0,0 +1,68 @@
1
+ # SPDX-License-Identifier: MIT OR Apache-2.0
2
+ """Fix-commit catalog.
3
+
4
+ This is where bug-age decides which commits are bug fixes. It does not test anything: it reads
5
+ commit subjects. A commit is a fix when it is on the first-parent history, touches the path, its
6
+ subject matches INCLUDE, and it matches none of the EXCLUDE rules. Bugs that were never fixed, or
7
+ were fixed under a subject without these words, are not in the catalog. Every entry records the
8
+ rule that admitted or rejected it, so the catalog can be audited and re-derived.
9
+ """
10
+ import re
11
+
12
+ from ._compat import split_whitespace, trim
13
+ from .git import git
14
+
15
+ # A subject is a fix candidate when it says fix/fixes/fixed/fixing, bug(s) or hotfix as a word.
16
+ INCLUDE = r"(?i)\b(fix(e[sd]|ing)?|bugs?|hotfix)\b"
17
+
18
+ # (rule name, pattern). The first matching rule excludes the commit.
19
+ EXCLUDE = (
20
+ ("typo", r"(?i)\btypos?\b"),
21
+ ("lint-or-format", r"(?i)\b(warnings?|clippy|lint(s|ing)?|fmt|rustfmt|format(ting)?)\b"),
22
+ ("docs", r"(?i)\b(docs?|readme|documentation|docstrings?|comments?)\b"),
23
+ ("ci", r"(?i)\b(ci|workflows?)\b"),
24
+ ("release", r"(?i)\b(release|bump|version)\b"),
25
+ ("deprecation", r"(?i)\bdeprecat"),
26
+ ("build-only", r"(?i)\b(compile errors?|builds?|compilation)\b"),
27
+ ("feature-name", r"(?i)\bfixed[- ]length\b"),
28
+ )
29
+
30
+ _INCLUDE = re.compile(INCLUDE)
31
+ _EXCLUDE = tuple((name, re.compile(pat)) for name, pat in EXCLUDE)
32
+ _FROM = re.compile(r"\s+from\s+\S+/\S+")
33
+ _HANDLE = re.compile(r"@[A-Za-z0-9][A-Za-z0-9_-]*")
34
+
35
+
36
+ def sanitize_subject(subject):
37
+ """Subject without merge-source `from owner/branch` and @handles, trimmed, at most 100 characters."""
38
+ return trim(_HANDLE.sub("", _FROM.sub("", subject)))[:100]
39
+
40
+
41
+ def classify(subject):
42
+ """None if the subject is not a fix candidate; else "include" or "exclude:<rule>"."""
43
+ if not _INCLUDE.search(subject):
44
+ return None
45
+ for name, pat in _EXCLUDE:
46
+ if pat.search(subject):
47
+ return f"exclude:{name}"
48
+ return "include"
49
+
50
+
51
+ def build(repo, head, path):
52
+ """Scan first-parent history of `head` touching `path`; return the catalog as a dict."""
53
+ log = git(repo, ["log", "--first-parent", "--format=%H%x1f%P%x1f%cs%x1f%s%x1e", head, "--", path])
54
+ fixes, excluded, scanned = [], [], 0
55
+ for rec in (trim(r) for r in log.split("\x1e")):
56
+ if not rec:
57
+ continue
58
+ scanned += 1
59
+ f = rec.split("\x1f")
60
+ rule = classify(f[3])
61
+ if rule is None:
62
+ continue
63
+ entry = {"sha": f[0], "parents": split_whitespace(f[1]), "date": f[2],
64
+ "subject": sanitize_subject(f[3]), "rule": rule}
65
+ (fixes if rule == "include" else excluded).append(entry)
66
+ head = trim(git(repo, ["rev-parse", head]))
67
+ return {"head": head, "path": path, "scanned": scanned, "include_rule": INCLUDE,
68
+ "fixes": fixes, "excluded": excluded}