crapkit 0.4.2__tar.gz → 0.4.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {crapkit-0.4.2/src/crapkit.egg-info → crapkit-0.4.4}/PKG-INFO +35 -8
  2. {crapkit-0.4.2 → crapkit-0.4.4}/README.md +32 -7
  3. {crapkit-0.4.2 → crapkit-0.4.4}/pyproject.toml +6 -1
  4. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/__init__.py +1 -1
  5. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/analyze.py +20 -16
  6. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/churn_cache.py +11 -6
  7. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/churn_log.py +18 -6
  8. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/cli/__init__.py +6 -0
  9. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/cli/admin.py +48 -1
  10. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/cli/claude_hook.py +14 -4
  11. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/cli/parser.py +2 -0
  12. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/cli/queue.py +45 -13
  13. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/cli/ratchet_cmds.py +37 -8
  14. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/cli/reports.py +16 -3
  15. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/cli/scoring.py +37 -5
  16. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/cli/verifying.py +70 -9
  17. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/config.py +136 -13
  18. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/doctor.py +1 -1
  19. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/dup.py +16 -3
  20. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/gitio.py +8 -2
  21. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/hook.py +19 -4
  22. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/junitparse.py +53 -3
  23. crapkit-0.4.4/src/crapkit/keys.py +86 -0
  24. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/lanes.py +30 -0
  25. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/override.py +5 -3
  26. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/ratchet.py +76 -18
  27. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/store.py +25 -1
  28. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/verify.py +19 -6
  29. {crapkit-0.4.2 → crapkit-0.4.4/src/crapkit.egg-info}/PKG-INFO +35 -8
  30. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit.egg-info/SOURCES.txt +1 -0
  31. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit.egg-info/requires.txt +3 -0
  32. {crapkit-0.4.2 → crapkit-0.4.4}/LICENSE +0 -0
  33. {crapkit-0.4.2 → crapkit-0.4.4}/setup.cfg +0 -0
  34. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/__main__.py +0 -0
  35. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/_pygdefer.py +0 -0
  36. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/cache.py +0 -0
  37. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/churn.py +0 -0
  38. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/cli/_shared.py +0 -0
  39. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/cli/analyses.py +0 -0
  40. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/coupling.py +0 -0
  41. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/coverage_istanbul.py +0 -0
  42. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/coverage_py.py +0 -0
  43. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/covstream.py +0 -0
  44. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/diffparse.py +0 -0
  45. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/digest.py +0 -0
  46. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/discover.py +0 -0
  47. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/errors.py +0 -0
  48. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/lizardcognitive.py +0 -0
  49. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/lizardpowershell.py +0 -0
  50. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/lizardrust.py +0 -0
  51. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/lizardshell.py +0 -0
  52. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/mcp_server.py +0 -0
  53. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/merge.py +0 -0
  54. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/mutate.py +0 -0
  55. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/mutate_pool.py +0 -0
  56. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/packet.py +0 -0
  57. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/ratchet_report.py +0 -0
  58. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/report.py +0 -0
  59. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/sarif.py +0 -0
  60. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/sarifio.py +0 -0
  61. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/scaffold.py +0 -0
  62. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/score.py +0 -0
  63. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/snapshot.py +0 -0
  64. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/uncovered.py +0 -0
  65. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/universe.py +0 -0
  66. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/watch.py +0 -0
  67. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit/worklist.py +0 -0
  68. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit.egg-info/dependency_links.txt +0 -0
  69. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit.egg-info/entry_points.txt +0 -0
  70. {crapkit-0.4.2 → crapkit-0.4.4}/src/crapkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crapkit
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more.
5
5
  Author: Jean-Francois Gagne
6
6
  License: MIT
@@ -29,6 +29,8 @@ Provides-Extra: dev
29
29
  Requires-Dist: pytest>=8; extra == "dev"
30
30
  Requires-Dist: pytest-cov>=5; extra == "dev"
31
31
  Requires-Dist: pytest-xdist>=3; extra == "dev"
32
+ Provides-Extra: py
33
+ Requires-Dist: pytest-cov>=5; extra == "py"
32
34
  Dynamic: license-file
33
35
 
34
36
  # crapkit
@@ -93,7 +95,10 @@ the repo can get better and never worse while you burn it down.
93
95
  out to your own test runner, and the runner needs its coverage package installed:
94
96
  `pytest-cov` for pytest, `@vitest/coverage-v8` (pinned to your vitest major) for vitest.
