crapkit 0.4.0__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 (70) hide show
  1. {crapkit-0.4.0/src/crapkit.egg-info → crapkit-0.4.2}/PKG-INFO +484 -462
  2. {crapkit-0.4.0 → crapkit-0.4.2}/README.md +473 -458
  3. crapkit-0.4.2/pyproject.toml +65 -0
  4. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/__init__.py +1 -1
  5. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/analyze.py +6 -1
  6. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/cli/__init__.py +3 -0
  7. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/cli/admin.py +27 -7
  8. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/cli/queue.py +14 -3
  9. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/cli/scoring.py +24 -3
  10. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/cli/verifying.py +18 -2
  11. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/config.py +17 -0
  12. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/lizardcognitive.py +37 -7
  13. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/mcp_server.py +11 -5
  14. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/packet.py +36 -1
  15. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/scaffold.py +29 -4
  16. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/store.py +37 -13
  17. {crapkit-0.4.0 → crapkit-0.4.2/src/crapkit.egg-info}/PKG-INFO +484 -462
  18. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit.egg-info/requires.txt +1 -0
  19. crapkit-0.4.0/pyproject.toml +0 -45
  20. {crapkit-0.4.0 → crapkit-0.4.2}/LICENSE +0 -0
  21. {crapkit-0.4.0 → crapkit-0.4.2}/setup.cfg +0 -0
  22. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/__main__.py +0 -0
  23. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/_pygdefer.py +0 -0
  24. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/cache.py +0 -0
  25. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/churn.py +0 -0
  26. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/churn_cache.py +0 -0
  27. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/churn_log.py +0 -0
  28. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/cli/_shared.py +0 -0
  29. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/cli/analyses.py +0 -0
  30. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/cli/claude_hook.py +0 -0
  31. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/cli/parser.py +0 -0
  32. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/cli/ratchet_cmds.py +0 -0
  33. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/cli/reports.py +0 -0
  34. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/coupling.py +0 -0
  35. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/coverage_istanbul.py +0 -0
  36. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/coverage_py.py +0 -0
  37. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/covstream.py +0 -0
  38. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/diffparse.py +0 -0
  39. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/digest.py +0 -0
  40. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/discover.py +0 -0
  41. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/doctor.py +0 -0
  42. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/dup.py +0 -0
  43. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/errors.py +0 -0
  44. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/gitio.py +0 -0
  45. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/hook.py +0 -0
  46. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/junitparse.py +0 -0
  47. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/lanes.py +0 -0
  48. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/lizardpowershell.py +0 -0
  49. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/lizardrust.py +0 -0
  50. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/lizardshell.py +0 -0
  51. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/merge.py +0 -0
  52. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/mutate.py +0 -0
  53. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/mutate_pool.py +0 -0
  54. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/override.py +0 -0
  55. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/ratchet.py +0 -0
  56. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/ratchet_report.py +0 -0
  57. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/report.py +0 -0
  58. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/sarif.py +0 -0
  59. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/sarifio.py +0 -0
  60. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/score.py +0 -0
  61. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/snapshot.py +0 -0
  62. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/uncovered.py +0 -0
  63. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/universe.py +0 -0
  64. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/verify.py +0 -0
  65. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/watch.py +0 -0
  66. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit/worklist.py +0 -0
  67. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit.egg-info/SOURCES.txt +0 -0
  68. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit.egg-info/dependency_links.txt +0 -0
  69. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit.egg-info/entry_points.txt +0 -0
  70. {crapkit-0.4.0 → crapkit-0.4.2}/src/crapkit.egg-info/top_level.txt +0 -0
@@ -1,20 +1,26 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crapkit
3
- Version: 0.4.0
4
- Summary: Deterministic CRAP-score framework: per-function complexity x coverage risk, worklists, ratchets, refactor verification
3
+ Version: 0.4.2
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
7
- Project-URL: Repository, https://github.com/JeanFrancoisGagne/crapkit
8
- Keywords: crap,code-quality,complexity,coverage,ratchet,technical-debt
7
+ Project-URL: Homepage, https://github.com/JeanFrancoisGagne/crapkit
8
+ Project-URL: Documentation, https://jeanfrancoisgagne.github.io/crapkit/handbook.html
9
+ Project-URL: Changelog, https://github.com/JeanFrancoisGagne/crapkit/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/JeanFrancoisGagne/crapkit/issues
11
+ Project-URL: Source, https://github.com/JeanFrancoisGagne/crapkit
12
+ Keywords: complexity,coverage,crap,code-quality,technical-debt,cyclomatic,ratchet,pre-commit,claude-code
9
13
  Classifier: Development Status :: 4 - Beta
10
14
  Classifier: Environment :: Console
11
15
  Classifier: Intended Audience :: Developers
12
16
  Classifier: License :: OSI Approved :: MIT License
13
17
  Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
14
19
  Classifier: Programming Language :: Python :: 3.11
15
20
  Classifier: Programming Language :: Python :: 3.12
16
21
  Classifier: Programming Language :: Python :: 3.13
17
22
  Classifier: Topic :: Software Development :: Quality Assurance
23
+ Classifier: Topic :: Software Development :: Testing
18
24
  Requires-Python: >=3.11
19
25
  Description-Content-Type: text/markdown
20
26
  License-File: LICENSE
@@ -22,81 +28,460 @@ Requires-Dist: lizard>=1.24.0
22
28
  Provides-Extra: dev
23
29
  Requires-Dist: pytest>=8; extra == "dev"
24
30
  Requires-Dist: pytest-cov>=5; extra == "dev"
31
+ Requires-Dist: pytest-xdist>=3; extra == "dev"
25
32
  Dynamic: license-file
26
33
 
27
34
  # crapkit
28
35
 
