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