The Handbook · self-contained · reads offline from a clone
A deterministic change-risk gate for Python, TypeScript, TSX, JavaScript, Vue, Go, Rust, shell, PowerShell, Swift, C and C++, Objective-C, Java and Zig. It scores every function on complexity times uncovered risk, ranks the worst by how often their files change, hands a coding agent everything it needs to fix one, and blocks commits that add more.
tools/demo/generate.py builds the fixture repo, runs these five commands and renders what they printed.Yes, the name is real and it is not ours: C.R.A.P. (Change Risk Anti-Patterns) was coined for crap4j by Alberto Savoia and Bob Evans in 2007. An honest score deserved an honest name. We just built the kit.
You need to know which function will bite you next. One number answers that.
Complex functions break more. Untested functions break silently. CRAP multiplies the two: cyclomatic complexity squared, times the cube of what your tests never touch. A simple covered function scores near its complexity. A complex uncovered one explodes. The worst crapkit has scored in the field was an anonymous handler at ccn 220 with no tests, CRAP 48,620.
Plenty of tools report complexity. Plenty report coverage. crapkit earns its place with what happens after the arithmetic.
ccn × churn weight, not by score alone.schema, because half the callers are coding agents.Two inputs, and the second one has an exponent on it. That is the whole design.
ccn is cyclomatic complexity, the count of independent paths through a function. crapkit takes the smaller of lizard's standard and modified counts, where modified treats a whole switch or match as one decision. Comprehension for and if, ternaries, and and/or all count.
cov is branch coverage inside the function's span. With no branches it falls back to statement coverage, and with no statements to invoked-or-not. A half-executed straight-line function never reads as fully covered.
+ ccn floor, but never below it. At 100% coverage, CRAP equals ccn.Full coverage still leaves CRAP = ccn, so a target is secretly two rules. At the default target of 6, a function at ccn 7 with 100% coverage scores 7 and fails. Above the ceiling, coverage cannot save you and the only clearing move is decomposition. Below it, the target prescribes how much coverage each complexity owes.
A Sonar-spec cognitive complexity column rides along in the brief and next-item payloads as a second opinion on readability, measured in every language crapkit scans. Running everywhere is not the same as modelling everything: a construct the reader for that language does not model reads 0, so a low cognitive number beside a high ccn is a question, not an all-clear. It reports and never gates. The gate is min-ccn only, so the rule a commit is judged by has exactly one definition.
Two questions before you install: can crapkit score my repo, and will coverage ever join the score.
Fourteen languages, two coverage parsers. The left column is the label a crapkit.toml spells in languages = [...]. A file joins a scope only when its path prefix and its extension both match.
| Language | Extensions | Coverage |
|---|---|---|
| python | .py | coveragepy |
| typescript | .ts | istanbul |
| tsx | .tsx | istanbul |
| javascript | .js .jsx .mjs .cjs | istanbul |
| vue | .vue | istanbul, when your own vitest run reports on .vue files |
| go | .go | cc-only |
| rust | .rs | cc-only |
| shell | .sh .bash | cc-only |
| powershell | .ps1 .psm1 | cc-only |
| swift | .swift | cc-only |
| cpp | .c .cc .cpp .cxx .h .hpp | cc-only |
| objectivec | .m .mm | cc-only |
| java | .java | cc-only |
| zig | .zig | cc-only |
| kotlin | .kt .kts | not admitted, blocked upstream |
crapkit ships two artifact parsers and no more, so most of that table scores crap = ccn under coverage_optional = true, with no lane to run. That costs less than it sounds, because the ceiling is a ccn ceiling (§02). Coverage cannot rescue a function above target in any language. What a cc-only scope loses is the ability to earn a pass below the ceiling with tests, and the dark-lines detail in the packet. What it keeps is the whole gate: a ccn-9 function still fails, still ranks, still gets a packet.
crapkit init writes coverage_optional = true on every scope whose languages all sit in that cc-only half, and leaves it off any scope one parser can still reach. A repo made entirely of those scopes therefore declares no [[lane]] at all, and crapkit coverage runs it anyway: the scored run it writes is the baseline worklist, next-item, rescore, ratchet seed and verify all read. Exit 3 survives for the case it was written for — a scope with neither a lane nor the key — and the message names that scope.
Write a coverage lane the day the language gets one. Nothing else in the config changes.
match as a keyword and counts no arm (lizard #494), so a dispatch table reads as trivial: a five-arm match that crapkit scores ccn 5 scores 2 upstream. crapkit counts one point per non-wildcard arm, exactly like a C case. A test pins the stock reader's wrong answer, so the module retires itself the day upstream lands the fix. The cognitive column charges the same block once, +1 plus the nesting it sits in with the arms free, the way Sonar charges a switch: seven ways through, one decision to read.case per arm rather than once per block. It reports functions only: top-level script code belongs to lizard's *global* pseudo-function, exactly like Python module level, so a script that is one long top-level sequence reports nothing. That is the answer, not a parse failure.switch, because a twelve-arm dispatcher should not score the same 2 as a one-arm one. default is free, the same rule the Rust reader applies to a wildcard, so the same switch with and without one scores alike: it is the fall-through, not a decision. Source bytes decode utf-8 first and cp1252 second, by content rather than by the machine's locale. Windows PowerShell writes cp1252 by default, and left to io.open the same file scored one way on Windows and another on Linux.Kotlin is measured correctly and still refused. lizard's KotlinReader records only brace-bodied functions, so fun sign(x: Int) = if (x > 0) 1 else if (x < 0) -1 else 0 yields no function at all and its two decisions land nowhere. A language that can hide arbitrary branching from the gate is worse than one crapkit declines, so the label waits on upstream.
cpp, C included: lizard resolves all six suffixes to one reader, and .h is the header both dialects share. A && before the opening brace declares an rvalue reference rather than deciding anything, and costs no cognitive complexity; the same rule covers objectivec, since .mm is Objective-C++. Both arms of an #ifdef fork are textually present, so a platform shim defines one function twice in one file. Each arm takes its own ratchet key, the first as written and later ones suffixed #2, #3 in file order, so both are marked and both are gated; crapkit prints one stderr line naming any file that happens in.<script> block. Template directives (v-if, v-for) are HTML attributes to lizard and contribute nothing, so a component that branches in its template reports only what its methods do.switch's else prong as one more case, so a Zig switch reads one point above its hand count. Inflation only. It can cost a refactor that was not needed; it never hides one that was.One pass of analysis, your own test commands for coverage, one SQLite store, many views.
src, ui, py), each with its languages, an optional per-scope target, and free-text notes: the written-down operational traps that ride into every packet. A file belongs to exactly one scope, and since 0.4.5 one predicate decides which, for scoring, test-scoped routing, lane reuse and the packet alike: the deepest declared path wins. Three parts of the code used to answer that differently, so if your scope paths nest (src and src/api), some files change owner on the next scan and take their per-scope rollup and ceiling with them. Scope paths that do not nest see no change at all.no-lane and the run partial. Visible, never fatal, never a baseline.runs prune is the retention knob.Every threshold in this handbook is a default, not a constant. These are the keys people change first.
[crapkit]
target = 6 # THE dial: the CRAP ceiling, and with it the ccn ceiling
churn_window_months = 12 # how far back "hot" looks
worklist_floor = 5 # admission floor for the risk map (never hides over-ceiling debt)
worklist_top = 50 # rows the worklist prints, and rows the report page renders
diff_uncovered_max = 40 # optional: verify exit 9 when a change leaves more dark lines
max_parallel_lanes = 3 # coverage lanes at once (default 1, so this one is a raise)
notes = ["…"] # repo traps, delivered inside every packet
[[scope]]
name = "legacy"
target = 10 # a scope can carry its own ceiling…
coverage_optional = true # …or opt out of coverage entirely (crap = ccn, cc-only)
[[lane]]
timeout_seconds = 2400 # per-lane watchdog
no_progress_seconds = 300 # or when its log stops growing
retries = 1 # and how often a flaky lane gets another shot
Per-scope targets are how a repo holds new code to 6 while a quarantined legacy tree ratchets down from 10. Every key, with type, default and effect, is in docs/configuration.md. doctor tells you when the file and the repo have drifted apart, and doctor --tune proposes numbers that fit what it found.
Two hooks fire on their own. Only one of them can stop you, and knowing which is the difference between reading a note and losing a commit.
The advisory runs after every edit and annotates. The commit gate runs at git commit and blocks. They read the same ceiling map and subtract the same ratchet marks, so they cannot disagree about a function.
PostToolUse runs after the write. The edit is already on disk, so a hook that claimed to block would be lying. It says the opposite outright: the head line ends (the edit landed; nothing was blocked).
Silence everywhere else is the other half of the design. This hook is installed once per user and then fires on every edit that user makes, anywhere. 47.5% of the edits it was measured against land in repos with no crapkit.toml. A hook that spoke there would be unbearable within a day, so an unmeasured repo costs one stat and an exit 0.
Before this release the two disagreed. The advisory pardoned ratchet-marked functions and the commit gate did not, so touching signed debt produced a wall: green advisories all session, then a refused commit. The commit gate now exempts marked functions too, on mark existence, and prints one line saying how many. crapkit verify keeps the numeric check and is what fails a mark that actually rises.
Existence, not the numeric rule, because the gate judges staged blobs. A blob carries no coverage, so there is no CRAP to compare against a mark. The only question the gate can answer is the one it asks: did the repo already sign for this function?
The third reader joined them. crapkit verify now reads a mark the way rescore --gate always did: a touched function whose fresh CRAP sits at or under its mark is exempt, not a gate violation. Before this, an edit inside signed debt passed the advisory, passed the commit gate, and then met exit 6 at merge, which was the last place the three readers could still disagree about one function.
Exit 7 is untouched. A mark that rises is still a ratchet regression, and it is still reported on functions the diff never named, which is where coverage rot shows up.
The advisory now answers Bash events too. A Bash payload carries the command and no file path, so source written through a shell heredoc or python - <<'PY' named no file for the hook to judge, and every breach written that way landed unadvised. It reads the working tree instead: the *.py files git reports dirty or untracked, whose mtime falls inside a 12-second window, at most 25 of them, each through the same per-file ladder an Edit takes. Exit 2 still means the one thing it has always meant. A clean tree, a file that went stale, a command run outside any git repo: silence, as before.
The window is what stops the first dirty file from drawing the same advisory again on every later ls, and the cap is there because PostToolUse waits this process out, so a large dirty tree would be a stall rather than a reason to judge all of it. Only Python is judged. Every other language stays the commit gate's business, which is what keeps a check that runs on every shell call cheap enough to run.
The shipped plugin still registers Edit|Write only, so nothing above fires until you ask for it. A Bash matcher is a second entry in your own settings hooks, same command, and it costs one git rev-parse and one git status per shell call in any git repo, measured or not. Add it when your harness writes source through the shell; skip it when every write arrives as an Edit.
The protocol check also moved to the front of the ladder. It used to run after the walk for a crapkit.toml and the mid-rebase test; it now runs before the event is even shaped, so a payload asking for a protocol this CLI does not answer costs the read of stdin and nothing else. Same silences, and it matters more than it used to: the working-tree fallback is the first thing this hook does that spawns git, and a wrong protocol never reaches it.
One rule, checked at five distances from the keyboard: every touched function at or under its scope's ceiling.
Touched means the diff intersects the function's span. The two automatic checks are §06. The other three you invoke.
| Where | Command | Blocks? | Sees coverage? | What it is for |
|---|---|---|---|---|
| after every edit | crapkit claude-hook | no | no | Automatic, via the plugin. Names a function the edit pushed over its ceiling. |
| on demand, mid-edit | rescore FILE --gate | no | no | Sub-second preview of the commit gate's verdict, before you stage anything. It reads the newest run's coverage, so it needs one: exit 1 until the first coverage. |
at git commit | crapkit hook-precommit | exit 6, git says 1 | no | The enforcement point. Staged blobs, in memory, no store needed. |
| before merge | crapkit verify --base REF | 6 7 8 9 | yes | The full verdict: gate, ratchet, new test failures, dark diff lines. |
| in CI | crapkit verify --baseline-tsv FILE | 6 7 8 9 | yes | A fresh clone judged against a committed baseline. Add --sarif PATH or --github. |
A refusal is design feedback, not a threshold to widen. The attended escape is an audited override: a reason, a snapshot record, and auto-staged ratchet debt. All three land or none do.
| Exit | Meaning | The move |
|---|---|---|
| 0 | Verdict clean | Proceed. For verify, this is the green that ends a loop iteration. |
| 1 | Overloaded: no data yet, a doctor FAIL, or a debt-policy breach | Read the message. It names the command that creates the missing run, or the FAIL line. |
| 2 | Usage error | argparse refused. Check the flag spelling and order. |
| 3 | Config gap | A scope without a template, a metric-stamp mismatch. Fix crapkit.toml or restamp. |
| 4 | Git error | Not a repository, or a baseline commit rewritten out of history by a force-push. |
| 5 | Tool error: lizard missing, a lane produced no artifact (or one measuring a different tree, or one measuring this tree in absolute paths) or timed out past retries, an alert command failed | For the artifact cases: fix the lane, not the code. The refusal names the lane log in full. Seven root causes, triaged in docs/lanes.md. |
| 6 | Gate: touched function over its ceiling | Decompose until every piece fits. |
| 7 | Ratchet regression | A standing mark got worse. On a function you never edited, suspect test rot first. |
| 8 | New test failures vs baseline | The flake retest already ran if configured. What remains is real. |
| 9 | Changed lines nobody covered | Only in repos that set diff_uncovered_max. |
verify reports the first of 6, 7, 8, 9 that fires, in that order, so a CI script branches on one code per run.
How a brownfield repo turns the gate on today instead of after the cleanup.
crapkit ratchet seed writes every standing over-target score into crapkit-ratchet.tsv, a committed, human-readable file of pardons. A mark pardons debt at or under its recorded score. The function may stay bad; it may not get worse. Improve it and the mark tightens at the next green verify. Debt can only shrink. That is the ratchet.
A mark does three jobs at once. It pardons the commit gate (§06), it silences the per-edit advisory, and it sets the high-water line verify refuses to let rise. Marks carry a metric-version stamp: if a future crapkit changes how ccn is counted, verify refuses to compare across metrics instead of silently re-judging old pardons with new math. Optional policy knobs add expiring debt and repayment quotas for teams that want the screw to turn on a schedule.
Shell cognitive complexity now nests: fi, done and esac close the level if, a loop keyword or case opened, so a 4-deep shell if reads 10 like every other language instead of 4. That is analysis version 8, so the first command you run after the upgrade refuses the marks the old version signed:
$ crapkit verify
crapkit: ratchet marks were recorded under [crapkit-analysis=7 lizard=1.24.0] but this run measures [crapkit-analysis=8 lizard=1.24.0] — CRAP scores are not comparable across metric versions; re-baseline with `crapkit ratchet seed`
Do what it says. Re-seeding brings the same marks back: only shell's cognitive column moved, ccn is untouched in every language, and no mark, gate or CRAP score reads the cognitive column at all (§02).
Tightening is damped, because a tighten claims the code improved and one commit measured twice cannot have. Some suites measure the same bytes two ways: a coverage attribution that races a subprocess reported one function at CRAP 20.0 on one run of a commit and 72.0 on the next. Tighten on the lucky half of that and the unlucky half fails the mark, so the gate becomes a coin flip on an unchanged tree and the marks file churns 20 to 72 and back in commits. So verify compares each marked function against the same commit's previous trusted run — the same word ratchet seed uses, so a failed verify and a partial run are invisible to both — and holds any mark whose score moved by more than tighten_max_jump (default 2x), naming the function and both values on stderr. Real work moves a score by less than a measurement race does, so a stable improvement still tightens in full; verify --no-tighten holds the whole file when you want the verdict without the rewrite.
Every verdict compares against one earlier run. Which one is a rule, not a guess.
Qualifying runs are coverage runs and passing verifies. A failed verify never qualifies, and it taints what follows. Runs taken after a failure are skipped by default baseline selection until some verify passes, so nobody retires findings by quietly re-running coverage on the refused tree.
runs list marks the row verify compares against today, and warns when a moved HEAD makes the snapshot stale. For a repo with no store at all, such as a CI clone, verify --emit-baseline on the main branch writes a commit-stamped TSV that verify --baseline-tsv reads in the pull request.
Two families read that timeline, and since 0.4.5 each has one answer. The views, worklist, next-item, rescore and brief, take the newest trusted run and stop there: a view compares nothing, so it has nothing to launder. The judges, verify, ratchet seed and ratchet prune, add the taint rule on top of that pick. Before, worklist could rank one snapshot while next-item handed out work from another, and ratchet seed could sign marks off a run verify had just refused, which is exactly how a failed verify's findings stop counting.
--baseline ID is the deliberate escape, and it can name a run that exists and still cannot be measured against. That used to print the empty-store line, which sent people off to run coverage for a store they already had. It now names the run, the reason, and the runs that can:
$ crapkit verify --baseline 2
crapkit: run 2 is an inventory run (no coverage was measured) and cannot serve as a baseline; trusted runs: 1; pass `--baseline 1` for the newest
Four reasons reach that line: a failed verify, a hook run, a partial run (a lane subset, or a lane that failed), and an inventory run, which measured complexity and no coverage.
crapkit runs no test framework of its own. A lane is your command, its artifact, and a parser.
[[lane]]
name = "unit"
command = "npx vitest run --coverage --coverage.reportsDirectory=.crapkit/cov/unit --reporter=default --reporter=junit --outputFile=.crapkit/cov/unit/junit.xml"
artifact = ".crapkit/cov/unit/coverage-final.json"
results_artifact = ".crapkit/cov/unit/junit.xml"
parser = "istanbul" # or "coveragepy"
scopes = ["src"]
--reuse-unchanged reruns only lanes whose scope files moved, and since 0.4.5 a scope path that names a file rather than a directory (paths = ["core/hot.py"]) marks its lane changed when that file moves.results_artifact names the junit XML the same command writes. Two of verify's checks read it and nothing else does: the crashed-worker trust check, and no-new-failures (exit 8). A lane without one measures coverage exactly as it did, with both of those checks off, which until 0.4.5 nothing said out loud. doctor now WARNs, and init writes the reporter flag and the key together on the lanes it detects.timeout_seconds, no_progress_seconds and retries. no_progress_seconds kills the tree when the lane log stops growing, which is the hang a total deadline cannot catch, since that deadline has to be longer than your slowest honest run. A configured retest_command reruns just the newly-failing tests before exit 8 calls them real. Lanes run in parallel up to max_parallel_lanes, longest first. Since 0.4.5 the timeout kills the whole process tree (taskkill /T on Windows, killpg on POSIX) and waits for it, so a suite the watchdog gave up on is not still running an hour later; the lane log streams while it goes. Since 0.4.7 the cause a refusal hoists in front of the log tail is read from the last attempt only, after the final --- attempt N --- banner, so a retry never inherits the reason a superseded attempt died for.no-lane and the run partial. The queue counts what it cannot see instead of pretending. The seven classic causes are triaged in docs/lanes.md. An artifact that parses and still reaches none of its scopes is a different refusal, below.[crapkit.scoped_tests] entry per scope says how to run that scope's tests in isolation. It is what crapkit test-scoped executes, what the packet's commands.scoped_tests carries, and what doctor warns about when a laned scope has none..crapkit/. doctor warns about lanes littering the repo root, unclaimed files, and scopes with no test template, and FAILs a lane whose runner does not resolve on PATH or will not start.crapkit.toml does not have to be at the git top. A package one directory down is a supported shape, with no monorepo mode to turn on: crapkit coverage --repo packages/api scores that package. 0.4.4 made the scored paths and the churn behind them relative to that root; 0.4.5 finished the job, so the commit gate, verify's changed files, lane reuse, mutate's targets, the ratchet's rename follow and the per-edit advisory all join on root-relative paths too. Every git call now also runs with core.quotePath=false, which is what made a dirty file with a non-ASCII name invisible to lane reuse: git quoted the name, ls-files did not, and the two never matched. The nested shape is written up in docs/lanes.md.One gap costs a python lane its numbers with nothing said, and it is not crapkit's to fix: pytest-cov 7.0.0 dropped its own subprocess measurement, so a suite that drives a CLI through subprocess.run reads every entry point at 0% and the lane still succeeds. crapkit's own suite is such a suite. pytest-cov 7 and subprocess coverage has the measurements and the two config lines that restore it.
An artifact can parse, measure hundreds of files, and touch none of the paths its lane's scopes declare. Joining it would score every function in those scopes untested: a confident grade F assembled out of a tooling mistake, which is worse than a missing artifact because it looks like an answer. Since 0.4.7 the measured paths say which of three things happened, judged against the repo root.
| What the artifact reports | Reading | Verdict |
|---|---|---|
| paths resolving outside the root | another tree. A venv whose editable install points at a second checkout is the quiet way in | exit 5 |
| absolute paths resolving under the root | this tree, spelled absolutely. The join is on root-relative paths, so it matches nothing either | exit 5 |
| in-tree relative paths that miss every scope | a suite that imports none of the scoped source yet, which should score untested | warning, scores on |
The middle row is the one 0.4.7 added, and it gets its own fix, because the advice for the first row is wrong for it: the environment is right, and path_prefix only ever prepends. A coveragepy lane is told to set relative_files; an istanbul lane is told to point its reporter's own cwd or root option at this checkout.
crapkit: lane 'py' FAILED: lane 'py' measured 2 file(s), none of them under the paths its scopes declare (src), and 2 of them written as absolute paths that DO sit under this checkout — .crapkit/cov/py.json measured this tree and spelled it absolutely, and the join is on root-relative paths, so it still matches nothing and every function in those scopes would score untested; it reports paths like /repo/src/faro/core.py, /repo/src/faro/util.py. Make the runner write relative paths: `relative_files = true` under `[tool.coverage.run]` in pyproject.toml, or `[run] relative_files = true` in .coveragerc, then rerun the lane
Nothing is rebased and no path is guessed at. A ../ climb is always another tree, because the runner's working directory is recorded nowhere in the artifact, and a mixed artifact is another tree as well: a path from somewhere else can only have come from somewhere else. One failed lane leaves the run partial; exit 5 arrives when every lane failed.
A lane command runs under a shell, so both lane lints read it with the shell that will run it: sh on POSIX, cmd.exe on Windows. That is a character walk over the quotes, and on Windows over ^ escapes as well, not a whitespace split. So a quote that opens mid-token is one argument, --cov-report=json:".crapkit/cov/unit dir/coverage.json" included, and -k ^"not slow^" keeps its value in one piece.
Write values in double quotes. They are the one form both shells read the same way. To cmd.exe a single quote is an ordinary character, so -m 'not slow' arrives as two words: -m takes the first, and the second lands as a positional filter that narrows the run to nothing. The lane then writes no artifact. The guard refuses it at config load and names the shell it read with:
command = "python -m pytest -m 'not slow' --cov=app --cov-report=json:.crapkit/cov/slow/coverage.json"
$ crapkit doctor
crapkit: lane 'slow': positional argument 'slow'' narrows a full-suite coverage run; drop it, attach it to the flag it belongs to (-n8, --numprocesses=8), or set full_suite = false deliberately (cmd.exe does not treat ' as a quote: write the value in double quotes)
A chained command is read one argv per &&, ||, & or |, and every segment that runs the runner is checked. A second, narrower run after the operator is still a narrowed coverage run:
command = "python -m pytest --cov=app --cov-report=json:.crapkit/cov/slow/coverage.json --junitxml=.crapkit/cov/slow/junit.xml && python -m pytest tests/smoke --cov=app --cov-report=json:.crapkit/cov/slow/coverage.json"
$ crapkit doctor
crapkit: lane 'slow': positional argument 'tests/smoke' narrows a full-suite coverage run; drop it, attach it to the flag it belongs to (-n8, --numprocesses=8), or set full_suite = false deliberately
Redirections (> nul, 2>&1) belong to the shell and are never positionals, an empty quoted argument stays empty instead of shifting the next path onto the flag, and words break on space, tab and line endings only, so a non-breaking space pasted out of a wiki no longer splits a value. doctor reads a lane the same way, which is how it knows a quoted interpreter path is one word and that the runner after && is a runner. It also probes each distinct runner once instead of once per lane, and FAILs a lane whose first word will not start rather than calling the repo clean.
The results-file WARN reads like this, hint included:
$ crapkit doctor
WARN lane 'unit' declares no results_artifact: the crashed-worker check and the no-new-failures check (exit 8) cannot run for it; add --junitxml=.crapkit/cov/junit-unit.xml to the command and results_artifact = ".crapkit/cov/junit-unit.xml" to the lane
Two commands rank the same run differently on purpose. Pick the wrong one and you get the wrong answer.
next-item carries the only one.One run, and since 0.4.5 that is enforced rather than assumed: both views ask the same question the baseline rule answers (§09) and get the newest trusted run. They could pick differently before, so a worklist could rank one snapshot while next-item dealt work from another, and the two disagreed about what was left.
risk 5.4 ccn 6 ( 6 std) 5c/1a w 0.90 src/crapkit/cli/reports.py:62 cmd_digest( args )
risk 5.4 .......... ccn x churn weight - what ranks this map
ccn 6 (6 std) ..... min(standard, modified) ccn, standard in parentheses
5c/1a w 0.90 ...... 5 commits, 1 author in the window, recency weight 0.90
path:62 ........... file and the line the function opens on
cmd_digest( args ) the long name - paste it straight into `crapkit brief`
The weight favors recent activity: five commits last month outrank one commit each of five months. A function over its ceiling is admitted whatever its ccn, so the admission floor (worklist_floor, default ccn 5) trims the list without ever hiding debt. Hot-but-simple code appears too, which is the map doing its job; the ok marker says no action is owed.
Three conditions from next-item, all at once: empty: true, no items skipped because another session claims them, and no no_lane_over_target count in the reasons. Debt a wiring gap keeps unreachable is still debt. One flag alone is not done.
The first half hour of any refactor is reading. brief deletes it.
Ask about one function and the reply is a start-editing packet: everything a session would otherwise assemble with an editor, a grep and three git commands. The fields group into five questions.
crapkit brief --batch N --json emits the top N queue items as N packets from one process. One store open, one churn load, one source read per file, and since 0.4.5 one shingling pass over the snapshot for the whole batch instead of one per packet: a batch of 5 on a 31k-file repo went from 11.8 s to 5.2 s, byte-identical output. An orchestrator dealing work to parallel agents pays the fixed costs once.commands.gate arrives as crapkit rescore PATH --gate. It used to be spelled python -m crapkit, which asks whichever python is first on PATH to be the one crapkit lives in; the console script is the form the docs promise and the one that resolves from a venv on Windows. Run the strings as given either way.(anonymous)#N meaning the Nth anonymous function in the file by start order, or the twin selector NAME#2 for one of several functions a file gives one name to. Both ordinal forms survive edits that shift lines above them, which a start line does not; claims takes the handle. The bare name is the long name's leading token, which in Rust and Go is all there is before the parameters: route cmd : & Cmd is route. Matching is exact first and falls back to a substring search only when no function answers to the name, so route never also returns route_chain. Since 0.4.5 explain resolves a start line the same way, so crapkit explain app/grade.py 1 answers where it used to want a name.stale says the snapshot predates HEAD. uncovered_lines: null plus a note names the lane that abstained, never conflated with "nothing to cover". refresh_writes_run warns that refreshing writes a run.Half of crapkit's callers are language models. That is a design constraint here, not an afterthought.
Parallel sessions coordinate through next-item --claim: a claimed item is skipped by everyone else, released on green verify or by hand. Handles survive line shifts.
crapkit worklist --batches N cuts the queue into file-disjoint batches, co-changing files kept together. A fleet plan with no overlap by construction.
crapkit mcp is a dependency-free stdio server exposing the read side as tools. Register it globally; in an unmeasured directory it answers with guidance instead of dying.
plugin/ is the agent surface as one installable artifact: three skills (the loop, the failure router, the onboarding walk), the MCP server, and the advisory hook. Contract tests pin their text and the hook's handlers to the CLI so they cannot drift.
Repo and scope notes in crapkit.toml ride into every packet. The channel for hard-won operational traps, delivered at the moment of use.
Every read command emits JSON under --json, with next-item always and digest alone staying plain lines by design. Payloads carry schema: 1 and change additively only.
Six read-only commands that answer a question the score cannot.
mutate: are the tests real?Diff-scoped mutation testing. Flips comparisons and boolean logic on the lines you changed, runs the suite per mutant, lists survivors. The audit for coverage that moved the number without testing anything.
duplication: does this already exist?Near-duplicate function detection by shingle similarity, containment-aware so an enclosing function is not reported as its own twin.
coupling: what changes together?Files that co-change across the churn window, with support and confidence. The blast-radius map for a refactor. Cached since 0.4.5, so a warm run is a file read.
trend: is it getting better?Totals per scored run: over-target count, CRAP load, grade. The burn-down chart's data, read since 0.4.5 off a per-run rollup instead of every row.
doctor: does the config still fit?Every drift between crapkit.toml and reality, as WARN or FAIL. Run it after any rename.
overrides: who granted what?The audit trail behind every gate override: who, when, why, and the debt it staged.
These are the commands people run over and over, so three of them stopped redoing work. coupling, brief and worklist --batches share a ranked-pairs file, .crapkit/coupling-cache-v1.json, beside the churn caches. Its key is HEAD, the churn window, the UTC date, the path format and a digest of the tracked set, so it cannot answer for a tree it did not see; --min-support or --min-confidence off their defaults bypass it rather than poison it, and --top reads it. On crapkit's own repo, 337 tracked files over 220 commits:
$ ls -1 .crapkit/*.json .crapkit/*.z
.crapkit/cache.json
.crapkit/churn-cache-v2.json
.crapkit/churn-log-v2.json
.crapkit/churn-log-v2.z
.crapkit/coupling-cache-v1.json
.crapkit/stat-stamps.json
$ crapkit coupling --top 5 # cold, caches deleted: 0.53 s
$ crapkit coupling --top 5 # warm: 0.09 s
trend and report read a rollup row written once per run and pruned with that run, instead of rescanning every scored row the store holds. Both now write to the store on a best-effort basis, which is new for two commands that used to be pure readers.
mutate with mutation_workers above 1 keeps its worker worktrees at .crapkit/mutate-pool/ and re-prepares them each run rather than adding and removing them. Single-worker runs are untouched. The pool is not size-bounded, so when you want the disk back:
$ crapkit mutate --drop-pool
removed 2 pooled worktrees from …\.crapkit\mutate-pool
One base install everyone shares. Then pick the surfaces you want, in any order.
pip install crapkit # the PyPI release; git+https://github.com/JeanFrancoisGagne/crapkit.git for main
cd your-repo
crapkit init # sniffs tracked source into scopes, writes live lanes it can prove
crapkit doctor # config against reality: unknown keys, unclaimed files, lane runners that will not start
crapkit coverage # runs the lanes, joins coverage, writes the first scored run and its grade
Python 3.11 or newer, one runtime dependency (lizard). Plain pip install crapkit works once the PyPI release lands. python -m crapkit is identical to the console script and is what to use from a source checkout.
Read the doctor line narrowly. It resolves the first word of every command segment on PATH (the pieces &&, ||, & and | cut the line into) and starts the command's own first word. That is the whole of what it knows about a lane. A lane spelled npm run test is checked as far as npm, never the script behind it. So doctor can print doctor: no problems found and the next line of the block, crapkit coverage, can still exit 5 on that same lane. The reading rules in full are in docs/lanes.md.
The python lane init writes runs pytest --cov, and those flags come from pytest-cov, a package of your suite's interpreter. A pipx or uv-tool install of crapkit shares no venv with that suite, so no dependency of crapkit's could ever guarantee it. init probes the python its lane will run and prints the fix when the import fails: install pytest-cov where the suite runs, or, when crapkit lives in that same environment, pip install "crapkit[py]".
The double quotes are not decoration. cmd.exe passes ' through as an ordinary character, so the single-quoted form reaches pip as a requirement it rejects; double quotes are the one spelling cmd, PowerShell, bash and zsh all read the same way, and the bare form still breaks zsh's globbing.
Two Windows cases get their own notes since 0.4.5. On a PATH carrying only the py launcher, the lane is written py -m pytest: the chain is python, then python3, then py, and py is last because it exists on no other operating system. And when cmd.exe cannot start the interpreter at all, which is what the Windows Store python.exe alias does with no Store app behind it (exit 9009, "Python was not found"), init says that instead of talking about pytest-cov, and doctor FAILs the lane rather than calling the repo clean. Nothing ran, so nothing about pytest-cov was learned.
uv tool upgrade crapkit, and pip install -U into a tool venv, fail with os error 32 ("The process cannot access the file because it is being used by another process") while a crapkit MCP server is live: an agent session spawns crapkit.exe mcp, which holds the launcher, and Windows will not overwrite a running executable. The venv upgrades before that copy fails, so crapkit --version already reports the new version and the launcher is the only stale piece. Quit the agent session and rerun the upgrade, or rename the locked exe aside (Windows allows renaming a running one) and copy the new one in; the .old file goes at the next reboot.
mv ~/.local/bin/crapkit.exe ~/.local/bin/crapkit.exe.old
cp %APPDATA%/uv/tools/crapkit/Scripts/crapkit.exe ~/.local/bin/crapkit.exe
claude plugin marketplace add JeanFrancoisGagne/crapkit
claude plugin install crapkit@crapkit
That installs three skills, the MCP server, and the per-edit advisory hook as one artifact whose version tracks the CLI's. It is a machine-wide install and it changes nothing inside any repo. A repo with a crapkit.toml gets the full ladder; a repo without one gets a silent no-op per edit.
A CLI installed from a clone wants its plugin from that same clone. claude plugin marketplace add takes a path as well as a GitHub slug, and the clone root already carries the .claude-plugin/marketplace.json that names ./plugin as its source, so the same pair of commands installs the plugin sitting beside the CLI:
claude plugin marketplace add /path/to/your/crapkit-clone
claude plugin install crapkit@crapkit
Take that route whenever the CLI came from pip install . or from the git tip. A CLI from the tip and a plugin from the marketplace are the pair that drift, and the next paragraph is how you find out. Both marketplaces answer to the name crapkit, since it is the same file either way, so drop the GitHub one with claude plugin marketplace remove crapkit before adding the clone.
A runtime with no plugin marketplace copies plugin/skills/* into its own skills directory and gets the skills alone. crapkit doctor --plugin-root PATH compares an installed plugin against the crapkit on PATH on two things, its version and the hook protocol it asks for, and prints one line per disagreement at exit 1. Agreement prints nothing at all, at exit 0.
Since 0.4.5 you do not have to know the path. Pass the plugin root, or any directory above it, ~/.claude included, and the newest crapkit install under it wins; pass no path at all and it reads Claude Code's own plugin cache. A root it picked for you is named first, as crapkit doctor: checking PATH, so a wrong guess is visible rather than silent. A root you typed exactly is checked in silence.
crapkit ratchet seed # pardons standing debt into crapkit-ratchet.tsv
git add crapkit-ratchet.tsv && git commit -m "seed the crapkit ratchet"
printf '#!/bin/sh\nexec python -m crapkit hook-precommit\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
From here every commit is judged, new debt only. Seed before arming, or the first commit on a brownfield repo refuses everything. The plugin is optional and the hook is not: the advisory annotates, this blocks.
Two things bite here. Git runs hooks outside your shell's activated venv, so bare python must resolve to an interpreter that has crapkit installed, or write the absolute path. And a hook in .git/hooks/ is not committed, so teammates get nothing. The README's gate section has the committed-hooks-directory route, the pre-commit-framework route, and CI.
crapkit worklist # the risk map: what is worst, and how hot it runs
crapkit report # renders .crapkit/report.html for people who will not open a terminal
Walked-through quickstarts for Python and TypeScript, with real transcripts, live in the README. The judgment calls, meaning scope granularity, exclude versus lane, and the first-verify hazard, are in docs/adoption.md.
The same store, six different days. Find yours.
You are writing code. crapkit stays out of the way until a function crosses the line, and then it tells you twice: once as a note you can ignore, once as a commit that stops.
… you edit auth/session.ts, the advisory says nothing …
… you edit it again, and now it has something to say:
crapkit advisory: 1 function(s) over ceiling 6 in auth/session.ts (the edit landed; nothing was blocked)
ccn 9 auth/session.ts:41 refreshToken ( req , store )
the commit gate enforces this; decompose there or mark the debt
$ crapkit rescore auth/session.ts --gate # the same verdict, on demand, sub-second
$ crapkit brief auth/session.ts refreshToken # the packet, if you want the seams handed to you
… split it, then …
$ git commit -m "refresh tokens through a store adapter" # gate runs, exit 0, commit lands
The advisory and the gate agree by construction (§06). If you skip the advisory entirely, nothing changes: the gate is the enforcement point either way.
You hand a coding session the queue and walk away. The loop is §13. These are the commands it runs.
$ crapkit next-item --claim # worst by crap, claimed so no sibling session takes it
$ crapkit brief PATH NAME --json # source, siblings, ceiling, lane, commands, repo traps
… decompose or cover, at the seams the packet named …
$ crapkit rescore PATH --gate # exit 6 sends you back to the edit
$ crapkit test-scoped PATH # just this scope's tests, from its template
$ crapkit verify # the full verdict; green releases the claim
Stop when next-item reports empty: true and nothing is skipped as claimed and no no_lane_over_target count appears (§11). An empty-looking worklist is not the stop signal.
A repo with Python, TypeScript, Rust and shell in it. init sniffs all four; the two with coverage tooling get live lanes, the other two get scored anyway.
$ crapkit init
wrote crapkit.toml with 4 scope(s): api, infra, ops, ui
detected 2 lane(s) from this repo's own files: py, js — next: run `crapkit coverage`
added to .gitignore: .crapkit/, .coverage, __pycache__/
$ crapkit doctor
ok every tracked source file belongs to a scope
FAIL scope 'infra' is in no lane's scopes list — its functions can only score no-lane (declare a lane, or coverage_optional = true)
FAIL scope 'ops' is in no lane's scopes list — its functions can only score no-lane (declare a lane, or coverage_optional = true)
doctor: 2 problem(s)
$ crapkit inventory # complexity alone, no lanes needed
run 1 @ 8da79a04d51: 4 functions in 4 files (0 cached)
$ crapkit worklist
worklist @ 8da79a04d51 (run 1, floor ccn>=5, churn 12mo) — 4 active, 0 dormant
risk 0.0 ccn 7 ( 7 std) 1c/1a w 0.00 api/grade.py:1 grade( n , total )
risk 0.0 ccn 6 ( 6 std) 1c/1a w 0.00 ui/panel.ts:1 label ( kind , hot )
risk 0.0 ccn 5 ( 5 std) 1c/1a w 0.00 infra/main.rs:1 route kind : u8
risk 0.0 ccn 5 ( 5 std) 1c/1a w 0.00 ops/deploy.sh:1 release()
Read those two FAILs as a fork, not an error: declare a lane for the scope, or set coverage_optional = true and accept cc-only. Both are correct answers; leaving it undecided is not, because no-lane debt is unreachable debt.
Note the Rust row. Stock lizard scores that five-arm match ccn 2, which is under the ceiling and invisible. crapkit's corrected reader scores it 5 (§03).
A CI job runs on a fresh clone, so it has no .crapkit/ store and bare crapkit verify exits 1. Running coverage first would make the pull request's own tree the baseline, which is a gate that can never fail. The portable baseline is the mechanism.
# on the default branch, after a passing verify: commit this file
$ crapkit verify --emit-baseline crapkit-baseline.tsv
# in the PR job, against the committed baseline
$ crapkit verify --baseline-tsv crapkit-baseline.tsv --github
--github emits annotations that land on the pull request diff; --sarif PATH writes SARIF 2.1.0 for code-scanning upload. Refresh the committed baseline whenever the default branch's verify passes. Branch on the exit code: 6 is complexity, 7 is a ratchet regression, 8 is new test failures, 9 is uncovered changed lines.
$ crapkit report # writes .crapkit/report.html, prints the path it wrote
One self-contained page, three sections, rendered from the same payloads the JSON commands print. It cannot rank a different function first than worklist just did.
worklist_top rows (default 50). Each row prints the crapkit explain PATH NAME call that opens it, because no repo-wide payload carries per-function CRAP and the page refuses to invent one.A banner across the top names every lane whose artifact no longer describes the tree. Read the banner before you read the grades.
That is a campaign, and it has its own section: §17.
Nothing above assumes crapkit.toml sits at the git root. A package one or more directories down is the ordinary monorepo shape, and it needs no mode and no flag beyond --repo. 0.4.4 fixed the scoring half of it; the commit gate was still reading git diff --cached from the git top, so under a nested root the staged paths matched no scope, and a function at twice the ceiling committed with a warning. 0.4.5 fixed that, and the nine siblings that joined the same two path shapes. The gate now judges what you staged, named the way the rest of the run names it:
$ git add packages/api/app/route.py
$ cd packages/api
$ python -m crapkit hook-precommit # what the git hook runs
crapkit gate: 1 staged function(s) exceed the complexity ceiling of 6:
ccn 10 app/route.py:1 route( kind , hot , live , admin )
decompose before committing (coverage cannot save a function above the target).
A file staged above the crapkit root is outside that diff by design, and the gate no longer names it.
| You are… | Reach for | Because |
|---|---|---|
| about to edit an unfamiliar file | brief PATH NAME --json | Source, siblings, churn, coupled files, the ceiling and the repo's traps, before the file is even open. |
| wondering if the hook will refuse your commit | rescore FILE --gate | The same verdict the hook gives, sub-second, before you stage. |
| deciding where new code should live | worklist · coupling · duplication | Hot fragile files are a design input; a near-twin means the helper already exists. |
| writing tests for a change | uncovered_lines · est_uncovered_paths | The packet names the dark branches; the estimate sizes the work. |
| wondering if a past refactor held | explain PATH NAME | The score's trajectory across runs, plus regrowth when a split quietly un-split itself. |
| back from lunch on a shared repo | digest | The delta between the last two runs. Silent when nothing changed, which is the healthy reading. |
| already in a file anyway | file_functions in any brief | The boy-scout check: a sibling one point over its ceiling is cheapest to fix while context is hot. |
| watching scores while you edit | watch | Rescores tracked files as they change, in a terminal pane. |
Everything above composes into one flow when the goal is the whole repo. Four phases, and the gate goes on first.
entries of its own batch and let it call brief PATH NAME per item.ok markers.ccn × churn and the hand-out queue by CRAP, and the top of it is where incidents live. "Fix the worst slice and gate the rest forever" is the same campaign ended earlier, and the ratchet holds whatever line you reach.est_splits saying dozens is the tell.crapkit.toml, crapkit-ratchet.tsv, git-hooks/pre-commit). Measured on the 0.4.5 tree: 1,291 functions, all measured, 0 over the ceiling of 6, CRAP load 3,605.78, grade A+. The marks file is a header and one standing mark on cli/admin.py, a pardon the repo has since paid back.coupling 1.05 s to 0.11 s, worklist --batches down 62%, a batch of five briefs 11.8 s to 5.2 s, doctor 7.5 s to 1.4 s across 14 lanes over 2 runners, trend 4.58 s to 0.04 s warm, report down 76%, verify 25.5 s to 18.9 s, and mutate's worker setup 30.6 s to 0.46 s. Plan the loop knowing the read commands are near-free and verify is the one that costs a suite run.verify on an unchanged tree (the cache key cannot see a second edit to an already-dirty file), serving MCP tool calls from a kept process (a stale source breaks the packet contract), parallel git date slices for the churn walk, and a faster JSON decoder.schema: 1 means an agent's parser written today keeps working.