crapkit 0.4.5__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.5/src/crapkit.egg-info → crapkit-0.4.7}/PKG-INFO +48 -9
- {crapkit-0.4.5 → crapkit-0.4.7}/README.md +45 -8
- {crapkit-0.4.5 → crapkit-0.4.7}/pyproject.toml +20 -3
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/__init__.py +1 -1
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/__init__.py +3 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/admin.py +97 -16
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/claude_hook.py +127 -4
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/covstream.py +10 -3
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/lanes.py +326 -9
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/scaffold.py +97 -18
- {crapkit-0.4.5 → crapkit-0.4.7/src/crapkit.egg-info}/PKG-INFO +48 -9
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit.egg-info/requires.txt +2 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/LICENSE +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/setup.cfg +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/__main__.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/_pygdefer.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/analyze.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cache.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/churn.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/churn_cache.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/churn_log.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/_shared.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/analyses.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/parser.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/queue.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/ratchet_cmds.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/reports.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/scoring.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/cli/verifying.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/config.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/coupling.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/coupling_cache.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/coverage_istanbul.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/coverage_py.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/diffparse.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/digest.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/discover.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/doctor.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/dup.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/errors.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/gitio.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/hook.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/junitparse.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/keys.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/lizardcognitive.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/lizardpowershell.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/lizardrust.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/lizardshell.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/mcp_server.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/merge.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/mutate.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/mutate_pool.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/override.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/packet.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/procs.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/ratchet.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/ratchet_report.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/report.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/sarif.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/sarifio.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/score.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/snapshot.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/store.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/uncovered.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/universe.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/verify.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/watch.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit/worklist.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit.egg-info/SOURCES.txt +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit.egg-info/dependency_links.txt +0 -0
- {crapkit-0.4.5 → crapkit-0.4.7}/src/crapkit.egg-info/entry_points.txt +0 -0
- {crapkit-0.4.5 → 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
|
|
@@ -31,8 +31,10 @@ Provides-Extra: dev
|
|
|
31
31
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
32
32
|
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
33
33
|
Requires-Dist: pytest-xdist>=3; extra == "dev"
|
|
34
|
+
Requires-Dist: coverage>=7.10.6; extra == "dev"
|
|
34
35
|
Provides-Extra: py
|
|
35
36
|
Requires-Dist: pytest-cov>=5; extra == "py"
|
|
37
|
+
Requires-Dist: coverage>=7.10.6; extra == "py"
|
|
36
38
|
Dynamic: license-file
|
|
37
39
|
|
|
38
40
|
# crapkit
|
|
@@ -143,7 +145,7 @@ changing crapkit.
|
|
|
143
145
|
|
|
144
146
|
```
|
|
145
147
|
$ crapkit --version
|
|
146
|
-
crapkit 0.4.
|
|
148
|
+
crapkit 0.4.7
|
|
147
149
|
```
|
|
148
150
|
|
|
149
151
|
`python -m crapkit` works identically to the console script and is what to use from a
|
|
@@ -225,6 +227,33 @@ ceiling. It adds no files to your repo, and it needs the crapkit CLI on PATH.
|
|
|
225
227
|
A repo with no `crapkit.toml` costs a silent sub-50 ms no-op per edit. Other agent
|
|
226
228
|
runtimes have no marketplace: copy `plugin/skills/*` into their skills directory instead.
|
|
227
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
|
+
|
|
228
257
|
## Languages
|
|
229
258
|
|
|
230
259
|
14 languages, two coverage parsers. Coverage joins where a parser exists; everything else
|
|
@@ -344,7 +373,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
|
|
|
344
373
|
repos:
|
|
345
374
|
- repo: https://github.com/JeanFrancoisGagne/crapkit
|
|
346
375
|
# crapkit's release step rewrites this line to the tag it just cut
|
|
347
|
-
rev: v0.4.
|
|
376
|
+
rev: v0.4.7
|
|
348
377
|
hooks:
|
|
349
378
|
- id: crapkit-gate
|
|
350
379
|
```
|
|
@@ -486,7 +515,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
|
|
|
486
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. |
|
|
487
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. |
|
|
488
517
|
| `hook-precommit` | The cc-only gate on staged blobs. No coverage, no snapshot, no repo-wide cache. Exit 6 on a violation. |
|
|
489
|
-
| `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. |
|
|
490
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. |
|
|
491
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). |
|
|
492
521
|
|
|
@@ -614,7 +643,7 @@ crapkit: run 3 is an inventory run (no coverage was measured) and cannot serve a
|
|
|
614
643
|
| 2 | Usage error from argparse: unknown flag, missing positional. Raised before crapkit's own error handling. |
|
|
615
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. |
|
|
616
645
|
| 4 | Git error: not a repository, a baseline commit rewritten out of the history. |
|
|
617
|
-
| 5 | Tool error: lizard not importable, a lane produced no artifact, 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. |
|
|
618
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. |
|
|
619
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. |
|
|
620
649
|
| 8 | New test failures against the baseline run. Failures the baseline already had do not count. |
|
|
@@ -647,6 +676,11 @@ pip install pytest-cov
|
|
|
647
676
|
|
|
648
677
|
(`pip install "crapkit[py]"` pulls both at once when crapkit shares the suite's venv.)
|
|
649
678
|
|
|
679
|
+
If your suite drives its own CLI through `subprocess.run`, add `[tool.coverage.run]
|
|
680
|
+
patch = ["subprocess"]` to `pyproject.toml` and keep `coverage>=7.10.6`: pytest-cov 7.0.0
|
|
681
|
+
dropped subprocess measurement, so without that key every entry point scores 0% and nothing
|
|
682
|
+
warns. [docs/lanes.md](docs/lanes.md) has the whole rule.
|
|
683
|
+
|
|
650
684
|
### 1. Scaffold the config
|
|
651
685
|
|
|
652
686
|
```
|
|
@@ -659,9 +693,14 @@ added to .gitignore: .crapkit/, .coverage, __pycache__/
|
|
|
659
693
|
`init` sniffs tracked source into one scope per top-level source directory, and detects a
|
|
660
694
|
coverage lane from what the repo already has: a pytest marker file (`pyproject.toml`,
|
|
661
695
|
`pytest.ini`, `setup.cfg`) writes a live `[[lane]]`, and so does a `test` script or
|
|
662
|
-
`vitest`/`jest` in `package.json`.
|
|
663
|
-
|
|
664
|
-
|
|
696
|
+
`vitest`/`jest` in `package.json`. A lockfile beside them names the environment: `uv.lock`,
|
|
697
|
+
`poetry.lock`, `pdm.lock` or `Pipfile.lock` makes the lane `uv run python -m pytest …` (and
|
|
698
|
+
the matching `run` for the rest), because a bare `python` binds to whichever venv the shell
|
|
699
|
+
has active rather than the one the repo pins — see
|
|
700
|
+
[The interpreter a lane binds to](docs/lanes.md#the-interpreter-a-lane-binds-to). Whatever
|
|
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
|
|
703
|
+
it writes reports into `.crapkit/cov/`, which is why the `.gitignore` list is so short: see
|
|
665
704
|
[Where artifacts live](docs/lanes.md#where-artifacts-live).
|
|
666
705
|
|
|
667
706
|
```toml
|
|
@@ -821,7 +860,7 @@ exit 5:
|
|
|
821
860
|
|
|
822
861
|
```
|
|
823
862
|
$ crapkit coverage
|
|
824
|
-
crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
|
|
863
|
+
crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); full log: /repo/.crapkit/lane-js.log; last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
|
|
825
864
|
|
|
826
865
|
MISSING DEPENDENCY Cannot find dependency '@vitest/coverage-v8'
|
|
827
866
|
|
|
@@ -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, 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. |
|
|
@@ -610,6 +637,11 @@ pip install pytest-cov
|
|
|
610
637
|
|
|
611
638
|
(`pip install "crapkit[py]"` pulls both at once when crapkit shares the suite's venv.)
|
|
612
639
|
|
|
640
|
+
If your suite drives its own CLI through `subprocess.run`, add `[tool.coverage.run]
|
|
641
|
+
patch = ["subprocess"]` to `pyproject.toml` and keep `coverage>=7.10.6`: pytest-cov 7.0.0
|
|
642
|
+
dropped subprocess measurement, so without that key every entry point scores 0% and nothing
|
|
643
|
+
warns. [docs/lanes.md](docs/lanes.md) has the whole rule.
|
|
644
|
+
|
|
613
645
|
### 1. Scaffold the config
|
|
614
646
|
|
|
615
647
|
```
|
|
@@ -622,9 +654,14 @@ added to .gitignore: .crapkit/, .coverage, __pycache__/
|
|
|
622
654
|
`init` sniffs tracked source into one scope per top-level source directory, and detects a
|
|
623
655
|
coverage lane from what the repo already has: a pytest marker file (`pyproject.toml`,
|
|
624
656
|
`pytest.ini`, `setup.cfg`) writes a live `[[lane]]`, and so does a `test` script or
|
|
625
|
-
`vitest`/`jest` in `package.json`.
|
|
626
|
-
|
|
627
|
-
|
|
657
|
+
`vitest`/`jest` in `package.json`. A lockfile beside them names the environment: `uv.lock`,
|
|
658
|
+
`poetry.lock`, `pdm.lock` or `Pipfile.lock` makes the lane `uv run python -m pytest …` (and
|
|
659
|
+
the matching `run` for the rest), because a bare `python` binds to whichever venv the shell
|
|
660
|
+
has active rather than the one the repo pins — see
|
|
661
|
+
[The interpreter a lane binds to](docs/lanes.md#the-interpreter-a-lane-binds-to). Whatever
|
|
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
|
|
664
|
+
it writes reports into `.crapkit/cov/`, which is why the `.gitignore` list is so short: see
|
|
628
665
|
[Where artifacts live](docs/lanes.md#where-artifacts-live).
|
|
629
666
|
|
|
630
667
|
```toml
|
|
@@ -784,7 +821,7 @@ exit 5:
|
|
|
784
821
|
|
|
785
822
|
```
|
|
786
823
|
$ crapkit coverage
|
|
787
|
-
crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
|
|
824
|
+
crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); full log: /repo/.crapkit/lane-js.log; last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
|
|
788
825
|
|
|
789
826
|
MISSING DEPENDENCY Cannot find dependency '@vitest/coverage-v8'
|
|
790
827
|
|
|
@@ -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" }
|
|
@@ -63,14 +63,19 @@ Source = "https://github.com/JeanFrancoisGagne/crapkit"
|
|
|
63
63
|
# about 8 minutes serial against about 1m30 parallel on the same tree. Nothing
|
|
64
64
|
# here needs pytest-timeout: no test takes a `--timeout` and none carries the
|
|
65
65
|
# marker.
|
|
66
|
-
dev = ["pytest>=8", "pytest-cov>=5", "pytest-xdist>=3"]
|
|
66
|
+
dev = ["pytest>=8", "pytest-cov>=5", "pytest-xdist>=3", "coverage>=7.10.6"]
|
|
67
|
+
# coverage>=7.10.6 in both extras is the floor `[tool.coverage.run] patch` needs,
|
|
68
|
+
# and it is not decoration: coverage 7.9 answers an unrecognized `[run] patch=`
|
|
69
|
+
# with a CoverageWarning that pytest-cov 6.2.0 and later file under
|
|
70
|
+
# `once::CoverageWarning`, so an older coverage takes the key, says so once in a
|
|
71
|
+
# warnings block nobody reads, and measures no subprocess at all.
|
|
67
72
|
# The py lane's runtime half, for installs that share the suite's venv: the
|
|
68
73
|
# lane init writes runs `pytest --cov`, and those flags come from pytest-cov.
|
|
69
74
|
# An install elsewhere (pipx, uv tool) still needs pytest-cov beside the SUITE;
|
|
70
75
|
# init probes the python that runs pytest, not its own, and names
|
|
71
76
|
# `pip install "crapkit[py]"` when those are the same environment. Double
|
|
72
77
|
# quotes: single ones do not survive cmd.exe.
|
|
73
|
-
py = ["pytest-cov>=5"]
|
|
78
|
+
py = ["pytest-cov>=5", "coverage>=7.10.6"]
|
|
74
79
|
|
|
75
80
|
[project.scripts]
|
|
76
81
|
crapkit = "crapkit.cli:main"
|
|
@@ -85,3 +90,15 @@ where = ["src"]
|
|
|
85
90
|
[tool.pytest.ini_options]
|
|
86
91
|
testpaths = ["tests"]
|
|
87
92
|
addopts = "-q --tb=short -p no:cacheprovider"
|
|
93
|
+
|
|
94
|
+
# Every CLI entry point crapkit ships is exercised through `subprocess.run` by
|
|
95
|
+
# tests/e2e and by nothing else, so without a subprocess measurement `cmd_init`
|
|
96
|
+
# and its siblings read 0% and the gate fires on any `cmd_*` a diff touches.
|
|
97
|
+
# pytest-cov 7.0.0 dropped the mechanism that used to do this (its own
|
|
98
|
+
# `pytest-cov.pth` reading COV_CORE_*) and pointed at coverage's own patch
|
|
99
|
+
# system, added in coverage 7.10. Measured on tests/e2e/test_init_doctor_e2e.py:
|
|
100
|
+
# src/crapkit/cli/admin.py scores 0/498 statements under pytest-cov 7.1.0 with
|
|
101
|
+
# this key absent, and 317/498 with it present, on 7.1.0 and 6.3.0 alike.
|
|
102
|
+
# `parallel = true` follows automatically; .coverage* is already gitignored.
|
|
103
|
+
[tool.coverage.run]
|
|
104
|
+
patch = ["subprocess"]
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crapkit: deterministic CRAP-score framework."""
|
|
2
|
-
__version__ = "0.4.
|
|
2
|
+
__version__ = "0.4.7"
|
|
@@ -107,6 +107,7 @@ _OWNER = {
|
|
|
107
107
|
"_extend_gitignore": "admin",
|
|
108
108
|
"_fewer_tests_line": "verifying",
|
|
109
109
|
"_file_sizer": "_shared",
|
|
110
|
+
"_first_word": "admin",
|
|
110
111
|
"_flag_counts": "scoring",
|
|
111
112
|
"_flake_retry": "verifying",
|
|
112
113
|
"_gate_candidates": "scoring",
|
|
@@ -190,6 +191,7 @@ _OWNER = {
|
|
|
190
191
|
"_plugin_json": "admin",
|
|
191
192
|
"_plugins_dir": "admin",
|
|
192
193
|
"_policy_findings": "ratchet_cmds",
|
|
194
|
+
"_present_lockfiles": "admin",
|
|
193
195
|
"_present_markers": "admin",
|
|
194
196
|
"_present_on_disk": "scoring",
|
|
195
197
|
"_prior_crap": "verifying",
|
|
@@ -218,6 +220,7 @@ _OWNER = {
|
|
|
218
220
|
"_pruned": "ratchet_cmds",
|
|
219
221
|
"_pushdown_floor": "queue",
|
|
220
222
|
"_pytest_cov_probe": "admin",
|
|
223
|
+
"_python_name": "admin",
|
|
221
224
|
"_range_lines": "analyses",
|
|
222
225
|
"_rankable": "queue",
|
|
223
226
|
"_ratchet_entries": "_shared",
|
|
@@ -23,7 +23,13 @@ from ..universe import assign_files, scan_files
|
|
|
23
23
|
from ._shared import _file_sizer, _load_repo_config, _print_json
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def
|
|
26
|
+
def _present_lockfiles(root: Path) -> frozenset[str]:
|
|
27
|
+
from ..scaffold import LOCKFILE_RUNNERS
|
|
28
|
+
|
|
29
|
+
return frozenset(name for name, _ in LOCKFILE_RUNNERS if (root / name).is_file())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _python_name() -> str:
|
|
27
33
|
"""The interpreter name a committed config can call. sys.executable is this
|
|
28
34
|
machine's absolute path and would not survive the repo reaching anyone else.
|
|
29
35
|
|
|
@@ -41,6 +47,25 @@ def _interpreter() -> str:
|
|
|
41
47
|
return "python3"
|
|
42
48
|
|
|
43
49
|
|
|
50
|
+
def _interpreter(root: Path) -> str:
|
|
51
|
+
"""The python invocation a committed config can call.
|
|
52
|
+
|
|
53
|
+
A lockfile at the root wins: `uv run python` and its siblings resolve to the
|
|
54
|
+
environment the repo pins, while a bare `python` resolves to whichever venv
|
|
55
|
+
the shell happens to have active — which in two worktrees of one branch is
|
|
56
|
+
how a lane measures the OTHER checkout and scores this one untested.
|
|
57
|
+
|
|
58
|
+
The manager only PREFIXES the name; which name it prefixes is still
|
|
59
|
+
`_python_name`'s answer, so a Windows PATH carrying no `python` gets
|
|
60
|
+
`uv run py` rather than a word the shell cannot start.
|
|
61
|
+
"""
|
|
62
|
+
from ..scaffold import lockfile_runner
|
|
63
|
+
|
|
64
|
+
runner = lockfile_runner(_present_lockfiles(root))
|
|
65
|
+
name = _python_name()
|
|
66
|
+
return f"{runner} {name}" if runner else name
|
|
67
|
+
|
|
68
|
+
|
|
44
69
|
def _present_markers(root: Path) -> frozenset[str]:
|
|
45
70
|
from ..scaffold import PYTEST_MARKERS
|
|
46
71
|
|
|
@@ -137,6 +162,14 @@ def _start_probe(word: str) -> int | None:
|
|
|
137
162
|
return None
|
|
138
163
|
|
|
139
164
|
|
|
165
|
+
def _first_word(command: str) -> str:
|
|
166
|
+
"""The word the shell will try to start, read the way that shell reads the
|
|
167
|
+
line: a quoted interpreter path stays one word, where a whitespace split
|
|
168
|
+
would break it at its space. "" when the line holds no word at all."""
|
|
169
|
+
words = shell_words(command)
|
|
170
|
+
return words[0] if words else ""
|
|
171
|
+
|
|
172
|
+
|
|
140
173
|
def _dead_first_word(command: str) -> tuple[str, int] | None:
|
|
141
174
|
"""The command's first word and the shell's verdict, when the shell cannot
|
|
142
175
|
start it. None when it starts, and None when the word does not resolve on
|
|
@@ -144,11 +177,11 @@ def _dead_first_word(command: str) -> tuple[str, int] | None:
|
|
|
144
177
|
proves nothing."""
|
|
145
178
|
import shutil
|
|
146
179
|
|
|
147
|
-
|
|
148
|
-
if not
|
|
180
|
+
word = _first_word(command)
|
|
181
|
+
if not word or shutil.which(word) is None:
|
|
149
182
|
return None
|
|
150
|
-
code = _start_probe(
|
|
151
|
-
return (
|
|
183
|
+
code = _start_probe(word)
|
|
184
|
+
return (word, code) if _could_not_run_it(code) else None
|
|
152
185
|
|
|
153
186
|
|
|
154
187
|
def _probe_answered_no(returncode: int) -> bool:
|
|
@@ -189,7 +222,14 @@ def _probe_interpreter(command: str) -> str | None:
|
|
|
189
222
|
"""The python this lane runs pytest with, or None when no python runs it.
|
|
190
223
|
`coverage run -m pytest` names no interpreter at all: the probe asked
|
|
191
224
|
`coverage` to import pytest_cov, read its argument error as a missing
|
|
192
|
-
package, and printed the pip note on a machine where pytest_cov imports.
|
|
225
|
+
package, and printed the pip note on a machine where pytest_cov imports.
|
|
226
|
+
|
|
227
|
+
An environment manager heads its segment for the same reason: `uv run` and
|
|
228
|
+
its siblings CREATE or sync the project environment before running anything,
|
|
229
|
+
so init has no business provisioning one to ask a question about it, and the
|
|
230
|
+
head word is not a python — `uv -c "import pytest_cov"` is not the probe it
|
|
231
|
+
looks like, and would have warned about the wrong gap on every uv repo.
|
|
232
|
+
"""
|
|
193
233
|
segment = _pytest_segment(command)
|
|
194
234
|
if not segment or not _is_python(segment[0]):
|
|
195
235
|
return None
|
|
@@ -202,7 +242,8 @@ def _pytest_cov_probe(command: str) -> bool:
|
|
|
202
242
|
the lane will get (a .bat shim included), not the one CreateProcess finds.
|
|
203
243
|
True too when the probe cannot run — only a clean "no" earns the warning,
|
|
204
244
|
and a missing interpreter is doctor's finding, not this one's. True as well
|
|
205
|
-
when no python runs the suite
|
|
245
|
+
when no python runs the suite, `uv run python -m pytest` included: nothing
|
|
246
|
+
here can be asked."""
|
|
206
247
|
import shutil
|
|
207
248
|
|
|
208
249
|
from ..procs import run_bounded
|
|
@@ -256,11 +297,41 @@ def _missing_pytest_cov_note(name: str) -> str:
|
|
|
256
297
|
"then `crapkit coverage`")
|
|
257
298
|
|
|
258
299
|
|
|
300
|
+
def _absent_manager(command: str) -> str | None:
|
|
301
|
+
"""The environment manager heading this lane, when this machine's PATH
|
|
302
|
+
carries no such word. None when it resolves, and None when nothing manages
|
|
303
|
+
the lane at all.
|
|
304
|
+
|
|
305
|
+
A lockfile is a property of the REPO, so `init` writes `uv run python` off
|
|
306
|
+
its presence alone — right for the repo, and unrunnable on a checkout whose
|
|
307
|
+
owner installed the dependencies with pip. Nothing else catches it: the
|
|
308
|
+
start check skips a word that does not resolve, and the pytest-cov probe
|
|
309
|
+
refuses to provision an environment to ask a question about it.
|
|
310
|
+
"""
|
|
311
|
+
import shutil
|
|
312
|
+
|
|
313
|
+
from ..scaffold import LOCKFILE_RUNNERS
|
|
314
|
+
|
|
315
|
+
managers = {runner.split()[0] for _, runner in LOCKFILE_RUNNERS}
|
|
316
|
+
head = _first_word(command)
|
|
317
|
+
return head if head in managers and shutil.which(head) is None else None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _missing_manager_note(name: str, manager: str) -> str:
|
|
321
|
+
return (f"note: lane {name!r} runs through `{manager}`, which this machine's PATH does "
|
|
322
|
+
f"not carry — install {manager}, or point the lane's command in crapkit.toml at "
|
|
323
|
+
f"an interpreter that resolves here, then `crapkit coverage`")
|
|
324
|
+
|
|
325
|
+
|
|
259
326
|
def _lane_first_run_note(lane) -> str | None:
|
|
260
327
|
"""What init owes this lane before the first `crapkit coverage`, or None
|
|
261
|
-
when the lane will run.
|
|
262
|
-
sentence:
|
|
263
|
-
|
|
328
|
+
when the lane will run. Three different gaps, and they are not the same
|
|
329
|
+
sentence: a manager that is not installed never gets as far as a python, and
|
|
330
|
+
an interpreter that never started answered nothing about pytest_cov, so
|
|
331
|
+
`pip install pytest-cov` fixes neither."""
|
|
332
|
+
manager = _absent_manager(lane.command)
|
|
333
|
+
if manager:
|
|
334
|
+
return _missing_manager_note(lane.name, manager)
|
|
264
335
|
dead = _dead_first_word(lane.command)
|
|
265
336
|
if dead:
|
|
266
337
|
return _dead_interpreter_note(lane.name, *dead)
|
|
@@ -279,9 +350,16 @@ def _warn_missing_pytest_cov(lanes: tuple) -> None:
|
|
|
279
350
|
"""The first-run trap, caught where it starts. The py lane shells out to
|
|
280
351
|
`pytest --cov`, and the --cov flags come from pytest-cov — a package of the
|
|
281
352
|
REPO's interpreter, so a crapkit dependency could only ever cover installs
|
|
282
|
-
sharing the suite's venv.
|
|
283
|
-
|
|
284
|
-
|
|
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
|
+
"""
|
|
285
363
|
for lane in _probed_lanes(lanes):
|
|
286
364
|
note = _lane_first_run_note(lane)
|
|
287
365
|
if note:
|
|
@@ -315,9 +393,12 @@ def cmd_init(args: argparse.Namespace) -> int:
|
|
|
315
393
|
raise ConfigError(_no_scopes_reason(root))
|
|
316
394
|
# A config whose lanes are all commented out scores every function no-lane,
|
|
317
395
|
# so a fresh repo cannot rank anything until somebody hand-writes a lane.
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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)
|
|
321
402
|
load_config_text(text) # self-check: never write a config crapkit cannot read back
|
|
322
403
|
toml_path.write_text(text, encoding="utf-8", newline="\n")
|
|
323
404
|
_print_init_summary(scopes, lanes)
|