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/admin.py
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
"""The setup and upkeep commands: `init` (sniff the repo, write a starter
|
|
2
|
+
crapkit.toml and extend .gitignore), `doctor` (does crapkit.toml still describe
|
|
3
|
+
this repo: keys, scopes, lanes, tools, unmeasured directories, plus the --json
|
|
4
|
+
report and the --tune knob advice) and `watch` (poll tracked files and rescore
|
|
5
|
+
what moved)."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .. import __version__
|
|
14
|
+
from ..config import load_config_text
|
|
15
|
+
from ..doctor import Finding
|
|
16
|
+
from ..errors import ConfigError, ToolError
|
|
17
|
+
from ..gitio import ls_files
|
|
18
|
+
from ..store import SnapshotStore
|
|
19
|
+
from ..universe import assign_files, scan_files
|
|
20
|
+
from ._shared import _file_sizer, _load_repo_config, _print_json
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _interpreter() -> str:
|
|
24
|
+
"""The interpreter name a committed config can call. sys.executable is this
|
|
25
|
+
machine's absolute path and would not survive the repo reaching anyone else."""
|
|
26
|
+
import shutil
|
|
27
|
+
|
|
28
|
+
return "python" if shutil.which("python") else "python3"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _present_markers(root: Path) -> frozenset[str]:
|
|
32
|
+
from ..scaffold import PYTEST_MARKERS
|
|
33
|
+
|
|
34
|
+
return frozenset(name for name in PYTEST_MARKERS if (root / name).is_file())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _package_json(root: Path) -> str:
|
|
38
|
+
path = root / "package.json"
|
|
39
|
+
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _print_init_summary(scopes: dict, lanes: tuple) -> None:
|
|
43
|
+
print(f"wrote crapkit.toml with {len(scopes)} scope(s): {', '.join(scopes)}")
|
|
44
|
+
if lanes:
|
|
45
|
+
print(f"detected {len(lanes)} lane(s) from this repo's own files: "
|
|
46
|
+
f"{', '.join(lane.name for lane in lanes)} — next: run `crapkit coverage`")
|
|
47
|
+
return
|
|
48
|
+
print("next: declare a [[lane]] per coverage command (see the commented template), "
|
|
49
|
+
"then run `crapkit coverage`")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _no_scopes_reason(root: Path) -> str:
|
|
53
|
+
"""Why init found nothing to scope.
|
|
54
|
+
|
|
55
|
+
crapkit reads `git ls-files`, so source nobody added is source it cannot see
|
|
56
|
+
— and that is the common case on the first command a new user runs. Blaming
|
|
57
|
+
the directory sends them to look in the one place that is already right.
|
|
58
|
+
"""
|
|
59
|
+
from ..gitio import untracked_files
|
|
60
|
+
from ..scaffold import source_candidates
|
|
61
|
+
|
|
62
|
+
untracked = source_candidates(untracked_files(root))
|
|
63
|
+
if not untracked:
|
|
64
|
+
return "no source files found to scope — is this the repo root?"
|
|
65
|
+
return ("no tracked source files to scope — crapkit scores git-tracked files only; "
|
|
66
|
+
f"run `git add` first ({len(untracked)} untracked source file(s) found)")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _extend_gitignore(root: Path, lanes: tuple) -> None:
|
|
70
|
+
"""Ignore what adopting crapkit will write: the store, and each lane's
|
|
71
|
+
artifact. Without this the consumer's next `git status` is a wall of
|
|
72
|
+
untracked coverage output nobody asked for."""
|
|
73
|
+
from ..scaffold import gitignore_update
|
|
74
|
+
|
|
75
|
+
path = root / ".gitignore"
|
|
76
|
+
current = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
77
|
+
text, added = gitignore_update(current, lanes)
|
|
78
|
+
if not added:
|
|
79
|
+
return
|
|
80
|
+
path.write_text(text, encoding="utf-8", newline="\n")
|
|
81
|
+
print(f"added to .gitignore: {', '.join(added)}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
85
|
+
from ..scaffold import detect_lanes, live_lanes, sniff_scopes, starter_toml
|
|
86
|
+
|
|
87
|
+
root = Path(args.repo).resolve()
|
|
88
|
+
toml_path = root / "crapkit.toml"
|
|
89
|
+
if toml_path.is_file():
|
|
90
|
+
raise ConfigError(f"crapkit.toml already exists in {root} — edit it instead")
|
|
91
|
+
scopes = sniff_scopes(ls_files(root))
|
|
92
|
+
if not scopes:
|
|
93
|
+
raise ConfigError(_no_scopes_reason(root))
|
|
94
|
+
# A config whose lanes are all commented out scores every function no-lane,
|
|
95
|
+
# so a fresh repo cannot rank anything until somebody hand-writes a lane.
|
|
96
|
+
lanes = detect_lanes(_present_markers(root), _package_json(root),
|
|
97
|
+
interpreter=_interpreter())
|
|
98
|
+
text = starter_toml(scopes, lanes)
|
|
99
|
+
load_config_text(text) # self-check: never write a config crapkit cannot read back
|
|
100
|
+
toml_path.write_text(text, encoding="utf-8", newline="\n")
|
|
101
|
+
_print_init_summary(scopes, lanes)
|
|
102
|
+
_extend_gitignore(root, live_lanes(lanes, scopes))
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _unknown_key_text(unknown) -> str:
|
|
107
|
+
"""Name the key AND the spellings its table accepts. A bare rejection makes
|
|
108
|
+
the reader hunt for a key list the tool never printed anywhere."""
|
|
109
|
+
from ..doctor import table_label, valid_keys
|
|
110
|
+
|
|
111
|
+
noun = "keys" if unknown.table else "tables"
|
|
112
|
+
return (f"unknown key {unknown.path} — crapkit ignores it (typo?); "
|
|
113
|
+
f"{table_label(unknown.table)} accepts these {noun}: "
|
|
114
|
+
f"{', '.join(valid_keys(unknown.table))}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _doctor_keys(raw: dict) -> list[Finding]:
|
|
118
|
+
from ..doctor import unknown_key_findings
|
|
119
|
+
|
|
120
|
+
problems = [Finding("FAIL", _unknown_key_text(u)) for u in unknown_key_findings(raw)]
|
|
121
|
+
return problems or [Finding("ok", "config keys all recognized")]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _listed_files(files: list[str], show: bool) -> list[Finding]:
|
|
125
|
+
return [Finding("", f" {f}") for f in files] if show else []
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _doctor_scope_files(files_by_scope: dict, cfg, show_files: bool) -> list[Finding]:
|
|
129
|
+
out: list[Finding] = []
|
|
130
|
+
for scope in cfg.scopes:
|
|
131
|
+
files = files_by_scope.get(scope.name, [])
|
|
132
|
+
out.append(Finding("ok" if files else "FAIL",
|
|
133
|
+
f"scope {scope.name!r}: {len(files)} files"))
|
|
134
|
+
out += _listed_files(files, show_files)
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _doctor_unclaimed(unclaimed: tuple[str, ...]) -> list[Finding]:
|
|
139
|
+
"""Tracked source in a declared language that no scope path owns. It is
|
|
140
|
+
analyzed by nothing and gated by nothing, and it says so nowhere else.
|
|
141
|
+
|
|
142
|
+
The paths ride the finding itself rather than trailing it as loose lines, so
|
|
143
|
+
the machine report names them too.
|
|
144
|
+
"""
|
|
145
|
+
if not unclaimed:
|
|
146
|
+
return [Finding("ok", "every tracked source file belongs to a scope")]
|
|
147
|
+
return [Finding("FAIL", f"{len(unclaimed)} tracked file(s) match a scope language but "
|
|
148
|
+
f"no scope path: {', '.join(unclaimed)} — add a [[scope]] "
|
|
149
|
+
"claiming them, or an [exclude] glob (docs/configuration.md)")]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _covered_scope_names(cfg) -> set[str]:
|
|
153
|
+
return {s for lane in cfg.lanes for s in lane.scopes} | cfg.coverage_optional_scopes
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _uncovered_scopes(cfg) -> list[str]:
|
|
157
|
+
# A repo with no lanes at all is the inventory-only case the lane summary
|
|
158
|
+
# already notes; coverage_optional scopes never need one.
|
|
159
|
+
if not cfg.lanes:
|
|
160
|
+
return []
|
|
161
|
+
covered = _covered_scope_names(cfg)
|
|
162
|
+
return [s.name for s in cfg.scopes if s.name not in covered]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _doctor_uncovered(cfg) -> list[Finding]:
|
|
166
|
+
return [Finding("FAIL", f"scope {name!r} is in no lane's scopes list — its functions "
|
|
167
|
+
"can only score no-lane (declare a lane, or "
|
|
168
|
+
"coverage_optional = true)")
|
|
169
|
+
for name in _uncovered_scopes(cfg)]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _doctor_oversized(oversized: tuple[tuple[str, int], ...]) -> list[Finding]:
|
|
173
|
+
"""Reported, never a failure: skipping the blob is what max_file_bytes asked for."""
|
|
174
|
+
return [Finding("note", f"{path} ({size} bytes) skipped: over max_file_bytes")
|
|
175
|
+
for path, size in oversized]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _doctor_scopes(root: Path, cfg, files: list[str], show_files: bool) -> list[Finding]:
|
|
179
|
+
universe = scan_files(files, cfg, size_of=_file_sizer(root))
|
|
180
|
+
return (_doctor_scope_files(universe.by_scope, cfg, show_files)
|
|
181
|
+
+ _doctor_unclaimed(universe.unclaimed)
|
|
182
|
+
+ _doctor_uncovered(cfg)
|
|
183
|
+
+ _doctor_oversized(universe.oversized))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _lane_problem(root: Path, lane) -> str | None:
|
|
187
|
+
if lane.cwd and not (root / lane.cwd).is_dir():
|
|
188
|
+
return f"lane {lane.name!r}: cwd {lane.cwd!r} does not exist"
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
_SCRIPT_SUFFIXES = (".py", ".mjs", ".js", ".ts", ".ps1", ".sh")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _missing_named_script(cwd: Path, tok: str) -> bool:
|
|
196
|
+
if not tok.endswith(_SCRIPT_SUFFIXES) or tok.startswith("-") or "=" in tok:
|
|
197
|
+
return False
|
|
198
|
+
return not (cwd / tok).is_file()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _lane_command_problems(root: Path, lane) -> list[str]:
|
|
202
|
+
"""Config rot a lane would only reveal 40 minutes in: a runner that no
|
|
203
|
+
longer resolves, a named script that left the repo. Nothing is executed."""
|
|
204
|
+
import shutil
|
|
205
|
+
|
|
206
|
+
problems = []
|
|
207
|
+
tokens = lane.command.split()
|
|
208
|
+
if tokens and shutil.which(tokens[0]) is None:
|
|
209
|
+
problems.append(f"lane {lane.name!r}: executable {tokens[0]!r} does not resolve on PATH")
|
|
210
|
+
cwd = root / lane.cwd if lane.cwd else root
|
|
211
|
+
problems += [f"lane {lane.name!r}: command names {tok!r}, which does not exist"
|
|
212
|
+
for tok in tokens[1:] if _missing_named_script(cwd, tok)]
|
|
213
|
+
return problems
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _doctor_lane_summary(cfg) -> Finding:
|
|
217
|
+
if cfg.lanes:
|
|
218
|
+
return Finding("ok", f"{len(cfg.lanes)} lane(s) declared")
|
|
219
|
+
return Finding("note", "no [[lane]] declared — inventory works; coverage needs one")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _lane_problems(root: Path, cfg) -> list[str]:
|
|
223
|
+
return [p for lane in cfg.lanes
|
|
224
|
+
for p in (_lane_problem(root, lane), *_lane_command_problems(root, lane)) if p]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _doctor_lanes(root: Path, cfg) -> list[Finding]:
|
|
228
|
+
return ([Finding("FAIL", p) for p in _lane_problems(root, cfg)]
|
|
229
|
+
or [_doctor_lane_summary(cfg)])
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _doctor_artifact_litter(cfg) -> list[Finding]:
|
|
233
|
+
"""WARN, never FAIL: a lane writing at the repo root still measures what it
|
|
234
|
+
always did. Failing here would break every consumer that adopted crapkit
|
|
235
|
+
before its lanes wrote under .crapkit/, over tree hygiene."""
|
|
236
|
+
from ..doctor import artifact_litter, scope_top_dirs
|
|
237
|
+
|
|
238
|
+
return [Finding("WARN", f"lane {item.lane!r} writes {item.path} at the repo root — "
|
|
239
|
+
f"point it under .crapkit/ (for example .crapkit/cov/{item.lane}/) "
|
|
240
|
+
"to keep the tree clean")
|
|
241
|
+
for item in artifact_litter(cfg.lanes, scope_top_dirs(cfg.scopes))]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _lizard_version() -> str | None:
|
|
245
|
+
try:
|
|
246
|
+
import lizard
|
|
247
|
+
except ImportError:
|
|
248
|
+
return None
|
|
249
|
+
return getattr(lizard, "version", "?")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _doctor_tools() -> list[Finding]:
|
|
253
|
+
version = _lizard_version()
|
|
254
|
+
if version is None:
|
|
255
|
+
return [Finding("FAIL", "lizard is not importable — pip install lizard")]
|
|
256
|
+
return [Finding("ok", f"lizard {version}")]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _store_path(root: Path) -> Path:
|
|
260
|
+
return root / ".crapkit" / "crap.sqlite"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _store_if_any(root: Path) -> SnapshotStore | None:
|
|
264
|
+
"""The store, or None when this repo has never run inventory. Doctor is the
|
|
265
|
+
one command that must describe a repo with nothing recorded yet."""
|
|
266
|
+
path = _store_path(root)
|
|
267
|
+
return SnapshotStore(path) if path.is_file() else None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _newest_coverage_run(store: SnapshotStore) -> dict | None:
|
|
271
|
+
runs = [r for r in store.list_runs() if r["kind"] == "coverage"]
|
|
272
|
+
return runs[-1] if runs else None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass
|
|
276
|
+
class _DirCount:
|
|
277
|
+
"""One directory's share of a run: how many functions it holds, how many of
|
|
278
|
+
them carry a verdict other than untested, and the file stems to match on."""
|
|
279
|
+
functions: int = 0
|
|
280
|
+
others: int = 0
|
|
281
|
+
stems: set = field(default_factory=set)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _dirs_from_counts(counts: list[tuple]) -> dict[str, _DirCount]:
|
|
285
|
+
from ..doctor import _dir_of, _stem_of
|
|
286
|
+
|
|
287
|
+
dirs: dict[str, _DirCount] = {}
|
|
288
|
+
for path, functions, others in counts:
|
|
289
|
+
entry = dirs.setdefault(_dir_of(path), _DirCount())
|
|
290
|
+
entry.functions += functions
|
|
291
|
+
entry.others += others
|
|
292
|
+
entry.stems.add(_stem_of(path))
|
|
293
|
+
return dirs
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _unmeasured_gaps(counts: list[tuple], tracked: list[str]) -> tuple:
|
|
297
|
+
"""doctor.unmeasured_directories, fed per-path counts instead of per-row rows.
|
|
298
|
+
|
|
299
|
+
Same rule, same order, same findings: a directory qualifies when nothing in
|
|
300
|
+
it carries a verdict other than untested and a tracked test file names its
|
|
301
|
+
code. The matching itself stays doctor's, so the mirror rule has one copy.
|
|
302
|
+
"""
|
|
303
|
+
from ..doctor import UnmeasuredDir, _matching_test, _test_files
|
|
304
|
+
|
|
305
|
+
test_files = _test_files(tracked)
|
|
306
|
+
found = []
|
|
307
|
+
for directory, stats in sorted(_dirs_from_counts(counts).items()):
|
|
308
|
+
example = _matching_test(directory, stats.stems, test_files) if not stats.others else None
|
|
309
|
+
if example:
|
|
310
|
+
found.append(UnmeasuredDir(directory, stats.functions, example))
|
|
311
|
+
return tuple(found)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _doctor_unmeasured(root: Path, cfg, files: list[str]) -> list[Finding]:
|
|
315
|
+
"""WARN, never FAIL: a directory whose functions are all untested while its
|
|
316
|
+
tests exist is a lane that runs without measuring the code it covers.
|
|
317
|
+
|
|
318
|
+
The store does the grouping. This used to build a hundred thousand
|
|
319
|
+
sixteen-field rows to read three fields off each of them, then filter the
|
|
320
|
+
coverage_optional scopes back out after reading them.
|
|
321
|
+
"""
|
|
322
|
+
store = _store_if_any(root)
|
|
323
|
+
run = _newest_coverage_run(store) if store else None
|
|
324
|
+
if run is None:
|
|
325
|
+
return []
|
|
326
|
+
counts = store.count_by_path(run["id"], flag="untested",
|
|
327
|
+
skip_scopes=cfg.coverage_optional_scopes)
|
|
328
|
+
return [Finding("WARN", f"{g.directory}: {g.functions} function(s) all flagged untested "
|
|
329
|
+
f"while {g.example_test} exists — tests exist but no lane "
|
|
330
|
+
"measures them")
|
|
331
|
+
for g in _unmeasured_gaps(counts, files)]
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _hook_modes(root: Path) -> dict[str, str]:
|
|
335
|
+
"""Index modes of the files the repo's `core.hooksPath` points at.
|
|
336
|
+
|
|
337
|
+
Empty when no hooks path is configured, when it points outside the worktree
|
|
338
|
+
(an absolute path is a legitimate setup, and `git ls-files` refuses it with
|
|
339
|
+
exit 128), and when nothing under it is tracked — the local `.git/hooks`
|
|
340
|
+
route commits no files, so there is no bit to be wrong.
|
|
341
|
+
"""
|
|
342
|
+
from ..errors import GitError
|
|
343
|
+
from ..gitio import config_value, index_modes
|
|
344
|
+
|
|
345
|
+
hooks_path = config_value(root, "core.hooksPath")
|
|
346
|
+
if not hooks_path:
|
|
347
|
+
return {}
|
|
348
|
+
try:
|
|
349
|
+
return index_modes(root, hooks_path)
|
|
350
|
+
except GitError:
|
|
351
|
+
return {}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _doctor_hook_modes(root: Path) -> list[Finding]:
|
|
355
|
+
"""WARN, never FAIL: on Windows the bit is unreadable from the filesystem and
|
|
356
|
+
the hook still runs, so a Windows author must not be blocked by it. On Linux
|
|
357
|
+
and macOS git skips a 100644 hook without a word, which is how crapkit's own
|
|
358
|
+
contributor gate armed nothing."""
|
|
359
|
+
from ..doctor import non_executable_hooks
|
|
360
|
+
|
|
361
|
+
return [Finding("WARN", f"{path} is not executable in the index — core.hooksPath "
|
|
362
|
+
"is set, so Unix clones silently skip it; fix with "
|
|
363
|
+
f"`git update-index --chmod=+x {path}` and commit")
|
|
364
|
+
for path in non_executable_hooks(_hook_modes(root))]
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
_CG_SIGNATURE = b"CGPH"
|
|
368
|
+
_BLOOM_CHUNK = b"BIDX" # the changed-path Bloom filter index
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _git_dir(root: Path) -> Path:
|
|
372
|
+
"""This repo's git directory. `.git` is a FILE in a linked worktree and in a
|
|
373
|
+
submodule, and its one line names the directory it stands for."""
|
|
374
|
+
dot = root / ".git"
|
|
375
|
+
if not dot.is_file():
|
|
376
|
+
return dot
|
|
377
|
+
named = dot.read_text(encoding="utf-8").partition("gitdir:")[2].strip()
|
|
378
|
+
return (root / named).resolve()
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _object_info_dir(root: Path) -> Path:
|
|
382
|
+
"""Where the commit-graph lives. A linked worktree keeps its own git
|
|
383
|
+
directory and shares the main one's objects, which `commondir` names."""
|
|
384
|
+
gitdir = _git_dir(root)
|
|
385
|
+
common = gitdir / "commondir"
|
|
386
|
+
if common.is_file():
|
|
387
|
+
gitdir = (gitdir / common.read_text(encoding="utf-8").strip()).resolve()
|
|
388
|
+
return gitdir / "objects" / "info"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _graph_files(info: Path) -> list[Path]:
|
|
392
|
+
"""Every commit-graph layer this repo has: the single file, or the layers a
|
|
393
|
+
chain file names. `git maintenance` writes the chain, `gc` writes the file."""
|
|
394
|
+
chain = info / "commit-graphs" / "commit-graph-chain"
|
|
395
|
+
if not chain.is_file():
|
|
396
|
+
single = info / "commit-graph"
|
|
397
|
+
return [single] if single.is_file() else []
|
|
398
|
+
return [info / "commit-graphs" / f"graph-{line}.graph"
|
|
399
|
+
for line in chain.read_text(encoding="utf-8").split()]
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _graph_chunks(path: Path) -> frozenset | None:
|
|
403
|
+
"""The chunk ids one commit-graph declares, or None when it declares none we
|
|
404
|
+
can trust. The header is signature, version, hash version, chunk count, base
|
|
405
|
+
count; then a 12-byte table entry per chunk plus a terminator. The chunk
|
|
406
|
+
bodies are never read, so this is one short read per layer.
|
|
407
|
+
"""
|
|
408
|
+
try:
|
|
409
|
+
with path.open("rb") as fh:
|
|
410
|
+
head = fh.read(8)
|
|
411
|
+
if len(head) < 8 or head[:4] != _CG_SIGNATURE:
|
|
412
|
+
return None
|
|
413
|
+
toc = fh.read((head[6] + 1) * 12)
|
|
414
|
+
except OSError:
|
|
415
|
+
return None
|
|
416
|
+
return frozenset(toc[i:i + 4] for i in range(0, len(toc), 12))
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _bloomless_graphs(root: Path) -> list[Path]:
|
|
420
|
+
"""Commit-graph layers written without changed-path Bloom filters."""
|
|
421
|
+
layers = [(path, _graph_chunks(path)) for path in _graph_files(_object_info_dir(root))]
|
|
422
|
+
return [path for path, chunks in layers if chunks and _BLOOM_CHUNK not in chunks]
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _doctor_commit_graph(root: Path) -> list[Finding]:
|
|
426
|
+
"""WARN, never FAIL: a commit-graph carrying no changed-path Bloom filters.
|
|
427
|
+
|
|
428
|
+
Every per-file history walk crapkit makes — churn, `brief`,
|
|
429
|
+
`explain --history` — asks git which commits touched one path, and without
|
|
430
|
+
the filters git opens every tree along the way: 1,147 ms against 194 ms on
|
|
431
|
+
the flagship consumer's 72,470 commits. A repo with NO commit-graph is left
|
|
432
|
+
alone; there is no shape to fix, and git decides when a history wants one.
|
|
433
|
+
"""
|
|
434
|
+
if not _bloomless_graphs(root):
|
|
435
|
+
return []
|
|
436
|
+
return [Finding("WARN", "the commit-graph carries no changed-path Bloom filters, so every "
|
|
437
|
+
"per-file history walk (churn, brief, explain --history) opens "
|
|
438
|
+
"every tree it passes — fix with `git commit-graph write "
|
|
439
|
+
"--reachable --changed-paths`")]
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _doctor_findings(root: Path, cfg, raw: dict, files: list[str],
|
|
443
|
+
show_files: bool) -> list[Finding]:
|
|
444
|
+
return (_doctor_keys(raw)
|
|
445
|
+
+ _doctor_scopes(root, cfg, files, show_files)
|
|
446
|
+
+ _doctor_lanes(root, cfg)
|
|
447
|
+
+ _doctor_artifact_litter(cfg)
|
|
448
|
+
+ _doctor_hook_modes(root)
|
|
449
|
+
+ _doctor_commit_graph(root)
|
|
450
|
+
+ _doctor_tools()
|
|
451
|
+
+ _doctor_scoped_tests(cfg)
|
|
452
|
+
+ _doctor_unmeasured(root, cfg, files))
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _doctor_scoped_tests(cfg) -> list[Finding]:
|
|
456
|
+
from ..doctor import scoped_test_gaps
|
|
457
|
+
return list(scoped_test_gaps(cfg.lanes, cfg.scoped_tests))
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _at_level(findings: list[Finding], level: str) -> list[str]:
|
|
461
|
+
return [f.text for f in findings if f.level == level]
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _version_report() -> dict:
|
|
465
|
+
import platform
|
|
466
|
+
|
|
467
|
+
return {"crapkit": __version__, "lizard": _lizard_version(),
|
|
468
|
+
"python": platform.python_version()}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _store_report(root: Path) -> dict:
|
|
472
|
+
path = _store_path(root)
|
|
473
|
+
present = path.is_file()
|
|
474
|
+
return {"path": ".crapkit/crap.sqlite", "present": present,
|
|
475
|
+
"size_bytes": path.stat().st_size if present else 0}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _newest_run_report(store: SnapshotStore | None) -> dict | None:
|
|
479
|
+
runs = store.list_runs() if store else []
|
|
480
|
+
if not runs:
|
|
481
|
+
return None
|
|
482
|
+
return {"id": runs[-1]["id"], "kind": runs[-1]["kind"],
|
|
483
|
+
"verdict_ok": runs[-1]["verdict_ok"]}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _lane_report(root: Path, lane, stamp: dict) -> dict:
|
|
487
|
+
return {"artifact": lane.artifact,
|
|
488
|
+
"artifact_present": (root / lane.artifact).is_file(),
|
|
489
|
+
"commit": stamp.get("commit"),
|
|
490
|
+
"name": lane.name,
|
|
491
|
+
"seconds": stamp.get("seconds")}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _lane_reports(root: Path, cfg) -> list[dict]:
|
|
495
|
+
from ..lanes import read_stamps
|
|
496
|
+
|
|
497
|
+
stamps = read_stamps(root)
|
|
498
|
+
return [_lane_report(root, lane, stamps.get(lane.artifact, {})) for lane in cfg.lanes]
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _doctor_report(root: Path, cfg, findings: list[Finding]) -> dict:
|
|
502
|
+
"""Everything a wrapper needs to tell lane rot from a stale artifact without
|
|
503
|
+
parsing prose: versions, store, newest run, per-lane stamps, findings."""
|
|
504
|
+
from ..analyze import ANALYSIS_VERSION
|
|
505
|
+
|
|
506
|
+
return {"analysis_version": ANALYSIS_VERSION,
|
|
507
|
+
"lanes": _lane_reports(root, cfg),
|
|
508
|
+
"newest_run": _newest_run_report(_store_if_any(root)),
|
|
509
|
+
"problems": _at_level(findings, "FAIL"),
|
|
510
|
+
"store": _store_report(root),
|
|
511
|
+
"versions": _version_report(),
|
|
512
|
+
"warnings": _at_level(findings, "WARN")}
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _print_findings(findings: list[Finding]) -> None:
|
|
516
|
+
for f in findings:
|
|
517
|
+
print(f"{f.level:<4} {f.text}" if f.level else f.text)
|
|
518
|
+
problems = _at_level(findings, "FAIL")
|
|
519
|
+
print("doctor: no problems found" if not problems else f"doctor: {len(problems)} problem(s)")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _emit_doctor(root: Path, cfg, findings: list[Finding], as_json: bool) -> None:
|
|
523
|
+
if as_json:
|
|
524
|
+
_print_json(_doctor_report(root, cfg, findings))
|
|
525
|
+
return
|
|
526
|
+
_print_findings(findings)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _junit_seconds(path: Path) -> float | None:
|
|
530
|
+
from ..junitparse import suite_seconds
|
|
531
|
+
|
|
532
|
+
if not path.is_file():
|
|
533
|
+
return None
|
|
534
|
+
try:
|
|
535
|
+
return suite_seconds(path.read_text(encoding="utf-8"))
|
|
536
|
+
except ToolError:
|
|
537
|
+
return None
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _lane_seconds(root: Path, lane, stamps: dict) -> float | None:
|
|
541
|
+
"""What this lane costs, best signal first: the duration its own run
|
|
542
|
+
recorded, else the wall time its junit report claims. None means this lane
|
|
543
|
+
has never left a cost signal on disk — which is not the same as costing 0."""
|
|
544
|
+
recorded = stamps.get(lane.artifact, {}).get("seconds")
|
|
545
|
+
if isinstance(recorded, (int, float)):
|
|
546
|
+
return float(recorded)
|
|
547
|
+
return _junit_seconds(root / lane.results_artifact) if lane.results_artifact else None
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _lane_durations(root: Path, cfg) -> tuple[float, ...]:
|
|
551
|
+
from ..lanes import read_stamps
|
|
552
|
+
|
|
553
|
+
stamps = read_stamps(root)
|
|
554
|
+
measured = [_lane_seconds(root, lane, stamps) for lane in cfg.lanes]
|
|
555
|
+
return tuple(s for s in measured if s is not None)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _doctor_tune(root: Path, cfg) -> int:
|
|
559
|
+
"""Advisory only: knob lines from this machine's cpu count and whatever lane
|
|
560
|
+
durations are already on disk. Nothing is written and nothing is executed."""
|
|
561
|
+
import os
|
|
562
|
+
|
|
563
|
+
from ..doctor import suggest_knobs, tune_lines
|
|
564
|
+
|
|
565
|
+
cpus = os.cpu_count() or 1
|
|
566
|
+
knobs = suggest_knobs(cpus=cpus, lanes=len(cfg.lanes))
|
|
567
|
+
for line in tune_lines(cpus=cpus, knobs=knobs, durations=_lane_durations(root, cfg)):
|
|
568
|
+
print(line)
|
|
569
|
+
return 0
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
573
|
+
import tomllib
|
|
574
|
+
|
|
575
|
+
root = Path(args.repo).resolve()
|
|
576
|
+
cfg = _load_repo_config(root) # a config that does not parse already exits 3 here
|
|
577
|
+
if args.tune:
|
|
578
|
+
return _doctor_tune(root, cfg)
|
|
579
|
+
raw = tomllib.loads((root / "crapkit.toml").read_text(encoding="utf-8"))
|
|
580
|
+
findings = _doctor_findings(root, cfg, raw, ls_files(root), args.show_files)
|
|
581
|
+
_emit_doctor(root, cfg, findings, args.json)
|
|
582
|
+
return 1 if _at_level(findings, "FAIL") else 0
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _watch_rescore(root: Path, moved: list[str]) -> None:
|
|
586
|
+
import subprocess
|
|
587
|
+
|
|
588
|
+
present = [f for f in moved if (root / f).is_file()]
|
|
589
|
+
if not present:
|
|
590
|
+
return
|
|
591
|
+
# flush: watch output exists to be tailed live; a block-buffered pipe sits silent
|
|
592
|
+
print(f"--- changed: {', '.join(moved)}", flush=True)
|
|
593
|
+
# a subprocess so a half-saved syntax error can never kill the watcher
|
|
594
|
+
subprocess.run([sys.executable, "-m", "crapkit", "rescore", *present, "--repo", str(root)])
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _watched_files(root: Path, cfg) -> list[str]:
|
|
598
|
+
"""Every tracked file a scope claims, flat — the whole subject of one poll."""
|
|
599
|
+
by_scope = assign_files(ls_files(root), cfg, size_of=_file_sizer(root))
|
|
600
|
+
return [f for files in by_scope.values() for f in files]
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _watch_cycles(cycles: int | None):
|
|
604
|
+
"""The poll counter: `cycles` polls, or an endless one when nothing bounds it.
|
|
605
|
+
|
|
606
|
+
Unbounded is the default, because a watcher an operator starts is meant to
|
|
607
|
+
outlive the shell it was typed into. A bound is what lets the loop be driven
|
|
608
|
+
to a known end — by a test, or by a caller that wants one sweep and its exit
|
|
609
|
+
code rather than a process to kill.
|
|
610
|
+
"""
|
|
611
|
+
from itertools import count
|
|
612
|
+
|
|
613
|
+
return count() if cycles is None else range(cycles)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _watch_banner(watched: int, interval: float, cycles: int | None) -> str:
|
|
617
|
+
"""The first line, naming how this run ends. Telling an operator to press
|
|
618
|
+
ctrl-c on a `--cycles 3` run describes a loop that is not the one running."""
|
|
619
|
+
stop = "ctrl-c to stop" if cycles is None else f"{cycles} poll(s) then stop"
|
|
620
|
+
return f"watching {watched} tracked files every {interval}s — {stop}"
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _watch_cycle(root: Path, files: list[str], prev: dict[str, float],
|
|
624
|
+
interval: float) -> dict[str, float]:
|
|
625
|
+
"""One poll: wait, re-stat, rescore whatever moved; the new snapshot out."""
|
|
626
|
+
import time
|
|
627
|
+
|
|
628
|
+
from ..watch import changed_paths, snapshot_mtimes
|
|
629
|
+
|
|
630
|
+
time.sleep(interval)
|
|
631
|
+
cur = snapshot_mtimes(root, files)
|
|
632
|
+
moved = changed_paths(prev, cur)
|
|
633
|
+
if moved:
|
|
634
|
+
_watch_rescore(root, moved)
|
|
635
|
+
return cur
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def cmd_watch(args: argparse.Namespace) -> int:
|
|
639
|
+
from ..watch import snapshot_mtimes
|
|
640
|
+
|
|
641
|
+
root = Path(args.repo).resolve()
|
|
642
|
+
files = _watched_files(root, _load_repo_config(root))
|
|
643
|
+
prev = snapshot_mtimes(root, files)
|
|
644
|
+
print(_watch_banner(len(prev), args.interval, args.cycles), flush=True)
|
|
645
|
+
try:
|
|
646
|
+
for _ in _watch_cycles(args.cycles):
|
|
647
|
+
prev = _watch_cycle(root, files, prev, args.interval)
|
|
648
|
+
except KeyboardInterrupt:
|
|
649
|
+
pass
|
|
650
|
+
return 0
|