New:Microsoft Teams Notifications Are Now Available in Socket.Learn more
Get Started

crapkit

Package Overview
Dependencies
Maintainers
1
Versions
27
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

crapkit

Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more.

Source
pipPyPI
Version
0.7.6
Weekly downloads
866
-52.39%
Maintainers
1
Weekly downloads
 
Created

crapkit

ci PyPI Python License: MIT crapkit MCP server

crapkit init, coverage and worklist --top 5 on a small Python repo, then a shell heredoc adding a function at ccn 7: the per-edit advisory reports it and exits 2, and the commit gate refuses the staged file with exit 6

crapkit scores every function in your repo on complexity times uncovered risk, ranks the worst ones by how often the file changes, and blocks commits that add more. It reads Python, TypeScript, TSX, JavaScript, Swift, Go, Rust, shell, PowerShell, C and C++, Objective-C, Vue, Java and Zig through lizard, and joins per-function branch coverage from the istanbul or coverage.py artifact your own test command already writes. JSON commands use sorted keys and a versioned schema for scripts, coding agents and the optional MCP server.

CRAP = ccn^2 * (1 - cov)^3 + ccn

The name is not ours: C.R.A.P. (Change Risk Anti-Patterns) was coined for crap4j by Alberto Savoia and Bob Evans in 2007.

ccn is the smaller of standard and modified cyclomatic complexity, both read off one lizard pass. 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, so a half-executed straight-line function never reads as fully covered.

Above the ceiling, coverage cannot save you. Decompose. At the default target of 6, a function at ccn 7 with 100% coverage still scores 7 and still fails the gate. The only move that clears it is splitting the function.

Why 6 and not 30. crap4j's conventional threshold of 30 is a CRAP score: it lets an untested ccn 5 through (25 + 5 = 30) and a fully covered ccn 30 too. crapkit's default is a complexity ceiling, because coverage can at best collapse CRAP to ccn, and a function you cannot cover past ccn 6 is one you decompose. Set target = 30 in crapkit.toml if you want the crap4j number. A repo with existing debt does not need to: ratchet seed marks today's over-ceiling functions at today's score, the gate then judges only the functions a change touches, and marks may only fall, so adoption never starts with a wall of red. Next to crap4py, radon, xenon, wily and SonarQube: docs/comparison.md.

crapkit scores git-tracked files only. Source you have not git added is invisible to it.

Start withWhen
Install and the 60-second startYou want the first score in an existing Git repository.
Python or TypeScript quickstartYou want a worked example from setup through a passing verify.
AdoptionYou need to choose scopes, wire tests or introduce a ratchet to existing debt.
UpgradingYou already have saved runs, ratchet marks or an installed plugin.
Subcommands and JSON/MCP referenceYou are scripting commands or connecting a coding agent.

The 60-second start

pip install crapkit
cd your-repo
crapkit init        # write crapkit.toml and ignore measurement output
crapkit doctor      # check scopes, test commands and coverage dependencies
crapkit coverage    # runs the lane, joins coverage, stores a scored run
crapkit worklist    # the ranked risk map
crapkit ratchet seed
git add crapkit.toml crapkit-ratchet.tsv .gitignore

Not a Python repo? uvx crapkit init runs the same commands and adds nothing to your manifest: see A repo that is not Python.

init detects pytest, Vitest and Jest from the repository's own files. Review the generated config before running its commands. When detection leaves a commented lane, fill it in using the lane recipes. Commit the adoption files, then run crapkit verify to establish a passing verdict. Install the commit gate when the config and ratchet are ready.

coverage scores, worklist ranks:

$ crapkit coverage
run 1 @ fae4db93108: 2 functions scored: 2 measured, 1 over ceiling 6, CRAP load 41.0, grade F
-> next: crapkit worklist

$ crapkit worklist
worklist @ fae4db93108 (run 1, floor ccn>=5, churn 12mo) - 1 of 1 active (worklist_top 50), 0 dormant
  risk     14.0  ccn  14  crap    38.5  cov  50%    1c/1a  calc/grade.py:7  classify( score , attempts , late , bonus )

risk 14.0 is ccn times a churn weight of one: a one-commit repo has no spread of commits to weight, so each commit counts once and the ranking is complexity order until the history grows (Risk). crap 38.5 and cov 50% are the score and the coverage behind it.

ratchet seed signs today's debt at today's score. From then on marks only ever fall, so the repo can get better and never worse while you burn it down.

One thing stops most first runs: the coverage plugin. init writes a lane that shells out to your own test runner, and the runner needs its coverage package installed: pytest-cov for pytest, @vitest/coverage-v8 (pinned to your vitest major) for vitest. Without it the lane produces no artifact and coverage exits 5 quoting the runner's own error. For pytest, init probes the python its lane will run and prints the install command when pytest_cov is missing; pip install "crapkit[py]" pulls the plugin alongside crapkit when the two share a venv. On a Windows PATH holding only the py launcher it writes py, not a python3 the lane could never run, and when cmd.exe cannot start the interpreter at all (exit 9009, the Store alias) it names that instead of guessing at pytest-cov. A repo that pins no lockfile and carries its own .venv gets that venv's interpreter in the lane, when that interpreter can import pytest, rather than whichever python the shell answers with. The two quickstarts below walk a real repo end to end.

On Windows a lane command is read by cmd.exe, the shell that will run it, not by sh. Double quotes are the portable quoting. A single-quoted value is refused at config load with exit 3, because cmd.exe would hand pytest five words and the lane would write no artifact:

# the lane in crapkit.toml
command = "python -m pytest -m 'not live and not perf' --cov=calc --cov-branch --cov-report=json:.crapkit/cov/py.json"

$ crapkit doctor
crapkit: lane 'py': positional argument 'live' 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 suite whose testpaths cannot be collected in one process needs one lane per testpath, each with full_suite = false and its own artifact

Write it -m "not live and not perf". Carets, && and | segments, redirections and empty quoted arguments all read the way the shell reads them, so a chained lane (cd tests && python -m pytest --cov ...) is checked one segment at a time. doctor reads a lane the same way, and FAILs one whose runner will not start.

Install

pip install crapkit

That is the release on PyPI. For the unreleased tip of main, or from a local clone (run at the clone root):

pip install git+https://github.com/JeanFrancoisGagne/crapkit.git
pip install .

A repo that is not Python

crapkit is a command-line tool, never a dependency of the code it scores. A TypeScript, Go or Rust repo adds nothing to its own manifest. With uv on the machine, uvx fetches crapkit into a cache of its own and runs it:

$ uvx crapkit init
wrote crapkit.toml with 1 scope(s): src
detected 1 lane(s) from this repo's own files: js - next: run `crapkit coverage`
added to .gitignore: .crapkit/

$ uvx crapkit coverage
$ uvx crapkit worklist

The lane still runs your own test runner, so Vitest or Jest and its coverage package come from the repo's node_modules as they do today. uv tool install crapkit or pipx install crapkit puts a crapkit command on PATH once, which is what the commit gate and the Claude Code plugin call. uv brings its own Python when the machine has none.

Requires Python 3.11 or newer and Git on PATH. The CLI has one runtime dependency, lizard>=1.24.0; a package mirror needs both distributions. Install into the environment you intend to use, then check crapkit --version. The pip install -e ".[dev]" under Development is a different thing: it adds the test extra, for people changing crapkit.

Python projects can install pip install "crapkit[py]" in their test environment to include pytest-cov and subprocess-capable coverage.py. A separate tool installation still needs the coverage plugin in the environment that runs the suite.

Analysis and scoring run locally and send no telemetry. Configured lane, mutation and alert commands run with your permissions and can contact services or change files. Review those commands before running Crapkit in a repository you do not trust (SECURITY.md).

$ crapkit --version
crapkit 0.7.6

