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/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}")
|