crapkit 0.4.5__tar.gz → 0.4.7__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 (72) hide show
  1. {crapkit-0.4.5/src/crapkit.egg-info → crapkit-0.4.7}/PKG-INFO +48 -9
  2. {crapkit-0.4.5 → crapkit-0.4.7}/README.md +45 -8
  3. {crapkit-0.4.5 → crapkit-0.4.7}/pyproject.toml +20 -3
  4. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/__init__.py +1 -1
  5. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/__init__.py +3 -0
  6. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/admin.py +97 -16
  7. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/claude_hook.py +127 -4
  8. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/covstream.py +10 -3
  9. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/lanes.py +326 -9
  10. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/scaffold.py +97 -18
  11. {crapkit-0.4.5 → crapkit-0.4.7/src/crapkit.egg-info}/PKG-INFO +48 -9
  12. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit.egg-info/requires.txt +2 -0
  13. {crapkit-0.4.5 → crapkit-0.4.7}/LICENSE +0 -0
  14. {crapkit-0.4.5 → crapkit-0.4.7}/setup.cfg +0 -0
  15. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/__main__.py +0 -0
  16. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/_pygdefer.py +0 -0
  17. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/analyze.py +0 -0
  18. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cache.py +0 -0
  19. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/churn.py +0 -0
  20. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/churn_cache.py +0 -0
  21. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/churn_log.py +0 -0
  22. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/_shared.py +0 -0
  23. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/analyses.py +0 -0
  24. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/parser.py +0 -0
  25. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/queue.py +0 -0
  26. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/ratchet_cmds.py +0 -0
  27. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/reports.py +0 -0
  28. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/scoring.py +0 -0
  29. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/verifying.py +0 -0
  30. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/config.py +0 -0
  31. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/coupling.py +0 -0
  32. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/coupling_cache.py +0 -0
  33. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/coverage_istanbul.py +0 -0
  34. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/coverage_py.py +0 -0
  35. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/diffparse.py +0 -0
  36. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/digest.py +0 -0
  37. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/discover.py +0 -0
  38. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/doctor.py +0 -0
  39. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/dup.py +0 -0
  40. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/errors.py +0 -0
  41. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/gitio.py +0 -0
  42. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/hook.py +0 -0
  43. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/junitparse.py +0 -0
  44. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/keys.py +0 -0
  45. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/lizardcognitive.py +0 -0
  46. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/lizardpowershell.py +0 -0
  47. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/lizardrust.py +0 -0
  48. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/lizardshell.py +0 -0
  49. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/mcp_server.py +0 -0
  50. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/merge.py +0 -0
  51. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/mutate.py +0 -0
  52. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/mutate_pool.py +0 -0
  53. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/override.py +0 -0
  54. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/packet.py +0 -0
  55. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/procs.py +0 -0
  56. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/ratchet.py +0 -0
  57. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/ratchet_report.py +0 -0
  58. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/report.py +0 -0
  59. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/sarif.py +0 -0
  60. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/sarifio.py +0 -0
  61. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/score.py +0 -0
  62. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/snapshot.py +0 -0
  63. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/store.py +0 -0
  64. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/uncovered.py +0 -0
  65. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/universe.py +0 -0
  66. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/verify.py +0 -0
  67. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/watch.py +0 -0
  68. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/worklist.py +0 -0
  69. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit.egg-info/SOURCES.txt +0 -0
  70. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit.egg-info/dependency_links.txt +0 -0
  71. {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit.egg-info/entry_points.txt +0 -0
  72. {crapkit-0.4.5 → crapkit-0.4.7}/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.5
3
+ Version: 0.4.7
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
@@ -31,8 +31,10 @@ Provides-Extra: dev
31
31
  Requires-Dist: pytest>=8; extra == "dev"
32
32
  Requires-Dist: pytest-cov>=5; extra == "dev"
33
33
  Requires-Dist: pytest-xdist>=3; extra == "dev"
34
+ Requires-Dist: coverage>=7.10.6; extra == "dev"
34
35
  Provides-Extra: py
35
36
  Requires-Dist: pytest-cov>=5; extra == "py"
37
+ Requires-Dist: coverage>=7.10.6; extra == "py"
36
38
  Dynamic: license-file
37
39
 
38
40
  # crapkit
@@ -143,7 +145,7 @@ changing crapkit.
143
145
 
144
146
  ```
145
147
  $ crapkit --version
146
- crapkit 0.4.5
148
+ crapkit 0.4.7
147
149
  ```
148
150
 
149
151
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -225,6 +227,33 @@ ceiling. It adds no files to your repo, and it needs the crapkit CLI on PATH.
225
227
  A repo with no `crapkit.toml` costs a silent sub-50 ms no-op per edit. Other agent
226
228
  runtimes have no marketplace: copy `plugin/skills/*` into their skills directory instead.
227
229
 
230
+ The hook registers on `Edit|Write`, which is every write that names a file. An agent that
231
+ writes its source through a shell heredoc names none, so a `Bash` event is judged off the
232
+ working tree instead. That half is yours to register, because it costs two
233
+ git spawns per shell call. Add a second PostToolUse entry to your own settings, same
234
+ command, matcher `Bash`:
235
+
236
+ ```json
237
+ {
238
+ "hooks": {
239
+ "PostToolUse": [
240
+ {
241
+ "matcher": "Bash",
242
+ "hooks": [
243
+ { "type": "command", "command": "crapkit claude-hook --protocol 1", "timeout": 20 }
244
+ ]
245
+ }
246
+ ]
247
+ }
248
+ }
249
+ ```
250
+
251
+ The cost is one `git rev-parse --show-toplevel` and one `git status --porcelain -z -uall`
252
+ per shell call in any git repo, whether or not crapkit measures it: about 30 ms together
253
+ on crapkit's own checkout, and more on a bigger tree. What comes back is the dirty or
254
+ untracked `*.py` files written in the last 12 seconds, 25 at most, each judged the way an
255
+ edit is. Python only, so a TypeScript or Go repo pays the two spawns and hears nothing.
256
+
228
257
  ## Languages
229
258
 
230
259
  14 languages, two coverage parsers. Coverage joins where a parser exists; everything else
@@ -344,7 +373,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
344
373
  repos:
345
374
  - repo: https://github.com/JeanFrancoisGagne/crapkit
346
375
  # crapkit's release step rewrites this line to the tag it just cut
347
- rev: v0.4.5
376
+ rev: v0.4.7
348
377
  hooks:
349
378
  - id: crapkit-gate
350
379
  ```
@@ -486,7 +515,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
486
515
  | `mutate [--files F ...] [--max-mutants N] [--drop-pool] [--json]` | Diff-scoped mutation testing: flips comparisons, boundary shifts, boolean connectives and boolean literals on changed lines, runs `mutation_command` per mutant, lists survivors. `--files` replaces diff scope with the whole file. `--max-mutants` (default 100) caps the run and the cap warning goes to stderr only, so `mutants` in `--json` is the capped count. Shell and PowerShell files are refused by name on stderr rather than mutated: `<` and `>` are redirections there, not comparisons. With `mutation_workers > 1` the worker worktrees are kept at `.crapkit/mutate-pool/` and re-prepared per run (30.6 s to build four on a 31,459-file repo, 0.46 s to re-prepare them); `--drop-pool` removes them and exits. |
487
516
  | `test-scoped FILE ...` | Runs each owning scope's `[crapkit.scoped_tests]` template on the files (quoted, longest-prefix scope wins). A template with no `{files}` runs as written, which is how a scope whose tests live outside its own paths runs its whole suite. Exit code only; a nonzero runner exits 1. |
488
517
  | `hook-precommit` | The cc-only gate on staged blobs. No coverage, no snapshot, no repo-wide cache. Exit 6 on a violation. |
489
- | `claude-hook [--protocol N]` | Reads one Claude Code PostToolUse payload from stdin and judges the file it edited: ccn against the scope ceiling, on functions the edit changed, minus functions a ratchet mark already covers. Advisory only. The edit has landed, nothing is blocked, and `hook-precommit` stays the enforcement point. Exit 2 with three lines on stderr is the only thing it ever says: no `crapkit.toml` above the edited file, an unscoped file, mid-rebase or mid-merge, a `--protocol` other than 1, source that parses to no functions, or any internal failure all exit 0 in silence. Takes no `--repo`, because the root is the first `crapkit.toml` above the edited file and the upward walk stops at a `.git` entry, so a worktree never borrows its parent's config. It opens no snapshot and writes nothing. |
518
+ | `claude-hook [--protocol N]` | Reads one Claude Code PostToolUse payload from stdin and judges the file it edited: ccn against the scope ceiling, on functions the edit changed, minus functions a ratchet mark already covers. Advisory only: the edit has landed, and `hook-precommit` stays the enforcement point. Exit 2 and an advisory on stderr is the only thing it ever says, one block per judged file (a head line, one line per breaching function, a closing line): no `crapkit.toml` above the edited file, an unscoped file, mid-rebase or mid-merge, a `--protocol` other than 1, source that parses to no functions, or any internal failure all exit 0 in silence. The root is the first `crapkit.toml` above the edited file; the walk stops at a `.git` entry, so a worktree never borrows its parent's config. A `Bash` event names no file, so it judges the working tree instead: the dirty or untracked `*.py` files touched in the last 12 seconds, 25 at most, each through the same ladder, and silence for a clean tree or a cwd outside any repo. That half fires only where you register a `Bash` matcher ([The Claude Code plugin](#the-claude-code-plugin)). It opens no snapshot and writes nothing. |
490
519
  | `watch [--interval SECONDS] [--cycles N]` | Rescores tracked files as they change (mtime polling, default 2s, subprocess-isolated so a half-saved syntax error never kills the watcher). `--cycles N` polls exactly N times and exits 0; without it the loop runs until ctrl-c. |
491
520
  | `mcp` | A dependency-free stdio MCP server (newline JSON-RPC 2.0) exposing nine read-only tools. Every tool shells to the CLI's own `--json` surface, so the MCP view cannot drift from what the CLI reports. Answering from a kept in-process store was benchmarked and rejected: a packet's `source` would go stale behind the edit it describes. See [docs/agent-json.md](docs/agent-json.md#mcp-server). |
492
521
 
@@ -614,7 +643,7 @@ crapkit: run 3 is an inventory run (no coverage was measured) and cannot serve a
614
643
  | 2 | Usage error from argparse: unknown flag, missing positional. Raised before crapkit's own error handling. |
615
644
  | 3 | Config error: `crapkit.toml` missing or unparseable, an unknown language or parser, a lane command the shell that runs it reads as a narrowed suite, a ratchet metric-stamp mismatch ([Upgrading from 0.4.4](#upgrading-from-044)), a `test-scoped` file under no scope or under a scope with no template. |
616
645
  | 4 | Git error: not a repository, a baseline commit rewritten out of the history. |
617
- | 5 | Tool error: lizard not importable, a lane produced no artifact, a lane timed out past its retries, an override alert command failed. A `timeout_seconds` kills the whole process tree, so no orphan suite keeps running behind the failure. |
646
+ | 5 | Tool error: lizard not importable, a lane that produced no artifact, one that measured a different tree, one that measured this tree and reported it in absolute paths (the join is root-relative, so those match nothing either; the refusal names the runner's own switch, `relative_files = true` under `[tool.coverage.run]` for a coveragepy lane, the reporter's `cwd`/`root` option for an istanbul one), a lane that timed out past its retries, an override alert command that failed. A `timeout_seconds` kills the whole process tree, so no orphan suite keeps running behind the failure. |
618
647
  | 6 | Gate violation. A function the diff touched is over its ceiling and past any ratchet mark it carries: an edit that leaves a marked function at or under its mark is the debt the repo signed for and is exempt. Also `rescore --gate`, which applies the same rule, and `hook-precommit`, which exempts on the mark's existence instead. |
619
648
  | 7 | Ratchet regression the diff never touched. A marked function scores worse than its recorded high-water mark; a touched one past its mark reports 6. |
620
649
  | 8 | New test failures against the baseline run. Failures the baseline already had do not count. |
@@ -647,6 +676,11 @@ pip install pytest-cov
647
676
 
648
677
  (`pip install "crapkit[py]"` pulls both at once when crapkit shares the suite's venv.)
649
678
 
679
+ If your suite drives its own CLI through `subprocess.run`, add `[tool.coverage.run]
680
+ patch = ["subprocess"]` to `pyproject.toml` and keep `coverage>=7.10.6`: pytest-cov 7.0.0
681
+ dropped subprocess measurement, so without that key every entry point scores 0% and nothing
682
+ warns. [docs/lanes.md](docs/lanes.md) has the whole rule.
683
+
650
684
  ### 1. Scaffold the config
651
685
 
652
686
  ```
@@ -659,9 +693,14 @@ added to .gitignore: .crapkit/, .coverage, __pycache__/
659
693
  `init` sniffs tracked source into one scope per top-level source directory, and detects a
660
694
  coverage lane from what the repo already has: a pytest marker file (`pyproject.toml`,
661
695
  `pytest.ini`, `setup.cfg`) writes a live `[[lane]]`, and so does a `test` script or
662
- `vitest`/`jest` in `package.json`. Whatever it detects, it also leaves commented templates
663
- for the runners it did not find. Every lane it writes reports into `.crapkit/cov/`, which
664
- is why the `.gitignore` list is so short: see
696
+ `vitest`/`jest` in `package.json`. A lockfile beside them names the environment: `uv.lock`,
697
+ `poetry.lock`, `pdm.lock` or `Pipfile.lock` makes the lane `uv run python -m pytest …` (and
698
+ the matching `run` for the rest), because a bare `python` binds to whichever venv the shell
699
+ has active rather than the one the repo pins — see
700
+ [The interpreter a lane binds to](docs/lanes.md#the-interpreter-a-lane-binds-to). Whatever
701
+ it detects, it also leaves commented templates for the runners it did not find, and those
702
+ carry the same launcher, so uncommenting one cannot hand the bare `python` back. Every lane
703
+ it writes reports into `.crapkit/cov/`, which is why the `.gitignore` list is so short: see
665
704
  [Where artifacts live](docs/lanes.md#where-artifacts-live).
666
705
 
667
706
  ```toml
@@ -821,7 +860,7 @@ exit 5:
821
860
 
822
861
  ```
823
862
  $ crapkit coverage
824
- crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
863
+ crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); full log: /repo/.crapkit/lane-js.log; last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
825
864
 
826
865
  MISSING DEPENDENCY Cannot find dependency '@vitest/coverage-v8'
827
866
 
@@ -106,7 +106,7 @@ changing crapkit.
106
106
 
107
107
  ```
108
108
  $ crapkit --version
109
- crapkit 0.4.5
109
+ crapkit 0.4.7
110
110
  ```
111
111
 
112
112
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -188,6 +188,33 @@ ceiling. It adds no files to your repo, and it needs the crapkit CLI on PATH.
188
188
  A repo with no `crapkit.toml` costs a silent sub-50 ms no-op per edit. Other agent
189
189
  runtimes have no marketplace: copy `plugin/skills/*` into their skills directory instead.
190
190
 
191
+ The hook registers on `Edit|Write`, which is every write that names a file. An agent that
192
+ writes its source through a shell heredoc names none, so a `Bash` event is judged off the
193
+ working tree instead. That half is yours to register, because it costs two
194
+ git spawns per shell call. Add a second PostToolUse entry to your own settings, same
195
+ command, matcher `Bash`:
196
+
197
+ ```json
198
+ {
199
+ "hooks": {
200
+ "PostToolUse": [
201
+ {
202
+ "matcher": "Bash",
203
+ "hooks": [
204
+ { "type": "command", "command": "crapkit claude-hook --protocol 1", "timeout": 20 }
205
+ ]
206
+ }
207
+ ]
208
+ }
209
+ }
210
+ ```
211
+
212
+ The cost is one `git rev-parse --show-toplevel` and one `git status --porcelain -z -uall`
213
+ per shell call in any git repo, whether or not crapkit measures it: about 30 ms together
214
+ on crapkit's own checkout, and more on a bigger tree. What comes back is the dirty or
215
+ untracked `*.py` files written in the last 12 seconds, 25 at most, each judged the way an
216
+ edit is. Python only, so a TypeScript or Go repo pays the two spawns and hears nothing.
217
+
191
218
  ## Languages
192
219
 
193
220
  14 languages, two coverage parsers. Coverage joins where a parser exists; everything else
@@ -307,7 +334,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
307
334
  repos:
308
335
  - repo: https://github.com/JeanFrancoisGagne/crapkit
309
336
  # crapkit's release step rewrites this line to the tag it just cut
310
- rev: v0.4.5
337
+ rev: v0.4.7
311
338
  hooks:
312
339
  - id: crapkit-gate
313
340
  ```
@@ -449,7 +476,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
449
476
  | `mutate [--files F ...] [--max-mutants N] [--drop-pool] [--json]` | Diff-scoped mutation testing: flips comparisons, boundary shifts, boolean connectives and boolean literals on changed lines, runs `mutation_command` per mutant, lists survivors. `--files` replaces diff scope with the whole file. `--max-mutants` (default 100) caps the run and the cap warning goes to stderr only, so `mutants` in `--json` is the capped count. Shell and PowerShell files are refused by name on stderr rather than mutated: `<` and `>` are redirections there, not comparisons. With `mutation_workers > 1` the worker worktrees are kept at `.crapkit/mutate-pool/` and re-prepared per run (30.6 s to build four on a 31,459-file repo, 0.46 s to re-prepare them); `--drop-pool` removes them and exits. |
450
477
  | `test-scoped FILE ...` | Runs each owning scope's `[crapkit.scoped_tests]` template on the files (quoted, longest-prefix scope wins). A template with no `{files}` runs as written, which is how a scope whose tests live outside its own paths runs its whole suite. Exit code only; a nonzero runner exits 1. |
451
478
  | `hook-precommit` | The cc-only gate on staged blobs. No coverage, no snapshot, no repo-wide cache. Exit 6 on a violation. |
452
- | `claude-hook [--protocol N]` | Reads one Claude Code PostToolUse payload from stdin and judges the file it edited: ccn against the scope ceiling, on functions the edit changed, minus functions a ratchet mark already covers. Advisory only. The edit has landed, nothing is blocked, and `hook-precommit` stays the enforcement point. Exit 2 with three lines on stderr is the only thing it ever says: no `crapkit.toml` above the edited file, an unscoped file, mid-rebase or mid-merge, a `--protocol` other than 1, source that parses to no functions, or any internal failure all exit 0 in silence. Takes no `--repo`, because the root is the first `crapkit.toml` above the edited file and the upward walk stops at a `.git` entry, so a worktree never borrows its parent's config. It opens no snapshot and writes nothing. |
479
+ | `claude-hook [--protocol N]` | Reads one Claude Code PostToolUse payload from stdin and judges the file it edited: ccn against the scope ceiling, on functions the edit changed, minus functions a ratchet mark already covers. Advisory only: the edit has landed, and `hook-precommit` stays the enforcement point. Exit 2 and an advisory on stderr is the only thing it ever says, one block per judged file (a head line, one line per breaching function, a closing line): no `crapkit.toml` above the edited file, an unscoped file, mid-rebase or mid-merge, a `--protocol` other than 1, source that parses to no functions, or any internal failure all exit 0 in silence. The root is the first `crapkit.toml` above the edited file; the walk stops at a `.git` entry, so a worktree never borrows its parent's config. A `Bash` event names no file, so it judges the working tree instead: the dirty or untracked `*.py` files touched in the last 12 seconds, 25 at most, each through the same ladder, and silence for a clean tree or a cwd outside any repo. That half fires only where you register a `Bash` matcher ([The Claude Code plugin](#the-claude-code-plugin)). It opens no snapshot and writes nothing. |
453
480
  | `watch [--interval SECONDS] [--cycles N]` | Rescores tracked files as they change (mtime polling, default 2s, subprocess-isolated so a half-saved syntax error never kills the watcher). `--cycles N` polls exactly N times and exits 0; without it the loop runs until ctrl-c. |
454
481
  | `mcp` | A dependency-free stdio MCP server (newline JSON-RPC 2.0) exposing nine read-only tools. Every tool shells to the CLI's own `--json` surface, so the MCP view cannot drift from what the CLI reports. Answering from a kept in-process store was benchmarked and rejected: a packet's `source` would go stale behind the edit it describes. See [docs/agent-json.md](docs/agent-json.md#mcp-server). |
455
482
 
@@ -577,7 +604,7 @@ crapkit: run 3 is an inventory run (no coverage was measured) and cannot serve a
577
604
  | 2 | Usage error from argparse: unknown flag, missing positional. Raised before crapkit's own error handling. |
578
605
  | 3 | Config error: `crapkit.toml` missing or unparseable, an unknown language or parser, a lane command the shell that runs it reads as a narrowed suite, a ratchet metric-stamp mismatch ([Upgrading from 0.4.4](#upgrading-from-044)), a `test-scoped` file under no scope or under a scope with no template. |
579
606
  | 4 | Git error: not a repository, a baseline commit rewritten out of the history. |
580
- | 5 | Tool error: lizard not importable, a lane produced no artifact, a lane timed out past its retries, an override alert command failed. A `timeout_seconds` kills the whole process tree, so no orphan suite keeps running behind the failure. |
607
+ | 5 | Tool error: lizard not importable, a lane that produced no artifact, one that measured a different tree, one that measured this tree and reported it in absolute paths (the join is root-relative, so those match nothing either; the refusal names the runner's own switch, `relative_files = true` under `[tool.coverage.run]` for a coveragepy lane, the reporter's `cwd`/`root` option for an istanbul one), a lane that timed out past its retries, an override alert command that failed. A `timeout_seconds` kills the whole process tree, so no orphan suite keeps running behind the failure. |
581
608
  | 6 | Gate violation. A function the diff touched is over its ceiling and past any ratchet mark it carries: an edit that leaves a marked function at or under its mark is the debt the repo signed for and is exempt. Also `rescore --gate`, which applies the same rule, and `hook-precommit`, which exempts on the mark's existence instead. |
582
609
  | 7 | Ratchet regression the diff never touched. A marked function scores worse than its recorded high-water mark; a touched one past its mark reports 6. |
583
610
  | 8 | New test failures against the baseline run. Failures the baseline already had do not count. |
@@ -610,6 +637,11 @@ pip install pytest-cov
610
637
 
611
638
  (`pip install "crapkit[py]"` pulls both at once when crapkit shares the suite's venv.)
612
639
 
640
+ If your suite drives its own CLI through `subprocess.run`, add `[tool.coverage.run]
641
+ patch = ["subprocess"]` to `pyproject.toml` and keep `coverage>=7.10.6`: pytest-cov 7.0.0
642
+ dropped subprocess measurement, so without that key every entry point scores 0% and nothing
643
+ warns. [docs/lanes.md](docs/lanes.md) has the whole rule.
644
+
613
645
  ### 1. Scaffold the config
614
646
 
615
647
  ```
@@ -622,9 +654,14 @@ added to .gitignore: .crapkit/, .coverage, __pycache__/
622
654
  `init` sniffs tracked source into one scope per top-level source directory, and detects a
623
655
  coverage lane from what the repo already has: a pytest marker file (`pyproject.toml`,
624
656
  `pytest.ini`, `setup.cfg`) writes a live `[[lane]]`, and so does a `test` script or
625
- `vitest`/`jest` in `package.json`. Whatever it detects, it also leaves commented templates
626
- for the runners it did not find. Every lane it writes reports into `.crapkit/cov/`, which
627
- is why the `.gitignore` list is so short: see
657
+ `vitest`/`jest` in `package.json`. A lockfile beside them names the environment: `uv.lock`,
658
+ `poetry.lock`, `pdm.lock` or `Pipfile.lock` makes the lane `uv run python -m pytest …` (and
659
+ the matching `run` for the rest), because a bare `python` binds to whichever venv the shell
660
+ has active rather than the one the repo pins — see
661
+ [The interpreter a lane binds to](docs/lanes.md#the-interpreter-a-lane-binds-to). Whatever
662
+ it detects, it also leaves commented templates for the runners it did not find, and those
663
+ carry the same launcher, so uncommenting one cannot hand the bare `python` back. Every lane
664
+ it writes reports into `.crapkit/cov/`, which is why the `.gitignore` list is so short: see
628
665
  [Where artifacts live](docs/lanes.md#where-artifacts-live).
629
666
 
630
667
  ```toml
@@ -784,7 +821,7 @@ exit 5:
784
821
 
785
822
  ```
786
823
  $ crapkit coverage
787
- crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
824
+ crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); full log: /repo/.crapkit/lane-js.log; last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
788
825
 
789
826
  MISSING DEPENDENCY Cannot find dependency '@vitest/coverage-v8'
790
827
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "crapkit"
7
- version = "0.4.5"
7
+ version = "0.4.7"
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" }
@@ -63,14 +63,19 @@ Source = "https://github.com/JeanFrancoisGagne/crapkit"
63
63
  # about 8 minutes serial against about 1m30 parallel on the same tree. Nothing
64
64
  # here needs pytest-timeout: no test takes a `--timeout` and none carries the
65
65
  # marker.
66
- dev = ["pytest>=8", "pytest-cov>=5", "pytest-xdist>=3"]
66
+ dev = ["pytest>=8", "pytest-cov>=5", "pytest-xdist>=3", "coverage>=7.10.6"]
67
+ # coverage>=7.10.6 in both extras is the floor `[tool.coverage.run] patch` needs,
68
+ # and it is not decoration: coverage 7.9 answers an unrecognized `[run] patch=`
69
+ # with a CoverageWarning that pytest-cov 6.2.0 and later file under
70
+ # `once::CoverageWarning`, so an older coverage takes the key, says so once in a
71
+ # warnings block nobody reads, and measures no subprocess at all.
67
72
  # The py lane's runtime half, for installs that share the suite's venv: the
68
73
  # lane init writes runs `pytest --cov`, and those flags come from pytest-cov.
69
74
  # An install elsewhere (pipx, uv tool) still needs pytest-cov beside the SUITE;
70
75
  # init probes the python that runs pytest, not its own, and names
71
76
  # `pip install "crapkit[py]"` when those are the same environment. Double
72
77
  # quotes: single ones do not survive cmd.exe.
73
- py = ["pytest-cov>=5"]
78
+ py = ["pytest-cov>=5", "coverage>=7.10.6"]
74
79
 
75
80
  [project.scripts]
76
81
  crapkit = "crapkit.cli:main"
@@ -85,3 +90,15 @@ where = ["src"]
85
90
  [tool.pytest.ini_options]
86
91
  testpaths = ["tests"]
87
92
  addopts = "-q --tb=short -p no:cacheprovider"
93
+
94
+ # Every CLI entry point crapkit ships is exercised through `subprocess.run` by
95
+ # tests/e2e and by nothing else, so without a subprocess measurement `cmd_init`
96
+ # and its siblings read 0% and the gate fires on any `cmd_*` a diff touches.
97
+ # pytest-cov 7.0.0 dropped the mechanism that used to do this (its own
98
+ # `pytest-cov.pth` reading COV_CORE_*) and pointed at coverage's own patch
99
+ # system, added in coverage 7.10. Measured on tests/e2e/test_init_doctor_e2e.py:
100
+ # src/crapkit/cli/admin.py scores 0/498 statements under pytest-cov 7.1.0 with
101
+ # this key absent, and 317/498 with it present, on 7.1.0 and 6.3.0 alike.
102
+ # `parallel = true` follows automatically; .coverage* is already gitignored.
103
+ [tool.coverage.run]
104
+ patch = ["subprocess"]
@@ -1,2 +1,2 @@
1
1
  """crapkit: deterministic CRAP-score framework."""
2
- __version__ = "0.4.5"
2
+ __version__ = "0.4.7"
@@ -107,6 +107,7 @@ _OWNER = {
107
107
  "_extend_gitignore": "admin",
108
108
  "_fewer_tests_line": "verifying",
109
109
  "_file_sizer": "_shared",
110
+ "_first_word": "admin",
110
111
  "_flag_counts": "scoring",
111
112
  "_flake_retry": "verifying",
112
113
  "_gate_candidates": "scoring",
@@ -190,6 +191,7 @@ _OWNER = {
190
191
  "_plugin_json": "admin",
191
192
  "_plugins_dir": "admin",
192
193
  "_policy_findings": "ratchet_cmds",
194
+ "_present_lockfiles": "admin",
193
195
  "_present_markers": "admin",
194
196
  "_present_on_disk": "scoring",
195
197
  "_prior_crap": "verifying",
@@ -218,6 +220,7 @@ _OWNER = {
218
220
  "_pruned": "ratchet_cmds",
219
221
  "_pushdown_floor": "queue",
220
222
  "_pytest_cov_probe": "admin",
223
+ "_python_name": "admin",
221
224
  "_range_lines": "analyses",
222
225
  "_rankable": "queue",
223
226
  "_ratchet_entries": "_shared",
@@ -23,7 +23,13 @@ from ..universe import assign_files, scan_files
23
23
  from ._shared import _file_sizer, _load_repo_config, _print_json
24
24
 
25
25
 
26
- def _interpreter() -> str:
26
+ def _present_lockfiles(root: Path) -> frozenset[str]:
27
+ from ..scaffold import LOCKFILE_RUNNERS
28
+
29
+ return frozenset(name for name, _ in LOCKFILE_RUNNERS if (root / name).is_file())
30
+
31
+
32
+ def _python_name() -> str:
27
33
  """The interpreter name a committed config can call. sys.executable is this
28
34
  machine's absolute path and would not survive the repo reaching anyone else.
29
35
 
@@ -41,6 +47,25 @@ def _interpreter() -> str:
41
47
  return "python3"
42
48
 
43
49
 
50
+ def _interpreter(root: Path) -> str:
51
+ """The python invocation a committed config can call.
52
+
53
+ A lockfile at the root wins: `uv run python` and its siblings resolve to the
54
+ environment the repo pins, while a bare `python` resolves to whichever venv
55
+ the shell happens to have active — which in two worktrees of one branch is
56
+ how a lane measures the OTHER checkout and scores this one untested.
57
+
58
+ The manager only PREFIXES the name; which name it prefixes is still
59
+ `_python_name`'s answer, so a Windows PATH carrying no `python` gets
60
+ `uv run py` rather than a word the shell cannot start.
61
+ """
62
+ from ..scaffold import lockfile_runner
63
+
64
+ runner = lockfile_runner(_present_lockfiles(root))
65
+ name = _python_name()
66
+ return f"{runner} {name}" if runner else name
67
+
68
+
44
69
  def _present_markers(root: Path) -> frozenset[str]:
45
70
  from ..scaffold import PYTEST_MARKERS
46
71
 
@@ -137,6 +162,14 @@ def _start_probe(word: str) -> int | None:
137
162
  return None
138
163
 
139
164
 
165
+ def _first_word(command: str) -> str:
166
+ """The word the shell will try to start, read the way that shell reads the
167
+ line: a quoted interpreter path stays one word, where a whitespace split
168
+ would break it at its space. "" when the line holds no word at all."""
169
+ words = shell_words(command)
170
+ return words[0] if words else ""
171
+
172
+
140
173
  def _dead_first_word(command: str) -> tuple[str, int] | None:
141
174
  """The command's first word and the shell's verdict, when the shell cannot
142
175
  start it. None when it starts, and None when the word does not resolve on
@@ -144,11 +177,11 @@ def _dead_first_word(command: str) -> tuple[str, int] | None:
144
177
  proves nothing."""
145
178
  import shutil
146
179
 
147
- words = shell_words(command)
148
- if not words or shutil.which(words[0]) is None:
180
+ word = _first_word(command)
181
+ if not word or shutil.which(word) is None:
149
182
  return None
150
- code = _start_probe(words[0])
151
- return (words[0], code) if _could_not_run_it(code) else None
183
+ code = _start_probe(word)
184
+ return (word, code) if _could_not_run_it(code) else None
152
185
 
153
186
 
154
187
  def _probe_answered_no(returncode: int) -> bool:
@@ -189,7 +222,14 @@ def _probe_interpreter(command: str) -> str | None:
189
222
  """The python this lane runs pytest with, or None when no python runs it.
190
223
  `coverage run -m pytest` names no interpreter at all: the probe asked
191
224
  `coverage` to import pytest_cov, read its argument error as a missing
192
- package, and printed the pip note on a machine where pytest_cov imports."""
225
+ package, and printed the pip note on a machine where pytest_cov imports.
226
+
227
+ An environment manager heads its segment for the same reason: `uv run` and
228
+ its siblings CREATE or sync the project environment before running anything,
229
+ so init has no business provisioning one to ask a question about it, and the
230
+ head word is not a python — `uv -c "import pytest_cov"` is not the probe it
231
+ looks like, and would have warned about the wrong gap on every uv repo.
232
+ """
193
233
  segment = _pytest_segment(command)
194
234
  if not segment or not _is_python(segment[0]):
195
235
  return None
@@ -202,7 +242,8 @@ def _pytest_cov_probe(command: str) -> bool:
202
242
  the lane will get (a .bat shim included), not the one CreateProcess finds.
203
243
  True too when the probe cannot run — only a clean "no" earns the warning,
204
244
  and a missing interpreter is doctor's finding, not this one's. True as well
205
- when no python runs the suite: nothing here can be asked."""
245
+ when no python runs the suite, `uv run python -m pytest` included: nothing
246
+ here can be asked."""
206
247
  import shutil
207
248
 
208
249
  from ..procs import run_bounded
@@ -256,11 +297,41 @@ def _missing_pytest_cov_note(name: str) -> str:
256
297
  "then `crapkit coverage`")
257
298
 
258
299
 
300
+ def _absent_manager(command: str) -> str | None:
301
+ """The environment manager heading this lane, when this machine's PATH
302
+ carries no such word. None when it resolves, and None when nothing manages
303
+ the lane at all.
304
+
305
+ A lockfile is a property of the REPO, so `init` writes `uv run python` off
306
+ its presence alone — right for the repo, and unrunnable on a checkout whose
307
+ owner installed the dependencies with pip. Nothing else catches it: the
308
+ start check skips a word that does not resolve, and the pytest-cov probe
309
+ refuses to provision an environment to ask a question about it.
310
+ """
311
+ import shutil
312
+
313
+ from ..scaffold import LOCKFILE_RUNNERS
314
+
315
+ managers = {runner.split()[0] for _, runner in LOCKFILE_RUNNERS}
316
+ head = _first_word(command)
317
+ return head if head in managers and shutil.which(head) is None else None
318
+
319
+
320
+ def _missing_manager_note(name: str, manager: str) -> str:
321
+ return (f"note: lane {name!r} runs through `{manager}`, which this machine's PATH does "
322
+ f"not carry — install {manager}, or point the lane's command in crapkit.toml at "
323
+ f"an interpreter that resolves here, then `crapkit coverage`")
324
+
325
+
259
326
  def _lane_first_run_note(lane) -> str | None:
260
327
  """What init owes this lane before the first `crapkit coverage`, or None
261
- when the lane will run. Two different gaps, and they are not the same
262
- sentence: an interpreter that never started answered nothing about
263
- pytest_cov, and `pip install pytest-cov` fixes none of it."""
328
+ when the lane will run. Three different gaps, and they are not the same
329
+ sentence: a manager that is not installed never gets as far as a python, and
330
+ an interpreter that never started answered nothing about pytest_cov, so
331
+ `pip install pytest-cov` fixes neither."""
332
+ manager = _absent_manager(lane.command)
333
+ if manager:
334
+ return _missing_manager_note(lane.name, manager)
264
335
  dead = _dead_first_word(lane.command)
265
336
  if dead:
266
337
  return _dead_interpreter_note(lane.name, *dead)
@@ -279,9 +350,16 @@ def _warn_missing_pytest_cov(lanes: tuple) -> None:
279
350
  """The first-run trap, caught where it starts. The py lane shells out to
280
351
  `pytest --cov`, and the --cov flags come from pytest-cov — a package of the
281
352
  REPO's interpreter, so a crapkit dependency could only ever cover installs
282
- sharing the suite's venv. Probe the python the lane will actually run and
283
- say the fix now, instead of `coverage` exiting 5 with a lane log the first
284
- run has to decode."""
353
+ sharing the suite's venv. Say the fix now, instead of `coverage` exiting 5
354
+ with a lane log the first run has to decode.
355
+
356
+ Only a lane whose pytest segment starts with a python is probed at all. A
357
+ lane an environment manager heads is not: `uv run` and its siblings create
358
+ or sync the project environment before running anything, so probing one
359
+ would provision an environment to ask a question about it. Such a lane
360
+ still earns the two notes ahead of the probe, a manager PATH does not carry
361
+ and a first word the shell cannot start.
362
+ """
285
363
  for lane in _probed_lanes(lanes):
286
364
  note = _lane_first_run_note(lane)
287
365
  if note:
@@ -315,9 +393,12 @@ def cmd_init(args: argparse.Namespace) -> int:
315
393
  raise ConfigError(_no_scopes_reason(root))
316
394
  # A config whose lanes are all commented out scores every function no-lane,
317
395
  # so a fresh repo cannot rank anything until somebody hand-writes a lane.
318
- lanes = detect_lanes(_present_markers(root), _package_json(root),
319
- interpreter=_interpreter())
320
- text = starter_toml(scopes, lanes)
396
+ # The interpreter goes to both: a repo with no pytest marker file gets no
397
+ # lane to read it back off, and its commented template is what the reader
398
+ # uncomments.
399
+ interpreter = _interpreter(root)
400
+ lanes = detect_lanes(_present_markers(root), _package_json(root), interpreter=interpreter)
401
+ text = starter_toml(scopes, lanes, interpreter=interpreter)
321
402
  load_config_text(text) # self-check: never write a config crapkit cannot read back
322
403
  toml_path.write_text(text, encoding="utf-8", newline="\n")
323
404
  _print_init_summary(scopes, lanes)