MCP server: One MCP server.

crapkit

@jeanfrancoisgagneRun by its publisher: served from their own domain, namespace checked by the registry.MCP server · External

Twelve tools to inspect CRAP scores, find work, and check edited functions against their ceiling.

Version controlv0.8.1MITPython
Freeno charge per call

Runs on your machine from its package; the steps are below.

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
From the README

What it does

  1. Reads
  2. Writes
  3. High impact

At most: moves money, runs code or commands, deletes data, or sends messages for you.

What it costs

Free

Odel charges nothing per call. The publisher may charge for its own service.

Runs locally over stdio

This server isn't hosted — your MCP client launches it from a package registry. Use one of the commands below, or drop the config into your client (e.g. Claude Desktop).

PyPIcrapkitv0.8.1

Run

uvx crapkit

MCP client config

{
  "mcpServers": {
    "crapkit": {
      "command": "uvx",
      "args": [
        "crapkit"
      ]
    }
  }
}
README44 min read

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 ceiling 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; init and verify name the first three such files they find.

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
git commit -m "adopt crapkit"
crapkit verify      # the first passing verdict

Not a Python repo? uvx crapkit init runs the same commands and adds nothing to your manifest: see A repo that is not Python. A Python repo skips uvx and installs crapkit as above, or with uv tool install or pipx install; that section says why. pip stops with externally-managed-environment? See When pip refuses.

coverage measures a commit, so a fresh git init repo needs its first commit before it. Until then it exits 4 and says so:

$ crapkit coverage
crapkit: the git repository at /repo has no commit yet: crapkit measures a commit, so make the first one (git add, then git commit) and run it again

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. Each step prints the next one: init names coverage, coverage names worklist, worklist names ratchet seed, and ratchet seed names the commit and verify.

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 )
no crapkit-ratchet.tsv yet: seed marks each function over its ceiling at today's score, and from then on a mark may only fall
-> next: crapkit ratchet seed

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. A pytest lane also needs coverage.py 7.13.1 or newer, which writes the function start lines crapkit reads; an older one fails the lane at exit 5. 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 .

Commits on main between two releases carry the same version string, and pip keeps an installed crapkit whose version matches, so running the git line again after a new commit leaves the old code in place. This line replaces it with the tip:

pip install --force-reinstall --no-deps git+https://github.com/JeanFrancoisGagne/crapkit.git

pip install . in a clone rebuilds on every run and needs no flag.

When pip refuses

Debian 12, Ubuntu 23.04 and later, Homebrew and uv mark the Python they install as theirs (PEP 668), and pip refuses to write into it with error: externally-managed-environment:

$ pip install crapkit
error: externally-managed-environment

Give crapkit an environment of its own. Either line installs it into one and puts a crapkit command in ~/.local/bin (%USERPROFILE%\.local\bin on Windows), the command the agent plugins start:

pipx install crapkit
uv tool install crapkit

When that directory is not on PATH yet, the installer says so and names the fix: pipx ensurepath, or uv tool update-shell. Open a new shell after either. The python on PATH does not hold this install, and the commit hook runs the crapkit command before it tries python, so it reaches either install as printed.

A Python repo can carry crapkit in its own venv instead, beside the test dependencies its lane runs:

sh
python3 -m venv .venv
. .venv/bin/activate
pip install "crapkit[py]"

The same from cmd.exe on Windows:

bat
python -m venv .venv
.venv\Scripts\activate
pip install "crapkit[py]"

--break-system-packages makes pip write into the system Python anyway, where the next OS package update can break crapkit or the OS's own tools. None of the routes above needs it.

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 `uvx crapkit coverage`
added to .gitignore: .crapkit/

$ uvx crapkit coverage
$ uvx crapkit worklist

uvx puts no crapkit on PATH, so every next step crapkit prints under uvx starts with uvx crapkit. 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. The ratchet's merge driver takes either form; the git merge driver gives the uvx line. uv brings its own Python when the machine has none.

A Python repo installs crapkit instead: in the environment its suite runs in (pip install "crapkit[py]"), or with uv tool install crapkit or pipx install crapkit. uvx puts its own environment first on the PATH the lane inherits, so the lane's python starts uv's cached interpreter, which holds neither the suite's packages nor pytest-cov. init then says that interpreter cannot import pytest_cov, and installing pytest-cov into uv's cache fixes nothing the suite needs.

