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.
Files changed (70) hide show
  1. {crapkit-0.4.3/src/crapkit.egg-info → crapkit-0.4.4}/PKG-INFO +13 -4
  2. {crapkit-0.4.3 → crapkit-0.4.4}/README.md +10 -3
  3. {crapkit-0.4.3 → crapkit-0.4.4}/pyproject.toml +6 -1
  4. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/__init__.py +1 -1
  5. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/churn_cache.py +11 -6
  6. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/churn_log.py +18 -6
  7. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/__init__.py +2 -0
  8. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/admin.py +48 -1
  9. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/config.py +55 -7
  10. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/gitio.py +8 -2
  11. {crapkit-0.4.3 → crapkit-0.4.4/src/crapkit.egg-info}/PKG-INFO +13 -4
  12. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit.egg-info/requires.txt +3 -0
  13. {crapkit-0.4.3 → crapkit-0.4.4}/LICENSE +0 -0
  14. {crapkit-0.4.3 → crapkit-0.4.4}/setup.cfg +0 -0
  15. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/__main__.py +0 -0
  16. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/_pygdefer.py +0 -0
  17. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/analyze.py +0 -0
  18. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cache.py +0 -0
  19. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/churn.py +0 -0
  20. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/_shared.py +0 -0
  21. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/analyses.py +0 -0
  22. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/claude_hook.py +0 -0
  23. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/parser.py +0 -0
  24. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/queue.py +0 -0
  25. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/ratchet_cmds.py +0 -0
  26. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/reports.py +0 -0
  27. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/scoring.py +0 -0
  28. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/cli/verifying.py +0 -0
  29. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/coupling.py +0 -0
  30. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/coverage_istanbul.py +0 -0
  31. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/coverage_py.py +0 -0
  32. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/covstream.py +0 -0
  33. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/diffparse.py +0 -0
  34. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/digest.py +0 -0
  35. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/discover.py +0 -0
  36. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/doctor.py +0 -0
  37. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/dup.py +0 -0
  38. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/errors.py +0 -0
  39. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/hook.py +0 -0
  40. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/junitparse.py +0 -0
  41. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/keys.py +0 -0
  42. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/lanes.py +0 -0
  43. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/lizardcognitive.py +0 -0
  44. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/lizardpowershell.py +0 -0
  45. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/lizardrust.py +0 -0
  46. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/lizardshell.py +0 -0
  47. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/mcp_server.py +0 -0
  48. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/merge.py +0 -0
  49. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/mutate.py +0 -0
  50. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/mutate_pool.py +0 -0
  51. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/override.py +0 -0
  52. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/packet.py +0 -0
  53. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/ratchet.py +0 -0
  54. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/ratchet_report.py +0 -0
  55. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/report.py +0 -0
  56. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/sarif.py +0 -0
  57. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/sarifio.py +0 -0
  58. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/scaffold.py +0 -0
  59. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/score.py +0 -0
  60. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/snapshot.py +0 -0
  61. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/store.py +0 -0
  62. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/uncovered.py +0 -0
  63. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/universe.py +0 -0
  64. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/verify.py +0 -0
  65. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/watch.py +0 -0
  66. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit/worklist.py +0 -0
  67. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit.egg-info/SOURCES.txt +0 -0
  68. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit.egg-info/dependency_links.txt +0 -0
  69. {crapkit-0.4.3 → crapkit-0.4.4}/src/crapkit.egg-info/entry_points.txt +0 -0
  70. {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
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. The two quickstarts below walk a real repo end to end.
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.3
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.3
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. The two quickstarts below walk a real repo end to end.
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.3
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.3
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.3"
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.3"
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 history; the
9
- window pins the command; the date is there because `--since=N months ago` is
10
- evaluated against the wall clock, so yesterday's cache describes a window one
11
- day wider than today's. Anything else is a miss, and a miss rebuilds.
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
- return {"head": head, "months": months, "date": _utc_date()}
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, and behind us."""
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
- return _git_lines(root, "log", f"--since={months} months ago", LOG_FORMAT, "--name-only")
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.split()
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!r} narrows a full-suite coverage run; "
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", "--config", "--dir", "--environment", "--exclude", "--outputFile",
135
- "--pool", "--project", "--reporter", "--root", "--shard",
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.split()
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]!r} combined with --coverage silently narrows "
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
- return _git_lines(root, "log", f"--since={months} months ago",
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
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. The two quickstarts below walk a real repo end to end.
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.3
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.3
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
  ```
@@ -4,3 +4,6 @@ lizard>=1.24.0
4
4
  pytest>=8
5
5
  pytest-cov>=5
6
6
  pytest-xdist>=3
7
+
8
+ [py]
9
+ pytest-cov>=5
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