python -m crapkit works identically to the console script and is what to use from a source checkout. Every subcommand accepts --repo PATH (default: the nearest crapkit.toml at or above the current directory, so a monorepo workspace finds the root's), and with it you never have to cd into the repo you are scoring; Subcommands shows where the flag goes.

Upgrading

Keep the CLI and plugin versions aligned, measure fresh coverage after upgrading, and review any ratchet identity refusal before reseeding. The current reader is analysis version 10; older JavaScript and TypeScript callback marks can require a reviewed mapping. Follow the upgrade guide for saved state, portable records and Windows launcher locks.

Upgrading from 0.4.4

This historical example describes the 0.4.4 to 0.4.5 transition, from analysis version 7 to 8. It is retained to explain older refusal messages:

$ 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`

That transition changed cognitive complexity, not ccn or the CRAP formula. Later reader changes also affect function identity. Use the current upgrade guide when moving from any older release to today's reader.

The exe lock on Windows

An active MCP server can hold crapkit.exe open and make an upgrade fail with Windows error 32. Stop that server or its agent session, rerun the upgrade with the same installer, then restart the client. See the Windows upgrade procedure.

The Claude Code plugin

claude plugin marketplace add JeanFrancoisGagne/crapkit
claude plugin install crapkit@crapkit

Two commands, installed once per user, and every repo on the machine gets it. The plugin ships three skills, the read-side MCP server, and one advisory PostToolUse hook that names any function an edit pushed over its ceiling. Claude reaches two of the skills by itself, crapkit and crapkit-recover; the third you type, as /crapkit:crapkit-onboard, because wiring a repo up happens once and its description has no business in every turn's window. It adds no files to your repo, and it needs the crapkit CLI on PATH.

A repo with no crapkit.toml costs a silent sub-50 ms no-op per edit. After upgrading the CLI, refresh the marketplace before updating the installed plugin:

claude plugin marketplace update crapkit
claude plugin update crapkit@crapkit --scope user
crapkit doctor --plugin-root

Restart existing Claude Code sessions to apply the plugin update. The check above compares installed files with the CLI on PATH; it does not reload a running session.

The hook registers on Edit|Write, which is every write that names a file. An agent that writes its source through a shell heredoc names none, so a Bash event is judged off the working tree instead. That half is yours to register, because it costs two git spawns per shell call. Add a second PostToolUse entry to your own settings, same command, matcher Bash:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "crapkit claude-hook --protocol 1", "timeout": 20 }
        ]
      }
    ]
  }
}

The cost is one git rev-parse --show-toplevel and one git status --porcelain -z -uall per shell call in any git repo, whether or not crapkit measures it: about 30 ms together on crapkit's own checkout, and more on a bigger tree. What comes back is the dirty or untracked *.py files written in the last 12 seconds, 25 at most, each judged the way an edit is. Python only, so a TypeScript or Go repo pays the two spawns and hears nothing.

Codex

Codex can install the same marketplace's plugin through its own manager:

codex plugin marketplace add https://github.com/JeanFrancoisGagne/crapkit.git
codex plugin add crapkit@crapkit

Use the three skills and MCP server in Codex. The advisory hook instructions above configure Claude Code's PostToolUse event. To refresh an existing Codex installation:

codex plugin marketplace upgrade crapkit
codex plugin add crapkit@crapkit
codex plugin list --marketplace crapkit --json

Check the installed Codex plugin with an explicit crapkit doctor --plugin-root PATH. See plugin upgrades for choosing that path and starting a fresh MCP session. A runtime with a skills directory but no compatible marketplace can copy plugin/skills/* instead; other MCP clients use the stdio setup.

Languages

14 languages, two coverage parsers. Coverage joins where a parser exists; everything else scores on complexity alone.

LanguageFilesCoverage
Python.pycoverage.py
TypeScript.tsistanbul
TSX.tsxistanbul
JavaScript.js .jsx .mjs .cjsistanbul
Vue.vueistanbul, when your vitest run reports on .vue files
Swift.swiftnone: cc-only
Go.gonone: cc-only
Rust.rsnone: cc-only
shell.sh .bashnone: cc-only
PowerShell.ps1 .psm1none: cc-only
C and C++.c .cc .cpp .cxx .h .hppnone: cc-only
Objective-C.m .mmnone: cc-only
Java.javanone: cc-only
Zig.zignone: cc-only

A cc-only scope declares coverage_optional = true, scores crap = ccn, and needs no lane. Nothing about it is provisional: the ceiling still binds and the gate still refuses a function over it. Add a coverage lane the day a parser exists and the same scope starts joining coverage.

crapkit init writes that key itself, on every scope whose languages all lack a parser, and leaves it off any scope a lane could still measure. So the 60-second start above runs unchanged on a Go, Rust or shell repo: crapkit coverage scores it with no lane at all, and that run is the baseline worklist, next-item, ratchet seed and verify read.

Three readers are crapkit's own. lizard ships none for shell or PowerShell, so crapkit counts their functions itself. Its Rust reader scores a 7-arm match as ccn 2 (filed as lizard #494), so crapkit counts each non-wildcard arm like a C case and retires the override the day upstream fixes it. The cognitive column charges that same block once, the way Sonar charges a switch.

Expression arrows in arrays and argument lists are measured separately. In TypeScript, wrap an arrow body in parentheses when it contains < before a comma, such as x => (pair<T,U>(x)) or x => (x < 0). Without that delimiter, analysis refuses the file because this reader cannot distinguish type arguments from an expression separator. Generic arrow parameter declarations remain supported.

Functions on the same line have separate occurrence identifiers. Existing ratchet marks with ambiguous old identities require a reviewed mapping; see same-line function identity.

The gate

Use the advisory while editing, the gate when committing, and verify for the full verdict. The preview and hooks differ in what their available evidence can prove:

SurfaceFiresPower
crapkit claude-hookafter an agent's edit landsadvisory. Names the breach on stderr. Blocks nothing, because PostToolUse runs after the write
crapkit rescore FILE --gatewhen you ask, after the first coverage runpreview. The commit gate's verdict on demand, sub-second, before you stage. With no run behind it, exit 1 and no snapshot
crapkit hook-precommitgit commitblocks. The hook exits 6; git reports 1. Staged blobs only, so it costs the size of the commit and needs no coverage
crapkit verifybefore you push, and in CIthe verdict. Gate, ratchet, new test failures, diff coverage, against the trusted baseline

Both hooks exempt a function the committed ratchet already carries a mark for, so touching signed debt never refuses a commit. verify is what fails a mark that rises. Since 0.4.5 its gate exempts a touched function whose fresh CRAP sits at or under its mark, the rule rescore --gate already applied; push it past the mark and the gate fires again. The pre-commit hook still exempts on the mark's existence alone, on purpose: a staged blob has no coverage, so there is no fresh CRAP to compare against. It reports each exemption count on stderr (staged function(s) carry a ratchet mark and were not gated), and says the same about a staged file no [[scope]] claims, so a new top-level directory cannot go ungated in silence.

The Crapkit root can sit below the Git top. A config in packages/api gates that package's staged files as project-relative paths such as app/m.py. Path and root rules also cover absolute arguments, literal filenames and Git diff settings.

Git runs hooks outside your shell's activated venv. Bare python must resolve to an interpreter that has crapkit installed, or spell it out (exec /path/to/venv/Scripts/python -m crapkit hook-precommit).

Route 1: .git/hooks/pre-commit (local, not committed)

cat > .git/hooks/pre-commit <<'EOF'
#!/bin/sh
exec python -m crapkit hook-precommit
EOF
chmod +x .git/hooks/pre-commit

The same file from PowerShell. Out-File and > write a byte-order mark (UTF-16 on 5.1) in front of the shebang, and git then answers every commit with cannot spawn .git/hooks/pre-commit and lets it through; Set-Content -Encoding ascii does not. Git runs the hook with its own sh, so the interpreter is spelled with forward slashes and quoted, and no chmod is needed on Windows:

$python = (Get-Command python).Source -replace '\\', '/'
Set-Content -Path .git/hooks/pre-commit -Encoding ascii -NoNewline -Value "#!/bin/sh`nexec '$python' -m crapkit hook-precommit`n"

crapkit doctor warns when the hook file git would spawn starts with a byte-order mark.

Route 2: a committed hooks directory

The whole route, from a repo that has no githooks/ yet:

mkdir -p githooks
cat > githooks/pre-commit <<'EOF'
#!/bin/sh
exec python -m crapkit hook-precommit
EOF
chmod +x githooks/pre-commit
printf 'githooks/pre-commit text eol=lf\n' >> .gitattributes
git add .gitattributes githooks/pre-commit
git update-index --chmod=+x githooks/pre-commit
git commit -m "add crapkit gate hook"
git config core.hooksPath githooks

The --chmod goes between the add and the commit. It writes the executable bit to the index, so a commit that already happened does not carry it: run it after and git ls-tree HEAD still says 100644, which is a hook Unix checkouts silently skip. The .gitattributes line is the harder half of the same failure: under Windows' default core.autocrlf the hook checks out CRLF and #!/bin/sh\r dies on Linux and macOS with a bad-interpreter error. crapkit doctor warns when a file under core.hooksPath is not 100755 in the index and prints the update-index line for it.

Git will not read a hooks path out of a committed file, so that git config line belongs in your CONTRIBUTING setup steps. Every clone arms the gate with it.

Route 3: the pre-commit framework

crapkit ships a .pre-commit-hooks.yaml declaring id: crapkit-gate. In your .pre-commit-config.yaml:

repos:
  - repo: https://github.com/JeanFrancoisGagne/crapkit
    # crapkit's release step rewrites this line to the tag it just cut
    rev: v0.7.6
    hooks:
      - id: crapkit-gate

That file arms nothing on its own. The framework writes .git/hooks/pre-commit when you tell it to, and until then git commit runs no gate and says nothing:

pip install pre-commit
pre-commit install

pre-commit install is the line every clone needs, the way Route 2 needs its git config core.hooksPath line.

rev is a git ref pre-commit resolves against that remote. Pin a release tag, not a branch: pre-commit autoupdate only moves between tags, and a moving main would change your gate under you.

Route 4: CI

A CI job runs on a fresh clone, which has no .crapkit/ store, so bare crapkit verify exits 1. Running coverage first would make the PR's own tree the baseline, 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 ::error file=... annotations that land on the PR diff; --sarif PATH writes SARIF 2.1.0 for code-scanning upload. Refresh the committed baseline whenever the default branch's verify passes.

Two things the job has to do before those lines run. Install crapkit, pip install crapkit, and pin the version the way Route 3 pins rev: an unpinned install moves your gate on whatever day a release lands. Fetch the whole history. actions/checkout clones one commit by default, verify reads the diff against the baseline's commit out of git, and a shallow clone does not have that commit:

$ crapkit verify --baseline-tsv crapkit-baseline.tsv
crapkit: baseline commit a74260f321f is not an ancestor of HEAD in this shallow clone, which does not hold it; set fetch-depth: 0 on the checkout or run git fetch --unshallow

That is exit 4 on a git clone --depth 1 of a repo whose baseline verifies at full depth. On a full clone the same exit blames what it used to, a rebase or an amend that rewrote history, and asks for a fresh baseline instead. Set fetch-depth: 0 on the checkout step, which is what crapkit's own .github/workflows/ci.yml does.

The whole PR job, on GitHub Actions:

on: pull_request
jobs:
  crapkit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0        # verify needs the baseline's commit
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install crapkit
      - run: pip install -e ".[dev]"   # your own test dependencies
      - run: crapkit verify --baseline-tsv crapkit-baseline.tsv --github

The second install is the one people leave out. verify reruns your lanes, so the job needs whatever your test command needs: the coverage plugin, npm ci, a database, all of it. Without them the lane writes no artifact and verify exits 5 quoting the runner's own error, which is a broken job and not a verdict.

What a refusal looks like

$ git commit -m "add route"
crapkit gate: 1 staged function(s) exceed the complexity ceiling of 6:
  ccn   7  app/m.py:9  route( a , b , c , d )
decompose before committing (coverage cannot save a function above the target).

That commit exited 1, not 6. Git collapses any failed hook to 1, so 6 is a code you only ever see by running the hook yourself: crapkit hook-precommit exits 6 on a violation and 0 otherwise. The stderr block above is the same either way.

CRAPKIT_OVERRIDE_REASON is not a bypass. Setting it routes the commit through the full three-record audit: an alert line through alert_command, a ratchet entry staged into the commit, and a row in the override log. All three land or nothing does, and an unset alert_command refuses the override outright. See docs/ratchet.md.

The GitHub Action

action.yml at this repository's root is a composite action, so a reviewer sees crapkit's numbers on the pull request without installing anything. Four lines add it to a workflow, and every input has a default:

      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: JeanFrancoisGagne/crapkit@v0.7.6

The whole job those four lines sit in:

on: pull_request
jobs:
  crapkit:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write             # the comment, and nothing else
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0               # the diff, and verify's baseline commit
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"       # the interpreter the install below lands in
      - run: pip install -e ".[dev]"   # whatever your lanes need to run
      - uses: JeanFrancoisGagne/crapkit@v0.7.6
        with:
          gate: "false"

That pip install step is the one people leave out, and it is the same one Route 4 above names: the action installs crapkit and nothing else, so your lanes still need whatever your test command needs. Without it the lane writes no artifact and the comment says so.

fetch-depth: 0 is the other one. actions/checkout clones a single commit; the action reads the pull request's changed files out of git and verify reads the diff against the baseline's commit. With a shallow clone the file list comes back empty and the comment ranks the whole repository instead of the diff.

The action installs crapkit from $GITHUB_ACTION_PATH, which is its own checkout of the ref you pinned in uses:. So a pin left at last month's tag scores your tree with last month's crapkit rather than with whatever released since, and pinning a tag is the whole version policy; the snippets above name the current release.

What the comment looks like

One comment per pull request, edited in place on every push. A hidden <!-- crapkit-action --> line is how the next run finds it, so a fifteen-push branch carries one comment and not fifteen. On a push event there is no pull request to carry it, and the same text goes to the job log instead.

Rendered from three saved payloads: a pull request that adds an untested route() (ccn 8) beside a ratchet-marked legacy_router(), in a repository whose diff_uncovered_max is 3. The payloads are under tests/fixtures/action_comment/, and the unit suite pins this block to their render:

<!-- crapkit-action -->

## crapkit

4 functions in 2 files, 2 over ceiling 6, CRAP load 149.59, grade F.

**verify failed, exit 6: complexity gate.**

- gate: `app/calc.py:34` `route( a , b , c , d )` ccn 8, cov 0%, crap 72.0 -> decompose
- uncovered lines in `app/calc.py`: 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45

Run 3 against baseline 1, 1 changed file: 1 gate violation, 0 ratchet regressions, 0 new test failures, 11 uncovered changed lines.

### Worklist: 1 changed file

| File | Function | ccn | risk | remedy |
|---|---|---:|---:|---|
| `app/calc.py:34` | `route( a , b , c , d )` | 8 | 4.0 | decompose |
| `app/calc.py:19` | `legacy_router( a , b , c , d , e )` | 8 | 4.0 | decompose (accepted debt) |

The first line is the run crapkit coverage wrote: functions and files, how many sit over the ceiling (over ceiling 6, or over their ceilings (6; reports 12, util 4) when scopes set their own), CRAP load and grade, with a failed lane's first error line appended as ; lane 'js' failed: .... When coverage --json died before a summary, the line quotes the error object it printed instead: `crapkit coverage` exited 5: lane 'py' cannot import pytest-cov; pip install pytest-cov.

The verdict opens with the exit code and the rule it stands for (complexity gate, ratchet regressions, new test failures, diff-coverage ceiling N), then one bullet per finding: each gate violation with its function, ccn, coverage, CRAP and remedy; each ratchet regression as recorded -> fresh; each new test failure by id; and the first twenty uncovered changed lines, one bullet per file, with a count of the rest. The counts line closes it. A verify that passed is one line: **verify passed.** Run 2 against baseline 1, 7 changed files.

The rows are the ranked worklist for the files the pull request changed, worst first, top of them, with the rows a finding names listed first. risk is ccn times churn weight, the number crapkit worklist ranks on, and remedy is the run's own verdict for that function: decompose, split-lines, add-tests or ok. (accepted debt) marks a function the committed ratchet carries a mark for, so an untouched legacy_router does not read like the pull request's own new function. A pull request that touches no ranked function gets the heading and no table.

The two file counts describe the same diff, counted twice. 39 changed files is git diff --name-only base.sha...HEAD, the branch's own commits, and it is what the table is filtered to. The count on the verdict line is what verify measured from the same fork point. With delta: "false" the second one is 0, because there is nothing behind the checkout to measure from.

The inputs

InputDefaultWhat it does
gate"false""true" exits with crapkit verify's own code, so a finding fails the check. On a pull request with delta on it also exits 1 when the base run was not made, because a verdict with no base run judged no changed function. Anything else exits 0 and the comment is the whole output
delta"true"scores the pull request's base commit first, so the verdict covers the commits the pull request adds. Costs a second lane run; "false" scores the checkout alone, and the verdict then judges no changed function
top"5"worklist rows rendered in the table
python-version"3.12"the interpreter actions/setup-python installs crapkit into. Match it to the version your own setup-python step named, or the lanes run on an interpreter your dependencies never reached

gate: "false" is the default on purpose. A team adopts the action before it has decided which findings should stop a merge, and a check that fails on day one gets turned off on day two.

What the verdict line covers

On a pull request, the commits the pull request adds. The action scores the fork point first, then the checkout, then runs crapkit verify --base <fork>, which measures the diff from there and takes the fork point's run as its baseline. So the gate judges the functions in the diff a reviewer is reading, and a repository that was already over its ceiling before the branch started does not fail every pull request that touches it.

The fork point is git merge-base of base.sha and HEAD, not base.sha itself. base.sha is the base branch's tip when the event fired, so a base branch that moved after the branch forked carries commits HEAD never saw, and a run there would be neither the baseline verify wants nor a diff anyone is reviewing.

The base run happens in a detached worktree under RUNNER_TEMP, and its store is copied over the checkout's so both runs sit in one place. The cost is two lane runs on a pull request: your suite runs once at the fork point and once on the checkout. Set delta: "false" to skip the base run, and the verdict falls back to the checkout against its own run, which reports the tree's own health and judges no changed function. The comment says so in place of verify passed:

**verify judged no changed function:** the base run was not made (no base commit). Run 2 against baseline 2, 0 changed files.

Three things leave the base run unmade: a shallow clone that does not hold the fork point, a fork point older than your crapkit.toml, and a lane that will not run against that tree. The step logs crapkit base scoring exited N and writes the reason to crapkit-base.reason in the words the comment then quotes, shallow clone does not hold the fork point of <sha>; set fetch-depth: 0 on the checkout, no usable crapkit.toml at the fork point <sha>: ..., or lane failed at the fork point <sha>: ... with the lane's first error line. The verdict falls back the same way delta: "false" does, and the ratchet still runs, so exit 7 there is a finding. What differs is the job's status. With gate: "true" on a pull request whose base run was attempted and not made, the exit step exits 1 and prints the reason, because actions/checkout's default depth-1 clone would otherwise turn every pull request into a green check that judged nothing. A push event and delta: "false" never attempt the base run, so they keep verify's own code; a push has no base commit and no pull request to comment on.

One requirement the base run adds: the lane has to measure the tree it runs in. A lane that reaches an installed copy of your package instead of the checkout will measure the pull request's code while standing on the base commit, and the two runs then describe the same tree. crapkit verify refuses a run whose artifact names files outside the tree (exit 5), which catches the loud version of this; a lane pinned to a path outside the worktree is the quiet one. Point the lane at the tree, or set delta: "false".

--reuse-artifacts is what keeps each of those runs to one pass of your suite. coverage ran the lanes moments earlier on that tree, and verify parses those artifacts rather than running the whole suite a second time for the same numbers.

Which is why crapkit coverage has to exit 0 for there to be a verdict. When it exits anything else (a lane that failed, an artifact it refused), the action does not run verify: on a runner that keeps its workspace between jobs (clean: false), a verify --reuse-artifacts over a failed measurement read the artifact the dead lane had left from an earlier run, passed over it, and made that run the trusted baseline. The comment then carries coverage's failure in place of the verdict:

**no verdict: `crapkit coverage` exited 5 (lane 'py' failed: lane 'py' wrote no artifact on its last attempt; the .crapkit/cov/py.json on disk predates it); verify did not run.**

The parenthesis is the first line of the lane failure the summary carries. When every lane failed, coverage prints no summary at all and the lane errors are only in the job log, and the line says so. With gate: "true" the job exits with coverage's code.

The other gate that judges a delta is the portable baseline in Route 4: commit crapkit-baseline.tsv on the default branch and run crapkit verify --baseline-tsv crapkit-baseline.tsv in a step of your own. It needs no second lane run, and it needs someone to keep that file current.

The comment is posted with gh api and the job's own GITHUB_TOKEN, which needs pull-requests: write. Two things it cannot do: a pull request from a fork gets a read-only token, so the POST is a 403 there, and a self-hosted runner without the gh CLI on PATH fails that step. Both leave the rendered text in the job log.

Subcommands

crapkit clean --dry-run --json previews policy-based cleanup. See resource policies for shared analysis workers, process lifetime, bounded logs and retained evidence.

Every subcommand takes --repo PATH, and the flag goes after the subcommand. Without it the root is the nearest crapkit.toml at or above the current directory (ADR 0002): from a monorepo workspace crapkit worklist reads the root configuration that claims the workspace, says crapkit: using crapkit.toml at /repo on stderr, and reads a relative path argument from where you stand. claude-hook is the one exception: it has no --repo, because it takes its root from the file named in the hook payload it reads.

$ crapkit worklist --repo /path/to/repo --scope util --top 1
worklist @ a7c5c85ac37 (run 1, floor ccn>=5, churn 12mo) - 1 of 3 active (--top 1), 0 dormant
  risk      5.4  ccn   5  crap    30.0  cov   0%    5c/1a  util/stats.py:1  bucket( value , low , high )

Before it, argparse reads the path as the subcommand name and exits 2 without ever mentioning --repo:

$ crapkit --repo /path/to/repo worklist --top 1
crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from 'inventory', 'coverage', ...)

--json prints one sorted-keys JSON object on stdout, always carrying a schema field.

CommandWhat it does
clean [--dry-run] [--json]Applies configured retention to recognized idle default test evidence and recovers abandoned temporary mutation worktrees. Preserves active runs, caller-managed output and intentional mutation pools. --dry-run reports planned removals.
initSniffs tracked source into per-directory scopes, writes a self-validated starter crapkit.toml whose lanes report into .crapkit/cov/, and appends .crapkit/ plus each runner's own droppings to .gitignore. Writes a live [[lane]] when it can detect the test runner, otherwise a commented template. Refuses to clobber an existing config.
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 reads each lane command with the shell that will run it, so a quoted interpreter path is one word and a runner after && is checked too, and it FAILs a lane whose runner does not resolve or that the shell cannot start, naming the word to change; a bare name is looked for on PATH and a runner spelled as a path is looked for under the directory the lane runs in, so .venv/bin/python answers the same from any directory you run doctor in; each distinct runner is probed once, not once per lane. It WARNs on a lane writing its artifact at the repo root, a coveragepy or istanbul lane with no results_artifact (the crashed-worker and no-new-failures checks are off for it, whichever runner the lane spells), 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 against the crapkit on PATH (the bare name its hooks and MCP server spawn) on both version and hook --protocol, and FAILs when PATH carries no crapkit at all, one line per disagreement and silence when they agree; PATH is the plugin root or any directory above it, ~/.claude included (only manifests named crapkit count, and the newest install wins), and with no PATH it looks in Claude Code's plugin cache. A root it found rather than one you typed is named first, as crapkit doctor: checking PATH. See docs/agent-json.md.
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.
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.
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), 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. It reads each istanbul artifact once for coverage, dead lines and its digest, and skips the artifact walk on an empty diff; skipping the whole run on an unchanged tree was measured and rejected, because a key made of HEAD plus the dirty names cannot see a second edit to a file that was already dirty.
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. Every row carries the function's crap and cov off the ranked run and its ratchet_mark when the committed marks file signs for it, and the header counts the active rows the cap hid: 50 of 3980 active (worklist_top 50). --scope NAME (repeatable) is exact, not a substring; a name no [[scope]] declares is a configuration error, exit 3, naming the declared scopes. --batches N adds a batches[] view cutting the active list into at most N file-disjoint batches with co-changing files kept together, off the same cached pairs coupling reads; the normal keys stay.
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, and a name no [[scope]] declares is a configuration error, exit 3, naming the declared scopes. --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.
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.
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 and one duplication pass over the snapshot for the whole batch (batch of 5: 11.8 s to 5.2 s, output byte-identical to five separate calls).
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. It also takes the function's start line, the form brief takes, which is how you open one printed (anonymous). --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). --json emits the same content as one schema 1 object.
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 whose CRAP sits at or under their ratchet mark, and exits 6. A marked function past its mark is gated; the pre-commit hook exempts on the mark's existence instead, because a staged blob has no coverage to score.
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.
runs [list | prune [--keep N]] [--json]Run history, and retention. list marks the run verify compares against today baseline, and prints verdict=- for a run that produces no verdict rather than one that failed. See The trusted baseline. --keep (default 5) is a floor on the newest trusted runs, not a cap: the digest pair, every passing verify baseline, every run an override names, and the newest non-hook run are kept too. prune VACUUMs afterwards.
overrides [--json]The override audit trail: who granted what, when, and why.
trend [--json]Totals per trusted run: functions, over-target count, CRAP load, average, per-scope rollup. It reads a per-run rollup table rather than rescanning every scored row, and fills that table for any run missing one, so it writes to the store (best effort: a read-only .crapkit/ costs the speed, not the command).
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.
report [--out PATH]One self-contained HTML page written to .crapkit/report.html (or --out PATH, repo-relative, or an absolute path you name), 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. Every row carries the function's CRAP and coverage, and prints the crapkit explain call for the rest: dark lines, history, the mark. It reads the same per-run rollups trend does, and writes them on the same terms.
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. Ties have a stable order across hash seeds. A positive --top bounds retained candidates and output; dense inputs still require pair comparisons. A function and its nested closure never pair.
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. The ranked pairs are cached in .crapkit/coupling-cache-v1.json, keyed on HEAD, the churn window, today's UTC date, the path format and a digest of the tracked set, and shared with brief and worklist --batches (warm: 1.05 s to 0.11 s on a 72k-commit repo). The date is part of that key, so the first run after midnight UTC rebuilds the pairs on an unchanged HEAD. --top reads the cache, because it truncates that same order; --min-support or --min-confidence off their defaults ask a wider question than the file answers, so they bypass it and recompute.
mutate [--files F ...] [--max-mutants N] [--drop-pool] [--json]Diff-scoped mutation testing: flips comparisons, boundary shifts, boolean connectives and boolean literals on changed lines, runs mutation_command per mutant, lists survivors. --files replaces diff scope with the whole file. Both lists pass through the scored corpus first, the same predicate coverage uses (scopes, excludes, the test-file cut, max_file_bytes): a test file, an excluded path, a file over max_file_bytes or a file no scope claims is named on stderr and never mutated, --json lists it under outside_corpus, and when nothing is left stdout says nothing to mutate at exit 0 without starting the suite. --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. Every worker uses a kept worktree, including one; see mutation worktrees. --drop-pool removes them and exits.
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.
hook-precommitThe cc-only gate on staged blobs. No coverage, no snapshot, no repo-wide cache. Exit 6 on a violation.
claude-hook [--protocol N]Reads one Claude Code PostToolUse payload from stdin and judges the file it edited: ccn against the scope ceiling, on functions the edit changed, minus functions a ratchet mark already covers. Advisory only: the edit has landed, and hook-precommit stays the enforcement point. Exit 2 and an advisory on stderr is the only thing it ever says, one block per judged file (a head line, one line per breaching function, a closing line): no crapkit.toml above the edited file, an unscoped file, mid-rebase or mid-merge, a --protocol other than 1, source that parses to no functions, or any internal failure all exit 0 in silence. The root is the first crapkit.toml above the edited file; the walk stops at a .git entry, so a worktree never borrows its parent's config. A Bash event names no file, so it judges the working tree instead: the dirty or untracked *.py files touched in the last 12 seconds, 25 at most, each through the same ladder, and silence for a clean tree or a cwd outside any repo. That half fires only where you register a Bash matcher (The Claude Code plugin). It opens no snapshot and writes nothing.
watch [--interval SECONDS] [--cycles N]Rescores tracked files as they change (mtime polling, default 2s, subprocess-isolated so a half-saved syntax error never kills the watcher). --cycles N polls exactly N times and exits 0; without it the loop runs until ctrl-c.
help [TOPIC]The help git, npm and docker answer to. With no TOPIC it prints the command list; with one it prints that subcommand's own help, the same page as crapkit TOPIC --help. A TOPIC that names no subcommand exits 3.
mcpA stdio MCP server with no extra dependency, exposing twelve read-side tools named verb_noun, each with a title and output schema. Tools call the CLI to inspect current scores, source and edited-file gates. They take no claims and run no verification; calls can write caches or store metadata. See the MCP contract and setup.

Reading the output

Flags: why a coverage number is missing

FlagMeaningScored
measuredA lane artifact spoke about this function.Real cov.
untestedA lane covers the scope, but its artifact is silent on this function, which normally means no test imports the file.cov = 0. A testing gap, and uncovered_lines comes back null because no artifact can name lines it never saw.
no-laneNo lane's scopes list names this function's scope.cov = 0. A tooling gap, not a testing gap. next-item never hands one out and counts them in skipped_no_lane; worklist ranks them and marks the row no-lane, because a wiring gap is a risk you have to see.
cc-onlyThe scope sets coverage_optional = true, so no coverage number can exist.crap = ccn, and remedy can only be ok or decompose. uncovered_lines comes back null with a note naming that setting.

The coverage summary counts all four as measured / untested / no_lane / cc_only.

Remedy: what to do about it

RemedyConditionAction
decomposeccn > ceilingSplit it. No amount of coverage clears this.
split-linesccn <= ceiling, crap > ceiling, and another function shares its source linesPut each definition on its own lines, then measure again. Coverage cannot tell functions on one line apart, so the score stays at uncovered whatever the tests do.
add-testsccn <= ceiling and crap > ceilingCover the branches.
okcrap <= ceilingNothing.

Grade and CRAP load

The grade is the share of functions over their ceiling: A+ at exactly zero, A under 2%, B under 5%, C under 10%, D under 20%, F at 20% or more. crap_load beside it is the plain sum of every function's CRAP score, so it moves when a function gets better even if the letter does not.

Risk: what ranks the worklist

risk = ccn * churn weight. The weight is a time-weighted sum over the file's commits in the churn window: each commit contributes a logistic weight rising to 0.5 for the newest commit in the log and falling to near zero for the oldest, so five edits last month outrank fifty from two years ago. The window anchors on the newest commit, never on the wall clock, so a fixed tree ranks identically forever.

Age is not the input, position in the log is. A log whose commits all share one timestamp has no range to weight against, so each commit counts once: a one-commit repo weighs every file 1.0, ranks by ccn, and promotes nothing under the floor, because a top 10% of equal weights would be every file. Commits minutes apart already rank. This repo was eight commits old, all made the same day:

$ crapkit worklist --scope util
worklist @ a7c5c85ac37 (run 1, floor ccn>=5, churn 12mo) - 3 of 3 active (worklist_top 50), 0 dormant
  risk      5.4  ccn   5  crap    30.0  cov   0%    5c/1a  util/stats.py:1  bucket( value , low , high )
  risk      4.5  ccn   9  crap    90.0  cov   0%    1c/1a  util/curve.py:1  curve( scores , mode , floor , ceiling , skip_none )
  risk      4.3  ccn   4  crap     4.2  cov  75%    5c/1a  util/stats.py:13  spread( values , cap )  ok

bucket at ccn 5 outranks curve at ccn 9 because five commits touched it and one touched curve. That is the whole point of weighting by churn. spread carries the ok marker: already at or under its ceiling, listed anyway, and next-item would not hand it out.

The list splits in two: active (files with commits in the window) and dormant (zero churn, kept out of the queue but counted). Two rules reach under the worklist_floor. A file whose churn weight sits in the top 10% is promoted down to ccn 3, which is why spread appears above at ccn 4. And a function over its ceiling is admitted whatever its ccn, so the floor can never hold back debt.

The trusted baseline

Every verify measures the working tree against one earlier run, the trusted baseline. crapkit runs list marks which one that is today.

Which runs qualify. A coverage run, or a verify that passed. A failed verify never qualifies, and neither does a partial run (a lane failed, so some scope fell back to no-lane) nor a hook override record, which carries no scored rows at all. In runs list, verdict=- marks a run that produces no verdict rather than one that failed: only verify renders a verdict. Four readers ask this one question and get this one answer: the baseline pick here, ratchet seed, prune, and the tighten damping that compares a mark against the same commit's previous run. A mark can no longer be signed off a run verify refused.

What advances it. Any qualifying run. coverage writes one wherever HEAD is, so a dashboard cron advances the baseline exactly as CI does. A passing verify advances it and tightens the ratchet on the way.

The taint rule. A failed verify recorded findings against a tree. Until some verify passes, runs made after that failure do not become the baseline: choosing one would move the comparison point past the findings, the flagged function would stop counting as touched, and nothing would look at it again. verify says which run it refused and falls back to the newest run in front of the failure.

$ crapkit runs list
run   1 @ 88012a148f6 2026-08-23T09:27:46Z coverage  verdict=-      lanes=py  baseline
run   2 @ 803bdde8556 2026-08-23T09:27:53Z verify    verdict=FAILED lanes=py
run   3 @ 803bdde8556 2026-08-23T09:28:02Z coverage  verdict=-      lanes=py

$ crapkit verify
warning: run 3 is not the baseline: verify run 2 FAILED with 1 finding(s) and no passing verify has cleared it since — measuring against run 1 @ 88012a148f6 instead, so those findings stay visible. Fix them, or pass `--baseline 3` to accept the newer run deliberately.
verify FAILED @ d89068de7f3 vs baseline 88012a148f6 (2 changed files)
  GATE  crap     72.0  ccn   8 cov 0%  calc/legacy.py:7  legacy_router( a , b , c , d , e )  -> decompose
  findings: 1 committed / 0 dirty (uncommitted edits and untracked files)

Run 3 is a coverage run somebody took on the tree run 2 refused, and it scores the same ccn-8 function. Without the rule it would have become the baseline, legacy_router would have stopped being a touched function, and that gate line would never print again.

The escape, twice. Fix the findings and let a verify pass, which clears the taint for good. Or accept the newer run on purpose with verify --baseline 3: an explicit id bypasses the rule, and the run history records which run the verdict used. Nothing here touches a repo that has never run verify: with no failure to protect, coverage alone always advances the baseline.

When the id you pass cannot serve. A --baseline ID naming a real run that is not a candidate says which run it is, why, and which ones can:

$ crapkit verify --baseline 3
crapkit: run 3 is an inventory run (no coverage was measured) and cannot serve as a baseline; trusted runs: 1, 2; pass `--baseline 2` for the newest

Exit codes

CodeMeaning
0OK. For verify and hook-precommit: the gate passed.
1Overloaded. Three unrelated things, listed below the table.
2Usage error from argparse: unknown flag, missing positional. Raised before crapkit's own error handling.
3Config error: crapkit.toml missing or unparseable, an unknown language or parser, a lane command the shell that runs it reads as a narrowed suite, a ratchet metric-stamp mismatch (Upgrading from 0.4.4), a test-scoped file under no scope or under a scope with no template.
4Git error: not a repository, a baseline commit rewritten out of the history.
5Tool error: lizard not importable, a lane that produced no artifact, one that measured a different tree, one that measured this tree and reported it in absolute paths (the join is root-relative, so those match nothing either; the refusal names the runner's own switch, relative_files = true under [tool.coverage.run] for a coveragepy lane, the reporter's cwd/root option for an istanbul one), a lane that timed out past its retries, an override alert command that failed. A timeout_seconds kills the whole process tree, so no orphan suite keeps running behind the failure.
6Gate violation. A function the diff touched is over its ceiling and past any ratchet mark it carries: an edit that leaves a marked function at or under its mark is the debt the repo signed for and is exempt. Also rescore --gate, which applies the same rule, and hook-precommit, which exempts on the mark's existence instead.
7Ratchet regression the diff never touched. A marked function scores worse than its recorded high-water mark; a touched one past its mark reports 6.
8New test failures against the baseline run. Failures the baseline already had do not count.
9Diff-coverage ceiling breached: diff_uncovered_max is set and more changed lines than that never ran.

Exit 1 means one of three things

CI cannot tell a crash from a clean policy verdict on the code alone. Which one you got depends on the command:

CommandWhat exit 1 means
doctorA FAIL finding. This is a verdict, not a crash. A WARN (an unmeasured directory, or a lane writing its artifact at the repo root) and a note (a file over max_file_bytes, or no lanes declared) both exit 0.
ratchet report --enforceThe debt policy was breached. Also a verdict.
anything elseAn unexpected error: "no snapshot yet, run crapkit coverage first", a brief name that matches no function, a test-scoped runner that exited non-zero.

verify reports the first of 6, 7, 8, 9 that fires, in that order. A gate violation and a ratchet regression together report 6. A run that takes any of them fails, so it neither advances the baseline nor tightens the ratchet, exit 9 included.

Quickstart: Python

A repo with calc/grade.py, tests/test_grade.py, and a pyproject.toml. Commit first; crapkit reads git ls-files. Install the coverage plugin first, because the lane init writes runs pytest --cov and those flags come from pytest-cov:

pip install pytest-cov

(pip install "crapkit[py]" pulls both at once when crapkit shares the suite's venv.)

If your suite drives its own CLI through subprocess.run, add [tool.coverage.run] patch = ["subprocess"] to pyproject.toml and keep coverage>=7.10.6: pytest-cov 7.0.0 dropped subprocess measurement, so without that key every entry point scores 0% and nothing warns. docs/lanes.md has the whole rule.

1. Scaffold the config

$ crapkit init
wrote crapkit.toml with 1 scope(s): calc
detected 1 lane(s) from this repo's own files: py - next: run `crapkit coverage`
added to .gitignore: .crapkit/, .coverage, __pycache__/

init sniffs tracked source into one scope per top-level source directory, and detects a coverage lane from what the repo already has: a pytest marker file (pyproject.toml, pytest.ini, setup.cfg) writes a live [[lane]], and so does a test script or vitest/jest in package.json. A lockfile beside them names the environment: uv.lock, poetry.lock, pdm.lock or Pipfile.lock makes the lane uv run python -m pytest … (and the matching run for the rest), because a bare python binds to whichever venv the shell has active rather than the one the repo pins — see The interpreter a lane binds to. Whatever it detects, it also leaves commented templates for the runners it did not find, and those carry the same launcher, so uncommenting one cannot hand the bare python back. Every lane it writes reports into .crapkit/cov/, which is why the .gitignore list is so short: see Where artifacts live.

[crapkit]
target = 6

[[scope]]
name = "calc"
paths = ["calc"]
languages = ["python"]

[exclude]
# A leading **/ matches zero or more directories, so each glob below reaches the
# repo root and every nested copy. Test directories leave the corpus on their own.
globs = [
  "**/node_modules/**",
  "**/dist/**",
  "**/build/**",
  "**/vendor/**",
  "**/generated/**",
  "**/__generated__/**",
  "**/*.generated.*",
  "**/*.test.*",
  "**/*.spec.*",
  "**/test_*.py",
  "**/*_test.py",
  "**/conftest.py",
  "**/*_test.go",
  "**/*.config.ts",
  "**/*.config.js",
  "**/*.config.mts",
]

[[lane]]
name = "py"
command = "python -m pytest --cov --cov-branch --cov-report=json:.crapkit/cov/py.json --junitxml=.crapkit/cov/junit-py.xml --continue-on-collection-errors"
artifact = ".crapkit/cov/py.json"
results_artifact = ".crapkit/cov/junit-py.xml"
parser = "coveragepy"
scopes = ["calc"]

# Declare one [[lane]] per coverage command, then run `crapkit coverage`.
# [[lane]]
# name = "js"
# command = "npx vitest run --coverage --coverage.reportsDirectory=.crapkit/cov/js --coverage.reportOnFailure --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml"
# artifact = ".crapkit/cov/js/coverage-final.json"
# results_artifact = ".crapkit/cov/js/junit.xml"
# parser = "istanbul"
# scopes = ["<your-scope>"]

# `crapkit test-scoped FILES` runs one command per scope, with {files}
# replaced by that scope's files, each quoted; a template with no {files}
# runs as written, which is how a scope whose tests live elsewhere runs them.
[crapkit.scoped_tests]
# calc: no test file under calc/, so the whole suite runs, from tests/
calc = "python -m pytest tests -q -p no:cacheprovider"

The last block is the one an agent loop needs. crapkit test-scoped exits 3 for a file whose scope declares no template, and AGENTS.md makes it step 4 of the burn-down loop. Every key is in docs/configuration.md.

2. Check the config against the repo

$ crapkit doctor
ok   config keys all recognized
ok   scope 'calc': 1 file
ok   every tracked source file belongs to a scope
ok   1 lane(s) declared
ok   lane 'py': python -> /home/you/ledger/.venv/bin/python (pytest 8.3.3, pytest-cov 7.1.0)
ok   lizard 1.24.0
doctor: no problems found

doctor prints one line per check and exits 1 only on a FAIL. WARN and note report and exit 0.

3. Score the repo, and read the queue

$ crapkit coverage
run 1 @ fae4db93108: 2 functions scored: 2 measured, 1 over ceiling 6, CRAP load 41.0, grade F
-> next: crapkit worklist

$ crapkit worklist
worklist @ fae4db93108 (run 1, floor ccn>=5, churn 12mo) - 1 of 1 active (worklist_top 50), 0 dormant
  risk     14.0  ccn  14  crap    38.5  cov  50%    1c/1a  calc/grade.py:7  classify( score , attempts , late , bonus )

Columns: risk, ccn, the function's crap and cov off the ranked run (- on an inventory-only run), <commits>c/<authors>a in the churn window, path:line, the function's long name, then a marker on rows the burn-down queue will not hand out (ok, no-lane). The header counts the active rows against their total, so 50 of 3980 active (worklist_top 50) says what the cap hid, and reads (--top N) when the flag set the cap. --json also carries ccn_std, weight and ratchet_mark.

worklist is the risk map, not a to-do list. It ranks finished rows too, so it does not empty when the burn-down does. next-item is the other view of that run: it drops the no-lane rows, ranks by crap, and its empty: true is the stop condition.

4. Take the top item

$ crapkit next-item
{"commit": "fae4db93108b4841a00959f9117430679e7250ca", "empty": false, "item": {"authors": 1, "ccn": 14, "ccn_std": 14, "cognitive": 13, "commits": 1, "cov": 0.5, "crap": 38.5, "end": 28, "est_splits": 3, "est_uncovered_paths": 7, "flag": "measured", "function": "classify( score , attempts , late , bonus )", "handle": "classify", "nesting": 3, "nloc": 22, "path": "calc/grade.py", "remedy": "decompose", "scope": "calc", "start": 7, "target": 6, "uncovered_lines": [9, 11, 15, 17, 19, 24, 25, 26, 27, 28]}, "run_id": 1, "schema": 1, "skipped_no_lane": 0, "stale": false}

remedy: "decompose", est_splits: 3 (this needs roughly three pieces to fit under 6), and uncovered_lines naming the ten lines no test walks. handle is the name form to pass back, and stale: false says the run still describes HEAD. Every field is in docs/agent-json.md.

5. Seed the ratchet

Arm the debt gate before fixing anything. ratchet seed records every over-target function at its current score, and from then on nothing may get worse.

$ crapkit ratchet seed
crapkit-ratchet.tsv: added 1, tightened 0 - 1 mark(s) vs run 1 (fae4db93108)

$ git add crapkit.toml crapkit-ratchet.tsv .gitignore && git commit -m "adopt crapkit"

6. Fix it and verify

Extract until every piece sits at or under the ceiling. Here classify became _validate, _adjusted, _band and a classify that only sequences them, with the table of cases pushed into parametrized tests. Commit the fix, then:

$ crapkit verify
verify OK @ 8d10c13303d vs baseline fae4db93108 (5 changed files)

$ crapkit coverage
run 3 @ 8d10c13303d: 5 functions scored: 5 measured, 0 over ceiling 6, CRAP load 19.0, grade A+
-> next: crapkit worklist

CRAP load 41.0 to 19.0, grade F to A+. verify reruns the lanes and checks three things against the trusted baseline: every function the diff touched sits at or under its ceiling, no marked function got worse, and no test that passed in the baseline fails now. Exit 0 advances the baseline and tightens crapkit-ratchet.tsv in place, so the repaid mark leaves the file: follow up with git commit -am "ratchet: classify repaid". The full mark lifecycle is in docs/ratchet.md.

crapkit next-item now comes back empty: true with a reasons object saying which ending you got. That is most of the stop condition, not all of it: AGENTS.md states the whole rule and reads the rest of reasons.

Quickstart: TypeScript

A vitest repo with src/grade.ts and test/grade.test.ts.

1. Scaffold the config

$ crapkit init
wrote crapkit.toml with 1 scope(s): src
detected 1 lane(s) from this repo's own files: js - next: run `crapkit coverage`
added to .gitignore: .crapkit/

The lane init wrote is npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --coverage.reportOnFailure --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml. It reads vitest's json reporter from .crapkit/cov/js/coverage-final.json; the reportsDirectory flag is what keeps that report out of your root. The junit half is the lane's results_artifact, which the crashed-worker and no-new-failures checks read; both reporters are named because --reporter=junit alone would replace the console output you watch the suite through. Anything that produces an istanbul coverage-final.json works; see docs/lanes.md for the jest and pytest recipes, a package one directory down, and a crapkit root below the repo top.

2. Install a coverage provider

This is the step that stops most TypeScript users. vitest ships no coverage provider by default. Without one, init and doctor are both happy and coverage dies with exit 5:

$ crapkit coverage
crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); lane log: /repo/.crapkit/lane-js.log; last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --coverage.reportOnFailure --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml

 MISSING DEPENDENCY  Cannot find dependency '@vitest/coverage-v8'

(exit 1)
crapkit: every lane failed (1 of 1); the errors are above

That failure writes no run. Every lane failed, so coverage exits before it opens a store: there is no .crapkit/crap.sqlite yet and the run ids below still start at 1.

Install the provider, and pin the major yourself. Unpinned, npm resolves the newest provider against your older vitest and refuses the tree:

npm i -D "@vitest/coverage-v8@<your vitest major>"
QuestionAnswer
Which provider?Either works. @vitest/coverage-v8 is vitest's default and needs no config. @vitest/coverage-istanbul also works and needs coverage.provider = "istanbul" in your vitest config.
Which crapkit parser?Both feed parser = "istanbul". The provider name and the parser name are unrelated: v8 output is remapped to the istanbul JSON schema before it is written.
Which version?The provider's major has to match vitest's. On vitest 2 that is npm i -D "@vitest/coverage-v8@2", on vitest 3 npm i -D "@vitest/coverage-v8@3". Drop the pin and npm answers ERESOLVE unable to resolve dependency tree, naming the peer it could not satisfy.

The artifact crapkit wants is coverage-final.json, written by vitest's json coverage reporter, which is on by default. If your vitest config sets coverage.reporter explicitly, keep "json" in the list.

vitest writes no coverage report at all when the run fails. The lane init wrote already carries --coverage.reportOnFailure, so a red test still produces the artifact. If you write the lane by hand, or you would rather keep the switch beside your other coverage settings, coverage.reportOnFailure = true in the vitest config does the same job; either one is enough. The full block is in docs/lanes.md.

3. Score the repo

$ crapkit coverage
run 1 @ 8bfbe613fcd: 2 functions scored: 2 measured, 1 over ceiling 6, CRAP load 56.68, grade F
-> next: crapkit worklist

$ crapkit worklist
worklist @ 8bfbe613fcd (run 1, floor ccn>=5, churn 12mo) - 1 of 1 active (worklist_top 50), 0 dormant
  risk     15.0  ccn  15  crap    52.4  cov  45%    1c/1a  src/grade.ts:8  classify ( row Row )

classify is ccn 15 against a ceiling of 6: one function holding the late-and-retry penalty, the letter bands, the demotion rule and the null case.

4. Seed the ratchet and commit

ratchet seed records every over-target function at the score it has today, so nothing can get worse while you burn this one down.

$ crapkit ratchet seed
crapkit-ratchet.tsv: added 1, tightened 0 - 1 mark(s) vs run 1 (8bfbe613fcd)

$ git add crapkit.toml crapkit-ratchet.tsv .gitignore && git commit -m "adopt crapkit"

5. Fix it

Above the ceiling, coverage cannot help, so classify gets split rather than tested. penalty, band and demote come out as their own exported functions, and classify keeps the null case and the bonus:

export function classify(row: Row): string {
  if (row.score === null) {
    return "N/A";
  }
  let score = row.score - penalty(row.attempts, row.late);
  if (row.bonus && score < 90) {
    score += 3;
  }
  return demote(band(score), row);
}

rescore --gate judges that edit on complexity alone, before the slow step:

$ crapkit rescore src/grade.ts --gate
rescore vs run 1 @ 8bfbe613fcd (coverage STALE, complexity fresh)
   ccn   cov     crap  remedy      function
     5    0%     30.0  add-tests   src/grade.ts:22  band ( score )
     5    0%     30.0  add-tests   src/grade.ts:38  demote ( letter , row Row )
     4    0%     20.0  add-tests   src/grade.ts:8  penalty ( attempts , late )
     4   45%      6.7  add-tests   src/grade.ts:48  classify ( row Row )
     4   75%      4.2  ok          src/grade.ts:59  average ( scores Array )

Exit 0: every piece is at or under 6. The crap column is loud because its coverage half is still run 1's, from before three of those functions existed, and add-tests is the literal instruction for step 6.

6. Cover the new pieces

rescore --gate passed on complexity, not on coverage. penalty, band and demote are three functions no test has ever called, so each gets a table test:

describe("band", () => {
  it.each([[95, "A"], [85, "B"], [75, "C"], [65, "D"], [10, "F"]])(
    "scores %i as %s", (score, expected) => expect(band(score)).toBe(expected));
});

Run the suite once before the slow step:

$ npx vitest run
 Test Files  1 passed (1)
      Tests  21 passed (21)

Skip this step and step 7 fails rather than passes. Run on a copy of this repo with step 6 left out, verify reruns the lanes against the real tree and three functions the old suite never called come back over the ceiling:

$ crapkit verify
verify FAILED @ 0296156ff21 vs baseline 0e646697946 (1 changed files)
  GATE  crap     17.8  ccn   5 cov 20%  src/grade.ts:38  demote ( letter , row Row )  -> add-tests
  GATE  crap     12.4  ccn   5 cov 33%  src/grade.ts:22  band ( score )  -> add-tests
  GATE  crap     10.8  ccn   4 cov 25%  src/grade.ts:8  penalty ( attempts , late )  -> add-tests

7. Verify

$ crapkit verify
verify OK @ 2af3433d979 vs baseline 8bfbe613fcd (3 changed files)

$ crapkit coverage
run 3 @ 2af3433d979: 5 functions scored: 5 measured, 0 over ceiling 6, CRAP load 22.0, grade A+
-> next: crapkit worklist

CRAP load 56.68 to 22.0, grade F to A+, and the mark seeded in step 4 is gone: verify dropped it once classify scored under the ceiling, rewriting the tracked crapkit-ratchet.tsv in place. Commit it with your change. Marks only ever fall.

A verify may also print warning: N changed line(s) have no coverage above its verdict; that block is advisory unless diff_uncovered_max is set (docs/configuration.md). It prints warning: N function(s) over the ceiling carry no ratchet mark when the tree holds debt ratchet seed never signed: the gate judges touched functions only and the ratchet check compares marks only, so coverage loss on such a function would pass unseen. The count is unmarked_over_target in --json, fires no exit code, and is zero on a repo with no debt.

Documentation

PageCovers
The handbookStart here for anything deeper. The illustrated handbook: what crapkit is, how every piece works, and where each command earns its keep. Also at docs/handbook.html, self-contained, so it opens straight from a clone.
docs/adoption.mdThe judgment layer over the quickstarts: scope granularity, exclude vs lane, scoped_tests wiring, the first-verify taint hazard.
docs/configuration.mdEvery crapkit.toml key: type, default, and what it does.
docs/lanes.mdThe lane model, vitest and jest and pytest recipes, artifact reuse, flake retest, containers.
docs/resources.mdWorker budgets, command cleanup, log rotation, test evidence retention and safe cleanup.
docs/ratchet.mdSeeding, pruning, the git merge driver, metric stamps, debt policy, overrides.
docs/upgrading.mdExisting installations: analysis and key versions, saved state, plugin alignment and Windows upgrades.
docs/portable-records.mdLossless exports, portable baselines and ratchets, including filenames with delimiters.
docs/agent-json.mdThe machine surface: schema, every payload field, real captured examples.
docs/comparison.mdWhere crapkit sits next to radon, xenon, wily, coverage.py and SonarQube, and how they run together.
AGENTS.mdThe burn-down loop an agent runs, and the rules for changing crapkit itself.
plugin/Three skills and the MCP server for Claude Code and Codex, with advisory PostToolUse hook instructions for Claude Code.

crapkit.schema.json is the authority on the config file shape.

Development

pip install -e ".[dev]"
git config core.hooksPath git-hooks
python tools/testing/run.py

The dev extra includes pytest, pytest-cov, pytest-xdist and coverage.py. The shared runner owns the unit and E2E schedule; use --unit-workers 1 for serial unit reproduction or --coverage for combined branch coverage and JUnit. The git config line arms the complexity gate. See CONTRIBUTING.md for development and the verified implementation report for complete Windows source and Linux wheel results, focused benchmarks and their limits.

Maintainer and project background

crapkit is created and maintained by Jean-François Gagné. Read the project background for the problem it addresses and how it fits into his work on software and AI.

License

MIT. See LICENSE.

Keywords

complexity

FAQs

Related posts