crapkit 0.2.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.
Files changed (59) hide show
  1. crapkit/__init__.py +2 -0
  2. crapkit/__main__.py +5 -0
  3. crapkit/_pygdefer.py +86 -0
  4. crapkit/analyze.py +375 -0
  5. crapkit/cache.py +58 -0
  6. crapkit/churn.py +113 -0
  7. crapkit/churn_cache.py +108 -0
  8. crapkit/churn_log.py +286 -0
  9. crapkit/cli/__init__.py +316 -0
  10. crapkit/cli/_shared.py +130 -0
  11. crapkit/cli/admin.py +650 -0
  12. crapkit/cli/analyses.py +144 -0
  13. crapkit/cli/parser.py +384 -0
  14. crapkit/cli/queue.py +926 -0
  15. crapkit/cli/ratchet_cmds.py +172 -0
  16. crapkit/cli/reports.py +459 -0
  17. crapkit/cli/scoring.py +500 -0
  18. crapkit/cli/verifying.py +580 -0
  19. crapkit/config.py +289 -0
  20. crapkit/coupling.py +89 -0
  21. crapkit/coverage_istanbul.py +225 -0
  22. crapkit/coverage_py.py +87 -0
  23. crapkit/covstream.py +320 -0
  24. crapkit/diffparse.py +98 -0
  25. crapkit/digest.py +191 -0
  26. crapkit/discover.py +365 -0
  27. crapkit/doctor.py +308 -0
  28. crapkit/dup.py +179 -0
  29. crapkit/errors.py +18 -0
  30. crapkit/gitio.py +504 -0
  31. crapkit/hook.py +167 -0
  32. crapkit/junitparse.py +87 -0
  33. crapkit/lanes.py +373 -0
  34. crapkit/lizardcognitive.py +238 -0
  35. crapkit/mcp_server.py +167 -0
  36. crapkit/merge.py +77 -0
  37. crapkit/mutate.py +96 -0
  38. crapkit/mutate_pool.py +152 -0
  39. crapkit/override.py +94 -0
  40. crapkit/packet.py +343 -0
  41. crapkit/ratchet.py +236 -0
  42. crapkit/ratchet_report.py +135 -0
  43. crapkit/sarif.py +82 -0
  44. crapkit/sarifio.py +49 -0
  45. crapkit/scaffold.py +361 -0
  46. crapkit/score.py +255 -0
  47. crapkit/snapshot.py +51 -0
  48. crapkit/store.py +1066 -0
  49. crapkit/uncovered.py +131 -0
  50. crapkit/universe.py +157 -0
  51. crapkit/verify.py +194 -0
  52. crapkit/watch.py +112 -0
  53. crapkit/worklist.py +290 -0
  54. crapkit-0.2.0.dist-info/METADATA +802 -0
  55. crapkit-0.2.0.dist-info/RECORD +59 -0
  56. crapkit-0.2.0.dist-info/WHEEL +5 -0
  57. crapkit-0.2.0.dist-info/entry_points.txt +2 -0
  58. crapkit-0.2.0.dist-info/licenses/LICENSE +21 -0
  59. crapkit-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,172 @@
