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,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