crapkit 0.4.3__tar.gz → 0.4.4__tar.gz
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-0.4.3/src/crapkit.egg-info → crapkit-0.4.4}/PKG-INFO +13 -4
- {crapkit-0.4.3 → crapkit-0.4.4}/README.md +10 -3
- {crapkit-0.4.3 → crapkit-0.4.4}/pyproject.toml +6 -1
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/__init__.py +1 -1
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/churn_cache.py +11 -6
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/churn_log.py +18 -6
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/__init__.py +2 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/admin.py +48 -1
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/config.py +55 -7
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/gitio.py +8 -2
- {crapkit-0.4.3 → crapkit-0.4.4/src/crapkit.egg-info}/PKG-INFO +13 -4
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit.egg-info/requires.txt +3 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/LICENSE +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/setup.cfg +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/__main__.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/_pygdefer.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/analyze.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cache.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/churn.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/_shared.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/analyses.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/claude_hook.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/parser.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/queue.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/ratchet_cmds.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/reports.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/scoring.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/verifying.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/coupling.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/coverage_istanbul.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/coverage_py.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/covstream.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/diffparse.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/digest.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/discover.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/doctor.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/dup.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/errors.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/hook.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/junitparse.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/keys.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/lanes.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/lizardcognitive.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/lizardpowershell.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/lizardrust.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/lizardshell.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/mcp_server.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/merge.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/mutate.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/mutate_pool.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/override.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/packet.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/ratchet.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/ratchet_report.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/report.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/sarif.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/sarifio.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/scaffold.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/score.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/snapshot.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/store.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/uncovered.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/universe.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/verify.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/watch.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/worklist.py +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit.egg-info/SOURCES.txt +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit.egg-info/dependency_links.txt +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit.egg-info/entry_points.txt +0 -0
- {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crapkit
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more.
|
|
5
5
|
Author: Jean-Francois Gagne
|
|
6
6
|
License: MIT
|
|
@@ -29,6 +29,8 @@ Provides-Extra: dev
|
|
|
29
29
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
30
30
|
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
31
31
|
Requires-Dist: pytest-xdist>=3; extra == "dev"
|
|
32
|
+
Provides-Extra: py
|
|
33
|
+
Requires-Dist: pytest-cov>=5; extra == "py"
|
|
32
34
|
Dynamic: license-file
|
|
33
35
|
|
|
34
36
|
# crapkit
|
|
@@ -93,7 +95,10 @@ the repo can get better and never worse while you burn it down.
|
|
|
93
95
|
out to your own test runner, and the runner needs its coverage package installed:
|
|
94
96
|
`pytest-cov` for pytest, `@vitest/coverage-v8` (pinned to your vitest major) for vitest.
|
|
95
97
|
Without it the lane produces no artifact and `coverage` exits 5 quoting the runner's own
|
|
96
|
-
error.
|
|
98
|
+
error. For pytest, `init` probes the python its lane will run and prints the install
|
|
99
|
+
command when `pytest_cov` is missing; `pip install 'crapkit[py]'` pulls the plugin
|
|
100
|
+
alongside crapkit when the two share a venv. The two quickstarts below walk a real repo
|
|
101
|
+
end to end.
|
|
97
102
|
|
|
98
103
|
## Install
|
|
99
104
|
|
|
@@ -116,7 +121,7 @@ changing crapkit.
|
|
|
116
121
|
|
|
117
122
|
```
|
|
118
123
|
$ crapkit --version
|
|
119
|
-
crapkit 0.4.
|
|
124
|
+
crapkit 0.4.4
|
|
120
125
|
```
|
|
121
126
|
|
|
122
127
|
`python -m crapkit` works identically to the console script and is what to use from a
|
|
@@ -263,7 +268,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
|
|
|
263
268
|
repos:
|
|
264
269
|
- repo: https://github.com/JeanFrancoisGagne/crapkit
|
|
265
270
|
# crapkit's release step rewrites this line to the tag it just cut
|
|
266
|
-
rev: v0.4.
|
|
271
|
+
rev: v0.4.4
|
|
267
272
|
hooks:
|
|
268
273
|
- id: crapkit-gate
|
|
269
274
|
```
|
|
@@ -505,6 +510,10 @@ writes runs `pytest --cov` and those flags come from `pytest-cov`:
|
|
|
505
510
|
pip install pytest-cov
|
|
506
511
|
```
|
|
507
512
|
|
|
513
|
+
(or `pip install 'crapkit[py]'` to pull both at once, when crapkit lives in the same venv
|
|
514
|
+
as the suite — skip it and `init` prints this same fix when its probe finds the plugin
|
|
515
|
+
missing).
|
|
516
|
+
|
|
508
517
|
### 1. Scaffold the config
|
|
509
518
|
|
|
510
519
|
```
|
|
@@ -60,7 +60,10 @@ the repo can get better and never worse while you burn it down.
|
|
|
60
60
|
out to your own test runner, and the runner needs its coverage package installed:
|
|
61
61
|
`pytest-cov` for pytest, `@vitest/coverage-v8` (pinned to your vitest major) for vitest.
|
|
62
62
|
Without it the lane produces no artifact and `coverage` exits 5 quoting the runner's own
|
|
63
|
-
error.
|
|
63
|
+
error. For pytest, `init` probes the python its lane will run and prints the install
|
|
64
|
+
command when `pytest_cov` is missing; `pip install 'crapkit[py]'` pulls the plugin
|
|
65
|
+
alongside crapkit when the two share a venv. The two quickstarts below walk a real repo
|
|
66
|
+
end to end.
|
|
64
67
|
|
|
65
68
|
## Install
|
|
66
69
|
|
|
@@ -83,7 +86,7 @@ changing crapkit.
|
|
|
83
86
|
|
|
84
87
|
```
|
|
85
88
|
$ crapkit --version
|
|
86
|
-
crapkit 0.4.
|
|
89
|
+
crapkit 0.4.4
|
|
87
90
|
```
|
|
88
91
|
|
|
89
92
|
`python -m crapkit` works identically to the console script and is what to use from a
|
|
@@ -230,7 +233,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
|
|
|
230
233
|
repos:
|
|
231
234
|
- repo: https://github.com/JeanFrancoisGagne/crapkit
|
|
232
235
|
# crapkit's release step rewrites this line to the tag it just cut
|
|
233
|
-
rev: v0.4.
|
|
236
|
+
rev: v0.4.4
|
|
234
237
|
hooks:
|
|
235
238
|
- id: crapkit-gate
|
|
236
239
|
```
|
|
@@ -472,6 +475,10 @@ writes runs `pytest --cov` and those flags come from `pytest-cov`:
|
|
|
472
475
|
pip install pytest-cov
|
|
473
476
|
```
|
|
474
477
|
|
|
478
|
+
(or `pip install 'crapkit[py]'` to pull both at once, when crapkit lives in the same venv
|
|
479
|
+
as the suite — skip it and `init` prints this same fix when its probe finds the plugin
|
|
480
|
+
missing).
|
|
481
|
+
|
|
475
482
|
### 1. Scaffold the config
|
|
476
483
|
|
|
477
484
|
```
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "crapkit"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.4"
|
|
8
8
|
description = "Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more."
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -53,6 +53,11 @@ Source = "https://github.com/JeanFrancoisGagne/crapkit"
|
|
|
53
53
|
# without it. Left out of this list it took down 31 e2e tests on every one of
|
|
54
54
|
# the six CI test jobs (run 33225190806), ubuntu and windows alike.
|
|
55
55
|
dev = ["pytest>=8", "pytest-cov>=5", "pytest-xdist>=3"]
|
|
56
|
+
# The py lane's runtime half, for installs that share the suite's venv: the
|
|
57
|
+
# lane init writes runs `pytest --cov`, and those flags come from pytest-cov.
|
|
58
|
+
# An install elsewhere (pipx, uv tool) still needs pytest-cov beside the SUITE;
|
|
59
|
+
# init probes for that and prints the fix.
|
|
60
|
+
py = ["pytest-cov>=5"]
|
|
56
61
|
|
|
57
62
|
[project.scripts]
|
|
58
63
|
crapkit = "crapkit.cli:main"
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crapkit: deterministic CRAP-score framework."""
|
|
2
|
-
__version__ = "0.4.
|
|
2
|
+
__version__ = "0.4.4"
|
|
@@ -5,10 +5,12 @@ git diffing every commit's tree, and worklist, next-item and coupling each paid
|
|
|
5
5
|
it in full on every invocation, at an unmoved HEAD. Every one of them reaches
|
|
6
6
|
git through this module, so `.crapkit/churn-cache.json` has exactly one writer.
|
|
7
7
|
|
|
8
|
-
The key is (HEAD sha, window months, UTC date). The sha pins the
|
|
9
|
-
window pins the command; the date is there because `--since=N
|
|
10
|
-
evaluated against the wall clock, so yesterday's cache describes
|
|
11
|
-
day wider than today's
|
|
8
|
+
The key is (HEAD sha, window months, UTC date, path format). The sha pins the
|
|
9
|
+
history; the window pins the command; the date is there because `--since=N
|
|
10
|
+
months ago` is evaluated against the wall clock, so yesterday's cache describes
|
|
11
|
+
a window one day wider than today's; the format marker retires maps whose
|
|
12
|
+
paths predate `git log --relative`. Anything else is a miss, and a miss
|
|
13
|
+
rebuilds.
|
|
12
14
|
|
|
13
15
|
A cache is disposable: unreadable, corrupt or unkeyable content reads as cold,
|
|
14
16
|
never as a crash. Uncommitted work is invisible to churn either way. The one
|
|
@@ -23,7 +25,7 @@ from datetime import datetime, timezone
|
|
|
23
25
|
from pathlib import Path
|
|
24
26
|
|
|
25
27
|
from .churn import FileChurn, parse_git_log_lines
|
|
26
|
-
from .churn_log import has_cache, log_lines
|
|
28
|
+
from .churn_log import RELATIVE_PATHS, has_cache, log_lines
|
|
27
29
|
from .errors import GitError
|
|
28
30
|
from .gitio import churn_log_lines, head_commit
|
|
29
31
|
|
|
@@ -64,7 +66,10 @@ def _cache_key(root: Path, months: int) -> dict | None:
|
|
|
64
66
|
head = head_commit(root)
|
|
65
67
|
except GitError:
|
|
66
68
|
return None
|
|
67
|
-
|
|
69
|
+
# `paths` marks the format, not a question: maps built before --relative
|
|
70
|
+
# hold top-relative paths in a subdirectory root, and must read as cold.
|
|
71
|
+
return {"head": head, "months": months, "date": _utc_date(),
|
|
72
|
+
"paths": RELATIVE_PATHS}
|
|
68
73
|
|
|
69
74
|
|
|
70
75
|
def _read_cache(path: Path, key: dict | None) -> dict[str, FileChurn] | None:
|
|
@@ -42,6 +42,11 @@ from .gitio import _git_lines, head_commit, is_ancestor
|
|
|
42
42
|
LOG_NAME = "churn-log.z"
|
|
43
43
|
LOG_FORMAT = "--format=%x01%an%x02%at%x02%ct"
|
|
44
44
|
CHUNK = 1 << 20
|
|
45
|
+
# The key's format marker: these logs hold root-relative paths (--relative),
|
|
46
|
+
# the only kind that joins against ls-files rows when the root sits below the
|
|
47
|
+
# repo top. A key without it names a top-relative log, and that one is cold —
|
|
48
|
+
# served OR refreshed, it would feed every consumer paths that match nothing.
|
|
49
|
+
RELATIVE_PATHS = "root-relative"
|
|
45
50
|
|
|
46
51
|
|
|
47
52
|
def log_lines(root: Path, months: int) -> Iterator[str]:
|
|
@@ -103,7 +108,8 @@ def _cache_key(root: Path, months: int) -> dict | None:
|
|
|
103
108
|
head = head_commit(root)
|
|
104
109
|
except GitError:
|
|
105
110
|
return None
|
|
106
|
-
return {"head": head, "months": months, "date": _utc_date()
|
|
111
|
+
return {"head": head, "months": months, "date": _utc_date(),
|
|
112
|
+
"paths": RELATIVE_PATHS}
|
|
107
113
|
|
|
108
114
|
|
|
109
115
|
def _key_path(path: Path) -> Path:
|
|
@@ -119,7 +125,7 @@ def _read_key(path: Path) -> dict | None:
|
|
|
119
125
|
|
|
120
126
|
|
|
121
127
|
def _key_fields(doc: dict) -> dict:
|
|
122
|
-
return {field: doc.get(field) for field in ("head", "months", "date")}
|
|
128
|
+
return {field: doc.get(field) for field in ("head", "months", "date", "paths")}
|
|
123
129
|
|
|
124
130
|
|
|
125
131
|
def _cached(path: Path, key: dict | None) -> Iterator[str] | None:
|
|
@@ -173,9 +179,12 @@ def _refreshed(root: Path, months: int, path: Path, key: dict | None) -> Iterato
|
|
|
173
179
|
|
|
174
180
|
|
|
175
181
|
def _refreshable(root: Path, stored: dict | None, key: dict) -> bool:
|
|
176
|
-
"""True only for a cached log this HEAD grew from: same window,
|
|
182
|
+
"""True only for a cached log this HEAD grew from: same window, same path
|
|
183
|
+
format, and behind us."""
|
|
177
184
|
if stored is None or stored.get("months") != key["months"]:
|
|
178
185
|
return False
|
|
186
|
+
if stored.get("paths") != key["paths"]:
|
|
187
|
+
return False # a top-relative log: prepending would stack fresh commits on wrong paths
|
|
179
188
|
if stored.get("head") == key["head"]:
|
|
180
189
|
return True
|
|
181
190
|
return is_ancestor(root, str(stored.get("head")), key["head"])
|
|
@@ -262,13 +271,16 @@ def _keep(part: BinaryIO, path: Path, key: dict | None) -> None:
|
|
|
262
271
|
|
|
263
272
|
|
|
264
273
|
def _window_log(root: Path, months: int) -> Iterator[str]:
|
|
265
|
-
"""The whole window, from git. The expensive one.
|
|
266
|
-
|
|
274
|
+
"""The whole window, from git. The expensive one. --relative for the same
|
|
275
|
+
reason gitio.churn_log_lines carries it: consumers join these paths against
|
|
276
|
+
root-relative rows, and a root below the repo top matched nothing without it."""
|
|
277
|
+
return _git_lines(root, "log", "--relative", f"--since={months} months ago",
|
|
278
|
+
LOG_FORMAT, "--name-only")
|
|
267
279
|
|
|
268
280
|
|
|
269
281
|
def _range_log(root: Path, base: str, head: str) -> Iterator[str]:
|
|
270
282
|
"""Only what HEAD added on top of the cached log."""
|
|
271
|
-
return _git_lines(root, "log", f"{base}..{head}", LOG_FORMAT, "--name-only")
|
|
283
|
+
return _git_lines(root, "log", "--relative", f"{base}..{head}", LOG_FORMAT, "--name-only")
|
|
272
284
|
|
|
273
285
|
|
|
274
286
|
def _window_cutoff(root: Path, months: int) -> int | None:
|
|
@@ -204,6 +204,7 @@ _OWNER = {
|
|
|
204
204
|
"_prune_renames": "ratchet_cmds",
|
|
205
205
|
"_pruned": "ratchet_cmds",
|
|
206
206
|
"_pushdown_floor": "queue",
|
|
207
|
+
"_pytest_cov_probe": "admin",
|
|
207
208
|
"_range_lines": "analyses",
|
|
208
209
|
"_rankable": "queue",
|
|
209
210
|
"_ratchet_entries": "_shared",
|
|
@@ -287,6 +288,7 @@ _OWNER = {
|
|
|
287
288
|
"_version_line": "parser",
|
|
288
289
|
"_version_report": "admin",
|
|
289
290
|
"_warn_diff_uncovered": "verifying",
|
|
291
|
+
"_warn_missing_pytest_cov": "admin",
|
|
290
292
|
"_warn_suite_shrink": "verifying",
|
|
291
293
|
"_warn_untracked": "scoring",
|
|
292
294
|
"_watch_banner": "admin",
|
|
@@ -6,12 +6,13 @@ what moved)."""
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
import argparse
|
|
9
|
+
import os
|
|
9
10
|
import sys
|
|
10
11
|
from dataclasses import dataclass, field
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
|
|
13
14
|
from .. import __version__
|
|
14
|
-
from ..config import load_config_text
|
|
15
|
+
from ..config import load_config_text, shell_words
|
|
15
16
|
from ..doctor import Finding
|
|
16
17
|
from ..errors import ConfigError, ToolError
|
|
17
18
|
from ..gitio import ls_files
|
|
@@ -82,6 +83,51 @@ def _no_scopes_reason(root: Path) -> str:
|
|
|
82
83
|
f"run `git add` first ({len(untracked)} untracked source file(s) found)")
|
|
83
84
|
|
|
84
85
|
|
|
86
|
+
def _pytest_cov_probe(command: str) -> bool:
|
|
87
|
+
"""Can the interpreter this lane names import pytest_cov? The probe runs
|
|
88
|
+
through the same shell as the lane, so a bare `python` resolves to the one
|
|
89
|
+
the lane will get (a .bat shim included), not the one CreateProcess finds.
|
|
90
|
+
True too when the probe cannot run — only a clean "no" earns the warning,
|
|
91
|
+
and a missing interpreter is doctor's finding, not this one's."""
|
|
92
|
+
import shutil
|
|
93
|
+
import subprocess
|
|
94
|
+
|
|
95
|
+
words = shell_words(command)
|
|
96
|
+
if not words or shutil.which(words[0]) is None:
|
|
97
|
+
return True
|
|
98
|
+
probe = f'{_shell_quote(words[0])} -c "import pytest_cov"'
|
|
99
|
+
try:
|
|
100
|
+
return subprocess.run(probe, shell=True, capture_output=True,
|
|
101
|
+
timeout=15).returncode == 0
|
|
102
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _shell_quote(word: str) -> str:
|
|
107
|
+
"""One word, quoted for the shell the lane runs under."""
|
|
108
|
+
import shlex
|
|
109
|
+
|
|
110
|
+
if os.name != "nt":
|
|
111
|
+
return shlex.quote(word)
|
|
112
|
+
return f'"{word}"' if " " in word else word
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _warn_missing_pytest_cov(lanes: tuple) -> None:
|
|
116
|
+
"""The first-run trap, caught where it starts. The py lane shells out to
|
|
117
|
+
`pytest --cov`, and the --cov flags come from pytest-cov — a package of the
|
|
118
|
+
REPO's interpreter, so a crapkit dependency could only ever cover installs
|
|
119
|
+
sharing the suite's venv. Probe the python the lane will actually run and
|
|
120
|
+
say the fix now, instead of `coverage` exiting 5 with a lane log the first
|
|
121
|
+
run has to decode."""
|
|
122
|
+
for lane in lanes:
|
|
123
|
+
if lane.parser == "coveragepy" and "--cov" in lane.command \
|
|
124
|
+
and not _pytest_cov_probe(lane.command):
|
|
125
|
+
print(f"note: lane {lane.name!r} runs `pytest --cov`, and this python cannot "
|
|
126
|
+
"import pytest_cov — pip install pytest-cov where the suite runs "
|
|
127
|
+
"(pip install 'crapkit[py]' when that is crapkit's own environment), "
|
|
128
|
+
"then `crapkit coverage`", file=sys.stderr)
|
|
129
|
+
|
|
130
|
+
|
|
85
131
|
def _extend_gitignore(root: Path, lanes: tuple) -> None:
|
|
86
132
|
"""Ignore what adopting crapkit will write: the store, and each lane's
|
|
87
133
|
artifact. Without this the consumer's next `git status` is a wall of
|
|
@@ -115,6 +161,7 @@ def cmd_init(args: argparse.Namespace) -> int:
|
|
|
115
161
|
load_config_text(text) # self-check: never write a config crapkit cannot read back
|
|
116
162
|
toml_path.write_text(text, encoding="utf-8", newline="\n")
|
|
117
163
|
_print_init_summary(scopes, lanes)
|
|
164
|
+
_warn_missing_pytest_cov(live_lanes(lanes, scopes))
|
|
118
165
|
_extend_gitignore(root, live_lanes(lanes, scopes))
|
|
119
166
|
return 0
|
|
120
167
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""crapkit.toml parsing. Pure: text in, Config out; every rejection is a ConfigError (exit 3)."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import os
|
|
5
|
+
import shlex
|
|
4
6
|
import tomllib
|
|
5
7
|
from typing import NamedTuple
|
|
6
8
|
|
|
@@ -62,6 +64,49 @@ _PYTEST_VALUE_FLAGS = frozenset({
|
|
|
62
64
|
})
|
|
63
65
|
|
|
64
66
|
|
|
67
|
+
# Lane commands run under shell=True: sh on POSIX, cmd.exe on Windows. The two
|
|
68
|
+
# read a command line differently, and the guard has to read it like the one
|
|
69
|
+
# that will run it, or it accepts a lane cmd.exe breaks and refuses one it runs.
|
|
70
|
+
SHELL_IS_CMD = os.name == "nt"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def shell_words(command: str, cmd: bool | None = None) -> list[str]:
|
|
74
|
+
"""The words the shell hands the runner. `-m "not live and not perf"` is one
|
|
75
|
+
argument there and must be one token here — a whitespace split reads four
|
|
76
|
+
positionals into it. A command the shell would refuse (an unbalanced quote)
|
|
77
|
+
gets the whitespace read instead: a rough lint beats a crash at config load."""
|
|
78
|
+
cmd = SHELL_IS_CMD if cmd is None else cmd
|
|
79
|
+
try:
|
|
80
|
+
return _cmd_words(command) if cmd else shlex.split(command)
|
|
81
|
+
except ValueError:
|
|
82
|
+
return command.split()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _cmd_words(command: str) -> list[str]:
|
|
86
|
+
"""cmd.exe's reading: a double-quoted phrase is one word and loses its
|
|
87
|
+
quotes; a single quote is an ordinary character, so `'not live'` is two
|
|
88
|
+
words; a backslash separates path components and escapes nothing."""
|
|
89
|
+
lexer = shlex.shlex(command, posix=False)
|
|
90
|
+
lexer.whitespace_split = True
|
|
91
|
+
lexer.quotes = '"'
|
|
92
|
+
lexer.commenters = ""
|
|
93
|
+
return [_strip_double_quotes(word) for word in lexer]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _strip_double_quotes(word: str) -> str:
|
|
97
|
+
if len(word) >= 2 and word[0] == word[-1] == '"':
|
|
98
|
+
return word[1:-1]
|
|
99
|
+
return word
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _quote_hint(command: str) -> str:
|
|
103
|
+
"""The usual reason a Windows lane trips the guard: a value in single
|
|
104
|
+
quotes, which cmd.exe hands the runner one word per space."""
|
|
105
|
+
if SHELL_IS_CMD and "'" in command:
|
|
106
|
+
return " (cmd.exe does not treat ' as a quote: write the value in double quotes)"
|
|
107
|
+
return ""
|
|
108
|
+
|
|
109
|
+
|
|
65
110
|
def _looks_like_a_test_path(tok: str) -> bool:
|
|
66
111
|
"""The shapes a pytest positional actually takes: a path or a node id."""
|
|
67
112
|
return "/" in tok or "\\" in tok or "::" in tok or tok.endswith(".py")
|
|
@@ -105,14 +150,14 @@ def _validate_coveragepy_command(name: str, command: str) -> None:
|
|
|
105
150
|
# Subset coverage under a suite with cross-file pollution is run-order-dependent;
|
|
106
151
|
# a full-suite lane refuses positional narrowing. Scoped suites opt out with
|
|
107
152
|
# full_suite = false, an explicit and reviewable decision.
|
|
108
|
-
tokens = command
|
|
153
|
+
tokens = shell_words(command)
|
|
109
154
|
if "pytest" not in " ".join(tokens):
|
|
110
155
|
return
|
|
111
156
|
for tok in _narrowing_arguments(_tokens_after_pytest(tokens)):
|
|
112
157
|
raise ConfigError(
|
|
113
|
-
f"lane {name!r}: positional argument {tok
|
|
158
|
+
f"lane {name!r}: positional argument '{tok}' narrows a full-suite coverage run; "
|
|
114
159
|
f"drop it, attach it to the flag it belongs to (-n8, --numprocesses=8), "
|
|
115
|
-
f"or set full_suite = false deliberately")
|
|
160
|
+
f"or set full_suite = false deliberately{_quote_hint(command)}")
|
|
116
161
|
|
|
117
162
|
|
|
118
163
|
def _asks_for_coverage(tokens: list[str]) -> bool:
|
|
@@ -131,8 +176,11 @@ def _first_filter_position(tokens: list[str]) -> int:
|
|
|
131
176
|
# a filter here has to end in a source suffix already, so guessing that an unknown
|
|
132
177
|
# flag swallows one would retire the check instead of sharpening it.
|
|
133
178
|
_VITEST_VALUE_FLAGS = frozenset({
|
|
134
|
-
"-c", "
|
|
135
|
-
"--
|
|
179
|
+
"-c", "-t", "--config", "--coverage.exclude", "--coverage.include",
|
|
180
|
+
"--coverage.provider", "--coverage.reporter", "--coverage.reportsDirectory",
|
|
181
|
+
"--dir", "--environment", "--exclude", "--globalSetup", "--outputFile",
|
|
182
|
+
"--pool", "--project", "--reporter", "--root", "--setupFiles", "--shard",
|
|
183
|
+
"--testNamePattern",
|
|
136
184
|
})
|
|
137
185
|
|
|
138
186
|
|
|
@@ -148,13 +196,13 @@ def _validate_istanbul_command(name: str, command: str) -> None:
|
|
|
148
196
|
# The measured vitest trap: any file filter passed beside --coverage silently
|
|
149
197
|
# narrows the coverage include set. A lane command is fixed configuration, so
|
|
150
198
|
# the combination is a config error, not a runtime surprise.
|
|
151
|
-
tokens = command
|
|
199
|
+
tokens = shell_words(command)
|
|
152
200
|
if not _asks_for_coverage(tokens):
|
|
153
201
|
return
|
|
154
202
|
for i in range(_first_filter_position(tokens), len(tokens)):
|
|
155
203
|
if _is_file_filter(tokens[i], tokens[i - 1]):
|
|
156
204
|
raise ConfigError(
|
|
157
|
-
f"lane {name!r}: file filter {tokens[i]
|
|
205
|
+
f"lane {name!r}: file filter '{tokens[i]}' combined with --coverage silently narrows "
|
|
158
206
|
f"the coverage include set; drop the filter or use a dedicated config")
|
|
159
207
|
|
|
160
208
|
|
|
@@ -338,8 +338,14 @@ def file_log_patches(root: Path, rel_path: str) -> list[tuple[int, str]]:
|
|
|
338
338
|
|
|
339
339
|
def churn_log_lines(root: Path, months: int) -> Iterator[str]:
|
|
340
340
|
"""The churn window's log, streamed. On a big repo this is 21 MB of text and
|
|
341
|
-
the single most expensive call crapkit makes, so it is never held whole.
|
|
342
|
-
|
|
341
|
+
the single most expensive call crapkit makes, so it is never held whole.
|
|
342
|
+
|
|
343
|
+
--relative, because every consumer joins these paths against root-relative
|
|
344
|
+
ls-files rows: log --name-only answers relative to the repo top, so a root
|
|
345
|
+
one directory down (a monorepo member, a project nested in a worktree)
|
|
346
|
+
read every scored file as zero-churn. At the top the flag changes nothing.
|
|
347
|
+
"""
|
|
348
|
+
return _git_lines(root, "log", "--relative", f"--since={months} months ago",
|
|
343
349
|
"--format=%x01%an%x02%at", "--name-only")
|
|
344
350
|
|
|
345
351
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crapkit
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more.
|
|
5
5
|
Author: Jean-Francois Gagne
|
|
6
6
|
License: MIT
|
|
@@ -29,6 +29,8 @@ Provides-Extra: dev
|
|
|
29
29
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
30
30
|
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
31
31
|
Requires-Dist: pytest-xdist>=3; extra == "dev"
|
|
32
|
+
Provides-Extra: py
|
|
33
|
+
Requires-Dist: pytest-cov>=5; extra == "py"
|
|
32
34
|
Dynamic: license-file
|
|
33
35
|
|
|
34
36
|
# crapkit
|
|
@@ -93,7 +95,10 @@ the repo can get better and never worse while you burn it down.
|
|
|
93
95
|
out to your own test runner, and the runner needs its coverage package installed:
|
|
94
96
|
`pytest-cov` for pytest, `@vitest/coverage-v8` (pinned to your vitest major) for vitest.
|
|
95
97
|
Without it the lane produces no artifact and `coverage` exits 5 quoting the runner's own
|
|
96
|
-
error.
|
|
98
|
+
error. For pytest, `init` probes the python its lane will run and prints the install
|
|
99
|
+
command when `pytest_cov` is missing; `pip install 'crapkit[py]'` pulls the plugin
|
|
100
|
+
alongside crapkit when the two share a venv. The two quickstarts below walk a real repo
|
|
101
|
+
end to end.
|
|
97
102
|
|
|
98
103
|
## Install
|
|
99
104
|
|
|
@@ -116,7 +121,7 @@ changing crapkit.
|
|
|
116
121
|
|
|
117
122
|
```
|
|
118
123
|
$ crapkit --version
|
|
119
|
-
crapkit 0.4.
|
|
124
|
+
crapkit 0.4.4
|
|
120
125
|
```
|
|
121
126
|
|
|
122
127
|
`python -m crapkit` works identically to the console script and is what to use from a
|
|
@@ -263,7 +268,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
|
|
|
263
268
|
repos:
|
|
264
269
|
- repo: https://github.com/JeanFrancoisGagne/crapkit
|
|
265
270
|
# crapkit's release step rewrites this line to the tag it just cut
|
|
266
|
-
rev: v0.4.
|
|
271
|
+
rev: v0.4.4
|
|
267
272
|
hooks:
|
|
268
273
|
- id: crapkit-gate
|
|
269
274
|
```
|
|
@@ -505,6 +510,10 @@ writes runs `pytest --cov` and those flags come from `pytest-cov`:
|
|
|
505
510
|
pip install pytest-cov
|
|
506
511
|
```
|
|
507
512
|
|
|
513
|
+
(or `pip install 'crapkit[py]'` to pull both at once, when crapkit lives in the same venv
|
|
514
|
+
as the suite — skip it and `init` prints this same fix when its probe finds the plugin
|
|
515
|
+
missing).
|
|
516
|
+
|
|
508
517
|
### 1. Scaffold the config
|
|
509
518
|
|
|
510
519
|
```
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|