crapkit 0.4.1__tar.gz → 0.4.2__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.1/src/crapkit.egg-info → crapkit-0.4.2}/PKG-INFO +33 -13
- {crapkit-0.4.1 → crapkit-0.4.2}/README.md +31 -12
- {crapkit-0.4.1 → crapkit-0.4.2}/pyproject.toml +6 -2
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/__init__.py +1 -1
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/analyze.py +6 -1
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/__init__.py +3 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/admin.py +27 -7
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/queue.py +14 -3
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/scoring.py +24 -3
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/verifying.py +18 -2
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/config.py +17 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/lizardcognitive.py +37 -7
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/mcp_server.py +11 -5
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/packet.py +36 -1
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/scaffold.py +29 -4
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/store.py +37 -13
- {crapkit-0.4.1 → crapkit-0.4.2/src/crapkit.egg-info}/PKG-INFO +33 -13
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit.egg-info/requires.txt +1 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/LICENSE +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/setup.cfg +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/__main__.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/_pygdefer.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cache.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/churn.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/churn_cache.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/churn_log.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/_shared.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/analyses.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/claude_hook.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/parser.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/ratchet_cmds.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/cli/reports.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/coupling.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/coverage_istanbul.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/coverage_py.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/covstream.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/diffparse.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/digest.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/discover.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/doctor.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/dup.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/errors.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/gitio.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/hook.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/junitparse.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/lanes.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/lizardpowershell.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/lizardrust.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/lizardshell.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/merge.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/mutate.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/mutate_pool.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/override.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/ratchet.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/ratchet_report.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/report.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/sarif.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/sarifio.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/score.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/snapshot.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/uncovered.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/universe.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/verify.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/watch.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit/worklist.py +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit.egg-info/SOURCES.txt +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit.egg-info/dependency_links.txt +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/src/crapkit.egg-info/entry_points.txt +0 -0
- {crapkit-0.4.1 → crapkit-0.4.2}/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.2
|
|
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
|
|
@@ -28,6 +28,7 @@ Requires-Dist: lizard>=1.24.0
|
|
|
28
28
|
Provides-Extra: dev
|
|
29
29
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
30
30
|
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-xdist>=3; extra == "dev"
|
|
31
32
|
Dynamic: license-file
|
|
32
33
|
|
|
33
34
|
# crapkit
|
|
@@ -115,7 +116,7 @@ changing crapkit.
|
|
|
115
116
|
|
|
116
117
|
```
|
|
117
118
|
$ crapkit --version
|
|
118
|
-
crapkit 0.4.
|
|
119
|
+
crapkit 0.4.2
|
|
119
120
|
```
|
|
120
121
|
|
|
121
122
|
`python -m crapkit` works identically to the console script and is what to use from a
|
|
@@ -165,10 +166,16 @@ lane. Nothing about it is provisional: the ceiling still binds and the gate stil
|
|
|
165
166
|
a function over it. Add a coverage lane the day a parser exists and the same scope starts
|
|
166
167
|
joining coverage.
|
|
167
168
|
|
|
169
|
+
`crapkit init` writes that key itself, on every scope whose languages all lack a parser,
|
|
170
|
+
and leaves it off any scope a lane could still measure. So the 60-second start above runs
|
|
171
|
+
unchanged on a Go, Rust or shell repo: `crapkit coverage` scores it with no lane at all,
|
|
172
|
+
and that run is the baseline `worklist`, `next-item`, `ratchet seed` and `verify` read.
|
|
173
|
+
|
|
168
174
|
Three readers are crapkit's own. lizard ships none for shell or PowerShell, so crapkit
|
|
169
175
|
counts their functions itself. Its Rust reader scores a 7-arm `match` as ccn 2 (filed as
|
|
170
176
|
lizard #494), so crapkit counts each non-wildcard arm like a C `case` and retires the
|
|
171
|
-
override the day upstream fixes it.
|
|
177
|
+
override the day upstream fixes it. The cognitive column charges that same block once,
|
|
178
|
+
the way Sonar charges a `switch`.
|
|
172
179
|
|
|
173
180
|
## The gate
|
|
174
181
|
|
|
@@ -178,8 +185,8 @@ different powers:
|
|
|
178
185
|
| Surface | Fires | Power |
|
|
179
186
|
|---|---|---|
|
|
180
187
|
| `crapkit claude-hook` | after an agent's edit lands | **advisory.** Names the breach on stderr. Blocks nothing, because PostToolUse runs after the write |
|
|
181
|
-
| `crapkit rescore FILE --gate` | when you ask | **preview.** The commit gate's verdict on demand, sub-second, before you stage |
|
|
182
|
-
| `crapkit hook-precommit` | `git commit` | **blocks.**
|
|
188
|
+
| `crapkit rescore FILE --gate` | when you ask, after the first coverage run | **preview.** The commit gate's verdict on demand, sub-second, before you stage. With no run behind it, exit 1 and `no snapshot` |
|
|
189
|
+
| `crapkit hook-precommit` | `git commit` | **blocks.** The hook exits 6; git reports 1. Staged blobs only, so it costs the size of the commit and needs no coverage |
|
|
183
190
|
| `crapkit verify` | before you push, and in CI | **the verdict.** Gate, ratchet, new test failures, diff coverage, against the trusted baseline |
|
|
184
191
|
|
|
185
192
|
Both hooks exempt a function the committed ratchet already carries a mark for, so touching
|
|
@@ -239,7 +246,8 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
|
|
|
239
246
|
```yaml
|
|
240
247
|
repos:
|
|
241
248
|
- repo: https://github.com/JeanFrancoisGagne/crapkit
|
|
242
|
-
|
|
249
|
+
# crapkit's release step rewrites this line to the tag it just cut
|
|
250
|
+
rev: v0.4.2
|
|
243
251
|
hooks:
|
|
244
252
|
- id: crapkit-gate
|
|
245
253
|
```
|
|
@@ -275,7 +283,9 @@ crapkit gate: 1 staged function(s) exceed the complexity ceiling of 6:
|
|
|
275
283
|
decompose before committing (coverage cannot save a function above the target).
|
|
276
284
|
```
|
|
277
285
|
|
|
278
|
-
|
|
286
|
+
That commit exited **1**, not 6. Git collapses any failed hook to 1, so 6 is a code you
|
|
287
|
+
only ever see by running the hook yourself: `crapkit hook-precommit` exits 6 on a
|
|
288
|
+
violation and 0 otherwise. The stderr block above is the same either way.
|
|
279
289
|
|
|
280
290
|
`CRAPKIT_OVERRIDE_REASON` is not a bypass. Setting it routes the commit through the full
|
|
281
291
|
three-record audit: an alert line through `alert_command`, a ratchet entry staged into the
|
|
@@ -316,7 +326,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
|
|
|
316
326
|
| `next-item [--top N] [--exclude FRAG] [--scope NAME] [--claim]` | The actionable queue as JSON, with churn, budget estimates and uncovered lines. Same run and same admission floor as `worklist`, a different view of it: `no-lane` rows are skipped and counted in `skipped_no_lane`, and what is left is ranked by `crap` descending rather than by risk, so the item it hands out is often not the worklist's first row. `--exclude FRAG` (repeatable) skips items whose path or function name contains FRAG; `--scope NAME` (repeatable) is exact, not a substring. `--claim` holds what it hands out so a second session skips it. `stale` is true when the ranked run's commit is not HEAD, the same field `worklist` carries. Every item carries a `handle`: the bare identifier, or `(anonymous)#N` for a function with no name, which is the name form that survives the edit the item asks for. |
|
|
317
327
|
| `claims [list \| release PATH NAME \| release --all] [--json]` | The open claims, and the way to hand one back without waiting for a verify. `release` takes the bare identifier, the whole long name, or the `handle` the claim was taken under, which is the only one that picks out a single `(anonymous)` claim. |
|
|
318
328
|
| `brief FILE NAME [--batch N] [--json]` | The start-editing packet for one function: its own `source` text, every function in the file, the scored row and the scope ceiling, the ratchet mark and what the gate will bind on, uncovered lines, duplication twins, file churn, coupling partners, the config's notes, and the literal commands for the rest of the loop. Plus `handle`, `remedy` and the same `est_splits` / `est_uncovered_paths` the queue prints, and a `commands.refresh` that writes a run (`refresh_writes_run`) rather than re-reading the stale one. `NAME` takes the bare identifier, the long name `next-item` printed, the function's start line, or `(anonymous)#N` for a function printed `(anonymous)`, counting the file's anonymous functions from the top. `--batch N` drops the positionals and emits `packets[]` instead: the top N of the queue, built from one read of the store. |
|
|
319
|
-
| `explain FILE NAME [--history] [--tests] [--json]` | A function's score across runs plus its mark. `--history` adds the commits that touched it (`git log -L`), each carrying its message `body`, `--tests` the tests that covered it, which needs coverage.py contexts turned on ([recipe](docs/lanes.md#test-attribution-for-explain---tests)). `--json` emits the same content as one `schema` 1 object. |
|
|
329
|
+
| `explain FILE NAME [--history] [--tests] [--json]` | A function's score across runs plus its mark. `NAME` resolves exact first: a function whose bare identifier or long name is exactly `NAME` wins, and only when nothing matches exactly does it fall back to a prefix match, so `route` explains `route` rather than every `route_*` beside it. `--history` adds the commits that touched it (`git log -L`), each carrying its message `body`, `--tests` the tests that covered it, which needs coverage.py contexts turned on ([recipe](docs/lanes.md#test-attribution-for-explain---tests)). `--json` emits the same content as one `schema` 1 object. |
|
|
320
330
|
| `rescore FILE ... [--gate] [--json]` | Fresh complexity for named files over the latest run's stale coverage, joined by name. Advisory: it writes no run. `--gate` applies the pre-commit hook's policy to the same selection the hook uses (functions the tree changed since HEAD), minus functions a ratchet mark already covers, and exits 6. |
|
|
321
331
|
| `ratchet seed \| prune \| merge \| move \| report [--enforce] [--json]` | The mark lifecycle: seed new debt, prune gone code (a mark whose file git renamed follows it), merge as a git driver, move re-paths marks, report reads burn-down from the file's own git history. See [docs/ratchet.md](docs/ratchet.md). |
|
|
322
332
|
| `runs [list \| prune [--keep N]] [--json]` | Run history, and retention. `list` marks the run `verify` compares against today `baseline`, and prints `verdict=-` for a run that produces no verdict rather than one that failed. See [The trusted baseline](#the-trusted-baseline). `--keep` (default 5) is a floor on the newest trusted runs, not a cap: the digest pair, every passing verify baseline, every run an override names, and the newest non-hook run are kept too. `prune` VACUUMs afterwards. |
|
|
@@ -536,6 +546,11 @@ makes it step 4 of the burn-down loop. Every key is in
|
|
|
536
546
|
|
|
537
547
|
```
|
|
538
548
|
$ crapkit doctor
|
|
549
|
+
ok config keys all recognized
|
|
550
|
+
ok scope 'calc': 1 files
|
|
551
|
+
ok every tracked source file belongs to a scope
|
|
552
|
+
ok 1 lane(s) declared
|
|
553
|
+
ok lizard 1.24.0
|
|
539
554
|
doctor: no problems found
|
|
540
555
|
```
|
|
541
556
|
|
|
@@ -567,11 +582,12 @@ worklist has none.
|
|
|
567
582
|
|
|
568
583
|
```
|
|
569
584
|
$ crapkit next-item
|
|
570
|
-
{"commit": "fae4db93108b4841a00959f9117430679e7250ca", "empty": false, "item": {"authors": 1, "ccn": 14, "ccn_std": 14, "cognitive": 13, "commits": 1, "cov": 0.5, "crap": 38.5, "end": 28, "est_splits": 3, "est_uncovered_paths": 7, "flag": "measured", "function": "classify( score , attempts , late , bonus )", "nesting": 8, "nloc": 22, "path": "calc/grade.py", "remedy": "decompose", "scope": "calc", "start": 7, "target": 6, "uncovered_lines": [9, 11, 15, 17, 19, 24, 25, 26, 27, 28]}, "run_id": 1, "schema": 1, "skipped_no_lane": 0}
|
|
585
|
+
{"commit": "fae4db93108b4841a00959f9117430679e7250ca", "empty": false, "item": {"authors": 1, "ccn": 14, "ccn_std": 14, "cognitive": 13, "commits": 1, "cov": 0.5, "crap": 38.5, "end": 28, "est_splits": 3, "est_uncovered_paths": 7, "flag": "measured", "function": "classify( score , attempts , late , bonus )", "handle": "classify", "nesting": 8, "nloc": 22, "path": "calc/grade.py", "remedy": "decompose", "scope": "calc", "start": 7, "target": 6, "uncovered_lines": [9, 11, 15, 17, 19, 24, 25, 26, 27, 28]}, "run_id": 1, "schema": 1, "skipped_no_lane": 0, "stale": false}
|
|
571
586
|
```
|
|
572
587
|
|
|
573
588
|
`remedy: "decompose"`, `est_splits: 3` (this needs roughly three pieces to fit under 6),
|
|
574
|
-
and `uncovered_lines` naming the ten lines no test walks.
|
|
589
|
+
and `uncovered_lines` naming the ten lines no test walks. `handle` is the name form to
|
|
590
|
+
pass back, and `stale: false` says the run still describes HEAD. Every field is in
|
|
575
591
|
[docs/agent-json.md](docs/agent-json.md).
|
|
576
592
|
|
|
577
593
|
### 5. Seed the ratchet
|
|
@@ -648,17 +664,21 @@ crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/cov
|
|
|
648
664
|
crapkit: every lane failed: ...
|
|
649
665
|
```
|
|
650
666
|
|
|
651
|
-
|
|
667
|
+
That failure **writes no run**. Every lane failed, so `coverage` exits before it opens a
|
|
668
|
+
store: there is no `.crapkit/crap.sqlite` yet and the run ids below still start at 1.
|
|
669
|
+
|
|
670
|
+
Install the provider, and pin the major yourself. Unpinned, npm resolves the newest
|
|
671
|
+
provider against your older vitest and refuses the tree:
|
|
652
672
|
|
|
653
673
|
```
|
|
654
|
-
npm i -D @vitest/coverage-v8
|
|
674
|
+
npm i -D "@vitest/coverage-v8@<your vitest major>"
|
|
655
675
|
```
|
|
656
676
|
|
|
657
677
|
| Question | Answer |
|
|
658
678
|
|---|---|
|
|
659
679
|
| Which provider? | Either works. `@vitest/coverage-v8` is vitest's default and needs no config. `@vitest/coverage-istanbul` also works and needs `coverage.provider = "istanbul"` in your vitest config. |
|
|
660
680
|
| Which crapkit parser? | Both feed `parser = "istanbul"`. The provider name and the parser name are unrelated: v8 output is remapped to the istanbul JSON schema before it is written. |
|
|
661
|
-
| Which version? |
|
|
681
|
+
| Which version? | The provider's major has to match vitest's. On vitest 2 that is `npm i -D "@vitest/coverage-v8@2"`, on vitest 3 `npm i -D "@vitest/coverage-v8@3"`. Drop the pin and npm answers `ERESOLVE unable to resolve dependency tree`, naming the peer it could not satisfy. |
|
|
662
682
|
|
|
663
683
|
The artifact crapkit wants is `coverage-final.json`, written by vitest's `json` coverage
|
|
664
684
|
reporter, which is on by default. If your vitest config sets `coverage.reporter`
|
|
@@ -83,7 +83,7 @@ changing crapkit.
|
|
|
83
83
|
|
|
84
84
|
```
|
|
85
85
|
$ crapkit --version
|
|
86
|
-
crapkit 0.4.
|
|
86
|
+
crapkit 0.4.2
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
`python -m crapkit` works identically to the console script and is what to use from a
|
|
@@ -133,10 +133,16 @@ lane. Nothing about it is provisional: the ceiling still binds and the gate stil
|
|
|
133
133
|
a function over it. Add a coverage lane the day a parser exists and the same scope starts
|
|
134
134
|
joining coverage.
|
|
135
135
|
|
|
136
|
+
`crapkit init` writes that key itself, on every scope whose languages all lack a parser,
|
|
137
|
+
and leaves it off any scope a lane could still measure. So the 60-second start above runs
|
|
138
|
+
unchanged on a Go, Rust or shell repo: `crapkit coverage` scores it with no lane at all,
|
|
139
|
+
and that run is the baseline `worklist`, `next-item`, `ratchet seed` and `verify` read.
|
|
140
|
+
|
|
136
141
|
Three readers are crapkit's own. lizard ships none for shell or PowerShell, so crapkit
|
|
137
142
|
counts their functions itself. Its Rust reader scores a 7-arm `match` as ccn 2 (filed as
|
|
138
143
|
lizard #494), so crapkit counts each non-wildcard arm like a C `case` and retires the
|
|
139
|
-
override the day upstream fixes it.
|
|
144
|
+
override the day upstream fixes it. The cognitive column charges that same block once,
|
|
145
|
+
the way Sonar charges a `switch`.
|
|
140
146
|
|
|
141
147
|
## The gate
|
|
142
148
|
|
|
@@ -146,8 +152,8 @@ different powers:
|
|
|
146
152
|
| Surface | Fires | Power |
|
|
147
153
|
|---|---|---|
|
|
148
154
|
| `crapkit claude-hook` | after an agent's edit lands | **advisory.** Names the breach on stderr. Blocks nothing, because PostToolUse runs after the write |
|
|
149
|
-
| `crapkit rescore FILE --gate` | when you ask | **preview.** The commit gate's verdict on demand, sub-second, before you stage |
|
|
150
|
-
| `crapkit hook-precommit` | `git commit` | **blocks.**
|
|
155
|
+
| `crapkit rescore FILE --gate` | when you ask, after the first coverage run | **preview.** The commit gate's verdict on demand, sub-second, before you stage. With no run behind it, exit 1 and `no snapshot` |
|
|
156
|
+
| `crapkit hook-precommit` | `git commit` | **blocks.** The hook exits 6; git reports 1. Staged blobs only, so it costs the size of the commit and needs no coverage |
|
|
151
157
|
| `crapkit verify` | before you push, and in CI | **the verdict.** Gate, ratchet, new test failures, diff coverage, against the trusted baseline |
|
|
152
158
|
|
|
153
159
|
Both hooks exempt a function the committed ratchet already carries a mark for, so touching
|
|
@@ -207,7 +213,8 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
|
|
|
207
213
|
```yaml
|
|
208
214
|
repos:
|
|
209
215
|
- repo: https://github.com/JeanFrancoisGagne/crapkit
|
|
210
|
-
|
|
216
|
+
# crapkit's release step rewrites this line to the tag it just cut
|
|
217
|
+
rev: v0.4.2
|
|
211
218
|
hooks:
|
|
212
219
|
- id: crapkit-gate
|
|
213
220
|
```
|
|
@@ -243,7 +250,9 @@ crapkit gate: 1 staged function(s) exceed the complexity ceiling of 6:
|
|
|
243
250
|
decompose before committing (coverage cannot save a function above the target).
|
|
244
251
|
```
|
|
245
252
|
|
|
246
|
-
|
|
253
|
+
That commit exited **1**, not 6. Git collapses any failed hook to 1, so 6 is a code you
|
|
254
|
+
only ever see by running the hook yourself: `crapkit hook-precommit` exits 6 on a
|
|
255
|
+
violation and 0 otherwise. The stderr block above is the same either way.
|
|
247
256
|
|
|
248
257
|
`CRAPKIT_OVERRIDE_REASON` is not a bypass. Setting it routes the commit through the full
|
|
249
258
|
three-record audit: an alert line through `alert_command`, a ratchet entry staged into the
|
|
@@ -284,7 +293,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
|
|
|
284
293
|
| `next-item [--top N] [--exclude FRAG] [--scope NAME] [--claim]` | The actionable queue as JSON, with churn, budget estimates and uncovered lines. Same run and same admission floor as `worklist`, a different view of it: `no-lane` rows are skipped and counted in `skipped_no_lane`, and what is left is ranked by `crap` descending rather than by risk, so the item it hands out is often not the worklist's first row. `--exclude FRAG` (repeatable) skips items whose path or function name contains FRAG; `--scope NAME` (repeatable) is exact, not a substring. `--claim` holds what it hands out so a second session skips it. `stale` is true when the ranked run's commit is not HEAD, the same field `worklist` carries. Every item carries a `handle`: the bare identifier, or `(anonymous)#N` for a function with no name, which is the name form that survives the edit the item asks for. |
|
|
285
294
|
| `claims [list \| release PATH NAME \| release --all] [--json]` | The open claims, and the way to hand one back without waiting for a verify. `release` takes the bare identifier, the whole long name, or the `handle` the claim was taken under, which is the only one that picks out a single `(anonymous)` claim. |
|
|
286
295
|
| `brief FILE NAME [--batch N] [--json]` | The start-editing packet for one function: its own `source` text, every function in the file, the scored row and the scope ceiling, the ratchet mark and what the gate will bind on, uncovered lines, duplication twins, file churn, coupling partners, the config's notes, and the literal commands for the rest of the loop. Plus `handle`, `remedy` and the same `est_splits` / `est_uncovered_paths` the queue prints, and a `commands.refresh` that writes a run (`refresh_writes_run`) rather than re-reading the stale one. `NAME` takes the bare identifier, the long name `next-item` printed, the function's start line, or `(anonymous)#N` for a function printed `(anonymous)`, counting the file's anonymous functions from the top. `--batch N` drops the positionals and emits `packets[]` instead: the top N of the queue, built from one read of the store. |
|
|
287
|
-
| `explain FILE NAME [--history] [--tests] [--json]` | A function's score across runs plus its mark. `--history` adds the commits that touched it (`git log -L`), each carrying its message `body`, `--tests` the tests that covered it, which needs coverage.py contexts turned on ([recipe](docs/lanes.md#test-attribution-for-explain---tests)). `--json` emits the same content as one `schema` 1 object. |
|
|
296
|
+
| `explain FILE NAME [--history] [--tests] [--json]` | A function's score across runs plus its mark. `NAME` resolves exact first: a function whose bare identifier or long name is exactly `NAME` wins, and only when nothing matches exactly does it fall back to a prefix match, so `route` explains `route` rather than every `route_*` beside it. `--history` adds the commits that touched it (`git log -L`), each carrying its message `body`, `--tests` the tests that covered it, which needs coverage.py contexts turned on ([recipe](docs/lanes.md#test-attribution-for-explain---tests)). `--json` emits the same content as one `schema` 1 object. |
|
|
288
297
|
| `rescore FILE ... [--gate] [--json]` | Fresh complexity for named files over the latest run's stale coverage, joined by name. Advisory: it writes no run. `--gate` applies the pre-commit hook's policy to the same selection the hook uses (functions the tree changed since HEAD), minus functions a ratchet mark already covers, and exits 6. |
|
|
289
298
|
| `ratchet seed \| prune \| merge \| move \| report [--enforce] [--json]` | The mark lifecycle: seed new debt, prune gone code (a mark whose file git renamed follows it), merge as a git driver, move re-paths marks, report reads burn-down from the file's own git history. See [docs/ratchet.md](docs/ratchet.md). |
|
|
290
299
|
| `runs [list \| prune [--keep N]] [--json]` | Run history, and retention. `list` marks the run `verify` compares against today `baseline`, and prints `verdict=-` for a run that produces no verdict rather than one that failed. See [The trusted baseline](#the-trusted-baseline). `--keep` (default 5) is a floor on the newest trusted runs, not a cap: the digest pair, every passing verify baseline, every run an override names, and the newest non-hook run are kept too. `prune` VACUUMs afterwards. |
|
|
@@ -504,6 +513,11 @@ makes it step 4 of the burn-down loop. Every key is in
|
|
|
504
513
|
|
|
505
514
|
```
|
|
506
515
|
$ crapkit doctor
|
|
516
|
+
ok config keys all recognized
|
|
517
|
+
ok scope 'calc': 1 files
|
|
518
|
+
ok every tracked source file belongs to a scope
|
|
519
|
+
ok 1 lane(s) declared
|
|
520
|
+
ok lizard 1.24.0
|
|
507
521
|
doctor: no problems found
|
|
508
522
|
```
|
|
509
523
|
|
|
@@ -535,11 +549,12 @@ worklist has none.
|
|
|
535
549
|
|
|
536
550
|
```
|
|
537
551
|
$ crapkit next-item
|
|
538
|
-
{"commit": "fae4db93108b4841a00959f9117430679e7250ca", "empty": false, "item": {"authors": 1, "ccn": 14, "ccn_std": 14, "cognitive": 13, "commits": 1, "cov": 0.5, "crap": 38.5, "end": 28, "est_splits": 3, "est_uncovered_paths": 7, "flag": "measured", "function": "classify( score , attempts , late , bonus )", "nesting": 8, "nloc": 22, "path": "calc/grade.py", "remedy": "decompose", "scope": "calc", "start": 7, "target": 6, "uncovered_lines": [9, 11, 15, 17, 19, 24, 25, 26, 27, 28]}, "run_id": 1, "schema": 1, "skipped_no_lane": 0}
|
|
552
|
+
{"commit": "fae4db93108b4841a00959f9117430679e7250ca", "empty": false, "item": {"authors": 1, "ccn": 14, "ccn_std": 14, "cognitive": 13, "commits": 1, "cov": 0.5, "crap": 38.5, "end": 28, "est_splits": 3, "est_uncovered_paths": 7, "flag": "measured", "function": "classify( score , attempts , late , bonus )", "handle": "classify", "nesting": 8, "nloc": 22, "path": "calc/grade.py", "remedy": "decompose", "scope": "calc", "start": 7, "target": 6, "uncovered_lines": [9, 11, 15, 17, 19, 24, 25, 26, 27, 28]}, "run_id": 1, "schema": 1, "skipped_no_lane": 0, "stale": false}
|
|
539
553
|
```
|
|
540
554
|
|
|
541
555
|
`remedy: "decompose"`, `est_splits: 3` (this needs roughly three pieces to fit under 6),
|
|
542
|
-
and `uncovered_lines` naming the ten lines no test walks.
|
|
556
|
+
and `uncovered_lines` naming the ten lines no test walks. `handle` is the name form to
|
|
557
|
+
pass back, and `stale: false` says the run still describes HEAD. Every field is in
|
|
543
558
|
[docs/agent-json.md](docs/agent-json.md).
|
|
544
559
|
|
|
545
560
|
### 5. Seed the ratchet
|
|
@@ -616,17 +631,21 @@ crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/cov
|
|
|
616
631
|
crapkit: every lane failed: ...
|
|
617
632
|
```
|
|
618
633
|
|
|
619
|
-
|
|
634
|
+
That failure **writes no run**. Every lane failed, so `coverage` exits before it opens a
|
|
635
|
+
store: there is no `.crapkit/crap.sqlite` yet and the run ids below still start at 1.
|
|
636
|
+
|
|
637
|
+
Install the provider, and pin the major yourself. Unpinned, npm resolves the newest
|
|
638
|
+
provider against your older vitest and refuses the tree:
|
|
620
639
|
|
|
621
640
|
```
|
|
622
|
-
npm i -D @vitest/coverage-v8
|
|
641
|
+
npm i -D "@vitest/coverage-v8@<your vitest major>"
|
|
623
642
|
```
|
|
624
643
|
|
|
625
644
|
| Question | Answer |
|
|
626
645
|
|---|---|
|
|
627
646
|
| Which provider? | Either works. `@vitest/coverage-v8` is vitest's default and needs no config. `@vitest/coverage-istanbul` also works and needs `coverage.provider = "istanbul"` in your vitest config. |
|
|
628
647
|
| Which crapkit parser? | Both feed `parser = "istanbul"`. The provider name and the parser name are unrelated: v8 output is remapped to the istanbul JSON schema before it is written. |
|
|
629
|
-
| Which version? |
|
|
648
|
+
| Which version? | The provider's major has to match vitest's. On vitest 2 that is `npm i -D "@vitest/coverage-v8@2"`, on vitest 3 `npm i -D "@vitest/coverage-v8@3"`. Drop the pin and npm answers `ERESOLVE unable to resolve dependency tree`, naming the peer it could not satisfy. |
|
|
630
649
|
|
|
631
650
|
The artifact crapkit wants is `coverage-final.json`, written by vitest's `json` coverage
|
|
632
651
|
reporter, which is on by default. If your vitest config sets `coverage.reporter`
|
|
@@ -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.2"
|
|
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" }
|
|
@@ -48,7 +48,11 @@ Issues = "https://github.com/JeanFrancoisGagne/crapkit/issues"
|
|
|
48
48
|
Source = "https://github.com/JeanFrancoisGagne/crapkit"
|
|
49
49
|
|
|
50
50
|
[project.optional-dependencies]
|
|
51
|
-
|
|
51
|
+
# pytest-xdist is not a convenience: tests/fixtures/mini_repo declares a lane
|
|
52
|
+
# that shells out to `pytest pylib -n 2 ...`, and pytest rejects `-n` outright
|
|
53
|
+
# without it. Left out of this list it took down 31 e2e tests on every one of
|
|
54
|
+
# the six CI test jobs (run 33225190806), ubuntu and windows alike.
|
|
55
|
+
dev = ["pytest>=8", "pytest-cov>=5", "pytest-xdist>=3"]
|
|
52
56
|
|
|
53
57
|
[project.scripts]
|
|
54
58
|
crapkit = "crapkit.cli:main"
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crapkit: deterministic CRAP-score framework."""
|
|
2
|
-
__version__ = "0.4.
|
|
2
|
+
__version__ = "0.4.2"
|
|
@@ -49,7 +49,12 @@ _POOL_THRESHOLD = 16
|
|
|
49
49
|
# Bump whenever analysis semantics change (merge rules, extension set, record
|
|
50
50
|
# extraction): the fingerprint must invalidate cached records produced by older
|
|
51
51
|
# logic even when file content and tool versions are identical.
|
|
52
|
-
ANALYSIS_VERSION =
|
|
52
|
+
ANALYSIS_VERSION = 7 # 7: a Rust `match` is a cognitive condition (+1 and the
|
|
53
|
+
# nesting it sits in), so every cached .rs record
|
|
54
|
+
# carries a cognitive score measured without it. Rust
|
|
55
|
+
# is the only language whose stored values move; ccn is
|
|
56
|
+
# untouched in every language, Rust included.
|
|
57
|
+
# 6: five more C-family extension sets and .ps1/.psm1 are
|
|
53
58
|
# admitted, a C++ rvalue reference is no longer a
|
|
54
59
|
# cognitive condition, and source bytes decode utf-8
|
|
55
60
|
# then cp1252 instead of by machine locale
|
|
@@ -152,6 +152,7 @@ _OWNER = {
|
|
|
152
152
|
"_next_item_payload": "queue",
|
|
153
153
|
"_next_ranked": "queue",
|
|
154
154
|
"_next_reasons": "queue",
|
|
155
|
+
"_next_step": "admin",
|
|
155
156
|
"_no_baseline": "verifying",
|
|
156
157
|
"_no_handle_message": "queue",
|
|
157
158
|
"_no_lane_debt": "queue",
|
|
@@ -210,6 +211,8 @@ _OWNER = {
|
|
|
210
211
|
"_reconfigure_streams": "parser",
|
|
211
212
|
"_records": "claude_hook",
|
|
212
213
|
"_records_by_scope": "scoring",
|
|
214
|
+
"_refuse_empty_lane_run": "scoring",
|
|
215
|
+
"_refuse_lane_less_verify": "verifying",
|
|
213
216
|
"_release_claims": "verifying",
|
|
214
217
|
"_release_target": "queue",
|
|
215
218
|
"_report": "claude_hook",
|
|
@@ -39,14 +39,30 @@ def _package_json(root: Path) -> str:
|
|
|
39
39
|
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def _next_step(scopes: dict, lanes: tuple) -> str:
|
|
43
|
+
"""What to run next, which is not the same sentence in all three cases.
|
|
44
|
+
|
|
45
|
+
A repo whose every language is cc-only was told to declare a lane per
|
|
46
|
+
coverage command. There is no lane to declare: neither parser reads Go,
|
|
47
|
+
Rust, shell or the six others, `init` just wrote `coverage_optional` for
|
|
48
|
+
each scope, and `crapkit coverage` scores them from complexity alone.
|
|
49
|
+
"""
|
|
50
|
+
from ..scaffold import cc_only_scope
|
|
51
|
+
|
|
52
|
+
if lanes:
|
|
53
|
+
return (f"detected {len(lanes)} lane(s) from this repo's own files: "
|
|
54
|
+
f"{', '.join(lane.name for lane in lanes)} — next: run `crapkit coverage`")
|
|
55
|
+
if all(cc_only_scope(languages) for languages in scopes.values()):
|
|
56
|
+
return ("no coverage parser reads this repo's languages, so every scope is "
|
|
57
|
+
"cc-only (coverage_optional = true) and needs no lane — next: run "
|
|
58
|
+
"`crapkit coverage`")
|
|
59
|
+
return ("next: declare a [[lane]] per coverage command (see the commented template), "
|
|
60
|
+
"then run `crapkit coverage`")
|
|
61
|
+
|
|
62
|
+
|
|
42
63
|
def _print_init_summary(scopes: dict, lanes: tuple) -> None:
|
|
43
64
|
print(f"wrote crapkit.toml with {len(scopes)} scope(s): {', '.join(scopes)}")
|
|
44
|
-
|
|
45
|
-
print(f"detected {len(lanes)} lane(s) from this repo's own files: "
|
|
46
|
-
f"{', '.join(lane.name for lane in lanes)} — next: run `crapkit coverage`")
|
|
47
|
-
return
|
|
48
|
-
print("next: declare a [[lane]] per coverage command (see the commented template), "
|
|
49
|
-
"then run `crapkit coverage`")
|
|
65
|
+
print(_next_step(scopes, lanes))
|
|
50
66
|
|
|
51
67
|
|
|
52
68
|
def _no_scopes_reason(root: Path) -> str:
|
|
@@ -214,9 +230,13 @@ def _lane_command_problems(root: Path, lane) -> list[str]:
|
|
|
214
230
|
|
|
215
231
|
|
|
216
232
|
def _doctor_lane_summary(cfg) -> Finding:
|
|
233
|
+
"""No lanes is a gap in most repos and the finished state in a cc-only one,
|
|
234
|
+
where every scope declares coverage_optional and `coverage` runs anyway."""
|
|
217
235
|
if cfg.lanes:
|
|
218
236
|
return Finding("ok", f"{len(cfg.lanes)} lane(s) declared")
|
|
219
|
-
|
|
237
|
+
if cfg.lane_less_scopes:
|
|
238
|
+
return Finding("note", "no [[lane]] declared — inventory works; coverage needs one")
|
|
239
|
+
return Finding("ok", "no [[lane]] declared: every scope is cc-only, so none is needed")
|
|
220
240
|
|
|
221
241
|
|
|
222
242
|
def _lane_problems(root: Path, cfg) -> list[str]:
|
|
@@ -359,8 +359,13 @@ def cmd_claims(args: argparse.Namespace) -> int:
|
|
|
359
359
|
|
|
360
360
|
|
|
361
361
|
def _name_prefix(long_name: str) -> str:
|
|
362
|
-
"""The identifier a long_name opens with, before its parameter list.
|
|
363
|
-
|
|
362
|
+
"""The identifier a long_name opens with, before its parameter list.
|
|
363
|
+
|
|
364
|
+
packet.bare_name is the definition. This was a second copy of the cut, and
|
|
365
|
+
it is what kept `brief` rejecting a Rust or Go bare name after the packet
|
|
366
|
+
that published it had already been fixed.
|
|
367
|
+
"""
|
|
368
|
+
return packet.bare_name(long_name)
|
|
364
369
|
|
|
365
370
|
|
|
366
371
|
def _name_matches(long_name: str, name: str) -> bool:
|
|
@@ -375,7 +380,13 @@ def _name_matches(long_name: str, name: str) -> bool:
|
|
|
375
380
|
|
|
376
381
|
|
|
377
382
|
def _matching_rows(rows: list, name: str) -> list:
|
|
378
|
-
|
|
383
|
+
"""The rows NAME resolves to, by the rule `explain` runs on the same string.
|
|
384
|
+
|
|
385
|
+
Both commands go through `packet.matching_names`, so a name that picks one
|
|
386
|
+
function in a packet cannot pick three in a trajectory.
|
|
387
|
+
"""
|
|
388
|
+
wanted = set(packet.matching_names([r.long_name for r in rows], name))
|
|
389
|
+
return [r for r in rows if r.long_name in wanted]
|
|
379
390
|
|
|
380
391
|
|
|
381
392
|
def _no_match_message(path: str, name: str, rows: list, candidates: list) -> str:
|
|
@@ -194,7 +194,9 @@ def _collect_lanes(root: Path, lanes, outcomes: dict):
|
|
|
194
194
|
stamps[lane.artifact] = outcome.stamp
|
|
195
195
|
succeeded.append(lane)
|
|
196
196
|
write_stamps(root, stamps)
|
|
197
|
-
|
|
197
|
+
# `lane_errors` and not `lanes`: a cc-only repo declares no lanes, so nothing
|
|
198
|
+
# succeeded and nothing failed either, and that run is still a scored run.
|
|
199
|
+
if lane_errors and not succeeded:
|
|
198
200
|
raise ToolError(f"every lane failed: {'; '.join(lane_errors.values())}")
|
|
199
201
|
return coverage_by_path, provenance, lane_errors, succeeded
|
|
200
202
|
|
|
@@ -254,11 +256,30 @@ def _run_kind(lanes, cfg, failures) -> str:
|
|
|
254
256
|
return "coverage" if full else "partial"
|
|
255
257
|
|
|
256
258
|
|
|
259
|
+
def _refuse_empty_lane_run(cfg, requested) -> None:
|
|
260
|
+
"""Refuse a run that selected no lane, unless running with none is right.
|
|
261
|
+
|
|
262
|
+
Right for a repo whose every scope declares `coverage_optional`: nine of the
|
|
263
|
+
fourteen languages have no coverage parser at all, such a repo is scored
|
|
264
|
+
from complexity alone, and demanding a lane refused exactly the repos that
|
|
265
|
+
can never supply one. Wrong the moment one scope has neither, which is what
|
|
266
|
+
the message names — the scopes, not the empty list, because the list is a
|
|
267
|
+
symptom and the scopes are the gap.
|
|
268
|
+
"""
|
|
269
|
+
if requested is not None:
|
|
270
|
+
raise ConfigError(f"no lane named {requested!r}")
|
|
271
|
+
if cfg.lane_less_scopes:
|
|
272
|
+
raise ConfigError(
|
|
273
|
+
f"no [[lane]] to run for scope(s) {', '.join(cfg.lane_less_scopes)} — declare a "
|
|
274
|
+
"[[lane]] measuring them in crapkit.toml, or set coverage_optional = true on a "
|
|
275
|
+
"scope no coverage parser can read")
|
|
276
|
+
|
|
277
|
+
|
|
257
278
|
def _select_lanes(cfg, requested):
|
|
279
|
+
"""The lanes this run executes; an empty list is a legitimate answer."""
|
|
258
280
|
lanes = [l for l in cfg.lanes if requested is None or l.name == requested]
|
|
259
281
|
if not lanes:
|
|
260
|
-
|
|
261
|
-
else f"no lane named {requested!r}")
|
|
282
|
+
_refuse_empty_lane_run(cfg, requested)
|
|
262
283
|
return lanes
|
|
263
284
|
|
|
264
285
|
|
|
@@ -320,6 +320,23 @@ def _report_verify(as_json: bool, out: dict, verdict, overridden) -> None:
|
|
|
320
320
|
_print_finding_split(verdict)
|
|
321
321
|
|
|
322
322
|
|
|
323
|
+
def _refuse_lane_less_verify(cfg) -> None:
|
|
324
|
+
"""Refuse a verdict on a scope nothing measures and no key excuses.
|
|
325
|
+
|
|
326
|
+
Stricter than `coverage`, on purpose. `coverage` scores such a scope and
|
|
327
|
+
flags every row `no-lane`, because scoring is its whole job; `verify` calls
|
|
328
|
+
coverage half the verdict, so an unmeasured scope leaves it with half an
|
|
329
|
+
answer and it says so instead. The test that a cc-only repo passes is that
|
|
330
|
+
`lane_less_scopes` is empty, not that lanes exist: such a repo has none and
|
|
331
|
+
never will, and its verdict is the gate and the ratchet, neither of which
|
|
332
|
+
needs a coverage number.
|
|
333
|
+
"""
|
|
334
|
+
if cfg.lane_less_scopes:
|
|
335
|
+
raise ConfigError(f"verify needs a [[lane]] for scope(s) {', '.join(cfg.lane_less_scopes)}"
|
|
336
|
+
" — coverage is half the verdict; a scope no coverage parser can read "
|
|
337
|
+
"declares coverage_optional = true instead")
|
|
338
|
+
|
|
339
|
+
|
|
323
340
|
def cmd_verify(args: argparse.Namespace) -> int:
|
|
324
341
|
from ..diffparse import changed_ranges
|
|
325
342
|
from ..gitio import GitFacts, diff_since
|
|
@@ -328,8 +345,7 @@ def cmd_verify(args: argparse.Namespace) -> int:
|
|
|
328
345
|
|
|
329
346
|
root = Path(args.repo).resolve()
|
|
330
347
|
cfg = _load_repo_config(root)
|
|
331
|
-
|
|
332
|
-
raise ConfigError("verify needs [[lane]] declarations — coverage is half the verdict")
|
|
348
|
+
_refuse_lane_less_verify(cfg)
|
|
333
349
|
store = _verify_store(root, args.baseline_tsv)
|
|
334
350
|
_guard_ratchet_stamp(root / cfg.ratchet_file, cfg.ratchet_file)
|
|
335
351
|
# One context for the whole command: the ancestry checks, the lane runner and
|
|
@@ -110,6 +110,23 @@ class Config(NamedTuple):
|
|
|
110
110
|
"""The scopes scored cc-only: no coverage join, and no lane required."""
|
|
111
111
|
return frozenset(s.name for s in self.scopes if s.coverage_optional)
|
|
112
112
|
|
|
113
|
+
@property
|
|
114
|
+
def lane_less_scopes(self) -> tuple[str, ...]:
|
|
115
|
+
"""Scopes no lane measures and no `coverage_optional` excuses.
|
|
116
|
+
|
|
117
|
+
Empty is the licence to run with no lanes at all: every scope is either
|
|
118
|
+
measured by one or scored cc-only, so there is nothing left for a lane
|
|
119
|
+
to say.
|
|
120
|
+
|
|
121
|
+
The two readers weigh a non-empty answer differently, and the difference
|
|
122
|
+
is deliberate. `verify` refuses outright and names the list. `coverage`
|
|
123
|
+
refuses only when it also selected no lane, and otherwise scores the
|
|
124
|
+
scope and flags every row `no-lane` — a flag it could not print at all
|
|
125
|
+
if owing a lane refused the run. `doctor` is what says so out of band.
|
|
126
|
+
"""
|
|
127
|
+
covered = {s for lane in self.lanes for s in lane.scopes} | self.coverage_optional_scopes
|
|
128
|
+
return tuple(s.name for s in self.scopes if s.name not in covered)
|
|
129
|
+
|
|
113
130
|
@property
|
|
114
131
|
def scope_paths(self) -> dict[str, tuple[str, ...]]:
|
|
115
132
|
"""Every scope's declared paths, by name — what a lane's scopes resolve to."""
|
|
@@ -10,9 +10,14 @@ same rules with no second parse and no new dependency:
|
|
|
10
10
|
try / finally / case labels / with are free; nesting rises inside the
|
|
11
11
|
block structures listed above.
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
anything, and costs
|
|
13
|
+
Two language-specific rules:
|
|
14
|
+
* in C/C++ and Objective-C/C++ a `&&` before the function's opening brace
|
|
15
|
+
declares an rvalue reference rather than deciding anything, and costs
|
|
16
|
+
nothing. See `_declarator_and`.
|
|
17
|
+
* in Rust a `match` is a switch and is charged as one, +1 and +nesting with
|
|
18
|
+
the arms free. It is read only for the Rust readers because `match` is a
|
|
19
|
+
soft keyword in Python, where the same spelling is an ordinary identifier.
|
|
20
|
+
See `_counting_match`.
|
|
16
21
|
|
|
17
22
|
Attribution follows lizard's function splitting (a nested arrow's tokens are
|
|
18
23
|
the arrow's), exactly as ccn is attributed today. Ternary branches do not
|
|
@@ -53,14 +58,22 @@ _RUN_RESETS = frozenset({";", ",", "{", "}"})
|
|
|
53
58
|
# lose a real one.
|
|
54
59
|
_DECLARATOR_READERS = frozenset({"CLikeReader", "ObjCReader"})
|
|
55
60
|
|
|
61
|
+
# The readers whose `match` is Rust's switch: lizard's own, and crapkit's
|
|
62
|
+
# subclass of it. Exact names for the same reason `_DECLARATOR_READERS` uses
|
|
63
|
+
# them — the discriminator is the language, and an issubclass test would also
|
|
64
|
+
# catch anything a later lizard derives from RustReader for another one.
|
|
65
|
+
_MATCH_READERS = frozenset({"RustReader", "CorrectedRustReader"})
|
|
66
|
+
|
|
56
67
|
|
|
57
68
|
class _FnState:
|
|
58
69
|
__slots__ = ("total", "stack", "brace_depth", "line_indent", "at_line_start",
|
|
59
70
|
"pending", "else_pending", "question_pending", "bool_op", "name",
|
|
60
|
-
"recursed", "body_started", "prev", "label_check", "c_family"
|
|
71
|
+
"recursed", "body_started", "prev", "label_check", "c_family",
|
|
72
|
+
"match_kw")
|
|
61
73
|
|
|
62
|
-
def __init__(self, name: str, c_family: bool = False):
|
|
74
|
+
def __init__(self, name: str, c_family: bool = False, match_kw: bool = False):
|
|
63
75
|
self.c_family = c_family
|
|
76
|
+
self.match_kw = match_kw
|
|
64
77
|
self.total = 0
|
|
65
78
|
self.stack = [] # (entry_brace_depth) or python header indents
|
|
66
79
|
self.brace_depth = 0
|
|
@@ -91,11 +104,13 @@ class LizardExtension:
|
|
|
91
104
|
reader_name = type(reader).__name__
|
|
92
105
|
is_python = reader_name.lower().startswith("python")
|
|
93
106
|
c_family = reader_name in _DECLARATOR_READERS
|
|
107
|
+
match_kw = reader_name in _MATCH_READERS
|
|
94
108
|
for token in tokens:
|
|
95
109
|
fn = reader.context.current_function
|
|
96
110
|
state = states.get(fn)
|
|
97
111
|
if state is None:
|
|
98
|
-
state = states[fn] = _FnState(getattr(fn, "name", ""), c_family
|
|
112
|
+
state = states[fn] = _FnState(getattr(fn, "name", ""), c_family,
|
|
113
|
+
match_kw)
|
|
99
114
|
_step(state, token, is_python)
|
|
100
115
|
fn.cognitive_complexity = state.total
|
|
101
116
|
yield token
|
|
@@ -235,12 +250,27 @@ def _keywords(state: _FnState, token: str, is_python: bool) -> None:
|
|
|
235
250
|
_if_token(state, is_python)
|
|
236
251
|
elif token in _ELSE_KEYWORDS:
|
|
237
252
|
_else_token(state, token, is_python)
|
|
238
|
-
elif token in _COUNTING:
|
|
253
|
+
elif token in _COUNTING or _counting_match(state, token):
|
|
239
254
|
_structure_token(state, token, is_python)
|
|
240
255
|
else:
|
|
241
256
|
_jumps_and_recursion(state, token, is_python)
|
|
242
257
|
|
|
243
258
|
|
|
259
|
+
def _counting_match(state: _FnState, token: str) -> bool:
|
|
260
|
+
"""True for a Rust `match`, which is a switch and is charged as one.
|
|
261
|
+
|
|
262
|
+
Not in `_COUNTING`, because that set is read by every language and `match`
|
|
263
|
+
is a soft keyword in Python: `match = re.match(...)` would cost a point and
|
|
264
|
+
open a block that never closes. The reader decides, the way it decides
|
|
265
|
+
whether a `&&` is a declarator.
|
|
266
|
+
|
|
267
|
+
The block itself pays +1 and the nesting it sits in; the arms pay nothing,
|
|
268
|
+
exactly as a C `case` pays nothing. Rust's cyclomatic column counts the arms
|
|
269
|
+
instead, so the two columns say different things about one block on purpose.
|
|
270
|
+
"""
|
|
271
|
+
return token == "match" and state.match_kw
|
|
272
|
+
|
|
273
|
+
|
|
244
274
|
def _structure_token(state: _FnState, token: str, is_python: bool) -> None:
|
|
245
275
|
if token == "while" and state.prev == "}":
|
|
246
276
|
return # the closing half of do-while; the do already paid
|
|
@@ -15,6 +15,14 @@ PROTOCOL_VERSION = "2024-11-05"
|
|
|
15
15
|
|
|
16
16
|
_REPO = {"repo": {"type": "string", "description": "repo root (default: the server's)"}}
|
|
17
17
|
|
|
18
|
+
# brief and explain resolve NAME by one rule, so they describe it with one
|
|
19
|
+
# string. The bare identifier is the long name's leading token, which is all
|
|
20
|
+
# there is before the parameters in Rust and Go.
|
|
21
|
+
_NAME_DESCRIPTION = ("the bare identifier (classify, or route for a Rust "
|
|
22
|
+
"`route cmd : & Cmd`) or the whole long_name next_item "
|
|
23
|
+
"printed (classify( score , late )); both resolve, exact "
|
|
24
|
+
"match first")
|
|
25
|
+
|
|
18
26
|
TOOLS: tuple[dict, ...] = (
|
|
19
27
|
{"name": "next_item", "argv": ["next-item"], "json_flag": False, "positional": (),
|
|
20
28
|
"flags": {"top": "--top", "exclude": "--exclude"},
|
|
@@ -31,14 +39,12 @@ TOOLS: tuple[dict, ...] = (
|
|
|
31
39
|
"description": "One function's whole context: scored row, ratchet mark, uncovered lines, "
|
|
32
40
|
"duplication twins, churn and change-coupling partners",
|
|
33
41
|
"properties": {"path": {"type": "string", "description": "repo-relative source file"},
|
|
34
|
-
"name": {"type": "string",
|
|
35
|
-
"description": "the bare identifier (classify) or the whole "
|
|
36
|
-
"long_name next_item printed "
|
|
37
|
-
"(classify( score , late )); both resolve"}}},
|
|
42
|
+
"name": {"type": "string", "description": _NAME_DESCRIPTION}}},
|
|
38
43
|
{"name": "explain", "argv": ["explain"], "json_flag": False, "positional": ("path", "name"),
|
|
39
44
|
"flags": {},
|
|
40
45
|
"description": "One function's score trajectory across runs plus its ratchet mark",
|
|
41
|
-
"properties": {"path": {"type": "string"},
|
|
46
|
+
"properties": {"path": {"type": "string"},
|
|
47
|
+
"name": {"type": "string", "description": _NAME_DESCRIPTION}}},
|
|
42
48
|
{"name": "doctor", "argv": ["doctor"], "json_flag": False, "positional": (), "flags": {},
|
|
43
49
|
"description": "Config/repo agreement check: typo keys, empty scopes, missing lane cwds",
|
|
44
50
|
"properties": {}},
|
|
@@ -132,11 +132,46 @@ def commands(path: str, scoped: str | None, note: str = "") -> dict:
|
|
|
132
132
|
def bare_name(long_name: str) -> str:
|
|
133
133
|
"""The identifier a long_name opens with, before its parameter list.
|
|
134
134
|
|
|
135
|
+
Two cuts, because lizard's readers spell a parameter list two ways. Python
|
|
136
|
+
and shell close the name with `(` — `classify( score , limit = 1 )`,
|
|
137
|
+
`classify()` — and Rust and Go print the parameters after a space with no
|
|
138
|
+
parenthesis at all: `route cmd : & Cmd`, `Classify n int`. Cutting only at
|
|
139
|
+
the `(` handed those back whole, so the handle a packet published was a
|
|
140
|
+
signature no command would accept back.
|
|
141
|
+
|
|
142
|
+
The leading token settles both. It moves no parenthesised language, because
|
|
143
|
+
none of those puts a space before the `(`: `n::K::m( int a)` keeps its
|
|
144
|
+
namespace and an Objective-C `doThing:( int )` keeps its selector colon.
|
|
145
|
+
|
|
135
146
|
Empty for a function lizard could not name: both `(anonymous)` and
|
|
136
147
|
`(anonymous) ( z )` open with the parenthesis, so an empty prefix IS the
|
|
137
148
|
test for anonymity, with no second string to keep in step.
|
|
138
149
|
"""
|
|
139
|
-
|
|
150
|
+
head = long_name.split("(")[0].strip()
|
|
151
|
+
return head.split()[0] if head else ""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def exact_names(names, name: str) -> list[str]:
|
|
155
|
+
"""The long names `name` names outright: the whole string, or the bare one."""
|
|
156
|
+
return [n for n in names if name in (n, bare_name(n))]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def matching_names(names, name: str) -> list[str]:
|
|
160
|
+
"""The long names one NAME resolves to, in the order `names` arrived.
|
|
161
|
+
|
|
162
|
+
Exact first, the fragment second. `brief` matched only exactly and `explain`
|
|
163
|
+
only loosely, so `route` picked one function in one command and three —
|
|
164
|
+
`route`, `route_chain`, `route_num` — in the other, off the same string in
|
|
165
|
+
the same payload. Nesting names is the ordinary case, so the loose command
|
|
166
|
+
was wrong far more often than the strict one was unhelpful.
|
|
167
|
+
|
|
168
|
+
The fragment survives as the fallback because a name nobody owns is usually
|
|
169
|
+
a typo, and listing everything holding it is what tells a session which name
|
|
170
|
+
it meant. An empty NAME resolves to nothing rather than to everything.
|
|
171
|
+
"""
|
|
172
|
+
if not name:
|
|
173
|
+
return []
|
|
174
|
+
return exact_names(names, name) or [n for n in names if name in n]
|
|
140
175
|
|
|
141
176
|
|
|
142
177
|
def anonymous_starts(rows) -> list[int]:
|
|
@@ -8,6 +8,23 @@ from .universe import LANGUAGE_EXTENSIONS, exclude_matcher, excluded
|
|
|
8
8
|
|
|
9
9
|
_EXT_LANGUAGE = {ext: lang for lang, exts in LANGUAGE_EXTENSIONS.items() for ext in exts}
|
|
10
10
|
|
|
11
|
+
# The languages a coverage parser can read: coverage.py reads python, istanbul
|
|
12
|
+
# reads the JavaScript family and the .vue files a vitest run reports on. Every
|
|
13
|
+
# other supported language scores on complexity alone, which is what
|
|
14
|
+
# `coverage_optional = true` declares — so init writes the key for a scope built
|
|
15
|
+
# entirely from the rest, rather than leaving it to score `no-lane` forever
|
|
16
|
+
# against a lane nobody can write.
|
|
17
|
+
COVERABLE_LANGUAGES = frozenset({"python", "javascript", "typescript", "tsx", "vue"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def cc_only_scope(languages: tuple[str, ...]) -> bool:
|
|
21
|
+
"""True when no coverage parser reads any of this scope's languages.
|
|
22
|
+
|
|
23
|
+
One coverable language is enough to keep the key off: a lane can still
|
|
24
|
+
measure that part, and `coverage_optional` would forgive the whole scope.
|
|
25
|
+
"""
|
|
26
|
+
return not any(lang in COVERABLE_LANGUAGES for lang in languages)
|
|
27
|
+
|
|
11
28
|
DEFAULT_EXCLUDES = (
|
|
12
29
|
"**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**",
|
|
13
30
|
"**/*.test.*", "**/*.spec.*", "**/test_*.py", "**/*_test.py", "**/conftest.py",
|
|
@@ -174,8 +191,9 @@ def _quoted(names) -> str:
|
|
|
174
191
|
|
|
175
192
|
|
|
176
193
|
def _scope_stanza(name: str, languages: tuple[str, ...]) -> list[str]:
|
|
194
|
+
optional = ["coverage_optional = true"] if cc_only_scope(languages) else []
|
|
177
195
|
return ["[[scope]]", f'name = "{name}"', f'paths = ["{name}"]',
|
|
178
|
-
f"languages = [{_quoted(languages)}]", ""]
|
|
196
|
+
f"languages = [{_quoted(languages)}]", *optional, ""]
|
|
179
197
|
|
|
180
198
|
|
|
181
199
|
def _exclude_stanza() -> list[str]:
|
|
@@ -297,10 +315,17 @@ def _commented_block(rest: dict[str, tuple[str, ...]], has_live: bool) -> list[s
|
|
|
297
315
|
return header + _scoped_entry_lines(rest, False)
|
|
298
316
|
|
|
299
317
|
|
|
300
|
-
def _template_lines(covered: set[str],
|
|
318
|
+
def _template_lines(covered: set[str], scopes: dict[str, tuple[str, ...]]) -> list[str]:
|
|
319
|
+
"""Commented lane templates for the parsers no live lane covers.
|
|
320
|
+
|
|
321
|
+
None at all when every scope is cc-only. Neither parser reads any language
|
|
322
|
+
in the repo, so both templates would be a step the reader cannot take, under
|
|
323
|
+
a heading telling them to take it before running `crapkit coverage`.
|
|
324
|
+
"""
|
|
325
|
+
if all(cc_only_scope(languages) for languages in scopes.values()):
|
|
326
|
+
return []
|
|
301
327
|
# the template's scope is a placeholder on purpose: writing a real scope
|
|
302
328
|
# name pointed a TS lane template at a python project's sources
|
|
303
|
-
del scope_list
|
|
304
329
|
lines: list[str] = []
|
|
305
330
|
for parser in sorted(_TEMPLATES):
|
|
306
331
|
if parser not in covered:
|
|
@@ -316,7 +341,7 @@ def starter_toml(scopes: dict[str, tuple[str, ...]], lanes: tuple[LaneSpec, ...]
|
|
|
316
341
|
lines += _scope_stanza(name, languages)
|
|
317
342
|
lines += _exclude_stanza()
|
|
318
343
|
live, covered = _live_lanes(lanes, scopes)
|
|
319
|
-
return "\n".join(lines + live + _template_lines(covered,
|
|
344
|
+
return "\n".join(lines + live + _template_lines(covered, scopes)
|
|
320
345
|
+ _scoped_tests_stub(scopes, _confirmed_languages(lanes)))
|
|
321
346
|
|
|
322
347
|
|
|
@@ -25,7 +25,7 @@ from itertools import takewhile
|
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
from typing import NamedTuple
|
|
27
27
|
|
|
28
|
-
from .packet import bare_name, handle_ordinal
|
|
28
|
+
from .packet import bare_name, handle_ordinal, matching_names
|
|
29
29
|
from .snapshot import InventoryRow
|
|
30
30
|
|
|
31
31
|
# {table} so the migration can build the same shape under a temp name and swap
|
|
@@ -850,23 +850,34 @@ class SnapshotStore:
|
|
|
850
850
|
return list(cur.fetchall())
|
|
851
851
|
|
|
852
852
|
def find_functions(self, path: str, name_fragment: str) -> list[str]:
|
|
853
|
-
"""
|
|
853
|
+
"""The long_names in a path that one NAME resolves to, across all runs.
|
|
854
854
|
|
|
855
|
-
|
|
856
|
-
the
|
|
857
|
-
|
|
855
|
+
`packet.matching_names` is the rule, shared with `brief`: exact first,
|
|
856
|
+
the fragment only when nothing matches exactly. This used to be a SQL
|
|
857
|
+
`LIKE '%name%'` and nothing else, so `explain src/lib.rs route` printed
|
|
858
|
+
the trajectories of `route`, `route_chain` and `route_num` for a
|
|
859
|
+
question about one function. Matching in Python also makes `_` and `%`
|
|
860
|
+
the characters they are rather than LIKE's wildcards.
|
|
858
861
|
|
|
859
862
|
`(anonymous)#N` is resolved by position instead: an anonymous function
|
|
860
|
-
carries no text
|
|
861
|
-
|
|
863
|
+
carries no text to match, and the fragment would otherwise hunt for a
|
|
864
|
+
`#` no long_name has.
|
|
862
865
|
"""
|
|
863
866
|
ordinal = handle_ordinal(name_fragment)
|
|
864
867
|
if ordinal is not None:
|
|
865
868
|
return self._nth_anonymous(path, ordinal)
|
|
869
|
+
return matching_names(self._long_names(path), name_fragment)
|
|
870
|
+
|
|
871
|
+
def _long_names(self, path: str) -> list[str]:
|
|
872
|
+
"""Every distinct long_name a surviving run scored in this path.
|
|
873
|
+
|
|
874
|
+
Joined to functions rather than read off identities alone: a prune drops
|
|
875
|
+
the rows of a run and leaves its identities behind, and a name no
|
|
876
|
+
surviving run scored is a name `brief` cannot resolve.
|
|
877
|
+
"""
|
|
866
878
|
cur = self._conn.execute(
|
|
867
|
-
f"SELECT DISTINCT i.long_name {_BY_PATH} "
|
|
868
|
-
"
|
|
869
|
-
(path, f"%{name_fragment}%"))
|
|
879
|
+
f"SELECT DISTINCT i.long_name {_BY_PATH} WHERE i.path = ? "
|
|
880
|
+
"ORDER BY i.long_name", (path,))
|
|
870
881
|
return [n for (n,) in cur]
|
|
871
882
|
|
|
872
883
|
def _nth_anonymous(self, path: str, ordinal: int) -> list[str]:
|
|
@@ -1014,11 +1025,24 @@ def pick_baseline(runs: list[dict]) -> BaselinePick:
|
|
|
1014
1025
|
|
|
1015
1026
|
|
|
1016
1027
|
def is_trusted(r: dict) -> bool:
|
|
1017
|
-
"""Trusted = a coverage run, or a verify run whose verdict passed.
|
|
1018
|
-
|
|
1028
|
+
"""Trusted = a full coverage run, or a verify run whose verdict passed.
|
|
1029
|
+
|
|
1030
|
+
`kind` decides it, not lane provenance. A repo whose every scope declares
|
|
1031
|
+
`coverage_optional` scores with no lanes at all, and reading the empty
|
|
1032
|
+
provenance as "nothing was measured" left it with a coverage run no
|
|
1033
|
+
baseline reader would accept — worklist, next-item, rescore, ratchet seed
|
|
1034
|
+
and verify all reported there was no scored run right after one.
|
|
1035
|
+
|
|
1036
|
+
`legacy` is the exception that keeps the old test: those rows were migrated
|
|
1037
|
+
from before the column existed, so one label covers their inventory runs and
|
|
1038
|
+
their coverage runs alike and only provenance tells the two apart.
|
|
1039
|
+
"""
|
|
1040
|
+
if r["kind"] == "hook":
|
|
1019
1041
|
return False
|
|
1020
|
-
if r["kind"]
|
|
1042
|
+
if r["kind"] == "coverage":
|
|
1021
1043
|
return True
|
|
1044
|
+
if r["kind"] in ("legacy", None):
|
|
1045
|
+
return bool(r["lanes"])
|
|
1022
1046
|
return r["kind"] == "verify" and r["verdict_ok"] is True
|
|
1023
1047
|
|
|
1024
1048
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crapkit
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
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
|
|
@@ -28,6 +28,7 @@ Requires-Dist: lizard>=1.24.0
|
|
|
28
28
|
Provides-Extra: dev
|
|
29
29
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
30
30
|
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-xdist>=3; extra == "dev"
|
|
31
32
|
Dynamic: license-file
|
|
32
33
|
|
|
33
34
|
# crapkit
|
|
@@ -115,7 +116,7 @@ changing crapkit.
|
|
|
115
116
|
|
|
116
117
|
```
|
|
117
118
|
$ crapkit --version
|
|
118
|
-
crapkit 0.4.
|
|
119
|
+
crapkit 0.4.2
|
|
119
120
|
```
|
|
120
121
|
|
|
121
122
|
`python -m crapkit` works identically to the console script and is what to use from a
|
|
@@ -165,10 +166,16 @@ lane. Nothing about it is provisional: the ceiling still binds and the gate stil
|
|
|
165
166
|
a function over it. Add a coverage lane the day a parser exists and the same scope starts
|
|
166
167
|
joining coverage.
|
|
167
168
|
|
|
169
|
+
`crapkit init` writes that key itself, on every scope whose languages all lack a parser,
|
|
170
|
+
and leaves it off any scope a lane could still measure. So the 60-second start above runs
|
|
171
|
+
unchanged on a Go, Rust or shell repo: `crapkit coverage` scores it with no lane at all,
|
|
172
|
+
and that run is the baseline `worklist`, `next-item`, `ratchet seed` and `verify` read.
|
|
173
|
+
|
|
168
174
|
Three readers are crapkit's own. lizard ships none for shell or PowerShell, so crapkit
|
|
169
175
|
counts their functions itself. Its Rust reader scores a 7-arm `match` as ccn 2 (filed as
|
|
170
176
|
lizard #494), so crapkit counts each non-wildcard arm like a C `case` and retires the
|
|
171
|
-
override the day upstream fixes it.
|
|
177
|
+
override the day upstream fixes it. The cognitive column charges that same block once,
|
|
178
|
+
the way Sonar charges a `switch`.
|
|
172
179
|
|
|
173
180
|
## The gate
|
|
174
181
|
|
|
@@ -178,8 +185,8 @@ different powers:
|
|
|
178
185
|
| Surface | Fires | Power |
|
|
179
186
|
|---|---|---|
|
|
180
187
|
| `crapkit claude-hook` | after an agent's edit lands | **advisory.** Names the breach on stderr. Blocks nothing, because PostToolUse runs after the write |
|
|
181
|
-
| `crapkit rescore FILE --gate` | when you ask | **preview.** The commit gate's verdict on demand, sub-second, before you stage |
|
|
182
|
-
| `crapkit hook-precommit` | `git commit` | **blocks.**
|
|
188
|
+
| `crapkit rescore FILE --gate` | when you ask, after the first coverage run | **preview.** The commit gate's verdict on demand, sub-second, before you stage. With no run behind it, exit 1 and `no snapshot` |
|
|
189
|
+
| `crapkit hook-precommit` | `git commit` | **blocks.** The hook exits 6; git reports 1. Staged blobs only, so it costs the size of the commit and needs no coverage |
|
|
183
190
|
| `crapkit verify` | before you push, and in CI | **the verdict.** Gate, ratchet, new test failures, diff coverage, against the trusted baseline |
|
|
184
191
|
|
|
185
192
|
Both hooks exempt a function the committed ratchet already carries a mark for, so touching
|
|
@@ -239,7 +246,8 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
|
|
|
239
246
|
```yaml
|
|
240
247
|
repos:
|
|
241
248
|
- repo: https://github.com/JeanFrancoisGagne/crapkit
|
|
242
|
-
|
|
249
|
+
# crapkit's release step rewrites this line to the tag it just cut
|
|
250
|
+
rev: v0.4.2
|
|
243
251
|
hooks:
|
|
244
252
|
- id: crapkit-gate
|
|
245
253
|
```
|
|
@@ -275,7 +283,9 @@ crapkit gate: 1 staged function(s) exceed the complexity ceiling of 6:
|
|
|
275
283
|
decompose before committing (coverage cannot save a function above the target).
|
|
276
284
|
```
|
|
277
285
|
|
|
278
|
-
|
|
286
|
+
That commit exited **1**, not 6. Git collapses any failed hook to 1, so 6 is a code you
|
|
287
|
+
only ever see by running the hook yourself: `crapkit hook-precommit` exits 6 on a
|
|
288
|
+
violation and 0 otherwise. The stderr block above is the same either way.
|
|
279
289
|
|
|
280
290
|
`CRAPKIT_OVERRIDE_REASON` is not a bypass. Setting it routes the commit through the full
|
|
281
291
|
three-record audit: an alert line through `alert_command`, a ratchet entry staged into the
|
|
@@ -316,7 +326,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
|
|
|
316
326
|
| `next-item [--top N] [--exclude FRAG] [--scope NAME] [--claim]` | The actionable queue as JSON, with churn, budget estimates and uncovered lines. Same run and same admission floor as `worklist`, a different view of it: `no-lane` rows are skipped and counted in `skipped_no_lane`, and what is left is ranked by `crap` descending rather than by risk, so the item it hands out is often not the worklist's first row. `--exclude FRAG` (repeatable) skips items whose path or function name contains FRAG; `--scope NAME` (repeatable) is exact, not a substring. `--claim` holds what it hands out so a second session skips it. `stale` is true when the ranked run's commit is not HEAD, the same field `worklist` carries. Every item carries a `handle`: the bare identifier, or `(anonymous)#N` for a function with no name, which is the name form that survives the edit the item asks for. |
|
|
317
327
|
| `claims [list \| release PATH NAME \| release --all] [--json]` | The open claims, and the way to hand one back without waiting for a verify. `release` takes the bare identifier, the whole long name, or the `handle` the claim was taken under, which is the only one that picks out a single `(anonymous)` claim. |
|
|
318
328
|
| `brief FILE NAME [--batch N] [--json]` | The start-editing packet for one function: its own `source` text, every function in the file, the scored row and the scope ceiling, the ratchet mark and what the gate will bind on, uncovered lines, duplication twins, file churn, coupling partners, the config's notes, and the literal commands for the rest of the loop. Plus `handle`, `remedy` and the same `est_splits` / `est_uncovered_paths` the queue prints, and a `commands.refresh` that writes a run (`refresh_writes_run`) rather than re-reading the stale one. `NAME` takes the bare identifier, the long name `next-item` printed, the function's start line, or `(anonymous)#N` for a function printed `(anonymous)`, counting the file's anonymous functions from the top. `--batch N` drops the positionals and emits `packets[]` instead: the top N of the queue, built from one read of the store. |
|
|
319
|
-
| `explain FILE NAME [--history] [--tests] [--json]` | A function's score across runs plus its mark. `--history` adds the commits that touched it (`git log -L`), each carrying its message `body`, `--tests` the tests that covered it, which needs coverage.py contexts turned on ([recipe](docs/lanes.md#test-attribution-for-explain---tests)). `--json` emits the same content as one `schema` 1 object. |
|
|
329
|
+
| `explain FILE NAME [--history] [--tests] [--json]` | A function's score across runs plus its mark. `NAME` resolves exact first: a function whose bare identifier or long name is exactly `NAME` wins, and only when nothing matches exactly does it fall back to a prefix match, so `route` explains `route` rather than every `route_*` beside it. `--history` adds the commits that touched it (`git log -L`), each carrying its message `body`, `--tests` the tests that covered it, which needs coverage.py contexts turned on ([recipe](docs/lanes.md#test-attribution-for-explain---tests)). `--json` emits the same content as one `schema` 1 object. |
|
|
320
330
|
| `rescore FILE ... [--gate] [--json]` | Fresh complexity for named files over the latest run's stale coverage, joined by name. Advisory: it writes no run. `--gate` applies the pre-commit hook's policy to the same selection the hook uses (functions the tree changed since HEAD), minus functions a ratchet mark already covers, and exits 6. |
|
|
321
331
|
| `ratchet seed \| prune \| merge \| move \| report [--enforce] [--json]` | The mark lifecycle: seed new debt, prune gone code (a mark whose file git renamed follows it), merge as a git driver, move re-paths marks, report reads burn-down from the file's own git history. See [docs/ratchet.md](docs/ratchet.md). |
|
|
322
332
|
| `runs [list \| prune [--keep N]] [--json]` | Run history, and retention. `list` marks the run `verify` compares against today `baseline`, and prints `verdict=-` for a run that produces no verdict rather than one that failed. See [The trusted baseline](#the-trusted-baseline). `--keep` (default 5) is a floor on the newest trusted runs, not a cap: the digest pair, every passing verify baseline, every run an override names, and the newest non-hook run are kept too. `prune` VACUUMs afterwards. |
|
|
@@ -536,6 +546,11 @@ makes it step 4 of the burn-down loop. Every key is in
|
|
|
536
546
|
|
|
537
547
|
```
|
|
538
548
|
$ crapkit doctor
|
|
549
|
+
ok config keys all recognized
|
|
550
|
+
ok scope 'calc': 1 files
|
|
551
|
+
ok every tracked source file belongs to a scope
|
|
552
|
+
ok 1 lane(s) declared
|
|
553
|
+
ok lizard 1.24.0
|
|
539
554
|
doctor: no problems found
|
|
540
555
|
```
|
|
541
556
|
|
|
@@ -567,11 +582,12 @@ worklist has none.
|
|
|
567
582
|
|
|
568
583
|
```
|
|
569
584
|
$ crapkit next-item
|
|
570
|
-
{"commit": "fae4db93108b4841a00959f9117430679e7250ca", "empty": false, "item": {"authors": 1, "ccn": 14, "ccn_std": 14, "cognitive": 13, "commits": 1, "cov": 0.5, "crap": 38.5, "end": 28, "est_splits": 3, "est_uncovered_paths": 7, "flag": "measured", "function": "classify( score , attempts , late , bonus )", "nesting": 8, "nloc": 22, "path": "calc/grade.py", "remedy": "decompose", "scope": "calc", "start": 7, "target": 6, "uncovered_lines": [9, 11, 15, 17, 19, 24, 25, 26, 27, 28]}, "run_id": 1, "schema": 1, "skipped_no_lane": 0}
|
|
585
|
+
{"commit": "fae4db93108b4841a00959f9117430679e7250ca", "empty": false, "item": {"authors": 1, "ccn": 14, "ccn_std": 14, "cognitive": 13, "commits": 1, "cov": 0.5, "crap": 38.5, "end": 28, "est_splits": 3, "est_uncovered_paths": 7, "flag": "measured", "function": "classify( score , attempts , late , bonus )", "handle": "classify", "nesting": 8, "nloc": 22, "path": "calc/grade.py", "remedy": "decompose", "scope": "calc", "start": 7, "target": 6, "uncovered_lines": [9, 11, 15, 17, 19, 24, 25, 26, 27, 28]}, "run_id": 1, "schema": 1, "skipped_no_lane": 0, "stale": false}
|
|
571
586
|
```
|
|
572
587
|
|
|
573
588
|
`remedy: "decompose"`, `est_splits: 3` (this needs roughly three pieces to fit under 6),
|
|
574
|
-
and `uncovered_lines` naming the ten lines no test walks.
|
|
589
|
+
and `uncovered_lines` naming the ten lines no test walks. `handle` is the name form to
|
|
590
|
+
pass back, and `stale: false` says the run still describes HEAD. Every field is in
|
|
575
591
|
[docs/agent-json.md](docs/agent-json.md).
|
|
576
592
|
|
|
577
593
|
### 5. Seed the ratchet
|
|
@@ -648,17 +664,21 @@ crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/cov
|
|
|
648
664
|
crapkit: every lane failed: ...
|
|
649
665
|
```
|
|
650
666
|
|
|
651
|
-
|
|
667
|
+
That failure **writes no run**. Every lane failed, so `coverage` exits before it opens a
|
|
668
|
+
store: there is no `.crapkit/crap.sqlite` yet and the run ids below still start at 1.
|
|
669
|
+
|
|
670
|
+
Install the provider, and pin the major yourself. Unpinned, npm resolves the newest
|
|
671
|
+
provider against your older vitest and refuses the tree:
|
|
652
672
|
|
|
653
673
|
```
|
|
654
|
-
npm i -D @vitest/coverage-v8
|
|
674
|
+
npm i -D "@vitest/coverage-v8@<your vitest major>"
|
|
655
675
|
```
|
|
656
676
|
|
|
657
677
|
| Question | Answer |
|
|
658
678
|
|---|---|
|
|
659
679
|
| Which provider? | Either works. `@vitest/coverage-v8` is vitest's default and needs no config. `@vitest/coverage-istanbul` also works and needs `coverage.provider = "istanbul"` in your vitest config. |
|
|
660
680
|
| Which crapkit parser? | Both feed `parser = "istanbul"`. The provider name and the parser name are unrelated: v8 output is remapped to the istanbul JSON schema before it is written. |
|
|
661
|
-
| Which version? |
|
|
681
|
+
| Which version? | The provider's major has to match vitest's. On vitest 2 that is `npm i -D "@vitest/coverage-v8@2"`, on vitest 3 `npm i -D "@vitest/coverage-v8@3"`. Drop the pin and npm answers `ERESOLVE unable to resolve dependency tree`, naming the peer it could not satisfy. |
|
|
662
682
|
|
|
663
683
|
The artifact crapkit wants is `coverage-final.json`, written by vitest's `json` coverage
|
|
664
684
|
reporter, which is on by default. If your vitest config sets `coverage.reporter`
|
|
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
|