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/churn_cache.py ADDED
@@ -0,0 +1,108 @@
1
+ """The one door to churn data, with git's tree walk cached behind it.
2
+
3
+ `git log --name-only` over a year of a large repo costs 6.5s, 5.8s of which is
4
+ git diffing every commit's tree, and worklist, next-item and coupling each paid
5
+ it in full on every invocation, at an unmoved HEAD. Every one of them reaches
6
+ git through this module, so `.crapkit/churn-cache.json` has exactly one writer.
7
+
8
+ The key is (HEAD sha, window months, UTC date). The sha pins the history; the
9
+ window pins the command; the date is there because `--since=N months ago` is
10
+ evaluated against the wall clock, so yesterday's cache describes a window one
11
+ day wider than today's. Anything else is a miss, and a miss rebuilds.
12
+
13
+ A cache is disposable: unreadable, corrupt or unkeyable content reads as cold,
14
+ never as a crash. Uncommitted work is invisible to churn either way. The one
15
+ thing a sha does not pin is depth — deepening a shallow clone adds history
16
+ under an unmoved HEAD — and that resolves itself at the next date rollover.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from collections.abc import Iterator
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ from .churn import FileChurn, parse_git_log_lines
26
+ from .churn_log import has_cache, log_lines
27
+ from .errors import GitError
28
+ from .gitio import churn_log_lines, head_commit
29
+
30
+ CACHE_NAME = "churn-cache.json"
31
+
32
+
33
+ def _window_lines(root: Path, months: int) -> Iterator[str]:
34
+ """The raw window log a map rebuild parses.
35
+
36
+ Read through the deflated log cache when one is on disk (free at an exact
37
+ key, a cached..HEAD range walk otherwise); straight from git when none is.
38
+ A map-only command never lays the log down — the commands that need its
39
+ per-commit structure (brief, batches, coupling) already do."""
40
+ if has_cache(root):
41
+ return log_lines(root, months)
42
+ return churn_log_lines(root, months)
43
+
44
+
45
+ def load_churn(root: Path, months: int) -> dict[str, FileChurn]:
46
+ """Per-file churn for the window — from disk when the key still matches, else rebuilt."""
47
+ path = root / ".crapkit" / CACHE_NAME
48
+ key = _cache_key(root, months)
49
+ cached = _read_cache(path, key)
50
+ if cached is not None:
51
+ return cached
52
+ churn = parse_git_log_lines(_window_lines(root, months))
53
+ _write_cache(path, key, churn)
54
+ return churn
55
+
56
+
57
+ def _utc_date() -> str:
58
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d")
59
+
60
+
61
+ def _cache_key(root: Path, months: int) -> dict | None:
62
+ """None when HEAD is unreadable — then there is nothing safe to key on."""
63
+ try:
64
+ head = head_commit(root)
65
+ except GitError:
66
+ return None
67
+ return {"head": head, "months": months, "date": _utc_date()}
68
+
69
+
70
+ def _read_cache(path: Path, key: dict | None) -> dict[str, FileChurn] | None:
71
+ if key is None:
72
+ return None
73
+ doc = _read_doc(path)
74
+ if doc is None or doc.get("key") != key:
75
+ return None
76
+ return _decode(doc.get("files"))
77
+
78
+
79
+ def _read_doc(path: Path) -> dict | None:
80
+ try:
81
+ doc = json.loads(path.read_text(encoding="utf-8"))
82
+ except (OSError, ValueError):
83
+ return None
84
+ return doc if isinstance(doc, dict) else None
85
+
86
+
87
+ def _decode(files) -> dict[str, FileChurn] | None:
88
+ """Rebuild the map from whatever JSON held under "files" — hence the wide except.
89
+
90
+ Weights are round(_, 4) floats, which JSON round-trips exactly, so a warm
91
+ read is byte-identical to the cold one it replaces.
92
+ """
93
+ try:
94
+ return {p: FileChurn(int(c), int(a), float(w)) for p, (c, a, w) in files.items()}
95
+ except (AttributeError, TypeError, ValueError):
96
+ return None
97
+
98
+
99
+ def _write_cache(path: Path, key: dict | None, churn: dict[str, FileChurn]) -> None:
100
+ """Best effort: a read-only .crapkit costs the speedup, never the command."""
101
+ if key is None:
102
+ return
103
+ doc = {"key": key, "files": {p: [c.commits, c.authors, c.weight] for p, c in churn.items()}}
104
+ try:
105
+ path.parent.mkdir(parents=True, exist_ok=True)
106
+ path.write_text(json.dumps(doc, sort_keys=True), encoding="utf-8")
107
+ except OSError:
108
+ return
crapkit/churn_log.py ADDED
@@ -0,0 +1,286 @@
1
+ """The churn window's raw log, kept on disk deflated, and refreshed instead of rewalked.
2
+
3
+ `churn_cache` stores the DERIVED per-file map. Coupling needs the structure that
4
+ map threw away — which files shared a commit — so `brief` and `worklist
5
+ --batches` re-walked twelve months of history on every invocation: 6.9 s of git
6
+ diffing every commit's tree, at an unmoved HEAD, for a stream they read once.
7
+ Off disk the same 628k lines take 0.22 s.
8
+
9
+ This is that stream, written down. Deflated, because the log is 23 MB of highly
10
+ repetitive path text that compresses to 4.7 MB, and reading 4.7 MB is what makes
11
+ the copy worth having. The whole compressed file is read before the first line
12
+ goes out (its CRC is the only way to know a log is not half-written); the 23 MB
13
+ of text it holds never is.
14
+
15
+ Two things are stored that the served lines do not carry. The commit date (%ct)
16
+ rides along on every header line, so an aged-out commit can be expired without
17
+ asking git; and the key records the HEAD the log was built from, so a HEAD that
18
+ grew from it costs `git log cached..HEAD` instead of the window — 0.77 s instead
19
+ of 6.9 s at a day-old HEAD, for the same 628k lines. Commit date is not author
20
+ date: `--since` filters on the committer's clock while the recency weight uses
21
+ the author's, and a rebased commit has two different ones.
22
+
23
+ A cache is disposable. An unreadable, torn or unkeyable log reads as cold, never
24
+ as a crash, and a HEAD the cached log is not an ancestor of (a rewind, a rebase,
25
+ a force-push) rebuilds rather than prepends.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import codecs
30
+ import json
31
+ import os
32
+ import zlib
33
+ from collections.abc import Iterator
34
+ from datetime import datetime, timezone
35
+ from itertools import chain
36
+ from pathlib import Path
37
+ from typing import BinaryIO
38
+
39
+ from .errors import GitError
40
+ from .gitio import _git_lines, head_commit, is_ancestor
41
+
42
+ LOG_NAME = "churn-log.z"
43
+ LOG_FORMAT = "--format=%x01%an%x02%at%x02%ct"
44
+ CHUNK = 1 << 20
45
+
46
+
47
+ def log_lines(root: Path, months: int) -> Iterator[str]:
48
+ """The churn window's log, streamed, in the format every consumer parses.
49
+
50
+ Same lines as `gitio.churn_log_lines`, off disk whenever the key still holds.
51
+ """
52
+ return (_shipped(line) for line in _stored_lines(root, months))
53
+
54
+
55
+ def has_cache(root: Path) -> bool:
56
+ """Whether a laid-down log exists to serve or refresh from, whatever its key.
57
+
58
+ The per-file map uses this to choose its source: a map-only command must
59
+ never pay the log's disk footprint into being, but ignoring a log already
60
+ on disk would re-buy the walk the log exists to end."""
61
+ path = root / ".crapkit" / LOG_NAME
62
+ return path.is_file() and _key_path(path).is_file()
63
+
64
+
65
+ def _stored_lines(root: Path, months: int) -> Iterator[str]:
66
+ """The same log with its commit dates still attached — the on-disk form."""
67
+ path = root / ".crapkit" / LOG_NAME
68
+ key = _cache_key(root, months)
69
+ served = _cached(path, key)
70
+ if served is not None:
71
+ return served
72
+ return _tee(_source(root, months, path, key), path, key)
73
+
74
+
75
+ def _source(root: Path, months: int, path: Path, key: dict | None) -> Iterator[str]:
76
+ refreshed = _refreshed(root, months, path, key)
77
+ if refreshed is not None:
78
+ return refreshed
79
+ return _window_log(root, months)
80
+
81
+
82
+ def _shipped(line: str) -> str:
83
+ """An enriched header down to the shipped `%an\\x02%at`; path lines pass through."""
84
+ if not line.startswith("\x01"):
85
+ return line
86
+ head, sep, _ = line.rstrip("\n").rpartition("\x02")
87
+ return head + "\n" if sep else line
88
+
89
+
90
+ def _commit_time(line: str) -> int:
91
+ """The committer timestamp off a header line; 0 when there is none to read."""
92
+ raw = line.rstrip("\n").rpartition("\x02")[2]
93
+ return int(raw) if raw.isdigit() else 0
94
+
95
+
96
+ def _utc_date() -> str:
97
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d")
98
+
99
+
100
+ def _cache_key(root: Path, months: int) -> dict | None:
101
+ """None when HEAD is unreadable — then there is nothing safe to key on."""
102
+ try:
103
+ head = head_commit(root)
104
+ except GitError:
105
+ return None
106
+ return {"head": head, "months": months, "date": _utc_date()}
107
+
108
+
109
+ def _key_path(path: Path) -> Path:
110
+ return path.with_suffix(".json")
111
+
112
+
113
+ def _read_key(path: Path) -> dict | None:
114
+ try:
115
+ doc = json.loads(_key_path(path).read_text(encoding="utf-8"))
116
+ except (OSError, ValueError):
117
+ return None
118
+ return doc if isinstance(doc, dict) else None
119
+
120
+
121
+ def _key_fields(doc: dict) -> dict:
122
+ return {field: doc.get(field) for field in ("head", "months", "date")}
123
+
124
+
125
+ def _cached(path: Path, key: dict | None) -> Iterator[str] | None:
126
+ """The stored log when it answers exactly this key, else None."""
127
+ stored = _read_key(path)
128
+ if key is None or stored is None or _key_fields(stored) != key:
129
+ return None
130
+ return _read_log(path, stored)
131
+
132
+
133
+ def _read_log(path: Path, stored: dict) -> Iterator[str] | None:
134
+ """Integrity first: the verdict on a torn log has to land before line one."""
135
+ try:
136
+ blob = path.read_bytes()
137
+ except OSError:
138
+ return None
139
+ if len(blob) != stored.get("size") or zlib.crc32(blob) != stored.get("crc"):
140
+ return None
141
+ return _inflate(blob)
142
+
143
+
144
+ def _inflate(blob: bytes) -> Iterator[str]:
145
+ """Inflate a chunk at a time and hand back whole lines: 4.7 MB of deflate is
146
+ 23 MB of text, and only the compressed side is ever resident.
147
+
148
+ An INCREMENTAL utf-8 decoder, because a deflate chunk boundary can fall in
149
+ the middle of a multi-byte character and author names are full of them.
150
+ """
151
+ dec = zlib.decompressobj()
152
+ utf8 = codecs.getincrementaldecoder("utf-8")("replace")
153
+ tail = ""
154
+ for start in range(0, len(blob), CHUNK):
155
+ text = tail + utf8.decode(dec.decompress(blob[start:start + CHUNK]))
156
+ lines = text.split("\n")
157
+ tail = lines.pop()
158
+ yield from (line + "\n" for line in lines)
159
+ if tail:
160
+ yield tail
161
+
162
+
163
+ def _refreshed(root: Path, months: int, path: Path, key: dict | None) -> Iterator[str] | None:
164
+ """The cached log carried forward to this HEAD, or None when only a walk will do."""
165
+ stored = _read_key(path)
166
+ if key is None or not _refreshable(root, stored, key):
167
+ return None
168
+ cached = _read_log(path, stored)
169
+ cutoff = _window_cutoff(root, months)
170
+ if cached is None or cutoff is None:
171
+ return None
172
+ return _within(chain(_fresh_commits(root, stored["head"], key["head"]), cached), cutoff)
173
+
174
+
175
+ def _refreshable(root: Path, stored: dict | None, key: dict) -> bool:
176
+ """True only for a cached log this HEAD grew from: same window, and behind us."""
177
+ if stored is None or stored.get("months") != key["months"]:
178
+ return False
179
+ if stored.get("head") == key["head"]:
180
+ return True
181
+ return is_ancestor(root, str(stored.get("head")), key["head"])
182
+
183
+
184
+ def _fresh_commits(root: Path, base: str, head: str) -> Iterator[str]:
185
+ """Nothing to walk when the log already ends at this HEAD — a re-dating is free."""
186
+ if base == head:
187
+ return iter(())
188
+ return _range_log(root, base, head)
189
+
190
+
191
+ def _within(lines: Iterator[str], cutoff: int) -> Iterator[str]:
192
+ """Commit blocks below the window floor, dropped whole with their paths."""
193
+ keep = False
194
+ for line in lines:
195
+ if line.startswith("\x01"):
196
+ keep = _commit_time(line) >= cutoff
197
+ if keep:
198
+ yield line
199
+
200
+
201
+ def _tee(source: Iterator[str], path: Path, key: dict | None) -> Iterator[str]:
202
+ """Stream the log out and lay the compressed copy down as the lines go past.
203
+
204
+ Written under a pid-unique .part and renamed at the end, so a reader that
205
+ stops early, a crash, or a second crapkit running beside this one never
206
+ leaves a truncated log looking valid.
207
+ """
208
+ part = _open_part(path, key)
209
+ if part is None:
210
+ yield from source
211
+ return
212
+ comp = zlib.compressobj(1)
213
+ try:
214
+ for line in source:
215
+ # normalized: the seam promises lines, not their endings, and a
216
+ # stub that strips them would be written back as one long line.
217
+ part.write(comp.compress(line.rstrip("\n").encode("utf-8") + b"\n"))
218
+ yield line
219
+ part.write(comp.flush())
220
+ except BaseException:
221
+ _discard(part, path)
222
+ raise
223
+ _keep(part, path, key)
224
+
225
+
226
+ def _part_path(path: Path) -> Path:
227
+ return path.with_suffix(f".{os.getpid()}.part")
228
+
229
+
230
+ def _open_part(path: Path, key: dict | None) -> BinaryIO | None:
231
+ """None when there is nothing to key on or nowhere to write: a read-only
232
+ .crapkit costs the speedup, never the command."""
233
+ if key is None:
234
+ return None
235
+ try:
236
+ path.parent.mkdir(parents=True, exist_ok=True)
237
+ return _part_path(path).open("wb")
238
+ except OSError:
239
+ return None
240
+
241
+
242
+ def _discard(part: BinaryIO, path: Path) -> None:
243
+ """Never raises: a cleanup error must not mask the failure that caused it."""
244
+ try:
245
+ part.close()
246
+ except OSError:
247
+ pass
248
+ _part_path(path).unlink(missing_ok=True)
249
+
250
+
251
+ def _keep(part: BinaryIO, path: Path, key: dict | None) -> None:
252
+ """Rename, then key. The key is written last and carries the log's size and
253
+ CRC, so a log without one — or with one that describes other bytes — is cold."""
254
+ try:
255
+ part.close()
256
+ _part_path(path).replace(path)
257
+ blob = path.read_bytes()
258
+ stamp = {**key, "size": len(blob), "crc": zlib.crc32(blob)}
259
+ _key_path(path).write_text(json.dumps(stamp, sort_keys=True), encoding="utf-8")
260
+ except OSError:
261
+ _part_path(path).unlink(missing_ok=True)
262
+
263
+
264
+ def _window_log(root: Path, months: int) -> Iterator[str]:
265
+ """The whole window, from git. The expensive one."""
266
+ return _git_lines(root, "log", f"--since={months} months ago", LOG_FORMAT, "--name-only")
267
+
268
+
269
+ def _range_log(root: Path, base: str, head: str) -> Iterator[str]:
270
+ """Only what HEAD added on top of the cached log."""
271
+ return _git_lines(root, "log", f"{base}..{head}", LOG_FORMAT, "--name-only")
272
+
273
+
274
+ def _window_cutoff(root: Path, months: int) -> int | None:
275
+ """git's own reading of "N months ago": rev-parse prints the --max-age it would
276
+ hand rev-list, so a re-dated log expires exactly what a fresh --since would.
277
+
278
+ None when git will not answer — and then a refresh would be guessing at the
279
+ window, which is what the full walk is for.
280
+ """
281
+ try:
282
+ out = "".join(_git_lines(root, "rev-parse", f"--since={months} months ago"))
283
+ except GitError:
284
+ return None
285
+ digits = out.strip().rpartition("=")[2]
286
+ return int(digits) if digits.isdigit() else None