Requires Python 3.11 or newer and Git on PATH. On an older Python pip stops with No matching distribution found for crapkit, and uvx crapkit runs crapkit on a Python 3.11 or newer that uv finds or downloads. The CLI has one runtime dependency, lizard>=1.24.0,<1.25; a package mirror needs both distributions. Install into the environment you intend to use, then check crapkit --version. The pip install -e ".[dev,accuracy-push]" under Development is a different thing: it adds the test extras, for people changing crapkit.

pip install --user crapkit puts the crapkit command in your user scripts directory: ~/.local/bin on Linux, ~/Library/Python/3.12/bin for a python.org Python 3.12 on macOS, %APPDATA%\Python\Python312\Scripts for Python 3.12 on Windows. When that directory is not on PATH, pip ends its install with WARNING: The script crapkit is installed in '...' which is not on PATH. Add the directory it names to PATH and open a new shell. Until then the shell answers crapkit with command not found (is not recognized on Windows), and the Claude Code plugin lists its server as Failed to connect.

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.8.1

python -m crapkit works identically to the console script and is what to use from a source checkout. A next step crapkit prints names crapkit when PATH finds this installation's console script, and otherwise the interpreter running it, spelled with forward slashes (C:/venv/Scripts/python.exe -m crapkit coverage) so Git Bash, cmd.exe and PowerShell all run it as printed. A segment that holds a space is quoted on its own (C:/"Program Files"/Python312/python.exe -m crapkit coverage), so the line never opens with a quote and runs in PowerShell too. One case loses cmd.exe: a venv's python.exe in a spaced directory with no 8.3 short name, where you put the whole path in double quotes by hand (docs/adr/0003). 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. A leading ~ in PATH is your home directory, also in cmd.exe and in an MCP client's args, where no shell expands it.

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 13. It reads a coverage.py function's span from the start_line coverage.py 7.13.1 and newer write, and refuses a report without one. It scores a source saved as UTF-16 and keys an identifier that holds one of the five bytes cp1252 leaves undefined by its name. It moves numbers in every language but Python and some of Python's cognitive and nesting, puts JavaScript and TypeScript coverage on the function that owns it, and computes CRAP alike on every platform. So every marks file re-seeds once. Version 11, in 0.8.0, renamed once each Python def with a PEP 695 type parameter list and each def nested three or more deep, and listed a def whose body sits on its colon line; crapkit ratchet prune drops the marks left under the old names. Older JavaScript and TypeScript callback marks can require a reviewed mapping. Follow the upgrade guide for the upgrade line each installer takes (pip, pip --user, pipx, uv tool, uvx and a git install), saved state, portable records and Windows launcher locks. CHANGELOG.md says what each release changed.

Upgrading from 0.8.0 to 0.8.1

  1. Install coverage.py 7.13.1 or newer where each Python lane runs: pip install -U "coverage>=7.13.1", or pip install -U "crapkit[py]" when crapkit shares the suite's venv.
  2. Upgrade crapkit in every clone, then re-seed each repo once: crapkit coverage, crapkit ratchet prune, crapkit ratchet seed. When a failed verify pins the baseline, pass --baseline N to prune and to seed.
  3. Commit the re-seed together with every pin that runs crapkit: the CI install pin, the Action's uses: pin and the pre-commit rev. 0.8.1's verify refuses marks stamped with analysis version 11, and 0.8.0's refuses marks stamped 13.
  4. Keep {python} and {python:DIR} out of a committed crapkit.toml until every clone runs 0.8.1: 0.8.0 hands the token to the shell as written, and the lane fails.

Until the re-seed, crapkit verify refuses the marks 0.8.0 stamped, at exit 3:

crapkit: ratchet marks were recorded under [crapkit-analysis=11 lizard=1.24.0] but this run measures [crapkit-analysis=13 lizard=1.24.0] - CRAP scores are not comparable across metric versions; run `crapkit coverage`, then `crapkit ratchet prune`, then re-baseline with `crapkit ratchet seed`

The upgrade guide lists every step in order, and analysis version 13 says what moves. A metric-stamp refusal from an older upgrade is quoted under analysis version 8.

The exe lock on Windows

