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.
- {crapkit-0.4.6/src/crapkit.egg-info → crapkit-0.4.7}/PKG-INFO +34 -6
- {crapkit-0.4.6 → crapkit-0.4.7}/README.md +33 -5
- {crapkit-0.4.6 → crapkit-0.4.7}/pyproject.toml +1 -1
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/__init__.py +1 -1
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/admin.py +16 -6
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/claude_hook.py +127 -4
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/lanes.py +115 -18
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/scaffold.py +30 -10
- {crapkit-0.4.6 → crapkit-0.4.7/src/crapkit.egg-info}/PKG-INFO +34 -6
- {crapkit-0.4.6 → crapkit-0.4.7}/LICENSE +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/setup.cfg +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/__main__.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/_pygdefer.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/analyze.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cache.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/churn.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/churn_cache.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/churn_log.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/__init__.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/_shared.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/analyses.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/parser.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/queue.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/ratchet_cmds.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/reports.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/scoring.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/cli/verifying.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/config.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/coupling.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/coupling_cache.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/coverage_istanbul.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/coverage_py.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/covstream.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/diffparse.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/digest.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/discover.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/doctor.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/dup.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/errors.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/gitio.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/hook.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/junitparse.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/keys.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/lizardcognitive.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/lizardpowershell.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/lizardrust.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/lizardshell.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/mcp_server.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/merge.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/mutate.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/mutate_pool.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/override.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/packet.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/procs.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/ratchet.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/ratchet_report.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/report.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/sarif.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/sarifio.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/score.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/snapshot.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/store.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/uncovered.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/universe.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/verify.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/watch.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit/worklist.py +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit.egg-info/SOURCES.txt +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit.egg-info/dependency_links.txt +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit.egg-info/entry_points.txt +0 -0
- {crapkit-0.4.6 → crapkit-0.4.7}/src/crapkit.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
66
|
-
if not edited:
|
|
82
|
+
if args.protocol != PROTOCOL:
|
|
67
83
|
return 0
|
|
68
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
417
|
-
"""The measured files
|
|
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
|
|
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
|
-
|
|
492
|
-
apart. Measured files OUTSIDE
|
|
493
|
-
that fails the lane.
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
501
|
-
if
|
|
502
|
-
raise ToolError(_wrong_tree_message(lane, coverage, declared,
|
|
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
|
|
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
|
|
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 += [*
|
|
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, ...] = ()
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|