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/queue.py ADDED
@@ -0,0 +1,926 @@
1
+ """The burn-down queue and everything that reads it: `next-item` (the ranked
2
+ candidate an agent session takes, with its admission rules and empty-queue
3
+ reasons), `claims` (the loop state that keeps two sessions off one function),
4
+ `brief` (one function's whole start-editing packet, one at a time or a batch of
5
+ them) and `worklist` (the ranked risk queue and its batch split)."""
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from .. import packet
13
+ from ..churn_cache import load_churn
14
+ from ..errors import ConfigError, CrapkitError
15
+ from ..gitio import head_commit
16
+ from ..store import SnapshotStore
17
+ from ..uncovered import load_uncovered
18
+ from ..worklist import admission, build_worklist, sql_floor
19
+ from ._shared import (_latest_scored, _load_repo_config, _load_sources, _open_store,
20
+ _print_json, _ratchet_entries)
21
+
22
+
23
+ def _scored_store(root: Path) -> tuple[SnapshotStore, dict]:
24
+ """The store and the run next-item ranks, or the error naming what to run."""
25
+ db_path = root / ".crapkit" / "crap.sqlite"
26
+ if not db_path.is_file():
27
+ raise CrapkitError(f"no snapshot in {root} — run `crapkit coverage` first")
28
+ store = SnapshotStore(db_path)
29
+ latest = _latest_scored(store)
30
+ if latest is None:
31
+ raise CrapkitError(f"no scored run in {root} — run `crapkit coverage` first")
32
+ return store, latest
33
+
34
+
35
+ def _pushdown_floor(cfg) -> int:
36
+ """The lowest ccn next-item's SQL read may skip.
37
+
38
+ Nothing under it can be admitted by any rule — not the floor, not hot
39
+ promotion, and not a ceiling, since its worst-case CRAP cannot reach the
40
+ smallest one configured — so that one number is a COUNT rather than 100k rows.
41
+ """
42
+ return sql_floor(cfg.worklist_floor, min([cfg.target, *cfg.scope_targets.values()]))
43
+
44
+
45
+ def cmd_next_item(args: argparse.Namespace) -> int:
46
+ root = Path(args.repo).resolve()
47
+ cfg = _load_repo_config(root)
48
+ store, latest = _scored_store(root)
49
+ scopes = args.scope or []
50
+ scored = store.read_scored(latest["id"], min_ccn=_pushdown_floor(cfg), scopes=scopes)
51
+ adm = admission(load_churn(root, cfg.churn_window_months), cfg.worklist_floor)
52
+ ranked, skipped_no_lane = _next_ranked(scored, adm)
53
+ excludes = args.exclude or []
54
+ ranked = [r for r in ranked if not _excluded_item(r, excludes)]
55
+ ranked, skipped_claimed = _unclaimed(store, ranked)
56
+ # one HEAD read for both the staleness verdict and the claims this call takes
57
+ commit = head_commit(root)
58
+ head = _next_head(latest, skipped_no_lane, skipped_claimed, latest["commit"] != commit)
59
+ handles = _Handles(store, latest["id"])
60
+ _maybe_claim(store, commit, args.claim, _claimable(ranked, args.top), handles)
61
+ _emit_next(store, head, ranked, args.top, adm, cfg, scored, excludes, scopes,
62
+ load_uncovered(root, cfg), handles)
63
+ return 0
64
+
65
+
66
+ class _Handles:
67
+ """The handle for a ranked row, counted over its whole file.
68
+
69
+ The queue is a cut of the run: rows under the pushdown floor never reach it,
70
+ and a scope filter can drop more. An ordinal counted over that cut would
71
+ number a different function than the one `brief` names, so the count runs
72
+ over the file's rows, read once per path and kept.
73
+ """
74
+
75
+ def __init__(self, store, run_id: int) -> None:
76
+ self._store = store
77
+ self._run_id = run_id
78
+ self._by_path: dict[str, dict] = {}
79
+
80
+ def of(self, row) -> str:
81
+ if row.path not in self._by_path:
82
+ self._by_path[row.path] = packet.handles(
83
+ self._store.read_scored_file(self._run_id, row.path))
84
+ return self._by_path[row.path][row.start]
85
+
86
+
87
+ def _unclaimed(store, ranked: list) -> tuple[list, int]:
88
+ """The rows no session is holding, and how many an open claim hid.
89
+
90
+ Filtering happens whether or not this session claims anything: a claim is
91
+ worthless if only the session that took it honours it.
92
+ """
93
+ held = {(c["path"], c["long_name"]) for c in store.open_claims()}
94
+ free = [r for r in ranked if (r.path, r.long_name) not in held]
95
+ return free, len(ranked) - len(free)
96
+
97
+
98
+ def _next_head(latest: dict, skipped_no_lane: int, skipped_claimed: int,
99
+ stale: bool) -> dict:
100
+ """skipped_claimed appears only when a claim actually hid something, so a
101
+ store nobody ever claimed in emits exactly the JSON it emitted before.
102
+
103
+ `stale` is the same verdict worklist prints its warning from: the snapshot
104
+ describes a commit HEAD has moved past, so the spans in it may have moved.
105
+ """
106
+ head = {"run_id": latest["id"], "commit": latest["commit"],
107
+ "skipped_no_lane": skipped_no_lane, "stale": stale}
108
+ if skipped_claimed:
109
+ head["skipped_claimed"] = skipped_claimed
110
+ return head
111
+
112
+
113
+ def _claimable(ranked: list, top: int) -> list:
114
+ """What this call is about to hand out — nothing at all once the queue is
115
+ finished, so an exploratory --claim on it cannot hide tomorrow's top item."""
116
+ return ranked[:max(top, 1)] if _actionable(ranked) else []
117
+
118
+
119
+ def _maybe_claim(store, commit: str, take: bool, items: list, handles=None) -> None:
120
+ """Record a claim per item this invocation is about to hand out.
121
+
122
+ HEAD, not the snapshot's commit: the claim describes the tree the session
123
+ starts editing, which is what makes the ancestor test at verify meaningful.
124
+
125
+ The handle goes down with it, because it is the string the release takes:
126
+ an anonymous function's long_name names every anonymous function in its file.
127
+ """
128
+ if not take:
129
+ return
130
+ for r in items:
131
+ store.record_claim(path=r.path, long_name=r.long_name, commit=commit,
132
+ handle=None if handles is None else handles.of(r))
133
+
134
+
135
+ def _actionable(ranked: list) -> list:
136
+ """The candidates with work left in them.
137
+
138
+ A row whose remedy is "ok" sits at or under its ceiling with nothing to
139
+ decompose and nothing to test; handing it back is what made the burn-down
140
+ loop run forever.
141
+ """
142
+ return [r for r in ranked if r.remedy != "ok"]
143
+
144
+
145
+ def _next_reasons(store, run_id: int, ranked: list, scored, adm, cfg,
146
+ excludes: list, scopes: list) -> dict:
147
+ """Why the queue is empty, including the case where it is empty because the
148
+ work is done rather than because a filter ate everything."""
149
+ reasons = _empty_reasons(store, run_id, scored, adm, cfg, excludes, scopes)
150
+ if ranked:
151
+ reasons["all_remaining_at_or_under_target"] = len(ranked)
152
+ return reasons
153
+
154
+
155
+ def _emit_next(store, head: dict, ranked, top: int, adm, cfg, scored,
156
+ excludes: list, scopes: list, uncovered, handles=None) -> None:
157
+ if not _actionable(ranked):
158
+ head.update(empty=True,
159
+ reasons=_next_reasons(store, head["run_id"], ranked, scored, adm, cfg,
160
+ excludes, scopes))
161
+ elif top and top > 1:
162
+ head.update(empty=False,
163
+ items=[_next_item_payload(r, adm, cfg, uncovered, _handle(handles, r))
164
+ for r in ranked[:top]])
165
+ else:
166
+ head.update(empty=False,
167
+ item=_next_item_payload(ranked[0], adm, cfg, uncovered,
168
+ _handle(handles, ranked[0])))
169
+ _print_json(head)
170
+
171
+
172
+ def _handle(handles, row) -> str | None:
173
+ """This row's handle, or None for a caller holding no file rows to count."""
174
+ return None if handles is None else handles.of(row)
175
+
176
+
177
+ def _excluded_item(r, excludes: list) -> bool:
178
+ return any(pat in r.path or pat in r.long_name for pat in excludes)
179
+
180
+
181
+ def _skip_reason(r, adm, excludes: list) -> str | None:
182
+ """The reasons bucket this scored row falls in, or None when the queue took it.
183
+
184
+ One walk, one rule: every count here is the complement of the admission the
185
+ ranking used, so `below_floor` can never claim a row the queue handed out.
186
+ """
187
+ if r.flag == "no-lane":
188
+ return "no_lane"
189
+ if _excluded_item(r, excludes):
190
+ return "excluded_by_flag"
191
+ if _rankable(r, adm):
192
+ return None
193
+ return "no_churn_in_window" if r.path not in adm.churn else "below_floor"
194
+
195
+
196
+ def _no_lane_debt(r) -> bool:
197
+ """A no-lane row that is over its ceiling.
198
+
199
+ `no_lane` alone counts a wiring gap, which may hold nothing but healthy
200
+ code. This is the half a stop condition has to read: work the queue can
201
+ never hand out, because no lane measures its scope.
202
+ """
203
+ return r.flag == "no-lane" and r.remedy != "ok"
204
+
205
+
206
+ def _empty_reasons(store, run_id: int, scored, adm, cfg, excludes: list,
207
+ scopes: list) -> dict:
208
+ """An empty queue must say what was filtered, or the silence reads as done.
209
+
210
+ Rows under the pushdown floor can never be admitted by any rule, so one
211
+ COUNT stands in for them; every row the read did return is bucketed by the
212
+ admission itself. The count takes the same scope cut, or a scoped queue
213
+ reports rows it was never going to offer.
214
+ """
215
+ reasons = {"no_lane": 0, "no_churn_in_window": 0, "excluded_by_flag": 0,
216
+ "below_floor": store.count_scored_below(run_id, _pushdown_floor(cfg), scopes),
217
+ "churn_window_months": cfg.churn_window_months,
218
+ "no_lane_over_target": sum(1 for r in scored if _no_lane_debt(r))}
219
+ for r in scored:
220
+ bucket = _skip_reason(r, adm, excludes)
221
+ if bucket:
222
+ reasons[bucket] += 1
223
+ return reasons
224
+
225
+
226
+ def _rankable(r, adm) -> bool:
227
+ """A scored row the queue may hand out.
228
+
229
+ no-lane rows score cov=0 for lack of TOOLING, not lack of tests; handing one
230
+ to a session would rank a wiring gap above real risk. A file with no churn in
231
+ the window belongs to the worklist's dormant list rather than to the queue —
232
+ unless it is over its ceiling, and then it is debt wherever it sleeps.
233
+ """
234
+ over = r.remedy != "ok"
235
+ if r.flag == "no-lane" or (r.path not in adm.churn and not over):
236
+ return False
237
+ return adm.admits(r.path, r.ccn, over_target=over)
238
+
239
+
240
+ def _no_lane_gap(r, adm) -> bool:
241
+ """A row the queue declines only because its scope has no lane.
242
+
243
+ Its cov=0 is a tooling gap, so it never ranks, but an agent still has to be
244
+ told it exists — including when only the ceiling would have admitted it.
245
+ """
246
+ return r.flag == "no-lane" and adm.admits(r.path, r.ccn, over_target=r.remedy != "ok")
247
+
248
+
249
+ def _next_ranked(scored, adm):
250
+ ranked = sorted((r for r in scored if _rankable(r, adm)),
251
+ key=lambda r: (-r.crap, -adm.of(r.path).commits, r.path, r.start))
252
+ return ranked, sum(1 for r in scored if _no_lane_gap(r, adm))
253
+
254
+
255
+ def _uncovered_fields(uncovered, row) -> dict:
256
+ """The dark lines inside one function's span, or null and the reason there
257
+ are none to be had.
258
+
259
+ null, not []: [] is what a function every artifact ran reports, so answering
260
+ [] for a file no artifact spoke about tells an agent there is nothing left to
261
+ test. The note key stays opt-in, so a repo whose artifacts answer emits
262
+ exactly the JSON it emitted before dark lines existed.
263
+ """
264
+ note = uncovered.note_for(row.path, row.flag, row.scope)
265
+ if note:
266
+ return {"uncovered_lines": None, "uncovered_lines_note": note}
267
+ return {"uncovered_lines": uncovered.in_span(row.path, row.start, row.end)}
268
+
269
+
270
+ def _next_item_payload(top, adm, cfg, uncovered, handle: str | None = None) -> dict:
271
+ c = adm.of(top.path)
272
+ ceiling = cfg.scope_targets.get(top.scope, cfg.target)
273
+ return {
274
+ "scope": top.scope, "path": top.path, "function": top.long_name,
275
+ # the name form that survives the session's own edit: a start line moves,
276
+ # a position among the file's anonymous functions does not
277
+ "handle": handle,
278
+ "start": top.start, "end": top.end, "ccn": top.ccn, "ccn_std": top.ccn_std,
279
+ "cov": top.cov, "flag": top.flag, "crap": top.crap, "remedy": top.remedy,
280
+ "nloc": top.nloc, "nesting": top.nesting, "cognitive": top.cognitive,
281
+ "commits": c.commits, "authors": c.authors,
282
+ "target": ceiling,
283
+ # budgeting hints: pieces a decomposition needs; decision paths no test
284
+ # walks. brief publishes these from the same helper, never a second copy
285
+ **packet.budget(top, ceiling),
286
+ # est_uncovered_paths counts them; these name them, so an add-tests
287
+ # remedy no longer costs a by-hand read of the coverage artifact
288
+ **_uncovered_fields(uncovered, top),
289
+ }
290
+
291
+
292
+ def _claims_summary(claims: list) -> str:
293
+ return ", ".join(f"{c['path']} {c['long_name']}" for c in claims) or "none"
294
+
295
+
296
+ def _print_claims(as_json: bool, claims: list) -> None:
297
+ if as_json:
298
+ _print_json({"claims": claims, "open": len(claims)})
299
+ return
300
+ print(f"{len(claims)} open claim(s)")
301
+ for c in claims:
302
+ print(f" {c['created_at']} {c['commit'][:11]} {c['path']} {c['long_name']}")
303
+
304
+
305
+ def _release_target(target: list) -> tuple[str, str]:
306
+ if len(target) != 2:
307
+ raise CrapkitError("claims release needs PATH NAME — the path and function "
308
+ "next-item printed — or --all to close every open claim")
309
+ return target[0], target[1]
310
+
311
+
312
+ def _claim_matches(c: dict, name: str) -> bool:
313
+ """Does this claim answer to `name`?
314
+
315
+ Either name form it was handed out under. The handle is the one that works
316
+ for an anonymous function: its long_name is `(anonymous)`, which every
317
+ anonymous function in the file also answers to, so a release by long_name
318
+ would close whichever claim sorts first.
319
+ """
320
+ return _name_matches(c["long_name"], name) or c.get("handle") == name
321
+
322
+
323
+ def _named_claims(claims: list, path: str, name: str) -> list:
324
+ held = [c for c in claims if c["path"] == path and _claim_matches(c, name)]
325
+ if not held:
326
+ raise CrapkitError(f"no open claim on {name!r} in {path} — "
327
+ f"open: {_claims_summary(claims)}")
328
+ return held
329
+
330
+
331
+ def _claims_to_release(claims: list, release_all: bool, target: list) -> list:
332
+ if release_all:
333
+ return claims
334
+ return _named_claims(claims, *_release_target(target))
335
+
336
+
337
+ def _print_released(as_json: bool, closed: int) -> None:
338
+ if as_json:
339
+ _print_json({"released": closed})
340
+ return
341
+ print(f"released {closed} claim(s)")
342
+
343
+
344
+ def cmd_claims(args: argparse.Namespace) -> int:
345
+ """The queue's loop state, and the way back out of a `next-item --claim`.
346
+
347
+ Without a release path, one exploratory claim hides the top item until some
348
+ verify happens to score that function at its ceiling — which, for the worst
349
+ function in the repo, is the whole job.
350
+ """
351
+ store = _open_store(Path(args.repo).resolve())
352
+ claims = store.open_claims()
353
+ if args.action != "release":
354
+ _print_claims(args.json, claims)
355
+ return 0
356
+ to_close = _claims_to_release(claims, args.all, args.target)
357
+ _print_released(args.json, store.close_claims([c["id"] for c in to_close]))
358
+ return 0
359
+
360
+
361
+ def _name_prefix(long_name: str) -> str:
362
+ """The identifier a long_name opens with, before its parameter list."""
363
+ return long_name.split("(")[0].strip()
364
+
365
+
366
+ def _name_matches(long_name: str, name: str) -> bool:
367
+ """Does `name` name this function, bare or whole?
368
+
369
+ next-item, worklist and brief all publish `function` as the long_name, so the
370
+ string an agent has just read has to be a string it can pass back — here, and
371
+ to `claims release`. Matching only the bare identifier broke the chain: the
372
+ exact value one command printed was rejected by the next.
373
+ """
374
+ return name in (long_name, _name_prefix(long_name))
375
+
376
+
377
+ def _matching_rows(rows: list, name: str) -> list:
378
+ return [r for r in rows if _name_matches(r.long_name, name)]
379
+
380
+
381
+ def _no_match_message(path: str, name: str, rows: list, candidates: list) -> str:
382
+ if candidates:
383
+ return f"{name!r} in {path} is ambiguous — candidates: {', '.join(candidates)}"
384
+ known = sorted({_name_prefix(r.long_name) for r in rows})
385
+ return (f"no function named {name!r} in {path} in the latest scored run"
386
+ f" — it holds: {', '.join(known) or 'nothing'}")
387
+
388
+
389
+ def _row_at_line(path: str, rows: list, name: str):
390
+ """The row opening on this line, when NAME is a bare start line.
391
+
392
+ An `(anonymous)` function has no name to pass back and a file can hold two
393
+ functions sharing one, so the line a row opens on is the disambiguator that
394
+ always exists: no two functions in a file start on the same line.
395
+ """
396
+ if not name.isdigit():
397
+ return None
398
+ at = [r for r in rows if r.start == int(name)]
399
+ if not at:
400
+ raise CrapkitError(_no_line_message(path, name, rows))
401
+ return at[0]
402
+
403
+
404
+ def _no_line_message(path: str, name: str, rows: list) -> str:
405
+ starts = ", ".join(str(s) for s in sorted({r.start for r in rows}))
406
+ return (f"no function starts at line {name} in {path} in the latest scored run"
407
+ f" — it starts functions at: {starts or 'nothing'}")
408
+
409
+
410
+ def _row_by_handle(path: str, rows: list, name: str):
411
+ """The row `(anonymous)#N` names, or None when NAME is not that form.
412
+
413
+ N counts the file's anonymous functions in start order, so the handle
414
+ outlives an edit above it that a start line would not. Out of range is an
415
+ error here rather than a fall-through to the name forms: `(anonymous)#5` is
416
+ unambiguously a handle, and reporting it as an unknown NAME would send a
417
+ session hunting for a function whose real handle is on the list.
418
+ """
419
+ ordinal = packet.handle_ordinal(name)
420
+ if ordinal is None:
421
+ return None
422
+ starts = packet.anonymous_starts(rows)
423
+ if not 1 <= ordinal <= len(starts):
424
+ raise CrapkitError(_no_handle_message(path, name, rows))
425
+ return next(r for r in rows if r.start == starts[ordinal - 1])
426
+
427
+
428
+ def _no_handle_message(path: str, name: str, rows: list) -> str:
429
+ held = ", ".join(packet.handle_names(rows))
430
+ return (f"no {name} in {path} in the latest scored run"
431
+ f" — it holds: {held or 'no anonymous functions'}")
432
+
433
+
434
+ def _pick_function(path: str, rows: list, name: str):
435
+ """The one row `name` names, or an error listing what the file does hold.
436
+
437
+ Twins (one long_name at two spans) are ONE candidate, not an ambiguity: the
438
+ worst-scoring twin is what a burn-down item means, the same rule the ratchet
439
+ and the verdict use. The -start term keeps verify.worst_twins' tie-break —
440
+ equal-scoring twins resolve to the one that appears first in the file.
441
+ """
442
+ at_line = _row_at_line(path, rows, name)
443
+ if at_line is not None:
444
+ return at_line
445
+ by_handle = _row_by_handle(path, rows, name)
446
+ if by_handle is not None:
447
+ return by_handle
448
+ matched = _matching_rows(rows, name)
449
+ candidates = sorted({r.long_name for r in matched})
450
+ if len(candidates) != 1:
451
+ raise CrapkitError(_no_match_message(path, name, rows, candidates))
452
+ return max(matched, key=lambda r: (r.crap, -r.start))
453
+
454
+
455
+ def _brief_mark(entries: list | None, row) -> float | None:
456
+ """The committed mark on this function, or None when the repo carries none."""
457
+ from ..ratchet import mark_for
458
+
459
+ return None if entries is None else mark_for(entries, row.path, row.long_name)
460
+
461
+
462
+ def _brief_churn(churn: dict, path: str) -> dict | None:
463
+ c = churn.get(path)
464
+ return None if c is None else {"commits": c.commits, "authors": c.authors,
465
+ "weight": c.weight}
466
+
467
+
468
+ def _brief_coupling(ranked: list, path: str) -> list[dict]:
469
+ """This file's partners, cut out of the ranking every path in a batch shares."""
470
+ from .verifying import _is_test_path
471
+
472
+ return packet.coupling_partners(ranked, path, _is_test_path)
473
+
474
+
475
+ def _brief_twins(loader, row) -> list[dict]:
476
+ from ..dup import find_twins
477
+
478
+ return packet.with_contained(find_twins(row, loader.rows(), loader.sources()))
479
+
480
+
481
+ class _BriefLoader:
482
+ """Everything a packet reads that is not about one function, read once.
483
+
484
+ A batch of N packets asked git for the same churn window N times, split the
485
+ same file texts N times and reopened the same scored file per packet. Each
486
+ read here happens on first use and is answered from memory afterwards, keyed
487
+ by what actually invalidates it — the path — never by which packet asked.
488
+ """
489
+
490
+ def __init__(self, root: Path, cfg, store, latest: dict) -> None:
491
+ self.root = root
492
+ self.cfg = cfg
493
+ self.store = store
494
+ self.latest = latest
495
+ self._whole_repo: dict = {}
496
+ self._scored_files: dict = {}
497
+ self._attempts: dict = {}
498
+
499
+ def _once(self, key: str, build):
500
+ """The one read behind `key`, kept for every packet after the first."""
501
+ if key not in self._whole_repo:
502
+ self._whole_repo[key] = build()
503
+ return self._whole_repo[key]
504
+
505
+ def rows(self) -> list:
506
+ return self._once("rows", lambda: self.store.read_rows(self.latest["id"]))
507
+
508
+ def sources(self) -> dict:
509
+ return self._once("sources",
510
+ lambda: _load_sources(self.root, {r.path for r in self.rows()}))
511
+
512
+ def source(self, path: str) -> str | None:
513
+ return self.sources().get(path)
514
+
515
+ def churn(self) -> dict:
516
+ return self._once("churn",
517
+ lambda: load_churn(self.root, self.cfg.churn_window_months))
518
+
519
+ def coupling(self) -> list:
520
+ """Every co-change pair in the window, ranked before any per-path cut."""
521
+ return self._once("coupling", self._rank_coupling)
522
+
523
+ def _rank_coupling(self) -> list:
524
+ from ..churn_log import log_lines
525
+ from ..coupling import change_coupling_lines
526
+
527
+ return change_coupling_lines(log_lines(self.root, self.cfg.churn_window_months),
528
+ top=None)
529
+
530
+ def uncovered(self):
531
+ return self._once("uncovered", lambda: load_uncovered(self.root, self.cfg))
532
+
533
+ def head(self) -> str:
534
+ return self._once("head", lambda: head_commit(self.root))
535
+
536
+ def stale(self) -> bool:
537
+ return self.latest["commit"] != self.head()
538
+
539
+ def versions(self) -> dict:
540
+ return self._once("versions", _brief_versions)
541
+
542
+ def scored_file(self, path: str) -> list:
543
+ if path not in self._scored_files:
544
+ self._scored_files[path] = self.store.read_scored_file(self.latest["id"], path)
545
+ return self._scored_files[path]
546
+
547
+ def mark(self, row) -> float | None:
548
+ return _brief_mark(self._once("marks",
549
+ lambda: _ratchet_entries(self.root, self.cfg)), row)
550
+
551
+ def mark_age(self, row, mark: float | None) -> int | None:
552
+ """How long the mark has stood. No mark, no history read: reading the
553
+ ratchet file's git log is a spawn, and an unmarked function owes none."""
554
+ if mark is None:
555
+ return None
556
+ return packet.mark_age_days(self._once("mark_events", self._read_mark_events),
557
+ (row.path, row.long_name))
558
+
559
+ def _read_mark_events(self) -> list:
560
+ from ..gitio import file_log_patches
561
+ from ..ratchet_report import mark_events
562
+
563
+ return mark_events(file_log_patches(self.root, self.cfg.ratchet_file))
564
+
565
+ def attempts(self, row) -> list:
566
+ key = (row.path, row.long_name)
567
+ if key not in self._attempts:
568
+ self._attempts.update(self.store.attempts_for([key]))
569
+ return self._attempts[key]
570
+
571
+ def prime_attempts(self, rows: list) -> None:
572
+ """One query for a whole batch's claims, before the packets ask one by one."""
573
+ self._attempts.update(
574
+ self.store.attempts_for([(r.path, r.long_name) for r in rows]))
575
+
576
+
577
+ def _brief_versions() -> dict:
578
+ """What produced these numbers. doctor already assembles the tool block, so
579
+ a packet and a doctor report name the same three strings."""
580
+ from ..analyze import ANALYSIS_VERSION
581
+ from .admin import _version_report
582
+
583
+ return packet.versions_block(_version_report(), ANALYSIS_VERSION)
584
+
585
+
586
+ def _row_ceiling(cfg, row) -> int:
587
+ return cfg.scope_targets.get(row.scope, cfg.target)
588
+
589
+
590
+ def _packet_scope(cfg, row) -> str:
591
+ """The scope that OWNS the path, which is what routes a lane and a scoped
592
+ test command. The scored row's scope is the fallback, for a path no
593
+ [[scope]] claims by prefix."""
594
+ from .verifying import _owning_scope
595
+
596
+ return _owning_scope(row.path, cfg.scope_paths) or row.scope
597
+
598
+
599
+ def _scope_config(cfg, name: str):
600
+ return next((s for s in cfg.scopes if s.name == name), None)
601
+
602
+
603
+ def _packet_gate(loader, row) -> dict:
604
+ mark = loader.mark(row)
605
+ return packet.gate_rule(ceiling=_row_ceiling(loader.cfg, row), mark=mark,
606
+ mark_age_days=loader.mark_age(row, mark),
607
+ diff_uncovered_max=loader.cfg.diff_uncovered_max)
608
+
609
+
610
+ def _packet_commands(cfg, row, scope: str) -> dict:
611
+ from .verifying import _scoped_command
612
+
613
+ template = dict(cfg.scoped_tests).get(scope)
614
+ scoped = _scoped_command(template, [row.path]) if template else None
615
+ return packet.commands(row.path, scoped,
616
+ f"no [crapkit.scoped_tests] template for scope {scope!r}")
617
+
618
+
619
+ def _packet_context(loader, row, rows: list) -> dict:
620
+ """What the packet ADDED around the brief: the function's own text, the rest
621
+ of its file, the rule it will be judged by, the lane that measures it, its
622
+ history of attempts, and the commands to run next."""
623
+ cfg = loader.cfg
624
+ scope = _packet_scope(cfg, row)
625
+ return {
626
+ "source": packet.function_source(loader.source(row.path), row.start, row.end),
627
+ "file_functions": packet.file_functions(rows),
628
+ "file_totals": packet.file_totals(rows, cfg.scope_targets, cfg.target),
629
+ "gate_rule": _packet_gate(loader, row),
630
+ "lane": packet.lane_record(packet.lane_for(scope, cfg.lanes)),
631
+ "stale": loader.stale(),
632
+ "versions": loader.versions(),
633
+ "commands": _packet_commands(cfg, row, scope),
634
+ "attempts": loader.attempts(row),
635
+ "regrowth": packet.regrowth(loader.store.function_history(row.path, row.long_name)),
636
+ "params": packet.params(row.long_name),
637
+ "notes": packet.notes(cfg, _scope_config(cfg, scope)),
638
+ }
639
+
640
+
641
+ def _brief_packet(loader, row) -> dict:
642
+ """One function's whole start-editing context.
643
+
644
+ Every field `brief --json` published before is here unchanged; the rest is
645
+ what a session used to open the file, the config and the store to work out.
646
+
647
+ `remedy` and the two estimates are promoted out of `scored` and out of
648
+ `next-item`: they are what the session decides on, and reading one of them
649
+ off a nested object while the queue published it at the top made the two
650
+ payloads look like different answers.
651
+ """
652
+ rows = loader.scored_file(row.path)
653
+ ceiling = _row_ceiling(loader.cfg, row)
654
+ return {
655
+ "run_id": loader.latest["id"], "commit": loader.latest["commit"],
656
+ "path": row.path, "function": row.long_name,
657
+ "handle": packet.handles(rows)[row.start],
658
+ "scored": dict(row._asdict()),
659
+ "target": ceiling,
660
+ "remedy": row.remedy,
661
+ **packet.budget(row, ceiling),
662
+ "ratchet_mark": loader.mark(row),
663
+ "churn": _brief_churn(loader.churn(), row.path),
664
+ "coupling": _brief_coupling(loader.coupling(), row.path),
665
+ "duplication_twins": _brief_twins(loader, row),
666
+ **_uncovered_fields(loader.uncovered(), row),
667
+ **_packet_context(loader, row, rows),
668
+ }
669
+
670
+
671
+ def _brief_payload(root: Path, cfg, store: SnapshotStore, latest: dict, row) -> dict:
672
+ """One packet for a caller holding a repo and a row rather than a loader."""
673
+ return _brief_packet(_BriefLoader(root, cfg, store, latest), row)
674
+
675
+
676
+ def _mark_text(mark: float | None) -> str:
677
+ return "none" if mark is None else f"{mark:.4f}"
678
+
679
+
680
+ def _churn_text(churn: dict | None) -> str:
681
+ if churn is None:
682
+ return "none in the window"
683
+ return f"{churn['commits']} commits / {churn['authors']} authors"
684
+
685
+
686
+ def _brief_lines_text(out: dict) -> str:
687
+ """null lines print their note; an empty list is a covered function, and says so."""
688
+ lines = out["uncovered_lines"]
689
+ if lines is None:
690
+ return out["uncovered_lines_note"]
691
+ return ", ".join(str(n) for n in lines) or "none"
692
+
693
+
694
+ def _print_brief_neighbours(out: dict) -> None:
695
+ for t in out["duplication_twins"]:
696
+ print(f" twin {t['similarity']:.0%} {t['path']}:{t['start']} {t['long_name']}")
697
+ for c in out["coupling"]:
698
+ print(f" co-changes {c['confidence']:.0%} ({c['support']}x) {c['path']}")
699
+
700
+
701
+ def _print_brief_context(out: dict) -> None:
702
+ """The packet fields a human at a terminal still wants: what else is in the
703
+ file, what the gate will hold this function to, and the command to run."""
704
+ totals = out["file_totals"]
705
+ lane = out["lane"]
706
+ print(f" file: {totals['functions']} function(s), {totals['over_target']} over "
707
+ f"target, crap load {totals['crap_load']}")
708
+ print(f" gate ceiling {out['gate_rule']['ceiling']} "
709
+ f"lane {lane['name'] if lane else 'none'} -> {out['commands']['gate']}")
710
+
711
+
712
+ def _print_brief(as_json: bool, out: dict) -> None:
713
+ if as_json:
714
+ _print_json(out)
715
+ return
716
+ s = out["scored"]
717
+ print(f"{out['path']}:{s['start']} {out['function']}")
718
+ print(f" ccn {s['ccn']} (cognitive {s['cognitive']}) cov {s['cov']:.0%} "
719
+ f"crap {s['crap']:.1f} vs target {out['target']} -> {s['remedy']}")
720
+ print(f" mark {_mark_text(out['ratchet_mark'])} churn {_churn_text(out['churn'])}")
721
+ print(f" uncovered lines: {_brief_lines_text(out)}")
722
+ _print_brief_neighbours(out)
723
+ _print_brief_context(out)
724
+
725
+
726
+ def _resolve_batch(requested: int) -> int:
727
+ if requested < 1:
728
+ raise ConfigError(f"brief --batch must be >= 1, got {requested}")
729
+ return requested
730
+
731
+
732
+ def _batch_rows(loader, count: int) -> list:
733
+ """The top N actionable queue items, admitted exactly as next-item admits them."""
734
+ scored = loader.store.read_scored(loader.latest["id"],
735
+ min_ccn=_pushdown_floor(loader.cfg))
736
+ ranked, _ = _next_ranked(scored, admission(loader.churn(), loader.cfg.worklist_floor))
737
+ return _actionable(ranked)[:count]
738
+
739
+
740
+ def _brief_batch(loader, count: int) -> dict:
741
+ """N start-editing packets out of ONE process.
742
+
743
+ A session that briefs its whole batch one command at a time pays for the
744
+ store, the config, the churn window, the git log and every file text once
745
+ per function. Here they are read once and every packet is cut from them.
746
+ """
747
+ rows = _batch_rows(loader, count)
748
+ loader.prime_attempts(rows)
749
+ return {"run_id": loader.latest["id"], "commit": loader.latest["commit"],
750
+ "stale": loader.stale(),
751
+ "packets": [_brief_packet(loader, row) for row in rows]}
752
+
753
+
754
+ def _brief_target(args: argparse.Namespace) -> tuple[str, str]:
755
+ """PATH and NAME, which only --batch may leave out."""
756
+ if not args.path or not args.name:
757
+ raise CrapkitError("brief needs PATH NAME — the path and function next-item "
758
+ "printed — or --batch N for the top N queue items")
759
+ return args.path, args.name
760
+
761
+
762
+ def cmd_brief(args: argparse.Namespace) -> int:
763
+ """One call for everything a burn-down session opens a file already knowing."""
764
+ root = Path(args.repo).resolve()
765
+ cfg = _load_repo_config(root)
766
+ store, latest = _scored_store(root)
767
+ loader = _BriefLoader(root, cfg, store, latest)
768
+ if args.batch is not None:
769
+ _print_json(_brief_batch(loader, _resolve_batch(args.batch)))
770
+ return 0
771
+ path, name = _brief_target(args)
772
+ row = _pick_function(path, loader.scored_file(path), name)
773
+ _print_brief(args.json, _brief_packet(loader, row))
774
+ return 0
775
+
776
+
777
+ def _resolve_top(requested: int | None, cfg) -> int:
778
+ top = requested if requested is not None else cfg.worklist_top
779
+ if top < 1:
780
+ raise ConfigError(f"worklist top must be >= 1, got {top}")
781
+ return top
782
+
783
+
784
+ def _stale_warning(stale: bool, as_json: bool, latest: dict) -> None:
785
+ if stale and not as_json:
786
+ print(f"warning: snapshot is for {latest['commit'][:11]}, HEAD has moved on — "
787
+ "rerun `crapkit coverage`", file=sys.stderr)
788
+
789
+
790
+ def _entry_json(e) -> dict:
791
+ """One ranked row. `flag` and `remedy` are the run's verdict on it, so a
792
+ caller can tell a wiring gap and a finished row from real work without a
793
+ second call; both are null on an inventory-only run."""
794
+ return {"scope": e.scope, "path": e.path, "function": e.long_name,
795
+ "start": e.start, "end": e.end, "ccn": e.ccn, "ccn_std": e.ccn_std,
796
+ "nloc": e.nloc, "commits": e.commits, "authors": e.authors,
797
+ "weight": e.weight, "risk": e.risk, "flag": e.flag, "remedy": e.remedy}
798
+
799
+
800
+ def _row_marker(e) -> str:
801
+ """What the burn-down queue will do with this row, when that is not "hand it out".
802
+
803
+ `no-lane` is a wiring gap next-item never ranks; `ok` is finished work the
804
+ risk map still lists. Without them the two views read as contradicting each
805
+ other on the same screen.
806
+ """
807
+ marks = []
808
+ if e.flag == "no-lane":
809
+ marks.append("no-lane")
810
+ if e.remedy == "ok":
811
+ marks.append("ok")
812
+ return " " + " ".join(marks) if marks else ""
813
+
814
+
815
+ def _worklist_payload(wl, latest: dict, cfg, stale: bool, batches: list | None) -> dict:
816
+ """The queue, plus `batches` when one was asked for.
817
+
818
+ ADDED, never swapped in: a reader that wants active[] or stale off a batched
819
+ call gets them, because the cut is a second view of the same queue.
820
+ """
821
+ payload = {
822
+ "run_id": latest["id"], "commit": latest["commit"], "stale": stale,
823
+ "floor": cfg.worklist_floor, "churn_window_months": cfg.churn_window_months,
824
+ "active": [_entry_json(e) for e in wl.active],
825
+ "dormant_count": len(wl.dormant),
826
+ "dormant_top": [_entry_json(e) for e in wl.dormant[:10]],
827
+ }
828
+ if batches is not None:
829
+ payload["batches"] = [_batch_json(b) for b in batches]
830
+ return payload
831
+
832
+
833
+ def _worklist_print(as_json: bool, wl, latest: dict, cfg, stale: bool,
834
+ batches: list | None) -> None:
835
+ _stale_warning(stale, as_json, latest)
836
+ if as_json:
837
+ _print_json(_worklist_payload(wl, latest, cfg, stale, batches))
838
+ return
839
+ print(f"worklist @ {latest['commit'][:11]} (run {latest['id']}, floor ccn>={cfg.worklist_floor}, "
840
+ f"churn {cfg.churn_window_months}mo) — {len(wl.active)} active, {len(wl.dormant)} dormant")
841
+ for e in wl.active:
842
+ print(f" risk {e.risk:>8.1f} ccn {e.ccn:>3} ({e.ccn_std:>3} std) "
843
+ f"{e.commits:>3}c/{e.authors}a w{e.weight:>7.2f} {e.path}:{e.start} "
844
+ f"{e.long_name}{_row_marker(e)}")
845
+ _print_batches(batches)
846
+
847
+
848
+ def _rowful_runs(store) -> list[dict]:
849
+ """Everything but hook-override runs, which carry no rows at all."""
850
+ return [r for r in store.list_runs() if r["kind"] != "hook"]
851
+
852
+
853
+ def _worklist_run(root: Path, store) -> dict:
854
+ """The newest SCORED run when one exists, so worklist and next-item describe
855
+ one state; an inventory-only run ranks complexity alone until the first
856
+ coverage run."""
857
+ runs = _rowful_runs(store)
858
+ scored = [r for r in runs if r["kind"] != "inventory"]
859
+ if scored or runs:
860
+ return (scored or runs)[-1]
861
+ raise CrapkitError(f"no snapshot in {root} — run `crapkit coverage` first "
862
+ "(or `crapkit inventory` for complexity-only ranking, "
863
+ "with no coverage, flags or remedies)")
864
+
865
+
866
+ def cmd_worklist(args: argparse.Namespace) -> int:
867
+ root = Path(args.repo).resolve()
868
+ cfg = _load_repo_config(root)
869
+ store = _open_store(root, first_command="coverage")
870
+ latest = _worklist_run(root, store)
871
+ # the same pushdown next-item uses: no rule can admit anything below it, and
872
+ # anything above it might be over target, so those rows have to be read
873
+ scopes = args.scope or []
874
+ rows = store.read_rows(latest["id"], min_ccn=_pushdown_floor(cfg), scopes=scopes)
875
+ churn = load_churn(root, cfg.churn_window_months)
876
+ wl = build_worklist(rows, churn, floor=cfg.worklist_floor,
877
+ top=_resolve_top(args.top, cfg),
878
+ marks=_worklist_marks(store, cfg, latest["id"], scopes))
879
+ batches = _worklist_batches(root, cfg, wl.active, args.batches)
880
+ _worklist_print(args.json, wl, latest, cfg, latest["commit"] != head_commit(root), batches)
881
+ return 0
882
+
883
+
884
+ def _worklist_marks(store, cfg, run_id: int, scopes: list) -> dict:
885
+ """The run's verdict per function: what the floor may not hide, and what
886
+ each ranked row is.
887
+
888
+ Empty on an inventory-only run, which scored no remedy: a run with no
889
+ verdict has no debt to protect from the floor and nothing to say about a
890
+ row, and there the floor is the whole admission rule.
891
+ """
892
+ return store.read_marks(run_id, min_ccn=_pushdown_floor(cfg), scopes=scopes)
893
+
894
+
895
+ def _worklist_batches(root: Path, cfg, active: list, requested: int | None) -> list | None:
896
+ """The split queue when --batches asked for one, else None — the flag adds a
897
+ view of the queue, it does not replace the queue."""
898
+ if requested is None:
899
+ return None
900
+ return _split_worklist(root, cfg, active, _resolve_batches(requested))
901
+
902
+
903
+ def _resolve_batches(requested: int) -> int:
904
+ if requested < 1:
905
+ raise ConfigError(f"worklist --batches must be >= 1, got {requested}")
906
+ return requested
907
+
908
+
909
+ def _split_worklist(root: Path, cfg, active: list, count: int) -> list:
910
+ """The active queue cut into collision-free batches, coupling included."""
911
+ from ..churn_log import log_lines
912
+ from ..coupling import change_coupling_lines
913
+ from ..worklist import BATCH_CONTAINMENT, BATCH_PAIR_LIMIT, split_batches
914
+
915
+ pairs = change_coupling_lines(log_lines(root, cfg.churn_window_months),
916
+ min_confidence=BATCH_CONTAINMENT, top=BATCH_PAIR_LIMIT)
917
+ return split_batches(active, pairs, batches=count)
918
+
919
+
920
+ def _batch_json(b) -> dict:
921
+ return {"files": b.files, "entries": [_entry_json(e) for e in b.entries]}
922
+
923
+
924
+ def _print_batches(batches: list | None) -> None:
925
+ for i, b in enumerate(batches or [], 1):
926
+ print(f"batch {i}: {len(b.entries)} items in {len(b.files)} files: {', '.join(b.files)}")