crapkit 0.2.0__py3-none-any.whl

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