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
crapkit/cli/scoring.py ADDED
@@ -0,0 +1,500 @@
1
+ """The scoring pipeline and the commands built on it: `inventory` (complexity
2
+ snapshot), `coverage` (lanes run, coverage joined onto a fresh inventory, scored
3
+ run written) and `rescore` (fresh complexity for named files over the latest
4
+ run's coverage, plus its --gate policy). The lane runner lives here because
5
+ scoring is what lanes exist to feed; `verify` borrows _scored_run from it."""
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import NamedTuple
12
+
13
+ from .. import __version__
14
+ from ..cache import merged_cache
15
+ from ..errors import ConfigError, CrapkitError, ToolError
16
+ from ..gitio import GitFacts, ls_files
17
+ from ..snapshot import build_inventory_rows, tsv_lines
18
+ from ..store import SnapshotStore
19
+ from ..universe import assign_files, scan_files
20
+ from ._shared import (_analysis_tools, _emit_findings, _file_sizer, _gate_line,
21
+ _latest_scored, _load_repo_config, _print_json, _ratchet_entries,
22
+ _write_tsv)
23
+
24
+
25
+ def _tracked_files(files_by_scope: dict) -> list[str]:
26
+ """Every scope's files flattened into one sorted list, each path once."""
27
+ return sorted({f for files in files_by_scope.values() for f in files})
28
+
29
+
30
+ def _present_on_disk(root: Path, tracked: list[str]) -> list[str]:
31
+ """Keep the tracked paths that exist, naming each dropped one on stderr."""
32
+ # git ls-files lists staged deletions too; a tracked-but-absent file has no
33
+ # functions and must not crash the run
34
+ present = [f for f in tracked if (root / f).is_file()]
35
+ for gone in set(tracked) - set(present):
36
+ print(f"crapkit: tracked file missing from working tree, skipped: {gone}", file=sys.stderr)
37
+ return present
38
+
39
+
40
+ def _records_by_scope(files_by_scope: dict, records_by_path: dict) -> dict:
41
+ """Regroup per-file analysis records under the scope that owns each file."""
42
+ return {
43
+ scope: [r for f in files for r in records_by_path[f]]
44
+ for scope, files in files_by_scope.items()
45
+ }
46
+
47
+
48
+ def _analysis_workers(cfg) -> int | None:
49
+ """[crapkit] analysis_workers, as ProcessPoolExecutor wants it: 0 means
50
+ 'unset', which is one worker per core — the pool's own default."""
51
+ return cfg.analysis_workers or None
52
+
53
+
54
+ def _analyzed_corpus(root: Path, cache_path: Path, flat: list,
55
+ workers: int | None = None) -> tuple[dict, int]:
56
+ """Analyze the whole corpus cache-first and write the rebuilt cache back.
57
+
58
+ The prior cache dies with this frame on purpose: it holds a second copy of
59
+ every record on a warm corpus, and nothing downstream reads it.
60
+ """
61
+ _, analyze_files, load_cache, save_cache = _analysis_tools()
62
+ prior = load_cache(cache_path)
63
+ records_by_path, cache_hits, new_cache = analyze_files(root, flat, cache=prior, workers=workers)
64
+ # Saved unmerged on purpose: this rebuild is the one point that evicts the
65
+ # entries for content no longer in the corpus.
66
+ save_cache(cache_path, new_cache, prior=prior)
67
+ return records_by_path, cache_hits
68
+
69
+
70
+ class _Corpus(NamedTuple):
71
+ """What the analyzed file list came to: files in, files the byte ceiling cut."""
72
+ files: int
73
+ skipped_max_bytes: int
74
+
75
+
76
+ def _build_inventory(root: Path, cfg, git=None) -> tuple[str, list, _Corpus, int, dict]:
77
+ """Shared by inventory/coverage: returns (commit, rows, corpus, cache_hits, tool_versions)."""
78
+ lizard, *_ = _analysis_tools()
79
+ commit = (git or GitFacts(root)).head_commit()
80
+ universe = scan_files(ls_files(root), cfg, size_of=_file_sizer(root))
81
+ flat = _present_on_disk(root, _tracked_files(universe.by_scope))
82
+ records_by_path, cache_hits = _analyzed_corpus(
83
+ root, root / ".crapkit" / "cache.json", flat, _analysis_workers(cfg))
84
+ rows = build_inventory_rows(_records_by_scope(universe.by_scope, records_by_path))
85
+ tool_versions = {"crapkit": __version__, "lizard": lizard.version}
86
+ return commit, rows, _Corpus(len(flat), len(universe.oversized)), cache_hits, tool_versions
87
+
88
+
89
+ def cmd_inventory(args: argparse.Namespace) -> int:
90
+ root = Path(args.repo).resolve()
91
+ cfg = _load_repo_config(root)
92
+ commit, rows, corpus, cache_hits, tool_versions = _build_inventory(root, cfg)
93
+ state_dir = root / ".crapkit"
94
+
95
+ db_path = Path(args.db) if args.db else state_dir / "crap.sqlite"
96
+ db_path.parent.mkdir(parents=True, exist_ok=True)
97
+ store = SnapshotStore(db_path)
98
+ run_id = store.write_run(commit=commit, tool_versions=tool_versions, rows=rows, kind="inventory")
99
+
100
+ if args.export:
101
+ _write_tsv(root / args.export, tsv_lines(rows))
102
+
103
+ summary = {
104
+ "run_id": run_id,
105
+ "commit": commit,
106
+ "files": corpus.files,
107
+ "functions": len(rows),
108
+ "cache_hits": cache_hits,
109
+ "skipped_max_bytes": corpus.skipped_max_bytes,
110
+ "db": str(db_path),
111
+ }
112
+ if args.json:
113
+ _print_json(summary)
114
+ else:
115
+ print(f"run {run_id} @ {commit[:11]}: {summary['functions']} functions "
116
+ f"in {summary['files']} files ({cache_hits} cached) -> {db_path}")
117
+ return 0
118
+
119
+
120
+ def _lane_reuse(root: Path, lane, scope_paths: dict, reuse_artifacts: bool, reuse_unchanged: bool,
121
+ git) -> bool:
122
+ from ..lanes import lane_unchanged
123
+
124
+ if reuse_artifacts:
125
+ return True
126
+ if reuse_unchanged and lane_unchanged(root, lane, scope_paths, git):
127
+ print(f"crapkit: lane {lane.name!r}: artifact still matches its scopes; reusing without rerun",
128
+ file=sys.stderr)
129
+ return True
130
+ return False
131
+
132
+
133
+ def _progress(message: str) -> None:
134
+ """One write, so two lanes reporting at once cannot split each other's line."""
135
+ sys.stderr.write(f"crapkit: {message}\n")
136
+
137
+
138
+ def _run_one_lane(root: Path, lane, reuse: bool, scope_paths: dict | None, git):
139
+ """One lane's outcome or its error text; a failed lane never sinks the run."""
140
+ from ..lanes import run_lane
141
+
142
+ try:
143
+ return run_lane(root, lane, reuse_artifact=reuse, scope_paths=scope_paths, git=git), ""
144
+ except ToolError as exc:
145
+ return None, str(exc)
146
+
147
+
148
+ def _traced_lane(root: Path, lane, reuse: bool, scope_paths: dict | None, git):
149
+ _progress(f"lane {lane.name!r} started")
150
+ outcome = _run_one_lane(root, lane, reuse, scope_paths, git)
151
+ _progress(f"lane {lane.name!r} finished")
152
+ return outcome
153
+
154
+
155
+ def _execute_parallel(root: Path, ordered, reuse: dict, scope_paths, git, max_parallel: int) -> dict:
156
+ """Lanes are subprocess-bound, so threads are enough: subprocess.run drops the
157
+ GIL for the whole command and each lane streams to its own log file."""
158
+ from concurrent.futures import ThreadPoolExecutor
159
+
160
+ with ThreadPoolExecutor(max_workers=max_parallel) as pool:
161
+ futures = {lane: pool.submit(_traced_lane, root, lane, reuse[lane], scope_paths, git)
162
+ for lane in ordered}
163
+ return {lane: future.result() for lane, future in futures.items()}
164
+
165
+
166
+ def _execute_lanes(root: Path, ordered, reuse: dict, scope_paths, git, max_parallel: int) -> dict:
167
+ """lane -> (outcome, error text), keyed by the Lane itself rather than its
168
+ name, which the config does not force to be unique. Serial below 2, which is
169
+ the default: same thread, same order, none of the started/finished chatter."""
170
+ if max_parallel < 2:
171
+ return {lane: _run_one_lane(root, lane, reuse[lane], scope_paths, git) for lane in ordered}
172
+ return _execute_parallel(root, ordered, reuse, scope_paths, git, max_parallel)
173
+
174
+
175
+ def _collect_lanes(root: Path, lanes, outcomes: dict):
176
+ """Fold the outcomes back together in DECLARATION order, whatever order they
177
+ finished in, and persist every stamp in one write."""
178
+ from ..lanes import write_stamps
179
+
180
+ coverage_by_path: dict[str, list] = {}
181
+ provenance: dict[str, dict] = {}
182
+ lane_errors: dict[str, str] = {}
183
+ stamps: dict[str, dict] = {}
184
+ succeeded = []
185
+ for lane in lanes:
186
+ outcome, error = outcomes[lane]
187
+ if error:
188
+ lane_errors[lane.name] = error
189
+ print(f"crapkit: lane {lane.name!r} FAILED: {error}", file=sys.stderr)
190
+ continue
191
+ for path, fns in outcome.coverage.items():
192
+ coverage_by_path.setdefault(path, []).extend(fns)
193
+ provenance[lane.name] = outcome.provenance
194
+ stamps[lane.artifact] = outcome.stamp
195
+ succeeded.append(lane)
196
+ write_stamps(root, stamps)
197
+ if not succeeded:
198
+ raise ToolError(f"every lane failed: {'; '.join(lane_errors.values())}")
199
+ return coverage_by_path, provenance, lane_errors, succeeded
200
+
201
+
202
+ def _run_lanes(root: Path, lanes, reuse_artifacts: bool, scope_paths: dict | None = None,
203
+ reuse_unchanged: bool = False, max_parallel: int = 1, git=None):
204
+ """Run each lane; a failed lane is recorded and skipped, never fatal alone.
205
+
206
+ Every reuse decision is taken up front, on one thread: it reads the working
207
+ tree and a lane command WRITES to the working tree, so deciding lane by lane
208
+ would let one lane's output change the next lane's answer. Results then merge
209
+ in declaration order however the lanes finished, so max_parallel_lanes moves
210
+ wall time only — never a score.
211
+ """
212
+ from ..lanes import lane_order
213
+
214
+ facts = git or GitFacts(root)
215
+ reuse = {lane: _lane_reuse(root, lane, scope_paths or {}, reuse_artifacts,
216
+ reuse_unchanged, facts)
217
+ for lane in lanes}
218
+ ordered = lane_order(root, list(lanes)) if max_parallel > 1 else list(lanes)
219
+ return _collect_lanes(root, lanes,
220
+ _execute_lanes(root, ordered, reuse, scope_paths, facts, max_parallel))
221
+
222
+
223
+ def _scored_run(root: Path, cfg, lanes, *, reuse_artifacts: bool, reuse_unchanged: bool = False,
224
+ git=None):
225
+ """Shared by coverage/verify: inventory + lanes + score. Returns everything both need.
226
+
227
+ `git` is the caller's GitFacts when it already has one — verify asks for the
228
+ dirty set before this runs, and that answer is the one the lanes must see too.
229
+ """
230
+ from ..score import score_rows
231
+
232
+ git = git or GitFacts(root)
233
+ commit, rows, corpus, cache_hits, tool_versions = _build_inventory(root, cfg, git)
234
+
235
+ coverage_by_path, provenance, lane_errors, succeeded = _run_lanes(
236
+ root, lanes, reuse_artifacts, cfg.scope_paths, reuse_unchanged,
237
+ cfg.max_parallel_lanes, git)
238
+
239
+ # Only scopes a SUCCESSFUL lane covers count as measured; a failed lane's
240
+ # scopes fall back to no-lane flags rather than reading as untested code.
241
+ lane_scopes = {s for lane in succeeded for s in lane.scopes}
242
+ scored = score_rows(rows, coverage_by_path, lane_scopes=lane_scopes, target=cfg.target,
243
+ scope_targets=cfg.scope_targets,
244
+ cc_only_scopes=cfg.coverage_optional_scopes)
245
+ test_failures = {f for prov in provenance.values() for f in prov.get("failures", ())}
246
+ return commit, scored, provenance, lane_errors, test_failures, tool_versions, corpus, cache_hits
247
+
248
+
249
+ def _run_kind(lanes, cfg, failures) -> str:
250
+ """A --lane subset or a run with failed lanes must never serve as the
251
+ verify baseline: its lane set differs from what verify runs, so every
252
+ pre-existing failure in the missing lanes would read as NEW forever."""
253
+ full = not failures and {l.name for l in lanes} == {l.name for l in cfg.lanes}
254
+ return "coverage" if full else "partial"
255
+
256
+
257
+ def _select_lanes(cfg, requested):
258
+ lanes = [l for l in cfg.lanes if requested is None or l.name == requested]
259
+ if not lanes:
260
+ raise ConfigError("no [[lane]] to run — declare lanes in crapkit.toml" if not cfg.lanes
261
+ else f"no lane named {requested!r}")
262
+ return lanes
263
+
264
+
265
+ def _export_scored(root: Path, export: str, scored) -> None:
266
+ from ..score import scored_tsv_lines
267
+
268
+ _write_tsv(root / export, scored_tsv_lines(scored))
269
+
270
+
271
+ def _flag_counts(scored) -> dict[str, int]:
272
+ flags = {"measured": 0, "untested": 0, "no-lane": 0, "cc-only": 0}
273
+ for r in scored:
274
+ flags[r.flag] += 1
275
+ return flags
276
+
277
+
278
+ def _by_scope(scored, cfg) -> dict[str, dict]:
279
+ from ..digest import scope_rollup, scope_totals
280
+
281
+ return scope_rollup(scope_totals(scored, target=cfg.target,
282
+ scope_targets=cfg.scope_targets))
283
+
284
+
285
+ def _coverage_summary(run_id, commit, scored, cfg, provenance, failures, corpus, cache_hits, db_path):
286
+ from ..score import grade
287
+
288
+ flags = _flag_counts(scored)
289
+ over = sum(1 for r in scored if r.crap > cfg.scope_targets.get(r.scope, cfg.target))
290
+ return {
291
+ "run_id": run_id, "commit": commit, "files": corpus.files, "functions": len(scored),
292
+ "cache_hits": cache_hits, "measured": flags["measured"], "untested": flags["untested"],
293
+ "no_lane": flags["no-lane"], "cc_only": flags["cc-only"],
294
+ "skipped_max_bytes": corpus.skipped_max_bytes,
295
+ "over_target": over, "grade": grade(over, len(scored)),
296
+ "by_scope": _by_scope(scored, cfg),
297
+ "crap_load": round(sum(r.crap for r in scored), 2), "lanes": provenance,
298
+ "lane_failures": failures, "db": str(db_path),
299
+ }
300
+
301
+
302
+ def _print_coverage(as_json: bool, summary: dict, cfg, failures: dict) -> None:
303
+ if as_json:
304
+ _print_json(summary)
305
+ return
306
+ print(f"run {summary['run_id']} @ {summary['commit'][:11]}: {summary['functions']} functions scored — "
307
+ f"{summary['measured']} measured / {summary['untested']} untested / "
308
+ f"{summary['no_lane']} no-lane / {summary['cc_only']} cc-only, "
309
+ f"{summary['over_target']} over target {cfg.target}, CRAP load {summary['crap_load']}, "
310
+ f"grade {summary['grade']}")
311
+ for name, err in failures.items():
312
+ print(f" lane {name!r} FAILED: {err}")
313
+
314
+
315
+ def cmd_coverage(args: argparse.Namespace) -> int:
316
+ root = Path(args.repo).resolve()
317
+ cfg = _load_repo_config(root)
318
+ lanes = _select_lanes(cfg, args.lane)
319
+
320
+ commit, scored, provenance, failures, _, tool_versions, corpus, cache_hits = _scored_run(
321
+ root, cfg, lanes, reuse_artifacts=args.reuse_artifacts, reuse_unchanged=args.reuse_unchanged)
322
+
323
+ db_path = root / ".crapkit" / "crap.sqlite"
324
+ db_path.parent.mkdir(parents=True, exist_ok=True)
325
+ store = SnapshotStore(db_path)
326
+ run_id = store.write_run(commit=commit, tool_versions=tool_versions, rows=scored,
327
+ lanes=provenance, kind=_run_kind(lanes, cfg, failures))
328
+ if args.export:
329
+ _export_scored(root, args.export, scored)
330
+ _emit_coverage_findings(root, args, scored, cfg)
331
+
332
+ summary = _coverage_summary(run_id, commit, scored, cfg, provenance, failures,
333
+ corpus, cache_hits, db_path)
334
+ _print_coverage(args.json, summary, cfg, failures)
335
+ return 5 if failures else 0
336
+
337
+
338
+ def _emit_coverage_findings(root: Path, args, scored, cfg) -> None:
339
+ if not (args.sarif or args.github):
340
+ return
341
+ from ..sarif import over_target_results
342
+
343
+ _emit_findings(root, args.sarif, args.github,
344
+ over_target_results(scored, cfg.scope_targets, cfg.target))
345
+
346
+
347
+ def _rescored_records(root: Path, cache_path: Path, flat: list,
348
+ workers: int | None = None) -> dict:
349
+ """Fresh records for `flat`, folded INTO the shared cache rather than over it.
350
+
351
+ A rescore knows about a handful of files; writing its entry map straight out
352
+ would throw away every other file's analysis and leave the next full run cold.
353
+ """
354
+ _, analyze_files, load_cache, save_cache = _analysis_tools()
355
+ prior = load_cache(cache_path)
356
+ records_by_path, _, new_cache = analyze_files(root, flat, cache=prior, workers=workers)
357
+ save_cache(cache_path, merged_cache(prior, new_cache), prior=prior)
358
+ return records_by_path
359
+
360
+
361
+ def _rescore_analyze(root: Path, cfg, files) -> tuple[list, list, dict]:
362
+ """Fresh complexity for the named files; the shared cache is merged, never truncated."""
363
+ from ..hook import file_ceilings
364
+
365
+ rel_paths = sorted({p.replace("\\", "/") for p in files})
366
+ files_by_scope = assign_files(rel_paths, cfg, size_of=_file_sizer(root))
367
+ flat = sorted(set().union(*files_by_scope.values())) if files_by_scope else []
368
+ records_by_path = _rescored_records(root, root / ".crapkit" / "cache.json", flat,
369
+ _analysis_workers(cfg))
370
+ by_scope = {scope: [r for f in scope_files for r in records_by_path[f]]
371
+ for scope, scope_files in files_by_scope.items()}
372
+ return build_inventory_rows(by_scope), flat, file_ceilings(cfg, files_by_scope, flat)
373
+
374
+
375
+ def _rescore_overlay(store: SnapshotStore, latest: dict, rows: list, flat: list, cfg):
376
+ """Fresh complexity joined onto the LATEST run's stale coverage, by NAME first."""
377
+ from ..score import overlay_stale_coverage
378
+
379
+ baseline_scored = store.read_scored(latest["id"])
380
+ in_scope = set(flat)
381
+ lane_scopes = {s for prov in latest["lanes"].values() for s in prov.get("scopes", ())}
382
+ return overlay_stale_coverage(rows, [r for r in baseline_scored if r.path in in_scope],
383
+ lane_scopes=lane_scopes, target=cfg.target,
384
+ scope_targets=cfg.scope_targets,
385
+ cc_only_scopes=cfg.coverage_optional_scopes)
386
+
387
+
388
+ def _rescore_json(overlay, latest: dict) -> None:
389
+ _print_json({
390
+ "baseline_run": latest["id"], "baseline_commit": latest["commit"],
391
+ "functions": [{
392
+ "scope": r.scope, "path": r.path, "function": r.long_name, "start": r.start,
393
+ "end": r.end, "ccn": r.ccn, "cov": r.cov, "flag": r.flag, "crap": r.crap,
394
+ "remedy": r.remedy, "stale_coverage": True,
395
+ } for r in overlay],
396
+ "note": "coverage is the baseline run's; complexity is the working tree's. Run verify for the real verdict.",
397
+ })
398
+
399
+
400
+ def _ceiling_breaches(rows, ceilings: dict[str, int]) -> list:
401
+ """The pre-commit hook's policy over already-scored rows: ccn against the
402
+ file's ceiling, coverage ignored. Shaped as gate violations so verify's
403
+ printer serves this verdict too."""
404
+ from ..verify import GateViolation
405
+
406
+ breaches = [GateViolation(r.path, r.long_name, r.start, r.ccn, r.cov, r.crap, r.remedy)
407
+ for r in rows if r.ccn > ceilings[r.path]]
408
+ breaches.sort(key=lambda v: (-v.ccn, v.path, v.start))
409
+ return breaches
410
+
411
+
412
+ def _gate_candidates(root: Path, rows: list) -> list:
413
+ """The functions this commit could be about: spans the working tree changed
414
+ against HEAD, index included, which is the set the pre-commit hook will see.
415
+
416
+ Judging the whole file instead would flag every legacy function in it, so on
417
+ any repo with seeded debt the flag is red forever and says nothing.
418
+ """
419
+ from ..diffparse import changed_ranges
420
+ from ..gitio import diff_since
421
+ from ..verify import touched_rows
422
+
423
+ return touched_rows(rows, changed_ranges(diff_since(root, "HEAD")))
424
+
425
+
426
+ def _unmarked_breaches(breaches: list, entries: list) -> list:
427
+ """Breaches no ratchet mark already covers.
428
+
429
+ A mark is a recorded decision to carry a function as it stands. At or under
430
+ it the function is exactly the debt the repo signed up for; past it, verify's
431
+ ratchet check would fail too, so the gate says so early.
432
+ """
433
+ from ..ratchet import mark_for
434
+
435
+ kept = []
436
+ for v in breaches:
437
+ mark = mark_for(entries, v.path, v.long_name)
438
+ if mark is None or round(v.crap, 4) > mark:
439
+ kept.append(v)
440
+ return kept
441
+
442
+
443
+ def _untracked_of(root: Path, overlay) -> set[str]:
444
+ """Rescored paths git tracks nothing of. Invisible to git diff, so without
445
+ special handling their violations print and then exit 0 — the one state
446
+ where the gate lies."""
447
+ tracked = set(ls_files(root))
448
+ return {r.path for r in overlay} - tracked
449
+
450
+
451
+ def _warn_untracked(untracked: set[str]) -> None:
452
+ if untracked:
453
+ print(f"crapkit: {len(untracked)} untracked file(s) gated in full "
454
+ f"({', '.join(sorted(untracked))}) — git add to gate only future edits",
455
+ file=sys.stderr)
456
+
457
+
458
+ def _rescore_gate(root: Path, cfg, overlay, ceilings: dict[str, int]) -> int:
459
+ """The commit's verdict, hours before the commit. Reported on stderr so
460
+ `--json` stdout stays one parseable object."""
461
+ untracked = _untracked_of(root, overlay)
462
+ _warn_untracked(untracked)
463
+ candidates = _gate_candidates(root, overlay) + [r for r in overlay if r.path in untracked]
464
+ touched = _ceiling_breaches(candidates, ceilings)
465
+ breaches = _unmarked_breaches(touched, _ratchet_entries(root, cfg) or [])
466
+ if not breaches:
467
+ return 0
468
+ print(f"crapkit gate: {len(breaches)} rescored function(s) over their scope ceiling:",
469
+ file=sys.stderr)
470
+ for v in breaches:
471
+ print(_gate_line(v), file=sys.stderr)
472
+ return 6
473
+
474
+
475
+ def cmd_rescore(args: argparse.Namespace) -> int:
476
+ root = Path(args.repo).resolve()
477
+ cfg = _load_repo_config(root)
478
+ db_path = root / ".crapkit" / "crap.sqlite"
479
+ if not db_path.is_file():
480
+ raise CrapkitError(f"no snapshot in {root} — run `crapkit coverage` first")
481
+ store = SnapshotStore(db_path)
482
+ latest = _latest_scored(store)
483
+ if latest is None:
484
+ raise CrapkitError(f"no scored run in {root} — run `crapkit coverage` first")
485
+
486
+ rows, flat, ceilings = _rescore_analyze(root, cfg, args.files)
487
+ overlay = _rescore_overlay(store, latest, rows, flat, cfg)
488
+ if args.json:
489
+ _rescore_json(overlay, latest)
490
+ else:
491
+ _print_rescore_table(overlay, latest)
492
+ return _rescore_gate(root, cfg, overlay, ceilings) if args.gate else 0
493
+
494
+
495
+ def _print_rescore_table(overlay, latest: dict) -> None:
496
+ """The refactor loop's view: fresh ccn, worst first, stale cov labeled."""
497
+ print(f"rescore vs run {latest['id']} @ {latest['commit'][:11]} (coverage STALE, complexity fresh)")
498
+ print(f" {'ccn':>4} {'cov':>5} {'crap':>8} {'remedy':10} function")
499
+ for r in sorted(overlay, key=lambda x: (-x.ccn, x.path, x.start)):
500
+ print(f" {r.ccn:>4} {r.cov:>5.0%} {r.crap:>8.1f} {r.remedy:10} {r.path}:{r.start} {r.long_name}")