crapkit 0.4.1__tar.gz → 0.4.2__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 (69) hide show
  1. {crapkit-0.4.1/src/crapkit.egg-info → crapkit-0.4.2}/PKG-INFO +33 -13
  2. {crapkit-0.4.1 → crapkit-0.4.2}/README.md +31 -12
  3. {crapkit-0.4.1 → crapkit-0.4.2}/pyproject.toml +6 -2
  4. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/__init__.py +1 -1
  5. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/analyze.py +6 -1
  6. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/__init__.py +3 -0
  7. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/admin.py +27 -7
  8. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/queue.py +14 -3
  9. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/scoring.py +24 -3
  10. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/verifying.py +18 -2
  11. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/config.py +17 -0
  12. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/lizardcognitive.py +37 -7
  13. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/mcp_server.py +11 -5
  14. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/packet.py +36 -1
  15. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/scaffold.py +29 -4
  16. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/store.py +37 -13
  17. {crapkit-0.4.1 → crapkit-0.4.2/src/crapkit.egg-info}/PKG-INFO +33 -13
  18. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit.egg-info/requires.txt +1 -0
  19. {crapkit-0.4.1 → crapkit-0.4.2}/LICENSE +0 -0
  20. {crapkit-0.4.1 → crapkit-0.4.2}/setup.cfg +0 -0
  21. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/__main__.py +0 -0
  22. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/_pygdefer.py +0 -0
  23. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cache.py +0 -0
  24. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/churn.py +0 -0
  25. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/churn_cache.py +0 -0
  26. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/churn_log.py +0 -0
  27. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/_shared.py +0 -0
  28. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/analyses.py +0 -0
  29. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/claude_hook.py +0 -0
  30. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/parser.py +0 -0
  31. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/ratchet_cmds.py +0 -0
  32. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/reports.py +0 -0
  33. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/coupling.py +0 -0
  34. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/coverage_istanbul.py +0 -0
  35. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/coverage_py.py +0 -0
  36. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/covstream.py +0 -0
  37. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/diffparse.py +0 -0
  38. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/digest.py +0 -0
  39. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/discover.py +0 -0
  40. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/doctor.py +0 -0
  41. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/dup.py +0 -0
  42. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/errors.py +0 -0
  43. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/gitio.py +0 -0
  44. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/hook.py +0 -0
  45. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/junitparse.py +0 -0
  46. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/lanes.py +0 -0
  47. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/lizardpowershell.py +0 -0
  48. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/lizardrust.py +0 -0
  49. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/lizardshell.py +0 -0
  50. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/merge.py +0 -0
  51. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/mutate.py +0 -0
  52. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/mutate_pool.py +0 -0
  53. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/override.py +0 -0
  54. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/ratchet.py +0 -0
  55. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/ratchet_report.py +0 -0
  56. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/report.py +0 -0
  57. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/sarif.py +0 -0
  58. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/sarifio.py +0 -0
  59. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/score.py +0 -0
  60. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/snapshot.py +0 -0
  61. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/uncovered.py +0 -0
  62. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/universe.py +0 -0
  63. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/verify.py +0 -0
  64. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/watch.py +0 -0
  65. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/worklist.py +0 -0
  66. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit.egg-info/SOURCES.txt +0 -0
  67. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit.egg-info/dependency_links.txt +0 -0
  68. {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit.egg-info/entry_points.txt +0 -0
  69. {crapkit-0.4.1 → crapkit-0.4.2}/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.1
3
+ Version: 0.4.2
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
@@ -28,6 +28,7 @@ Requires-Dist: lizard>=1.24.0
28
28
  Provides-Extra: dev
29
29
  Requires-Dist: pytest>=8; extra == "dev"
30
30
  Requires-Dist: pytest-cov>=5; extra == "dev"
31
+ Requires-Dist: pytest-xdist>=3; extra == "dev"
31
32
  Dynamic: license-file
32
33
 
33
34
  # crapkit
@@ -115,7 +116,7 @@ changing crapkit.
115
116
 
116
117
  ```
117
118
  $ crapkit --version
118
- crapkit 0.4.1
119
+ crapkit 0.4.2
119
120
  ```
120
121
 
121
122
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -165,10 +166,16 @@ lane. Nothing about it is provisional: the ceiling still binds and the gate stil
165
166
  a function over it. Add a coverage lane the day a parser exists and the same scope starts
166
167
  joining coverage.
167
168
 
169
+ `crapkit init` writes that key itself, on every scope whose languages all lack a parser,
170
+ and leaves it off any scope a lane could still measure. So the 60-second start above runs
171
+ unchanged on a Go, Rust or shell repo: `crapkit coverage` scores it with no lane at all,
172
+ and that run is the baseline `worklist`, `next-item`, `ratchet seed` and `verify` read.
173
+
168
174
  Three readers are crapkit's own. lizard ships none for shell or PowerShell, so crapkit
169
175
  counts their functions itself. Its Rust reader scores a 7-arm `match` as ccn 2 (filed as
170
176
  lizard #494), so crapkit counts each non-wildcard arm like a C `case` and retires the
171
- override the day upstream fixes it.
177
+ override the day upstream fixes it. The cognitive column charges that same block once,
178
+ the way Sonar charges a `switch`.
172
179
 
173
180
  ## The gate
174
181
 
@@ -178,8 +185,8 @@ different powers:
178
185
  | Surface | Fires | Power |
179
186
  |---|---|---|
180
187
  | `crapkit claude-hook` | after an agent's edit lands | **advisory.** Names the breach on stderr. Blocks nothing, because PostToolUse runs after the write |
181
- | `crapkit rescore FILE --gate` | when you ask | **preview.** The commit gate's verdict on demand, sub-second, before you stage |
182
- | `crapkit hook-precommit` | `git commit` | **blocks.** Exit 6. Staged blobs only, so it costs the size of the commit and needs no coverage |
188
+ | `crapkit rescore FILE --gate` | when you ask, after the first coverage run | **preview.** The commit gate's verdict on demand, sub-second, before you stage. With no run behind it, exit 1 and `no snapshot` |
189
+ | `crapkit hook-precommit` | `git commit` | **blocks.** The hook exits 6; git reports 1. Staged blobs only, so it costs the size of the commit and needs no coverage |
183
190
  | `crapkit verify` | before you push, and in CI | **the verdict.** Gate, ratchet, new test failures, diff coverage, against the trusted baseline |
184
191
 
185
192
  Both hooks exempt a function the committed ratchet already carries a mark for, so touching
@@ -239,7 +246,8 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
239
246
  ```yaml
240
247
  repos:
241
248
  - repo: https://github.com/JeanFrancoisGagne/crapkit
242
- rev: v0.4.0
249
+ # crapkit's release step rewrites this line to the tag it just cut
250
+ rev: v0.4.2
243
251
  hooks:
244
252
  - id: crapkit-gate
245
253
  ```
@@ -275,7 +283,9 @@ crapkit gate: 1 staged function(s) exceed the complexity ceiling of 6:
275
283
  decompose before committing (coverage cannot save a function above the target).
276
284
  ```
277
285
 
278
- Run directly, `crapkit hook-precommit` exits 6 on a violation and 0 otherwise.
286
+ That commit exited **1**, not 6. Git collapses any failed hook to 1, so 6 is a code you
287
+ only ever see by running the hook yourself: `crapkit hook-precommit` exits 6 on a
288
+ violation and 0 otherwise. The stderr block above is the same either way.
279
289
 
280
290
  `CRAPKIT_OVERRIDE_REASON` is not a bypass. Setting it routes the commit through the full
281
291
  three-record audit: an alert line through `alert_command`, a ratchet entry staged into the
@@ -316,7 +326,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
316
326
  | `next-item [--top N] [--exclude FRAG] [--scope NAME] [--claim]` | The actionable queue as JSON, with churn, budget estimates and uncovered lines. Same run and same admission floor as `worklist`, a different view of it: `no-lane` rows are skipped and counted in `skipped_no_lane`, and what is left is ranked by `crap` descending rather than by risk, so the item it hands out is often not the worklist's first row. `--exclude FRAG` (repeatable) skips items whose path or function name contains FRAG; `--scope NAME` (repeatable) is exact, not a substring. `--claim` holds what it hands out so a second session skips it. `stale` is true when the ranked run's commit is not HEAD, the same field `worklist` carries. Every item carries a `handle`: the bare identifier, or `(anonymous)#N` for a function with no name, which is the name form that survives the edit the item asks for. |
317
327
  | `claims [list \| release PATH NAME \| release --all] [--json]` | The open claims, and the way to hand one back without waiting for a verify. `release` takes the bare identifier, the whole long name, or the `handle` the claim was taken under, which is the only one that picks out a single `(anonymous)` claim. |
318
328
  | `brief FILE NAME [--batch N] [--json]` | The start-editing packet for one function: its own `source` text, every function in the file, the scored row and the scope ceiling, the ratchet mark and what the gate will bind on, uncovered lines, duplication twins, file churn, coupling partners, the config's notes, and the literal commands for the rest of the loop. Plus `handle`, `remedy` and the same `est_splits` / `est_uncovered_paths` the queue prints, and a `commands.refresh` that writes a run (`refresh_writes_run`) rather than re-reading the stale one. `NAME` takes the bare identifier, the long name `next-item` printed, the function's start line, or `(anonymous)#N` for a function printed `(anonymous)`, counting the file's anonymous functions from the top. `--batch N` drops the positionals and emits `packets[]` instead: the top N of the queue, built from one read of the store. |
319
- | `explain FILE NAME [--history] [--tests] [--json]` | A function's score across runs plus its mark. `--history` adds the commits that touched it (`git log -L`), each carrying its message `body`, `--tests` the tests that covered it, which needs coverage.py contexts turned on ([recipe](docs/lanes.md#test-attribution-for-explain---tests)). `--json` emits the same content as one `schema` 1 object. |
329
+ | `explain FILE NAME [--history] [--tests] [--json]` | A function's score across runs plus its mark. `NAME` resolves exact first: a function whose bare identifier or long name is exactly `NAME` wins, and only when nothing matches exactly does it fall back to a prefix match, so `route` explains `route` rather than every `route_*` beside it. `--history` adds the commits that touched it (`git log -L`), each carrying its message `body`, `--tests` the tests that covered it, which needs coverage.py contexts turned on ([recipe](docs/lanes.md#test-attribution-for-explain---tests)). `--json` emits the same content as one `schema` 1 object. |
320
330
  | `rescore FILE ... [--gate] [--json]` | Fresh complexity for named files over the latest run's stale coverage, joined by name. Advisory: it writes no run. `--gate` applies the pre-commit hook's policy to the same selection the hook uses (functions the tree changed since HEAD), minus functions a ratchet mark already covers, and exits 6. |
321
331
  | `ratchet seed \| prune \| merge \| move \| report [--enforce] [--json]` | The mark lifecycle: seed new debt, prune gone code (a mark whose file git renamed follows it), merge as a git driver, move re-paths marks, report reads burn-down from the file's own git history. See [docs/ratchet.md](docs/ratchet.md). |
322
332
  | `runs [list \| prune [--keep N]] [--json]` | Run history, and retention. `list` marks the run `verify` compares against today `baseline`, and prints `verdict=-` for a run that produces no verdict rather than one that failed. See [The trusted baseline](#the-trusted-baseline). `--keep` (default 5) is a floor on the newest trusted runs, not a cap: the digest pair, every passing verify baseline, every run an override names, and the newest non-hook run are kept too. `prune` VACUUMs afterwards. |
@@ -536,6 +546,11 @@ makes it step 4 of the burn-down loop. Every key is in
536
546
 
537
547
  ```
538
548
  $ crapkit doctor
549
+ ok config keys all recognized
550
+ ok scope 'calc': 1 files
551
+ ok every tracked source file belongs to a scope
552
+ ok 1 lane(s) declared
553
+ ok lizard 1.24.0
539
554
  doctor: no problems found
540
555
  ```
541
556
 
@@ -567,11 +582,12 @@ worklist has none.
567
582
 
568
583
  ```
569
584
  $ crapkit next-item
570
- {"commit": "fae4db93108b4841a00959f9117430679e7250ca", "empty": false, "item": {"authors": 1, "ccn": 14, "ccn_std": 14, "cognitive": 13, "commits": 1, "cov": 0.5, "crap": 38.5, "end": 28, "est_splits": 3, "est_uncovered_paths": 7, "flag": "measured", "function": "classify( score , attempts , late , bonus )", "nesting": 8, "nloc": 22, "path": "calc/grade.py", "remedy": "decompose", "scope": "calc", "start": 7, "target": 6, "uncovered_lines": [9, 11, 15, 17, 19, 24, 25, 26, 27, 28]}, "run_id": 1, "schema": 1, "skipped_no_lane": 0}
585
+ {"commit": "fae4db93108b4841a00959f9117430679e7250ca", "empty": false, "item": {"authors": 1, "ccn": 14, "ccn_std": 14, "cognitive": 13, "commits": 1, "cov": 0.5, "crap": 38.5, "end": 28, "est_splits": 3, "est_uncovered_paths": 7, "flag": "measured", "function": "classify( score , attempts , late , bonus )", "handle": "classify", "nesting": 8, "nloc": 22, "path": "calc/grade.py", "remedy": "decompose", "scope": "calc", "start": 7, "target": 6, "uncovered_lines": [9, 11, 15, 17, 19, 24, 25, 26, 27, 28]}, "run_id": 1, "schema": 1, "skipped_no_lane": 0, "stale": false}
571
586
  ```
572
587
 
573
588
  `remedy: "decompose"`, `est_splits: 3` (this needs roughly three pieces to fit under 6),
574
- and `uncovered_lines` naming the ten lines no test walks. Every field is in
589
+ and `uncovered_lines` naming the ten lines no test walks. `handle` is the name form to
590
+ pass back, and `stale: false` says the run still describes HEAD. Every field is in
575
591
  [docs/agent-json.md](docs/agent-json.md).
576
592
 
577
593
  ### 5. Seed the ratchet
@@ -648,17 +664,21 @@ crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/cov
648
664
  crapkit: every lane failed: ...
649
665
  ```
650
666
 
651
- Install the provider, pinned to your vitest major or npm refuses the peer dependency:
667
+ That failure **writes no run**. Every lane failed, so `coverage` exits before it opens a
668
+ store: there is no `.crapkit/crap.sqlite` yet and the run ids below still start at 1.
669
+
670
+ Install the provider, and pin the major yourself. Unpinned, npm resolves the newest
671
+ provider against your older vitest and refuses the tree:
652
672
 
653
673
  ```
654
- npm i -D @vitest/coverage-v8
674
+ npm i -D "@vitest/coverage-v8@<your vitest major>"
655
675
  ```
656
676
 
657
677
  | Question | Answer |
658
678
  |---|---|
659
679
  | Which provider? | Either works. `@vitest/coverage-v8` is vitest's default and needs no config. `@vitest/coverage-istanbul` also works and needs `coverage.provider = "istanbul"` in your vitest config. |
660
680
  | Which crapkit parser? | Both feed `parser = "istanbul"`. The provider name and the parser name are unrelated: v8 output is remapped to the istanbul JSON schema before it is written. |
661
- | Which version? | It must match your vitest major. npm refuses the install otherwise (`peer vitest@"4.x" from @vitest/coverage-v8@4.x`). On vitest 2, `npm i -D "@vitest/coverage-v8@2"`. |
681
+ | Which version? | The provider's major has to match vitest's. On vitest 2 that is `npm i -D "@vitest/coverage-v8@2"`, on vitest 3 `npm i -D "@vitest/coverage-v8@3"`. Drop the pin and npm answers `ERESOLVE unable to resolve dependency tree`, naming the peer it could not satisfy. |
662
682
 
663
683
  The artifact crapkit wants is `coverage-final.json`, written by vitest's `json` coverage
664
684
  reporter, which is on by default. If your vitest config sets `coverage.reporter`
@@ -83,7 +83,7 @@ changing crapkit.
83
83
 
84
84
  ```
85
85
  $ crapkit --version
86
- crapkit 0.4.1
86
+ crapkit 0.4.2
87
87
  ```
88
88
 
89
89
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -133,10 +133,16 @@ lane. Nothing about it is provisional: the ceiling still binds and the gate stil
133
133
  a function over it. Add a coverage lane the day a parser exists and the same scope starts
134
134
  joining coverage.
135
135
 
136
+ `crapkit init` writes that key itself, on every scope whose languages all lack a parser,
137
+ and leaves it off any scope a lane could still measure. So the 60-second start above runs
138
+ unchanged on a Go, Rust or shell repo: `crapkit coverage` scores it with no lane at all,
139
+ and that run is the baseline `worklist`, `next-item`, `ratchet seed` and `verify` read.
140
+
136
141
  Three readers are crapkit's own. lizard ships none for shell or PowerShell, so crapkit
137
142
  counts their functions itself. Its Rust reader scores a 7-arm `match` as ccn 2 (filed as
138
143
  lizard #494), so crapkit counts each non-wildcard arm like a C `case` and retires the
139
- override the day upstream fixes it.
144
+ override the day upstream fixes it. The cognitive column charges that same block once,
145
+ the way Sonar charges a `switch`.
140
146
 
141
147
  ## The gate
142
148
 
@@ -146,8 +152,8 @@ different powers:
146
152
  | Surface | Fires | Power |
147
153
  |---|---|---|
148
154
  | `crapkit claude-hook` | after an agent's edit lands | **advisory.** Names the breach on stderr. Blocks nothing, because PostToolUse runs after the write |
149
- | `crapkit rescore FILE --gate` | when you ask | **preview.** The commit gate's verdict on demand, sub-second, before you stage |
150
- | `crapkit hook-precommit` | `git commit` | **blocks.** Exit 6. Staged blobs only, so it costs the size of the commit and needs no coverage |
155
+ | `crapkit rescore FILE --gate` | when you ask, after the first coverage run | **preview.** The commit gate's verdict on demand, sub-second, before you stage. With no run behind it, exit 1 and `no snapshot` |
156
+ | `crapkit hook-precommit` | `git commit` | **blocks.** The hook exits 6; git reports 1. Staged blobs only, so it costs the size of the commit and needs no coverage |
151
157
  | `crapkit verify` | before you push, and in CI | **the verdict.** Gate, ratchet, new test failures, diff coverage, against the trusted baseline |
152
158
 
153
159
  Both hooks exempt a function the committed ratchet already carries a mark for, so touching
@@ -207,7 +213,8 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
207
213
  ```yaml
208
214
  repos:
209
215
  - repo: https://github.com/JeanFrancoisGagne/crapkit
210
- rev: v0.4.0
216
+ # crapkit's release step rewrites this line to the tag it just cut
217
+ rev: v0.4.2
211
218
  hooks:
212
219
  - id: crapkit-gate
213
220
  ```
@@ -243,7 +250,9 @@ crapkit gate: 1 staged function(s) exceed the complexity ceiling of 6:
243
250
  decompose before committing (coverage cannot save a function above the target).
244
251
  ```
245
252
 
246
- Run directly, `crapkit hook-precommit` exits 6 on a violation and 0 otherwise.
253
+ That commit exited **1**, not 6. Git collapses any failed hook to 1, so 6 is a code you
254
+ only ever see by running the hook yourself: `crapkit hook-precommit` exits 6 on a
255
+ violation and 0 otherwise. The stderr block above is the same either way.
247
256
 
248
257
  `CRAPKIT_OVERRIDE_REASON` is not a bypass. Setting it routes the commit through the full
249
258
  three-record audit: an alert line through `alert_command`, a ratchet entry staged into the
@@ -284,7 +293,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
284
293
  | `next-item [--top N] [--exclude FRAG] [--scope NAME] [--claim]` | The actionable queue as JSON, with churn, budget estimates and uncovered lines. Same run and same admission floor as `worklist`, a different view of it: `no-lane` rows are skipped and counted in `skipped_no_lane`, and what is left is ranked by `crap` descending rather than by risk, so the item it hands out is often not the worklist's first row. `--exclude FRAG` (repeatable) skips items whose path or function name contains FRAG; `--scope NAME` (repeatable) is exact, not a substring. `--claim` holds what it hands out so a second session skips it. `stale` is true when the ranked run's commit is not HEAD, the same field `worklist` carries. Every item carries a `handle`: the bare identifier, or `(anonymous)#N` for a function with no name, which is the name form that survives the edit the item asks for. |
285
294
  | `claims [list \| release PATH NAME \| release --all] [--json]` | The open claims, and the way to hand one back without waiting for a verify. `release` takes the bare identifier, the whole long name, or the `handle` the claim was taken under, which is the only one that picks out a single `(anonymous)` claim. |
286
295
  | `brief FILE NAME [--batch N] [--json]` | The start-editing packet for one function: its own `source` text, every function in the file, the scored row and the scope ceiling, the ratchet mark and what the gate will bind on, uncovered lines, duplication twins, file churn, coupling partners, the config's notes, and the literal commands for the rest of the loop. Plus `handle`, `remedy` and the same `est_splits` / `est_uncovered_paths` the queue prints, and a `commands.refresh` that writes a run (`refresh_writes_run`) rather than re-reading the stale one. `NAME` takes the bare identifier, the long name `next-item` printed, the function's start line, or `(anonymous)#N` for a function printed `(anonymous)`, counting the file's anonymous functions from the top. `--batch N` drops the positionals and emits `packets[]` instead: the top N of the queue, built from one read of the store. |
287
- | `explain FILE NAME [--history] [--tests] [--json]` | A function's score across runs plus its mark. `--history` adds the commits that touched it (`git log -L`), each carrying its message `body`, `--tests` the tests that covered it, which needs coverage.py contexts turned on ([recipe](docs/lanes.md#test-attribution-for-explain---tests)). `--json` emits the same content as one `schema` 1 object. |
296
+ | `explain FILE NAME [--history] [--tests] [--json]` | A function's score across runs plus its mark. `NAME` resolves exact first: a function whose bare identifier or long name is exactly `NAME` wins, and only when nothing matches exactly does it fall back to a prefix match, so `route` explains `route` rather than every `route_*` beside it. `--history` adds the commits that touched it (`git log -L`), each carrying its message `body`, `--tests` the tests that covered it, which needs coverage.py contexts turned on ([recipe](docs/lanes.md#test-attribution-for-explain---tests)). `--json` emits the same content as one `schema` 1 object. |
288
297
  | `rescore FILE ... [--gate] [--json]` | Fresh complexity for named files over the latest run's stale coverage, joined by name. Advisory: it writes no run. `--gate` applies the pre-commit hook's policy to the same selection the hook uses (functions the tree changed since HEAD), minus functions a ratchet mark already covers, and exits 6. |
289
298
  | `ratchet seed \| prune \| merge \| move \| report [--enforce] [--json]` | The mark lifecycle: seed new debt, prune gone code (a mark whose file git renamed follows it), merge as a git driver, move re-paths marks, report reads burn-down from the file's own git history. See [docs/ratchet.md](docs/ratchet.md). |
290
299
  | `runs [list \| prune [--keep N]] [--json]` | Run history, and retention. `list` marks the run `verify` compares against today `baseline`, and prints `verdict=-` for a run that produces no verdict rather than one that failed. See [The trusted baseline](#the-trusted-baseline). `--keep` (default 5) is a floor on the newest trusted runs, not a cap: the digest pair, every passing verify baseline, every run an override names, and the newest non-hook run are kept too. `prune` VACUUMs afterwards. |
@@ -504,6 +513,11 @@ makes it step 4 of the burn-down loop. Every key is in
504
513
 
505
514
  ```
506
515
  $ crapkit doctor
516
+ ok config keys all recognized
517
+ ok scope 'calc': 1 files
518
+ ok every tracked source file belongs to a scope
519
+ ok 1 lane(s) declared
520
+ ok lizard 1.24.0
507
521
  doctor: no problems found
508
522
  ```
509
523
 
@@ -535,11 +549,12 @@ worklist has none.
535
549
 
536
550
  ```
537
551
  $ crapkit next-item
538
- {"commit": "fae4db93108b4841a00959f9117430679e7250ca", "empty": false, "item": {"authors": 1, "ccn": 14, "ccn_std": 14, "cognitive": 13, "commits": 1, "cov": 0.5, "crap": 38.5, "end": 28, "est_splits": 3, "est_uncovered_paths": 7, "flag": "measured", "function": "classify( score , attempts , late , bonus )", "nesting": 8, "nloc": 22, "path": "calc/grade.py", "remedy": "decompose", "scope": "calc", "start": 7, "target": 6, "uncovered_lines": [9, 11, 15, 17, 19, 24, 25, 26, 27, 28]}, "run_id": 1, "schema": 1, "skipped_no_lane": 0}
552
+ {"commit": "fae4db93108b4841a00959f9117430679e7250ca", "empty": false, "item": {"authors": 1, "ccn": 14, "ccn_std": 14, "cognitive": 13, "commits": 1, "cov": 0.5, "crap": 38.5, "end": 28, "est_splits": 3, "est_uncovered_paths": 7, "flag": "measured", "function": "classify( score , attempts , late , bonus )", "handle": "classify", "nesting": 8, "nloc": 22, "path": "calc/grade.py", "remedy": "decompose", "scope": "calc", "start": 7, "target": 6, "uncovered_lines": [9, 11, 15, 17, 19, 24, 25, 26, 27, 28]}, "run_id": 1, "schema": 1, "skipped_no_lane": 0, "stale": false}
539
553
  ```
540
554
 
541
555
  `remedy: "decompose"`, `est_splits: 3` (this needs roughly three pieces to fit under 6),
542
- and `uncovered_lines` naming the ten lines no test walks. Every field is in
556
+ and `uncovered_lines` naming the ten lines no test walks. `handle` is the name form to
557
+ pass back, and `stale: false` says the run still describes HEAD. Every field is in
543
558
  [docs/agent-json.md](docs/agent-json.md).
544
559
 
545
560
  ### 5. Seed the ratchet
@@ -616,17 +631,21 @@ crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/cov
616
631
  crapkit: every lane failed: ...
617
632
  ```
618
633
 
619
- Install the provider, pinned to your vitest major or npm refuses the peer dependency:
634
+ That failure **writes no run**. Every lane failed, so `coverage` exits before it opens a
635
+ store: there is no `.crapkit/crap.sqlite` yet and the run ids below still start at 1.
636
+
637
+ Install the provider, and pin the major yourself. Unpinned, npm resolves the newest
638
+ provider against your older vitest and refuses the tree:
620
639
 
621
640
  ```
622
- npm i -D @vitest/coverage-v8
641
+ npm i -D "@vitest/coverage-v8@<your vitest major>"
623
642
  ```
624
643
 
625
644
  | Question | Answer |
626
645
  |---|---|
627
646
  | Which provider? | Either works. `@vitest/coverage-v8` is vitest's default and needs no config. `@vitest/coverage-istanbul` also works and needs `coverage.provider = "istanbul"` in your vitest config. |
628
647
  | Which crapkit parser? | Both feed `parser = "istanbul"`. The provider name and the parser name are unrelated: v8 output is remapped to the istanbul JSON schema before it is written. |
629
- | Which version? | It must match your vitest major. npm refuses the install otherwise (`peer vitest@"4.x" from @vitest/coverage-v8@4.x`). On vitest 2, `npm i -D "@vitest/coverage-v8@2"`. |
648
+ | Which version? | The provider's major has to match vitest's. On vitest 2 that is `npm i -D "@vitest/coverage-v8@2"`, on vitest 3 `npm i -D "@vitest/coverage-v8@3"`. Drop the pin and npm answers `ERESOLVE unable to resolve dependency tree`, naming the peer it could not satisfy. |
630
649
 
631
650
  The artifact crapkit wants is `coverage-final.json`, written by vitest's `json` coverage
632
651
  reporter, which is on by default. If your vitest config sets `coverage.reporter`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "crapkit"
7
- version = "0.4.1"
7
+ version = "0.4.2"
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" }
@@ -48,7 +48,11 @@ Issues = "https://github.com/JeanFrancoisGagne/crapkit/issues"
48
48
  Source = "https://github.com/JeanFrancoisGagne/crapkit"
49
49
 
50
50
  [project.optional-dependencies]
51
- dev = ["pytest>=8", "pytest-cov>=5"]
51
+ # pytest-xdist is not a convenience: tests/fixtures/mini_repo declares a lane
52
+ # that shells out to `pytest pylib -n 2 ...`, and pytest rejects `-n` outright
53
+ # without it. Left out of this list it took down 31 e2e tests on every one of
54
+ # the six CI test jobs (run 33225190806), ubuntu and windows alike.
55
+ dev = ["pytest>=8", "pytest-cov>=5", "pytest-xdist>=3"]
52
56
 
53
57
  [project.scripts]
54
58
  crapkit = "crapkit.cli:main"
@@ -1,2 +1,2 @@
1
1
  """crapkit: deterministic CRAP-score framework."""
2
- __version__ = "0.4.1"
2
+ __version__ = "0.4.2"
@@ -49,7 +49,12 @@ _POOL_THRESHOLD = 16
49
49
  # Bump whenever analysis semantics change (merge rules, extension set, record
50
50
  # extraction): the fingerprint must invalidate cached records produced by older
51
51
  # logic even when file content and tool versions are identical.
52
- ANALYSIS_VERSION = 6 # 6: five more C-family extension sets and .ps1/.psm1 are
52
+ ANALYSIS_VERSION = 7 # 7: a Rust `match` is a cognitive condition (+1 and the
53
+ # nesting it sits in), so every cached .rs record
54
+ # carries a cognitive score measured without it. Rust
55
+ # is the only language whose stored values move; ccn is
56
+ # untouched in every language, Rust included.
57
+ # 6: five more C-family extension sets and .ps1/.psm1 are
53
58
  # admitted, a C++ rvalue reference is no longer a
54
59
  # cognitive condition, and source bytes decode utf-8
55
60
  # then cp1252 instead of by machine locale
@@ -152,6 +152,7 @@ _OWNER = {
152
152
  "_next_item_payload": "queue",
153
153
  "_next_ranked": "queue",
154
154
  "_next_reasons": "queue",
155
+ "_next_step": "admin",
155
156
  "_no_baseline": "verifying",
156
157
  "_no_handle_message": "queue",
157
158
  "_no_lane_debt": "queue",
@@ -210,6 +211,8 @@ _OWNER = {
210
211
  "_reconfigure_streams": "parser",
211
212
  "_records": "claude_hook",
212
213
  "_records_by_scope": "scoring",
214
+ "_refuse_empty_lane_run": "scoring",
215
+ "_refuse_lane_less_verify": "verifying",
213
216
  "_release_claims": "verifying",
214
217
  "_release_target": "queue",
215
218
  "_report": "claude_hook",
@@ -39,14 +39,30 @@ def _package_json(root: Path) -> str:
39
39
  return path.read_text(encoding="utf-8") if path.is_file() else ""
40
40
 
41
41
 
42
+ def _next_step(scopes: dict, lanes: tuple) -> str:
43
+ """What to run next, which is not the same sentence in all three cases.
44
+
45
+ A repo whose every language is cc-only was told to declare a lane per
46
+ coverage command. There is no lane to declare: neither parser reads Go,
47
+ Rust, shell or the six others, `init` just wrote `coverage_optional` for
48
+ each scope, and `crapkit coverage` scores them from complexity alone.
49
+ """
50
+ from ..scaffold import cc_only_scope
51
+
52
+ if lanes:
53
+ return (f"detected {len(lanes)} lane(s) from this repo's own files: "
54
+ f"{', '.join(lane.name for lane in lanes)} — next: run `crapkit coverage`")
55
+ if all(cc_only_scope(languages) for languages in scopes.values()):
56
+ return ("no coverage parser reads this repo's languages, so every scope is "
57
+ "cc-only (coverage_optional = true) and needs no lane — next: run "
58
+ "`crapkit coverage`")
59
+ return ("next: declare a [[lane]] per coverage command (see the commented template), "
60
+ "then run `crapkit coverage`")
61
+
62
+
42
63
  def _print_init_summary(scopes: dict, lanes: tuple) -> None:
43
64
  print(f"wrote crapkit.toml with {len(scopes)} scope(s): {', '.join(scopes)}")
44
- if lanes:
45
- print(f"detected {len(lanes)} lane(s) from this repo's own files: "
46
- f"{', '.join(lane.name for lane in lanes)} — next: run `crapkit coverage`")
47
- return
48
- print("next: declare a [[lane]] per coverage command (see the commented template), "
49
- "then run `crapkit coverage`")
65
+ print(_next_step(scopes, lanes))
50
66
 
51
67
 
52
68
  def _no_scopes_reason(root: Path) -> str:
@@ -214,9 +230,13 @@ def _lane_command_problems(root: Path, lane) -> list[str]:
214
230
 
215
231
 
216
232
  def _doctor_lane_summary(cfg) -> Finding:
233
+ """No lanes is a gap in most repos and the finished state in a cc-only one,
234
+ where every scope declares coverage_optional and `coverage` runs anyway."""
217
235
  if cfg.lanes:
218
236
  return Finding("ok", f"{len(cfg.lanes)} lane(s) declared")
219
- return Finding("note", "no [[lane]] declared — inventory works; coverage needs one")
237
+ if cfg.lane_less_scopes:
238
+ return Finding("note", "no [[lane]] declared — inventory works; coverage needs one")
239
+ return Finding("ok", "no [[lane]] declared: every scope is cc-only, so none is needed")
220
240
 
221
241
 
222
242
  def _lane_problems(root: Path, cfg) -> list[str]:
@@ -359,8 +359,13 @@ def cmd_claims(args: argparse.Namespace) -> int:
359
359
 
360
360
 
361
361
  def _name_prefix(long_name: str) -> str:
362
- """The identifier a long_name opens with, before its parameter list."""
363
- return long_name.split("(")[0].strip()
362
+ """The identifier a long_name opens with, before its parameter list.
363
+
364
+ packet.bare_name is the definition. This was a second copy of the cut, and
365
+ it is what kept `brief` rejecting a Rust or Go bare name after the packet
366
+ that published it had already been fixed.
367
+ """
368
+ return packet.bare_name(long_name)
364
369
 
365
370
 
366
371
  def _name_matches(long_name: str, name: str) -> bool:
@@ -375,7 +380,13 @@ def _name_matches(long_name: str, name: str) -> bool:
375
380
 
376
381
 
377
382
  def _matching_rows(rows: list, name: str) -> list:
378
- return [r for r in rows if _name_matches(r.long_name, name)]
383
+ """The rows NAME resolves to, by the rule `explain` runs on the same string.
384
+
385
+ Both commands go through `packet.matching_names`, so a name that picks one
386
+ function in a packet cannot pick three in a trajectory.
387
+ """
388
+ wanted = set(packet.matching_names([r.long_name for r in rows], name))
389
+ return [r for r in rows if r.long_name in wanted]
379
390
 
380
391
 
381
392
  def _no_match_message(path: str, name: str, rows: list, candidates: list) -> str:
@@ -194,7 +194,9 @@ def _collect_lanes(root: Path, lanes, outcomes: dict):
194
194
  stamps[lane.artifact] = outcome.stamp
195
195
  succeeded.append(lane)
196
196
  write_stamps(root, stamps)
197
- if not succeeded:
197
+ # `lane_errors` and not `lanes`: a cc-only repo declares no lanes, so nothing
198
+ # succeeded and nothing failed either, and that run is still a scored run.
199
+ if lane_errors and not succeeded:
198
200
  raise ToolError(f"every lane failed: {'; '.join(lane_errors.values())}")
199
201
  return coverage_by_path, provenance, lane_errors, succeeded
200
202
 
@@ -254,11 +256,30 @@ def _run_kind(lanes, cfg, failures) -> str:
254
256
  return "coverage" if full else "partial"
255
257
 
256
258
 
259
+ def _refuse_empty_lane_run(cfg, requested) -> None:
260
+ """Refuse a run that selected no lane, unless running with none is right.
261
+
262
+ Right for a repo whose every scope declares `coverage_optional`: nine of the
263
+ fourteen languages have no coverage parser at all, such a repo is scored
264
+ from complexity alone, and demanding a lane refused exactly the repos that
265
+ can never supply one. Wrong the moment one scope has neither, which is what
266
+ the message names — the scopes, not the empty list, because the list is a
267
+ symptom and the scopes are the gap.
268
+ """
269
+ if requested is not None:
270
+ raise ConfigError(f"no lane named {requested!r}")
271
+ if cfg.lane_less_scopes:
272
+ raise ConfigError(
273
+ f"no [[lane]] to run for scope(s) {', '.join(cfg.lane_less_scopes)} — declare a "
274
+ "[[lane]] measuring them in crapkit.toml, or set coverage_optional = true on a "
275
+ "scope no coverage parser can read")
276
+
277
+
257
278
  def _select_lanes(cfg, requested):
279
+ """The lanes this run executes; an empty list is a legitimate answer."""
258
280
  lanes = [l for l in cfg.lanes if requested is None or l.name == requested]
259
281
  if not lanes:
260
- raise ConfigError("no [[lane]] to run — declare lanes in crapkit.toml" if not cfg.lanes
261
- else f"no lane named {requested!r}")
282
+ _refuse_empty_lane_run(cfg, requested)
262
283
  return lanes
263
284
 
264
285
 
@@ -320,6 +320,23 @@ def _report_verify(as_json: bool, out: dict, verdict, overridden) -> None:
320
320
  _print_finding_split(verdict)
321
321
 
322
322
 
323
+ def _refuse_lane_less_verify(cfg) -> None:
324
+ """Refuse a verdict on a scope nothing measures and no key excuses.
325
+
326
+ Stricter than `coverage`, on purpose. `coverage` scores such a scope and
327
+ flags every row `no-lane`, because scoring is its whole job; `verify` calls
328
+ coverage half the verdict, so an unmeasured scope leaves it with half an
329
+ answer and it says so instead. The test that a cc-only repo passes is that
330
+ `lane_less_scopes` is empty, not that lanes exist: such a repo has none and
331
+ never will, and its verdict is the gate and the ratchet, neither of which
332
+ needs a coverage number.
333
+ """
334
+ if cfg.lane_less_scopes:
335
+ raise ConfigError(f"verify needs a [[lane]] for scope(s) {', '.join(cfg.lane_less_scopes)}"
336
+ " — coverage is half the verdict; a scope no coverage parser can read "
337
+ "declares coverage_optional = true instead")
338
+
339
+
323
340
  def cmd_verify(args: argparse.Namespace) -> int:
324
341
  from ..diffparse import changed_ranges
325
342
  from ..gitio import GitFacts, diff_since
@@ -328,8 +345,7 @@ def cmd_verify(args: argparse.Namespace) -> int:
328
345
 
329
346
  root = Path(args.repo).resolve()
330
347
  cfg = _load_repo_config(root)
331
- if not cfg.lanes:
332
- raise ConfigError("verify needs [[lane]] declarations — coverage is half the verdict")
348
+ _refuse_lane_less_verify(cfg)
333
349
  store = _verify_store(root, args.baseline_tsv)
334
350
  _guard_ratchet_stamp(root / cfg.ratchet_file, cfg.ratchet_file)
335
351
  # One context for the whole command: the ancestry checks, the lane runner and
@@ -110,6 +110,23 @@ class Config(NamedTuple):
110
110
  """The scopes scored cc-only: no coverage join, and no lane required."""
111
111
  return frozenset(s.name for s in self.scopes if s.coverage_optional)
112
112
 
113
+ @property
114
+ def lane_less_scopes(self) -> tuple[str, ...]:
115
+ """Scopes no lane measures and no `coverage_optional` excuses.
116
+
117
+ Empty is the licence to run with no lanes at all: every scope is either
118
+ measured by one or scored cc-only, so there is nothing left for a lane
119
+ to say.
120
+
121
+ The two readers weigh a non-empty answer differently, and the difference
122
+ is deliberate. `verify` refuses outright and names the list. `coverage`
123
+ refuses only when it also selected no lane, and otherwise scores the
124
+ scope and flags every row `no-lane` — a flag it could not print at all
125
+ if owing a lane refused the run. `doctor` is what says so out of band.
126
+ """
127
+ covered = {s for lane in self.lanes for s in lane.scopes} | self.coverage_optional_scopes
128
+ return tuple(s.name for s in self.scopes if s.name not in covered)
129
+
113
130
  @property
114
131
  def scope_paths(self) -> dict[str, tuple[str, ...]]:
115
132
  """Every scope's declared paths, by name — what a lane's scopes resolve to."""
@@ -10,9 +10,14 @@ same rules with no second parse and no new dependency:
10
10
  try / finally / case labels / with are free; nesting rises inside the
11
11
  block structures listed above.
12
12
 
13
- One language-specific rule: in C/C++ and Objective-C/C++ a `&&` before the
14
- function's opening brace declares an rvalue reference rather than deciding
15
- anything, and costs nothing. See `_declarator_and`.
13
+ Two language-specific rules:
14
+ * in C/C++ and Objective-C/C++ a `&&` before the function's opening brace
15
+ declares an rvalue reference rather than deciding anything, and costs
16
+ nothing. See `_declarator_and`.
17
+ * in Rust a `match` is a switch and is charged as one, +1 and +nesting with
18
+ the arms free. It is read only for the Rust readers because `match` is a
19
+ soft keyword in Python, where the same spelling is an ordinary identifier.
20
+ See `_counting_match`.
16
21
 
17
22
  Attribution follows lizard's function splitting (a nested arrow's tokens are
18
23
  the arrow's), exactly as ccn is attributed today. Ternary branches do not
@@ -53,14 +58,22 @@ _RUN_RESETS = frozenset({";", ",", "{", "}"})
53
58
  # lose a real one.
54
59
  _DECLARATOR_READERS = frozenset({"CLikeReader", "ObjCReader"})
55
60
 
61
+ # The readers whose `match` is Rust's switch: lizard's own, and crapkit's
62
+ # subclass of it. Exact names for the same reason `_DECLARATOR_READERS` uses
63
+ # them — the discriminator is the language, and an issubclass test would also
64
+ # catch anything a later lizard derives from RustReader for another one.
65
+ _MATCH_READERS = frozenset({"RustReader", "CorrectedRustReader"})
66
+
56
67
 
57
68
  class _FnState:
58
69
  __slots__ = ("total", "stack", "brace_depth", "line_indent", "at_line_start",
59
70
  "pending", "else_pending", "question_pending", "bool_op", "name",
60
- "recursed", "body_started", "prev", "label_check", "c_family")
71
+ "recursed", "body_started", "prev", "label_check", "c_family",
72
+ "match_kw")
61
73
 
62
- def __init__(self, name: str, c_family: bool = False):
74
+ def __init__(self, name: str, c_family: bool = False, match_kw: bool = False):
63
75
  self.c_family = c_family
76
+ self.match_kw = match_kw
64
77
  self.total = 0
65
78
  self.stack = [] # (entry_brace_depth) or python header indents
66
79
  self.brace_depth = 0
@@ -91,11 +104,13 @@ class LizardExtension:
91
104
  reader_name = type(reader).__name__
92
105
  is_python = reader_name.lower().startswith("python")
93
106
  c_family = reader_name in _DECLARATOR_READERS
107
+ match_kw = reader_name in _MATCH_READERS
94
108
  for token in tokens:
95
109
  fn = reader.context.current_function
96
110
  state = states.get(fn)
97
111
  if state is None:
98
- state = states[fn] = _FnState(getattr(fn, "name", ""), c_family)
112
+ state = states[fn] = _FnState(getattr(fn, "name", ""), c_family,
113
+ match_kw)
99
114
  _step(state, token, is_python)
100
115
  fn.cognitive_complexity = state.total
101
116
  yield token
@@ -235,12 +250,27 @@ def _keywords(state: _FnState, token: str, is_python: bool) -> None:
235
250
  _if_token(state, is_python)
236
251
  elif token in _ELSE_KEYWORDS:
237
252
  _else_token(state, token, is_python)
238
- elif token in _COUNTING:
253
+ elif token in _COUNTING or _counting_match(state, token):
239
254
  _structure_token(state, token, is_python)
240
255
  else:
241
256
  _jumps_and_recursion(state, token, is_python)
242
257
 
243
258
 
259
+ def _counting_match(state: _FnState, token: str) -> bool:
260
+ """True for a Rust `match`, which is a switch and is charged as one.
261
+
262
+ Not in `_COUNTING`, because that set is read by every language and `match`
263
+ is a soft keyword in Python: `match = re.match(...)` would cost a point and
264
+ open a block that never closes. The reader decides, the way it decides
265
+ whether a `&&` is a declarator.
266
+
267
+ The block itself pays +1 and the nesting it sits in; the arms pay nothing,
268
+ exactly as a C `case` pays nothing. Rust's cyclomatic column counts the arms
269
+ instead, so the two columns say different things about one block on purpose.
270
+ """
271
+ return token == "match" and state.match_kw
272
+
273
+
244
274
  def _structure_token(state: _FnState, token: str, is_python: bool) -> None:
245
275
  if token == "while" and state.prev == "}":
246
276
  return # the closing half of do-while; the do already paid
@@ -15,6 +15,14 @@ PROTOCOL_VERSION = "2024-11-05"
15
15
 
16
16
  _REPO = {"repo": {"type": "string", "description": "repo root (default: the server's)"}}
17
17
 
18
+ # brief and explain resolve NAME by one rule, so they describe it with one
19
+ # string. The bare identifier is the long name's leading token, which is all
20
+ # there is before the parameters in Rust and Go.
21
+ _NAME_DESCRIPTION = ("the bare identifier (classify, or route for a Rust "
22
+ "`route cmd : & Cmd`) or the whole long_name next_item "
23
+ "printed (classify( score , late )); both resolve, exact "
24
+ "match first")
25
+
18
26
  TOOLS: tuple[dict, ...] = (
19
27
  {"name": "next_item", "argv": ["next-item"], "json_flag": False, "positional": (),
20
28
  "flags": {"top": "--top", "exclude": "--exclude"},
@@ -31,14 +39,12 @@ TOOLS: tuple[dict, ...] = (
31
39
  "description": "One function's whole context: scored row, ratchet mark, uncovered lines, "
32
40
  "duplication twins, churn and change-coupling partners",
33
41
  "properties": {"path": {"type": "string", "description": "repo-relative source file"},
34
- "name": {"type": "string",
35
- "description": "the bare identifier (classify) or the whole "
36
- "long_name next_item printed "
37
- "(classify( score , late )); both resolve"}}},
42
+ "name": {"type": "string", "description": _NAME_DESCRIPTION}}},
38
43
  {"name": "explain", "argv": ["explain"], "json_flag": False, "positional": ("path", "name"),
39
44
  "flags": {},
40
45
  "description": "One function's score trajectory across runs plus its ratchet mark",
41
- "properties": {"path": {"type": "string"}, "name": {"type": "string"}}},
46
+ "properties": {"path": {"type": "string"},
47
+ "name": {"type": "string", "description": _NAME_DESCRIPTION}}},
42
48
  {"name": "doctor", "argv": ["doctor"], "json_flag": False, "positional": (), "flags": {},
43
49
  "description": "Config/repo agreement check: typo keys, empty scopes, missing lane cwds",
44
50
  "properties": {}},
@@ -132,11 +132,46 @@ def commands(path: str, scoped: str | None, note: str = "") -> dict:
132
132
  def bare_name(long_name: str) -> str:
133
133
  """The identifier a long_name opens with, before its parameter list.
134
134
 
135
+ Two cuts, because lizard's readers spell a parameter list two ways. Python
136
+ and shell close the name with `(` — `classify( score , limit = 1 )`,
137
+ `classify()` — and Rust and Go print the parameters after a space with no
138
+ parenthesis at all: `route cmd : & Cmd`, `Classify n int`. Cutting only at
139
+ the `(` handed those back whole, so the handle a packet published was a
140
+ signature no command would accept back.
141
+
142
+ The leading token settles both. It moves no parenthesised language, because
143
+ none of those puts a space before the `(`: `n::K::m( int a)` keeps its
144
+ namespace and an Objective-C `doThing:( int )` keeps its selector colon.
145
+
135
146
  Empty for a function lizard could not name: both `(anonymous)` and
136
147
  `(anonymous) ( z )` open with the parenthesis, so an empty prefix IS the
137
148
  test for anonymity, with no second string to keep in step.
138
149
  """
139
- return long_name.split("(")[0].strip()
150
+ head = long_name.split("(")[0].strip()
151
+ return head.split()[0] if head else ""
152
+
153
+
154
+ def exact_names(names, name: str) -> list[str]:
155
+ """The long names `name` names outright: the whole string, or the bare one."""
156
+ return [n for n in names if name in (n, bare_name(n))]
157
+
158
+
159
+ def matching_names(names, name: str) -> list[str]:
160
+ """The long names one NAME resolves to, in the order `names` arrived.
161
+
162
+ Exact first, the fragment second. `brief` matched only exactly and `explain`
163
+ only loosely, so `route` picked one function in one command and three —
164
+ `route`, `route_chain`, `route_num` — in the other, off the same string in
165
+ the same payload. Nesting names is the ordinary case, so the loose command
166
+ was wrong far more often than the strict one was unhelpful.
167
+
168
+ The fragment survives as the fallback because a name nobody owns is usually
169
+ a typo, and listing everything holding it is what tells a session which name
170
+ it meant. An empty NAME resolves to nothing rather than to everything.
171
+ """
172
+ if not name:
173
+ return []
174
+ return exact_names(names, name) or [n for n in names if name in n]
140
175
 
141
176
 
142
177
  def anonymous_starts(rows) -> list[int]:
@@ -8,6 +8,23 @@ from .universe import LANGUAGE_EXTENSIONS, exclude_matcher, excluded
8
8
 
9
9
  _EXT_LANGUAGE = {ext: lang for lang, exts in LANGUAGE_EXTENSIONS.items() for ext in exts}
10
10
 
11
+ # The languages a coverage parser can read: coverage.py reads python, istanbul
12
+ # reads the JavaScript family and the .vue files a vitest run reports on. Every
13
+ # other supported language scores on complexity alone, which is what
14
+ # `coverage_optional = true` declares — so init writes the key for a scope built
15
+ # entirely from the rest, rather than leaving it to score `no-lane` forever
16
+ # against a lane nobody can write.
17
+ COVERABLE_LANGUAGES = frozenset({"python", "javascript", "typescript", "tsx", "vue"})
18
+
19
+
20
+ def cc_only_scope(languages: tuple[str, ...]) -> bool:
21
+ """True when no coverage parser reads any of this scope's languages.
22
+
23
+ One coverable language is enough to keep the key off: a lane can still
24
+ measure that part, and `coverage_optional` would forgive the whole scope.
25
+ """
26
+ return not any(lang in COVERABLE_LANGUAGES for lang in languages)
27
+
11
28
  DEFAULT_EXCLUDES = (
12
29
  "**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**",
13
30
  "**/*.test.*", "**/*.spec.*", "**/test_*.py", "**/*_test.py", "**/conftest.py",
@@ -174,8 +191,9 @@ def _quoted(names) -> str:
174
191
 
175
192
 
176
193
  def _scope_stanza(name: str, languages: tuple[str, ...]) -> list[str]:
194
+ optional = ["coverage_optional = true"] if cc_only_scope(languages) else []
177
195
  return ["[[scope]]", f'name = "{name}"', f'paths = ["{name}"]',
178
- f"languages = [{_quoted(languages)}]", ""]
196
+ f"languages = [{_quoted(languages)}]", *optional, ""]
179
197
 
180
198
 
181
199
  def _exclude_stanza() -> list[str]:
@@ -297,10 +315,17 @@ def _commented_block(rest: dict[str, tuple[str, ...]], has_live: bool) -> list[s
297
315
  return header + _scoped_entry_lines(rest, False)
298
316
 
299
317
 
300
- def _template_lines(covered: set[str], scope_list: str) -> list[str]:
318
+ def _template_lines(covered: set[str], scopes: dict[str, tuple[str, ...]]) -> list[str]:
319
+ """Commented lane templates for the parsers no live lane covers.
320
+
321
+ None at all when every scope is cc-only. Neither parser reads any language
322
+ in the repo, so both templates would be a step the reader cannot take, under
323
+ a heading telling them to take it before running `crapkit coverage`.
324
+ """
325
+ if all(cc_only_scope(languages) for languages in scopes.values()):
326
+ return []
301
327
  # the template's scope is a placeholder on purpose: writing a real scope
302
328
  # name pointed a TS lane template at a python project's sources
303
- del scope_list
304
329
  lines: list[str] = []
305
330
  for parser in sorted(_TEMPLATES):
306
331
  if parser not in covered:
@@ -316,7 +341,7 @@ def starter_toml(scopes: dict[str, tuple[str, ...]], lanes: tuple[LaneSpec, ...]
316
341
  lines += _scope_stanza(name, languages)
317
342
  lines += _exclude_stanza()
318
343
  live, covered = _live_lanes(lanes, scopes)
319
- return "\n".join(lines + live + _template_lines(covered, _quoted(scopes))
344
+ return "\n".join(lines + live + _template_lines(covered, scopes)
320
345
  + _scoped_tests_stub(scopes, _confirmed_languages(lanes)))
321
346
 
322
347
 
@@ -25,7 +25,7 @@ from itertools import takewhile
25
25
  from pathlib import Path
26
26
  from typing import NamedTuple
27
27
 
28
- from .packet import bare_name, handle_ordinal
28
+ from .packet import bare_name, handle_ordinal, matching_names
29
29
  from .snapshot import InventoryRow
30
30
 
31
31
  # {table} so the migration can build the same shape under a temp name and swap
@@ -850,23 +850,34 @@ class SnapshotStore:
850
850
  return list(cur.fetchall())
851
851
 
852
852
  def find_functions(self, path: str, name_fragment: str) -> list[str]:
853
- """Distinct long_names in a path containing the fragment, across all runs.
853
+ """The long_names in a path that one NAME resolves to, across all runs.
854
854
 
855
- Joined to functions rather than read off identities alone: a prune drops
856
- the rows of a run and leaves its identities behind, and a name no
857
- surviving run scored is a name `brief` cannot resolve.
855
+ `packet.matching_names` is the rule, shared with `brief`: exact first,
856
+ the fragment only when nothing matches exactly. This used to be a SQL
857
+ `LIKE '%name%'` and nothing else, so `explain src/lib.rs route` printed
858
+ the trajectories of `route`, `route_chain` and `route_num` for a
859
+ question about one function. Matching in Python also makes `_` and `%`
860
+ the characters they are rather than LIKE's wildcards.
858
861
 
859
862
  `(anonymous)#N` is resolved by position instead: an anonymous function
860
- carries no text for a LIKE to match, and the fragment would otherwise
861
- hunt for a `#` no long_name has.
863
+ carries no text to match, and the fragment would otherwise hunt for a
864
+ `#` no long_name has.
862
865
  """
863
866
  ordinal = handle_ordinal(name_fragment)
864
867
  if ordinal is not None:
865
868
  return self._nth_anonymous(path, ordinal)
869
+ return matching_names(self._long_names(path), name_fragment)
870
+
871
+ def _long_names(self, path: str) -> list[str]:
872
+ """Every distinct long_name a surviving run scored in this path.
873
+
874
+ Joined to functions rather than read off identities alone: a prune drops
875
+ the rows of a run and leaves its identities behind, and a name no
876
+ surviving run scored is a name `brief` cannot resolve.
877
+ """
866
878
  cur = self._conn.execute(
867
- f"SELECT DISTINCT i.long_name {_BY_PATH} "
868
- "WHERE i.path = ? AND i.long_name LIKE ? ORDER BY i.long_name",
869
- (path, f"%{name_fragment}%"))
879
+ f"SELECT DISTINCT i.long_name {_BY_PATH} WHERE i.path = ? "
880
+ "ORDER BY i.long_name", (path,))
870
881
  return [n for (n,) in cur]
871
882
 
872
883
  def _nth_anonymous(self, path: str, ordinal: int) -> list[str]:
@@ -1014,11 +1025,24 @@ def pick_baseline(runs: list[dict]) -> BaselinePick:
1014
1025
 
1015
1026
 
1016
1027
  def is_trusted(r: dict) -> bool:
1017
- """Trusted = a coverage run, or a verify run whose verdict passed."""
1018
- if not r["lanes"] or r["kind"] == "hook":
1028
+ """Trusted = a full coverage run, or a verify run whose verdict passed.
1029
+
1030
+ `kind` decides it, not lane provenance. A repo whose every scope declares
1031
+ `coverage_optional` scores with no lanes at all, and reading the empty
1032
+ provenance as "nothing was measured" left it with a coverage run no
1033
+ baseline reader would accept — worklist, next-item, rescore, ratchet seed
1034
+ and verify all reported there was no scored run right after one.
1035
+
1036
+ `legacy` is the exception that keeps the old test: those rows were migrated
1037
+ from before the column existed, so one label covers their inventory runs and
1038
+ their coverage runs alike and only provenance tells the two apart.
1039
+ """
1040
+ if r["kind"] == "hook":
1019
1041
  return False
1020
- if r["kind"] in ("coverage", "legacy", None):
1042
+ if r["kind"] == "coverage":
1021
1043
  return True
1044
+ if r["kind"] in ("legacy", None):
1045
+ return bool(r["lanes"])
1022
1046
  return r["kind"] == "verify" and r["verdict_ok"] is True
1023
1047
 
1024
1048
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crapkit
3
- Version: 0.4.1
3
+ Version: 0.4.2
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
@@ -28,6 +28,7 @@ Requires-Dist: lizard>=1.24.0
28
28
  Provides-Extra: dev
29
29
  Requires-Dist: pytest>=8; extra == "dev"
30
30
  Requires-Dist: pytest-cov>=5; extra == "dev"
31
+ Requires-Dist: pytest-xdist>=3; extra == "dev"
31
32
  Dynamic: license-file
32
33
 
33
34
  # crapkit
@@ -115,7 +116,7 @@ changing crapkit.
115
116
 
116
117
  ```
117
118
  $ crapkit --version
118
- crapkit 0.4.1
119
+ crapkit 0.4.2
119
120
  ```
120
121
 
121
122
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -165,10 +166,16 @@ lane. Nothing about it is provisional: the ceiling still binds and the gate stil
165
166
  a function over it. Add a coverage lane the day a parser exists and the same scope starts
166
167
  joining coverage.
167
168
 
169
+ `crapkit init` writes that key itself, on every scope whose languages all lack a parser,
170
+ and leaves it off any scope a lane could still measure. So the 60-second start above runs
171
+ unchanged on a Go, Rust or shell repo: `crapkit coverage` scores it with no lane at all,
172
+ and that run is the baseline `worklist`, `next-item`, `ratchet seed` and `verify` read.
173
+
168
174
  Three readers are crapkit's own. lizard ships none for shell or PowerShell, so crapkit
169
175
  counts their functions itself. Its Rust reader scores a 7-arm `match` as ccn 2 (filed as
170
176
  lizard #494), so crapkit counts each non-wildcard arm like a C `case` and retires the
171
- override the day upstream fixes it.
177
+ override the day upstream fixes it. The cognitive column charges that same block once,
178
+ the way Sonar charges a `switch`.
172
179
 
173
180
  ## The gate
174
181
 
@@ -178,8 +185,8 @@ different powers:
178
185
  | Surface | Fires | Power |
179
186
  |---|---|---|
180
187
  | `crapkit claude-hook` | after an agent's edit lands | **advisory.** Names the breach on stderr. Blocks nothing, because PostToolUse runs after the write |
181
- | `crapkit rescore FILE --gate` | when you ask | **preview.** The commit gate's verdict on demand, sub-second, before you stage |
182
- | `crapkit hook-precommit` | `git commit` | **blocks.** Exit 6. Staged blobs only, so it costs the size of the commit and needs no coverage |
188
+ | `crapkit rescore FILE --gate` | when you ask, after the first coverage run | **preview.** The commit gate's verdict on demand, sub-second, before you stage. With no run behind it, exit 1 and `no snapshot` |
189
+ | `crapkit hook-precommit` | `git commit` | **blocks.** The hook exits 6; git reports 1. Staged blobs only, so it costs the size of the commit and needs no coverage |
183
190
  | `crapkit verify` | before you push, and in CI | **the verdict.** Gate, ratchet, new test failures, diff coverage, against the trusted baseline |
184
191
 
185
192
  Both hooks exempt a function the committed ratchet already carries a mark for, so touching
@@ -239,7 +246,8 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
239
246
  ```yaml
240
247
  repos:
241
248
  - repo: https://github.com/JeanFrancoisGagne/crapkit
242
- rev: v0.4.0
249
+ # crapkit's release step rewrites this line to the tag it just cut
250
+ rev: v0.4.2
243
251
  hooks:
244
252
  - id: crapkit-gate
245
253
  ```
@@ -275,7 +283,9 @@ crapkit gate: 1 staged function(s) exceed the complexity ceiling of 6:
275
283
  decompose before committing (coverage cannot save a function above the target).
276
284
  ```
277
285
 
278
- Run directly, `crapkit hook-precommit` exits 6 on a violation and 0 otherwise.
286
+ That commit exited **1**, not 6. Git collapses any failed hook to 1, so 6 is a code you
287
+ only ever see by running the hook yourself: `crapkit hook-precommit` exits 6 on a
288
+ violation and 0 otherwise. The stderr block above is the same either way.
279
289
 
280
290
  `CRAPKIT_OVERRIDE_REASON` is not a bypass. Setting it routes the commit through the full
281
291
  three-record audit: an alert line through `alert_command`, a ratchet entry staged into the
@@ -316,7 +326,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
316
326
  | `next-item [--top N] [--exclude FRAG] [--scope NAME] [--claim]` | The actionable queue as JSON, with churn, budget estimates and uncovered lines. Same run and same admission floor as `worklist`, a different view of it: `no-lane` rows are skipped and counted in `skipped_no_lane`, and what is left is ranked by `crap` descending rather than by risk, so the item it hands out is often not the worklist's first row. `--exclude FRAG` (repeatable) skips items whose path or function name contains FRAG; `--scope NAME` (repeatable) is exact, not a substring. `--claim` holds what it hands out so a second session skips it. `stale` is true when the ranked run's commit is not HEAD, the same field `worklist` carries. Every item carries a `handle`: the bare identifier, or `(anonymous)#N` for a function with no name, which is the name form that survives the edit the item asks for. |
317
327
  | `claims [list \| release PATH NAME \| release --all] [--json]` | The open claims, and the way to hand one back without waiting for a verify. `release` takes the bare identifier, the whole long name, or the `handle` the claim was taken under, which is the only one that picks out a single `(anonymous)` claim. |
318
328
  | `brief FILE NAME [--batch N] [--json]` | The start-editing packet for one function: its own `source` text, every function in the file, the scored row and the scope ceiling, the ratchet mark and what the gate will bind on, uncovered lines, duplication twins, file churn, coupling partners, the config's notes, and the literal commands for the rest of the loop. Plus `handle`, `remedy` and the same `est_splits` / `est_uncovered_paths` the queue prints, and a `commands.refresh` that writes a run (`refresh_writes_run`) rather than re-reading the stale one. `NAME` takes the bare identifier, the long name `next-item` printed, the function's start line, or `(anonymous)#N` for a function printed `(anonymous)`, counting the file's anonymous functions from the top. `--batch N` drops the positionals and emits `packets[]` instead: the top N of the queue, built from one read of the store. |
319
- | `explain FILE NAME [--history] [--tests] [--json]` | A function's score across runs plus its mark. `--history` adds the commits that touched it (`git log -L`), each carrying its message `body`, `--tests` the tests that covered it, which needs coverage.py contexts turned on ([recipe](docs/lanes.md#test-attribution-for-explain---tests)). `--json` emits the same content as one `schema` 1 object. |
329
+ | `explain FILE NAME [--history] [--tests] [--json]` | A function's score across runs plus its mark. `NAME` resolves exact first: a function whose bare identifier or long name is exactly `NAME` wins, and only when nothing matches exactly does it fall back to a prefix match, so `route` explains `route` rather than every `route_*` beside it. `--history` adds the commits that touched it (`git log -L`), each carrying its message `body`, `--tests` the tests that covered it, which needs coverage.py contexts turned on ([recipe](docs/lanes.md#test-attribution-for-explain---tests)). `--json` emits the same content as one `schema` 1 object. |
320
330
  | `rescore FILE ... [--gate] [--json]` | Fresh complexity for named files over the latest run's stale coverage, joined by name. Advisory: it writes no run. `--gate` applies the pre-commit hook's policy to the same selection the hook uses (functions the tree changed since HEAD), minus functions a ratchet mark already covers, and exits 6. |
321
331
  | `ratchet seed \| prune \| merge \| move \| report [--enforce] [--json]` | The mark lifecycle: seed new debt, prune gone code (a mark whose file git renamed follows it), merge as a git driver, move re-paths marks, report reads burn-down from the file's own git history. See [docs/ratchet.md](docs/ratchet.md). |
322
332
  | `runs [list \| prune [--keep N]] [--json]` | Run history, and retention. `list` marks the run `verify` compares against today `baseline`, and prints `verdict=-` for a run that produces no verdict rather than one that failed. See [The trusted baseline](#the-trusted-baseline). `--keep` (default 5) is a floor on the newest trusted runs, not a cap: the digest pair, every passing verify baseline, every run an override names, and the newest non-hook run are kept too. `prune` VACUUMs afterwards. |
@@ -536,6 +546,11 @@ makes it step 4 of the burn-down loop. Every key is in
536
546
 
537
547
  ```
538
548
  $ crapkit doctor
549
+ ok config keys all recognized
550
+ ok scope 'calc': 1 files
551
+ ok every tracked source file belongs to a scope
552
+ ok 1 lane(s) declared
553
+ ok lizard 1.24.0
539
554
  doctor: no problems found
540
555
  ```
541
556
 
@@ -567,11 +582,12 @@ worklist has none.
567
582
 
568
583
  ```
569
584
  $ crapkit next-item
570
- {"commit": "fae4db93108b4841a00959f9117430679e7250ca", "empty": false, "item": {"authors": 1, "ccn": 14, "ccn_std": 14, "cognitive": 13, "commits": 1, "cov": 0.5, "crap": 38.5, "end": 28, "est_splits": 3, "est_uncovered_paths": 7, "flag": "measured", "function": "classify( score , attempts , late , bonus )", "nesting": 8, "nloc": 22, "path": "calc/grade.py", "remedy": "decompose", "scope": "calc", "start": 7, "target": 6, "uncovered_lines": [9, 11, 15, 17, 19, 24, 25, 26, 27, 28]}, "run_id": 1, "schema": 1, "skipped_no_lane": 0}
585
+ {"commit": "fae4db93108b4841a00959f9117430679e7250ca", "empty": false, "item": {"authors": 1, "ccn": 14, "ccn_std": 14, "cognitive": 13, "commits": 1, "cov": 0.5, "crap": 38.5, "end": 28, "est_splits": 3, "est_uncovered_paths": 7, "flag": "measured", "function": "classify( score , attempts , late , bonus )", "handle": "classify", "nesting": 8, "nloc": 22, "path": "calc/grade.py", "remedy": "decompose", "scope": "calc", "start": 7, "target": 6, "uncovered_lines": [9, 11, 15, 17, 19, 24, 25, 26, 27, 28]}, "run_id": 1, "schema": 1, "skipped_no_lane": 0, "stale": false}
571
586
  ```
572
587
 
573
588
  `remedy: "decompose"`, `est_splits: 3` (this needs roughly three pieces to fit under 6),
574
- and `uncovered_lines` naming the ten lines no test walks. Every field is in
589
+ and `uncovered_lines` naming the ten lines no test walks. `handle` is the name form to
590
+ pass back, and `stale: false` says the run still describes HEAD. Every field is in
575
591
  [docs/agent-json.md](docs/agent-json.md).
576
592
 
577
593
  ### 5. Seed the ratchet
@@ -648,17 +664,21 @@ crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/cov
648
664
  crapkit: every lane failed: ...
649
665
  ```
650
666
 
651
- Install the provider, pinned to your vitest major or npm refuses the peer dependency:
667
+ That failure **writes no run**. Every lane failed, so `coverage` exits before it opens a
668
+ store: there is no `.crapkit/crap.sqlite` yet and the run ids below still start at 1.
669
+
670
+ Install the provider, and pin the major yourself. Unpinned, npm resolves the newest
671
+ provider against your older vitest and refuses the tree:
652
672
 
653
673
  ```
654
- npm i -D @vitest/coverage-v8
674
+ npm i -D "@vitest/coverage-v8@<your vitest major>"
655
675
  ```
656
676
 
657
677
  | Question | Answer |
658
678
  |---|---|
659
679
  | Which provider? | Either works. `@vitest/coverage-v8` is vitest's default and needs no config. `@vitest/coverage-istanbul` also works and needs `coverage.provider = "istanbul"` in your vitest config. |
660
680
  | Which crapkit parser? | Both feed `parser = "istanbul"`. The provider name and the parser name are unrelated: v8 output is remapped to the istanbul JSON schema before it is written. |
661
- | Which version? | It must match your vitest major. npm refuses the install otherwise (`peer vitest@"4.x" from @vitest/coverage-v8@4.x`). On vitest 2, `npm i -D "@vitest/coverage-v8@2"`. |
681
+ | Which version? | The provider's major has to match vitest's. On vitest 2 that is `npm i -D "@vitest/coverage-v8@2"`, on vitest 3 `npm i -D "@vitest/coverage-v8@3"`. Drop the pin and npm answers `ERESOLVE unable to resolve dependency tree`, naming the peer it could not satisfy. |
662
682
 
663
683
  The artifact crapkit wants is `coverage-final.json`, written by vitest's `json` coverage
664
684
  reporter, which is on by default. If your vitest config sets `coverage.reporter`
@@ -3,3 +3,4 @@ lizard>=1.24.0
3
3
  [dev]
4
4
  pytest>=8
5
5
  pytest-cov>=5
6
+ pytest-xdist>=3
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