gitmole 0.12.0__tar.gz → 0.14.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gitmole-0.12.0 → gitmole-0.14.0}/PKG-INFO +1 -1
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/__init__.py +1 -1
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/cli.py +46 -8
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/evaluate.py +57 -7
- gitmole-0.14.0/gitmole/hook.py +99 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/load.py +2 -1
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/maat.py +32 -1
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/render.py +15 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/run.py +25 -0
- gitmole-0.14.0/gitmole/szz.py +135 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/textfmt.py +5 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/watch.py +73 -7
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole.egg-info/PKG-INFO +1 -1
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole.egg-info/SOURCES.txt +4 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_evaluate.py +35 -2
- gitmole-0.14.0/tests/test_hook.py +108 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_maat.py +29 -2
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_render.py +5 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_run.py +25 -0
- gitmole-0.14.0/tests/test_szz.py +99 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_watch.py +50 -1
- {gitmole-0.12.0 → gitmole-0.14.0}/LICENSE +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/README.md +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/__main__.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/backtest.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/banner.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/blame.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/classify.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/clean.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/compare.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/coupling.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/deps.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/duplicates.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/filetypes.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/findings.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/functions.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/hotspots.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/identity.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/knowledge.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/leaks.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/loss.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/trend.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole.egg-info/dependency_links.txt +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole.egg-info/entry_points.txt +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole.egg-info/requires.txt +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/gitmole.egg-info/top_level.txt +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/pyproject.toml +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/setup.cfg +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_backtest.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_banner.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_blame.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_classify.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_clean.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_cli.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_compare.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_coupling.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_deps.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_duplicates.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_filetypes.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_findings.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_functions.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_golden.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_hotspots.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_identity.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_knowledge.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_leaks.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_load.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_loss.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_packaging.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_render_examples.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_textfmt.py +0 -0
- {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_trend.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitmole
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.0
|
|
4
4
|
Summary: Offline git repository analysis with a terminal report: hotspots, coupling, ownership, code age, secrets, repo health.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/antvinni/gitmole
|
|
@@ -45,8 +45,16 @@ def parse_args(argv):
|
|
|
45
45
|
p.add_argument("--risk", metavar="BASE", help="score the files changed since BASE (merge base with HEAD) by their share of the repository's revisions × lines of code; needs a local path")
|
|
46
46
|
p.add_argument("--risk-threshold", type=float, metavar="N", help="with --risk: exit 3 when the changed files hold more than N percent of the repository's revisions × lines of code")
|
|
47
47
|
p.add_argument("--compare", metavar="BEFORE_JSON", help="add a 'Since last report' section against an earlier --json export of the same clone")
|
|
48
|
+
p.add_argument("--hook", action="store_true", help="with --no-run: read an agent hook's JSON on stdin (or files after --), score the files it names like --risk, "
|
|
49
|
+
"print a summary the agent reads back, exit 2 when --risk-threshold is exceeded")
|
|
48
50
|
p.add_argument("--version", action="version", version=f"gitmole {__version__}")
|
|
49
|
-
|
|
51
|
+
# --hook takes the files to score after --, pre-commit's way. Split them off here: Python 3.9's argparse
|
|
52
|
+
# cannot give a second positional a value once optionals sit between it and the first.
|
|
53
|
+
argv = list(argv)
|
|
54
|
+
files = argv[argv.index("--") + 1:] if "--" in argv else []
|
|
55
|
+
args = p.parse_args(argv[:argv.index("--")] if "--" in argv else argv)
|
|
56
|
+
args.files = files
|
|
57
|
+
return args
|
|
50
58
|
|
|
51
59
|
|
|
52
60
|
_control = run.Control()
|
|
@@ -58,7 +66,7 @@ def interrupt(*_):
|
|
|
58
66
|
|
|
59
67
|
|
|
60
68
|
def main(argv=None, console: Console = None, tool_check=run.missing_tools, planner=run.plan, estimator=run.estimate_blames,
|
|
61
|
-
lister=run.list_repos, cloner=run.clone, lizard_check=run.has_lizard, ask=None) -> int:
|
|
69
|
+
lister=run.list_repos, cloner=run.clone, lizard_check=run.has_lizard, ask=None, stdin=None) -> int:
|
|
62
70
|
global _control
|
|
63
71
|
_control = run.Control()
|
|
64
72
|
if threading.current_thread() is threading.main_thread():
|
|
@@ -80,7 +88,7 @@ def main(argv=None, console: Console = None, tool_check=run.missing_tools, plann
|
|
|
80
88
|
return rc
|
|
81
89
|
|
|
82
90
|
if args.no_run:
|
|
83
|
-
return _no_run(args, console, ui, err)
|
|
91
|
+
return _no_run(args, console, ui, err, stdin)
|
|
84
92
|
|
|
85
93
|
try:
|
|
86
94
|
kind, target = run.classify_target(args.target)
|
|
@@ -128,7 +136,8 @@ def _check_args(args, err, kind=None) -> int | None:
|
|
|
128
136
|
if kind is None:
|
|
129
137
|
bad = ("--yes needs --clean" if args.yes and not args.clean else
|
|
130
138
|
"target required" if args.target is None and not args.clean else
|
|
131
|
-
"--
|
|
139
|
+
"--hook needs --no-run and an output directory" if args.hook and not args.no_run else
|
|
140
|
+
"--risk-threshold needs --risk" if args.risk_threshold is not None and not args.risk and not args.hook else
|
|
132
141
|
"--compare: no such file: " + args.compare if args.compare and not os.path.isfile(args.compare) else None)
|
|
133
142
|
elif kind == "path":
|
|
134
143
|
bad = None
|
|
@@ -164,8 +173,9 @@ def _resolve_time(args, err, ui):
|
|
|
164
173
|
return None, now
|
|
165
174
|
|
|
166
175
|
|
|
167
|
-
def _no_run(args, console, ui, err) -> int:
|
|
168
|
-
"""Handle --no-run: re-render an existing output directory instead of running the pipeline
|
|
176
|
+
def _no_run(args, console, ui, err, stdin=None) -> int:
|
|
177
|
+
"""Handle --no-run: re-render an existing output directory instead of running the pipeline, or,
|
|
178
|
+
with --hook, score the files an agent's hook names against it."""
|
|
169
179
|
if args.since:
|
|
170
180
|
err.print("[red]--since needs a run:[/red] a re-render cannot narrow an earlier analysis")
|
|
171
181
|
return 2
|
|
@@ -173,11 +183,39 @@ def _no_run(args, console, ui, err) -> int:
|
|
|
173
183
|
if not os.path.isfile(os.path.join(out_dir, "meta.json")):
|
|
174
184
|
err.print(f"[red]no gitmole output found in {out_dir}[/red] (expected meta.json)")
|
|
175
185
|
return 2
|
|
186
|
+
if args.hook:
|
|
187
|
+
return _hook(out_dir, args, console, err, sys.stdin if stdin is None else stdin)
|
|
176
188
|
if ui.is_terminal:
|
|
177
189
|
ui.print(banner.neon(version=__version__))
|
|
178
190
|
return _render(out_dir, console, ui, args, err)
|
|
179
191
|
|
|
180
192
|
|
|
193
|
+
def _hook(out_dir: str, args, console: Console, err: Console, stdin) -> int:
|
|
194
|
+
"""The agent-hook gate (see hook.py): 2 over the threshold, 0 otherwise, silent when the event
|
|
195
|
+
names no file in the repository."""
|
|
196
|
+
from . import hook, watch
|
|
197
|
+
try:
|
|
198
|
+
report = load.load_report(out_dir)
|
|
199
|
+
except load.Unreadable as e:
|
|
200
|
+
err.print(f"[red]{e}[/red]", soft_wrap=True)
|
|
201
|
+
return 0 # a broken output directory must not block an edit
|
|
202
|
+
repo = report["meta"].get("path") or os.getcwd()
|
|
203
|
+
event = {} if args.files else hook.read_event(stdin)
|
|
204
|
+
files = [os.path.relpath(os.path.abspath(f), os.path.realpath(repo)) if os.path.isabs(f) else f for f in args.files] or hook.paths_in(event, repo)
|
|
205
|
+
if not files:
|
|
206
|
+
return 0
|
|
207
|
+
risk = watch.change_risk(report, files)
|
|
208
|
+
lines = hook.summary(risk, args.risk_threshold)
|
|
209
|
+
if args.files:
|
|
210
|
+
console.print("\n".join(lines), soft_wrap=True, markup=False, highlight=False)
|
|
211
|
+
else:
|
|
212
|
+
console.print(hook.hook_output(event, lines), soft_wrap=True, markup=False, highlight=False)
|
|
213
|
+
if args.risk_threshold is not None and risk["total"] > args.risk_threshold:
|
|
214
|
+
err.print("\n".join(lines), soft_wrap=True, markup=False, highlight=False) # exit 2: what the agent is told
|
|
215
|
+
return 2
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
181
219
|
def _clean(args, console: Console, ask) -> int:
|
|
182
220
|
"""Handle --clean: list what gitmole left behind, ask once, remove. ask(prompt) returns the answer."""
|
|
183
221
|
from . import clean, render
|
|
@@ -516,12 +554,12 @@ def _render(out_dir: str, console: Console, ui: Console, args, err: Console) ->
|
|
|
516
554
|
risk = None
|
|
517
555
|
if args.risk:
|
|
518
556
|
try:
|
|
519
|
-
|
|
557
|
+
stats = run.change_stats(report["meta"].get("path") or os.getcwd(), args.risk)
|
|
520
558
|
except ValueError as e:
|
|
521
559
|
err.print(f"[red]--risk {args.risk}:[/red] {e}", soft_wrap=True)
|
|
522
560
|
return 2
|
|
523
561
|
from . import watch
|
|
524
|
-
risk = {"base": args.risk, **watch.change_risk(report, files)}
|
|
562
|
+
risk = {"base": args.risk, **watch.change_risk(report, stats["files"], stats)}
|
|
525
563
|
comparison = None
|
|
526
564
|
if args.compare:
|
|
527
565
|
from . import compare as _compare
|
|
@@ -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
|
|
@@ -54,7 +80,8 @@ def report_at(commits: list, t: str, size: dict, meta: dict, generated: list, ve
|
|
|
54
80
|
ownership = [r for r in maat.entity_ownership(past) if r["author"] not in bots and not identity.is_bot(r["author"])]
|
|
55
81
|
return {"meta": {"now": t, "generated": generated, "vendored": vendored}, "size": size, "revisions": maat.revisions(past),
|
|
56
82
|
"plumbing": maat.plumbing(past), "authors": maat.authors(past), "ownership": ownership,
|
|
57
|
-
"fixes": maat.fixes(past, now=t), "coupling": [], "functions": []
|
|
83
|
+
"fixes": maat.fixes(past, now=t), "coupling": [], "functions": [],
|
|
84
|
+
"entropy": maat.entropy(past, now=t)}
|
|
58
85
|
|
|
59
86
|
|
|
60
87
|
SOLO_WEIGHT = 1.5 # how much single ownership lifts a factor product
|
|
@@ -110,6 +137,8 @@ def variants(report: dict) -> dict:
|
|
|
110
137
|
for name, key in watch.BASELINES.items():
|
|
111
138
|
out[name] = watch.ranked_by(rows, key)
|
|
112
139
|
out["recent fixes"] = watch.ranked_by(rows, lambda r: (r["recent_fixes"], r["revs"]))
|
|
140
|
+
hcm = {e["entity"]: e["hcm"] for e in report.get("entropy") or []}
|
|
141
|
+
out["change entropy (HCM)"] = watch.ranked_by(rows, lambda r: (hcm.get(r["file"], 0.0), r["revs"])) # Hassan's decayed HCM, the one metric with published evidence of beating churn
|
|
113
142
|
return out
|
|
114
143
|
|
|
115
144
|
|
|
@@ -122,10 +151,11 @@ def score(report: dict, fixed: set, top: int) -> dict:
|
|
|
122
151
|
return out
|
|
123
152
|
|
|
124
153
|
|
|
125
|
-
def table(results: list) -> str:
|
|
126
|
-
"""results: [(t, files fixed that were in the pool, pool size, {variant: hits})], oldest first -> Markdown.
|
|
154
|
+
def table(results: list, noun: str = "fixed") -> str:
|
|
155
|
+
"""results: [(t, files fixed that were in the pool, pool size, {variant: hits})], oldest first -> Markdown.
|
|
156
|
+
`noun` says what the outcome is: fixed, bug-inducing (R-SZZ) or labelled."""
|
|
127
157
|
names = list(results[0][3]) if results else []
|
|
128
|
-
head = "| variant | " + " | ".join(f"{t} ({fixed} of {pool}
|
|
158
|
+
head = "| variant | " + " | ".join(f"{t} ({fixed} of {pool} {noun})" for t, fixed, pool, _ in results) + " | total |"
|
|
129
159
|
rule = "|---|" + "---:|" * (len(results) + 1)
|
|
130
160
|
rows = []
|
|
131
161
|
for name in names:
|
|
@@ -152,6 +182,8 @@ def main(argv=None) -> int:
|
|
|
152
182
|
p.add_argument("--windows", type=int, default=6)
|
|
153
183
|
p.add_argument("--horizon", type=int, default=6, metavar="MONTHS")
|
|
154
184
|
p.add_argument("--top", type=int, default=watch.WATCH_TOP)
|
|
185
|
+
p.add_argument("--szz", action="store_true", help="also score against R-SZZ bug-inducing commits (one git blame per fix and file: minutes)")
|
|
186
|
+
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
187
|
args = p.parse_args(argv)
|
|
156
188
|
meta = load._read_json(args.out, "meta.json", {})
|
|
157
189
|
log_path = os.path.join(args.out, "log.txt")
|
|
@@ -165,7 +197,11 @@ def main(argv=None) -> int:
|
|
|
165
197
|
from . import run
|
|
166
198
|
declared = maat.read_ignore_revs(run.ignore_revs_files(args.repo))
|
|
167
199
|
ignored = {c["hash"] for c in commits if declared and maat.is_ignored(c["hash"], declared)}
|
|
168
|
-
|
|
200
|
+
labels = szz.read_labels(args.labels) if args.labels else None
|
|
201
|
+
if args.labels and not labels:
|
|
202
|
+
print(f"evaluate: no bug-inducing commits read from {args.labels}", file=sys.stderr)
|
|
203
|
+
return 2
|
|
204
|
+
results, induced_results, labelled_results = [], [], []
|
|
169
205
|
for t in cutoffs(meta["last_date"], args.windows, args.horizon):
|
|
170
206
|
rev = trend.rev_before(args.repo, t, end_of_day=False)
|
|
171
207
|
if not rev:
|
|
@@ -173,9 +209,17 @@ def main(argv=None) -> int:
|
|
|
173
209
|
size_json, generated, vendored = backtest.snapshot_at(args.repo, rev, args.out)
|
|
174
210
|
size = load.parse_scc(size_json, types)
|
|
175
211
|
report = report_at(commits, t, size, meta, generated, vendored, ignored)
|
|
176
|
-
|
|
212
|
+
end = months_after(t, args.horizon)
|
|
213
|
+
fixed = fixed_between(commits, t, end)
|
|
177
214
|
pool = set(variants(report)["churn"])
|
|
178
215
|
results.append((t, len(fixed & pool), len(pool), score(report, fixed, args.top)))
|
|
216
|
+
if args.szz:
|
|
217
|
+
vendor = tuple(vendored)
|
|
218
|
+
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))
|
|
219
|
+
induced_results.append((t, len(induced & pool), len(pool), score(report, induced, args.top)))
|
|
220
|
+
if labels:
|
|
221
|
+
marked = labelled_between(commits, labels, t, end)
|
|
222
|
+
labelled_results.append((t, len(marked & pool), len(pool), score(report, marked, args.top)))
|
|
179
223
|
print(f"evaluate: {t} done", file=sys.stderr)
|
|
180
224
|
if not results:
|
|
181
225
|
print("evaluate: no cut-off falls inside the history", file=sys.stderr)
|
|
@@ -183,6 +227,12 @@ def main(argv=None) -> int:
|
|
|
183
227
|
spread = ref_spread(args.repo)
|
|
184
228
|
print(f"### {meta.get('name', args.repo)}, top {args.top}, {args.horizon}-month horizon\n")
|
|
185
229
|
print(table(results))
|
|
230
|
+
if induced_results:
|
|
231
|
+
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")
|
|
232
|
+
print(table(induced_results, noun="bug-inducing"))
|
|
233
|
+
if labelled_results:
|
|
234
|
+
print(f"\nAgainst the files the bug-inducing commits labelled in {os.path.basename(args.labels)} touched inside each window:\n")
|
|
235
|
+
print(table(labelled_results, noun="labelled"))
|
|
186
236
|
print(f"\n`--all` exports {spread['all']:,} commits ({spread['fix_all']:,} fixes); HEAD reaches {spread['head']:,} ({spread['fix_head']:,} fixes).")
|
|
187
237
|
return 0
|
|
188
238
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""The agent-hook gate: `gitmole OUT_DIR --no-run --hook [--risk-threshold N] [-- FILE...]`.
|
|
2
|
+
|
|
3
|
+
Deterministic findings should run before inference, so the model reasons over a short list rather
|
|
4
|
+
than rediscovering known issues. A linter says what is wrong in the diff; gitmole says what history
|
|
5
|
+
says about the files the diff touches, and no other tool supplies those fields. The gate has the same
|
|
6
|
+
shape everywhere: the hook's JSON on stdin (Claude Code's PostToolUse, Cursor's afterFileEdit, Gemini
|
|
7
|
+
CLI's AfterTool), the files it names scored like `--risk`, a one-line summary per file with the
|
|
8
|
+
companions the edit left untouched, and exit 2 when the total is over the threshold, which is what
|
|
9
|
+
every one of those hooks reads as "block". Files on the command line (pre-commit's way) print the
|
|
10
|
+
summary as plain text instead of the hook JSON."""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
_PATH_KEYS = ("file_path", "filePath", "path", "notebook_path")
|
|
17
|
+
_LIST_KEYS = ("file_paths", "filePaths", "files", "paths")
|
|
18
|
+
_CONTAINERS = ("tool_input", "tool_response", "input", "output")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def paths_in(event, repo: str) -> list:
|
|
22
|
+
"""The files a hook event names, relative to `repo`, each once, in order: the path keys the
|
|
23
|
+
agents use at the top level and under tool_input/tool_response. A path outside the repository is
|
|
24
|
+
not this repository's risk and is left out; a relative path is taken as repository-relative."""
|
|
25
|
+
if not isinstance(event, dict):
|
|
26
|
+
return []
|
|
27
|
+
found = []
|
|
28
|
+
|
|
29
|
+
def take(value):
|
|
30
|
+
if isinstance(value, str) and value:
|
|
31
|
+
found.append(value)
|
|
32
|
+
elif isinstance(value, list):
|
|
33
|
+
for v in value:
|
|
34
|
+
if isinstance(v, str) and v:
|
|
35
|
+
found.append(v)
|
|
36
|
+
elif isinstance(v, dict):
|
|
37
|
+
for k in _PATH_KEYS:
|
|
38
|
+
if isinstance(v.get(k), str):
|
|
39
|
+
found.append(v[k])
|
|
40
|
+
|
|
41
|
+
for holder in (event, *[event.get(c) for c in _CONTAINERS if isinstance(event.get(c), dict)]):
|
|
42
|
+
for k in _PATH_KEYS:
|
|
43
|
+
take(holder.get(k))
|
|
44
|
+
for k in _LIST_KEYS:
|
|
45
|
+
take(holder.get(k))
|
|
46
|
+
root = os.path.realpath(repo)
|
|
47
|
+
out = []
|
|
48
|
+
for p in found:
|
|
49
|
+
full = os.path.realpath(p if os.path.isabs(p) else os.path.join(root, p))
|
|
50
|
+
if full == root or not full.startswith(root + os.sep):
|
|
51
|
+
continue
|
|
52
|
+
rel = os.path.relpath(full, root)
|
|
53
|
+
if rel not in out:
|
|
54
|
+
out.append(rel)
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read_event(stdin) -> dict:
|
|
59
|
+
"""The hook's JSON from stdin, or {} for nothing or for anything that is not JSON: garbage on
|
|
60
|
+
stdin is not a reason to block an edit."""
|
|
61
|
+
try:
|
|
62
|
+
text = stdin.read() if stdin is not None else ""
|
|
63
|
+
except (OSError, ValueError):
|
|
64
|
+
return {}
|
|
65
|
+
if not text or not text.strip():
|
|
66
|
+
return {}
|
|
67
|
+
try:
|
|
68
|
+
event = json.loads(text)
|
|
69
|
+
except ValueError:
|
|
70
|
+
return {}
|
|
71
|
+
return event if isinstance(event, dict) else {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def summary(risk: dict, threshold=None) -> list:
|
|
75
|
+
"""One line per file, worst first, then the companions the change left untouched, then the total
|
|
76
|
+
against the threshold when there is one."""
|
|
77
|
+
lines = []
|
|
78
|
+
for r in risk["files"]:
|
|
79
|
+
if r.get("rank"):
|
|
80
|
+
where = f"rank {r['rank']} of {risk.get('pool', '?')}" + (", on the watch list" if r.get("watched") else "")
|
|
81
|
+
lines.append(f"{r['file']}: {r['score']:.1f}% of the repository's revisions × lines of code ({where}); " + "; ".join(r["reasons"]))
|
|
82
|
+
else:
|
|
83
|
+
lines.append(f"{r['file']}: not scored ({r['reason']})")
|
|
84
|
+
gaps = risk.get("coupling_gaps") or []
|
|
85
|
+
if gaps:
|
|
86
|
+
from . import render
|
|
87
|
+
lines.append(render.gaps_line(gaps))
|
|
88
|
+
total = f"total {risk['total']:.1f}%"
|
|
89
|
+
if threshold is not None:
|
|
90
|
+
total += f", over the {threshold:g}% threshold" if risk["total"] > threshold else f", under the {threshold:g}% threshold"
|
|
91
|
+
lines.append(total)
|
|
92
|
+
return lines
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def hook_output(event: dict, lines: list) -> str:
|
|
96
|
+
"""The JSON Claude Code reads back (hookSpecificOutput.additionalContext, a soft warning); Cursor
|
|
97
|
+
and Gemini CLI ignore stdout and read the exit code, so the same document serves all three."""
|
|
98
|
+
name = event.get("hook_event_name") or "PostToolUse"
|
|
99
|
+
return json.dumps({"hookSpecificOutput": {"hookEventName": name, "additionalContext": "\n".join(lines)}})
|
|
@@ -82,7 +82,7 @@ def parse_scc(text: str, types=None) -> dict:
|
|
|
82
82
|
|
|
83
83
|
|
|
84
84
|
NUMERIC_COLUMNS = {"n-revs", "degree", "average-revs", "n-authors", "age-months", "added", "deleted", "n-fixes", "recent-fixes", "tiny-revs",
|
|
85
|
-
"minor", "soc", "partners", "n-sets", "with-tests"}
|
|
85
|
+
"minor", "soc", "partners", "n-sets", "with-tests", "periods"}
|
|
86
86
|
|
|
87
87
|
|
|
88
88
|
def parse_maat_csv(text: str) -> list:
|
|
@@ -309,6 +309,7 @@ def load_report(out_dir: str, nested: bool = True) -> dict:
|
|
|
309
309
|
"coupling": parse_maat_csv(_read(out_dir, "maat-coupling.csv")),
|
|
310
310
|
"soc": parse_maat_csv(_read(out_dir, "maat-soc.csv")), # sum of coupling; empty for an output directory from before 0.11
|
|
311
311
|
"tests": parse_maat_csv(_read(out_dir, "maat-tests.csv")), # test co-change per production file; empty before 0.12
|
|
312
|
+
"entropy": parse_maat_csv(_read(out_dir, "maat-entropy.csv")), # Hassan's change entropy per file; empty before 0.13
|
|
312
313
|
"authors": parse_maat_csv(_read(out_dir, "maat-authors.csv")),
|
|
313
314
|
"age": parse_maat_csv(_read(out_dir, "maat-age.csv")),
|
|
314
315
|
"ownership": ownership,
|
|
@@ -349,6 +349,36 @@ def test_cochange(commits: list) -> list:
|
|
|
349
349
|
return rows
|
|
350
350
|
|
|
351
351
|
|
|
352
|
+
ENTROPY_DECAY = 0.5 # a period's weight halves for every month it lies before the reference date
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def entropy(commits: list, now: str = None, decay: float = ENTROPY_DECAY) -> list:
|
|
356
|
+
"""Hassan's history complexity metric (ICSE 2009), decayed: for each calendar month, the Shannon
|
|
357
|
+
entropy of the files' shares of that month's changes, normalised by log2 of the files changed;
|
|
358
|
+
a file's score is the sum over months of its share times that entropy, each month weighted by
|
|
359
|
+
`decay` to the power of its distance from `now`. Changes scattered over many files in a month are
|
|
360
|
+
hard to keep track of; a month spent on one file is not. `periods` is how many months the file
|
|
361
|
+
changed in."""
|
|
362
|
+
now = now or dt.date.today().isoformat()
|
|
363
|
+
by_month = defaultdict(Counter)
|
|
364
|
+
for c in commits:
|
|
365
|
+
for p, _, _ in c["files"]:
|
|
366
|
+
by_month[c["date"][:7]][p] += 1
|
|
367
|
+
y0, m0 = int(now[:4]), int(now[5:7])
|
|
368
|
+
scores, periods = defaultdict(float), Counter()
|
|
369
|
+
for month, counts in by_month.items():
|
|
370
|
+
total, n = sum(counts.values()), len(counts)
|
|
371
|
+
h = -sum((v / total) * math.log2(v / total) for v in counts.values()) / math.log2(n) if n > 1 else 0.0
|
|
372
|
+
back = (y0 - int(month[:4])) * 12 + (m0 - int(month[5:7]))
|
|
373
|
+
weight = decay ** max(0, back)
|
|
374
|
+
for p, v in counts.items():
|
|
375
|
+
periods[p] += 1
|
|
376
|
+
scores[p] += (v / total) * h * weight
|
|
377
|
+
rows = [{"entity": p, "periods": periods[p], "hcm": round(scores[p], 6)} for p in periods]
|
|
378
|
+
rows.sort(key=lambda r: (-r["hcm"], -r["periods"], r["entity"]))
|
|
379
|
+
return rows
|
|
380
|
+
|
|
381
|
+
|
|
352
382
|
RECENT_MONTHS = 6
|
|
353
383
|
OVERSIZED_PERCENTILE = 0.99 # a fix changing more lines than this share of the history's commits credits nothing
|
|
354
384
|
OVERSIZED_FLOOR = 500 # ...and never under this many lines, so a small repository's percentile does not bite
|
|
@@ -526,8 +556,9 @@ ANALYSES = {
|
|
|
526
556
|
"age": (age, ["entity", "age-months"]),
|
|
527
557
|
"entity-ownership": (entity_ownership, ["entity", "author", "added", "deleted"]),
|
|
528
558
|
"fixes": (fixes, ["entity", "n-fixes", "last-fix", "recent-fixes"]),
|
|
559
|
+
"entropy": (entropy, ["entity", "periods", "hcm"]),
|
|
529
560
|
}
|
|
530
|
-
NEEDS_NOW = {"age", "fixes"}
|
|
561
|
+
NEEDS_NOW = {"age", "fixes", "entropy"}
|
|
531
562
|
|
|
532
563
|
|
|
533
564
|
def aliases_from_meta(path: str) -> dict:
|
|
@@ -359,10 +359,25 @@ def risk_section(risk: dict, base: str, full=True) -> dict:
|
|
|
359
359
|
more = _more(len(rows_all), limit)
|
|
360
360
|
if more:
|
|
361
361
|
notes.append(more)
|
|
362
|
+
factors = (risk.get("change") or {}).get("reasons") or []
|
|
363
|
+
if rows and factors:
|
|
364
|
+
notes.append("; ".join(factors))
|
|
365
|
+
gaps = risk.get("coupling_gaps") or []
|
|
366
|
+
if rows and gaps:
|
|
367
|
+
notes.append(gaps_line(gaps))
|
|
362
368
|
return _section(f"Change risk ({len(rows_all)} files since {base})", columns, rows,
|
|
363
369
|
note=None if rows else f"no files changed since {base}", caption="\n".join(notes) or None)
|
|
364
370
|
|
|
365
371
|
|
|
372
|
+
def gaps_line(gaps: list) -> str:
|
|
373
|
+
"""'not touched: core/ast.py, which changes with core/parser.py 72% of the time, and core/lexer.py (55%)':
|
|
374
|
+
the companions a change left out, strongest first."""
|
|
375
|
+
first = gaps[0]
|
|
376
|
+
rest = [f"{g['companion']} ({g['degree']}%)" for g in gaps[1:4]]
|
|
377
|
+
line = f"not touched: {first['companion']}, which changes with {first['file']} {first['degree']}% of the time"
|
|
378
|
+
return line + (", and " + textfmt.join_and(rest) if rest else "") + (f" and {len(gaps) - 4} more" if len(gaps) > 4 else "")
|
|
379
|
+
|
|
380
|
+
|
|
366
381
|
def size_section(report: dict, full: bool = True, width=None) -> dict:
|
|
367
382
|
langs = report["size"]["languages"]
|
|
368
383
|
total = report["size"]["total_code"]
|
|
@@ -542,6 +542,31 @@ def changed_files(repo_dir: str, base: str) -> list:
|
|
|
542
542
|
return sorted(p.decode("utf-8", "surrogateescape") for p in proc.stdout.split(b"\0") if p)
|
|
543
543
|
|
|
544
544
|
|
|
545
|
+
def change_stats(repo_dir: str, base: str) -> dict:
|
|
546
|
+
"""What a change is, for the Kamei factors: the files that differ between the merge base with
|
|
547
|
+
`base` and HEAD, lines added and deleted per file (whitespace ignored, as the change log is), the
|
|
548
|
+
author of HEAD and how many commits the change spans. ValueError when git refuses."""
|
|
549
|
+
files = changed_files(repo_dir, base)
|
|
550
|
+
proc = subprocess.run([*filetypes.GIT, "diff", "--numstat", "-w", "--ignore-blank-lines", f"{base}...HEAD"], cwd=repo_dir, capture_output=True)
|
|
551
|
+
if proc.returncode != 0:
|
|
552
|
+
raise ValueError((proc.stderr.decode("utf-8", "replace").strip() or f"git diff {base}...HEAD failed"))
|
|
553
|
+
added, deleted = {f: 0 for f in files}, {f: 0 for f in files}
|
|
554
|
+
for line in proc.stdout.decode("utf-8", "surrogateescape").split("\n"):
|
|
555
|
+
parts = line.split("\t", 2)
|
|
556
|
+
if len(parts) != 3:
|
|
557
|
+
continue
|
|
558
|
+
a, d, path = parts
|
|
559
|
+
path = filetypes.unquote(path)
|
|
560
|
+
if " => " in path:
|
|
561
|
+
path = path.split(" => ", 1)[1].rstrip("}") if "{" not in path else path
|
|
562
|
+
added[path] = int(a) if a.isdigit() else 0
|
|
563
|
+
deleted[path] = int(d) if d.isdigit() else 0
|
|
564
|
+
author = subprocess.run(["git", "log", "-1", "--use-mailmap", "--format=%aN", "HEAD"], cwd=repo_dir, capture_output=True).stdout.decode("utf-8", "replace").strip()
|
|
565
|
+
count = subprocess.run(["git", "rev-list", "--count", f"{base}..HEAD"], cwd=repo_dir, capture_output=True, text=True).stdout.strip()
|
|
566
|
+
return {"files": files, "added": {f: added.get(f, 0) for f in files}, "deleted": {f: deleted.get(f, 0) for f in files},
|
|
567
|
+
"author": author, "commits": int(count) if count.isdigit() else 0}
|
|
568
|
+
|
|
569
|
+
|
|
545
570
|
def save_meta(meta: dict, out_dir: str) -> None:
|
|
546
571
|
with open(os.path.join(out_dir, "meta.json"), "w") as fh:
|
|
547
572
|
json.dump(meta, fh, indent=2)
|
|
@@ -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
|
|
@@ -100,3 +100,8 @@ def tally(findings: list) -> str:
|
|
|
100
100
|
if counts["info"]:
|
|
101
101
|
parts.append(f"{counts['info']} note" + ("s" if counts["info"] != 1 else ""))
|
|
102
102
|
return ", ".join(parts) if parts else "nothing flagged"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def count(n: int, word: str, plural: str = None) -> str:
|
|
106
|
+
"""'1 file', '3 files', '2 directories': a number with its noun."""
|
|
107
|
+
return f"{n:,} {word if n == 1 else (plural or word + 's')}"
|