crapkit 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crapkit/__init__.py +2 -0
- crapkit/__main__.py +5 -0
- crapkit/_pygdefer.py +86 -0
- crapkit/analyze.py +375 -0
- crapkit/cache.py +58 -0
- crapkit/churn.py +113 -0
- crapkit/churn_cache.py +108 -0
- crapkit/churn_log.py +286 -0
- crapkit/cli/__init__.py +316 -0
- crapkit/cli/_shared.py +130 -0
- crapkit/cli/admin.py +650 -0
- crapkit/cli/analyses.py +144 -0
- crapkit/cli/parser.py +384 -0
- crapkit/cli/queue.py +926 -0
- crapkit/cli/ratchet_cmds.py +172 -0
- crapkit/cli/reports.py +459 -0
- crapkit/cli/scoring.py +500 -0
- crapkit/cli/verifying.py +580 -0
- crapkit/config.py +289 -0
- crapkit/coupling.py +89 -0
- crapkit/coverage_istanbul.py +225 -0
- crapkit/coverage_py.py +87 -0
- crapkit/covstream.py +320 -0
- crapkit/diffparse.py +98 -0
- crapkit/digest.py +191 -0
- crapkit/discover.py +365 -0
- crapkit/doctor.py +308 -0
- crapkit/dup.py +179 -0
- crapkit/errors.py +18 -0
- crapkit/gitio.py +504 -0
- crapkit/hook.py +167 -0
- crapkit/junitparse.py +87 -0
- crapkit/lanes.py +373 -0
- crapkit/lizardcognitive.py +238 -0
- crapkit/mcp_server.py +167 -0
- crapkit/merge.py +77 -0
- crapkit/mutate.py +96 -0
- crapkit/mutate_pool.py +152 -0
- crapkit/override.py +94 -0
- crapkit/packet.py +343 -0
- crapkit/ratchet.py +236 -0
- crapkit/ratchet_report.py +135 -0
- crapkit/sarif.py +82 -0
- crapkit/sarifio.py +49 -0
- crapkit/scaffold.py +361 -0
- crapkit/score.py +255 -0
- crapkit/snapshot.py +51 -0
- crapkit/store.py +1066 -0
- crapkit/uncovered.py +131 -0
- crapkit/universe.py +157 -0
- crapkit/verify.py +194 -0
- crapkit/watch.py +112 -0
- crapkit/worklist.py +290 -0
- crapkit-0.2.0.dist-info/METADATA +802 -0
- crapkit-0.2.0.dist-info/RECORD +59 -0
- crapkit-0.2.0.dist-info/WHEEL +5 -0
- crapkit-0.2.0.dist-info/entry_points.txt +2 -0
- crapkit-0.2.0.dist-info/licenses/LICENSE +21 -0
- crapkit-0.2.0.dist-info/top_level.txt +1 -0
crapkit/cli/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)}")
|