crapkit 0.4.6__tar.gz → 0.4.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {crapkit-0.4.6/src/crapkit.egg-info → crapkit-0.4.7}/PKG-INFO +34 -6
  2. {crapkit-0.4.6 → crapkit-0.4.7}/README.md +33 -5
  3. {crapkit-0.4.6 → crapkit-0.4.7}/pyproject.toml +1 -1
  4. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/__init__.py +1 -1
  5. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/admin.py +16 -6
  6. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/claude_hook.py +127 -4
  7. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/lanes.py +115 -18
  8. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/scaffold.py +30 -10
  9. {crapkit-0.4.6 → crapkit-0.4.7/src/crapkit.egg-info}/PKG-INFO +34 -6
  10. {crapkit-0.4.6 → crapkit-0.4.7}/LICENSE +0 -0
  11. {crapkit-0.4.6 → crapkit-0.4.7}/setup.cfg +0 -0
  12. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/__main__.py +0 -0
  13. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/_pygdefer.py +0 -0
  14. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/analyze.py +0 -0
  15. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cache.py +0 -0
  16. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/churn.py +0 -0
  17. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/churn_cache.py +0 -0
  18. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/churn_log.py +0 -0
  19. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/__init__.py +0 -0
  20. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/_shared.py +0 -0
  21. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/analyses.py +0 -0
  22. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/parser.py +0 -0
  23. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/queue.py +0 -0
  24. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/ratchet_cmds.py +0 -0
  25. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/reports.py +0 -0
  26. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/scoring.py +0 -0
  27. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/verifying.py +0 -0
  28. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/config.py +0 -0
  29. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/coupling.py +0 -0
  30. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/coupling_cache.py +0 -0
  31. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/coverage_istanbul.py +0 -0
  32. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/coverage_py.py +0 -0
  33. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/covstream.py +0 -0
  34. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/diffparse.py +0 -0
  35. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/digest.py +0 -0
  36. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/discover.py +0 -0
  37. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/doctor.py +0 -0
  38. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/dup.py +0 -0
  39. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/errors.py +0 -0
  40. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/gitio.py +0 -0
  41. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/hook.py +0 -0
  42. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/junitparse.py +0 -0
  43. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/keys.py +0 -0
  44. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/lizardcognitive.py +0 -0
  45. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/lizardpowershell.py +0 -0
  46. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/lizardrust.py +0 -0
  47. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/lizardshell.py +0 -0
  48. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/mcp_server.py +0 -0
  49. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/merge.py +0 -0
  50. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/mutate.py +0 -0
  51. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/mutate_pool.py +0 -0
  52. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/override.py +0 -0
  53. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/packet.py +0 -0
  54. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/procs.py +0 -0
  55. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/ratchet.py +0 -0
  56. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/ratchet_report.py +0 -0
  57. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/report.py +0 -0
  58. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/sarif.py +0 -0
  59. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/sarifio.py +0 -0
  60. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/score.py +0 -0
  61. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/snapshot.py +0 -0
  62. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/store.py +0 -0
  63. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/uncovered.py +0 -0
  64. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/universe.py +0 -0
  65. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/verify.py +0 -0
  66. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/watch.py +0 -0
  67. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/worklist.py +0 -0
  68. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit.egg-info/SOURCES.txt +0 -0
  69. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit.egg-info/dependency_links.txt +0 -0
  70. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit.egg-info/entry_points.txt +0 -0
  71. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit.egg-info/requires.txt +0 -0
  72. {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crapkit
3
- Version: 0.4.6
3
+ Version: 0.4.7
4
4
  Summary: Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more.
5
5
  Author: Jean-Francois Gagne
6
6
  License: MIT
@@ -145,7 +145,7 @@ changing crapkit.
145
145
 
146
146
  ```
147
147
  $ crapkit --version
148
- crapkit 0.4.6
148
+ crapkit 0.4.7
149
149
  ```
150
150
 
151
151
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -227,6 +227,33 @@ ceiling. It adds no files to your repo, and it needs the crapkit CLI on PATH.
227
227
  A repo with no `crapkit.toml` costs a silent sub-50 ms no-op per edit. Other agent
228
228
  runtimes have no marketplace: copy `plugin/skills/*` into their skills directory instead.
229
229
 
230
+ The hook registers on `Edit|Write`, which is every write that names a file. An agent that
231
+ writes its source through a shell heredoc names none, so a `Bash` event is judged off the
232
+ working tree instead. That half is yours to register, because it costs two
233
+ git spawns per shell call. Add a second PostToolUse entry to your own settings, same
234
+ command, matcher `Bash`:
235
+
236
+ ```json
237
+ {
238
+ "hooks": {
239
+ "PostToolUse": [
240
+ {
241
+ "matcher": "Bash",
242
+ "hooks": [
243
+ { "type": "command", "command": "crapkit claude-hook --protocol 1", "timeout": 20 }
244
+ ]
245
+ }
246
+ ]
247
+ }
248
+ }
249
+ ```
250
+
251
+ The cost is one `git rev-parse --show-toplevel` and one `git status --porcelain -z -uall`
252
+ per shell call in any git repo, whether or not crapkit measures it: about 30 ms together
253
+ on crapkit's own checkout, and more on a bigger tree. What comes back is the dirty or
254
+ untracked `*.py` files written in the last 12 seconds, 25 at most, each judged the way an
255
+ edit is. Python only, so a TypeScript or Go repo pays the two spawns and hears nothing.
256
+
230
257
  ## Languages
231
258
 
232
259
  14 languages, two coverage parsers. Coverage joins where a parser exists; everything else
@@ -346,7 +373,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
346
373
  repos:
347
374
  - repo: https://github.com/JeanFrancoisGagne/crapkit
348
375
  # crapkit's release step rewrites this line to the tag it just cut
349
- rev: v0.4.6
376
+ rev: v0.4.7
350
377
  hooks:
351
378
  - id: crapkit-gate
352
379
  ```
@@ -488,7 +515,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
488
515
  | `mutate [--files F ...] [--max-mutants N] [--drop-pool] [--json]` | Diff-scoped mutation testing: flips comparisons, boundary shifts, boolean connectives and boolean literals on changed lines, runs `mutation_command` per mutant, lists survivors. `--files` replaces diff scope with the whole file. `--max-mutants` (default 100) caps the run and the cap warning goes to stderr only, so `mutants` in `--json` is the capped count. Shell and PowerShell files are refused by name on stderr rather than mutated: `<` and `>` are redirections there, not comparisons. With `mutation_workers > 1` the worker worktrees are kept at `.crapkit/mutate-pool/` and re-prepared per run (30.6 s to build four on a 31,459-file repo, 0.46 s to re-prepare them); `--drop-pool` removes them and exits. |
489
516
  | `test-scoped FILE ...` | Runs each owning scope's `[crapkit.scoped_tests]` template on the files (quoted, longest-prefix scope wins). A template with no `{files}` runs as written, which is how a scope whose tests live outside its own paths runs its whole suite. Exit code only; a nonzero runner exits 1. |
490
517
  | `hook-precommit` | The cc-only gate on staged blobs. No coverage, no snapshot, no repo-wide cache. Exit 6 on a violation. |
491
- | `claude-hook [--protocol N]` | Reads one Claude Code PostToolUse payload from stdin and judges the file it edited: ccn against the scope ceiling, on functions the edit changed, minus functions a ratchet mark already covers. Advisory only. The edit has landed, nothing is blocked, and `hook-precommit` stays the enforcement point. Exit 2 with three lines on stderr is the only thing it ever says: no `crapkit.toml` above the edited file, an unscoped file, mid-rebase or mid-merge, a `--protocol` other than 1, source that parses to no functions, or any internal failure all exit 0 in silence. Takes no `--repo`, because the root is the first `crapkit.toml` above the edited file and the upward walk stops at a `.git` entry, so a worktree never borrows its parent's config. It opens no snapshot and writes nothing. |
518
+ | `claude-hook [--protocol N]` | Reads one Claude Code PostToolUse payload from stdin and judges the file it edited: ccn against the scope ceiling, on functions the edit changed, minus functions a ratchet mark already covers. Advisory only: the edit has landed, and `hook-precommit` stays the enforcement point. Exit 2 and an advisory on stderr is the only thing it ever says, one block per judged file (a head line, one line per breaching function, a closing line): no `crapkit.toml` above the edited file, an unscoped file, mid-rebase or mid-merge, a `--protocol` other than 1, source that parses to no functions, or any internal failure all exit 0 in silence. The root is the first `crapkit.toml` above the edited file; the walk stops at a `.git` entry, so a worktree never borrows its parent's config. A `Bash` event names no file, so it judges the working tree instead: the dirty or untracked `*.py` files touched in the last 12 seconds, 25 at most, each through the same ladder, and silence for a clean tree or a cwd outside any repo. That half fires only where you register a `Bash` matcher ([The Claude Code plugin](#the-claude-code-plugin)). It opens no snapshot and writes nothing. |
492
519
  | `watch [--interval SECONDS] [--cycles N]` | Rescores tracked files as they change (mtime polling, default 2s, subprocess-isolated so a half-saved syntax error never kills the watcher). `--cycles N` polls exactly N times and exits 0; without it the loop runs until ctrl-c. |
493
520
  | `mcp` | A dependency-free stdio MCP server (newline JSON-RPC 2.0) exposing nine read-only tools. Every tool shells to the CLI's own `--json` surface, so the MCP view cannot drift from what the CLI reports. Answering from a kept in-process store was benchmarked and rejected: a packet's `source` would go stale behind the edit it describes. See [docs/agent-json.md](docs/agent-json.md#mcp-server). |
494
521
 
@@ -616,7 +643,7 @@ crapkit: run 3 is an inventory run (no coverage was measured) and cannot serve a
616
643
  | 2 | Usage error from argparse: unknown flag, missing positional. Raised before crapkit's own error handling. |
617
644
  | 3 | Config error: `crapkit.toml` missing or unparseable, an unknown language or parser, a lane command the shell that runs it reads as a narrowed suite, a ratchet metric-stamp mismatch ([Upgrading from 0.4.4](#upgrading-from-044)), a `test-scoped` file under no scope or under a scope with no template. |
618
645
  | 4 | Git error: not a repository, a baseline commit rewritten out of the history. |
619
- | 5 | Tool error: lizard not importable, a lane produced no artifact or one that measured a different tree, a lane timed out past its retries, an override alert command failed. A `timeout_seconds` kills the whole process tree, so no orphan suite keeps running behind the failure. |
646
+ | 5 | Tool error: lizard not importable, a lane that produced no artifact, one that measured a different tree, one that measured this tree and reported it in absolute paths (the join is root-relative, so those match nothing either; the refusal names the runner's own switch, `relative_files = true` under `[tool.coverage.run]` for a coveragepy lane, the reporter's `cwd`/`root` option for an istanbul one), a lane that timed out past its retries, an override alert command that failed. A `timeout_seconds` kills the whole process tree, so no orphan suite keeps running behind the failure. |
620
647
  | 6 | Gate violation. A function the diff touched is over its ceiling and past any ratchet mark it carries: an edit that leaves a marked function at or under its mark is the debt the repo signed for and is exempt. Also `rescore --gate`, which applies the same rule, and `hook-precommit`, which exempts on the mark's existence instead. |
621
648
  | 7 | Ratchet regression the diff never touched. A marked function scores worse than its recorded high-water mark; a touched one past its mark reports 6. |
622
649
  | 8 | New test failures against the baseline run. Failures the baseline already had do not count. |
@@ -671,7 +698,8 @@ coverage lane from what the repo already has: a pytest marker file (`pyproject.t
671
698
  the matching `run` for the rest), because a bare `python` binds to whichever venv the shell