A running crapkit.exe mcp holds its launcher open, and each installer meets that lock its own way. From pip 23.3 on, pip succeeds: it moves the busy crapkit.exe aside and installs the new release, and the running server keeps serving the old code until you restart its client. pipx does the same when it installs through pip. An older pip, such as the 22.3.1 a Python 3.11.2 venv ships, fails with ERROR: Could not install packages due to an OSError: [WinError 5] Access is denied and puts the old release back: run python -m pip install --upgrade pip first, or stop the server as for uv. uv tool upgrade crapkit fails with os error 32. uv tool install crapkit@latest, and pipx upgrade crapkit when pipx installs through uv, fail with Access is denied. (os error 5); after that uv tool install the crapkit command fails with ModuleNotFoundError: No module named 'crapkit' until the install runs again. After any of those failures, stop the server or its agent session, rerun the same command, then restart the client. The Windows upgrade procedure lists what each installer printed.

Removing crapkit

Take the commit hook and the merge driver out before the package: both call crapkit. After pip uninstall crapkit alone, the sh hook Route 1 and Route 2 write keeps judging every commit through uvx crapkit on a machine with uv, with whichever crapkit release uv has cached or can download, and on a machine without uv stops every commit on No module named crapkit, or on exec: python: not found where there is no python at all, as on a Debian, Ubuntu or macOS that has only python3. The PowerShell hook Route 1 and the handbook write names the launcher that pip uninstall crapkit deletes, so it stops every commit on No such file or directory, with uv or without it. Every merge that touches crapkit-ratchet.tsv conflicts after the driver's crapkit: not found. The upgrade guide's Removing crapkit lists each piece, in a repo and on the machine.

The Claude Code plugin

claude plugin marketplace add JeanFrancoisGagne/crapkit --sparse .claude-plugin plugin
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.

The hook is one shell command, crapkit claude-hook --protocol 1, so it runs as written in any Claude Code version with plugin support, and in the other agents that load Claude Code plugins: Cursor (which imports them), GitHub Copilot CLI and VS Code. Claude Code gets the advisory on stderr with exit 2 and wakes the model with it. Those three read exit 2 as a deny, a message for the user alone, or a blocking error, so there the hook exits 0 and hands the model the same lines as added context (other harnesses).

--sparse .claude-plugin plugin checks out the two small directories the plugin ships from. Without it Claude Code clones the whole repository under a 120-second clone timeout, which one measured add ran out of. A marketplace added without --sparse keeps that full clone; claude plugin marketplace remove crapkit drops it and uninstalls the plugin, and the two lines above put both back.

A repo with no crapkit.toml costs a silent no-op per edit: 68 ms on Windows through the crapkit.exe launcher, where a bare python -c pass took 32 ms on the same machine. Every edit starts it, whatever the file type; an edit to a type crapkit does not measure stops at a suffix check before any config is read. On Windows, Claude Code starts the hook through Git Bash, whose own startup comes on top, and runs it async, so no edit waits for it. 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

The installed plugin moves only at a release. claude plugin update compares version strings, and main carries the last release's version until the next one, so between releases it reports the plugin up to date. 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. When they disagree it prints the commands for the side that is behind, and when claude plugin update answered "already at the latest version" over files main has moved past, it prints the uninstall and install lines that replace them. The update above is for a user-scope install. For one made with --scope project or --scope local, doctor names that scope and the project directory to run it in; for a plugin installed from a marketplace you added as a local directory, which Claude Code loads in place, it names git -C <that directory> pull.

A headless claude -p has no one to grant a tool call, so it refuses every crapkit call and the model reads Claude requested permissions to use ..., but you haven't granted it yet. Pass --allowedTools mcp__plugin_crapkit_crapkit for the plugin's server, or --allowedTools mcp__crapkit for one you added yourself.

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:

