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
crapkit/cli/verifying.py
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
"""The verdict commands: `verify` (baseline pick, lanes, gate/ratchet/failure
|
|
2
|
+
evaluation, override audit, claim release), `hook-precommit` (the staged-function
|
|
3
|
+
ceiling gate and its audited env override) and `test-scoped` (routing changed
|
|
4
|
+
files to their scope's isolated test command). All three answer the same
|
|
5
|
+
question at different moments: does this change hold?"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import hashlib
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ..errors import ConfigError, CrapkitError, ToolError
|
|
14
|
+
from ..store import SnapshotStore
|
|
15
|
+
from ._shared import (_analysis_tools, _dirty_tag, _emit_findings, _gate_line,
|
|
16
|
+
_load_ratchet_or_die, _load_repo_config, _print_json, _write_tsv)
|
|
17
|
+
from .scoring import _scored_run
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _emit_verify_findings(root: Path, args, verdict) -> None:
|
|
21
|
+
if not (args.sarif or args.github):
|
|
22
|
+
return
|
|
23
|
+
from ..sarif import gate_results, regression_results
|
|
24
|
+
|
|
25
|
+
_emit_findings(root, args.sarif, args.github,
|
|
26
|
+
gate_results(verdict.gate_violations)
|
|
27
|
+
+ regression_results(verdict.ratchet_regressions))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _no_baseline(root: Path) -> str:
|
|
31
|
+
return (f"no trusted scored baseline in {root} — run `crapkit coverage` first "
|
|
32
|
+
"(failed verifies and hook runs never serve as baselines)")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _taint_note(pick) -> str:
|
|
36
|
+
"""Why the newest trusted run is not the baseline, and the two ways past it.
|
|
37
|
+
|
|
38
|
+
Both escapes are in the line because both are legitimate: answer the
|
|
39
|
+
findings, or accept the newer run by name, which is a visible act somebody
|
|
40
|
+
can audit later.
|
|
41
|
+
"""
|
|
42
|
+
fallback = ("nothing older is left to measure against" if pick.run is None else
|
|
43
|
+
f"measuring against run {pick.run['id']} @ {pick.run['commit'][:11]} instead")
|
|
44
|
+
return (f"run {pick.skipped['id']} is not the baseline: verify run {pick.blocker['id']} "
|
|
45
|
+
f"FAILED with {pick.blocker['findings']} finding(s) and no passing verify has "
|
|
46
|
+
f"cleared it since — {fallback}, so those findings stay visible. Fix them, or "
|
|
47
|
+
f"pass `--baseline {pick.skipped['id']}` to accept the newer run deliberately.")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _named_baseline(store: SnapshotStore, root: Path, requested: int) -> dict:
|
|
51
|
+
"""`--baseline ID` bypasses the taint rule: naming a run is the deliberate act."""
|
|
52
|
+
from ..store import trusted_runs
|
|
53
|
+
|
|
54
|
+
baseline = next((r for r in trusted_runs(store) if r["id"] == requested), None)
|
|
55
|
+
if baseline is None:
|
|
56
|
+
raise CrapkitError(_no_baseline(root))
|
|
57
|
+
return baseline
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _verify_baseline(root: Path, store: SnapshotStore, requested: int | None) -> dict:
|
|
61
|
+
"""The trusted run this verify measures against."""
|
|
62
|
+
from ..store import pick_baseline
|
|
63
|
+
|
|
64
|
+
if requested is not None:
|
|
65
|
+
return _named_baseline(store, root, requested)
|
|
66
|
+
pick = pick_baseline(store.list_runs())
|
|
67
|
+
if pick.run is None:
|
|
68
|
+
raise CrapkitError(_taint_note(pick) if pick.blocker else _no_baseline(root))
|
|
69
|
+
if pick.blocker:
|
|
70
|
+
print(f"warning: {_taint_note(pick)}", file=sys.stderr)
|
|
71
|
+
return pick.run
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _require_ancestor(git, commit: str) -> None:
|
|
75
|
+
from ..errors import GitError
|
|
76
|
+
|
|
77
|
+
if not git.is_ancestor(commit):
|
|
78
|
+
raise GitError(
|
|
79
|
+
f"baseline commit {commit[:11]} is not an ancestor of HEAD "
|
|
80
|
+
"(rebase or amend rewrote history) — run `crapkit coverage` for a fresh baseline")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _baseline_behind(git, store: SnapshotStore, basis: str) -> dict:
|
|
84
|
+
"""The newest trusted run at or behind the fork point.
|
|
85
|
+
|
|
86
|
+
A run made further up the branch would measure the diff from its own commit,
|
|
87
|
+
and everything committed before it stops being touched — which is exactly the
|
|
88
|
+
shrinking --base exists to stop.
|
|
89
|
+
"""
|
|
90
|
+
from ..store import trusted_runs
|
|
91
|
+
|
|
92
|
+
behind = [r for r in trusted_runs(store) if git.is_ancestor(r["commit"], basis)]
|
|
93
|
+
if not behind:
|
|
94
|
+
raise CrapkitError(
|
|
95
|
+
f"no trusted scored run at or behind {basis[:11]} — run `crapkit coverage` "
|
|
96
|
+
"on the base commit before verifying against it")
|
|
97
|
+
return behind[-1]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _tsv_baseline(root: Path, rel: str) -> dict:
|
|
101
|
+
"""A baseline read from a file the repo carries, for a clone whose .crapkit/
|
|
102
|
+
is gitignored. It holds no lane provenance, so it can neither report a
|
|
103
|
+
shrinking suite nor forgive a failure the baseline run already had."""
|
|
104
|
+
from ..verify import parse_baseline_tsv
|
|
105
|
+
|
|
106
|
+
path = root / rel
|
|
107
|
+
if not path.is_file():
|
|
108
|
+
raise CrapkitError(f"no baseline file at {path} — write one with `verify --emit-baseline`")
|
|
109
|
+
try:
|
|
110
|
+
parsed = parse_baseline_tsv(path.read_text(encoding="utf-8"))
|
|
111
|
+
except ValueError as exc:
|
|
112
|
+
raise ConfigError(f"unreadable baseline file {rel}: {exc}") from exc
|
|
113
|
+
return {"id": None, "commit": parsed.commit, "kind": parsed.kind,
|
|
114
|
+
"lanes": {}, "rows": parsed.rows}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _pick_baseline(root: Path, store: SnapshotStore, args, basis: str | None, git) -> dict:
|
|
118
|
+
if args.baseline_tsv:
|
|
119
|
+
return _tsv_baseline(root, args.baseline_tsv)
|
|
120
|
+
if basis:
|
|
121
|
+
return _baseline_behind(git, store, basis)
|
|
122
|
+
return _verify_baseline(root, store, args.baseline)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _verify_basis(root: Path, store: SnapshotStore, args, git) -> tuple[dict, str]:
|
|
126
|
+
"""(the baseline record, the commit the diff is measured from).
|
|
127
|
+
|
|
128
|
+
--base pins the basis to merge-base(REF, HEAD) instead of the baseline run's
|
|
129
|
+
own commit; without it the two are the same commit and nothing changes.
|
|
130
|
+
"""
|
|
131
|
+
from ..gitio import merge_base
|
|
132
|
+
|
|
133
|
+
basis = merge_base(root, args.base) if args.base else None
|
|
134
|
+
baseline = _pick_baseline(root, store, args, basis, git)
|
|
135
|
+
_require_ancestor(git, baseline["commit"])
|
|
136
|
+
return baseline, basis or baseline["commit"]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _emit_baseline(root: Path, store: SnapshotStore, baseline: dict, rel: str | None) -> None:
|
|
140
|
+
"""Write the baseline this run used as a portable file, before the verdict:
|
|
141
|
+
a run that ends in a failure still owes the operator its basis."""
|
|
142
|
+
from ..verify import baseline_tsv_lines
|
|
143
|
+
|
|
144
|
+
if not rel:
|
|
145
|
+
return
|
|
146
|
+
rows = baseline.get("rows")
|
|
147
|
+
if rows is None:
|
|
148
|
+
rows = store.read_scored(baseline["id"])
|
|
149
|
+
_write_tsv(root / rel, baseline_tsv_lines(baseline["commit"], baseline["kind"], rows))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _verify_store(root: Path, tsv_baseline: str | None) -> SnapshotStore:
|
|
153
|
+
"""A fresh clone has no .crapkit/ at all. With a portable baseline the store
|
|
154
|
+
is created here, since this run is the first thing that will ever write it."""
|
|
155
|
+
db_path = root / ".crapkit" / "crap.sqlite"
|
|
156
|
+
if not (db_path.is_file() or tsv_baseline):
|
|
157
|
+
raise CrapkitError(f"no baseline snapshot in {root} — run `crapkit coverage` first")
|
|
158
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
return SnapshotStore(db_path)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _ratchet_sha256(path: Path) -> str | None:
|
|
163
|
+
"""The ratchet's bytes as the verdict saw them — hashed before a clean pass
|
|
164
|
+
tightens the file, so the receipt names the input, not the output."""
|
|
165
|
+
if not path.is_file():
|
|
166
|
+
return None
|
|
167
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _guard_ratchet_stamp(ratchet_path: Path, name: str) -> None:
|
|
171
|
+
"""Refuse to weigh fresh scores against marks another metric produced.
|
|
172
|
+
|
|
173
|
+
Runs before the lanes do: a metric bump that silently kept 40k old marks is
|
|
174
|
+
what this exists to stop, and finding out after a 40-minute run is too late.
|
|
175
|
+
"""
|
|
176
|
+
from ..ratchet import metric_version, read_stamp, stamp_conflict
|
|
177
|
+
|
|
178
|
+
if not ratchet_path.is_file():
|
|
179
|
+
return
|
|
180
|
+
recorded = read_stamp(ratchet_path.read_text(encoding="utf-8"))
|
|
181
|
+
if not recorded:
|
|
182
|
+
print(f"warning: {name} carries no metric stamp (written before stamping) — "
|
|
183
|
+
"re-baseline with `crapkit ratchet seed` to stamp it", file=sys.stderr)
|
|
184
|
+
return
|
|
185
|
+
conflict = stamp_conflict(recorded, metric_version())
|
|
186
|
+
if conflict:
|
|
187
|
+
raise ConfigError(conflict)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _apply_verify_override(store: SnapshotStore, run_id: int, root: Path, cfg, verdict, reason):
|
|
191
|
+
"""Grant --override for pure gate violations; regressions and new failures never qualify."""
|
|
192
|
+
from ..override import record_override
|
|
193
|
+
|
|
194
|
+
if verdict.ok or not reason or not verdict.gate_violations \
|
|
195
|
+
or verdict.ratchet_regressions or verdict.new_failures:
|
|
196
|
+
return verdict, []
|
|
197
|
+
record_override(store=store, run_id=run_id, root=root, ratchet_file=cfg.ratchet_file,
|
|
198
|
+
alert_command=cfg.alert_command, violations=verdict.gate_violations,
|
|
199
|
+
reason=reason)
|
|
200
|
+
overridden = verdict.gate_violations
|
|
201
|
+
return verdict._replace(ok=True, gate_violations=[]), overridden
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _settle_verify(store: SnapshotStore, run_id: int, verdict, overridden,
|
|
205
|
+
ratchet_path: Path, ratchet, scored, cfg) -> None:
|
|
206
|
+
"""Stamp the verdict; a clean pass (not an override) tightens the ratchet."""
|
|
207
|
+
from ..ratchet import dump_ratchet, update_ratchet
|
|
208
|
+
from ..verify import dirty_counts
|
|
209
|
+
|
|
210
|
+
store.set_verdict_ok(run_id, verdict.ok, findings=sum(dirty_counts(verdict)))
|
|
211
|
+
if verdict.ok and not overridden:
|
|
212
|
+
updated = update_ratchet(ratchet, scored, target=cfg.target,
|
|
213
|
+
scope_targets=cfg.scope_targets)
|
|
214
|
+
ratchet_path.write_text(dump_ratchet(updated), encoding="utf-8", newline="\n")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _release_claims(store: SnapshotStore, git, cfg, scored) -> None:
|
|
218
|
+
"""Verify is the only command that both rescores everything and knows where
|
|
219
|
+
HEAD is, so it is the one that can tell a finished claim from a held one."""
|
|
220
|
+
from ..worklist import closable_claims
|
|
221
|
+
|
|
222
|
+
claims = store.open_claims()
|
|
223
|
+
if not claims:
|
|
224
|
+
return
|
|
225
|
+
stale = {c["commit"] for c in claims if not git.is_ancestor(c["commit"])}
|
|
226
|
+
store.close_claims(closable_claims(claims, scored, target=cfg.target,
|
|
227
|
+
scope_targets=cfg.scope_targets, stale_commits=stale))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _print_verify_findings(verdict, overridden) -> None:
|
|
231
|
+
dirty_ids = set(verdict.dirty_failures)
|
|
232
|
+
for v in verdict.gate_violations:
|
|
233
|
+
print(_gate_line(v))
|
|
234
|
+
for r in verdict.ratchet_regressions:
|
|
235
|
+
print(f" RATCHET {r.path} {r.long_name}: {r.recorded} -> {r.fresh_crap}{_dirty_tag(r.dirty)}")
|
|
236
|
+
for f in verdict.new_failures:
|
|
237
|
+
print(f" NEW FAILURE {f}{_dirty_tag(f in dirty_ids)}")
|
|
238
|
+
for v in overridden:
|
|
239
|
+
print(f" OVERRIDDEN {v.path}:{v.start} {v.long_name}")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _print_finding_split(verdict) -> None:
|
|
243
|
+
"""A verdict measures the working tree, so a concurrent session's edits land
|
|
244
|
+
in it. One line says how much of this one is not yours."""
|
|
245
|
+
from ..verify import dirty_counts
|
|
246
|
+
|
|
247
|
+
committed, dirty = dirty_counts(verdict)
|
|
248
|
+
if committed or dirty:
|
|
249
|
+
print(f" findings: {committed} committed / {dirty} dirty (uncommitted tracked edits)")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _verify_exit_code(verdict, diff_breach: bool = False) -> int:
|
|
253
|
+
if verdict.gate_violations:
|
|
254
|
+
return 6
|
|
255
|
+
if verdict.ratchet_regressions:
|
|
256
|
+
return 7
|
|
257
|
+
if verdict.new_failures:
|
|
258
|
+
return 8
|
|
259
|
+
return 9 if diff_breach else 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _diff_cover_breach(cfg, uncovered: list) -> bool:
|
|
263
|
+
if cfg.diff_uncovered_max is None:
|
|
264
|
+
return False
|
|
265
|
+
if len(uncovered) <= cfg.diff_uncovered_max:
|
|
266
|
+
return False
|
|
267
|
+
print(f"diff coverage: {len(uncovered)} uncovered changed line(s) over the ceiling "
|
|
268
|
+
f"{cfg.diff_uncovered_max}", file=sys.stderr)
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _baseline_failures(baseline: dict) -> set:
|
|
273
|
+
return {f for prov in baseline["lanes"].values() for f in prov.get("failures", ())}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _verify_attribution(verdict) -> dict:
|
|
277
|
+
from ..verify import dirty_counts
|
|
278
|
+
|
|
279
|
+
committed, dirty = dirty_counts(verdict)
|
|
280
|
+
return {"committed_findings": committed, "dirty_findings": dirty,
|
|
281
|
+
"dirty_failures": list(verdict.dirty_failures)}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _verify_result(verdict, overridden, run_id: int, baseline: dict, commit: str, ranges,
|
|
285
|
+
uncovered: list) -> dict:
|
|
286
|
+
return {
|
|
287
|
+
"ok": verdict.ok,
|
|
288
|
+
"run_id": run_id,
|
|
289
|
+
"baseline_run": baseline["id"],
|
|
290
|
+
"baseline_commit": baseline["commit"],
|
|
291
|
+
"commit": commit,
|
|
292
|
+
"changed_files": len(ranges),
|
|
293
|
+
"gate_violations": [v._asdict() for v in verdict.gate_violations],
|
|
294
|
+
"ratchet_regressions": [r._asdict() for r in verdict.ratchet_regressions],
|
|
295
|
+
"new_failures": verdict.new_failures,
|
|
296
|
+
"overridden": [v._asdict() for v in overridden],
|
|
297
|
+
"diff_uncovered_count": len(uncovered),
|
|
298
|
+
"diff_uncovered": [{"path": p, "line": ln} for p, ln in uncovered[:50]],
|
|
299
|
+
**_verify_attribution(verdict),
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _warn_diff_uncovered(uncovered: list) -> None:
|
|
304
|
+
if not uncovered:
|
|
305
|
+
return
|
|
306
|
+
print(f"warning: {len(uncovered)} changed line(s) have no coverage", file=sys.stderr)
|
|
307
|
+
for path, line in uncovered[:20]:
|
|
308
|
+
print(f" uncovered {path}:{line}", file=sys.stderr)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _report_verify(as_json: bool, out: dict, verdict, overridden) -> None:
|
|
312
|
+
if as_json:
|
|
313
|
+
_print_json(out)
|
|
314
|
+
return
|
|
315
|
+
state = "OK" if verdict.ok else "FAILED"
|
|
316
|
+
print(f"verify {state} @ {out['commit'][:11]} vs baseline {out['baseline_commit'][:11]} "
|
|
317
|
+
f"({out['changed_files']} changed files)")
|
|
318
|
+
_print_verify_findings(verdict, overridden)
|
|
319
|
+
_print_finding_split(verdict)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def cmd_verify(args: argparse.Namespace) -> int:
|
|
323
|
+
from ..diffparse import changed_ranges
|
|
324
|
+
from ..gitio import GitFacts, diff_since
|
|
325
|
+
from ..uncovered import missing_by_path
|
|
326
|
+
from ..verify import diff_uncovered, evaluate
|
|
327
|
+
|
|
328
|
+
root = Path(args.repo).resolve()
|
|
329
|
+
cfg = _load_repo_config(root)
|
|
330
|
+
if not cfg.lanes:
|
|
331
|
+
raise ConfigError("verify needs [[lane]] declarations — coverage is half the verdict")
|
|
332
|
+
store = _verify_store(root, args.baseline_tsv)
|
|
333
|
+
_guard_ratchet_stamp(root / cfg.ratchet_file, cfg.ratchet_file)
|
|
334
|
+
# One context for the whole command: the ancestry checks, the lane runner and
|
|
335
|
+
# this attribution all used to spawn their own git. Asking here also FIXES the
|
|
336
|
+
# dirty set before any lane command runs, so a lane writing into a tracked file
|
|
337
|
+
# cannot enlarge the set this verdict blames on somebody else.
|
|
338
|
+
git = GitFacts(root)
|
|
339
|
+
dirty = set(git.status_names())
|
|
340
|
+
baseline, basis = _verify_basis(root, store, args, git)
|
|
341
|
+
_emit_baseline(root, store, baseline, args.emit_baseline)
|
|
342
|
+
|
|
343
|
+
commit, scored, provenance, lane_errors, fresh_failures, tool_versions, _, _ = _scored_run(
|
|
344
|
+
root, cfg, list(cfg.lanes), reuse_artifacts=args.reuse_artifacts,
|
|
345
|
+
reuse_unchanged=args.reuse_unchanged, git=git)
|
|
346
|
+
if lane_errors:
|
|
347
|
+
raise ToolError(f"verify cannot conclude with failed lanes: {'; '.join(lane_errors)}")
|
|
348
|
+
|
|
349
|
+
ranges = changed_ranges(diff_since(root, basis))
|
|
350
|
+
ratchet_path = root / cfg.ratchet_file
|
|
351
|
+
ratchet = _load_ratchet_or_die(ratchet_path, cfg.ratchet_file)
|
|
352
|
+
receipt = {"tool_versions": tool_versions, "ratchet_sha256": _ratchet_sha256(ratchet_path)}
|
|
353
|
+
|
|
354
|
+
verdict = evaluate(fresh=scored, changed_ranges=ranges, ratchet=ratchet,
|
|
355
|
+
baseline_failures=_baseline_failures(baseline), fresh_failures=fresh_failures,
|
|
356
|
+
target=cfg.target, scope_targets=cfg.scope_targets, dirty_paths=dirty)
|
|
357
|
+
verdict = _maybe_flake_retry(root, cfg, provenance, verdict)
|
|
358
|
+
_warn_suite_shrink(baseline, provenance)
|
|
359
|
+
uncovered = diff_uncovered(ranges, missing_by_path(root, cfg))
|
|
360
|
+
_warn_diff_uncovered(uncovered)
|
|
361
|
+
breach = _diff_cover_breach(cfg, uncovered)
|
|
362
|
+
if breach:
|
|
363
|
+
verdict = verdict._replace(ok=False) # a breached run never advances the baseline
|
|
364
|
+
run_id = store.write_run(commit=commit, tool_versions=tool_versions, rows=scored,
|
|
365
|
+
lanes=provenance, kind="verify")
|
|
366
|
+
verdict, overridden = _apply_verify_override(store, run_id, root, cfg, verdict, args.override)
|
|
367
|
+
_settle_verify(store, run_id, verdict, overridden, ratchet_path, ratchet, scored, cfg)
|
|
368
|
+
_release_claims(store, git, cfg, scored)
|
|
369
|
+
_emit_verify_findings(root, args, verdict)
|
|
370
|
+
|
|
371
|
+
_report_verify(args.json,
|
|
372
|
+
{**_verify_result(verdict, overridden, run_id, baseline, commit, ranges, uncovered),
|
|
373
|
+
**receipt},
|
|
374
|
+
verdict, overridden)
|
|
375
|
+
return _verify_exit_code(verdict, breach)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _flake_retry(root: Path, cfg, provenance: dict, new_failures: set) -> set:
|
|
379
|
+
"""Rerun just the newly-failed ids in lanes that declare retest_command;
|
|
380
|
+
lanes without one keep their failures untouched."""
|
|
381
|
+
from ..lanes import retest_lane
|
|
382
|
+
|
|
383
|
+
survivors = set(new_failures)
|
|
384
|
+
for lane in cfg.lanes:
|
|
385
|
+
lane_new = set(provenance.get(lane.name, {}).get("failures", ())) & new_failures
|
|
386
|
+
if not lane_new or not lane.retest_command:
|
|
387
|
+
continue
|
|
388
|
+
survivors -= retest_lane(root, lane, lane_new)
|
|
389
|
+
return survivors
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _maybe_flake_retry(root: Path, cfg, provenance: dict, verdict):
|
|
393
|
+
if not verdict.new_failures:
|
|
394
|
+
return verdict
|
|
395
|
+
survivors = _flake_retry(root, cfg, provenance, set(verdict.new_failures))
|
|
396
|
+
if len(survivors) == len(verdict.new_failures):
|
|
397
|
+
return verdict
|
|
398
|
+
print(f"flake retry: {len(verdict.new_failures) - len(survivors)} of "
|
|
399
|
+
f"{len(verdict.new_failures)} new failures passed on rerun", file=sys.stderr)
|
|
400
|
+
ok = not (verdict.gate_violations or verdict.ratchet_regressions or survivors)
|
|
401
|
+
return verdict._replace(ok=ok, new_failures=sorted(survivors))
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _warn_suite_shrink(baseline: dict, provenance: dict) -> None:
|
|
405
|
+
"""Suite decay passes a pass/fail check silently; say it out loud."""
|
|
406
|
+
for name, prov in provenance.items():
|
|
407
|
+
base = baseline.get("lanes", {}).get(name, {})
|
|
408
|
+
b_total, b_skip = base.get("tests_total"), base.get("tests_skipped")
|
|
409
|
+
if b_total and prov.get("tests_total", 0) < b_total:
|
|
410
|
+
print(f"warning: lane {name!r} runs {b_total - prov['tests_total']} "
|
|
411
|
+
f"fewer tests than the baseline", file=sys.stderr)
|
|
412
|
+
if b_skip is not None and prov.get("tests_skipped", 0) > b_skip:
|
|
413
|
+
print(f"warning: lane {name!r} skips {prov['tests_skipped'] - b_skip} "
|
|
414
|
+
f"more tests than the baseline", file=sys.stderr)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _under_scope_path(path: str, scope_path: str) -> bool:
|
|
418
|
+
"""True when path is the declared scope path itself or sits under it."""
|
|
419
|
+
return path == scope_path or path.startswith(scope_path.rstrip("/") + "/")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _owning_scope(path: str, scope_paths: dict[str, tuple[str, ...]]) -> str | None:
|
|
423
|
+
"""The scope matching deepest, so a nested scope wins over the parent that also contains it."""
|
|
424
|
+
matches = [(len(sp), name) for name, paths in scope_paths.items()
|
|
425
|
+
for sp in paths if _under_scope_path(path, sp)]
|
|
426
|
+
return max(matches)[1] if matches else None
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _is_test_path(path: str) -> bool:
|
|
430
|
+
parts = path.lower().split("/")
|
|
431
|
+
return any(p in ("test", "tests", "__tests__") for p in parts[:-1]) or parts[-1].startswith("test_") or ".test." in parts[-1] or ".spec." in parts[-1]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
_AMBIGUOUS_TEST = (
|
|
435
|
+
"{path} is a test file outside every scope and {n} scopes declare templates "
|
|
436
|
+
"({names}). Two routes work: name a SOURCE file from the scope you mean and "
|
|
437
|
+
"give that scope a scoped_tests template with no {{files}} placeholder, which "
|
|
438
|
+
"runs the scope's whole suite; or move the test file under one scope's paths, "
|
|
439
|
+
"where a {{files}} template can name it."
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _route_unowned(path: str, templates: dict) -> str:
|
|
444
|
+
"""Test directories sit outside every scope by design, so a test file routes
|
|
445
|
+
to the templated scope — unambiguously when there is exactly one."""
|
|
446
|
+
if _is_test_path(path) and len(templates) == 1:
|
|
447
|
+
return next(iter(templates))
|
|
448
|
+
if _is_test_path(path) and templates:
|
|
449
|
+
raise ConfigError(_AMBIGUOUS_TEST.format(path=path, n=len(templates),
|
|
450
|
+
names=", ".join(sorted(templates))))
|
|
451
|
+
raise ConfigError(f"{path} belongs to no declared scope")
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _group_files_by_scope(files, scope_paths: dict, templates: dict) -> dict[str, list[str]]:
|
|
455
|
+
"""Route each requested file to its owning scope; unowned or untemplated is a config error."""
|
|
456
|
+
by_scope: dict[str, list[str]] = {}
|
|
457
|
+
for raw in files:
|
|
458
|
+
path = raw.replace("\\", "/")
|
|
459
|
+
owner = _owning_scope(path, scope_paths) or _route_unowned(path, templates)
|
|
460
|
+
if owner not in templates:
|
|
461
|
+
raise ConfigError(f"no [crapkit.scoped_tests] template for scope {owner!r}")
|
|
462
|
+
by_scope.setdefault(owner, []).append(path)
|
|
463
|
+
return by_scope
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _scoped_command(template: str, files: list[str]) -> str:
|
|
467
|
+
"""The scope's test command: its template with the quoted file list, or the
|
|
468
|
+
template verbatim when it names no {files}.
|
|
469
|
+
|
|
470
|
+
A template without {files} runs the scope's whole suite. That is the coarse
|
|
471
|
+
but working escape for the ordinary layout, where tests live in a top-level
|
|
472
|
+
tests/ directory owned by no scope: substituting a SOURCE file there hands
|
|
473
|
+
pytest a collection target with no tests in it (exit 5).
|
|
474
|
+
"""
|
|
475
|
+
if "{files}" not in template:
|
|
476
|
+
return template
|
|
477
|
+
return template.replace("{files}", " ".join(f'"{f}"' for f in files))
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def cmd_test_scoped(args: argparse.Namespace) -> int:
|
|
481
|
+
import subprocess
|
|
482
|
+
|
|
483
|
+
root = Path(args.repo).resolve()
|
|
484
|
+
cfg = _load_repo_config(root)
|
|
485
|
+
templates = dict(cfg.scoped_tests)
|
|
486
|
+
by_scope = _group_files_by_scope(args.files, cfg.scope_paths, templates)
|
|
487
|
+
|
|
488
|
+
for scope, files in sorted(by_scope.items()):
|
|
489
|
+
command = _scoped_command(templates[scope], files)
|
|
490
|
+
proc = subprocess.run(command, shell=True, cwd=root)
|
|
491
|
+
if proc.returncode != 0:
|
|
492
|
+
print(f"crapkit: scoped tests for {scope!r} failed (runner exit {proc.returncode})", file=sys.stderr)
|
|
493
|
+
return 1 # the runner's own code would collide with crapkit's 3/5/6/7/8
|
|
494
|
+
return 0
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _note_stale_staged(root: Path, flagged_paths: set) -> None:
|
|
498
|
+
"""A developer who fixed the file but forgot `git add` gets told exactly that.
|
|
499
|
+
|
|
500
|
+
The difference is git's to decide, through its own filters: a byte compare
|
|
501
|
+
of the blob against the file called every CRLF checkout stale and sent the
|
|
502
|
+
reader hunting for a staging problem that did not exist.
|
|
503
|
+
"""
|
|
504
|
+
from ..gitio import unstaged_paths
|
|
505
|
+
|
|
506
|
+
for path in sorted(flagged_paths & unstaged_paths(root)):
|
|
507
|
+
print(f" note: {path} differs from the working tree — the STAGED blob is "
|
|
508
|
+
"what commits; re-stage with `git add` if you already fixed it.")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _grant_env_override(root: Path, cfg, violations, reason: str) -> None:
|
|
512
|
+
"""The audited hook override: alert line, ratchet debt (staged into the
|
|
513
|
+
pending commit), and a snapshot record — all three or nothing."""
|
|
514
|
+
from ..gitio import head_commit, stage_path
|
|
515
|
+
from ..override import record_override
|
|
516
|
+
from ..verify import GateViolation
|
|
517
|
+
|
|
518
|
+
db_path = root / ".crapkit" / "crap.sqlite"
|
|
519
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
520
|
+
store = SnapshotStore(db_path)
|
|
521
|
+
run_id = store.write_run(commit=head_commit(root), tool_versions={}, rows=[],
|
|
522
|
+
lanes={"_hook_override": {"staged": True}}, kind="hook")
|
|
523
|
+
gate = [GateViolation(v.path, v.long_name, v.start, v.ccn, 0.0, float(v.ccn * v.ccn + v.ccn), "decompose")
|
|
524
|
+
for v in violations]
|
|
525
|
+
record_override(store=store, run_id=run_id, root=root, ratchet_file=cfg.ratchet_file,
|
|
526
|
+
alert_command=cfg.alert_command, violations=gate, reason=reason,
|
|
527
|
+
raise_marks=False)
|
|
528
|
+
stage_path(root, cfg.ratchet_file) # the debt must be IN the commit, not dangling
|
|
529
|
+
print(f"crapkit: override granted with full audit ({reason}).")
|
|
530
|
+
print("crapkit: unset CRAPKIT_OVERRIDE_REASON now — while set it grants again on every commit.")
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _warn_unscoped_staged(unscoped: list) -> None:
|
|
534
|
+
if unscoped:
|
|
535
|
+
print(f"crapkit gate: {len(unscoped)} staged file(s) belong to no scope and were "
|
|
536
|
+
f"not gated: {', '.join(unscoped)} — add a [[scope]] claiming them "
|
|
537
|
+
"(see docs/configuration.md)", file=sys.stderr)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _staged_gate(root: Path, cfg):
|
|
541
|
+
"""The gate's verdict, with both git reads started before lizard is imported.
|
|
542
|
+
|
|
543
|
+
Neither answer is needed until the import is paid for and the two do not
|
|
544
|
+
depend on each other, so the spawns run underneath it. No git ANSWER is read
|
|
545
|
+
until the analyzer is in hand: a machine without lizard still exits 5 having
|
|
546
|
+
said nothing about the commit.
|
|
547
|
+
"""
|
|
548
|
+
from ..gitio import staged_reads
|
|
549
|
+
|
|
550
|
+
with staged_reads(root) as reads:
|
|
551
|
+
_analysis_tools() # importing crapkit.hook reaches lizard too, so it waits its turn
|
|
552
|
+
from ..hook import gate_staged
|
|
553
|
+
|
|
554
|
+
return gate_staged(root, cfg, reads)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def cmd_hook_precommit(args: argparse.Namespace) -> int:
|
|
558
|
+
import os
|
|
559
|
+
|
|
560
|
+
root = Path(args.repo).resolve()
|
|
561
|
+
cfg = _load_repo_config(root)
|
|
562
|
+
gate = _staged_gate(root, cfg)
|
|
563
|
+
_warn_unscoped_staged(gate.unscoped)
|
|
564
|
+
violations = gate.violations
|
|
565
|
+
if not violations:
|
|
566
|
+
return 0
|
|
567
|
+
print(f"crapkit gate: {len(violations)} staged function(s) exceed the complexity ceiling of {cfg.target}:")
|
|
568
|
+
for v in violations:
|
|
569
|
+
print(f" ccn {v.ccn:>3} {v.path}:{v.start} {v.long_name}")
|
|
570
|
+
_note_stale_staged(root, {v.path for v in violations})
|
|
571
|
+
|
|
572
|
+
# CRAPKIT_OVERRIDE_REASON is not a bypass: it routes through the full
|
|
573
|
+
# three-record audit and the gate holds unless all three land.
|
|
574
|
+
reason = os.environ.get("CRAPKIT_OVERRIDE_REASON", "").strip()
|
|
575
|
+
if reason:
|
|
576
|
+
_grant_env_override(root, cfg, violations, reason)
|
|
577
|
+
return 0
|
|
578
|
+
|
|
579
|
+
print("decompose before committing (coverage cannot save a function above the target).")
|
|
580
|
+
return 6
|