36
+ [![ci](https://github.com/JeanFrancoisGagne/crapkit/actions/workflows/ci.yml/badge.svg)](https://github.com/JeanFrancoisGagne/crapkit/actions/workflows/ci.yml)
37
+ [![PyPI](https://img.shields.io/pypi/v/crapkit)](https://pypi.org/project/crapkit/)
38
+ [![Python](https://img.shields.io/pypi/pyversions/crapkit)](https://pypi.org/project/crapkit/)
39
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
40
+
29
41
  crapkit scores every function in your repo on complexity times uncovered risk, ranks the
30
42
  worst ones by how often the file changes, and blocks commits that add more. It reads
31
- TypeScript, TSX, JavaScript, Python, Swift, Go, Rust, shell, PowerShell, C and C++,
32
- Objective-C, Vue, Java and Zig through [lizard](https://github.com/terryyin/lizard),
33
- and joins per-function branch coverage from istanbul or coverage.py artifacts your own test
34
- command already produces. Those two parsers are the whole list, so Swift, Go, Rust, shell,
35
- PowerShell, C and C++, Objective-C, Java and Zig have no coverage to join: declare those
36
- scopes `coverage_optional` and they score on complexity alone. Vue joins istanbul coverage
37
- when your own vitest run reports on `.vue` files. Every read-side command speaks JSON with a
38
- pinned schema, because half the callers are coding agents.
43
+ Python, TypeScript, TSX, JavaScript, Swift, Go, Rust, shell, PowerShell, C and C++,
44
+ Objective-C, Vue, Java and Zig through [lizard](https://github.com/terryyin/lizard), and
45
+ joins per-function branch coverage from the istanbul or coverage.py artifact your own test
46
+ command already writes. Every read command speaks sorted-keys JSON on a pinned schema,
47
+ because half the callers are coding agents.
39
48
 
40
49
  ```
41
50
  CRAP = ccn^2 * (1 - cov)^3 + ccn
42
51
  ```
43
52
 
44
- `ccn` is the smaller of standard and modified cyclomatic complexity, both read off one lizard pass.
45
- `cov` is branch coverage inside the function's span; with no branches it falls back to
46
- statement coverage, and with no statements to invoked-or-not, so a half-executed
47
- straight-line function never reads as fully covered.
53
+ `ccn` is the smaller of standard and modified cyclomatic complexity, both read off one
54
+ lizard pass. `cov` is branch coverage inside the function's span; with no branches it
55
+ falls back to statement coverage, and with no statements to invoked-or-not, so a
56
+ half-executed straight-line function never reads as fully covered.
48
57
 
49
58
  **Above the ceiling, coverage cannot save you. Decompose.** At the default target of 6, a
50
- function at ccn 7 with 100% coverage still scores 7 and still fails the gate. The only move
51
- that clears it is splitting the function.
59
+ function at ccn 7 with 100% coverage still scores 7 and still fails the gate. The only
60
+ move that clears it is splitting the function.
52
61
 
53
- crapkit scores **git-tracked files only**. Source you have not `git add`ed is invisible to it.
62
+ crapkit scores **git-tracked files only**. Source you have not `git add`ed is invisible to
63
+ it.
54
64
 
55
65
  ---
56
66
 
67
+ ## The 60-second start
68
+
69
+ ```
70
+ pip install crapkit
71
+ cd your-repo
72
+ crapkit init # writes crapkit.toml: scopes, a coverage lane, .gitignore lines
73
+ crapkit coverage # runs the lane, joins coverage, stores a scored run
74
+ crapkit worklist # the ranked risk map
75
+ crapkit ratchet seed && git add crapkit.toml crapkit-ratchet.tsv .gitignore
76
+ ```
77
+
78
+ `coverage` scores, `worklist` ranks:
79
+
80
+ ```
81
+ $ crapkit coverage
82
+ run 1 @ fae4db93108: 2 functions scored — 2 measured / 0 untested / 0 no-lane / 0 cc-only, 1 over target 6, CRAP load 41.0, grade F
83
+
84
+ $ crapkit worklist
85
+ worklist @ fae4db93108 (run 1, floor ccn>=5, churn 12mo) — 1 active, 0 dormant
86
+ risk 0.0 ccn 14 ( 14 std) 1c/1a w 0.00 calc/grade.py:7 classify( score , attempts , late , bonus )
87
+ ```
88
+
89
+ `ratchet seed` signs today's debt at today's score. From then on marks only ever fall, so
90
+ the repo can get better and never worse while you burn it down.
91
+
92
+ **One thing stops most first runs: the coverage plugin.** `init` writes a lane that shells
93
+ out to your own test runner, and the runner needs its coverage package installed:
94
+ `pytest-cov` for pytest, `@vitest/coverage-v8` (pinned to your vitest major) for vitest.
95
+ Without it the lane produces no artifact and `coverage` exits 5 quoting the runner's own
96
+ error. The two quickstarts below walk a real repo end to end.
97
+
57
98
  ## Install
58
99
 
59
100
  ```
60
- pip install git+https://github.com/JeanFrancoisGagne/crapkit.git
101
+ pip install crapkit
61
102
  ```
62
103
 
63
- From a clone of this repo, run at the clone root:
104
+ That is the release on [PyPI](https://pypi.org/project/crapkit/). For the unreleased tip
105
+ of `main`, or from a local clone (run at the clone root):
64
106
 
65
107
  ```
108
+ pip install git+https://github.com/JeanFrancoisGagne/crapkit.git
66
109
  pip install .
67
110
  ```
68
111
 
69
- Either install pulls one dependency, `lizard`. The `pip install -e ".[dev]"` under
70
- [Development](#development) is a different thing: it adds the test extra and is for people
112
+ Every route pulls one dependency, `lizard>=1.24.0`, a normal PyPI wheel, so an offline
113
+ mirror installs fine. Requires Python 3.11 or newer. The `pip install -e ".[dev]"` under
114
+ [Development](#development) is a different thing: it adds the test extra, for people
71
115
  changing crapkit.
72
116
 
73
- Once the PyPI release lands, plain `pip install crapkit` will work too.
117
+ ```
118
+ $ crapkit --version
119
+ crapkit 0.4.2
120
+ ```
74
121
 
75
- Requires Python 3.11 or newer. The one runtime dependency is `lizard>=1.24.0`, which comes
76
- from PyPI as a normal wheel, so a PyPI-only or offline-mirror environment installs fine and
77
- no `git` binary is needed for the dependency itself.
122
+ `python -m crapkit` works identically to the console script and is what to use from a
123
+ source checkout. Every subcommand accepts `--repo PATH` (default: the current directory),
124
+ so you never have to `cd` into the repo you are scoring. The flag goes after the
125
+ subcommand; [Subcommands](#subcommands) shows both orders.
78
126
 
79
- Check the install:
127
+ ## The Claude Code plugin
80
128
 
81
129
  ```
82
- $ crapkit --version
83
- crapkit 0.4.0
130
+ claude plugin marketplace add JeanFrancoisGagne/crapkit
131
+ claude plugin install crapkit@crapkit
84
132
  ```
85
133
 
86
- `python -m crapkit` works identically to the `crapkit` console script, and is what to use
87
- from a source checkout. Every subcommand accepts `--repo PATH` (default: the current
88
- directory), so you never have to `cd` into the repo you are scoring. The flag goes after
89
- the subcommand; [Subcommands](#subcommands) shows both orders.
134
+ Two commands, installed once per user, and every repo on the machine gets it. The plugin
135
+ ships three skills (`crapkit`, `crapkit-recover`, `crapkit-onboard`), the read-only MCP
136
+ server, and one advisory PostToolUse hook that names any function an edit pushed over its
137
+ ceiling. It adds no files to your repo, and it needs the crapkit CLI on PATH.
90
138
 
91
- ---
139
+ A repo with no `crapkit.toml` costs a silent sub-50 ms no-op per edit. Other agent
140
+ runtimes have no marketplace: copy `plugin/skills/*` into their skills directory instead.
141
+
142
+ ## Languages
143
+
144
+ 14 languages, two coverage parsers. Coverage joins where a parser exists; everything else
145
+ scores on complexity alone.
146
+
147
+ | Language | Files | Coverage |
148
+ |---|---|---|
149
+ | Python | `.py` | coverage.py |
150
+ | TypeScript | `.ts` | istanbul |
151
+ | TSX | `.tsx` | istanbul |
152
+ | JavaScript | `.js` `.jsx` `.mjs` `.cjs` | istanbul |
153
+ | Vue | `.vue` | istanbul, when your vitest run reports on `.vue` files |
154
+ | Swift | `.swift` | none: cc-only |
155
+ | Go | `.go` | none: cc-only |
156
+ | Rust | `.rs` | none: cc-only |
157
+ | shell | `.sh` `.bash` | none: cc-only |
158
+ | PowerShell | `.ps1` `.psm1` | none: cc-only |
159
+ | C and C++ | `.c` `.cc` `.cpp` `.cxx` `.h` `.hpp` | none: cc-only |
160
+ | Objective-C | `.m` `.mm` | none: cc-only |
161
+ | Java | `.java` | none: cc-only |
162
+ | Zig | `.zig` | none: cc-only |
163
+
164
+ A cc-only scope declares `coverage_optional = true`, scores `crap = ccn`, and needs no
165
+ lane. Nothing about it is provisional: the ceiling still binds and the gate still refuses
166
+ a function over it. Add a coverage lane the day a parser exists and the same scope starts
167
+ joining coverage.
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
+
174
+ Three readers are crapkit's own. lizard ships none for shell or PowerShell, so crapkit
175
+ counts their functions itself. Its Rust reader scores a 7-arm `match` as ccn 2 (filed as
176
+ lizard #494), so crapkit counts each non-wildcard arm like a C `case` and retires the
177
+ override the day upstream fixes it. The cognitive column charges that same block once,
178
+ the way Sonar charges a `switch`.
179
+
180
+ ## The gate
181
+
182
+ Four surfaces ask the same question, ccn against the scope's ceiling, with four
183
+ different powers:
184
+
185
+ | Surface | Fires | Power |
186
+ |---|---|---|
187
+ | `crapkit claude-hook` | after an agent's edit lands | **advisory.** Names the breach on stderr. Blocks nothing, because PostToolUse runs after the write |
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 |
190
+ | `crapkit verify` | before you push, and in CI | **the verdict.** Gate, ratchet, new test failures, diff coverage, against the trusted baseline |
191
+
192
+ Both hooks exempt a function the committed ratchet already carries a mark for, so touching
193
+ signed debt never refuses a commit. `verify` is what fails a mark that rises. The
194
+ pre-commit hook reports each exemption count on stderr (`staged function(s) carry a
195
+ ratchet mark and were not gated`), and says the same about a staged file no `[[scope]]`
196
+ claims, so a new top-level directory cannot go ungated in silence.
197
+
198
+ Git runs hooks outside your shell's activated venv. Bare `python` must resolve to an
199
+ interpreter that has crapkit installed, or spell it out
200
+ (`exec /path/to/venv/Scripts/python -m crapkit hook-precommit`).
201
+
202
+ ### Route 1: `.git/hooks/pre-commit` (local, not committed)
203
+
204
+ ```sh
205
+ cat > .git/hooks/pre-commit <<'EOF'
206
+ #!/bin/sh
207
+ exec python -m crapkit hook-precommit
208
+ EOF
209
+ chmod +x .git/hooks/pre-commit
210
+ ```
211
+
212
+ ### Route 2: a committed hooks directory
213
+
214
+ The whole route, from a repo that has no `githooks/` yet:
215
+
216
+ ```sh
217
+ mkdir -p githooks
218
+ cat > githooks/pre-commit <<'EOF'
219
+ #!/bin/sh
220
+ exec python -m crapkit hook-precommit
221
+ EOF
222
+ chmod +x githooks/pre-commit
223
+ printf 'githooks/pre-commit text eol=lf\n' >> .gitattributes
224
+ git add .gitattributes githooks/pre-commit
225
+ git update-index --chmod=+x githooks/pre-commit
226
+ git commit -m "add crapkit gate hook"
227
+ git config core.hooksPath githooks
228
+ ```
229
+
230
+ **The `--chmod` goes between the `add` and the `commit`.** It writes the executable bit to
231
+ the index, so a commit that already happened does not carry it: run it after and `git
232
+ ls-tree HEAD` still says `100644`, which is a hook Unix checkouts silently skip. The
233
+ `.gitattributes` line is the harder half of the same failure: under Windows' default
234
+ `core.autocrlf` the hook checks out CRLF and `#!/bin/sh\r` dies on Linux and macOS with a
235
+ bad-interpreter error. `crapkit doctor` warns when a file under `core.hooksPath` is not
236
+ `100755` in the index and prints the `update-index` line for it.
237
+
238
+ Git will not read a hooks path out of a committed file, so that `git config` line belongs
239
+ in your CONTRIBUTING setup steps. Every clone arms the gate with it.
240
+
241
+ ### Route 3: the pre-commit framework
242
+
243
+ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
244
+ `.pre-commit-config.yaml`:
245
+
246
+ ```yaml
247
+ repos:
248
+ - repo: https://github.com/JeanFrancoisGagne/crapkit
249
+ # crapkit's release step rewrites this line to the tag it just cut
250
+ rev: v0.4.2
251
+ hooks:
252
+ - id: crapkit-gate
253
+ ```
254
+
255
+ `rev` is a git ref pre-commit resolves against that remote. Pin a release tag, not a
256
+ branch: `pre-commit autoupdate` only moves between tags, and a moving `main` would change
257
+ your gate under you.
258
+
259
+ ### Route 4: CI
260
+
261
+ A CI job runs on a fresh clone, which has no `.crapkit/` store, so bare `crapkit verify`
262
+ exits 1. Running `coverage` first would make the PR's own tree the baseline, a gate that
263
+ can never fail. The portable baseline is the mechanism:
264
+
265
+ ```
266
+ # on the default branch, after a passing verify: commit this file
267
+ crapkit verify --emit-baseline crapkit-baseline.tsv
268
+
269
+ # in the PR job, against the committed baseline
270
+ crapkit verify --baseline-tsv crapkit-baseline.tsv --github
271
+ ```
272
+
273
+ `--github` emits `::error file=...` annotations that land on the PR diff; `--sarif PATH`
274
+ writes SARIF 2.1.0 for code-scanning upload. Refresh the committed baseline whenever the
275
+ default branch's verify passes.
276
+
277
+ ### What a refusal looks like
278
+
279
+ ```
280
+ $ git commit -m "add route"
281
+ crapkit gate: 1 staged function(s) exceed the complexity ceiling of 6:
282
+ ccn 7 app/m.py:9 route( a , b , c , d )
283
+ decompose before committing (coverage cannot save a function above the target).
284
+ ```
285
+
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.
289
+
290
+ `CRAPKIT_OVERRIDE_REASON` is not a bypass. Setting it routes the commit through the full
291
+ three-record audit: an alert line through `alert_command`, a ratchet entry staged into the
292
+ commit, and a row in the override log. All three land or nothing does, and an unset
293
+ `alert_command` refuses the override outright. See
294
+ [docs/ratchet.md](docs/ratchet.md#overrides-and-the-audit-trail).
295
+
296
+ ## Subcommands
297
+
298
+ Every subcommand takes `--repo PATH` (default `.`), and the flag goes **after** the
299
+ subcommand. `claude-hook` is the one exception: it has no `--repo`, because it takes its
300
+ root from the file named in the hook payload it reads.
301
+
302
+ ```
303
+ $ crapkit worklist --repo /path/to/repo --scope util --top 1
304
+ worklist @ a7c5c85ac37 (run 1, floor ccn>=5, churn 12mo) — 1 active, 0 dormant
305
+ risk 5.4 ccn 5 ( 5 std) 5c/1a w 1.08 util/stats.py:1 bucket( value , low , high )
306
+ ```
307
+
308
+ Before it, argparse reads the path as the subcommand name and exits 2 without ever
309
+ mentioning `--repo`:
310
+
311
+ ```
312
+ $ crapkit --repo /path/to/repo worklist --top 1
313
+ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from 'inventory', 'coverage', ...)
314
+ ```
315
+
316
+ `--json` prints one sorted-keys JSON object on stdout, always carrying a `schema` field.
317
+
318
+ | Command | What it does |
319
+ |---|---|
320
+ | `init` | Sniffs tracked source into per-directory scopes, writes a self-validated starter `crapkit.toml` whose lanes report into `.crapkit/cov/`, and appends `.crapkit/` plus each runner's own droppings to `.gitignore`. Writes a live `[[lane]]` when it can detect the test runner, otherwise a commented template. Refuses to clobber an existing config. |
321
+ | `doctor [--show-files] [--json] [--tune] [--plugin-root PATH]` | Checks the config still describes the repo: unknown keys (with the accepted spellings), zero-file scopes, tracked source no scope claims, scopes no lane covers, lane cwds and commands that no longer resolve, lizard importable, oversized files. It WARNs on a lane writing its artifact at the repo root, a committed hook under `core.hooksPath` that is not executable in the index, a directory whose functions are all `untested` while its tests exist, and a scope a lane measures with no `[crapkit.scoped_tests]` template behind it, which is the loop's step 4 with nothing to run. `--tune` prints suggested parallelism knobs and writes nothing. `--plugin-root PATH` reads no repo at all: it checks an installed [plugin](plugin/) against this CLI on both version and hook `--protocol`, one line per disagreement and silence when they agree. See [docs/agent-json.md](docs/agent-json.md#doctor---json). |
322
+ | `inventory [--db PATH] [--export PATH] [--json]` | One lizard pass over every in-scope file into a SQLite snapshot run, cached by content hash. `--db` is the only way to point crapkit at a store outside `.crapkit/`, and only this command accepts it. |
323
+ | `coverage [--lane NAME] [--reuse-artifacts] [--reuse-unchanged] [--export PATH] [--sarif PATH] [--github] [--json]` | Runs the lanes, joins branch coverage onto a fresh inventory, writes a scored run. A failed lane is recorded, not fatal: its scopes fall back to `no-lane` and the run is typed `partial`, so it can never serve as a baseline. See [docs/lanes.md](docs/lanes.md). |
324
+ | `verify [--baseline ID \| --base REF \| --baseline-tsv PATH] [--emit-baseline PATH] [--override REASON] [--reuse-artifacts] [--reuse-unchanged] [--sarif PATH] [--github] [--json]` | The full verdict against the trusted baseline: gate on touched functions, ratchet, no new test failures, optional diff-coverage ceiling. The three baseline selectors are mutually exclusive; `--baseline ID` also bypasses the taint rule ([The trusted baseline](#the-trusted-baseline)), and `--baseline-tsv` reads a commit-stamped file so a fresh clone verifies with no store. Findings a dirty tree produced are tagged `dirty` and counted apart. |
325
+ | `worklist [--top N] [--scope NAME] [--batches N] [--json]` | The risk map: every admitted function ranked by `ccn * churn weight`, floored by `worklist_floor`, with hot simple code and anything over its ceiling admitted past that floor. It ranks finished rows and `no-lane` rows too, marked `ok` and `no-lane`, so it never empties; `next-item` carries the stop condition. `--scope NAME` (repeatable) is exact, not a substring. `--batches N` **adds** a `batches[]` view cutting the active list into at most N file-disjoint batches with co-changing files kept together; the normal keys stay. |
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. |
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. |
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. |
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. |
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. |
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). |
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. |
333
+ | `overrides [--json]` | The override audit trail: who granted what, when, and why. |
334
+ | `trend [--json]` | Totals per trusted run: functions, over-target count, CRAP load, average, per-scope rollup. |
335
+ | `digest [--alert]` | The delta between the two newest runs with identical lane sets. Silent when nothing changed. `--alert` pipes the body to `alert_command` on stdin. Plain lines, never JSON. |
336
+ | `report [--out PATH]` | One self-contained HTML page written to `.crapkit/report.html` (or `--out PATH`, repo-relative), with the path printed on stdout. It renders what `worklist --json` and `trend --json` already answer at their defaults: the ranked worklist capped at `worklist_top`, the per-scope grades off the newest run, the trend series, and a banner naming every stale lane. It measures nothing and opens no network connection. Per-function CRAP and coverage are absent because no repo-wide payload carries them; each row prints the `crapkit explain` call that does. |
337
+ | `duplication [--min-lines N] [--similarity F] [--top N] [--json]` | Near-duplicate functions by normalized line shingles with containment scoring. Defaults: `--min-lines 8`, `--similarity 0.8`, `--top 50`. `--top` truncates the list. |
338
+ | `coupling [--min-support N] [--min-confidence F] [--top N] [--json]` | File pairs that keep landing in the same commits. Defaults: `--min-support 5` shared commits, `--min-confidence 0.5` max-direction ratio, `--top 50`. Bulk commits never couple pairs, and a young repo returns nothing at the default support. |
339
+ | `mutate [--files F ...] [--max-mutants N] [--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. |
340
+ | `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. |
341
+ | `hook-precommit` | The cc-only gate on staged blobs. No coverage, no snapshot, no repo-wide cache. Exit 6 on a violation. |
342
+ | `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. |
343
+ | `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. |
344
+ | `mcp` | A dependency-free stdio MCP server (newline JSON-RPC 2.0) exposing nine read-only tools. See [docs/agent-json.md](docs/agent-json.md#mcp-server). |
345
+
346
+ ## Reading the output
347
+
348
+ ### Flags: why a coverage number is missing
349
+
350
+ | Flag | Meaning | Scored |
351
+ |---|---|---|
352
+ | `measured` | A lane artifact spoke about this function. | Real `cov`. |
353
+ | `untested` | A lane covers the scope, but its artifact is silent on this function, which normally means no test imports the file. | `cov = 0`. A testing gap, and `uncovered_lines` comes back `null` because no artifact can name lines it never saw. |
354
+ | `no-lane` | No lane's `scopes` list names this function's scope. | `cov = 0`. A tooling gap, not a testing gap. `next-item` never hands one out and counts them in `skipped_no_lane`; `worklist` ranks them and marks the row `no-lane`, because a wiring gap is a risk you have to see. |
355
+ | `cc-only` | The scope sets `coverage_optional = true`, so no coverage number can exist. | `crap = ccn`, and `remedy` can only be `ok` or `decompose`. `uncovered_lines` comes back `null` with a note naming that setting. |
356
+
357
+ The coverage summary counts all four as `measured` / `untested` / `no_lane` / `cc_only`.
358
+
359
+ ### Remedy: what to do about it
360
+
361
+ | Remedy | Condition | Action |
362
+ |---|---|---|
363
+ | `decompose` | `ccn > ceiling` | Split it. No amount of coverage clears this. |
364
+ | `add-tests` | `ccn <= ceiling` and `crap > ceiling` | Cover the branches. |
365
+ | `ok` | `crap <= ceiling` | Nothing. |
366
+
367
+ ### Grade and CRAP load
368
+
369
+ The grade is the share of functions over their ceiling: `A+` at exactly zero, `A` under
370
+ 2%, `B` under 5%, `C` under 10%, `D` under 20%, `F` at 20% or more. `crap_load` beside it
371
+ is the plain sum of every function's CRAP score, so it moves when a function gets better
372
+ even if the letter does not.
373
+
374
+ ### Risk: what ranks the worklist
375
+
376
+ `risk = ccn * churn weight`. The weight is a time-weighted sum over the file's commits in
377
+ the churn window: each commit contributes a logistic weight rising to 0.5 for the newest
378
+ commit in the log and falling to near zero for the oldest, so five edits last month
379
+ outrank fifty from two years ago. The window anchors on the newest commit, never on the
380
+ wall clock, so a fixed tree ranks identically forever.
381
+
382
+ Age is not the input, position in the log is. A log whose commits all share one timestamp
383
+ reads 0.0 everywhere, which is why a one-commit repo shows `risk 0.0` on every row and
384
+ falls back to ccn order. Commits minutes apart already rank. This repo was eight commits
385
+ old, all made the same day:
386
+
387
+ ```
388
+ $ crapkit worklist --scope util
389
+ worklist @ a7c5c85ac37 (run 1, floor ccn>=5, churn 12mo) — 3 active, 0 dormant
390
+ risk 5.4 ccn 5 ( 5 std) 5c/1a w 1.08 util/stats.py:1 bucket( value , low , high )
391
+ risk 4.5 ccn 9 ( 9 std) 1c/1a w 0.50 util/curve.py:1 curve( scores , mode , floor , ceiling , skip_none )
392
+ risk 4.3 ccn 4 ( 4 std) 5c/1a w 1.08 util/stats.py:13 spread( values , cap ) ok
393
+ ```
394
+
395
+ `bucket` at ccn 5 outranks `curve` at ccn 9 because five commits touched it and one
396
+ touched `curve`. That is the whole point of weighting by churn. `spread` carries the `ok`
397
+ marker: already at or under its ceiling, listed anyway, and `next-item` would not hand it
398
+ out.
399
+
400
+ The list splits in two: **active** (files with commits in the window) and **dormant**
401
+ (zero churn, kept out of the queue but counted). Two rules reach under the
402
+ `worklist_floor`. A file whose churn weight sits in the top 10% is promoted down to ccn 3,
403
+ which is why `spread` appears above at ccn 4. And a function over its ceiling is admitted
404
+ whatever its ccn, so the floor can never hold back debt.
405
+
406
+ ### The trusted baseline
407
+
408
+ Every `verify` measures the working tree against one earlier run, the **trusted
409
+ baseline**. `crapkit runs list` marks which one that is today.
410
+
411
+ **Which runs qualify.** A `coverage` run, or a `verify` that passed. A failed `verify`
412
+ never qualifies, and neither does a `partial` run (a lane failed, so some scope fell back
413
+ to `no-lane`) nor a `hook` override record, which carries no scored rows at all. In `runs
414
+ list`, `verdict=-` marks a run that produces no verdict rather than one that failed: only
415
+ `verify` renders a verdict.
416
+
417
+ **What advances it.** Any qualifying run. `coverage` writes one wherever HEAD is, so a
418
+ dashboard cron advances the baseline exactly as CI does. A passing `verify` advances it
419
+ and tightens the ratchet on the way.
420
+
421
+ **The taint rule.** A failed `verify` recorded findings against a tree. Until some
422
+ `verify` passes, runs made after that failure do not become the baseline: choosing one
423
+ would move the comparison point past the findings, the flagged function would stop
424
+ counting as touched, and nothing would look at it again. `verify` says which run it
425
+ refused and falls back to the newest run in front of the failure.
426
+
427
+ ```
428
+ $ crapkit runs list
429
+ run 1 @ 88012a148f6 2026-08-23T09:27:46Z coverage verdict=- lanes=py baseline
430
+ run 2 @ 803bdde8556 2026-08-23T09:27:53Z verify verdict=FAILED lanes=py
431
+ run 3 @ 803bdde8556 2026-08-23T09:28:02Z coverage verdict=- lanes=py
432
+
433
+ $ crapkit verify
434
+ warning: run 3 is not the baseline: verify run 2 FAILED with 1 finding(s) and no passing verify has cleared it since — measuring against run 1 @ 88012a148f6 instead, so those findings stay visible. Fix them, or pass `--baseline 3` to accept the newer run deliberately.
435
+ verify FAILED @ d89068de7f3 vs baseline 88012a148f6 (2 changed files)
436
+ GATE crap 72.0 ccn 8 cov 0% calc/legacy.py:7 legacy_router( a , b , c , d , e ) -> decompose
437
+ findings: 1 committed / 0 dirty (uncommitted tracked edits)
438
+ ```
439
+
440
+ Run 3 is a `coverage` run somebody took on the tree run 2 refused, and it scores the same
441
+ ccn-8 function. Without the rule it would have become the baseline, `legacy_router` would
442
+ have stopped being a touched function, and that gate line would never print again.
443
+
444
+ **The escape, twice.** Fix the findings and let a `verify` pass, which clears the taint
445
+ for good. Or accept the newer run on purpose with `verify --baseline 3`: an explicit id
446
+ bypasses the rule, and the run history records which run the verdict used. Nothing here
447
+ touches a repo that has never run `verify`: with no failure to protect, `coverage` alone
448
+ always advances the baseline.
449
+
450
+ ## Exit codes
451
+
452
+ | Code | Meaning |
453
+ |---|---|
454
+ | 0 | OK. For `verify` and `hook-precommit`: the gate passed. |
455
+ | 1 | **Overloaded.** Three unrelated things, listed below the table. |
456
+ | 2 | Usage error from argparse: unknown flag, missing positional. Raised before crapkit's own error handling. |
457
+ | 3 | Config error: `crapkit.toml` missing or unparseable, an unknown language or parser, a ratchet metric-stamp mismatch, a `test-scoped` file under no scope or under a scope with no template. |
458
+ | 4 | Git error: not a repository, a baseline commit rewritten out of the history. |
459
+ | 5 | Tool error: lizard not importable, a lane produced no artifact, a lane timed out past its retries, an override alert command failed. |
460
+ | 6 | Gate violation. A function the diff touched is over its ceiling, or `rescore --gate` found one, or `hook-precommit` did. |
461
+ | 7 | Ratchet regression. A marked function scores worse than its recorded high-water mark, touched or not. |
462
+ | 8 | New test failures against the baseline run. Failures the baseline already had do not count. |
463
+ | 9 | Diff-coverage ceiling breached: `diff_uncovered_max` is set and more changed lines than that never ran. |
464
+
465
+ ### Exit 1 means one of three things
466
+
467
+ CI cannot tell a crash from a clean policy verdict on the code alone. Which one you got
468
+ depends on the command:
469
+
470
+ | Command | What exit 1 means |
471
+ |---|---|
472
+ | `doctor` | A **`FAIL` finding**. This is a verdict, not a crash. A `WARN` (an unmeasured directory, or a lane writing its artifact at the repo root) and a `note` (a file over `max_file_bytes`, or no lanes declared) both exit 0. |
473
+ | `ratchet report --enforce` | The **debt policy was breached**. Also a verdict. |
474
+ | anything else | An unexpected error: "no snapshot yet, run `crapkit coverage` first", a `brief` name that matches no function, a `test-scoped` runner that exited non-zero. |
475
+
476
+ `verify` reports the **first** of 6, 7, 8, 9 that fires, in that order. A gate violation
477
+ and a ratchet regression together report 6. A run that takes any of them fails, so it
478
+ neither advances the baseline nor tightens the ratchet, exit 9 included.
92
479
 
93
480
  ## Quickstart: Python
94
481
 
95
482
  A repo with `calc/grade.py`, `tests/test_grade.py`, and a `pyproject.toml`. Commit first;
96
- crapkit reads `git ls-files`.
97
-
98
- **This is the step that stops most Python users:** the lane `crapkit init` writes runs
99
- `pytest --cov`, and the `--cov` flags come from the `pytest-cov` package. Install it first:
483
+ crapkit reads `git ls-files`. Install the coverage plugin first, because the lane `init`
484
+ writes runs `pytest --cov` and those flags come from `pytest-cov`:
100
485
 
101
486
  ```
102
487
  pip install pytest-cov
@@ -115,14 +500,9 @@ added to .gitignore: .crapkit/, .coverage, __pycache__/
115
500
  coverage lane from what the repo already has: a pytest marker file (`pyproject.toml`,
116
501
  `pytest.ini`, `setup.cfg`) writes a live `[[lane]]`, and so does a `test` script or
117
502
  `vitest`/`jest` in `package.json`. Whatever it detects, it also leaves commented templates
118
- for the runners it did not find.
119
-
120
- Every lane it writes reports into `.crapkit/cov/`, so the only `.gitignore` lines it needs
121
- are `.crapkit/` and what the runner drops elsewhere in the tree (a pytest lane's
122
- `.coverage` and `__pycache__/`). Point the JUnit report and any lane you add by hand at
123
- `.crapkit/` too: see [Where artifacts live](docs/lanes.md#where-artifacts-live).
124
-
125
- The generated `crapkit.toml`:
503
+ for the runners it did not find. Every lane it writes reports into `.crapkit/cov/`, which
504
+ is why the `.gitignore` list is so short: see
505
+ [Where artifacts live](docs/lanes.md#where-artifacts-live).
126
506
 
127
507
  ```toml
128
508
  [crapkit]
@@ -157,13 +537,10 @@ scopes = ["calc"]
157
537
  calc = "python -m pytest {files} -q -p no:cacheprovider"
158
538
  ```
159
539
 
160
- The last block is the one an agent loop needs: `crapkit test-scoped` exits 3 for a file
540
+ The last block is the one an agent loop needs. `crapkit test-scoped` exits 3 for a file
161
541
  whose scope declares no template, and [AGENTS.md](AGENTS.md#4-run-the-owning-scopes-tests)
162
- makes it step 4 of the burn-down loop. init writes the entry live when it detected the
163
- runner (a pytest marker proves the python command); a scope whose runner init could not
164
- confirm gets a commented template to fill in, and `doctor` warns while it stays empty.
165
-
166
- Every key is in [docs/configuration.md](docs/configuration.md).
542
+ makes it step 4 of the burn-down loop. Every key is in
543
+ [docs/configuration.md](docs/configuration.md).
167
544
 
168
545
  ### 2. Check the config against the repo
169
546
 
@@ -177,61 +554,45 @@ ok lizard 1.24.0
177
554
  doctor: no problems found
178
555
  ```
179
556
 
180
- `doctor` exits 1 only on a `FAIL` line. `WARN` and `note` report and exit 0.
557
+ `doctor` prints one line per check and exits 1 only on a `FAIL`. `WARN` and `note` report
558
+ and exit 0.
181
559
 
182
- ### 3. Score the repo
560
+ ### 3. Score the repo, and read the queue
183
561
 
184
562
  ```
185
563
  $ crapkit coverage
186
564
  run 1 @ fae4db93108: 2 functions scored — 2 measured / 0 untested / 0 no-lane / 0 cc-only, 1 over target 6, CRAP load 41.0, grade F
187
- ```
188
565
 
189
- `coverage` runs the lane commands, parses their artifacts, joins coverage onto a fresh
190
- complexity inventory, and writes a scored run into `.crapkit/crap.sqlite`.
191
-
192
- ### 4. Read the queue
193
-
194
- ```
195
566
  $ crapkit worklist
196
567
  worklist @ fae4db93108 (run 1, floor ccn>=5, churn 12mo) — 1 active, 0 dormant
197
568
  risk 0.0 ccn 14 ( 14 std) 1c/1a w 0.00 calc/grade.py:7 classify( score , attempts , late , bonus )
198
569
  ```
199
570
 
200
- Columns: `risk`, then `ccn` with the standard-only ccn in parentheses, then
201
- `<commits>c/<authors>a` in the churn window and `w<weight>`, then `path:line` and the
202
- function's long name, and last a marker on rows the burn-down queue will not hand out:
203
- `ok` for a function already at or under its ceiling, `no-lane` for one no lane measures.
204
-
205
- `floor ccn>=5` in the header orders the list; it never withholds debt. A function whose
206
- CRAP is over its ceiling is listed whatever its ccn, so an empty `worklist` on a repo
207
- `coverage` just graded `F` is not a thing crapkit can print.
571
+ Columns: `risk`, `ccn` with the standard-only ccn in parentheses,
572
+ `<commits>c/<authors>a` in the churn window with `w<weight>`, `path:line`, the function's
573
+ long name, then a marker on rows the burn-down queue will not hand out (`ok`, `no-lane`).
208
574
 
209
575
  **`worklist` is the risk map, not a to-do list.** It ranks every function it admits,
210
- finished ones included, so it does not empty when the burn-down finishes — the `ok`
211
- markers are what a done repo looks like here. `next-item` is the other view: same run,
212
- same admission floor, but it drops the `no-lane` rows and ranks by `crap` descending
213
- instead of by risk. Its `empty: true` is the stop condition; the worklist has none.
576
+ finished ones included, so it does not empty when the burn-down finishes. `next-item` is
577
+ the other view: same run, same admission floor, but it drops the `no-lane` rows and ranks
578
+ by `crap` descending instead of by risk. Its `empty: true` is the stop condition; the
579
+ worklist has none.
214
580
 
215
- **Every risk is 0.0 here because this repo has one commit**: the churn
216
- weight is recency-weighted against the log's own span, and a log with no span has no
217
- recency to weight. Ranking then falls back to ccn order. It takes a second commit at a
218
- different second to end that, not days of history: see
219
- [Risk](#risk-what-ranks-the-worklist).
220
-
221
- ### 5. Take the top item
581
+ ### 4. Take the top item
222
582
 
223
583
  ```
224
584
  $ crapkit next-item
225
- {"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}
226
586
  ```
227
587
 
228
- `remedy: "decompose"`, `est_splits: 3` (this needs roughly three pieces to fit under 6), and
229
- `uncovered_lines` naming the ten lines no test walks. Every field is in
588
+ `remedy: "decompose"`, `est_splits: 3` (this needs roughly three pieces to fit under 6),
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
230
591
  [docs/agent-json.md](docs/agent-json.md).
231
592
 
232
- ### 6. Seed the ratchet
593
+ ### 5. Seed the ratchet
233
594
 
234
- Arm the debt gate before fixing anything: `ratchet seed` records every over-target
595
+ Arm the debt gate before fixing anything. `ratchet seed` records every over-target
235
596
  function at its current score, and from then on nothing may get worse.
236
597
 
237
598
  ```
@@ -241,47 +602,31 @@ crapkit-ratchet.tsv: added 1, tightened 0 — 1 mark(s) vs run 1 (fae4db93108)
241
602
  $ git add crapkit.toml crapkit-ratchet.tsv .gitignore && git commit -m "adopt crapkit"
242
603
  ```
243
604
 
244
- ### 7. Fix it and verify
605
+ ### 6. Fix it and verify
245
606
 
246
607
  Extract until every piece sits at or under the ceiling. Here `classify` became
247
608
  `_validate`, `_adjusted`, `_band` and a `classify` that only sequences them, with the
248
609
  table of cases pushed into parametrized tests. Commit the fix, then:
249
610
 
250
611
  ```
251
- $ crapkit verify
252
- verify OK @ 8d10c13303d vs baseline fae4db93108 (5 changed files)
253
- ```
254
-
255
- A passing verify tightens `crapkit-ratchet.tsv` in place (the repaid mark leaves the file),
256
- so follow up with `git commit -am "ratchet: classify repaid"` — or amend, if the fix commit
257
- is still unpushed. The full mark lifecycle is in [docs/ratchet.md](docs/ratchet.md).
258
-
259
- `verify` reruns the lanes and checks three things against the trusted baseline: every
260
- function the diff touched sits at or under its ceiling, no marked function got worse, and no
261
- test that passed in the baseline fails now. Exit 0 advances the baseline and tightens the
262
- ratchet.
263
-
264
- ```
612
+ $ crapkit verify
613
+ verify OK @ 8d10c13303d vs baseline fae4db93108 (5 changed files)
614
+
265
615
  $ crapkit coverage
266
616
  run 3 @ 8d10c13303d: 5 functions scored — 5 measured / 0 untested / 0 no-lane / 0 cc-only, 0 over target 6, CRAP load 19.0, grade A+
267
617
  ```
268
618
 
269
- CRAP load 41.0 to 19.0, grade F to A+, and the queue is empty:
270
-
271
- ```
272
- $ crapkit next-item
273
- {"commit": "8d10c13303dfd9ef4172d9f736582ff4ffa96e60", "empty": true, "reasons": {"all_remaining_at_or_under_target": 4, "below_floor": 1, "churn_window_months": 12, "excluded_by_flag": 0, "no_churn_in_window": 0, "no_lane": 0, "no_lane_over_target": 0}, "run_id": 3, "schema": 1, "skipped_no_lane": 0}
274
- ```
275
-
276
- `empty: true` is most of the stop condition: nothing the queue admits is over target, and
277
- `all_remaining_at_or_under_target: 4` says the queue emptied because the work is done
278
- rather than because a filter ate it. Two siblings finish the rule.
279
- `no_lane_over_target: 0` says no scope is holding debt no lane measures, and
280
- `skipped_claimed` is absent, which is how the payload says no claim hid a row.
281
- [AGENTS.md](AGENTS.md#the-termination-rule) states the whole condition and reads the rest
282
- of `reasons`.
619
+ CRAP load 41.0 to 19.0, grade F to A+. `verify` reruns the lanes and checks three things
620
+ against the trusted baseline: every function the diff touched sits at or under its
621
+ ceiling, no marked function got worse, and no test that passed in the baseline fails now.
622
+ Exit 0 advances the baseline and tightens `crapkit-ratchet.tsv` in place, so the repaid
623
+ mark leaves the file: follow up with `git commit -am "ratchet: classify repaid"`. The full
624
+ mark lifecycle is in [docs/ratchet.md](docs/ratchet.md).
283
625
 
284
- ---
626
+ `crapkit next-item` now comes back `empty: true` with a `reasons` object saying which
627
+ ending you got. That is most of the stop condition, not all of it:
628
+ [AGENTS.md](AGENTS.md#the-termination-rule) states the whole rule and reads the rest of
629
+ `reasons`.
285
630
 
286
631
  ## Quickstart: TypeScript
287
632
 
@@ -305,45 +650,43 @@ pytest, monorepo and per-package recipes.
305
650
 
306
651
  ### 2. Install a coverage provider
307
652
 
308
- **This is the step that stops most TypeScript users.** vitest ships no coverage provider by
309
- default. Without one, `init` and `doctor` are both happy and `coverage` dies:
653
+ **This is the step that stops most TypeScript users.** vitest ships no coverage provider
654
+ by default. Without one, `init` and `doctor` are both happy and `coverage` dies with
655
+ exit 5:
310
656
 
311
657
  ```
312
658
  $ crapkit coverage
313
659
  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
314
660
 
315
- > tsproj@0.1.0 test
316
- > vitest run --coverage --coverage.reportsDirectory=.crapkit/cov/js
317
-
318
661
  MISSING DEPENDENCY Cannot find dependency '@vitest/coverage-v8'
319
662
 
320
663
  (exit 1)
321
664
  crapkit: every lane failed: ...
322
665
  ```
323
666
 
324
- Exit 5. Install the provider, pinned to your vitest major or npm refuses the peer
325
- dependency (on vitest 2: `npm i -D "@vitest/coverage-v8@2"`):
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:
326
672
 
327
673
  ```
328
- npm i -D @vitest/coverage-v8
674
+ npm i -D "@vitest/coverage-v8@<your vitest major>"
329
675
  ```
330
676
 
331
- Three things about that package:
332
-
333
677
  | Question | Answer |
334
678
  |---|---|
335
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. |
336
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. |
337
- | 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. |
338
682
 
339
683
  The artifact crapkit wants is `coverage-final.json`, written by vitest's `json` coverage
340
684
  reporter, which is on by default. If your vitest config sets `coverage.reporter`
341
- explicitly, keep `"json"` in the list. The lane's `--coverage.reportsDirectory` flag
342
- decides where the report lands, and `init` points it at `.crapkit/cov/js/`.
685
+ explicitly, keep `"json"` in the list.
343
686
 
344
687
  One more vitest default worth flipping now: it writes **no coverage report at all when the
345
688
  run fails**, so a single red test becomes a missing artifact and a lane failure. Set
346
- `coverage.reportOnFailure = true`. Details and the full config block are in
689
+ `coverage.reportOnFailure = true`. The full config block is in
347
690
  [docs/lanes.md](docs/lanes.md#reportonfailure).
348
691
 
349
692
  ### 3. Score the repo
@@ -362,8 +705,8 @@ penalty, the letter bands, the demotion rule and the null case.
362
705
 
363
706
  ### 4. Seed the ratchet and commit
364
707
 
365
- `ratchet seed` records every over-target function at the score it has today, so nothing can
366
- get worse while you burn this one down.
708
+ `ratchet seed` records every over-target function at the score it has today, so nothing
709
+ can get worse while you burn this one down.
367
710
 
368
711
  ```
369
712
  $ crapkit ratchet seed
@@ -415,15 +758,8 @@ three functions no test has ever called, so each gets a table test:
415
758
 
416
759
  ```ts
417
760
  describe("band", () => {
418
- it.each([
419
- [95, "A"],
420
- [85, "B"],
421
- [75, "C"],
422
- [65, "D"],
423
- [10, "F"],
424
- ])("scores %i as %s", (score, expected) => {
425
- expect(band(score)).toBe(expected);
426
- });
761
+ it.each([[95, "A"], [85, "B"], [75, "C"], [65, "D"], [10, "F"]])(
762
+ "scores %i as %s", (score, expected) => expect(band(score)).toBe(expected));
427
763
  });
428
764
  ```
429
765
 
@@ -431,15 +767,13 @@ Run the suite once before the slow step:
431
767
 
432
768
  ```
433
769
  $ npx vitest run
434
- ✓ test/grade.test.ts (21 tests) 2ms
435
-
436
770
  Test Files 1 passed (1)
437
771
  Tests 21 passed (21)
438
772
  ```
439
773
 
440
774
  Skip this step and step 7 fails rather than passes. Run on a copy of this repo with step 6
441
- left out, `verify` reruns the lanes against the real tree and three functions the old suite
442
- never called come back over the ceiling:
775
+ left out, `verify` reruns the lanes against the real tree and three functions the old
776
+ suite never called come back over the ceiling:
443
777
 
444
778
  ```
445
779
  $ crapkit verify
@@ -461,336 +795,24 @@ run 3 @ 2af3433d979: 5 functions scored — 5 measured / 0 untested / 0 no-lane
461
795
 
462
796
  CRAP load 56.68 to 22.0, grade F to A+, and the mark seeded in step 4 is gone: `verify`
463
797
  dropped it once `classify` scored under the ceiling, rewriting the tracked
464
- `crapkit-ratchet.tsv` in place — commit it with your change. Marks only ever fall.
798
+ `crapkit-ratchet.tsv` in place. Commit it with your change. Marks only ever fall.
465
799
 
466
800
  A verify may also print `warning: N changed line(s) have no coverage` above its verdict;
467
801
  that block is advisory unless `diff_uncovered_max` is set
468
802
  ([docs/configuration.md](docs/configuration.md)).
469
803
 
470
- ---
471
-
472
- ## Installing the gate
473
-
474
- `crapkit hook-precommit` reads every staged blob in one `git cat-file --batch`, analyzes it
475
- without touching the repo-wide cache, and refuses the commit when a staged function exceeds
476
- its scope ceiling. It needs no coverage data and no snapshot, so it costs the size of the
477
- commit, not the size of the repo.
478
-
479
- Three limits to know. The gate judges files a `[[scope]]` claims; a staged source file no
480
- scope claims is not gated, and the hook says so on stderr (`N staged file(s) belong to no
481
- scope and were not gated`) so the hole is visible the moment a new top-level directory
482
- appears. A function the committed ratchet already carries a mark for is not gated either,
483
- so touching signed debt does not refuse the commit; the hook reports the count on stderr
484
- (`N staged function(s) carry a ratchet mark and were not gated`) and `crapkit verify` is
485
- what fails a mark that rose. And git runs hooks outside your shell's activated venv: bare
486
- `python` must resolve to an interpreter that has crapkit installed, or use the absolute
487
- form
488
- (`exec /path/to/venv/Scripts/python -m crapkit hook-precommit`).
489
-
490
- ### Route 1: `.git/hooks/pre-commit` (local, not committed)
491
-
492
- ```sh
493
- cat > .git/hooks/pre-commit <<'EOF'
494
- #!/bin/sh
495
- exec python -m crapkit hook-precommit
496
- EOF
497
- chmod +x .git/hooks/pre-commit
498
- ```
499
-
500
- ### Route 2: a committed hooks directory
501
-
502
- The whole route, from a repo that has no `githooks/` yet:
503
-
504
- ```sh
505
- mkdir -p githooks
506
- cat > githooks/pre-commit <<'EOF'
507
- #!/bin/sh
508
- exec python -m crapkit hook-precommit
509
- EOF
510
- chmod +x githooks/pre-commit
511
- printf 'githooks/pre-commit text eol=lf\n' >> .gitattributes
512
- git add .gitattributes githooks/pre-commit
513
- git update-index --chmod=+x githooks/pre-commit
514
- git commit -m "add crapkit gate hook"
515
- git config core.hooksPath githooks
516
- ```
517
-
518
- **The `--chmod` goes between the `add` and the `commit`.** It writes the executable bit to
519
- the index, so a commit that already happened does not carry it: run it after and `git
520
- ls-tree HEAD` still says `100644`, which is a hook Unix checkouts silently skip. The
521
- `.gitattributes` line is the harder half of the same failure — under Windows' default
522
- `core.autocrlf` the hook checks out CRLF and `#!/bin/sh\r` dies on Linux and macOS with a
523
- bad-interpreter error. `crapkit doctor` warns when a file under `core.hooksPath` is not
524
- `100755` in the index and prints the `update-index` line for it.
525
-
526
- Git will not read a hooks path out of a committed file, so that `git config` line belongs in
527
- your CONTRIBUTING setup steps. Every clone arms the gate with it.
528
-
529
- ### Route 3: the pre-commit framework
530
-
531
- crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
532
- `.pre-commit-config.yaml`:
533
-
534
- ```yaml
535
- repos:
536
- - repo: https://github.com/JeanFrancoisGagne/crapkit
537
- rev: 5ffd6361605469a4e7e1212876ab19177354b37b
538
- hooks:
539
- - id: crapkit-gate
540
- ```
541
-
542
- `rev` is a git ref pre-commit resolves against that remote, and the repo carries no tags
543
- yet, so pin a commit sha. The first release will ship a `v0.1.0` tag; pin that instead once
544
- it exists, since `pre-commit autoupdate` only moves between tags.
545
-
546
- ### What a refusal looks like
547
-
548
- ```
549
- $ git commit -m "add route"
550
- crapkit gate: 1 staged function(s) exceed the complexity ceiling of 6:
551
- ccn 7 app/m.py:9 route( a , b , c , d )
552
- decompose before committing (coverage cannot save a function above the target).
553
- ```
554
-
555
- Run directly, `crapkit hook-precommit` exits 6 on a violation and 0 otherwise.
556
-
557
- ### Route 4: CI
558
-
559
- A CI job runs on a fresh clone, which has no `.crapkit/` store, so bare `crapkit verify`
560
- exits 1 — and running `coverage` first would make the PR's own tree the baseline, a gate
561
- that can never fail. The portable baseline is the mechanism:
562
-
563
- ```
564
- # on the default branch, after a passing verify — commit this file
565
- crapkit verify --emit-baseline crapkit-baseline.tsv
566
-
567
- # in the PR job, against the committed baseline
568
- crapkit verify --baseline-tsv crapkit-baseline.tsv --github
569
- ```
570
-
571
- `--github` emits `::error file=...` annotations that land on the PR diff; `--sarif PATH`
572
- writes SARIF 2.1.0 for code-scanning upload. Refresh the committed baseline whenever the
573
- default branch's verify passes.
574
-
575
- `CRAPKIT_OVERRIDE_REASON` is not a bypass. Setting it routes the commit through the full
576
- three-record audit: an alert line through `alert_command`, a ratchet entry staged into the
577
- commit, and a row in the override log. All three land or nothing does, and an unset
578
- `alert_command` refuses the override outright. See
579
- [docs/ratchet.md](docs/ratchet.md#overrides-and-the-audit-trail).
580
-
581
- ---
582
-
583
- ## Reading the output
584
-
585
- ### Flags: why a coverage number is missing
586
-
587
- | Flag | Meaning | Scored |
588
- |---|---|---|
589
- | `measured` | A lane artifact spoke about this function. | Real `cov`. |
590
- | `untested` | A lane covers the scope, but its artifact is silent on this function, which normally means no test imports the file. | `cov = 0`. A testing gap, and `uncovered_lines` comes back `null` because no artifact can name lines it never saw. |
591
- | `no-lane` | No lane's `scopes` list names this function's scope. | `cov = 0`. A tooling gap, not a testing gap. `next-item` never hands one out and counts them in `skipped_no_lane`; `worklist` ranks them and marks the row `no-lane`, because a wiring gap is a risk you have to see. |
592
- | `cc-only` | The scope sets `coverage_optional = true`, so no coverage number can exist. | `crap = ccn`, and `remedy` can only be `ok` or `decompose`. `uncovered_lines` comes back `null` with a note naming that setting. |
593
-
594
- The coverage summary counts all four as `measured` / `untested` / `no_lane` / `cc_only`.
595
-
596
- ### Remedy: what to do about it
597
-
598
- | Remedy | Condition | Action |
599
- |---|---|---|
600
- | `decompose` | `ccn > ceiling` | Split it. No amount of coverage clears this. |
601
- | `add-tests` | `ccn <= ceiling` and `crap > ceiling` | Cover the branches. |
602
- | `ok` | `crap <= ceiling` | Nothing. |
603
-
604
- ### Grade: over-target density
605
-
606
- A letter for the fraction of functions over their ceiling. `A+` is reserved for zero.
607
-
608
- | Grade | Over-target share |
609
- |---|---|
610
- | `A+` | exactly 0 |
611
- | `A` | under 2% |
612
- | `B` | 2% to under 5% |
613
- | `C` | 5% to under 10% |
614
- | `D` | 10% to under 20% |
615
- | `F` | 20% or more |
616
-
617
- `crap_load` beside it is the plain sum of every function's CRAP score, so it moves when a
618
- function gets better even if the grade does not.
619
-
620
- ### Risk: what ranks the worklist
621
-
622
- `risk = ccn * churn weight`. The weight is a time-weighted sum over the file's commits in
623
- the churn window: each commit contributes a logistic weight rising to 0.5 for the newest
624
- commit in the log and falling to near zero for the oldest, so five edits last month outrank
625
- fifty from two years ago. The window anchors on the newest commit, never on the wall clock,
626
- so a fixed tree ranks identically forever.
627
-
628
- Age is not the input, position in the log is. A file only the oldest commit ever touched
629
- reads 0.0, and a log whose commits all share one timestamp reads 0.0 everywhere. Commits
630
- minutes apart already rank. This repo was eight commits old, all of them made the same day:
631
-
632
- ```
633
- $ crapkit worklist --scope util
634
- worklist @ a7c5c85ac37 (run 1, floor ccn>=5, churn 12mo) — 3 active, 0 dormant
635
- risk 5.4 ccn 5 ( 5 std) 5c/1a w 1.08 util/stats.py:1 bucket( value , low , high )
636
- risk 4.5 ccn 9 ( 9 std) 1c/1a w 0.50 util/curve.py:1 curve( scores , mode , floor , ceiling , skip_none )
637
- risk 4.3 ccn 4 ( 4 std) 5c/1a w 1.08 util/stats.py:13 spread( values , cap ) ok
638
- ```
639
-
640
- `bucket` at ccn 5 outranks `curve` at ccn 9 because five commits touched it and one touched
641
- `curve`. That is the whole point of weighting by churn. `spread` carries the `ok` marker:
642
- it is already at or under its ceiling, and the risk map lists it anyway — `next-item`
643
- would not hand it out.
644
-
645
- `worklist` splits its output in two: **active** (files with commits in the window) and
646
- **dormant** (zero churn, kept out of the queue but counted). Two rules reach under the
647
- `worklist_floor`. A file whose churn weight sits in the top 10% is promoted down to ccn 3,
648
- so heavily edited simple code cannot hide under the floor: that is why `spread` is in the
649
- list above at ccn 4, under the floor of 5. And a function scoring over its ceiling is
650
- admitted whatever its ccn, so the floor can never hold back debt.
651
-
652
- ### The trusted baseline
653
-
654
- Every `verify` measures the working tree against one earlier run, the **trusted
655
- baseline**. Three rules decide which run that is, and one escape overrides them.
656
- `crapkit runs list` marks the answer.
657
-
658
- **Which runs qualify.** A `coverage` run, or a `verify` run that passed. A failed
659
- `verify` never qualifies, and neither does a `partial` run (a lane failed, so some scope
660
- fell back to `no-lane`) nor a `hook` override record, which carries no scored rows at all.
661
- In `runs list`, `verdict=-` marks a run that produces no verdict rather than one that
662
- failed: only `verify` renders a verdict.
663
-
664
- **What advances it.** Any qualifying run. `coverage` writes one wherever HEAD is, so a
665
- dashboard cron advances the baseline exactly as CI does. A passing `verify` advances it
666
- too, and tightens the ratchet on the way.
667
-
668
- **The taint rule.** A failed `verify` recorded findings against a tree. Until some
669
- `verify` passes, runs made after that failure do not become the baseline: choosing one
670
- would move the comparison point past the findings, the flagged function would stop
671
- counting as touched, and nothing would ever look at it again. `verify` says which run it
672
- refused and falls back to the newest run in front of the failure.
673
-
674
- ```
675
- $ crapkit runs list
676
- run 1 @ 88012a148f6 2026-08-23T09:27:46Z coverage verdict=- lanes=py baseline
677
- run 2 @ 803bdde8556 2026-08-23T09:27:53Z verify verdict=FAILED lanes=py
678
- run 3 @ 803bdde8556 2026-08-23T09:28:02Z coverage verdict=- lanes=py
679
-
680
- $ crapkit verify
681
- warning: run 3 is not the baseline: verify run 2 FAILED with 1 finding(s) and no passing verify has cleared it since — measuring against run 1 @ 88012a148f6 instead, so those findings stay visible. Fix them, or pass `--baseline 3` to accept the newer run deliberately.
682
- verify FAILED @ d89068de7f3 vs baseline 88012a148f6 (2 changed files)
683
- GATE crap 72.0 ccn 8 cov 0% calc/legacy.py:7 legacy_router( a , b , c , d , e ) -> decompose
684
- findings: 1 committed / 0 dirty (uncommitted tracked edits)
685
- ```
686
-
687
- Run 3 is a `coverage` run somebody took on the tree run 2 refused, and it scores the same
688
- ccn-8 function. Without the rule it would have become the baseline, `legacy_router` would
689
- have stopped being a touched function, and that gate line would never print again.
690
-
691
- **The escape, twice.** Fix the findings and let a `verify` pass, which clears the taint
692
- for good. Or accept the newer run on purpose with `verify --baseline 3`: an explicit id
693
- bypasses the rule, and the run history records which run the verdict used. Nothing here
694
- touches a repo that has never run `verify` — with no failure to protect, `coverage` alone
695
- always advances the baseline.
696
-
697
- ---
698
-
699
- ## Exit codes
700
-
701
- | Code | Meaning |
702
- |---|---|
703
- | 0 | OK. For `verify` and `hook-precommit`: the gate passed. |
704
- | 1 | **Overloaded.** Three unrelated things, listed below the table. |
705
- | 2 | Usage error from argparse: unknown flag, missing positional. Raised before crapkit's own error handling. |
706
- | 3 | Config error: `crapkit.toml` missing or unparseable, an unknown language or parser, a ratchet metric-stamp mismatch, a `test-scoped` file under no scope or under a scope with no template. |
707
- | 4 | Git error: not a repository, a baseline commit rewritten out of the history. |
708
- | 5 | Tool error: lizard not importable, a lane produced no artifact, a lane timed out past its retries, an override alert command failed. |
709
- | 6 | Gate violation. A function the diff touched is over its ceiling, or `rescore --gate` found one, or `hook-precommit` did. |
710
- | 7 | Ratchet regression. A marked function scores worse than its recorded high-water mark, touched or not. |
711
- | 8 | New test failures against the baseline run. Failures the baseline already had do not count. |
712
- | 9 | Diff-coverage ceiling breached: `diff_uncovered_max` is set and more changed lines than that never ran. |
713
-
714
- ### Exit 1 means one of three things
715
-
716
- CI cannot tell a crash from a clean policy verdict on the code alone. Which one you got
717
- depends on the command:
718
-
719
- | Command | What exit 1 means |
720
- |---|---|
721
- | `doctor` | A **`FAIL` finding**. This is a verdict, not a crash. A `WARN` (an unmeasured directory, or a lane writing its artifact at the repo root) and a `note` (a file over `max_file_bytes`, or no lanes declared) both exit 0. |
722
- | `ratchet report --enforce` | The **debt policy was breached**. Also a verdict. |
723
- | anything else | An unexpected error: "no snapshot yet, run `crapkit coverage` first", a `brief` name that matches no function, a `test-scoped` runner that exited non-zero. |
724
-
725
- ### Precedence
726
-
727
- `verify` reports the **first** of 6, 7, 8, 9 that fires, in that order. A gate violation and
728
- a ratchet regression together report 6. A run that takes any of them fails, so it neither
729
- advances the baseline nor tightens the ratchet, exit 9 included.
730
-
731
- ---
732
-
733
- ## Subcommands
734
-
735
- Every subcommand takes `--repo PATH` (default `.`), and the flag goes **after** the
736
- subcommand. `claude-hook` is the one exception: it has no `--repo`, because it takes its
737
- root from the file named in the hook payload it reads.
738
-
739
- ```
740
- $ crapkit worklist --repo /path/to/repo --scope util --top 1
741
- worklist @ a7c5c85ac37 (run 1, floor ccn>=5, churn 12mo) — 1 active, 0 dormant
742
- risk 5.4 ccn 5 ( 5 std) 5c/1a w 1.08 util/stats.py:1 bucket( value , low , high )
743
- ```
744
-
745
- Before it, argparse reads the path as the subcommand name and exits 2 without ever
746
- mentioning `--repo`:
747
-
748
- ```
749
- $ crapkit --repo /path/to/repo worklist --top 1
750
- crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from 'inventory', 'coverage', ...)
751
- ```
752
-
753
- `--json` prints one sorted-keys JSON object on stdout, always carrying a `schema` field.
754
-
755
- | Command | What it does |
756
- |---|---|
757
- | `init` | Sniffs tracked source into per-directory scopes, writes a self-validated starter `crapkit.toml` whose lanes report into `.crapkit/cov/`, and appends `.crapkit/` plus each runner's own droppings to `.gitignore`. Writes a live `[[lane]]` when it can detect the test runner, otherwise a commented template. Refuses to clobber an existing config. |
758
- | `doctor [--show-files] [--json] [--tune] [--plugin-root PATH]` | Checks the config still describes the repo: unknown keys (with the accepted spellings), zero-file scopes, tracked source no scope claims, scopes no lane covers, lane cwds and commands that no longer resolve, lizard importable, oversized files, lanes writing their artifacts at the repo root instead of under `.crapkit/` (WARN), committed hooks under `core.hooksPath` that are not executable in the index (WARN), directories whose functions are all `untested` while their tests exist (WARN), and scopes a lane measures with no `[crapkit.scoped_tests]` template behind them (WARN), which is the loop's step 4 with nothing to run. `--tune` prints suggested parallelism knobs and writes nothing. `--plugin-root PATH` reads no repo at all: it checks an installed [plugin](plugin/) against this CLI, comparing `.claude-plugin/plugin.json`'s version against the running crapkit and every `--protocol` in `hooks/hooks.json` against the protocol `claude-hook` answers, one line per disagreement and silence when they agree. See [docs/agent-json.md](docs/agent-json.md#doctor---json). |
759
- | `inventory [--db PATH] [--export PATH] [--json]` | Two lizard passes over every in-scope file into a SQLite snapshot run, cached by content hash. `--db` is the only way to point crapkit at a store outside `.crapkit/`, and only this command accepts it. |
760
- | `coverage [--lane NAME] [--reuse-artifacts] [--reuse-unchanged] [--export PATH] [--sarif PATH] [--github] [--json]` | Runs the lanes, joins branch coverage onto a fresh inventory, writes a scored run. A failed lane is recorded, not fatal: its scopes fall back to `no-lane` and the run is typed `partial`, so it can never serve as a baseline. See [docs/lanes.md](docs/lanes.md). |
761
- | `verify [--baseline ID \| --base REF \| --baseline-tsv PATH] [--emit-baseline PATH] [--override REASON] [--reuse-artifacts] [--reuse-unchanged] [--sarif PATH] [--github] [--json]` | The full verdict against the trusted baseline: gate on touched functions, ratchet, no new test failures, optional diff-coverage ceiling. The three baseline selectors are mutually exclusive; `--baseline ID` also bypasses the taint rule ([The trusted baseline](#the-trusted-baseline)), and `--baseline-tsv` reads a commit-stamped file so a fresh clone verifies with no store. Findings a dirty tree produced are tagged `dirty` and counted apart. |
762
- | `worklist [--top N] [--scope NAME] [--batches N] [--json]` | The risk map: every admitted function ranked by `ccn * churn weight`, floored by `worklist_floor`, with hot simple code and anything over its ceiling admitted past that floor. It ranks finished rows and `no-lane` rows too, marked `ok` and `no-lane`, so it never empties; `next-item` carries the stop condition. `--scope NAME` (repeatable) is exact, not a substring. `--batches N` **adds** a `batches[]` view cutting the active list into at most N file-disjoint batches with co-changing files kept together; the normal keys stay. |
763
- | `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. |
764
- | `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. |
765
- | `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. |
766
- | `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. |
767
- | `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. |
768
- | `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). |
769
- | `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. |
770
- | `overrides [--json]` | The override audit trail: who granted what, when, and why. |
771
- | `trend [--json]` | Totals per trusted run: functions, over-target count, CRAP load, average, per-scope rollup. |
772
- | `digest [--alert]` | The delta between the two newest runs with identical lane sets. Silent when nothing changed. `--alert` pipes the body to `alert_command` on stdin. Plain lines, never JSON. |
773
- | `report [--out PATH]` | One self-contained HTML page written to `.crapkit/report.html` (or `--out PATH`, repo-relative), with the path printed on stdout. It renders what `worklist --json` and `trend --json` already answer at their defaults: the ranked worklist capped at `worklist_top`, the per-scope grades off the newest run, the trend series, and a banner naming every stale lane. It measures nothing, opens no network connection, and carries no per-function CRAP or coverage, because no repo-wide payload has them; each row prints the `crapkit explain` call that does. |
774
- | `duplication [--min-lines N] [--similarity F] [--top N] [--json]` | Near-duplicate functions by normalized line shingles with containment scoring. Defaults: `--min-lines 8`, `--similarity 0.8`, `--top 50`. `--top` truncates the list. |
775
- | `coupling [--min-support N] [--min-confidence F] [--top N] [--json]` | File pairs that keep landing in the same commits. Defaults: `--min-support 5` shared commits, `--min-confidence 0.5` max-direction ratio, `--top 50`. Bulk commits never couple pairs, and a young repo returns nothing at the default support. |
776
- | `mutate [--files F ...] [--max-mutants N] [--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. |
777
- | `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. |
778
- | `hook-precommit` | The cc-only gate on staged blobs. No coverage, no snapshot, no repo-wide cache. Exit 6 on a violation. |
779
- | `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 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: exit 0, silence, empty stderr. Takes no `--repo` — 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, writes nothing, and leaves stdout empty. |
780
- | `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. |
781
- | `mcp` | A dependency-free stdio MCP server (newline JSON-RPC 2.0) exposing nine read-only tools. See [docs/agent-json.md](docs/agent-json.md#mcp-server). |
782
-
783
804
  ## Documentation
784
805
 
785
806
  | Page | Covers |
786
807
  |---|---|
787
- | [docs/handbook.html](docs/handbook.html) | The illustrated handbook: what crapkit is, how every piece works, and where each command earns its keep. Self-contained HTML — open it straight from a clone. |
808
+ | [The handbook](https://jeanfrancoisgagne.github.io/crapkit/handbook.html) | **Start here for anything deeper.** The illustrated handbook: what crapkit is, how every piece works, and where each command earns its keep. Also at [docs/handbook.html](docs/handbook.html), self-contained, so it opens straight from a clone. |
809
+ | [docs/adoption.md](docs/adoption.md) | The judgment layer over the quickstarts: scope granularity, exclude vs lane, scoped_tests wiring, the first-verify taint hazard. |
788
810
  | [docs/configuration.md](docs/configuration.md) | Every `crapkit.toml` key: type, default, and what it does. |
789
811
  | [docs/lanes.md](docs/lanes.md) | The lane model, vitest and jest and pytest recipes, artifact reuse, flake retest, containers. |
790
812
  | [docs/ratchet.md](docs/ratchet.md) | Seeding, pruning, the git merge driver, metric stamps, debt policy, overrides. |
791
813
  | [docs/agent-json.md](docs/agent-json.md) | The machine surface: `schema`, every payload field, real captured examples. |
792
- | [docs/adoption.md](docs/adoption.md) | The judgment layer over the quickstarts: scope granularity, exclude vs lane, scoped_tests wiring, the first-verify taint hazard. |
793
- | [plugin/](plugin/) | The Claude Code plugin: three skills (`crapkit`, `crapkit-recover`, `crapkit-onboard`), the read-side MCP server, and the advisory PostToolUse hook. Install with `claude plugin marketplace add JeanFrancoisGagne/crapkit` then `claude plugin install crapkit@crapkit`; other runtimes copy `plugin/skills/*` into their skills directory. |
814
+ | [AGENTS.md](AGENTS.md) | The burn-down loop an agent runs, and the rules for changing crapkit itself. |
815
+ | [plugin/](plugin/) | The Claude Code plugin: three skills, the read-side MCP server, and the advisory PostToolUse hook. |
794
816
 
795
817
  [crapkit.schema.json](crapkit.schema.json) is the authority on the config file shape.
796
818