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.
- crapkit/__init__.py +2 -0
- crapkit/__main__.py +5 -0
- crapkit/_pygdefer.py +86 -0
- crapkit/analyze.py +375 -0
- crapkit/cache.py +58 -0
- crapkit/churn.py +113 -0
- crapkit/churn_cache.py +108 -0
- crapkit/churn_log.py +286 -0
- crapkit/cli/__init__.py +316 -0
- crapkit/cli/_shared.py +130 -0
- crapkit/cli/admin.py +650 -0
- crapkit/cli/analyses.py +144 -0
- crapkit/cli/parser.py +384 -0
- crapkit/cli/queue.py +926 -0
- crapkit/cli/ratchet_cmds.py +172 -0
- crapkit/cli/reports.py +459 -0
- crapkit/cli/scoring.py +500 -0
- crapkit/cli/verifying.py +580 -0
- crapkit/config.py +289 -0
- crapkit/coupling.py +89 -0
- crapkit/coverage_istanbul.py +225 -0
- crapkit/coverage_py.py +87 -0
- crapkit/covstream.py +320 -0
- crapkit/diffparse.py +98 -0
- crapkit/digest.py +191 -0
- crapkit/discover.py +365 -0
- crapkit/doctor.py +308 -0
- crapkit/dup.py +179 -0
- crapkit/errors.py +18 -0
- crapkit/gitio.py +504 -0
- crapkit/hook.py +167 -0
- crapkit/junitparse.py +87 -0
- crapkit/lanes.py +373 -0
- crapkit/lizardcognitive.py +238 -0
- crapkit/mcp_server.py +167 -0
- crapkit/merge.py +77 -0
- crapkit/mutate.py +96 -0
- crapkit/mutate_pool.py +152 -0
- crapkit/override.py +94 -0
- crapkit/packet.py +343 -0
- crapkit/ratchet.py +236 -0
- crapkit/ratchet_report.py +135 -0
- crapkit/sarif.py +82 -0
- crapkit/sarifio.py +49 -0
- crapkit/scaffold.py +361 -0
- crapkit/score.py +255 -0
- crapkit/snapshot.py +51 -0
- crapkit/store.py +1066 -0
- crapkit/uncovered.py +131 -0
- crapkit/universe.py +157 -0
- crapkit/verify.py +194 -0
- crapkit/watch.py +112 -0
- crapkit/worklist.py +290 -0
- crapkit-0.2.0.dist-info/METADATA +802 -0
- crapkit-0.2.0.dist-info/RECORD +59 -0
- crapkit-0.2.0.dist-info/WHEEL +5 -0
- crapkit-0.2.0.dist-info/entry_points.txt +2 -0
- crapkit-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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)")
|