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/__init__.py
ADDED
crapkit/__main__.py
ADDED
crapkit/_pygdefer.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Keep pygments out of a crapkit process that will never parse Erlang.
|
|
2
|
+
|
|
3
|
+
lizard imports every reader it ships, and `lizard_languages/erlang.py` binds
|
|
4
|
+
pygments at module scope — so `import lizard` drags in pygments, and behind it
|
|
5
|
+
importlib.metadata, email, zipfile and socket. Measured on this box: 42ms with
|
|
6
|
+
pygments, 16ms without, paid by every process that touches the analysis stack,
|
|
7
|
+
the pre-commit hook included.
|
|
8
|
+
|
|
9
|
+
crapkit analyzes five languages (typescript, tsx, javascript, python, swift).
|
|
10
|
+
Erlang is not one of them and no scope can name it. The readers that need
|
|
11
|
+
pygments are still SHIPPED, not removed: `deferred_pygments()` puts proxies in
|
|
12
|
+
sys.modules for the duration of the lizard import, so the readers bind stand-ins
|
|
13
|
+
and the real package loads the first time anything reads or calls one. An .erl
|
|
14
|
+
file analyzed through lizard directly gets the same answer; it just pays the
|
|
15
|
+
import at that moment.
|
|
16
|
+
|
|
17
|
+
Nothing is installed once pygments is already imported: a consumer that wanted
|
|
18
|
+
it keeps the module it loaded.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import importlib
|
|
23
|
+
import sys
|
|
24
|
+
import types
|
|
25
|
+
from contextlib import contextmanager
|
|
26
|
+
|
|
27
|
+
_NAMES = ("pygments", "pygments.token", "pygments.lexers")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _evict() -> None:
|
|
31
|
+
"""Drop the proxies so importlib loads the real modules underneath them."""
|
|
32
|
+
for name in _NAMES:
|
|
33
|
+
if isinstance(sys.modules.get(name), (_Proxy, _LazyCallable)):
|
|
34
|
+
del sys.modules[name]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _LazyCallable:
|
|
38
|
+
"""A pygments function bound by `from pygments import lex` before it exists."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, module: str, attr: str) -> None:
|
|
41
|
+
self._module, self._attr = module, attr
|
|
42
|
+
|
|
43
|
+
def __call__(self, *args, **kwargs):
|
|
44
|
+
_evict()
|
|
45
|
+
return getattr(importlib.import_module(self._module), self._attr)(*args, **kwargs)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _Proxy(types.ModuleType):
|
|
49
|
+
"""Stands in for a pygments module until something actually reads it."""
|
|
50
|
+
|
|
51
|
+
def __getattr__(self, attr: str):
|
|
52
|
+
if attr.startswith("__"):
|
|
53
|
+
raise AttributeError(attr)
|
|
54
|
+
_evict()
|
|
55
|
+
return getattr(importlib.import_module(self.__name__), attr)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _install() -> bool:
|
|
59
|
+
"""Proxy the three names lizard's Erlang reader binds. False when pygments
|
|
60
|
+
is already loaded, which leaves the real module exactly where it is."""
|
|
61
|
+
if "pygments" in sys.modules:
|
|
62
|
+
return False
|
|
63
|
+
stubs = {name: _Proxy(name) for name in _NAMES}
|
|
64
|
+
stubs["pygments"].token = stubs["pygments.token"]
|
|
65
|
+
stubs["pygments"].lexers = stubs["pygments.lexers"]
|
|
66
|
+
stubs["pygments"].lex = _LazyCallable("pygments", "lex")
|
|
67
|
+
sys.modules.update(stubs)
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@contextmanager
|
|
72
|
+
def deferred_pygments():
|
|
73
|
+
"""Import lizard inside this: its readers bind the proxies, and the proxies
|
|
74
|
+
come straight back out of sys.modules afterwards.
|
|
75
|
+
|
|
76
|
+
Taking them out is the part that matters for anything but speed. A module
|
|
77
|
+
left in sys.modules with no __path__ breaks the next `import
|
|
78
|
+
pygments.formatters` a process makes; only the readers that already bound a
|
|
79
|
+
proxy keep one, and each resolves itself on first use.
|
|
80
|
+
"""
|
|
81
|
+
installed = _install()
|
|
82
|
+
try:
|
|
83
|
+
yield
|
|
84
|
+
finally:
|
|
85
|
+
if installed:
|
|
86
|
+
_evict()
|
crapkit/analyze.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Analysis shell: run lizard once over explicit file lists and read both ccn columns off it.
|
|
2
|
+
|
|
3
|
+
Lizard is always fed explicit files, never directories: its own walker descends
|
|
4
|
+
nested node_modules (measured hang on the first consumer repo).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ._pygdefer import deferred_pygments
|
|
16
|
+
|
|
17
|
+
with deferred_pygments(): # lizard's Erlang reader would load pygments here
|
|
18
|
+
import lizard
|
|
19
|
+
|
|
20
|
+
from .cache import partition_by_cache, updated_cache
|
|
21
|
+
from .errors import ToolError
|
|
22
|
+
from .lizardcognitive import LizardExtension as _Cognitive
|
|
23
|
+
from .merge import FunctionRecord
|
|
24
|
+
|
|
25
|
+
_POOL_THRESHOLD = 16
|
|
26
|
+
|
|
27
|
+
# Bump whenever analysis semantics change (merge rules, extension set, record
|
|
28
|
+
# extraction): the fingerprint must invalidate cached records produced by older
|
|
29
|
+
# logic even when file content and tool versions are identical.
|
|
30
|
+
ANALYSIS_VERSION = 3 # 3: cognitive complexity column joined the standard pass
|
|
31
|
+
|
|
32
|
+
# The three tokens lizard's modified rule reacts to. Membership is checked before
|
|
33
|
+
# anything else runs, so the common token pays one frozenset lookup.
|
|
34
|
+
_SWITCH_TOKENS = frozenset({"switch", "match", "case"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _switch_delta(token: str, reader) -> int:
|
|
38
|
+
"""+1 for a switch-like block opener, -1 for one of its arms.
|
|
39
|
+
|
|
40
|
+
`match` and `case` are soft keywords in Python: the same spelling is an
|
|
41
|
+
identifier elsewhere, so the reader's own flags decide, exactly as lizard's
|
|
42
|
+
modified extension decides.
|
|
43
|
+
"""
|
|
44
|
+
if token == "case":
|
|
45
|
+
return -int("case" in reader.conditions or getattr(reader, "_keyword_case", False))
|
|
46
|
+
if token == "switch":
|
|
47
|
+
return 1
|
|
48
|
+
return int(getattr(reader, "_keyword_match", False))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _ModifiedDelta:
|
|
52
|
+
"""ccn_mod minus ccn_std, counted inside the standard pass.
|
|
53
|
+
|
|
54
|
+
lizard's modified extension does one thing: add_condition(+1) on a
|
|
55
|
+
switch/match opener and add_condition(-1) on each arm. It filters no tokens
|
|
56
|
+
and reads nothing else, so the two columns differ by that sum and by nothing
|
|
57
|
+
else, which made the second full tokenization of every file pure waste.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
FUNCTION_INFO = {"modified_delta": {"caption": " Mod "}}
|
|
61
|
+
|
|
62
|
+
def __call__(self, tokens, reader):
|
|
63
|
+
context = reader.context
|
|
64
|
+
for token in tokens:
|
|
65
|
+
if token in _SWITCH_TOKENS:
|
|
66
|
+
fn = context.current_function
|
|
67
|
+
delta = _switch_delta(token, reader)
|
|
68
|
+
fn.modified_delta = getattr(fn, "modified_delta", 0) + delta
|
|
69
|
+
yield token
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Built once per process, not once per file: 14k files paid 14k chain builds.
|
|
73
|
+
# Every extension in it keeps its state in the generator frame __call__ opens,
|
|
74
|
+
# so one chain serves every file. Cognitive comes FIRST because lizard's own
|
|
75
|
+
# preprocessors strip the whitespace tokens its python rules read; the delta
|
|
76
|
+
# comes last, where the modified pass used to sit.
|
|
77
|
+
_EXTENSIONS = [_Cognitive()] + lizard.get_extensions(["ND"]) + [_ModifiedDelta()]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _record(rel_path: str, fn) -> FunctionRecord:
|
|
81
|
+
std = fn.cyclomatic_complexity
|
|
82
|
+
mod = std + (getattr(fn, "modified_delta", 0) or 0)
|
|
83
|
+
return FunctionRecord(
|
|
84
|
+
path=rel_path,
|
|
85
|
+
long_name=fn.long_name,
|
|
86
|
+
start=fn.start_line,
|
|
87
|
+
end=fn.end_line,
|
|
88
|
+
ccn_std=std,
|
|
89
|
+
ccn_mod=mod,
|
|
90
|
+
ccn=min(std, mod),
|
|
91
|
+
nloc=fn.nloc,
|
|
92
|
+
params=len(fn.parameters),
|
|
93
|
+
nesting=getattr(fn, "max_nesting_depth", 0) or 0,
|
|
94
|
+
cognitive=getattr(fn, "cognitive_complexity", 0) or 0,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def analyze_one(args: tuple[str, str]) -> tuple[str, list[FunctionRecord]]:
|
|
99
|
+
abs_path, rel_path = args
|
|
100
|
+
try:
|
|
101
|
+
analysis = lizard.FileAnalyzer(_EXTENSIONS)(abs_path)
|
|
102
|
+
return rel_path, [_record(rel_path, fn) for fn in analysis.function_list]
|
|
103
|
+
except Exception as exc: # loud, with the file named
|
|
104
|
+
raise ToolError(f"lizard failed on {rel_path}: {exc}") from exc
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def analyze_source(rel_path: str, code: str) -> list[FunctionRecord]:
|
|
108
|
+
"""The records analyze_one would produce for a file holding `code`.
|
|
109
|
+
|
|
110
|
+
analyze_source_code is what FileAnalyzer.__call__ runs once it has read the
|
|
111
|
+
file, so nothing about the analysis depends on whether the source arrived
|
|
112
|
+
from the disk or from a git blob the caller already holds; rel_path picks
|
|
113
|
+
the language exactly as the path on disk did.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
analysis = lizard.FileAnalyzer(_EXTENSIONS).analyze_source_code(rel_path, code)
|
|
117
|
+
return [_record(rel_path, fn) for fn in analysis.function_list]
|
|
118
|
+
except Exception as exc: # loud, with the file named
|
|
119
|
+
raise ToolError(f"lizard failed on {rel_path}: {exc}") from exc
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def content_hash(path: Path) -> str:
|
|
123
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def fingerprint() -> str:
|
|
127
|
+
from . import __version__
|
|
128
|
+
return f"crapkit={__version__};analysis={ANALYSIS_VERSION};lizard={lizard.version}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _drained_records(entries: dict) -> dict[str, list[FunctionRecord]]:
|
|
132
|
+
"""Turn parsed rows into records, freeing each row list as it is consumed.
|
|
133
|
+
|
|
134
|
+
A comprehension over `entries` would hold the whole parsed list-of-lists
|
|
135
|
+
alongside the records built from it; on a large corpus that doubling is tens
|
|
136
|
+
of MB for no reason. Draining leaves the caller's parsed dict empty, which is
|
|
137
|
+
fine: nothing reads it afterwards.
|
|
138
|
+
"""
|
|
139
|
+
records: dict[str, list[FunctionRecord]] = {}
|
|
140
|
+
while entries:
|
|
141
|
+
h, rows = entries.popitem()
|
|
142
|
+
records[h] = [FunctionRecord(*vals) for vals in rows]
|
|
143
|
+
return records
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def load_cache(path: Path) -> dict:
|
|
147
|
+
"""A cache is disposable: corrupt or truncated content reads as cold, never as a crash.
|
|
148
|
+
|
|
149
|
+
save_cache is not atomic, so a killed process can leave a torn file; the
|
|
150
|
+
cache keys on raw bytes, so cross-machine line-ending settings just miss.
|
|
151
|
+
"""
|
|
152
|
+
if not path.is_file():
|
|
153
|
+
return {}
|
|
154
|
+
try:
|
|
155
|
+
with path.open(encoding="utf-8") as fh:
|
|
156
|
+
raw = json.load(fh)
|
|
157
|
+
return {"fp": raw.get("fp"), "entries": _drained_records(raw.get("entries", {}))}
|
|
158
|
+
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
|
159
|
+
return {}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def save_cache(path: Path, cache: dict, *, prior: dict | None = None) -> None:
|
|
163
|
+
"""Rewrite the cache file, unless `prior` says the bytes would not move.
|
|
164
|
+
|
|
165
|
+
Callers hand back what load_cache gave them. A fully-warm run rebuilds an
|
|
166
|
+
entry map equal to the one already on disk, and serializing it is the most
|
|
167
|
+
expensive thing such a run does; equal maps serialize identically because
|
|
168
|
+
the dump sorts its keys.
|
|
169
|
+
"""
|
|
170
|
+
if cache == prior:
|
|
171
|
+
return
|
|
172
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
with path.open("w", encoding="utf-8", newline="") as fh:
|
|
174
|
+
_write_entries(fh, cache["entries"])
|
|
175
|
+
fh.write(f", {json.dumps('fp')}: {json.dumps(cache['fp'])}}}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _write_entries(fh, entries: dict) -> None:
|
|
179
|
+
"""The `entries` object, one entry serialized at a time.
|
|
180
|
+
|
|
181
|
+
Byte-for-byte what json.dumps(document, sort_keys=True) wrote: keys sorted,
|
|
182
|
+
the two-character separators json uses by default, and `entries` ahead of
|
|
183
|
+
`fp` because sorted keys put it there. Building the document first meant the
|
|
184
|
+
rebuilt lists, the whole 18.6 MB string and its encoding were all live at
|
|
185
|
+
once; this way one entry is.
|
|
186
|
+
"""
|
|
187
|
+
fh.write('{"entries": {')
|
|
188
|
+
lead = ""
|
|
189
|
+
for h in sorted(entries):
|
|
190
|
+
fh.write(f"{lead}{json.dumps(h)}: {json.dumps([list(r) for r in entries[h]])}")
|
|
191
|
+
lead = ", "
|
|
192
|
+
fh.write("}")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
_STAMPS_NAME = "stat-stamps.json"
|
|
196
|
+
|
|
197
|
+
# A stamp is only recorded once the file has held still this long. Windows moves
|
|
198
|
+
# a file time on a ~15 ms clock tick, so two writes inside one tick can land on
|
|
199
|
+
# one mtime; a file written moments ago is not evidence that it is unchanged.
|
|
200
|
+
_STAMP_SETTLE_NS = 2_000_000_000
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _stamps_path(root: Path) -> Path:
|
|
204
|
+
return root / ".crapkit" / _STAMPS_NAME
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _load_stamps(path: Path) -> dict:
|
|
208
|
+
"""rel path -> [mtime_ns, size, content hash], or nothing at all.
|
|
209
|
+
|
|
210
|
+
An index of what the last run saw. Disposable exactly like the analysis
|
|
211
|
+
cache: unreadable, torn, or written by another format reads as no index,
|
|
212
|
+
which costs a full hashing pass and never a wrong answer.
|
|
213
|
+
"""
|
|
214
|
+
try:
|
|
215
|
+
with path.open(encoding="utf-8") as fh:
|
|
216
|
+
raw = json.load(fh)
|
|
217
|
+
return raw["stamps"] if raw.get("v") == 1 else {}
|
|
218
|
+
except (json.JSONDecodeError, OSError, TypeError, ValueError, KeyError, AttributeError):
|
|
219
|
+
return {}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _save_stamps(path: Path, stamps: dict, prior: dict) -> None:
|
|
223
|
+
if stamps == prior:
|
|
224
|
+
return
|
|
225
|
+
try:
|
|
226
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
path.write_text(json.dumps({"v": 1, "stamps": stamps}, sort_keys=True), encoding="utf-8")
|
|
228
|
+
except OSError:
|
|
229
|
+
pass # an index nobody can write is a slower run, never a failed one
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _stat_of(path: Path) -> tuple[int, int] | None:
|
|
233
|
+
try:
|
|
234
|
+
st = path.stat()
|
|
235
|
+
except OSError:
|
|
236
|
+
return None
|
|
237
|
+
return st.st_mtime_ns, st.st_size
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _unmoved(stat: tuple[int, int] | None, prior) -> bool:
|
|
241
|
+
"""True when this file's stat is exactly what it was when we hashed it."""
|
|
242
|
+
return bool(prior) and stat is not None and stat[0] == prior[0] and stat[1] == prior[1]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _stamp(fresh: dict, rel: str, stat: tuple[int, int] | None, digest: str, now: int) -> None:
|
|
246
|
+
if stat is not None and now - stat[0] >= _STAMP_SETTLE_NS:
|
|
247
|
+
fresh[rel] = [stat[0], stat[1], digest]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _hash_paths(root: Path, rel_paths: list[str], stamps: dict) -> tuple[dict[str, str], dict]:
|
|
251
|
+
"""Content hash per path, plus the stat index the next run should keep.
|
|
252
|
+
|
|
253
|
+
The cache keys are content hashes and stay content hashes; (mtime_ns, size)
|
|
254
|
+
only decides whether a hash has to be recomputed. A file that has not moved
|
|
255
|
+
since the run that hashed it keeps that hash without being opened, which is
|
|
256
|
+
the difference between reading 14k files and stat-ing them.
|
|
257
|
+
|
|
258
|
+
Its one blind spot: content rewritten to the same length under a deliberately
|
|
259
|
+
restored mtime. Any real write moves the mtime, and a write too close to the
|
|
260
|
+
last one is refused a stamp, so the next genuine change corrects it.
|
|
261
|
+
"""
|
|
262
|
+
now = time.time_ns()
|
|
263
|
+
hashes: dict[str, str] = {}
|
|
264
|
+
fresh: dict = {}
|
|
265
|
+
for rel in rel_paths:
|
|
266
|
+
stat = _stat_of(root / rel)
|
|
267
|
+
prior = stamps.get(rel)
|
|
268
|
+
hashes[rel] = prior[2] if _unmoved(stat, prior) else content_hash(root / rel)
|
|
269
|
+
_stamp(fresh, rel, stat, hashes[rel], now)
|
|
270
|
+
return hashes, fresh
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _kept_stamps(prior: dict, fresh: dict, visited: set) -> dict:
|
|
274
|
+
"""Paths this run looked at are described by this run alone: a file too
|
|
275
|
+
recently written to stamp must not keep an older run's stamp. Paths it never
|
|
276
|
+
looked at keep theirs, or a rescore of four files would blind the next
|
|
277
|
+
inventory of fourteen thousand."""
|
|
278
|
+
kept = {rel: stamp for rel, stamp in prior.items() if rel not in visited}
|
|
279
|
+
kept.update(fresh)
|
|
280
|
+
return kept
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _restamped(hits: dict[str, list[FunctionRecord]]) -> dict[str, list[FunctionRecord]]:
|
|
284
|
+
# A cache entry keys on content only; re-stamp the path so a moved file cannot
|
|
285
|
+
# carry its old location into the snapshot. Almost nothing moves between two
|
|
286
|
+
# runs, and rebuilding 140k namedtuples to write back the path they already
|
|
287
|
+
# hold is the most expensive thing a fully-warm run does.
|
|
288
|
+
return {path: _rows_for(path, rows) for path, rows in hits.items()}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _rows_for(path: str, rows: list[FunctionRecord]) -> list[FunctionRecord]:
|
|
292
|
+
"""One entry's rows all carry one path, because analyze_one stamps every
|
|
293
|
+
record it emits with the single path it was handed. The first row answers
|
|
294
|
+
for all of them."""
|
|
295
|
+
if rows and rows[0].path == path:
|
|
296
|
+
return rows
|
|
297
|
+
return [r._replace(path=path) for r in rows]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
_MEMORY_BUDGET_ENV = "CRAPKIT_ANALYSIS_MEMORY_MB"
|
|
301
|
+
|
|
302
|
+
# Measured peak RSS of one analysis worker over the consumer repo: 24 workers reached
|
|
303
|
+
# 807 MB of tree RSS, the biggest child 47 MB, the median child 35 MB.
|
|
304
|
+
_WORKER_PEAK_MB = 35
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _memory_budget_mb() -> int | None:
|
|
308
|
+
"""The budget in MB, or None when it is unset or not a positive whole number.
|
|
309
|
+
|
|
310
|
+
A mistyped knob must not silently serialize a run: unreadable reads as absent.
|
|
311
|
+
"""
|
|
312
|
+
raw = os.environ.get(_MEMORY_BUDGET_ENV, "").strip()
|
|
313
|
+
if not raw.isdigit() or raw == "0":
|
|
314
|
+
return None
|
|
315
|
+
return int(raw)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _memory_bounded(workers: int | None) -> int | None:
|
|
319
|
+
"""The worker count, capped by CRAPKIT_ANALYSIS_MEMORY_MB when it is set.
|
|
320
|
+
|
|
321
|
+
Off by default: no budget means one worker per core, as before. Cold over
|
|
322
|
+
the consumer repo, 24 workers peak at 807 MB in 11.8 s and 16 at 565 MB in 14.2 s, so
|
|
323
|
+
a box short of memory can buy 242 MB back for 2.4 s. Never returns zero
|
|
324
|
+
workers: a budget smaller than one worker still gets one.
|
|
325
|
+
"""
|
|
326
|
+
budget = _memory_budget_mb()
|
|
327
|
+
if budget is None:
|
|
328
|
+
return workers
|
|
329
|
+
return min(workers or os.cpu_count() or 1, max(1, budget // _WORKER_PEAK_MB))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def analyze_jobs(
|
|
333
|
+
jobs: list[tuple[str, str]],
|
|
334
|
+
*,
|
|
335
|
+
workers: int | None = None,
|
|
336
|
+
pool_threshold: int = _POOL_THRESHOLD,
|
|
337
|
+
chunksize: int = 32,
|
|
338
|
+
) -> dict[str, list[FunctionRecord]]:
|
|
339
|
+
"""Run lizard over (abs_path, rel_path) jobs, pooled once there are enough.
|
|
340
|
+
|
|
341
|
+
The two knobs exist because the inventory and the pre-commit hook sit at
|
|
342
|
+
different scales: an inventory feeds thousands of files and wants fat
|
|
343
|
+
chunks, a hook feeds a commit's worth and needs each job dealt to a
|
|
344
|
+
different worker (a chunksize above the job count leaves one worker doing
|
|
345
|
+
all of them, serially, after paying for the pool).
|
|
346
|
+
"""
|
|
347
|
+
fresh: dict[str, list[FunctionRecord]] = {}
|
|
348
|
+
if len(jobs) >= pool_threshold:
|
|
349
|
+
with ProcessPoolExecutor(max_workers=_memory_bounded(workers)) as pool:
|
|
350
|
+
for rel_path, records in pool.map(analyze_one, jobs, chunksize=chunksize):
|
|
351
|
+
fresh[rel_path] = records
|
|
352
|
+
return fresh
|
|
353
|
+
for job in jobs:
|
|
354
|
+
rel_path, records = analyze_one(job)
|
|
355
|
+
fresh[rel_path] = records
|
|
356
|
+
return fresh
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def analyze_files(
|
|
360
|
+
root: Path, rel_paths: list[str], *, cache: dict, workers: int | None = None
|
|
361
|
+
) -> tuple[dict[str, list[FunctionRecord]], int, dict]:
|
|
362
|
+
fp = fingerprint()
|
|
363
|
+
stamps_path = _stamps_path(root)
|
|
364
|
+
prior_stamps = _load_stamps(stamps_path)
|
|
365
|
+
hashes, fresh_stamps = _hash_paths(root, rel_paths, prior_stamps)
|
|
366
|
+
kept = _kept_stamps(prior_stamps, fresh_stamps, set(rel_paths))
|
|
367
|
+
_save_stamps(stamps_path, kept, prior_stamps)
|
|
368
|
+
hits, misses = partition_by_cache(hashes, cache, fingerprint=fp)
|
|
369
|
+
hits = _restamped(hits)
|
|
370
|
+
|
|
371
|
+
fresh = analyze_jobs([(str(root / rel), rel) for rel in misses], workers=workers)
|
|
372
|
+
|
|
373
|
+
all_records = {**hits, **fresh}
|
|
374
|
+
new_cache = updated_cache(hashes, all_records, fingerprint=fp)
|
|
375
|
+
return all_records, len(hits), new_cache
|
crapkit/cache.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Content-hash analysis cache. Pure partition/update; the shell owns file I/O.
|
|
2
|
+
|
|
3
|
+
Keys are file content hashes, never mtimes (checkout resets mtimes). The
|
|
4
|
+
fingerprint bundles everything that changes analysis output for identical
|
|
5
|
+
content (lizard pin, crapkit analysis version); a fingerprint change drops
|
|
6
|
+
the whole cache rather than serving stale records.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .merge import FunctionRecord
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def partition_by_cache(
|
|
14
|
+
hashes: dict[str, str],
|
|
15
|
+
cache: dict,
|
|
16
|
+
*,
|
|
17
|
+
fingerprint: str,
|
|
18
|
+
) -> tuple[dict[str, list[FunctionRecord]], list[str]]:
|
|
19
|
+
entries = cache.get("entries", {}) if cache.get("fp") == fingerprint else {}
|
|
20
|
+
hits: dict[str, list[FunctionRecord]] = {}
|
|
21
|
+
misses: list[str] = []
|
|
22
|
+
for path in sorted(hashes):
|
|
23
|
+
records = entries.get(hashes[path])
|
|
24
|
+
if records is None:
|
|
25
|
+
misses.append(path)
|
|
26
|
+
else:
|
|
27
|
+
hits[path] = records
|
|
28
|
+
return hits, misses
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def updated_cache(
|
|
32
|
+
hashes: dict[str, str],
|
|
33
|
+
records_by_path: dict[str, list[FunctionRecord]],
|
|
34
|
+
*,
|
|
35
|
+
fingerprint: str,
|
|
36
|
+
stale_hashes: list[str] | None = None,
|
|
37
|
+
) -> dict:
|
|
38
|
+
return {
|
|
39
|
+
"fp": fingerprint,
|
|
40
|
+
"entries": {hashes[path]: records for path, records in sorted(records_by_path.items())},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def merged_cache(prior: dict, fresh: dict) -> dict:
|
|
45
|
+
"""Fold a partial run's entries into the prior map instead of replacing it.
|
|
46
|
+
|
|
47
|
+
A run that analyzed a handful of files (rescore, watch, the pre-commit hook)
|
|
48
|
+
knows nothing about the rest of the corpus, so its `entries` map is not a new
|
|
49
|
+
cache but an addition to one. Merging leaves entries for content that has
|
|
50
|
+
left the corpus behind; the whole-corpus runs (inventory, coverage) save their
|
|
51
|
+
rebuilt map without merging, and that stays the one eviction point.
|
|
52
|
+
|
|
53
|
+
A prior written under another fingerprint is not stale, it is wrong; drop it
|
|
54
|
+
exactly as partition_by_cache does.
|
|
55
|
+
"""
|
|
56
|
+
fp = fresh["fp"]
|
|
57
|
+
prior_entries = prior.get("entries", {}) if prior.get("fp") == fp else {}
|
|
58
|
+
return {"fp": fp, "entries": {**prior_entries, **fresh["entries"]}}
|
crapkit/churn.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Per-file churn from git history. Pure parser; the shell supplies the log text.
|
|
2
|
+
|
|
3
|
+
The log format is one \\x01-prefixed author line per commit followed by the
|
|
4
|
+
commit's file paths (git log --format=%x01%an --name-only).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from typing import NamedTuple
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FileChurn(NamedTuple):
|
|
13
|
+
commits: int
|
|
14
|
+
authors: int
|
|
15
|
+
weight: float = 0.0 # recency-weighted commit sum; commit count when untimestamped
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_SIMPLE_ESCAPES = {"n": 10, "t": 9, "r": 13, '"': 34, "\\": 92, "a": 7, "b": 8, "f": 12, "v": 11}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_quoted(line: str) -> bool:
|
|
22
|
+
return len(line) >= 2 and line.startswith('"') and line.endswith('"')
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _escape_at(body: str, i: int) -> tuple[bytes, int]:
|
|
26
|
+
"""Decode the escape starting at the backslash on `i`; return its bytes and the next index."""
|
|
27
|
+
nxt = body[i + 1] if i + 1 < len(body) else ""
|
|
28
|
+
if nxt.isdigit():
|
|
29
|
+
return bytes([int(body[i + 1:i + 4], 8)]), i + 4
|
|
30
|
+
if nxt in _SIMPLE_ESCAPES:
|
|
31
|
+
return bytes([_SIMPLE_ESCAPES[nxt]]), i + 2
|
|
32
|
+
return nxt.encode("utf-8"), i + 2
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _unquote_git_path(line: str) -> str:
|
|
36
|
+
"""Undo git's core.quotePath C-style quoting so churn keys match ls-files paths.
|
|
37
|
+
|
|
38
|
+
Quoted lines are wrapped in double quotes with backslash escapes; non-ASCII
|
|
39
|
+
bytes appear as literal octal text (\\303\\251). Decoded octal bytes are
|
|
40
|
+
UTF-8.
|
|
41
|
+
"""
|
|
42
|
+
if not _is_quoted(line):
|
|
43
|
+
return line
|
|
44
|
+
body = line[1:-1]
|
|
45
|
+
out = bytearray()
|
|
46
|
+
i = 0
|
|
47
|
+
while i < len(body):
|
|
48
|
+
ch = body[i]
|
|
49
|
+
if ch != "\\":
|
|
50
|
+
out += ch.encode("utf-8")
|
|
51
|
+
i += 1
|
|
52
|
+
continue
|
|
53
|
+
chunk, i = _escape_at(body, i)
|
|
54
|
+
out += chunk
|
|
55
|
+
return out.decode("utf-8", errors="replace")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _twr(ts: int, oldest: int, newest: int) -> float:
|
|
59
|
+
import math
|
|
60
|
+
|
|
61
|
+
span = max(newest - oldest, 1)
|
|
62
|
+
t_norm = (ts - oldest) / span
|
|
63
|
+
return 1.0 / (1.0 + math.exp(-12.0 * t_norm + 12.0))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _split_author(line: str) -> tuple[str, int | None]:
|
|
67
|
+
body = line[1:]
|
|
68
|
+
if "\x02" not in body:
|
|
69
|
+
return body, None
|
|
70
|
+
name, _, raw = body.partition("\x02")
|
|
71
|
+
return name, int(raw) if raw.isdigit() else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _collect(lines: Iterable[str]):
|
|
75
|
+
commits: dict[str, int] = {}
|
|
76
|
+
authors: dict[str, set[str]] = {}
|
|
77
|
+
stamps: dict[str, list[int]] = {}
|
|
78
|
+
author, ts = None, None
|
|
79
|
+
for raw in lines:
|
|
80
|
+
line = raw.strip()
|
|
81
|
+
if not line:
|
|
82
|
+
continue
|
|
83
|
+
if line.startswith("\x01"):
|
|
84
|
+
author, ts = _split_author(line)
|
|
85
|
+
continue
|
|
86
|
+
if author is None:
|
|
87
|
+
continue
|
|
88
|
+
path = _unquote_git_path(line).replace("\\", "/")
|
|
89
|
+
commits[path] = commits.get(path, 0) + 1
|
|
90
|
+
authors.setdefault(path, set()).add(author)
|
|
91
|
+
if ts is not None:
|
|
92
|
+
stamps.setdefault(path, []).append(ts)
|
|
93
|
+
return commits, authors, stamps
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse_git_log(text: str) -> dict[str, FileChurn]:
|
|
97
|
+
"""Whole-text entrypoint: the log already in hand."""
|
|
98
|
+
return parse_git_log_lines(text.splitlines())
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_git_log_lines(lines: Iterable[str]) -> dict[str, FileChurn]:
|
|
102
|
+
"""Streaming entrypoint: one commit block resident at a time, whatever the log's size."""
|
|
103
|
+
commits, authors, stamps = _collect(lines)
|
|
104
|
+
all_stamps = [s for lst in stamps.values() for s in lst]
|
|
105
|
+
oldest, newest = (min(all_stamps), max(all_stamps)) if all_stamps else (0, 0)
|
|
106
|
+
|
|
107
|
+
def weight(path: str, count: int) -> float:
|
|
108
|
+
if path not in stamps:
|
|
109
|
+
return float(count) # untimestamped log: degrade to commit count
|
|
110
|
+
return round(sum(_twr(s, oldest, newest) for s in stamps[path]), 4)
|
|
111
|
+
|
|
112
|
+
return {p: FileChurn(commits=c, authors=len(authors[p]), weight=weight(p, c))
|
|
113
|
+
for p, c in commits.items()}
|