1
+ """The `ratchet` subcommand's five actions and the burn-down report behind
2
+ `ratchet report`: seed new debt, prune marks whose code left (renames followed
3
+ first), merge two marks files as a git merge driver, move marks at their
4
+ recorded values, and report ages and repayment from the marks file's history."""
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ from pathlib import Path
9
+
10
+ from ..errors import ConfigError, CrapkitError
11
+ from ..store import SnapshotStore
12
+ from ._shared import _load_ratchet_or_die, _load_repo_config, _open_store, _print_json
13
+
14
+
15
+ def _latest_full_run(store: SnapshotStore) -> dict:
16
+ runs = [r for r in store.list_runs() if r["kind"] in ("coverage", "verify")]
17
+ if not runs:
18
+ raise CrapkitError("no full coverage run to work from — run `crapkit coverage` first")
19
+ return runs[-1]
20
+
21
+
22
+ def _merge_stamp(texts: list[str]) -> str:
23
+ """The stamp the merged file keeps; the two sides must share one.
24
+
25
+ Reconciling marks across metrics means picking a minimum between numbers
26
+ produced by different rules, which is not a comparison at all.
27
+ """
28
+ from ..ratchet import read_stamp
29
+
30
+ ours, theirs = read_stamp(texts[1]), read_stamp(texts[2])
31
+ if ours != theirs:
32
+ raise ConfigError(
33
+ f"ratchet merge refused: ours is [{ours or 'unstamped'}] and theirs is "
34
+ f"[{theirs or 'unstamped'}] — marks from different metric versions cannot "
35
+ "merge; re-baseline one side with `crapkit ratchet seed`")
36
+ return ours
37
+
38
+
39
+ def _ratchet_merge(files: list) -> int:
40
+ from ..ratchet import dump_ratchet, load_ratchet, merge_ratchets
41
+
42
+ if len(files) != 3:
43
+ raise ConfigError("ratchet merge takes exactly three files: BASE OURS THEIRS (git %O %A %B)")
44
+ texts = [Path(f).read_text(encoding="utf-8") for f in files]
45
+ stamp = _merge_stamp(texts)
46
+ merged = merge_ratchets(*(load_ratchet(t) for t in texts))
47
+ Path(files[1]).write_text(dump_ratchet(merged, stamp=stamp), encoding="utf-8", newline="\n")
48
+ print(f"ratchet merge: {len(merged)} mark(s)")
49
+ return 0
50
+
51
+
52
+ def _ratchet_move(root: Path, cfg, files: list) -> int:
53
+ from ..ratchet import dump_ratchet, move_marks
54
+
55
+ if len(files) != 2:
56
+ raise ConfigError("ratchet move takes exactly two paths: OLD NEW")
57
+ ratchet_path = root / cfg.ratchet_file
58
+ entries, moved = move_marks(_load_ratchet_or_die(ratchet_path, cfg.ratchet_file),
59
+ files[0], files[1])
60
+ if not moved:
61
+ raise ConfigError(f"ratchet move: no mark under {files[0]} in {cfg.ratchet_file} "
62
+ "(a directory must end in '/')")
63
+ ratchet_path.write_text(dump_ratchet(entries), encoding="utf-8", newline="\n")
64
+ print(f"{cfg.ratchet_file}: moved {moved} mark(s) from {files[0]} to {files[1]}")
65
+ return 0
66
+
67
+
68
+ def _prune_renames(root: Path, store: SnapshotStore) -> dict[str, str]:
69
+ """Renames a mark could have lived through, as one tree-to-tree diff.
70
+
71
+ Anchored at the store's FIRST run: a mark can only have been seeded from a
72
+ run, so no mark era starts before it, and rename detection compares two trees
73
+ rather than walking history, so the widest window costs the same as a narrow
74
+ one and cannot invent a pairing. An anchor a rebase rewrote away yields no
75
+ renames and prune drops exactly as it did before.
76
+ """
77
+ from ..errors import GitError
78
+ from ..gitio import renamed_paths
79
+
80
+ runs = store.list_runs()
81
+ if not runs:
82
+ return {}
83
+ try:
84
+ return renamed_paths(root, runs[0]["commit"])
85
+ except GitError:
86
+ return {}
87
+
88
+
89
+ def _pruned(root: Path, store: SnapshotStore, prior: list, fresh: list) -> tuple[list, str]:
90
+ """Prune, renames first: a file git moved is a relocated mark, not repaid debt."""
91
+ from ..ratchet import follow_renames, prune_ratchet
92
+
93
+ followed, moved = follow_renames(prior, fresh, _prune_renames(root, store))
94
+ entries, dropped = prune_ratchet(followed, fresh)
95
+ return entries, f"pruned {dropped}, followed {moved} rename(s)"
96
+
97
+
98
+ def _print_ratchet_report(report: dict, violations: list, ratchet_file: str) -> None:
99
+ print(f"ratchet burn-down: {report['open']} open mark(s), {report['dropped_total']} repaid "
100
+ f"({report['dropped_last_30d']} in the last 30d, {report['dropped_last_90d']} in 90d)")
101
+ if report["uncommitted"]:
102
+ print(f" {report['uncommitted']} uncommitted mark(s) in {ratchet_file}: open reads the "
103
+ "working tree, ages and repayment read committed history")
104
+ for v in violations:
105
+ print(f" POLICY {v}")
106
+ for e in report["oldest"][:10]:
107
+ print(f" {e['age_days']:>5}d {e['path']} {e['long_name']}")
108
+
109
+
110
+ def _working_marks(root: Path, ratchet_file: str) -> dict:
111
+ """The marks on disk, keyed (path, long_name) -> crap. Ages come from the
112
+ file's git history, but which marks are OPEN is a question about now, and a
113
+ seed prints "added 1" long before anybody commits the TSV."""
114
+ entries = _load_ratchet_or_die(root / ratchet_file, ratchet_file)
115
+ return {(e.path, e.long_name): e.crap for e in entries}
116
+
117
+
118
+ def _policy_findings(cfg, report: dict, enforce: bool) -> list | None:
119
+ """The debt-policy findings, or None when no policy was evaluated.
120
+
121
+ None, not []: without --enforce, or with no debt knobs in [crapkit] to judge
122
+ by, nothing looked at the debt at all. [] then says "policy clean" about a
123
+ policy that does not exist.
124
+ """
125
+ from ..ratchet_report import policy_violations
126
+
127
+ knobs = (cfg.debt_max_age_months, cfg.repayment_min_per_30d)
128
+ if not enforce or all(k is None for k in knobs):
129
+ return None
130
+ return policy_violations(report, *knobs)
131
+
132
+
133
+ def _ratchet_report(root: Path, cfg, as_json: bool, enforce: bool) -> int:
134
+ from ..gitio import file_log_patches
135
+ from ..ratchet_report import mark_events, report_from_events
136
+
137
+ events = mark_events(file_log_patches(root, cfg.ratchet_file))
138
+ report = report_from_events(events, working=_working_marks(root, cfg.ratchet_file))
139
+ violations = _policy_findings(cfg, report, enforce)
140
+ if as_json:
141
+ _print_json({**report, "policy_violations": violations})
142
+ else:
143
+ _print_ratchet_report(report, violations or [], cfg.ratchet_file)
144
+ return 1 if violations else 0
145
+
146
+
147
+ def cmd_ratchet(args: argparse.Namespace) -> int:
148
+ from ..ratchet import dump_ratchet, seed_ratchet
149
+
150
+ if args.action == "merge": # a git merge driver runs with no crapkit.toml in sight
151
+ return _ratchet_merge(args.files)
152
+ root = Path(args.repo).resolve()
153
+ cfg = _load_repo_config(root)
154
+ if args.action == "report":
155
+ return _ratchet_report(root, cfg, args.json, args.enforce)
156
+ if args.action == "move": # a hand-declared rename needs no run to follow
157
+ return _ratchet_move(root, cfg, args.files)
158
+ store = _open_store(root)
159
+ latest = _latest_full_run(store)
160
+ fresh = store.read_scored(latest["id"])
161
+ ratchet_path = root / cfg.ratchet_file
162
+ prior = _load_ratchet_or_die(ratchet_path, cfg.ratchet_file)
163
+ if args.action == "seed":
164
+ entries, added, tightened = seed_ratchet(prior, fresh, target=cfg.target,
165
+ scope_targets=cfg.scope_targets)
166
+ note = f"added {added}, tightened {tightened}"
167
+ else:
168
+ entries, note = _pruned(root, store, prior, fresh)
169
+ ratchet_path.write_text(dump_ratchet(entries), encoding="utf-8", newline="\n")
170
+ print(f"{cfg.ratchet_file}: {note} — {len(entries)} mark(s) vs run {latest['id']} "
171
+ f"({latest['commit'][:11]})")
172
+ return 0
crapkit/cli/reports.py ADDED
@@ -0,0 +1,459 @@
1
+ """The read-only reporting commands: `digest` (delta between the last two scored
2
+ runs, optionally piped to alert_command), `trend` (per-run totals), `runs` (run
3
+ history and retention), `overrides` (the audit trail) and `explain` (one
4
+ function's trajectory, its ratchet mark, its dark lines, its commits and tests)."""
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import NamedTuple
11
+
12
+ from ..errors import ConfigError, CrapkitError, GitError, ToolError
13
+ from ..store import SnapshotStore
14
+ from ..uncovered import MissingLines, load_uncovered
15
+ from ._shared import _load_repo_config, _open_store, _print_json, _ratchet_entries
16
+
17
+
18
+ def _digest_pair(store):
19
+ """The newest two runs that share a lane set, or None after saying why not."""
20
+ from ..digest import latest_comparable_pair
21
+ from ..store import trusted_runs
22
+ scored_runs = trusted_runs(store)
23
+ pair = latest_comparable_pair(scored_runs)
24
+ if pair is None:
25
+ print(f"crapkit digest: no two of the {len(scored_runs)} scored run(s) share a lane set; "
26
+ "nothing comparable yet (a --lane subset run never pairs with a full run)")
27
+ _warn_skipped_runs(scored_runs, pair)
28
+ return pair
29
+
30
+
31
+ def _warn_skipped_runs(scored_runs: list[dict], pair) -> None:
32
+ """Say so when the pair is not the newest run and the one before it.
33
+
34
+ Stdout carries the digest body and the alert carries a copy of it, so this
35
+ goes to stderr like every other crapkit warning: additive for a reader,
36
+ invisible to anything parsing the report. It fires on a quiet digest too —
37
+ silence read off a stale pair is the version of this that costs the most.
38
+ """
39
+ from ..digest import skipped_runs
40
+
41
+ skipped = skipped_runs(scored_runs, pair)
42
+ if not skipped:
43
+ return
44
+ print(f"warning: digest compared runs {pair[0]['id']} -> {pair[1]['id']}, "
45
+ f"skipping run(s) {', '.join(str(r['id']) for r in skipped)} — "
46
+ "a run only pairs with one whose lane set is identical", file=sys.stderr)
47
+
48
+
49
+ def _send_digest_alert(root: Path, cfg, prev: dict, cur: dict, lines: list[str]) -> None:
50
+ """Hand the digest body to the configured alert command; a nonzero exit is fatal."""
51
+ import subprocess
52
+
53
+ if not cfg.alert_command.strip():
54
+ raise ConfigError("digest --alert needs [crapkit] alert_command")
55
+ body = f"crapkit digest (runs {prev['id']} -> {cur['id']}):\n" + "\n".join(lines) + "\n"
56
+ proc = subprocess.run(cfg.alert_command, shell=True, cwd=root, input=body,
57
+ capture_output=True, text=True, encoding="utf-8", errors="replace")
58
+ if proc.returncode != 0:
59
+ raise ToolError(f"digest alert command failed (exit {proc.returncode})")
60
+
61
+
62
+ def cmd_digest(args: argparse.Namespace) -> int:
63
+ from ..digest import build_digest
64
+
65
+ root = Path(args.repo).resolve()
66
+ cfg = _load_repo_config(root)
67
+ db_path = root / ".crapkit" / "crap.sqlite"
68
+ if not db_path.is_file():
69
+ raise CrapkitError(f"no snapshot in {root} — run `crapkit coverage` first")
70
+ store = SnapshotStore(db_path)
71
+ pair = _digest_pair(store)
72
+ if pair is None:
73
+ return 0
74
+ prev, cur = pair
75
+ # read_crap, not read_scored: build_digest names four of a ScoredRow's
76
+ # sixteen fields, and a digest carries two whole runs at once
77
+ d = build_digest(store.read_crap(prev["id"]), store.read_crap(cur["id"]), target=cfg.target)
78
+ if d.quiet:
79
+ return 0 # an unchanged week says nothing
80
+ for line in d.lines:
81
+ print(line)
82
+ if args.alert:
83
+ _send_digest_alert(root, cfg, prev, cur, d.lines)
84
+ return 0
85
+
86
+
87
+ def _scope_rollup_from_agg(scope_agg: dict) -> dict[str, dict]:
88
+ """Per-scope SQL sums shaped the way coverage --json shapes rows in hand.
89
+
90
+ Both go through totals_from_counts, which is where the rounding rule lives:
91
+ a second rounding here would make the same run read two ways.
92
+ """
93
+ from ..digest import scope_rollup, totals_from_counts
94
+
95
+ return scope_rollup({scope: totals_from_counts(*agg) for scope, agg in scope_agg.items()})
96
+
97
+
98
+ def _trend_row(run: dict, agg: tuple, scope_agg: dict) -> dict:
99
+ from ..digest import totals_from_counts
100
+
101
+ t = totals_from_counts(*agg)
102
+ return {"run_id": run["id"], "commit": run["commit"], "created_at": run["created_at"],
103
+ "functions": t.functions, "over_target": t.over_target,
104
+ "crap_load": t.crap_load, "avg": t.avg,
105
+ "by_scope": _scope_rollup_from_agg(scope_agg)}
106
+
107
+
108
+ def _print_trend(as_json: bool, rows_out: list, target: int) -> None:
109
+ if as_json:
110
+ _print_json({"target": target, "runs": rows_out})
111
+ return
112
+ for r in rows_out:
113
+ print(f"run {r['run_id']:>3} @ {r['commit'][:11]} {r['created_at']}: "
114
+ f"{r['over_target']} over target, load {r['crap_load']}, avg {r['avg']}")
115
+
116
+
117
+ def cmd_trend(args: argparse.Namespace) -> int:
118
+ from ..store import trusted_runs
119
+
120
+ root = Path(args.repo).resolve()
121
+ cfg = _load_repo_config(root)
122
+ store = _open_store(root)
123
+ # one GROUP BY for the whole history; this used to build every ScoredRow of
124
+ # every trusted run to add up three numbers per run
125
+ agg = store.run_totals(target=cfg.target, scope_targets=cfg.scope_targets)
126
+ by_scope = store.run_scope_totals(target=cfg.target, scope_targets=cfg.scope_targets)
127
+ rows_out = [_trend_row(run, agg.get(run["id"], (0, 0, 0.0)), by_scope.get(run["id"], {}))
128
+ for run in trusted_runs(store)]
129
+ _print_trend(args.json, rows_out, cfg.target)
130
+ return 0
131
+
132
+
133
+ def cmd_runs(args: argparse.Namespace) -> int:
134
+ """History without SQL: every run with kind, verdict, commit, lane set."""
135
+ store = _open_store(Path(args.repo).resolve())
136
+ if args.action == "prune":
137
+ return _runs_prune(store, keep=args.keep, as_json=args.json)
138
+ return _runs_list(store, as_json=args.json)
139
+
140
+
141
+ def _runs_list(store: SnapshotStore, *, as_json: bool) -> int:
142
+ """Every run, with the one `verify` compares against today marked.
143
+
144
+ "Which run is my baseline" is the question the taint rule turns on, and this
145
+ is the command a reader reaches for to answer it.
146
+ """
147
+ from ..store import pick_baseline
148
+
149
+ history = store.list_runs()
150
+ picked = pick_baseline(history).run
151
+ baseline_id = picked["id"] if picked else None
152
+ runs = [{"id": r["id"], "kind": r["kind"], "verdict_ok": r["verdict_ok"],
153
+ "findings": r["findings"], "baseline": r["id"] == baseline_id,
154
+ "commit": r["commit"], "lanes": sorted(r["lanes"]), "created_at": r["created_at"]}
155
+ for r in history]
156
+ if as_json:
157
+ _print_json({"runs": runs})
158
+ return 0
159
+ for r in runs:
160
+ print(_run_line(r))
161
+ return 0
162
+
163
+
164
+ def _run_line(r: dict) -> str:
165
+ """`verdict=-` means a run that produces no verdict, not a run that failed."""
166
+ verdict = {True: "ok", False: "FAILED", None: "-"}[r["verdict_ok"]]
167
+ mark = " baseline" if r["baseline"] else ""
168
+ return (f"run {r['id']:>3} @ {r['commit'][:11]} {r['created_at']} {r['kind']:<9} "
169
+ f"verdict={verdict:<6} lanes={','.join(r['lanes']) or '-'}{mark}")
170
+
171
+
172
+ def _runs_prune(store: SnapshotStore, *, keep: int, as_json: bool) -> int:
173
+ """Drop the runs outside the keep-set and hand the pages back to the OS.
174
+
175
+ Keep is a floor on retention, not a cap: the keep-set also holds the digest
176
+ pair, every passing verify baseline, every run an override names, and the
177
+ newest non-hook run, so a prune can never make another command lie.
178
+ """
179
+ from ..store import prune_keep_set
180
+
181
+ if keep < 1:
182
+ raise ConfigError(f"runs prune --keep must be >= 1, got {keep}")
183
+ keep_ids = prune_keep_set(store.list_runs(), store.override_run_ids(), keep=keep)
184
+ before = store.size_bytes()
185
+ store.prune_claims(keep_ids) # retention is one decision, runs and loop state together
186
+ deleted = store.prune_runs(keep_ids)
187
+ store.vacuum() # the DELETE alone frees pages, not disk
188
+ _print_prune(deleted, len(keep_ids), before - store.size_bytes(), as_json)
189
+ return 0
190
+
191
+
192
+ def _print_prune(deleted: int, kept: int, freed: int, as_json: bool) -> None:
193
+ if as_json:
194
+ _print_json({"pruned_runs": deleted, "kept_runs": kept, "freed_bytes": freed})
195
+ return
196
+ print(f"runs prune: deleted {deleted} run(s), kept {kept}, freed {freed} byte(s)")
197
+
198
+
199
+ def cmd_overrides(args: argparse.Namespace) -> int:
200
+ """The override audit trail, read back out of the snapshot store."""
201
+ store = _open_store(Path(args.repo).resolve())
202
+ trail = [{"run_id": rid, "path": path, "function": name, "crap": crap,
203
+ "reason": reason, "created_at": ts, "commit": sha}
204
+ for rid, path, name, crap, reason, ts, sha in store.read_overrides_all()]
205
+ if args.json:
206
+ _print_json({"overrides": trail})
207
+ return 0
208
+ for o in trail:
209
+ print(f"run {o['run_id']:>3} @ {o['commit'][:11]} {o['created_at']} crap {o['crap']:.1f} "
210
+ f"{o['path']} {o['function']} ({o['reason']})")
211
+ return 0
212
+
213
+
214
+ _NO_SPAN = "function not in the latest run"
215
+
216
+ _NO_CONTEXT = ("no context data — run the py lane with dynamic_context = "
217
+ "test_function and a --show-contexts JSON report")
218
+
219
+ # %x01 opens a commit record and %x02 closes it, so a body of any shape stays
220
+ # separable from the diff hunks `git log -L` prints between records.
221
+ _LOG_FORMAT = "%x01%h %ad %s%n%b%x02"
222
+
223
+
224
+ class _ExplainCtx(NamedTuple):
225
+ """What explain looks up ONCE for the whole command and reuses per match.
226
+
227
+ Every one of these used to be redone for each matched function: the lane
228
+ artifacts reparsed for dark lines and again for test attribution, the
229
+ ratchet file reread, crapkit.toml reloaded, the run table rescanned to find
230
+ the newest run. None of them depends on which function is being explained,
231
+ so a file with four overloads paid for four of each.
232
+ """
233
+ root: Path
234
+ path: str
235
+ run_id: int | None
236
+ uncovered: MissingLines
237
+ ratchet: list | None
238
+ contexts: dict
239
+
240
+
241
+ def cmd_explain(args: argparse.Namespace) -> int:
242
+ """The trajectory behind a verdict, assembled once and then rendered.
243
+
244
+ Text and --json read the same payload, so a section can never say one thing
245
+ to a human and another to a wrapper.
246
+ """
247
+ root = Path(args.repo).resolve()
248
+ cfg = _load_repo_config(root)
249
+ store = _open_store(root)
250
+ matches = store.find_functions(args.path, args.name)
251
+ if not matches:
252
+ raise CrapkitError(f"no function matching {args.name!r} in {args.path} appears in any run")
253
+ ctx = _explain_ctx(root, cfg, store, args)
254
+ _print_explain(args, [_explain_payload(ctx, store, args, name) for name in matches])
255
+ return 0
256
+
257
+
258
+ def _explain_ctx(root: Path, cfg, store: SnapshotStore, args) -> _ExplainCtx:
259
+ runs = [r for r in store.list_runs() if r["kind"] != "hook"]
260
+ return _ExplainCtx(root, args.path, runs[-1]["id"] if runs else None,
261
+ load_uncovered(root, cfg), _ratchet_entries(root, cfg),
262
+ _contexts_for_path(root, cfg, args.path) if args.tests else {})
263
+
264
+
265
+ def _explain_payload(ctx: _ExplainCtx, store: SnapshotStore, args, long_name: str) -> dict:
266
+ """One function's whole packet. The span is looked up once and passed down:
267
+ dark lines, --history and --tests all want the same line range."""
268
+ span = _latest_span(store, ctx.run_id, ctx.path, long_name)
269
+ out = {"long_name": long_name,
270
+ "history": store.function_history(ctx.path, long_name),
271
+ **_mark_fields(ctx.ratchet, ctx.path, long_name),
272
+ **_dark_fields(ctx.uncovered, ctx.path, span)}
273
+ if args.history:
274
+ out.update(_commits_fields(ctx.root, ctx.path, span))
275
+ if args.tests:
276
+ out.update(_tests_fields(ctx.contexts, span))
277
+ return out
278
+
279
+
280
+ def _print_explain(args, payloads: list[dict]) -> None:
281
+ """Text unless asked for JSON. The flag is read defensively because explain
282
+ answered in text long before it had one, and an absent flag means text."""
283
+ if getattr(args, "json", False):
284
+ _print_json({"path": args.path, "name": args.name, "functions": payloads})
285
+ return
286
+ for p in payloads:
287
+ print(f"{args.path} {p['long_name']}")
288
+ _print_history(p["history"])
289
+ print(f" mark: {_ratchet_mark(p)}")
290
+ _print_uncovered(p)
291
+ _explain_extras(p)
292
+
293
+
294
+ def _latest_span(store: SnapshotStore, run_id: int | None, path: str, long_name: str):
295
+ """The newest non-hook run's (start, end) for one function, or None.
296
+
297
+ A targeted lookup off the (run_id, path) index; this used to materialize
298
+ every row of that run to find one.
299
+ """
300
+ return None if run_id is None else store.function_span(run_id, path, long_name)
301
+
302
+
303
+ def _mark_fields(ratchet: list | None, path: str, long_name: str) -> dict:
304
+ """null and a note when the repo carries no marks file: an unmarked function
305
+ and a repo with no ratchet both read as null, and they want different moves."""
306
+ from ..ratchet import mark_for
307
+
308
+ if ratchet is None:
309
+ return {"ratchet_mark": None, "ratchet_mark_note": "no ratchet file"}
310
+ return {"ratchet_mark": mark_for(ratchet, path, long_name)}
311
+
312
+
313
+ def _dark_fields(uncovered: MissingLines, path: str, span) -> dict:
314
+ """The dark lines inside one span, or null and the reason there are none.
315
+
316
+ null when no artifact could answer, never []: [] is what a function every
317
+ artifact ran reports, and it would read as nothing left to test.
318
+ """
319
+ if span is None:
320
+ return {"uncovered_lines": None, "uncovered_lines_note": _NO_SPAN}
321
+ note = uncovered.note_for(path)
322
+ if note:
323
+ return {"uncovered_lines": None, "uncovered_lines_note": note}
324
+ return {"uncovered_lines": uncovered.in_span(path, span[0], span[1])}
325
+
326
+
327
+ def _commits_fields(root: Path, path: str, span) -> dict:
328
+ if span is None:
329
+ return {"commits": None, "commits_note": _NO_SPAN}
330
+ return {"commits": _function_commits(root, path, span[0], span[1])}
331
+
332
+
333
+ def _tests_fields(contexts: dict, span) -> dict:
334
+ """No span means silence, not guidance: nothing is missing for a function
335
+ the latest run does not carry."""
336
+ if span is None:
337
+ return {"tests": None}
338
+ tests = _contexts_for_span(contexts, span)
339
+ return {"tests": tests} if tests else {"tests": None, "tests_note": _NO_CONTEXT}
340
+
341
+
342
+ def _contexts_for_path(root: Path, cfg, path: str) -> dict[int, set]:
343
+ """line -> test ids for ONE file, off every coveragepy artifact, parsed once.
344
+
345
+ Every matched function used to reparse every artifact to ask the same
346
+ question about the same file.
347
+ """
348
+ from ..coverage_py import parse_coveragepy_contexts
349
+
350
+ by_line: dict[int, set] = {}
351
+ for lane in cfg.lanes:
352
+ artifact = root / lane.artifact
353
+ if lane.parser != "coveragepy" or not artifact.is_file():
354
+ continue
355
+ ctx = parse_coveragepy_contexts(artifact.read_text(encoding="utf-8"),
356
+ path_prefix=lane.path_prefix)
357
+ for line, ids in ctx.get(path, {}).items():
358
+ by_line.setdefault(line, set()).update(ids)
359
+ return by_line
360
+
361
+
362
+ def _contexts_for_span(contexts: dict, span) -> list[str]:
363
+ """The test ids recorded against any line inside one span, sorted."""
364
+ return sorted({t for line, ids in contexts.items()
365
+ if span[0] <= line <= span[1] for t in ids})
366
+
367
+
368
+ def _function_commits(root: Path, rel_path: str, start: int, end: int,
369
+ limit: int = 10) -> list[dict]:
370
+ """Commits that touched one line span, subject AND body, from `git log -L`.
371
+
372
+ The body is what says why a span keeps changing; a subject line rarely does.
373
+ """
374
+ from ..gitio import _git
375
+
376
+ try:
377
+ out = _git(root, "log", f"-L{start},{end}:{rel_path}", f"--format={_LOG_FORMAT}",
378
+ "--date=short", f"--max-count={limit}")
379
+ except GitError:
380
+ return []
381
+ return _parse_log_records(out)
382
+
383
+
384
+ def _parse_log_records(out: str) -> list[dict]:
385
+ """Records out of `git log -L`, dropping the diff hunks printed between them.
386
+
387
+ A line is body text only while a record is open, so a hunk that happens to
388
+ look like prose can never land in one.
389
+ """
390
+ records: list[dict] = []
391
+ body: list[str] | None = None
392
+ for line in out.splitlines():
393
+ if line.startswith("\x01"):
394
+ sha, date, subject = line[1:].split(" ", 2)
395
+ records.append({"sha": sha, "date": date, "subject": subject, "body": ""})
396
+ body = []
397
+ elif body is None:
398
+ continue
399
+ elif line == "\x02":
400
+ records[-1]["body"] = "\n".join(body).strip("\n")
401
+ body = None
402
+ else:
403
+ body.append(line)
404
+ return records
405
+
406
+
407
+ def _explain_extras(p: dict) -> None:
408
+ if "commits" in p:
409
+ _explain_commits(p)
410
+ if "tests" in p:
411
+ _explain_tests(p)
412
+
413
+
414
+ def _explain_commits(p: dict) -> None:
415
+ """A body indents under its commit; a bodiless commit keeps the one line it
416
+ always printed. A paragraph break stays blank rather than six spaces."""
417
+ if p["commits"] is None:
418
+ print(f" commits: {p['commits_note']}")
419
+ return
420
+ for c in p["commits"]:
421
+ print(f" {c['sha']} {c['date']} {c['subject']}")
422
+ for line in c["body"].splitlines():
423
+ print(f" {line}" if line else "")
424
+
425
+
426
+ def _explain_tests(p: dict) -> None:
427
+ if p["tests"] is None:
428
+ if "tests_note" in p:
429
+ print(f" tests: {p['tests_note']}")
430
+ return
431
+ for t in p["tests"]:
432
+ print(f" covered by {t}")
433
+
434
+
435
+ def _print_uncovered(p: dict) -> None:
436
+ print(f" uncovered lines: {_uncovered_text(p)}")
437
+
438
+
439
+ def _uncovered_text(p: dict) -> str:
440
+ lines = p["uncovered_lines"]
441
+ if lines is None:
442
+ return f"none ({p['uncovered_lines_note']})"
443
+ return ", ".join(str(n) for n in lines) or "none"
444
+
445
+
446
+ def _print_history(history: list) -> None:
447
+ for h in history:
448
+ cov = "-" if h["cov"] is None else f"{h['cov']:.0%}"
449
+ crap = "-" if h["crap"] is None else f"{h['crap']:.1f}"
450
+ print(f" run {h['run_id']:>3} @ {h['commit'][:11]} {h['kind']:<9} "
451
+ f"ccn {h['ccn']:>3} cov {cov:>5} crap {crap:>8} {h['flag'] or '-'}")
452
+
453
+
454
+ def _ratchet_mark(p: dict) -> str:
455
+ if "ratchet_mark_note" in p:
456
+ return p["ratchet_mark_note"]
457
+ mark = p["ratchet_mark"]
458
+ return ("none (below target or never marked)" if mark is None
459
+ else f"{mark:.4f} (committed high-water mark)")