672
699
  has active rather than the one the repo pins — see
673
700
  [The interpreter a lane binds to](docs/lanes.md#the-interpreter-a-lane-binds-to). Whatever
674
- it detects, it also leaves commented templates for the runners it did not find. Every lane
701
+ it detects, it also leaves commented templates for the runners it did not find, and those
702
+ carry the same launcher, so uncommenting one cannot hand the bare `python` back. Every lane
675
703
  it writes reports into `.crapkit/cov/`, which is why the `.gitignore` list is so short: see
676
704
  [Where artifacts live](docs/lanes.md#where-artifacts-live).
677
705
 
@@ -106,7 +106,7 @@ changing crapkit.
106
106
 
107
107
  ```
108
108
  $ crapkit --version
109
- crapkit 0.4.6
109
+ crapkit 0.4.7
110
110
  ```
111
111
 
112
112
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -188,6 +188,33 @@ ceiling. It adds no files to your repo, and it needs the crapkit CLI on PATH.
188
188
  A repo with no `crapkit.toml` costs a silent sub-50 ms no-op per edit. Other agent
189
189
  runtimes have no marketplace: copy `plugin/skills/*` into their skills directory instead.
190
190
 
191
+ The hook registers on `Edit|Write`, which is every write that names a file. An agent that
192
+ writes its source through a shell heredoc names none, so a `Bash` event is judged off the
193
+ working tree instead. That half is yours to register, because it costs two
194
+ git spawns per shell call. Add a second PostToolUse entry to your own settings, same
195
+ command, matcher `Bash`:
196
+
197
+ ```json
198
+ {
199
+ "hooks": {
200
+ "PostToolUse": [
201
+ {
202
+ "matcher": "Bash",
203
+ "hooks": [
204
+ { "type": "command", "command": "crapkit claude-hook --protocol 1", "timeout": 20 }
205
+ ]
206
+ }
207
+ ]
208
+ }
209
+ }
210
+ ```
211
+
212
+ The cost is one `git rev-parse --show-toplevel` and one `git status --porcelain -z -uall`
213
+ per shell call in any git repo, whether or not crapkit measures it: about 30 ms together
214
+ on crapkit's own checkout, and more on a bigger tree. What comes back is the dirty or
215
+ untracked `*.py` files written in the last 12 seconds, 25 at most, each judged the way an
216
+ edit is. Python only, so a TypeScript or Go repo pays the two spawns and hears nothing.
217
+
191
218
  ## Languages
192
219
 
193
220
  14 languages, two coverage parsers. Coverage joins where a parser exists; everything else
@@ -307,7 +334,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
307
334
  repos:
308
335
  - repo: https://github.com/JeanFrancoisGagne/crapkit
309
336
  # crapkit's release step rewrites this line to the tag it just cut
310
- rev: v0.4.6
337
+ rev: v0.4.7
311
338
  hooks:
312
339
  - id: crapkit-gate
313
340
  ```
@@ -449,7 +476,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
449
476
  | `mutate [--files F ...] [--max-mutants N] [--drop-pool] [--json]` | Diff-scoped mutation testing: flips comparisons, boundary shifts, boolean connectives and boolean literals on changed lines, runs `mutation_command` per mutant, lists survivors. `--files` replaces diff scope with the whole file. `--max-mutants` (default 100) caps the run and the cap warning goes to stderr only, so `mutants` in `--json` is the capped count. Shell and PowerShell files are refused by name on stderr rather than mutated: `<` and `>` are redirections there, not comparisons. With `mutation_workers > 1` the worker worktrees are kept at `.crapkit/mutate-pool/` and re-prepared per run (30.6 s to build four on a 31,459-file repo, 0.46 s to re-prepare them); `--drop-pool` removes them and exits. |
450
477
  | `test-scoped FILE ...` | Runs each owning scope's `[crapkit.scoped_tests]` template on the files (quoted, longest-prefix scope wins). A template with no `{files}` runs as written, which is how a scope whose tests live outside its own paths runs its whole suite. Exit code only; a nonzero runner exits 1. |
451
478
  | `hook-precommit` | The cc-only gate on staged blobs. No coverage, no snapshot, no repo-wide cache. Exit 6 on a violation. |
452
- | `claude-hook [--protocol N]` | Reads one Claude Code PostToolUse payload from stdin and judges the file it edited: ccn against the scope ceiling, on functions the edit changed, minus functions a ratchet mark already covers. Advisory only. The edit has landed, nothing is blocked, and `hook-precommit` stays the enforcement point. Exit 2 with three lines on stderr is the only thing it ever says: no `crapkit.toml` above the edited file, an unscoped file, mid-rebase or mid-merge, a `--protocol` other than 1, source that parses to no functions, or any internal failure all exit 0 in silence. Takes no `--repo`, because the root is the first `crapkit.toml` above the edited file and the upward walk stops at a `.git` entry, so a worktree never borrows its parent's config. It opens no snapshot and writes nothing. |
479
+ | `claude-hook [--protocol N]` | Reads one Claude Code PostToolUse payload from stdin and judges the file it edited: ccn against the scope ceiling, on functions the edit changed, minus functions a ratchet mark already covers. Advisory only: the edit has landed, and `hook-precommit` stays the enforcement point. Exit 2 and an advisory on stderr is the only thing it ever says, one block per judged file (a head line, one line per breaching function, a closing line): no `crapkit.toml` above the edited file, an unscoped file, mid-rebase or mid-merge, a `--protocol` other than 1, source that parses to no functions, or any internal failure all exit 0 in silence. The root is the first `crapkit.toml` above the edited file; the walk stops at a `.git` entry, so a worktree never borrows its parent's config. A `Bash` event names no file, so it judges the working tree instead: the dirty or untracked `*.py` files touched in the last 12 seconds, 25 at most, each through the same ladder, and silence for a clean tree or a cwd outside any repo. That half fires only where you register a `Bash` matcher ([The Claude Code plugin](#the-claude-code-plugin)). It opens no snapshot and writes nothing. |
453
480
  | `watch [--interval SECONDS] [--cycles N]` | Rescores tracked files as they change (mtime polling, default 2s, subprocess-isolated so a half-saved syntax error never kills the watcher). `--cycles N` polls exactly N times and exits 0; without it the loop runs until ctrl-c. |
454
481
  | `mcp` | A dependency-free stdio MCP server (newline JSON-RPC 2.0) exposing nine read-only tools. Every tool shells to the CLI's own `--json` surface, so the MCP view cannot drift from what the CLI reports. Answering from a kept in-process store was benchmarked and rejected: a packet's `source` would go stale behind the edit it describes. See [docs/agent-json.md](docs/agent-json.md#mcp-server). |
455
482
 
@@ -577,7 +604,7 @@ crapkit: run 3 is an inventory run (no coverage was measured) and cannot serve a
577
604
  | 2 | Usage error from argparse: unknown flag, missing positional. Raised before crapkit's own error handling. |
578
605
  | 3 | Config error: `crapkit.toml` missing or unparseable, an unknown language or parser, a lane command the shell that runs it reads as a narrowed suite, a ratchet metric-stamp mismatch ([Upgrading from 0.4.4](#upgrading-from-044)), a `test-scoped` file under no scope or under a scope with no template. |
579
606
  | 4 | Git error: not a repository, a baseline commit rewritten out of the history. |
580
- | 5 | Tool error: lizard not importable, a lane produced no artifact or one that measured a different tree, a lane timed out past its retries, an override alert command failed. A `timeout_seconds` kills the whole process tree, so no orphan suite keeps running behind the failure. |
607
+ | 5 | Tool error: lizard not importable, a lane that produced no artifact, one that measured a different tree, one that measured this tree and reported it in absolute paths (the join is root-relative, so those match nothing either; the refusal names the runner's own switch, `relative_files = true` under `[tool.coverage.run]` for a coveragepy lane, the reporter's `cwd`/`root` option for an istanbul one), a lane that timed out past its retries, an override alert command that failed. A `timeout_seconds` kills the whole process tree, so no orphan suite keeps running behind the failure. |
581
608
  | 6 | Gate violation. A function the diff touched is over its ceiling and past any ratchet mark it carries: an edit that leaves a marked function at or under its mark is the debt the repo signed for and is exempt. Also `rescore --gate`, which applies the same rule, and `hook-precommit`, which exempts on the mark's existence instead. |
582
609
  | 7 | Ratchet regression the diff never touched. A marked function scores worse than its recorded high-water mark; a touched one past its mark reports 6. |
583
610
  | 8 | New test failures against the baseline run. Failures the baseline already had do not count. |
@@ -632,7 +659,8 @@ coverage lane from what the repo already has: a pytest marker file (`pyproject.t
632
659
  the matching `run` for the rest), because a bare `python` binds to whichever venv the shell
633
660
  has active rather than the one the repo pins — see
634
661
  [The interpreter a lane binds to](docs/lanes.md#the-interpreter-a-lane-binds-to). Whatever
635
- it detects, it also leaves commented templates for the runners it did not find. Every lane
662
+ it detects, it also leaves commented templates for the runners it did not find, and those
663
+ carry the same launcher, so uncommenting one cannot hand the bare `python` back. Every lane
636
664
  it writes reports into `.crapkit/cov/`, which is why the `.gitignore` list is so short: see
637
665
  [Where artifacts live](docs/lanes.md#where-artifacts-live).
638
666
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "crapkit"
7
- version = "0.4.6"
7
+ version = "0.4.7"
8
8
  description = "Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more."
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  license = { text = "MIT" }
@@ -1,2 +1,2 @@
1
1
  """crapkit: deterministic CRAP-score framework."""
2
- __version__ = "0.4.6"
2
+ __version__ = "0.4.7"
@@ -350,9 +350,16 @@ def _warn_missing_pytest_cov(lanes: tuple) -> None:
350
350
  """The first-run trap, caught where it starts. The py lane shells out to
351
351
  `pytest --cov`, and the --cov flags come from pytest-cov — a package of the
352
352
  REPO's interpreter, so a crapkit dependency could only ever cover installs
353
- sharing the suite's venv. Probe the python the lane will actually run and
354
- say the fix now, instead of `coverage` exiting 5 with a lane log the first
355
- run has to decode."""
353
+ sharing the suite's venv. Say the fix now, instead of `coverage` exiting 5
354
+ with a lane log the first run has to decode.
355
+
356
+ Only a lane whose pytest segment starts with a python is probed at all. A
357
+ lane an environment manager heads is not: `uv run` and its siblings create
358
+ or sync the project environment before running anything, so probing one
359
+ would provision an environment to ask a question about it. Such a lane
360
+ still earns the two notes ahead of the probe, a manager PATH does not carry
361
+ and a first word the shell cannot start.
362
+ """
356
363
  for lane in _probed_lanes(lanes):
357
364
  note = _lane_first_run_note(lane)
358
365
  if note:
@@ -386,9 +393,12 @@ def cmd_init(args: argparse.Namespace) -> int:
386
393
  raise ConfigError(_no_scopes_reason(root))
387
394
  # A config whose lanes are all commented out scores every function no-lane,
388
395
  # so a fresh repo cannot rank anything until somebody hand-writes a lane.
389
- lanes = detect_lanes(_present_markers(root), _package_json(root),
390
- interpreter=_interpreter(root))
391
- text = starter_toml(scopes, lanes)
396
+ # The interpreter goes to both: a repo with no pytest marker file gets no
397
+ # lane to read it back off, and its commented template is what the reader
398
+ # uncomments.
399
+ interpreter = _interpreter(root)
400
+ lanes = detect_lanes(_present_markers(root), _package_json(root), interpreter=interpreter)
401
+ text = starter_toml(scopes, lanes, interpreter=interpreter)
392
402
  load_config_text(text) # self-check: never write a config crapkit cannot read back
393
403
  toml_path.write_text(text, encoding="utf-8", newline="\n")
394
404
  _print_init_summary(scopes, lanes)
@@ -6,6 +6,12 @@ measures, over its ceiling, carrying no ratchet mark. Everything else is exit 0
6
6
  and silence: the malformed payload, the unmeasured repo, the half-typed source
7
7
  and the internal exception included.
8
8
 
9
+ An Edit, Write or MultiEdit event names its file in `tool_input.file_path` and
10
+ is judged as that one file. A Bash event carries `tool_input.command` instead —
11
+ a heredoc or `python - <<'PY'` writes source no file_path ever names — so it
12
+ falls back to the working tree: the changed *.py files fresh enough for this
13
+ command to have plausibly written, each through the same per-file ladder.
14
+
9
15
  That silence is the design, not laziness. On PostToolUse a nonzero exit that is
10
16
  not 2 is invisible and a 2 is text the model has to read, so a hook that fires
11
17
  where crapkit measures nothing is either useless or unbearable; 47.5% of the
@@ -34,6 +40,8 @@ from __future__ import annotations
34
40
  import json
35
41
  import subprocess
36
42
  import sys
43
+ import time
44
+ from collections.abc import Iterator
37
45
  from pathlib import Path
38
46
 
39
47
  PROTOCOL = "1"
@@ -45,6 +53,15 @@ _SEQUENCING_MARKERS = ("rebase-merge", "rebase-apply", "MERGE_HEAD", "CHERRY_PIC
45
53
  # root walk into a filesystem scan.
46
54
  _MAX_LEVELS = 64
47
55
 
56
+ # The Bash fallback's freshness window: a dirty *.py whose mtime is older than
57
+ # this was not written by the command this event reports, so advising it again
58
+ # would repeat the advisory on every later Bash call in the session.
59
+ _FRESH_WINDOW_SECONDS = 12
60
+
61
+ # And its bound: PostToolUse waits this process out, so a huge dirty tree is a
62
+ # stall, not a license to judge everything in it.
63
+ _MAX_COMMAND_FILES = 25
64
+
48
65
 
49
66
  def cmd_claude_hook(args) -> int:
50
67
  """The whole subcommand, wrapped in the catch-all the contract promises.
@@ -62,12 +79,19 @@ def cmd_claude_hook(args) -> int:
62
79
  def _advise(args, stream) -> int:
63
80
  """The ladder. Each rung that fails to advance exits 0 and says nothing."""
64
81
  payload = _payload(stream)
65
- edited = _edited_file(payload)
66
- if not edited:
82
+ if args.protocol != PROTOCOL:
67
83
  return 0
68
- path = _edited_path(payload, edited)
84
+ edited = _edited_file(payload)
85
+ if edited:
86
+ return _judge_path(_edited_path(payload, edited))
87
+ return _advise_command(payload)
88
+
89
+
90
+ def _judge_path(path: Path) -> int:
91
+ """Root discovery and judgement for one absolute file path: the tail every
92
+ event shape shares once it holds a file to answer for."""
69
93
  root = _repo_root(path.parent)
70
- if root is None or _sequencing(root) or args.protocol != PROTOCOL:
94
+ if root is None or _sequencing(root):
71
95
  return 0
72
96
  return _judge(root, path.relative_to(root).as_posix())
73
97
 
@@ -110,6 +134,105 @@ def _edited_path(payload: dict, edited: str) -> Path:
110
134
  return Path(payload.get("cwd") or ".") / path
111
135
 
112
136
 
137
+ def _command_event(payload: dict) -> bool:
138
+ """Whether this is a PostToolUse for a tool that wrote through the shell.
139
+
140
+ Bash carries `tool_input.command` and never `file_path`, so protocol 1 has
141
+ no single file to judge and reads the working tree instead. Shape-based like
142
+ `_edited_file`: NotebookEdit and friends carry no `command` and fall out
143
+ here rather than needing a rule.
144
+ """
145
+ if payload.get("hook_event_name") != "PostToolUse":
146
+ return False
147
+ tool_input = payload.get("tool_input")
148
+ return isinstance(tool_input, dict) and isinstance(tool_input.get("command"), str)
149
+
150
+
151
+ def _advise_command(payload: dict) -> int:
152
+ """The Bash fallback: judge the fresh *.py files the working tree changed.
153
+
154
+ A shell heredoc or `python - <<'PY'` writes source no Edit event ever names,
155
+ so judging only `file_path` left every Bash-written breach unadvised. Each
156
+ file takes the same per-file ladder an Edit takes, so a file under no
157
+ crapkit root, mid-sequencing, unscoped or marked stays silent, and exit 2
158
+ means what it always means.
159
+ """
160
+ if not _command_event(payload):
161
+ return 0
162
+ top = _repo_top(Path(payload.get("cwd") or "."))
163
+ if top is None:
164
+ return 0
165
+ verdicts = [_judge_path(path) for path in _fresh_python(top)]
166
+ return 2 if 2 in verdicts else 0
167
+
168
+
169
+ def _repo_top(cwd: Path) -> Path | None:
170
+ """The git working-tree top above the command's own cwd, or None outside any
171
+ repo. The event's `cwd` is where the command ran, and `status --porcelain`
172
+ names every file relative to this top whatever directory asks."""
173
+ if not cwd.is_dir():
174
+ return None
175
+ res = subprocess.run(["git", "rev-parse", "--show-toplevel"], cwd=cwd,
176
+ capture_output=True, text=True, encoding="utf-8",
177
+ errors="replace")
178
+ top = res.stdout.strip()
179
+ return Path(top) if res.returncode == 0 and top else None
180
+
181
+
182
+ def _fresh_python(top: Path) -> list[Path]:
183
+ """Absolute paths of the changed *.py files this command plausibly wrote:
184
+ dirty or untracked per git, on disk, and with an mtime inside the window."""
185
+ cutoff = time.time() - _FRESH_WINDOW_SECONDS
186
+ fresh: list[Path] = []
187
+ for status, rel in _status_records(_porcelain(top)):
188
+ if _judgeable(status, rel) and _fresh(top / rel, cutoff):
189
+ fresh.append(top / rel)
190
+ if len(fresh) == _MAX_COMMAND_FILES:
191
+ break
192
+ return fresh
193
+
194
+
195
+ def _porcelain(top: Path) -> str:
196
+ """`git status --porcelain -z` over the whole tree, or "" when git cannot
197
+ answer. -uall, because a heredoc that creates a new DIRECTORY of source
198
+ would otherwise arrive as one collapsed `?? newdir/` row naming no file."""
199
+ res = subprocess.run(["git", "status", "--porcelain", "-z", "-uall"], cwd=top,
200
+ capture_output=True, text=True, encoding="utf-8",
201
+ errors="replace")
202
+ return res.stdout if res.returncode == 0 else ""
203
+
204
+
205
+ def _status_records(text: str) -> Iterator[tuple[str, str]]:
206
+ """(XY status, new-side path) per `--porcelain -z` record.
207
+
208
+ -z is NUL-separated and never quoted, so a non-ASCII path arrives as
209
+ itself. A rename or copy record carries the original name in a second
210
+ field, consumed here so it cannot be read as the next record's status.
211
+ """
212
+ fields = text.split("\0")
213
+ i = 0
214
+ while i < len(fields) and fields[i]:
215
+ status = fields[i][:2]
216
+ yield status, fields[i][3:]
217
+ i += 2 if status[:1] in ("R", "C") else 1
218
+
219
+
220
+ def _judgeable(status: str, rel: str) -> bool:
221
+ """A *.py with content on disk. A deletion in either column has nothing
222
+ left to judge, and every other language stays the commit gate's business:
223
+ only Python is cheap enough to analyze per shell call."""
224
+ return rel.endswith(".py") and "D" not in status
225
+
226
+
227
+ def _fresh(path: Path, cutoff: float) -> bool:
228
+ """mtime inside the window — the approximation of "this command wrote it".
229
+ A path status names but disk lacks is not fresh, whatever the record said."""
230
+ try:
231
+ return path.stat().st_mtime >= cutoff
232
+ except OSError:
233
+ return False
234
+
235
+
113
236
  def _repo_root(start: Path) -> Path | None:
114
237
  """The crapkit root above an edited file, or None when there is none.
115
238
 
@@ -57,6 +57,11 @@ _CAUSE_WIDTH = 200
57
57
  # scrolls off above it.
58
58
  _DIAGNOSTIC = re.compile(r"\s*(E\s|Traceback \(most recent call last\)|\w*(Error|Exception): )")
59
59
 
60
+ # The banner `_log_header` writes before every attempt after the first. A whole
61
+ # line, so a log line that quotes those words mid-text is output and not a
62
+ # boundary.
63
+ _ATTEMPT_BANNER = re.compile(r"--- attempt \d+ ---")
64
+
60
65
 
61
66
  def _log_lines(log_path: Path) -> list[str]:
62
67
  if not log_path.is_file():
@@ -94,8 +99,18 @@ def _cut_cause(line: str) -> str:
94
99
  return line if len(line) <= _CAUSE_WIDTH else "..." + line[-(_CAUSE_WIDTH - 3):]
95
100
 
96
101
 
102
+ def _last_attempt(lines: list[str]) -> list[str]:
103
+ """The final attempt's lines. A retried lane appends every attempt to one
104
+ log, so a scan of the whole file can hoist the reason a superseded attempt
105
+ died for and stand it in front of the last attempt's own output, with
106
+ nothing marking the boundary. Attempt 1 writes no banner, so a log holding
107
+ none is one attempt and comes back whole."""
108
+ banners = [i for i, line in enumerate(lines) if _ATTEMPT_BANNER.fullmatch(line)]
109
+ return lines[banners[-1] + 1:] if banners else lines
110
+
111
+
97
112
  def _cause_lines(lines: list[str], tail: list[str]) -> list[str]:
98
- """The last lines anywhere in the log that name a failure, when the tail
113
+ """The last lines of the final attempt that name a failure, when the tail
99
114
  carries none. Nothing when the tail already says why — repeating it would
100
115
  spend the message on the same words twice."""
101
116
  if any(_DIAGNOSTIC.match(line) for line in tail):
@@ -110,7 +125,7 @@ def _log_tail(log_path: Path) -> str:
110
125
  ellipsis between them marks the output they skipped over."""
111
126
  lines = _log_lines(log_path)
112
127
  tail = _tail_lines(lines, _TAIL_BUDGET)
113
- cause = _cause_lines(lines, tail)
128
+ cause = _cause_lines(_last_attempt(lines), tail)
114
129
  return "\n".join([*cause, "...", *tail] if cause else tail)
115
130
 
116
131
 
@@ -373,14 +388,49 @@ def _read_and_parse(lane: Lane, root: Path,
373
388
 
374
389
  _SAMPLE_PATHS = 3
375
390
 
376
- # A path the runner could not write relative to this checkout: absolute, drive
391
+ # A path the runner did not write relative to this checkout: absolute, drive
377
392
  # lettered, or climbing out of the tree. Both parsers rebase a file INSIDE the
378
- # repo to a repo-relative path, so one that is not is a file somewhere else.
393
+ # repo to a repo-relative path, so one that is not either came from elsewhere or
394
+ # was spelled absolutely by a runner told to spell it that way. Which of the two
395
+ # is decided against the root, below; the shape alone does not say.
379
396
  _DRIVE = re.compile(r"[A-Za-z]:[\\/]")
380
397
 
381
398
 
399
+ def _is_absolute(path: str) -> bool:
400
+ """Absolute in either spelling: a POSIX root, or a drive letter."""
401
+ return path.startswith("/") or _DRIVE.match(path) is not None
402
+
403
+
382
404
  def _escapes_repo(path: str) -> bool:
383
- return path.startswith(("/", "../")) or _DRIVE.match(path) is not None
405
+ return _is_absolute(path) or path.startswith("../")
406
+
407
+
408
+ def _resolved(path: str) -> str:
409
+ """One spelling, so both sides of the root comparison can be compared at
410
+ all: symlinks followed, separators normalized, and the case folded where the
411
+ filesystem folds it (`normcase` is identity on POSIX, which does not).
412
+
413
+ A path whose tail does not exist still normalizes; only a name the platform
414
+ cannot express at all raises, and that is answered as written."""
415
+ try:
416
+ resolved = str(Path(path).resolve())
417
+ except (OSError, ValueError):
418
+ return os.path.normcase(path)
419
+ return os.path.normcase(resolved)
420
+
421
+
422
+ def _under(root: str, path: str) -> bool:
423
+ """Both already `_resolved`. The root itself counts as under itself."""
424
+ return path == root or path.startswith(root.rstrip(os.sep) + os.sep)
425
+
426
+
427
+ def _lands_in_checkout(root: str, path: str) -> bool:
428
+ """An absolute path naming a file this checkout holds after all.
429
+
430
+ `../` is excluded on purpose: it is relative to the runner's working
431
+ directory, which the artifact never records, so there is nothing to resolve
432
+ it against and no honest way to place it."""
433
+ return _is_absolute(path) and _under(root, _resolved(path))
384
434
 
385
435
 
386
436
  def _as_reported(lane: Lane, path: str) -> str:
@@ -413,13 +463,26 @@ def _unreached_paths(lane: Lane, coverage: dict, scope_paths: dict) -> tuple[str
413
463
  return tuple(dict.fromkeys(m.path for m in matchers))
414
464
 
415
465
 
416
- def _outside_paths(lane: Lane, coverage: dict) -> list[str]:
417
- """The measured files that are not in this checkout, spelled the way the
418
- artifact spells them."""
466
+ def _escaped_paths(lane: Lane, coverage: dict) -> list[str]:
467
+ """The measured files the runner did not write relative to this checkout,
468
+ spelled the way the artifact spells them."""
419
469
  reported = (_as_reported(lane, path) for path in coverage)
420
470
  return sorted(path for path in reported if _escapes_repo(path))
421
471
 
422
472
 
473
+ def _split_escaped(root: Path, escaped: list[str]) -> tuple[list[str], list[str]]:
474
+ """(paths from another tree, absolute paths that land under this root).
475
+
476
+ The root resolves once, and every path resolves the same way, or a symlinked
477
+ or short-name checkout compares unequal to its own files."""
478
+ resolved_root = _resolved(str(root))
479
+ elsewhere: list[str] = []
480
+ inside: list[str] = []
481
+ for path in escaped:
482
+ (inside if _lands_in_checkout(resolved_root, path) else elsewhere).append(path)
483
+ return elsewhere, inside
484
+
485
+
423
486
  def _sample(paths) -> str:
424
487
  """A few of them and a count of the rest. A lane scoped to forty declared
425
488
  paths listed all forty, which pushed the sentence saying what to do off the
@@ -447,11 +510,25 @@ _ISTANBUL_FIX = ("The reader rebases every path under this checkout's root, so t
447
510
  _COVERAGEPY_MISS = "or the runner reports paths this lane needs path_prefix to rebase"
448
511
  _ISTANBUL_MISS = "or the suite measured a part of the tree these scopes do not name"
449
512
 
513
+ # And the third case: this tree, spelled absolutely. Neither fix above applies —
514
+ # the environment is right and path_prefix only ever PREPENDS — so the knob is
515
+ # the runner's own, and each reader has a different one.
516
+ _COVERAGEPY_ABSOLUTE_FIX = ("Make the runner write relative paths: `relative_files = true` "
517
+ "under `[tool.coverage.run]` in pyproject.toml, or "
518
+ "`[run] relative_files = true` in .coveragerc, then rerun the lane")
519
+ _ISTANBUL_ABSOLUTE_FIX = ("The reader strips this checkout's root off every measured path "
520
+ "literally, so the reporter spelled that root some other way: point "
521
+ "it at this checkout with its own cwd/root option, then rerun the lane")
522
+
450
523
 
451
524
  def _wrong_tree_fix(lane: Lane) -> str:
452
525
  return _COVERAGEPY_FIX if lane.parser == "coveragepy" else _ISTANBUL_FIX
453
526
 
454
527
 
528
+ def _absolute_fix(lane: Lane) -> str:
529
+ return _COVERAGEPY_ABSOLUTE_FIX if lane.parser == "coveragepy" else _ISTANBUL_ABSOLUTE_FIX
530
+
531
+
455
532
  def _unmeasured_reading(lane: Lane) -> str:
456
533
  return _COVERAGEPY_MISS if lane.parser == "coveragepy" else _ISTANBUL_MISS
457
534
 
@@ -471,6 +548,14 @@ def _wrong_tree_message(lane: Lane, coverage: dict, declared, outside: list[str]
471
548
  f"paths like {_sample(outside)}. {_wrong_tree_fix(lane)}")
472
549
 
473
550
 
551
+ def _absolute_message(lane: Lane, coverage: dict, declared, inside: list[str]) -> str:
552
+ return (f"{_zero_overlap(lane, coverage, declared)}, and {len(inside)} of them written "
553
+ f"as absolute paths that DO sit under this checkout — {lane.artifact} measured "
554
+ f"this tree and spelled it absolutely, and the join is on root-relative paths, "
555
+ f"so it still matches nothing and every function in those scopes would score "
556
+ f"untested; it reports paths like {_sample(inside)}. {_absolute_fix(lane)}")
557
+
558
+
474
559
  def _unmeasured_message(lane: Lane, coverage: dict, declared) -> str:
475
560
  reports = f"; it measured {_sample(coverage)}" if coverage else ""
476
561
  return (f"{_zero_overlap(lane, coverage, declared)}, so every function in those "
@@ -478,7 +563,8 @@ def _unmeasured_message(lane: Lane, coverage: dict, declared) -> str:
478
563
  f"{_unmeasured_reading(lane)}")
479
564
 
480
565
 
481
- def _judge_artifact_scope(lane: Lane, coverage: dict, scope_paths: dict | None) -> None:
566
+ def _judge_artifact_scope(lane: Lane, coverage: dict, scope_paths: dict | None,
567
+ root: Path) -> None:
482
568
  """Say something when a lane's artifact reaches none of the scopes it claims.
483
569
 
484
570
  Coverage joins on path and nothing else, so such an artifact contributes
@@ -488,18 +574,29 @@ def _judge_artifact_scope(lane: Lane, coverage: dict, scope_paths: dict | None)
488
574
  answer. Two worktrees of one branch reach it quietly: a venv whose editable
489
575
  install points at the other checkout makes coverage.py measure that tree.
490
576
 
491
- Two verdicts, because zero overlap has two readings and the paths tell them
492
- apart. Measured files OUTSIDE this checkout can only be another tree, and
493
- that fails the lane. In-tree paths that simply miss the scopes are the
494
- greenfield shape as well — a suite that imports none of the scoped source
495
- yet, which SHOULD score untested — so that one warns and scores on.
577
+ Three verdicts, because zero overlap has three readings and the paths tell
578
+ them apart, against the root. Measured files OUTSIDE the root can only be
579
+ another tree, and that fails the lane. Absolute paths that resolve UNDER it
580
+ are this tree with the runner spelling every path absolutely: the join is
581
+ root-relative, so it matches nothing either, and that fails the lane too —
582
+ with the runner's own knob named, because the venv advice above is not the
583
+ cause and path_prefix only prepends. In-tree relative paths that simply miss
584
+ the scopes are the greenfield shape as well — a suite that imports none of
585
+ the scoped source yet, which SHOULD score untested — so that one warns and
586
+ scores on.
587
+
588
+ A mixed artifact is another tree. A path from somewhere else can only have
589
+ come from somewhere else, and the absolute in-tree ones are what the same
590
+ wrong run reports about the files it did reach.
496
591
  """
497
592
  declared = _unreached_paths(lane, coverage, scope_paths or {})
498
593
  if not declared:
499
594
  return
500
- outside = _outside_paths(lane, coverage)
501
- if outside:
502
- raise ToolError(_wrong_tree_message(lane, coverage, declared, outside))
595
+ elsewhere, inside = _split_escaped(root, _escaped_paths(lane, coverage))
596
+ if elsewhere:
597
+ raise ToolError(_wrong_tree_message(lane, coverage, declared, elsewhere))
598
+ if inside:
599
+ raise ToolError(_absolute_message(lane, coverage, declared, inside))
503
600
  print(f"crapkit: {_unmeasured_message(lane, coverage, declared)}", file=sys.stderr)
504
601
 
505
602
 
@@ -630,7 +727,7 @@ def run_lane(root: Path, lane: Lane, *, reuse_artifact: bool = False,
630
727
  _refuse_container_python(lane)
631
728
  exit_code, seconds = _run_or_reuse(root, lane, facts, scope_paths, reuse_artifact)
632
729
  coverage, digest = _read_and_parse(lane, root, _artifact_path(root, lane))
633
- _judge_artifact_scope(lane, coverage, scope_paths)
730
+ _judge_artifact_scope(lane, coverage, scope_paths, root)
634
731
  provenance = {
635
732
  "artifact_sha256": digest,
636
733
  "exit_code": exit_code,
@@ -303,9 +303,13 @@ def _live_lanes(lanes: tuple[LaneSpec, ...],
303
303
  return lines, covered
304
304
 
305
305
 
306
+ # The python in the coveragepy template is a placeholder for the same reason
307
+ # the scoped-tests entries carry one: what a reader uncomments has to be the
308
+ # command init would have written live. A repo whose lockfile pins `uv run`
309
+ # read a bare `python` here and got the environment bug one uncomment later.
306
310
  _TEMPLATES = {
307
311
  "coveragepy": ("# [[lane]]", '# name = "py"',
308
- '# command = "python -m pytest --cov --cov-branch '
312
+ '# command = "{python} -m pytest --cov --cov-branch '
309
313
  f'--cov-report=json:{_PY_ARTIFACT} --junitxml={_PY_RESULTS}"',
310
314
  f'# artifact = "{_PY_ARTIFACT}"',
311
315
  f'# results_artifact = "{_PY_RESULTS}"', '# parser = "coveragepy"'),
@@ -350,16 +354,21 @@ def _pytest_lane_launcher(lanes: tuple[LaneSpec, ...]) -> str | None:
350
354
  return None
351
355
 
352
356
 
353
- def python_launcher(lanes: tuple[LaneSpec, ...]) -> str:
354
- """The python invocation the detected pytest lane already settled on.
357
+ def python_launcher(lanes: tuple[LaneSpec, ...], fallback: str = _DEFAULT_PYTHON) -> str:
358
+ """The python invocation the detected pytest lane already settled on, and
359
+ `fallback` when no detected lane runs pytest.
355
360
 
356
361
  Read back off the lane rather than passed in beside it, so the scoped-tests
357
362
  entry runs the suite the way the coverage lane runs it and the two cannot
358
363
  drift: a `uv run python` lane with a bare `python` step 4 would measure one
359
364
  environment and test another.
365
+
366
+ A repo with no pytest marker file has no lane to read, and every python
367
+ line init writes for it is commented. `fallback` is what init would have
368
+ run had there been one, so those lines uncomment into the same environment.
360
369
  """
361
370
  launcher = _pytest_lane_launcher(lanes)
362
- return _DEFAULT_PYTHON if launcher is None else launcher
371
+ return fallback if launcher is None else launcher
363
372
 
364
373
 
365
374
  def _scoped_test_command(languages: tuple[str, ...], launcher: str) -> str:
@@ -423,7 +432,14 @@ def _commented_block(rest: dict[str, tuple[str, ...]], has_live: bool,
423
432
  return header + _scoped_entry_lines(rest, False, launcher)
424
433
 
425
434
 
426
- def _template_lines(covered: set[str], scopes: dict[str, tuple[str, ...]]) -> list[str]:
435
+ def _template_stanza(parser: str, launcher: str) -> list[str]:
436
+ """One commented lane template, with the python it names filled in. The js
437
+ templates carry no placeholder, so they come back as written."""
438
+ return [line.replace("{python}", launcher) for line in _TEMPLATES[parser]]
439
+
440
+
441
+ def _template_lines(covered: set[str], scopes: dict[str, tuple[str, ...]],
442
+ launcher: str = _DEFAULT_PYTHON) -> list[str]:
427
443
  """Commented lane templates for the parsers no live lane covers.
428
444
 
429
445
  None at all when every scope is cc-only. Neither parser reads any language
@@ -437,21 +453,25 @@ def _template_lines(covered: set[str], scopes: dict[str, tuple[str, ...]]) -> li
437
453
  lines: list[str] = []
438
454
  for parser in sorted(_TEMPLATES):
439
455
  if parser not in covered:
440
- lines += [*_TEMPLATES[parser], '# scopes = ["<your-scope>"]', ""]
456
+ lines += [*_template_stanza(parser, launcher), '# scopes = ["<your-scope>"]', ""]
441
457
  if not lines:
442
458
  return []
443
459
  return ["# Declare one [[lane]] per coverage command, then run `crapkit coverage`.", *lines]
444
460
 
445
461
 
446
- def starter_toml(scopes: dict[str, tuple[str, ...]], lanes: tuple[LaneSpec, ...] = ()) -> str:
462
+ def starter_toml(scopes: dict[str, tuple[str, ...]], lanes: tuple[LaneSpec, ...] = (),
463
+ *, interpreter: str = _DEFAULT_PYTHON) -> str:
464
+ """The starter crapkit.toml. `interpreter` is the python a committed config
465
+ on this repo can call, the lockfile's manager prefix included; every python
466
+ line the file holds names it, commented templates as much as live lanes."""
447
467
  lines = ["[crapkit]", "target = 6", ""]
448
468
  for name, languages in scopes.items():
449
469
  lines += _scope_stanza(name, languages)
450
470
  lines += _exclude_stanza()
451
471
  live, covered = _live_lanes(lanes, scopes)
452
- return "\n".join(lines + live + _template_lines(covered, scopes)
453
- + _scoped_tests_stub(scopes, _confirmed_languages(lanes),
454
- python_launcher(lanes)))
472
+ launcher = python_launcher(lanes, interpreter)
473
+ return "\n".join(lines + live + _template_lines(covered, scopes, launcher)
474
+ + _scoped_tests_stub(scopes, _confirmed_languages(lanes), launcher))
455
475
 
456
476
 
457
477
  _STORE_IGNORE = ".crapkit/"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crapkit
3
- Version: 0.4.6
3
+ Version: 0.4.7
4
4
  Summary: Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more.
5
5
  Author: Jean-Francois Gagne
6
6
  License: MIT
@@ -145,7 +145,7 @@ changing crapkit.
145
145
 
146
146
  ```
147
147
  $ crapkit --version
148
- crapkit 0.4.6
148
+ crapkit 0.4.7
149
149
  ```
150
150
 
151
151
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -227,6 +227,33 @@ ceiling. It adds no files to your repo, and it needs the crapkit CLI on PATH.
227
227
  A repo with no `crapkit.toml` costs a silent sub-50 ms no-op per edit. Other agent
228
228
  runtimes have no marketplace: copy `plugin/skills/*` into their skills directory instead.
229
229
 
230
+ The hook registers on `Edit|Write`, which is every write that names a file. An agent that
231
+ writes its source through a shell heredoc names none, so a `Bash` event is judged off the
232
+ working tree instead. That half is yours to register, because it costs two
233
+ git spawns per shell call. Add a second PostToolUse entry to your own settings, same
234
+ command, matcher `Bash`:
235
+
236
+ ```json
237
+ {
238
+ "hooks": {
239
+ "PostToolUse": [
240
+ {
241
+ "matcher": "Bash",
242
+ "hooks": [
243
+ { "type": "command", "command": "crapkit claude-hook --protocol 1", "timeout": 20 }
244
+ ]
245
+ }
246
+ ]
247
+ }
248
+ }
249
+ ```
250
+
251
+ The cost is one `git rev-parse --show-toplevel` and one `git status --porcelain -z -uall`
252
+ per shell call in any git repo, whether or not crapkit measures it: about 30 ms together
253
+ on crapkit's own checkout, and more on a bigger tree. What comes back is the dirty or
254
+ untracked `*.py` files written in the last 12 seconds, 25 at most, each judged the way an
255
+ edit is. Python only, so a TypeScript or Go repo pays the two spawns and hears nothing.
256
+
230
257
  ## Languages
231
258
 
232
259
  14 languages, two coverage parsers. Coverage joins where a parser exists; everything else
@@ -346,7 +373,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
346
373
  repos:
347
374
  - repo: https://github.com/JeanFrancoisGagne/crapkit
348
375
  # crapkit's release step rewrites this line to the tag it just cut
349
- rev: v0.4.6
376
+ rev: v0.4.7
350
377
  hooks:
351
378
  - id: crapkit-gate
352
379
  ```
@@ -488,7 +515,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
488
515
  | `mutate [--files F ...] [--max-mutants N] [--drop-pool] [--json]` | Diff-scoped mutation testing: flips comparisons, boundary shifts, boolean connectives and boolean literals on changed lines, runs `mutation_command` per mutant, lists survivors. `--files` replaces diff scope with the whole file. `--max-mutants` (default 100) caps the run and the cap warning goes to stderr only, so `mutants` in `--json` is the capped count. Shell and PowerShell files are refused by name on stderr rather than mutated: `<` and `>` are redirections there, not comparisons. With `mutation_workers > 1` the worker worktrees are kept at `.crapkit/mutate-pool/` and re-prepared per run (30.6 s to build four on a 31,459-file repo, 0.46 s to re-prepare them); `--drop-pool` removes them and exits. |
489
516
  | `test-scoped FILE ...` | Runs each owning scope's `[crapkit.scoped_tests]` template on the files (quoted, longest-prefix scope wins). A template with no `{files}` runs as written, which is how a scope whose tests live outside its own paths runs its whole suite. Exit code only; a nonzero runner exits 1. |
490
517
  | `hook-precommit` | The cc-only gate on staged blobs. No coverage, no snapshot, no repo-wide cache. Exit 6 on a violation. |
491
- | `claude-hook [--protocol N]` | Reads one Claude Code PostToolUse payload from stdin and judges the file it edited: ccn against the scope ceiling, on functions the edit changed, minus functions a ratchet mark already covers. Advisory only. The edit has landed, nothing is blocked, and `hook-precommit` stays the enforcement point. Exit 2 with three lines on stderr is the only thing it ever says: no `crapkit.toml` above the edited file, an unscoped file, mid-rebase or mid-merge, a `--protocol` other than 1, source that parses to no functions, or any internal failure all exit 0 in silence. Takes no `--repo`, because the root is the first `crapkit.toml` above the edited file and the upward walk stops at a `.git` entry, so a worktree never borrows its parent's config. It opens no snapshot and writes nothing. |
518
+ | `claude-hook [--protocol N]` | Reads one Claude Code PostToolUse payload from stdin and judges the file it edited: ccn against the scope ceiling, on functions the edit changed, minus functions a ratchet mark already covers. Advisory only: the edit has landed, and `hook-precommit` stays the enforcement point. Exit 2 and an advisory on stderr is the only thing it ever says, one block per judged file (a head line, one line per breaching function, a closing line): no `crapkit.toml` above the edited file, an unscoped file, mid-rebase or mid-merge, a `--protocol` other than 1, source that parses to no functions, or any internal failure all exit 0 in silence. The root is the first `crapkit.toml` above the edited file; the walk stops at a `.git` entry, so a worktree never borrows its parent's config. A `Bash` event names no file, so it judges the working tree instead: the dirty or untracked `*.py` files touched in the last 12 seconds, 25 at most, each through the same ladder, and silence for a clean tree or a cwd outside any repo. That half fires only where you register a `Bash` matcher ([The Claude Code plugin](#the-claude-code-plugin)). It opens no snapshot and writes nothing. |
492
519
  | `watch [--interval SECONDS] [--cycles N]` | Rescores tracked files as they change (mtime polling, default 2s, subprocess-isolated so a half-saved syntax error never kills the watcher). `--cycles N` polls exactly N times and exits 0; without it the loop runs until ctrl-c. |
493
520
  | `mcp` | A dependency-free stdio MCP server (newline JSON-RPC 2.0) exposing nine read-only tools. Every tool shells to the CLI's own `--json` surface, so the MCP view cannot drift from what the CLI reports. Answering from a kept in-process store was benchmarked and rejected: a packet's `source` would go stale behind the edit it describes. See [docs/agent-json.md](docs/agent-json.md#mcp-server). |
494
521
 
@@ -616,7 +643,7 @@ crapkit: run 3 is an inventory run (no coverage was measured) and cannot serve a
616
643
  | 2 | Usage error from argparse: unknown flag, missing positional. Raised before crapkit's own error handling. |
617
644
  | 3 | Config error: `crapkit.toml` missing or unparseable, an unknown language or parser, a lane command the shell that runs it reads as a narrowed suite, a ratchet metric-stamp mismatch ([Upgrading from 0.4.4](#upgrading-from-044)), a `test-scoped` file under no scope or under a scope with no template. |
618
645
  | 4 | Git error: not a repository, a baseline commit rewritten out of the history. |
619
- | 5 | Tool error: lizard not importable, a lane produced no artifact or one that measured a different tree, a lane timed out past its retries, an override alert command failed. A `timeout_seconds` kills the whole process tree, so no orphan suite keeps running behind the failure. |
646
+ | 5 | Tool error: lizard not importable, a lane that produced no artifact, one that measured a different tree, one that measured this tree and reported it in absolute paths (the join is root-relative, so those match nothing either; the refusal names the runner's own switch, `relative_files = true` under `[tool.coverage.run]` for a coveragepy lane, the reporter's `cwd`/`root` option for an istanbul one), a lane that timed out past its retries, an override alert command that failed. A `timeout_seconds` kills the whole process tree, so no orphan suite keeps running behind the failure. |
620
647
  | 6 | Gate violation. A function the diff touched is over its ceiling and past any ratchet mark it carries: an edit that leaves a marked function at or under its mark is the debt the repo signed for and is exempt. Also `rescore --gate`, which applies the same rule, and `hook-precommit`, which exempts on the mark's existence instead. |
621
648
  | 7 | Ratchet regression the diff never touched. A marked function scores worse than its recorded high-water mark; a touched one past its mark reports 6. |
622
649
  | 8 | New test failures against the baseline run. Failures the baseline already had do not count. |
@@ -671,7 +698,8 @@ coverage lane from what the repo already has: a pytest marker file (`pyproject.t
671
698
  the matching `run` for the rest), because a bare `python` binds to whichever venv the shell
672
699
  has active rather than the one the repo pins — see
673
700
  [The interpreter a lane binds to](docs/lanes.md#the-interpreter-a-lane-binds-to). Whatever
674
- it detects, it also leaves commented templates for the runners it did not find. Every lane
701
+ it detects, it also leaves commented templates for the runners it did not find, and those
702
+ carry the same launcher, so uncommenting one cannot hand the bare `python` back. Every lane
675
703
  it writes reports into `.crapkit/cov/`, which is why the `.gitignore` list is so short: see
676
704
  [Where artifacts live](docs/lanes.md#where-artifacts-live).
677
705
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes