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.
Files changed (72) hide show
  1. {gitmole-0.12.0 → gitmole-0.14.0}/PKG-INFO +1 -1
  2. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/__init__.py +1 -1
  3. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/cli.py +46 -8
  4. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/evaluate.py +57 -7
  5. gitmole-0.14.0/gitmole/hook.py +99 -0
  6. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/load.py +2 -1
  7. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/maat.py +32 -1
  8. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/render.py +15 -0
  9. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/run.py +25 -0
  10. gitmole-0.14.0/gitmole/szz.py +135 -0
  11. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/textfmt.py +5 -0
  12. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/watch.py +73 -7
  13. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole.egg-info/PKG-INFO +1 -1
  14. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole.egg-info/SOURCES.txt +4 -0
  15. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_evaluate.py +35 -2
  16. gitmole-0.14.0/tests/test_hook.py +108 -0
  17. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_maat.py +29 -2
  18. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_render.py +5 -0
  19. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_run.py +25 -0
  20. gitmole-0.14.0/tests/test_szz.py +99 -0
  21. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_watch.py +50 -1
  22. {gitmole-0.12.0 → gitmole-0.14.0}/LICENSE +0 -0
  23. {gitmole-0.12.0 → gitmole-0.14.0}/README.md +0 -0
  24. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/__main__.py +0 -0
  25. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/backtest.py +0 -0
  26. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/banner.py +0 -0
  27. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/blame.py +0 -0
  28. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/classify.py +0 -0
  29. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/clean.py +0 -0
  30. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/compare.py +0 -0
  31. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/coupling.py +0 -0
  32. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/deps.py +0 -0
  33. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/duplicates.py +0 -0
  34. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/filetypes.py +0 -0
  35. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/findings.py +0 -0
  36. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/functions.py +0 -0
  37. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/hotspots.py +0 -0
  38. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/identity.py +0 -0
  39. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/knowledge.py +0 -0
  40. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/leaks.py +0 -0
  41. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/loss.py +0 -0
  42. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole/trend.py +0 -0
  43. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole.egg-info/dependency_links.txt +0 -0
  44. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole.egg-info/entry_points.txt +0 -0
  45. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole.egg-info/requires.txt +0 -0
  46. {gitmole-0.12.0 → gitmole-0.14.0}/gitmole.egg-info/top_level.txt +0 -0
  47. {gitmole-0.12.0 → gitmole-0.14.0}/pyproject.toml +0 -0
  48. {gitmole-0.12.0 → gitmole-0.14.0}/setup.cfg +0 -0
  49. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_backtest.py +0 -0
  50. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_banner.py +0 -0
  51. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_blame.py +0 -0
  52. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_classify.py +0 -0
  53. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_clean.py +0 -0
  54. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_cli.py +0 -0
  55. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_compare.py +0 -0
  56. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_coupling.py +0 -0
  57. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_deps.py +0 -0
  58. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_duplicates.py +0 -0
  59. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_filetypes.py +0 -0
  60. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_findings.py +0 -0
  61. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_functions.py +0 -0
  62. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_golden.py +0 -0
  63. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_hotspots.py +0 -0
  64. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_identity.py +0 -0
  65. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_knowledge.py +0 -0
  66. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_leaks.py +0 -0
  67. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_load.py +0 -0
  68. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_loss.py +0 -0
  69. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_packaging.py +0 -0
  70. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_render_examples.py +0 -0
  71. {gitmole-0.12.0 → gitmole-0.14.0}/tests/test_textfmt.py +0 -0
  72. {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.12.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
@@ -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.14.0"
@@ -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
- return p.parse_args(argv)
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
- "--risk-threshold needs --risk" if args.risk_threshold is not None and not args.risk else
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
- files = run.changed_files(report["meta"].get("path") or os.getcwd(), args.risk)
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} fixed)" for t, fixed, pool, _ in results) + " | total |"
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
- results = []
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
- fixed = fixed_between(commits, t, months_after(t, args.horizon))
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')}"