95
97
  Without it the lane produces no artifact and `coverage` exits 5 quoting the runner's own
96
- error. The two quickstarts below walk a real repo end to end.
98
+ error. For pytest, `init` probes the python its lane will run and prints the install
99
+ command when `pytest_cov` is missing; `pip install 'crapkit[py]'` pulls the plugin
100
+ alongside crapkit when the two share a venv. The two quickstarts below walk a real repo
101
+ end to end.
97
102
 
98
103
  ## Install
99
104
 
@@ -116,7 +121,7 @@ changing crapkit.
116
121
 
117
122
  ```
118
123
  $ crapkit --version
119
- crapkit 0.4.2
124
+ crapkit 0.4.4
120
125
  ```
121
126
 
122
127
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -124,6 +129,22 @@ source checkout. Every subcommand accepts `--repo PATH` (default: the current di
124
129
  so you never have to `cd` into the repo you are scoring. The flag goes after the
125
130
  subcommand; [Subcommands](#subcommands) shows both orders.
126
131
 
132
+ ### Upgrading on Windows
133
+
134
+ `uv tool upgrade crapkit`, and `pip install -U` into a tool venv, fail with `os error 32`
135
+ ("The process cannot access the file because it is being used by another process") while a
136
+ crapkit MCP server is live: an agent session spawns `crapkit.exe mcp`, which holds the
137
+ launcher, and Windows will not overwrite a running executable. The venv upgrades before
138
+ that copy fails, so `crapkit --version` already reports the new version and the launcher is
139
+ the only stale piece. Quit the agent session and rerun the upgrade, or rename the locked
140
+ exe aside (Windows allows renaming a running one) and copy the new one in; the `.old` file
141
+ goes at the next reboot.
142
+
143
+ ```
144
+ mv ~/.local/bin/crapkit.exe ~/.local/bin/crapkit.exe.old
145
+ cp %APPDATA%/uv/tools/crapkit/Scripts/crapkit.exe ~/.local/bin/crapkit.exe
146
+ ```
147
+
127
148
  ## The Claude Code plugin
128
149
 
129
150
  ```
@@ -247,7 +268,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
247
268
  repos:
248
269
  - repo: https://github.com/JeanFrancoisGagne/crapkit
249
270
  # crapkit's release step rewrites this line to the tag it just cut
250
- rev: v0.4.2
271
+ rev: v0.4.4
251
272
  hooks:
252
273
  - id: crapkit-gate
253
274
  ```
@@ -321,11 +342,11 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
321
342
  | `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
343
  | `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
344
  | `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. |
345
+ | `verify [--baseline ID \| --base REF \| --baseline-tsv PATH] [--emit-baseline PATH] [--override REASON] [--reuse-artifacts] [--reuse-unchanged] [--no-tighten] [--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. `--no-tighten` passes the verdict without rewriting the ratchet. Findings a dirty tree produced are tagged `dirty` and counted apart. |
325
346
  | `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
347
  | `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
348
  | `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. |
349
+ | `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, `(anonymous)#N` for a function printed `(anonymous)` counting the file's anonymous functions from the top, or `NAME#2` for the second of several functions a file gives one name to. `--batch N` drops the positionals and emits `packets[]` instead: the top N of the queue, built from one read of the store. |
329
350
  | `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
351
  | `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
352
  | `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). |
@@ -334,7 +355,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
334
355
  | `trend [--json]` | Totals per trusted run: functions, over-target count, CRAP load, average, per-scope rollup. |
335
356
  | `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
357
  | `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. |
358
+ | `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. A function and a function nested inside it never pair: their spans nest, they score 1.0 by construction, and nobody can deduplicate a factory from its own closure. |
338
359
  | `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
360
  | `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
361
  | `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. |
@@ -412,7 +433,9 @@ baseline**. `crapkit runs list` marks which one that is today.
412
433
  never qualifies, and neither does a `partial` run (a lane failed, so some scope fell back
413
434
  to `no-lane`) nor a `hook` override record, which carries no scored rows at all. In `runs
414
435
  list`, `verdict=-` marks a run that produces no verdict rather than one that failed: only
415
- `verify` renders a verdict.
436
+ `verify` renders a verdict. Three readers ask this one question and get this one answer:
437
+ the baseline pick here, `ratchet seed` and `prune`, and the tighten damping that compares a
438
+ mark against the same commit's previous run.
416
439
 
417
440
  **What advances it.** Any qualifying run. `coverage` writes one wherever HEAD is, so a
418
441
  dashboard cron advances the baseline exactly as CI does. A passing `verify` advances it
@@ -487,6 +510,10 @@ writes runs `pytest --cov` and those flags come from `pytest-cov`:
487
510
  pip install pytest-cov
488
511
  ```
489
512
 
513
+ (or `pip install 'crapkit[py]'` to pull both at once, when crapkit lives in the same venv
514
+ as the suite — skip it and `init` prints this same fix when its probe finds the plugin
515
+ missing).
516
+
490
517
  ### 1. Scaffold the config
491
518
 
492
519
  ```
@@ -60,7 +60,10 @@ the repo can get better and never worse while you burn it down.
60
60
  out to your own test runner, and the runner needs its coverage package installed:
61
61
  `pytest-cov` for pytest, `@vitest/coverage-v8` (pinned to your vitest major) for vitest.
62
62
  Without it the lane produces no artifact and `coverage` exits 5 quoting the runner's own
63
- error. The two quickstarts below walk a real repo end to end.
63
+ error. For pytest, `init` probes the python its lane will run and prints the install
64
+ command when `pytest_cov` is missing; `pip install 'crapkit[py]'` pulls the plugin
65
+ alongside crapkit when the two share a venv. The two quickstarts below walk a real repo
66
+ end to end.
64
67
 
65
68
  ## Install
66
69
 
@@ -83,7 +86,7 @@ changing crapkit.
83
86
 
84
87
  ```
85
88
  $ crapkit --version
86
- crapkit 0.4.2
89
+ crapkit 0.4.4
87
90
  ```
88
91
 
89
92
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -91,6 +94,22 @@ source checkout. Every subcommand accepts `--repo PATH` (default: the current di
91
94
  so you never have to `cd` into the repo you are scoring. The flag goes after the
92
95
  subcommand; [Subcommands](#subcommands) shows both orders.
93
96
 
97
+ ### Upgrading on Windows
98
+
99
+ `uv tool upgrade crapkit`, and `pip install -U` into a tool venv, fail with `os error 32`
100
+ ("The process cannot access the file because it is being used by another process") while a
101
+ crapkit MCP server is live: an agent session spawns `crapkit.exe mcp`, which holds the
102
+ launcher, and Windows will not overwrite a running executable. The venv upgrades before
103
+ that copy fails, so `crapkit --version` already reports the new version and the launcher is
104
+ the only stale piece. Quit the agent session and rerun the upgrade, or rename the locked
105
+ exe aside (Windows allows renaming a running one) and copy the new one in; the `.old` file
106
+ goes at the next reboot.
107
+
108
+ ```
109
+ mv ~/.local/bin/crapkit.exe ~/.local/bin/crapkit.exe.old
110
+ cp %APPDATA%/uv/tools/crapkit/Scripts/crapkit.exe ~/.local/bin/crapkit.exe
111
+ ```
112
+
94
113
  ## The Claude Code plugin
95
114
 
96
115
  ```
@@ -214,7 +233,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
214
233
  repos:
215
234
  - repo: https://github.com/JeanFrancoisGagne/crapkit
216
235
  # crapkit's release step rewrites this line to the tag it just cut
217
- rev: v0.4.2
236
+ rev: v0.4.4
218
237
  hooks:
219
238
  - id: crapkit-gate
220
239
  ```
@@ -288,11 +307,11 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
288
307
  | `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). |
289
308
  | `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. |
290
309
  | `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). |
291
- | `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. |
310
+ | `verify [--baseline ID \| --base REF \| --baseline-tsv PATH] [--emit-baseline PATH] [--override REASON] [--reuse-artifacts] [--reuse-unchanged] [--no-tighten] [--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. `--no-tighten` passes the verdict without rewriting the ratchet. Findings a dirty tree produced are tagged `dirty` and counted apart. |
292
311
  | `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. |
293
312
  | `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. |
294
313
  | `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. |
295
- | `brief FILE NAME [--batch N] [--json]` | The start-editing packet for one function: its own `source` text, every function in the file, the scored row and the scope ceiling, the ratchet mark and what the gate will bind on, uncovered lines, duplication twins, file churn, coupling partners, the config's notes, and the literal commands for the rest of the loop. Plus `handle`, `remedy` and the same `est_splits` / `est_uncovered_paths` the queue prints, and a `commands.refresh` that writes a run (`refresh_writes_run`) rather than re-reading the stale one. `NAME` takes the bare identifier, the long name `next-item` printed, the function's start line, or `(anonymous)#N` for a function printed `(anonymous)`, counting the file's anonymous functions from the top. `--batch N` drops the positionals and emits `packets[]` instead: the top N of the queue, built from one read of the store. |
314
+ | `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, `(anonymous)#N` for a function printed `(anonymous)` counting the file's anonymous functions from the top, or `NAME#2` for the second of several functions a file gives one name to. `--batch N` drops the positionals and emits `packets[]` instead: the top N of the queue, built from one read of the store. |
296
315
  | `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. |
297
316
  | `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. |
298
317
  | `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). |
@@ -301,7 +320,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
301
320
  | `trend [--json]` | Totals per trusted run: functions, over-target count, CRAP load, average, per-scope rollup. |
302
321
  | `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. |
303
322
  | `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. |
304
- | `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. |
323
+ | `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. A function and a function nested inside it never pair: their spans nest, they score 1.0 by construction, and nobody can deduplicate a factory from its own closure. |
305
324
  | `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. |
306
325
  | `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. |
307
326
  | `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. |
@@ -379,7 +398,9 @@ baseline**. `crapkit runs list` marks which one that is today.
379
398
  never qualifies, and neither does a `partial` run (a lane failed, so some scope fell back
380
399
  to `no-lane`) nor a `hook` override record, which carries no scored rows at all. In `runs
381
400
  list`, `verdict=-` marks a run that produces no verdict rather than one that failed: only
382
- `verify` renders a verdict.
401
+ `verify` renders a verdict. Three readers ask this one question and get this one answer:
402
+ the baseline pick here, `ratchet seed` and `prune`, and the tighten damping that compares a
403
+ mark against the same commit's previous run.
383
404
 
384
405
  **What advances it.** Any qualifying run. `coverage` writes one wherever HEAD is, so a
385
406
  dashboard cron advances the baseline exactly as CI does. A passing `verify` advances it
@@ -454,6 +475,10 @@ writes runs `pytest --cov` and those flags come from `pytest-cov`:
454
475
  pip install pytest-cov
455
476
  ```
456
477
 
478
+ (or `pip install 'crapkit[py]'` to pull both at once, when crapkit lives in the same venv
479
+ as the suite — skip it and `init` prints this same fix when its probe finds the plugin
480
+ missing).
481
+
457
482
  ### 1. Scaffold the config
458
483
 
459
484
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "crapkit"
7
- version = "0.4.2"
7
+ version = "0.4.4"
8
8
  description = "Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more."
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  license = { text = "MIT" }
@@ -53,6 +53,11 @@ Source = "https://github.com/JeanFrancoisGagne/crapkit"
53
53
  # without it. Left out of this list it took down 31 e2e tests on every one of
54
54
  # the six CI test jobs (run 33225190806), ubuntu and windows alike.
55
55
  dev = ["pytest>=8", "pytest-cov>=5", "pytest-xdist>=3"]
56
+ # The py lane's runtime half, for installs that share the suite's venv: the
57
+ # lane init writes runs `pytest --cov`, and those flags come from pytest-cov.
58
+ # An install elsewhere (pipx, uv tool) still needs pytest-cov beside the SUITE;
59
+ # init probes for that and prints the fix.
60
+ py = ["pytest-cov>=5"]
56
61
 
57
62
  [project.scripts]
58
63
  crapkit = "crapkit.cli:main"
@@ -1,2 +1,2 @@
1
1
  """crapkit: deterministic CRAP-score framework."""
2
- __version__ = "0.4.2"
2
+ __version__ = "0.4.4"
@@ -160,10 +160,11 @@ _NAMES_SHOWN = 5
160
160
  def _colliding_names(records: list[FunctionRecord]) -> list[str]:
161
161
  """Names this file gives to more than one function, in first-seen order.
162
162
 
163
- Anonymous functions are exempt. lizard calls every one of them
163
+ Anonymous functions are exempt from the line. lizard calls every one of them
164
164
  `(anonymous)`, so a file with two arrow callbacks collides by construction
165
- and a warning would name nothing anyone could act on; `packet.handles`
166
- answers that collision with the `(anonymous)#N` ordinal instead.
165
+ and the line would name nothing anyone could act on. They take the same
166
+ ordinal keys as any other twin; `packet.handles` already addresses them as
167
+ `(anonymous)#N`.
167
168
  """
168
169
  seen: set[str] = set()
169
170
  colliding: dict[str, None] = {}
@@ -174,23 +175,26 @@ def _colliding_names(records: list[FunctionRecord]) -> list[str]:
174
175
  return list(colliding)
175
176
 
176
177
 
177
- def _warn_on_collisions(rel_path: str, records: list[FunctionRecord]) -> None:
178
- """One stderr line for a file whose records cannot all reach the ratchet.
178
+ def _note_twin_keys(rel_path: str, records: list[FunctionRecord]) -> None:
179
+ """One stderr line for a file that gives one name to more than one function.
179
180
 
180
- A mark is keyed on (path, long_name), and so is the row a run writes, so the
181
- last function under a colliding name is the only one marked and the only one
182
- gated. C makes this ordinary: both arms of an `#ifdef` fork are textually
183
- present, so a platform shim defines the same function twice in one file.
184
- Python makes it ordinary too, because a method's long_name carries no class.
185
- Neither is fixable here — the ratchet cannot key on a span, which drifts with
186
- every edit — so the loss is announced rather than silent.
181
+ Information, not a warning. Until 0.4.2 it was the second: a mark keyed on
182
+ (path, long_name) meant one twin owned the key and the rest were neither
183
+ marked nor gated, which is the loss this announced. `keys` ends that by
184
+ giving each twin its own ordinal, so the line now says what a reader will
185
+ see in `crapkit-ratchet.tsv` and nothing is lost.
186
+
187
+ Still printed, because a `#2` appearing in a committed marks file is
188
+ otherwise unexplained. C makes the shape ordinary — both arms of an `#ifdef`
189
+ fork are textually present — and so does Python, whose method long_names
190
+ carry no class.
187
191
  """
188
192
  names = _colliding_names(records)
189
193
  if not names:
190
194
  return
191
- print(f"crapkit: {rel_path} defines {_listed(names)} more than once; the ratchet keys "
192
- f"on (path, long_name) and keeps the last, so the earlier ones are neither "
193
- f"marked nor gated", file=sys.stderr)
195
+ print(f"crapkit: {rel_path} defines {_listed(names)} more than once; each one takes "
196
+ f"its own ratchet key — the first as written, later ones suffixed #2, #3 in "
197
+ f"file order", file=sys.stderr)
194
198
 
195
199
 
196
200
  def _listed(names: list[str]) -> str:
@@ -201,7 +205,7 @@ def _listed(names: list[str]) -> str:
201
205
 
202
206
  def _file_records(rel_path: str, functions) -> list[FunctionRecord]:
203
207
  records = [_record(rel_path, fn) for fn in functions]
204
- _warn_on_collisions(rel_path, records)
208
+ _note_twin_keys(rel_path, records)
205
209
  return records
206
210
 
207
211
 
@@ -5,10 +5,12 @@ git diffing every commit's tree, and worklist, next-item and coupling each paid
5
5
  it in full on every invocation, at an unmoved HEAD. Every one of them reaches
6
6
  git through this module, so `.crapkit/churn-cache.json` has exactly one writer.
7
7
 
8
- The key is (HEAD sha, window months, UTC date). The sha pins the history; the
9
- window pins the command; the date is there because `--since=N months ago` is
10
- evaluated against the wall clock, so yesterday's cache describes a window one
11
- day wider than today's. Anything else is a miss, and a miss rebuilds.
8
+ The key is (HEAD sha, window months, UTC date, path format). The sha pins the
9
+ history; the window pins the command; the date is there because `--since=N
10
+ months ago` is evaluated against the wall clock, so yesterday's cache describes
11
+ a window one day wider than today's; the format marker retires maps whose
12
+ paths predate `git log --relative`. Anything else is a miss, and a miss
13
+ rebuilds.
12
14
 
13
15
  A cache is disposable: unreadable, corrupt or unkeyable content reads as cold,
14
16
  never as a crash. Uncommitted work is invisible to churn either way. The one
@@ -23,7 +25,7 @@ from datetime import datetime, timezone
23
25
  from pathlib import Path
24
26
 
25
27
  from .churn import FileChurn, parse_git_log_lines
26
- from .churn_log import has_cache, log_lines
28
+ from .churn_log import RELATIVE_PATHS, has_cache, log_lines
27
29
  from .errors import GitError
28
30
  from .gitio import churn_log_lines, head_commit
29
31
 
@@ -64,7 +66,10 @@ def _cache_key(root: Path, months: int) -> dict | None:
64
66
  head = head_commit(root)
65
67
  except GitError:
66
68
  return None
67
- return {"head": head, "months": months, "date": _utc_date()}
69
+ # `paths` marks the format, not a question: maps built before --relative
70
+ # hold top-relative paths in a subdirectory root, and must read as cold.
71
+ return {"head": head, "months": months, "date": _utc_date(),
72
+ "paths": RELATIVE_PATHS}
68
73
 
69
74
 
70
75
  def _read_cache(path: Path, key: dict | None) -> dict[str, FileChurn] | None:
@@ -42,6 +42,11 @@ from .gitio import _git_lines, head_commit, is_ancestor
42
42
  LOG_NAME = "churn-log.z"
43
43
  LOG_FORMAT = "--format=%x01%an%x02%at%x02%ct"
44
44
  CHUNK = 1 << 20
45
+ # The key's format marker: these logs hold root-relative paths (--relative),
46
+ # the only kind that joins against ls-files rows when the root sits below the
47
+ # repo top. A key without it names a top-relative log, and that one is cold —
48
+ # served OR refreshed, it would feed every consumer paths that match nothing.
49
+ RELATIVE_PATHS = "root-relative"
45
50
 
46
51
 
47
52
  def log_lines(root: Path, months: int) -> Iterator[str]:
@@ -103,7 +108,8 @@ def _cache_key(root: Path, months: int) -> dict | None:
103
108
  head = head_commit(root)
104
109
  except GitError:
105
110
  return None
106
- return {"head": head, "months": months, "date": _utc_date()}
111
+ return {"head": head, "months": months, "date": _utc_date(),
112
+ "paths": RELATIVE_PATHS}
107
113
 
108
114
 
109
115
  def _key_path(path: Path) -> Path:
@@ -119,7 +125,7 @@ def _read_key(path: Path) -> dict | None:
119
125
 
120
126
 
121
127
  def _key_fields(doc: dict) -> dict:
122
- return {field: doc.get(field) for field in ("head", "months", "date")}
128
+ return {field: doc.get(field) for field in ("head", "months", "date", "paths")}
123
129
 
124
130
 
125
131
  def _cached(path: Path, key: dict | None) -> Iterator[str] | None:
@@ -173,9 +179,12 @@ def _refreshed(root: Path, months: int, path: Path, key: dict | None) -> Iterato
173
179
 
174
180
 
175
181
  def _refreshable(root: Path, stored: dict | None, key: dict) -> bool:
176
- """True only for a cached log this HEAD grew from: same window, and behind us."""
182
+ """True only for a cached log this HEAD grew from: same window, same path
183
+ format, and behind us."""
177
184
  if stored is None or stored.get("months") != key["months"]:
178
185
  return False
186
+ if stored.get("paths") != key["paths"]:
187
+ return False # a top-relative log: prepending would stack fresh commits on wrong paths
179
188
  if stored.get("head") == key["head"]:
180
189
  return True
181
190
  return is_ancestor(root, str(stored.get("head")), key["head"])
@@ -262,13 +271,16 @@ def _keep(part: BinaryIO, path: Path, key: dict | None) -> None:
262
271
 
263
272
 
264
273
  def _window_log(root: Path, months: int) -> Iterator[str]:
265
- """The whole window, from git. The expensive one."""
266
- return _git_lines(root, "log", f"--since={months} months ago", LOG_FORMAT, "--name-only")
274
+ """The whole window, from git. The expensive one. --relative for the same
275
+ reason gitio.churn_log_lines carries it: consumers join these paths against
276
+ root-relative rows, and a root below the repo top matched nothing without it."""
277
+ return _git_lines(root, "log", "--relative", f"--since={months} months ago",
278
+ LOG_FORMAT, "--name-only")
267
279
 
268
280
 
269
281
  def _range_log(root: Path, base: str, head: str) -> Iterator[str]:
270
282
  """Only what HEAD added on top of the cached log."""
271
- return _git_lines(root, "log", f"{base}..{head}", LOG_FORMAT, "--name-only")
283
+ return _git_lines(root, "log", "--relative", f"{base}..{head}", LOG_FORMAT, "--name-only")
272
284
 
273
285
 
274
286
  def _window_cutoff(root: Path, months: int) -> int | None:
@@ -109,6 +109,7 @@ _OWNER = {
109
109
  "_group_files_by_scope": "verifying",
110
110
  "_guard_ratchet_stamp": "verifying",
111
111
  "_handle": "queue",
112
+ "_held_marks": "verifying",
112
113
  "_hook_handlers": "admin",
113
114
  "_hook_modes": "admin",
114
115
  "_hook_protocols": "admin",
@@ -160,6 +161,8 @@ _OWNER = {
160
161
  "_no_line_message": "queue",
161
162
  "_no_match_message": "queue",
162
163
  "_no_scopes_reason": "admin",
164
+ "_no_tighten_line": "verifying",
165
+ "_no_twin_message": "queue",
163
166
  "_note_marked_staged": "verifying",
164
167
  "_note_stale_staged": "verifying",
165
168
  "_open_store": "_shared",
@@ -176,6 +179,7 @@ _OWNER = {
176
179
  "_policy_findings": "ratchet_cmds",
177
180
  "_present_markers": "admin",
178
181
  "_present_on_disk": "scoring",
182
+ "_prior_crap": "verifying",
179
183
  "_print_batches": "queue",
180
184
  "_print_brief": "queue",
181
185
  "_print_brief_context": "queue",
@@ -200,6 +204,7 @@ _OWNER = {
200
204
  "_prune_renames": "ratchet_cmds",
201
205
  "_pruned": "ratchet_cmds",
202
206
  "_pushdown_floor": "queue",
207
+ "_pytest_cov_probe": "admin",
203
208
  "_range_lines": "analyses",
204
209
  "_rankable": "queue",
205
210
  "_ratchet_entries": "_shared",
@@ -283,6 +288,7 @@ _OWNER = {
283
288
  "_version_line": "parser",
284
289
  "_version_report": "admin",
285
290
  "_warn_diff_uncovered": "verifying",
291
+ "_warn_missing_pytest_cov": "admin",
286
292
  "_warn_suite_shrink": "verifying",
287
293
  "_warn_untracked": "scoring",
288
294
  "_watch_banner": "admin",
@@ -6,12 +6,13 @@ what moved)."""
6
6
  from __future__ import annotations
7
7
 
8
8
  import argparse
9
+ import os
9
10
  import sys
10
11
  from dataclasses import dataclass, field
11
12
  from pathlib import Path
12
13
 
13
14
  from .. import __version__
14
- from ..config import load_config_text
15
+ from ..config import load_config_text, shell_words
15
16
  from ..doctor import Finding
16
17
  from ..errors import ConfigError, ToolError
17
18
  from ..gitio import ls_files
@@ -82,6 +83,51 @@ def _no_scopes_reason(root: Path) -> str:
82
83
  f"run `git add` first ({len(untracked)} untracked source file(s) found)")
83
84
 
84
85
 
86
+ def _pytest_cov_probe(command: str) -> bool:
87
+ """Can the interpreter this lane names import pytest_cov? The probe runs
88
+ through the same shell as the lane, so a bare `python` resolves to the one
89
+ the lane will get (a .bat shim included), not the one CreateProcess finds.
90
+ True too when the probe cannot run — only a clean "no" earns the warning,
91
+ and a missing interpreter is doctor's finding, not this one's."""
92
+ import shutil
93
+ import subprocess
94
+
95
+ words = shell_words(command)
96
+ if not words or shutil.which(words[0]) is None:
97
+ return True
98
+ probe = f'{_shell_quote(words[0])} -c "import pytest_cov"'
99
+ try:
100
+ return subprocess.run(probe, shell=True, capture_output=True,
101
+ timeout=15).returncode == 0
102
+ except (OSError, subprocess.TimeoutExpired):
103
+ return True
104
+
105
+
106
+ def _shell_quote(word: str) -> str:
107
+ """One word, quoted for the shell the lane runs under."""
108
+ import shlex
109
+
110
+ if os.name != "nt":
111
+ return shlex.quote(word)
112
+ return f'"{word}"' if " " in word else word
113
+
114
+
115
+ def _warn_missing_pytest_cov(lanes: tuple) -> None:
116
+ """The first-run trap, caught where it starts. The py lane shells out to
117
+ `pytest --cov`, and the --cov flags come from pytest-cov — a package of the
118
+ REPO's interpreter, so a crapkit dependency could only ever cover installs
119
+ sharing the suite's venv. Probe the python the lane will actually run and
120
+ say the fix now, instead of `coverage` exiting 5 with a lane log the first
121
+ run has to decode."""
122
+ for lane in lanes:
123
+ if lane.parser == "coveragepy" and "--cov" in lane.command \
124
+ and not _pytest_cov_probe(lane.command):
125
+ print(f"note: lane {lane.name!r} runs `pytest --cov`, and this python cannot "
126
+ "import pytest_cov — pip install pytest-cov where the suite runs "
127
+ "(pip install 'crapkit[py]' when that is crapkit's own environment), "
128
+ "then `crapkit coverage`", file=sys.stderr)
129
+
130
+
85
131
  def _extend_gitignore(root: Path, lanes: tuple) -> None:
86
132
  """Ignore what adopting crapkit will write: the store, and each lane's
87
133
  artifact. Without this the consumer's next `git status` is a wall of
@@ -115,6 +161,7 @@ def cmd_init(args: argparse.Namespace) -> int:
115
161
  load_config_text(text) # self-check: never write a config crapkit cannot read back
116
162
  toml_path.write_text(text, encoding="utf-8", newline="\n")
117
163
  _print_init_summary(scopes, lanes)
164
+ _warn_missing_pytest_cov(live_lanes(lanes, scopes))
118
165
  _extend_gitignore(root, live_lanes(lanes, scopes))
119
166
  return 0
120
167
 
@@ -154,7 +154,7 @@ def _judge(root: Path, rel: str) -> int:
154
154
  records = _records(root, rel)
155
155
  ranges = _changed(root, rel, diff.communicate()[0])
156
156
  breaches, ceiling = _verdict(cfg, in_scope, rel, records, ranges)
157
- return _report(root, cfg, rel, breaches, ceiling)
157
+ return _report(root, cfg, rel, breaches, ceiling, _keys(records))
158
158
 
159
159
 
160
160
  def _config(root: Path):
@@ -260,11 +260,21 @@ def _answerable(over: list, ranges) -> list:
260
260
  return [rec for rec in over if _touches(rec, ranges)]
261
261
 
262
262
 
263
- def _report(root: Path, cfg, rel: str, breaches: list, ceiling: int) -> int:
263
+ def _keys(records: list) -> dict:
264
+ """The file's ratchet keys, built from every record rather than the breaching
265
+ ones: the ordinal counts same-named functions in file order."""
266
+ from ..keys import key_names
267
+
268
+ return key_names(records)
269
+
270
+
271
+ def _report(root: Path, cfg, rel: str, breaches: list, ceiling: int, keys: dict) -> int:
264
272
  """Rung 9. stdout stays empty whatever happens: protocol 1 reserves it for a
265
273
  future JSON channel, and Claude Code parses stdout JSON on exit 0."""
274
+ from ..keys import key_of
275
+
266
276
  marked = _marks_for(root / cfg.ratchet_file, rel)
267
- unmarked = [rec for rec in breaches if rec.long_name not in marked]
277
+ unmarked = [rec for rec in breaches if key_of(keys, rec)[1] not in marked]
268
278
  if not unmarked:
269
279
  return 0
270
280
  for line in _advisory_lines(rel, unmarked, ceiling):
@@ -273,7 +283,7 @@ def _report(root: Path, cfg, rel: str, breaches: list, ceiling: int) -> int:
273
283
 
274
284
 
275
285
  def _marks_for(marks_path: Path, rel: str) -> set[str]:
276
- """The function names one file carries ratchet marks for.
286
+ """The ratchet KEY names one file carries marks for, `#N` ordinals included.
277
287
 
278
288
  Existence, not the numeric high-water rule `verify` applies: crap needs
279
289
  coverage, coverage needs the store, and the store stays closed. A mark is a
@@ -286,6 +286,8 @@ def build_parser() -> argparse.ArgumentParser:
286
286
  help="rerun only lanes whose scope files changed since their artifact; reuse the rest")
287
287
  ver.add_argument("--override", default=None, metavar="REASON",
288
288
  help="audited exemption for gate violations: alert + ratchet debt + snapshot record")
289
+ ver.add_argument("--no-tighten", action="store_true",
290
+ help="pass the verdict without rewriting the ratchet; marks stay where they are")
289
291
  ver.add_argument("--sarif", default=None, metavar="PATH",
290
292
  help="write gate/ratchet findings as SARIF 2.1.0, relative to the repo")
291
293
  ver.add_argument("--github", action="store_true",