json
{
  "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 50 ms together on crapkit's own checkout on Windows, 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.

The hook remembers, per Claude Code session, the bytes each judgement read: one small record per file under .git/crapkit/claude-hook/<session_id>/, and a session idle for 7 days is pruned when another one starts. A recent file whose bytes match its record is skipped, so a touch, a rewrite of the same bytes, or a test run right after an Edit does not repeat an advisory the session already heard. Content that arrives with an old mtime is never judged by the Bash half: a file moved or copied in (mv, cp -p, an unpacked archive), or one a long command wrote well before it returned. The commit gate still judges those.

Codex

Codex 0.131.0 or newer installs the same marketplace's plugin through its own manager; 0.130.0 has no codex plugin add:

codex plugin marketplace add https://github.com/JeanFrancoisGagne/crapkit.git --ref v0.8.1 --sparse .claude-plugin --sparse plugin
codex plugin add crapkit@crapkit

Use the three skills and MCP server in Codex. Codex loads no crapkit hook: the plugin's Codex manifest leaves hooks out, because Codex reports an edit as apply_patch patch text, which the advisory does not read. Codex offers crapkit and crapkit-recover to the model by itself; crapkit-onboard stays out of the model's list until you type $crapkit:crapkit-onboard, as Claude Code waits for /crapkit:crapkit-onboard. A plugin from 0.8.0 or earlier ships no Codex manifest: Codex 0.156.1 lists its hooks as untrusted PostToolUse hooks that run a bare crapkit, and they should stay untrusted.

--ref pins the marketplace to this release's tag, and crapkit's release step rewrites it to the tag it cuts. Codex 0.156.1 checks every Git marketplace each time it starts and reinstalls the marketplace's plugins when it moved. Added without a ref, the marketplace follows main, and a push to main moves the plugin past the CLI you installed from PyPI with no command from you. --sparse fetches the two small directories the plugin ships from rather than the whole repository.

A marketplace added at a tag stays there: codex plugin marketplace upgrade keeps it at that tag. After upgrading the CLI, remove the marketplace, add it at the new tag, and install the plugin again. The listing's --json needs Codex 0.137.0; drop it on an older Codex.

codex plugin marketplace remove crapkit
codex plugin marketplace add https://github.com/JeanFrancoisGagne/crapkit.git --ref v0.8.1 --sparse .claude-plugin --sparse plugin
codex plugin add crapkit@crapkit
codex plugin list --marketplace crapkit --json

The installed plugin moves only at a release. Removing the marketplace keeps it, and it stays at the old version until codex plugin add installs the new one. The same four lines move a marketplace added without --ref onto the tag.

Check the installed Codex plugin with crapkit doctor --plugin-root: with no PATH it reads Codex's plugin cache when Claude Code has no install, and PATH names one install under ~/.codex/plugins/cache/crapkit/crapkit/. A gap there names these refresh lines, never a claude command. 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/* into it instead: ~/.claude/skills for Claude Code, $CODEX_HOME/skills (~/.codex/skills by default) for Codex, ~/.gemini/skills for Gemini CLI. Gemini CLI lists crapkit-onboard to its model with the other two; gemini skills disable crapkit-onboard --scope user takes it out once the repo is adopted.

Other agents

Every other agent starts crapkit mcp from its own config file, and each one reads its own key and fields. The mcpServers block Cursor takes starts nothing in OpenCode, Amp or VS Code's .vscode/mcp.json, and none of them says so; in Gemini CLI it connects, but a headless gemini -p offers the model none of the tools without "trust": true. Wiring crapkit into your agent gives the block for each of 27 agents, Gemini CLI, Goose, Zed and Continue among them, with the file it goes in and where the agent starts the server.

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.

Shell, PowerShell and Rust run on crapkit's own readers. lizard ships none for shell or PowerShell, so crapkit counts their functions itself, and reads the command inside a quoted "$(...)" as code: its &&, ||, -and and -or count as they do written bare. lizard's 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. It also reads a Rust signature, a closure's empty ||, a let-else and a for that is no loop the way Rust means them (see per-language gotchas), and retires each correction the day upstream fixes it. It lists #[inline] fn f() { written on one line, which lizard took for a C preprocessor line. The cognitive column charges a match once, the way Sonar charges a switch. The Rust, shell and PowerShell readers count each match, case or switch arm in the modified column too, so both columns agree and the arms are gated: a seven-arm match gates at ccn 8, where lizard's modified count, and so the gated ccn, of a C switch with seven cases is 2.

Go and Zig read through crapkit's subclasses of lizard's readers, which end a signature where the language does. A function type such as var cb func(int) error opens no function, a result type's braces are not the body, and a parameter of function type counts once.

Python and Swift read through crapkit's subclasses of lizard's readers too. lizard's Python reader ends a def at the first ) of a signature that runs past it, so crapkit reads the signature to the body's colon. lizard's Swift reader takes super.init(...), r.get() and Socket(protocol: p) for declarations and #fileID for the start of a preprocessor line, which hid the functions after them, and lists no function named by a raw identifier with a space in it (func `keeps onboarding if offline`()), so crapkit reads each one as the name or literal it is. It also reads the expression in a string's \( ) as code, which lizard read as part of the string.

C, C++, Objective-C and Java run on lizard's readers with crapkit's fixes on top. lizard hid every function after some constructs, named rows after an attribute or a macro, and left unnamed and array parameters out of params; the per-language notes list what crapkit reads differently.

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. A run scores the refused file as zero functions and names it, and every gate refuses it once a change touches it (The gate). 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 to the agent: on stderr in Claude Code, as added context in Cursor, Copilot CLI and VS Code. Blocks nothing, because PostToolUse runs after the write
crapkit rescore FILE --gatewhen you ask, after the first coverage runpreview. A stricter preview of the commit gate, sub-second, before you stage: a ratchet mark pardons a function only while its CRAP is at or under the mark. With no run behind it, exit 1 and no snapshot
crapkit hook-precommitgit commit, or by handblocks. The hook exits 6; git reports 1. Inside a commit it judges the staged blobs only, so it costs the size of the commit and needs no coverage. Run outside a commit with nothing staged, as pre-commit run --all-files does, it judges every tracked file and fails on committed debt no ratchet mark covers; crapkit coverage, then crapkit ratchet seed, records that debt
crapkit verifybefore you push, and in CIthe verdict. Gate, ratchet, new test failures, diff coverage, against the trusted baseline

Both hooks pardon a function that the marks file in the working tree carries a mark for, whether or not that mark is staged or committed, so touching signed debt never refuses a commit. In CI the file on disk is the committed one, so verify there fails a function whose mark never reached a commit. verify is also what fails a mark that rises. Since 0.4.5 its gate pardons 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 pardons 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 how many it pardoned 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.

Every gate refuses a changed file no reader could read, exit 6, because it judged none of the functions in it: crapkit gate: 1 staged file(s) could not be read, so no function in them was judged:, then UNREAD src/a.ts: ... with the reader's reason, then what to do. A TypeScript expression-arrow body with < before a comma is the common case; wrap the body in parentheses or a block. claude-hook names the same file after the edit, and an override grants nothing while one is in the change. crapkit coverage scores such a file as zero functions and names it on stderr, and crapkit doctor WARNs about each one the newest run could not read, so you meet the file before the commit gate refuses it. A file the change never touched still passes, so an old unanalyzable file blocks nothing until someone edits it.

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. A CI step starts at the top, where no crapkit.toml is, so Route 4 and the GitHub Action take working-directory: packages/api. Any command other than the hook that runs at the top exits 3, and the refusal names the root below it:

crapkit: no crapkit.toml at /repo - nothing to analyze; crapkit.toml sits below it in packages/api: pass --repo packages/api

Path and root rules also cover absolute arguments, literal filenames and Git diff settings. Routes 1 to 3 below need no --repo in such a monorepo when you run them at the git top. Git runs the hook there, and with no crapkit.toml at the top the gate runs in each crapkit root below that owns a staged file and names paths from the top (packages/api/app/m.py). A commit that stages nothing under any crapkit.toml, a docs-only commit or any commit in a repo armed before crapkit init, passes with one note on stderr. To pin the gate to one root instead, end each hook-precommit line of the hook below with --repo packages/api, as in exec python -m crapkit hook-precommit --repo packages/api, and Route 3 adds args: [--repo, packages/api] under id: crapkit-gate. Route 4's crapkit verify takes --repo packages/api.

Route 1 and Route 2 write this hook body. It runs the first of three that the hook's PATH offers: the crapkit command a pipx, uv tool or venv install puts there, then uvx crapkit, then python -m crapkit:

command -v crapkit >/dev/null 2>&1 && exec crapkit hook-precommit
command -v uvx >/dev/null 2>&1 && exec uvx crapkit hook-precommit
exec python -m crapkit hook-precommit

The uvx line is what gates a machine that runs crapkit only through uvx. It runs the release uv fetched first, or the newest one when uv has none, which need not be the release the rest of your team runs. It also keeps the gate running after pip uninstall crapkit on any machine with uv, so take the hook out before the package (Removing crapkit).

Route 1's PowerShell form writes the launcher's own path instead, as that route explains. Route 3's framework writes a hook of its own that runs crapkit hook-precommit from the environment it installs crapkit into, and Route 4 gates in CI with no hook.

Git hands the hook the PATH of whatever ran git commit. A terminal with your venv activated passes the venv on; an IDE or GUI client you did not start from that terminal does not. When that PATH reaches no crapkit command, no uvx and no python that imports crapkit, git refuses every commit. With no python at all, as on a Debian, Ubuntu or macOS that has only python3, the hook exits 127 with exec: python: not found. With a python that does not import crapkit, such as a system python, it exits 1 with No module named crapkit. Spell the path out for that client: exec /path/to/venv/bin/crapkit hook-precommit, or Scripts/crapkit.exe on Windows.

Run git config core.hooksPath before you pick a route. When it prints a directory, set globally or by husky, lefthook or another hook manager, git runs hooks from there and never reads .git/hooks: Route 1 arms nothing, and husky sets the path back over Route 2's on the next npm install. Add the gate to the pre-commit file that directory already runs. Under husky, that is crapkit hook-precommit as a line of its own in .husky/pre-commit, with no exec: husky runs the file under sh -e, so the gate's exit 6 stops the commit, and the lines after it still run when it passes.

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

sh
hook="$(git rev-parse --git-common-dir)/hooks/pre-commit"
cat > "$hook" <<'EOF'
#!/bin/sh
command -v crapkit >/dev/null 2>&1 && exec crapkit hook-precommit
command -v uvx >/dev/null 2>&1 && exec uvx crapkit hook-precommit
exec python -m crapkit hook-precommit
EOF
chmod +x "$hook"

git rev-parse --git-common-dir finds the repository's .git directory from any subdirectory and from a linked worktree, where .git is a file and cat > .git/hooks/pre-commit fails with Directory nonexistent. Every worktree runs the hook kept there. It ignores core.hooksPath on purpose: --git-path hooks follows a global hooks path, and would write this repo's gate into every repo on the machine.

The same hook from PowerShell. Windows PowerShell 5.1 puts a byte-order mark in front of the shebang, UTF-16 from > and Out-File and UTF-8 from Out-File -Encoding utf8, and git then refuses every commit with error: cannot spawn .git/hooks/pre-commit: No such file or directory (measured on git 2.43 for Windows) without ever running the gate. PowerShell 7 writes no mark, and Set-Content -Encoding ascii writes none in either. Git runs the hook with its own sh, so the launcher's path is spelled with forward slashes and quoted, and no chmod is needed on Windows:

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

This hook names the crapkit your shell finds today, so a GUI client that never saw your venv runs the same one. It has no uvx line: while that path is gone, as after you move the install or pip uninstall crapkit, every commit stops on .git/hooks/pre-commit: line 2: .../crapkit.exe: No such file or directory. Paste the block again after you move or rebuild that install.

Get-Command stops the block when no crapkit is on PATH (a uvx-only machine): install one with uv tool install crapkit or pipx install crapkit, or write the sh form above from Git Bash.

crapkit doctor warns when the hook file git would spawn starts with a byte-order mark, and when a core.hooksPath set elsewhere (a global one, or husky's .husky/_) sends git away from .git/hooks, where git then runs no hook at all.

PowerShell 5.1's > writes UTF-16 behind the same mark wherever it saves a file, and crapkit reads each such file as far as the program that owns it can:

  • A source file saved that way scores its functions as its UTF-8 twin does. git diffs it as binary, so crapkit doctor names it in a note.
  • A .gitignore in UTF-16 is one git cannot read. init names it on stderr with the fix and leaves it as it was.
  • A root package.json in UTF-16 is one init will not guess a lane from: it exits 3 before it writes any file, naming the file and the fix.
  • The marks file (crapkit-ratchet.tsv) saved that way reads as its rows, and a write puts it back in UTF-16 behind its mark and in its own line endings. A mark whose name holds a cp1252 byte reads with that byte as U+FFFD, and ratchet seed, prune, move, merge, verify's tighten and both overrides refuse at exit 3, naming the byte, rather than save U+FFFD in place of the name: how the marks file is read.

Set-Content -Encoding utf8 saves any of them as UTF-8.

Route 2: a committed hooks directory

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

sh
mkdir -p githooks
cat > githooks/pre-commit <<'EOF'
#!/bin/sh
command -v crapkit >/dev/null 2>&1 && exec crapkit hook-precommit
command -v uvx >/dev/null 2>&1 && exec uvx crapkit hook-precommit
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

PowerShell stops that block at its heredoc, before any hook is written, and the next commit goes through ungated. From PowerShell, the same route:

powershell
New-Item -ItemType Directory -Force githooks | Out-Null
Set-Content -Path githooks/pre-commit -Encoding ascii -NoNewline -Value "#!/bin/sh`ncommand -v crapkit >/dev/null 2>&1 && exec crapkit hook-precommit`ncommand -v uvx >/dev/null 2>&1 && exec uvx crapkit hook-precommit`nexec python -m crapkit hook-precommit`n"
Add-Content -Path .gitattributes -Encoding ascii -Value 'githooks/pre-commit text eol=lf'
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 committed hook keeps the PATH lookup rather than one machine's launcher path, since every clone runs it.

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. crapkit doctor names a clone that skipped it:

WARN githooks/pre-commit runs crapkit's gate, but core.hooksPath is unset and git runs .git/hooks/pre-commit, so every commit here skips the gate without a word; run `git config core.hooksPath githooks` in this repo, or call crapkit hook-precommit from .git/hooks/pre-commit

After husky's npm install takes core.hooksPath back to .husky/_, the same WARN names .husky/pre-commit as the file to call crapkit from.

Route 3: the pre-commit framework

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

yaml
repos:
  - repo: https://github.com/JeanFrancoisGagne/crapkit
    # crapkit's release step rewrites this line to the tag it just cut
    rev: v0.8.1
    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:

sh
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. prek reads the same .pre-commit-config.yaml: prek install arms the hook in its place, and the gate refuses and accepts the same commits. crapkit doctor WARNs on a config naming crapkit-gate while the hook git runs is not the one the framework writes.

pre-commit run --all-files, the form pre-commit.ci and pre-commit/action run, starts no commit and stages nothing. Outside a commit, with nothing staged, the hook judges every tracked file instead of a staged diff that is empty: a function over its ceiling fails unless the marks file in the working tree marks it, which in CI is the committed file. So a breach committed from a clone that never ran pre-commit install fails the CI job. Inside a commit the hook judges only what is staged, as before. It tells the two apart by GIT_INDEX_FILE, which git sets for the hooks a commit runs. For a CI gate that also weighs coverage and the marks, use Route 4.

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.

The file carries each lane's test count and failure list on its first line, so the PR job forgives a test that already failed on the default branch and warns when a lane runs fewer tests. A file written by crapkit 0.8.0 or older carries neither: against it every failing test is a new failure, exit 8, and verify says so and names the line that rewrites it:

warning: the baseline file crapkit-baseline.tsv holds no test results, so every test failure counts as new and no suite size is compared; write it again with `crapkit verify --emit-baseline crapkit-baseline.tsv` to carry them

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 that holds the commit, the same exit names the branch that holds it, or, when no branch does, blames a rebase or an amend that rewrote history, and asks for a fresh baseline instead. A clone that does not hold it at all, because .crapkit/ came from another clone or from a CI cache keyed on a branch, says so and names the fetch:

$ crapkit verify
crapkit: baseline commit a74260f321f is not in this clone, so git cannot say whether it is behind HEAD; fetch it with `git fetch origin a74260f321f4e0b9d2c61a8f3e57d0c1b2a9e8f7`, or run `crapkit coverage` here for a baseline this clone holds

verify --base and hook-precommit --base look up the fork point with git merge-base, and in the shallow clone above they refuse with exit 4 and the same fix:

$ crapkit hook-precommit --base c47a37b1df69c434ba42eec5979ddad03d2bf1e4
crapkit: git merge-base c47a37b1df69c434ba42eec5979ddad03d2bf1e4 HEAD failed in /home/runner/work/app/app: fatal: Not a valid commit name c47a37b1df69c434ba42eec5979ddad03d2bf1e4; this shallow clone does not hold every commit: set fetch-depth: 0 on the checkout or run git fetch --unshallow

When the clone holds both commits but not the one they fork from, the line reads no merge base between REF and HEAD in ROOT, followed by the same fix. ratchet report --enforce with debt_max_age_months or repayment_min_per_30d set refuses in a shallow clone, exit 4 with the same fix: every mark reads 0 days old there and no repayment shows, so an age limit a full clone fails would pass and a repayment quota a full clone passes would fail. 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:

yaml
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==0.8.1"
      - 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:

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

The whole job those four lines sit in:

yaml
on: pull_request
jobs:
  crapkit:
    runs-on: ubuntu-latest
    permissions:
      contents: read                   # actions/checkout clones the repository
      pull-requests: write             # the comment
    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.8.1
        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. A shallow clone lacks the base commit, so git refuses the diff: the step logs git's error, and the comment ranks the whole repository and says why under its heading, quoting git's first line and naming fetch-depth: 0. A shallow clone also flattens every number counted from history: churn sees one commit per file, so the worklist ranks on ccn alone, and a ratchet mark's age reads as 0 days. worklist, next-item, brief and ratchet report print one line on stderr and carry shallow: true in their JSON (false in a full clone), and the comment repeats the worklist's line above its table:

warning: churn counts read only the commits this clone holds; this shallow clone does not hold every commit: set fetch-depth: 0 on the checkout or run git fetch --unshallow

brief names churn counts and mark ages in that line, and ratchet report mark ages and repayments.

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. Move the pin in the same commit that re-seeds the marks under a new analysis version: an Action one release behind the marks refuses them with exit 3 on every pull request (a team's upgrade).

What the comment looks like

One comment per pull request, edited in place on every push. A hidden <!-- crapkit-action --> line opens it, and the next run edits the first comment that starts with that line, so a fifteen-push branch carries one comment and not fifteen. A reviewer's reply that quotes the comment does not start with it and is left alone. When GitHub answers a page of the comment list with an error, the step edits the crapkit comment it found before the error. When it found none, it lists the comments once more before it posts fresh; if that listing fails too, it posts a fresh comment, so the pull request still gets this push's verdict, and the job log says the listing failed twice. 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:

markdown
<!-- 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 (`app/calc.py`): 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, then ; scope `src` scored no function: it claims no file for each scope that scored nothing, and 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 changed file no reader could read, as - unread: `src/a.ts`, so the gate judged none of its functions: and the reader's reason; 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. Each kind prints fifty bullets at most, then - and 1450 more new test failures; crapkit verify lists them all. The counts line closes it, naming up to three of the files verify judged and counting the rest: 1 changed file (`app/calc.py`). An unread file fails the gate as a function over the ceiling does, so the counts line counts it among the gate violations and says how many were files: 1 gate violation (1 unread file). A verify that passed is one line: **verify passed.** Run 2 against baseline 1, 7 changed files (a.py, b.py, c.py and 4 more). A verify older than 0.8.1 lists no changed_paths, and its line gives the count alone. A lane that recorded no test results (it declares no results_artifact) adds New test failures went unchecked in lane `py`: it recorded no test results. to that line, and a new failure in a lane whose baseline recorded no failure list gets a bullet of its own, - lane `py`: the baseline recorded no failure list, so its new failures may predate this change: the fork point's lane declared no results_artifact, so nothing can tell the pull request's failures from older ones, and verify still counts them.

GitHub refuses a comment over 65,536 characters, and a pull request then gets no comment at all. A body that still runs over (long names under a large top) is cut at the last line that fits, the marker line still first, and ends with the comment stopped at GitHub's 65,536-character limit; the job log above holds the whole text. The step prints the whole comment to the job log before it posts.

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 ranked function in these files. When crapkit worklist wrote no ranking, the table's place says why rather than reading as an empty one: the error it printed (`crapkit worklist` exited 1: no snapshot in ...: run `crapkit coverage` first.), or, when it printed nothing, `crapkit worklist` printed no ranking; its error is in the job log.

The two file counts describe the same diff, counted twice. 39 changed files is git diff --name-only --relative base.sha...HEAD run in working-directory, the branch's own commits under it, and it is what the table is filtered to; the step's log names up to three of them. 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. An empty value renders 5. A value that is not a whole number ("ten", "5.0", "-1") renders 5 and puts a warning naming the input on the run's summary page
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
`wo

Similar modules

Browse Version control