Skip to content

Releases: JeanFrancoisGagne/crapkit

crapkit 0.5.0

Choose a tag to compare

@JeanFrancoisGagne JeanFrancoisGagne released this 04 Sep 01:55

The seventeen repairs from the seven-seat review of 0.4.15 (spec: docs/specs/2026-09-03-release-0.5.0.md, issue #58). Subsections land per slice below.

The Action says why the base run was not made, and gate: "true" fails a pull request that judged nothing

On actions/checkout's default depth-1 clone the Action's base step made no run, verify
judged the checkout against its own run (an empty diff), the comment said verify passed,
and gate: "true" exited 0 on a pull request that exits 6 at full depth. The base step now
writes the reason to crapkit-base.reason on every failure path: 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 comment renders **verify judged no changed function:** the base run was not made (<reason>) in place of verify passed, with no base commit as the reason on
a push event and under delta: "false". With gate: "true" the exit step exits 1 when the
base run was attempted on a pull request and not made, printing the reason; a push and
delta: "false" never attempt it and keep verify's own code. The renderer stays git-free:
the sha and the reason reach tools/action/comment.py as --base-sha and --base-reason
files. Moved contracts: the README's gate and delta input rows, and the "What the
verdict line covers" passage that said none of the three failures fails the job.

The Action does not ask verify for a verdict over a failed coverage

The verdict step ran crapkit verify --json --reuse-artifacts whatever crapkit coverage
had exited. On a runner that keeps its workspace between jobs (clean: false), a lane that
stopped writing its artifact was refused by coverage (exit 5) and then verify read the
artifact that lane had left from an earlier run, passed over it, and runs list showed
that run as the trusted baseline. The checkout step now records coverage's exit, the verdict
step reads it first and does not call verify when it is non-zero, and the comment says
**no verdict: `crapkit coverage` exited 5 (lane 'py' failed: <first line of the lane failure>); verify did not run.**, quoting the error object's message when coverage --json
died before a summary, or pointing at the job log when every lane failed and nothing was
printed. gate: "true" then exits with coverage's code. The renderer takes the code as
--coverage-exit.

The pull-request comment names the function and the rule that failed the check

On exit 6 the comment read 1 gate violation, 0 ratchet regressions, ... over a table in
which the pull request's own untested route() and an untouched ratchet-marked
legacy_router() were two identical rows, and on exit 9 the ceiling and the uncovered lines
were only in the job log. The verdict now opens with the rule the exit code stands for,
**verify failed, exit 6: complexity gate.** (7 ratchet regressions, 8 new test failures,
9 diff-coverage ceiling 3, the ceiling read from the receipt's diff_uncovered_max), then
one bullet per finding: - gate: `app/calc.py:34` `route( a , b , c , d )` ccn 8, cov 0%, crap 72.0 -> decompose, - ratchet: `app/calc.py` `legacy_router( ... )` 72.0 -> 80.5 (recorded -> fresh), - new test failure: `tests/test_calc.py::test_route`, and the first
twenty uncovered changed lines as - uncovered lines in `app/calc.py`: 35, 36, ... with one
bullet per file and - and N more uncovered changed lines for the rest. The counts line
closes the block unchanged. In the table, a row whose function the committed ratchet carries
a mark for (the worklist row's ratchet_mark) reads decompose (accepted debt), and the
rows a finding names come first, ahead of the top cap. Moved contract: the README's
rendered comment is now the byte-for-byte render of the payloads under
tests/fixtures/action_comment/ (a failing example), pinned by the unit suite.

The comment's scored line names the ceiling, a failed lane's first line, or the error

The first line of the pull-request comment read 153 over target with no number, while the
scopes carried ceilings 4, 6 and 12, and a coverage --json that died before printing a
summary left the comment with wrote no run summary and the sentence naming the fix in the
job log. The line now reads 2 over ceiling 6 or 2 over their ceilings (6; reports 12, util 4) from the summary's ceilings, appends ; lane 'js' failed: <first line> for each
entry of lane_failures, and, when the payload is the one-object error --json prints on
a crapkit error, reads `crapkit coverage` exited 5: <message>. A 0.4.x payload without
ceilings reads over the ceiling. The verdict line reads the same error object from
verify --json (a missing baseline commit, exit 4) as **`crapkit verify` exited 4 and wrote no verdict: <message>.** instead of counting it as a verdict with no findings.
Moved contract: the README's rendered comment is regenerated with the new first line.

The MCP server survives a bad call

A tools/call with a missing positional, an undeclared key or a wrong type answers a tool result with isError: true in the tool's own words (brief needs name (see inputSchema.required), worklist does not take 'bogus'; accepted: repo, top, scope, top must be an integer (got "three")) before any CLI spawns, and the session continues; on 0.4.15 a missing positional killed the server and every later request read end of file. params: null and arguments: null are refusals, not crashes, and a positional sent as null is a missing positional (brief needs path (see inputSchema.required)), not a spawned CLI's stderr. tools/list declares required from each tool's positionals. ping answers an empty result instead of -32601. An exception escaping the server answers a JSON-RPC -32603 reply and the loop reads on. ADR 0001 records why the refusals are tool results and not the protocol's -32602.

explain and doctor answer JSON over MCP

Both tools shell to their --json form, so all nine tools return one shape and carry structuredContent whenever the CLI exits 0. explain takes history and tests (booleans; true adds commits and tests to each function, the CLI's --history and --tests). A doctor that finds a FAIL exits 1 and answers its JSON text with isError: true and no structuredContent. Moved contracts: the two MCP e2e asserts that read no problems found from the doctor tool now read problems: []; the two "plain text" rows leave the MCP tables in docs/agent-json.md and AGENTS.md, and the agents guide's initialize reports protocol 2024-11-05 line, stale since 0.4.13, names the negotiated revisions.

worklist and next_item take a scope over MCP

Both tools accept scope, an array of declared scope names, one --scope each, so a large repository is partitioned before top applies; the CLI's answer to the flags comes back as the tool's result.

mutate never mutates a test

crapkit mutate placed mutants in every file the diff touched, tests included: on one review run 6 of 9 mutants landed in tests/test_tax.py and the survivor was an assertion. The diff's file list, and the files --files names, now pass through the corpus predicate scoring uses (scopes, excludes, the test-file cut and max_file_bytes) before a mutant is placed. A file outside the corpus is named on stderr as not mutating <path>: outside the scored corpus, --json lists it under outside_corpus, and a diff with nothing left prints mutation: nothing to mutate; outside the scored corpus (scopes, excludes, test files, max_file_bytes): <paths> at exit 0 without starting the suite. A scope declaring paths = ["."] claims nothing in scoring and now claims nothing for mutate either; declare the files or directories by name, as doctor already asks.

A Python row's nesting is a depth

nesting on a Python function is the deepest the cognitive pass's nesting stack gets, one level per if, elif, else, for, while, except and comprehension for, none for with, try, finally, match, case or a nested def: a flat function of seven ifs reads 1 and a three-deep one reads 3. Until now the column read lizard's ND extension, which counts nesting structures for Python rather than depth, so the flat function read 7 and looked seven levels deep next to the same number for a function that was. The same pass now reads which function owns a token after lizard has, so the first token of the line that leaves a Python function is no longer charged to it: an outer function whose blocks follow a nested helper keeps its own cognitive score and depth instead of handing the first of them to the helper, and the last function of a module no longer pays for the if __name__ == "__main__": or the module-level call that follows it (six of the 5,258 rows in crapkit's own tree move, by one point each); ccn does not move. Brace languages keep lizard's column. The analysis version moves to 9, so the first inventory or coverage after upgrading runs the analysis cache cold and re-measures the corpus once; the nesting row of docs/agent-json.md names the source per language and what opens a level. (#64)

verify says why it refused an override

verify --override on a run holding a ratchet regression or a new test failure used to
exit 6 with no line about the override at all: no OVERRIDDEN, no refusal, an empty
crapkit overrides. It now prints one stderr line naming the cause and the escape,
override refused: 1 ratchet regression (app/m.py pick( a ) 240.0 -> 380.0) never qualifies for an override; raise the mark by hand and commit it, both causes on the one line when a
run holds both. The exit code is unchanged and --json stdout stays one object.
docs/ratchet.md states the rule: a mark never rises through `...

Read more

crapkit 0.4.15

Choose a tag to compare

@JeanFrancoisGagne JeanFrancoisGagne released this 02 Sep 04:29

The registry name follows GitHub's casing

The MCP Registry grants each GitHub user the namespace spelled the way GitHub spells the login, so the server is io.github.JeanFrancoisGagne/crapkit in server.json, in the README ownership marker and in the contract that pins the two together. The lowercase form was refused with a 403 at publish time.

crapkit 0.4.14

Choose a tag to compare

@JeanFrancoisGagne JeanFrancoisGagne released this 02 Sep 03:44

The MCP Registry can verify and list this server

server.json at the repository root describes the server the way the official MCP
Registry (registry.modelcontextprotocol.io) reads it: the PyPI package, the stdio
transport, and the mcp subcommand a client passes to it. The README carries the
mcp-name: ownership marker the registry checks against the package's own description,
which is why the marker ships in a release rather than living only on GitHub. A contract
holds both version fields in the manifest to crapkit.__version__, so a release bump
cannot leave the registry pointing at last release's package.

A comparison page for the reader who already runs a neighbour

docs/comparison.md says what radon, xenon, wily, coverage.py and SonarQube each
measure and gate, where crapkit's complexity-times-uncovered join sits next to them, and
that nothing conflicts: crapkit reads the same coverage artifact the suite already
writes. The README's deep-reference table links it.

crapkit 0.4.13

Choose a tag to compare

@JeanFrancoisGagne JeanFrancoisGagne released this 02 Sep 01:53

The MCP handshake speaks the client's protocol revision

The server answered every initialize with 2024-11-05, the protocol's first revision,
which told a current client to drop everything newer. The handshake now echoes the
client's revision when the server implements it (2025-06-18, 2025-03-26 or
2024-11-05) and offers 2025-06-18 otherwise. A tool whose text is a JSON object now
also carries it parsed as structuredContent, which is how a client on the current
revision reads machine output; prose, arrays and error text stay text-only.

Every MCP tool says when to reach for it and what each argument means

The nine tool descriptions were one-line noun phrases, several arguments carried a bare
type with no description, and nothing in the listing said the tools were read-only. Every
description is now two sentences (what it answers, then when to use it or how it relates
to its neighbour), every argument names its meaning and default, every tool declares
readOnlyHint/idempotentHint/openWorldHint annotations, and initialize returns
instructions carrying the two-command prerequisite a connected model otherwise learns
from nine identical error results.

crapkit 0.4.12

Choose a tag to compare

@JeanFrancoisGagne JeanFrancoisGagne released this 02 Sep 00:38

A lane that writes nothing no longer scores the previous run's artifact

crapkit asked only whether the artifact file existed, never whether the run that just
finished wrote it. So a lane failed loud exactly once, on the first run against an empty
.crapkit/, and went quietly wrong on every run after: the suite dies in collection, last
run's coverage JSON is still sitting there, and crapkit scores it as fresh, stamps it with
the current commit and hands --reuse-unchanged a reason to keep trusting it. A vitest
lane without reportOnFailure and a pytest lane hitting a collection error both land here.
A lane now records the modification time of every file it declares before each attempt and
requires it to move, so the refusal fires on the second run the way it did on the first. A
leftover file gets its own wording — wrote no artifact this run — the .crapkit/cov/py.json on disk predates it and is the previous run's — because the old sentence, about a path
that holds a report, reads as crapkit failing to see the file. Where the artifact is missing
and the leftover is some other declared file, the artifact path still leads: produced no artifact at .crapkit/cov/py.json, and the .crapkit/cov/junit.xml on disk is the previous run's. results_artifact is held to
the same rule, so a killed suite's junit cannot feed the test-count and no-new-failures
checks last run's numbers. The check is the mtime and not the bytes, so a runner that
rewrites an identical report stays green, and --reuse-artifacts is untouched.

mutate refuses to score a suite that never ran

crapkit mutate read any nonzero exit from mutation_command as a killed mutant, so a
command that cannot run here killed all of them and printed a 100% mutation score for a
suite that never imported the code under test. The documented command is
python -m pytest -q -x, a bare name, so any machine whose PATH python is not the
interpreter holding pytest — a hook, a cron, cmd.exe, the Windows Store stub that exits
9009 — got a perfect score. The command now runs once against the unmutated tree before
the first mutant, in the worker's own checkout when the run is parallel. A baseline that
does not exit 0 ends the command (exit 5) naming the runner word, its exit code and the
score it would otherwise have printed, instead of scoring anything.

A scope path spelled ./src claims the files under src

paths = ["./src"] claimed nothing. The declared string is hoisted straight into a
match prefix, so the matcher looked for ./src/... while git ls-files emits
src/a.py: the scope scored zero files, every file under it became unclaimed, and
doctor printed two FAILs that named neither the dot — it blamed the empty scope, then
blamed the file for having no scope, which sends the reader to declare a second scope
for a path the first one already owned. Outside doctor it was quieter still:
crapkit inventory reported 0 functions in 0 files and exited 0. Backslashes were
already collapsed one layer down, which made the tool look like it normalized paths.
Scope paths are now normalized where they are parsed: a leading ./, a leading or
trailing /, and \ as a separator. A path holding .. or a drive letter is a config
error naming the scope, because no tracked file can ever match it.

duplication --top 0 no longer prints a clean bill of health

crapkit duplication --top 0 printed "no near-duplicate functions found" and exited 0
over a tree full of duplicate pairs, which is a false all-clear and the thing a CI job
reads. --top -1 sliced from the tail and dropped the last pair with nothing said.
coupling --top sat behind the same unguarded slice. Both commands now refuse anything
below 1 at the entry, before they open the store: duplication --top must be >= 1, got 0, exit 3.

next-item --top 0 refuses instead of handing out an item

crapkit next-item --top 0 printed an item and, with --claim, took a claim on it that
hid that function from every other session. --top -1 did the same. The slice was
written ranked[:max(top, 1)], so a 0 widened back to one, and the emit branch tested
top > 1, so anything below it fell through to the single-item shape. An agent
templating --top {budget} that computed 0 got work it had not asked for, locked. Its
sibling crapkit worklist --top 0 already exited 3 naming the rule. next-item now
answers the same way: next-item --top must be >= 1, got 0, exit 3, no claim taken.

One unimportable test file no longer takes the whole pytest lane down

A repo with a renamed module, a missing optional extra or a stale editable install got
coverage exit=5 and every lane failed (1 of 1) from a suite whose other test files
collected fine. pytest raises Interrupted at the end of collection when any module
fails to import, so pytest-cov's session finish never runs and the lane writes no
coverage JSON at all; the junit lands anyway, which makes the run read as half finished
rather than as a flag. doctor said "no problems found". The lane init writes now
carries --continue-on-collection-errors, and so does the commented template beside it,
which is pytest's half of the --coverage.reportOnFailure the vitest lane already got.
Nothing is hidden: the uncollected file's tests stay in the junit as errors. The vitest
and jest lanes are untouched.

The vitest lane still writes coverage when a test fails

vitest writes no coverage report at all on a failed run, so a repo with one red test got
exit 5 naming a missing coverage-final.json: a message about a file, for a run that was
really about a flag. The junit report landed anyway, which made the run look half
finished. The scaffolded vitest lane now carries --coverage.reportOnFailure, in the live
lane and in the commented template alike. jest gets no such flag: it reports on a red run
already, and exits on a flag it does not know. Setting reportOnFailure: true in your
vitest config is still the other way to spell it; init writes the flag because it must
not edit your vitest config to write a lane.

A coverage.py report without branch data scores instead of failing the lane

pytest --cov --cov-report=json without --cov-branch is the default shape of an existing
CI artifact, and it failed the whole lane: nothing scored, exit 5, on a report holding
per-function statement counts crapkit's own model already knows how to divide. Every
function falls back to statement coverage when it holds no branches, and that fallback runs
on every normal report, so the guard was blocking arithmetic crapkit performs all day. It
is now one stderr warning naming the lane and saying the coverage term is statement-based
for this artifact. A report carrying neither branch nor statement data is still refused,
because there is nothing to divide by and every function in it would score fully covered.

One file with no function regions no longer throws the whole report away

coverage.py writes the per-file functions key once per code-region kind that file's own
reporter declares, so a file measured by a plugin reporter declaring none — django or jinja
template coverage — loses the key while every .py file in the same report keeps it. That
single entry failed the lane, the run scored nothing, and the files that were fine were
never mentioned. Those files are now skipped and named in one warning, and the rest of the
report is scored. A report where NO file carries regions is still exit 5, which is the
"coverage is too old" case the message was written for. Both readers weigh that verdict
before the branch-data one, so pytest --cov --cov-report=json on a coverage below 7.6 —
missing regions and branch data at once — is told which version it needs instead of being
sent to add --cov-branch, which would change nothing.

--reuse-artifacts no longer refuses a salvaged coverage run

A killed suite leaves a good coverage JSON only if you combine its shards by hand, and
the junit beside it is the killed run's own: empty, or missing. Reading that report was
a hard exit 5, so the only way through was deleting results_artifact from the config,
which gives up the crashed-worker and no-new-failures checks on every future run instead
of on this one. Under --reuse-artifacts an unreadable junit is now one warning naming
the file and what cannot be checked, and the lane scores off the coverage JSON. The lane
lands on the no-counts path verify already reports. Nothing changed for a lane that
actually ran: a report that says the run did not finish still fails it, which is the
whole point of the check.

A lane that produced no artifact says whether its coverage shards survived

coverage run --parallel-mode, which pytest-xdist turns on, writes one .coverage.* per
process and combines them only at the end. A killed run therefore leaves every measurement
it took on disk and no JSON, one directory above the artifact path the refusal names, and
the refusal never mentioned them: one reporter found them on their own and combined them
by hand. The message now counts the shards, says which directory holds them, and gives the
two commands that turn them into a scored run (coverage combine && coverage json -o <artifact>, then --reuse-artifacts), with the -o target written relative to that
directory so a lane with a cwd writes the JSON where crapkit reads it. Only a
coveragepy lane gets the recipe. crapkit does not combine them itself: shards from
an interrupted suite merge into a report that looks like a whole run, which is what the
crashed-worker check exists to refuse.

New lane key no_progress_seconds kills a suite that stops making progress

timeout_seconds has to be longer than your slowest honest run, so it cannot cut a suite
that hangs at minute three without cutting the slow ones too, and its default is no
deadline at all: a lane that hung sat at 0% CPU with crapkit waiting on it and nothing
watching the log. `no_progress_s...

Read more

crapkit 0.4.11

Choose a tag to compare

@JeanFrancoisGagne JeanFrancoisGagne released this 01 Sep 18:37

Every README and handbook link is absolute

PyPI publishes the README verbatim as the long description, so its 36 repo-relative
links (docs/lanes.md, LICENSE, action.yml, ...) resolved against pypi.org and
answered nothing there. The handbook linked its five deep-reference pages as bare
lanes.md, which GitHub Pages serves as text/markdown, so the browser downloaded a
file where the reader expected a page. Both now link out by full URL, the README's
handbook link opens the rendered page on the project site, and two contracts hold
the relative form out.

The README pins uses: to the release it documents

The Action snippets in the README still said @v0.4.8 two releases later: the release
bump touched crapkit X.Y.Z and rev: vX.Y.Z and nothing else, and no test read the
third pin. A contract now holds every uses: pin in the README to crapkit.__version__,
so a bump that forgets it fails before the tag.

The 60-second start says when init writes a lane and when it writes a template

The comment on the crapkit init line promised "scopes, a coverage lane, .gitignore
lines" with no condition attached, so a reader whose repo carries neither a pytest marker
file nor a JS test setup expected a lane, got a commented template, and ran crapkit coverage into a config that measures nothing. The line now names what init recognizes
(pyproject.toml, pytest.ini or setup.cfg for pytest; a test script or vitest/jest in
package.json for the JS side) and what happens without one: the lane comes commented
out, init says to declare one, and docs/lanes.md is how to fill it in.

The formula says who coined the metric

The README printed CRAP = ccn^2 * (1 - cov)^3 + ccn with nothing under it about where
the score came from, which reads as if crapkit invented it. C.R.A.P., Change Risk
Anti-Patterns, was coined for crap4j by Alberto Savoia and Bob Evans in 2007, and the
handbook has said so from its first draft. The README now carries the same credit
directly under the formula, and a contract holds the four names in the paragraph that
formula sits in.

The sample worklist explains its own risk 0.0

The 60-second start prints a worklist row scoring risk 0.0, which a first-time reader
takes as a broken ranking rather than as arithmetic. Churn weight is position in the
commit log, so a one-commit repo weights every file the same and the ranking falls back to
ccn order. The sample now says that in a clause and points at the Risk section, which has
carried the full explanation all along.

The Action's whole-job snippet sets an interpreter up before installing into it

The snippet showed pip install -e ".[dev]" as the step before the action, with no
actions/setup-python in front of it. The action's own first step is
actions/setup-python, so a team copying that job installed their dependencies into
whatever interpreter the runner defaulted to and the lanes then ran on a different one:
the packages are on the machine and the lane still cannot import them. The snippet now
mirrors this repo's own dogfood job, actions/setup-python@v5 with python-version: "3.12" ahead of the install, and the python-version row of the inputs table says to
match the two. A contract holds the order in the snippet.

The plugin section says which skills Claude reaches on its own

The section listed three skills as one set, so a reader waited for Claude to pick up
crapkit-onboard and it never did. plugin/skills/crapkit-onboard/SKILL.md carries
disable-model-invocation: true: wiring a repo up happens once, and its description has
no business in every turn's window. The section now splits them: crapkit and
crapkit-recover are the two Claude reaches by itself, and the third is
/crapkit:crapkit-onboard, which you type.

The Install section says nothing leaves the machine

Nothing on the page told a reader evaluating crapkit for a private repo where their source
goes. It goes nowhere: scoring runs the reader's own test command locally and reads the
artifact it writes, and src/crapkit makes no network call of any kind. The Install
section now says so and links SECURITY.md, which has carried the same claim under
"It never phones home".

A fork's read-only token no longer fails the whole action

A pull request from a fork carries a read-only token, so the gh api call that posts
the comment came back 403. Composite run steps use bash's -e, and that 403 failed
the step and the job: the check went red on a pull request whose scoring had all
passed, and the verdict the steps above computed was never explained anywhere. The step
now opens code=0 and records what each gh api call got, the way the scoring steps
above it already did, and closes with a line naming the exit code and, when it is not
zero, the token as the likely cause. The comment lookup keeps a status of its own, so a
lookup that died on a closed pipe cannot blame the token for a comment that posted. No
step but the gate's now exits on a status it chose, and a contract test holds it there.

A run with no surviving lane prints each failure once

coverage printed every failed lane's refusal and then raised
every lane failed: <the same texts, joined>, which the CLI printed again. On the
screen most first-time users meet, a vitest lane with no coverage provider installed,
that was one eight-line block twice over, with the same absolute paths in both copies,
and nothing in the second copy that was not in the first. The closing line is now a
count and a pointer, every lane failed (1 of 1); the errors are above. README.md,
docs/lanes.md and the crapkit-recover skill show the new line.

doctor counts one file as one file

The per-scope line read ok scope 'calc': 1 files. It is the first proof a reader
gets that a scope path matches anything, and the quickstart publishes it, so the first
crapkit output a new user saw was ungrammatical. The noun now follows the count, and
zero keeps the plural, which is the FAIL case the line exists for.

The onboarding transcript names no machine and no release

The worked crapkit doctor --plugin-root example in plugin/skills/crapkit-onboard
was pasted off one machine: it printed that machine's home directory, spelled with the
name of whoever ran it, ending in an install six releases old. A reader matched their
own output against a path nobody else has and a version they were not meant to have.
It now reads <home>\.claude\plugins\cache\crapkit\crapkit\<version>, and
tests/unit/test_skills_contract.py holds every shipped skill page to it: no home
directory on any of them, and no release number on that line.

crapkit 0.4.10

Choose a tag to compare

@JeanFrancoisGagne JeanFrancoisGagne released this 01 Sep 16:36

The action is named "crapkit complexity gate"

The GitHub Marketplace refuses an action whose name matches an existing user or
organization, and a GitHub user named craPkit exists, so name: crapkit in
action.yml could not be published. The action is now "crapkit complexity gate"; the
uses: line a consumer writes is unchanged, since that names the repository, not the
action. A contract test keeps the name from collapsing back to the project's.

crapkit 0.4.9

Choose a tag to compare

@JeanFrancoisGagne JeanFrancoisGagne released this 01 Sep 15:18

The handbook's advisory panel draws the Bash half it has answered since 0.4.7

Section 06 of docs/handbook.html pairs a picture of the two hooks with prose about
them. The prose has said since 0.4.7 that the advisory answers Bash events off the
working tree; the picture still said it fires after every Edit and Write and nothing
else. Both sentences sit on one page, fifty lines apart, and a reader who trusted the
picture concluded a heredoc write is never judged.

The panel now states the whole rule: Edit and Write everywhere, because that is the
matcher plugin/hooks/hooks.json ships, plus a Bash write in the repos where the
reader registers a second matcher of their own, *.py only.
tests/unit/test_claude_hook_docs_contract.py reads the panel's own text back out of
the SVG and holds it to the shipped matcher, so the picture cannot fall behind the code
again without a red test.

The adoption page's whole-suite example keeps the launcher prefix the page requires

docs/adoption.md states that every python line crapkit init writes names one launcher,
the lockfile's where the repo has one, because step 3 measuring one environment while step
4 tests another is the bug that rule prevents. Twenty lines further down, the
[crapkit.scoped_tests] block that is the recommended way out of the two-templated-scopes
trap started at a bare python, so the block a reader copies produced exactly that
mismatch on a uv.lock repo and nothing failed loudly.

The example now reads uv run python -m pytest ..., with a line saying the prefix is the
example repo's own lockfile talking and that a repo with no lockfile names no launcher.
tests/unit/test_skills_contract.py pulls every [crapkit.scoped_tests] entry out of the
page's fenced toml and holds each one to the launcher names scaffold.LOCKFILE_RUNNERS
carries.

The Bash matcher snippet is parsed on all three pages that print it

README.md, docs/agent-json.md and the 0.4.7 section of CHANGELOG.md each carry the
JSON a consumer pastes into their own settings to register the Bash half of the
advisory. Nothing loaded any of the three, so a trailing comma, a renamed key or a
timeout that drifted from the shipped one would have shipped green and failed on the
reader's machine.

tests/unit/test_hook_snippet_contract.py pulls every fenced json block naming a
matcher off those pages, parses it, and holds it to one PostToolUse entry with matcher
Bash running one command hook, whose command line and timeout are read out of
plugin/hooks/hooks.json rather than typed again here.

Issue-form placeholders stopped naming a release

.github/ISSUE_TEMPLATE/bug_report.yml offered crapkit 0.4.0 as the example version
line, and field_report.yml offered crapkit 0.4.7. A placeholder is what a reporter
pattern-matches against, so a stale one teaches an old number as the normal answer, and
it goes stale again at every release with nothing failing. Both now read the output of crapkit --version, unedited, which cannot age.

tests/unit/test_issue_forms_contract.py holds the rule for the next one: every
placeholder value under .github/ISSUE_TEMPLATE/ either names the version this tree
ships or names no version at all.

The advisory's own wording is held to the pages that print it

_advisory_lines in src/crapkit/cli/claude_hook.py builds the three lines the
PostToolUse hook writes to stderr, and the first of them says outright that the edit
landed and nothing was blocked. That sentence is load-bearing: the reader is a model
holding a nonzero exit code, and the commit gate's own wording would tell it a landed
edit was rejected.

AGENTS.md, docs/agent-json.md and docs/handbook.html each print a rendered sample
of those lines, and nothing compared them with the format string. A new case in
tests/unit/test_claude_hook_docs_contract.py reads each page's sample, feeds its count,
ceiling and path back through _advisory_lines, and compares the whole line. The values
come from the page and the wording comes from the code, so what is compared is the
wording alone. The closing line, the commit gate enforces this, is pinned the same way.

The istanbul half of the absolute-path refusal is covered end to end

A lane whose artifact measures this checkout but spells every path absolutely joins with
nothing, because the join is root-relative. src/crapkit/lanes.py refuses it and picks
the advice from the lane's parser: coverage.py gets relative_files = true, istanbul
gets its reporter's own cwd/root option. Only the coveragepy branch had a test.

tests/e2e/test_lane_absolute_paths_istanbul_e2e.py runs crapkit coverage against a
fixture repo with an istanbul lane and asserts exit 5, the istanbul advice, and none of
the coveragepy advice. Staging it needs a root spelled two ways, since a reporter that
spells it as crapkit does is rebased and joins fine: the lane's script reaches the
checkout through its parent, the way a reporter writes keys when its root option was
joined rather than resolved. Case and symlinks stage the same thing on one platform each;
this spelling stages it on both.

The action's verdict covers the pull request's own delta

action.yml ran crapkit coverage and then crapkit verify at one commit, so verify's
baseline was the run it had just written and the gate judged no changed function. The
verdict line reported the tree's health and called it a pull request review.

On a pull_request event the action now scores the fork point first. It adds a detached
worktree at git merge-base of github.event.pull_request.base.sha and HEAD under
RUNNER_TEMP, runs the consumer's lanes there, and copies that store over the checkout's,
so the checkout's own crapkit coverage lands a second run beside it. The verdict step
then runs crapkit verify --json --reuse-artifacts --base <fork>, which measures the diff
from there and takes the fork point's run as its baseline. The gate judges the functions
the pull request changed and nothing else, so a repository that was already over its
ceiling before the branch started no longer fails every pull request that touches it.

The fork point rather than base.sha: base.sha is the base branch's tip when the event
fired, and a base branch that moved after the branch forked carries commits HEAD never
saw. A run there is at neither end of the diff verify would measure, and verify refuses
for want of a run at or behind the real fork. The changed-file list the comment's table
is filtered to moved to base.sha...HEAD for the same reason, so both counts in the
comment now describe the branch's own commits.

Measured on a two-commit repository whose second commit adds one uncovered ccn-10
function, running the step bodies against the first commit as the base. 0.4.8's call, and
0.4.9's beside it:

verify OK @ 04a8eefdd3d vs baseline 04a8eefdd3d (0 changed files)          # exit 0

verify FAILED @ 04a8eefdd3d vs baseline d2358fe6c0a (1 changed files)      # exit 6
  GATE  crap    110.0  ccn  10 cov 0%  calc/grade.py:8  curve( scores , mode , floor , ceiling , skip_none )  -> decompose

The price is two lane runs on a pull request, and the new delta input buys it back:
delta: "false" skips the base run and keeps 0.4.8's behaviour. A push event keeps it
too, having no base commit to score and no pull request to comment on.

Nothing here can fail the job. A shallow clone that does not hold the fork point, a fork
point older than the repo's crapkit.toml, and a lane that will not run against that
tree all leave crapkit base scoring exited N in the log and no base run behind it, and
the verdict step falls back to the single-commit call. The last of those three is the one
to know about: a lane that measures an installed copy of the package rather than the tree
it runs in would score the checkout while standing on the base commit. crapkit's own
--cov=crapkit lane is such a lane, which is why the dogfood job in .github/workflows/ci.yml
sets delta: "false"; crapkit coverage refuses that artifact (exit 5) rather than
joining it, so the failure is loud and the fallback is automatic.

A Dockerfile that runs the MCP server over stdio

Dockerfile at the repository root builds crapkit mcp as an image, for a client or a
registry that starts a server from a Dockerfile rather than from an installed package:

docker build -t crapkit .
docker run -i --rm -v "$PWD:/repo" -w /repo crapkit

python:3.12-slim, pip install . over four copied paths (pyproject.toml, README.md,
LICENSE and src/), and git, which the image needs because every MCP tool shells to the CLI
and the CLI reads git. The server runs as an unprivileged account and serves /repo, the
directory the run command mounts. That account also carries
git config --global --add safe.directory '*': a bind mount keeps the host's ownership,
git under a different uid refuses a repo it calls dubious, and the tools would report an
empty history rather than the repo's own.

.dockerignore keeps tests, docs and .crapkit/ out of the build context.
tests/unit/test_dockerfile_contract.py reads the Dockerfile the way the action contract
reads action.yml: the ENTRYPOINT names a [project.scripts] console script and a
subcommand the parser defines, every COPY names a path that exists, and the image installs
git and drops root. docs/agent-json.md documents the two commands
under its MCP section.

crapkit 0.4.8

Choose a tag to compare

@JeanFrancoisGagne JeanFrancoisGagne released this 01 Sep 13:30

A composite action that comments the worklist and the verdict on a pull request

action.yml at the repository root makes crapkit four lines in a consumer's workflow:

      - uses: JeanFrancoisGagne/crapkit@v0.4.8
        with:
          gate: "false"

The action sets up python, installs crapkit, and runs crapkit coverage --json,
crapkit verify --json --reuse-artifacts and crapkit worklist --json in the consumer's
checkout. The three payloads become one comment: what the run measured, the verdict line
with verify's own exit code, and the ranked worklist rows for the files the pull request
changed. A hidden <!-- crapkit-action --> line lets the next push find that comment
through the API and edit it, so a fifteen-push branch carries one comment and not fifteen.
A push event has no pull request to carry one, and the same text goes to the job log
instead.

The install reads $GITHUB_ACTION_PATH, the action's own checkout, rather than
pip install crapkit: the crapkit that scores a tree is the one in the ref the consumer
pinned in uses:, so @v0.4.8 cannot drift to whatever released last.

gate decides the exit code. false, the default, exits 0 whatever verify found and
leaves the comment as the whole output, which is how a team adopts the action before it
has decided which findings should stop a merge. true exits with verify's code, so a
finding fails the check. top caps the rendered rows at 5 by default and
python-version picks the interpreter. Posting the comment needs pull-requests: write
and nothing else.

What the verdict covers is worth reading once. The baseline is the coverage run the same
job wrote a step earlier, so on a clean checkout verify judges an empty diff and reports
the tree's own health rather than the pull request's delta. README's
The GitHub Action says so in the same words and names the
portable baseline that makes it judge the diff instead.

crapkit's own dogfood job runs the action on crapkit with uses: ./, on every push and
every pull request. action.yml is read by the runner and never imported, so that job is
the only thing that executes its steps; tests/unit/test_action_contract.py covers what a
unit test can, which is that the file parses, that every step names its shell, that every
crapkit call in it exists on the parser with the flags it passes, and that the marker
the builder writes is the one the action greps for.

The README and the handbook open with a generated demo

docs/demo.gif and docs/demo.svg show a 90-second terminal session: init sniffing a
small Python repo, coverage scoring it, worklist --top 5 ranking it, a shell heredoc
appending a function at ccn 7 while the per-edit advisory reports it and exits 2, and the
commit gate refusing the staged file with exit 6. The README embeds the GIF under its
badges and the handbook shows it on its first screen.

Nothing in the frames is written by hand. python tools/demo/generate.py builds a git
repo from the fixture under tools/demo/fixture/, replays its commit plan so the
worklist has real churn to rank, runs those five commands against this checkout's crapkit
and renders what they printed. Every captured line goes through a redaction pass that
strips the temp repo's path, wall-clock stamps and durations, and the generator refuses
to write an image if a machine path survived it. Two runs on an unchanged tree write
byte-identical files, which tests/unit/test_demo_generator.py holds them to, so
regenerating the demo for a release is a no-op unless the output actually moved.

The handbook's lanes section also links a new note on pytest-cov 7 and subprocess
coverage, beside the lane rules it belongs to.

A note on the Pages site: what pytest-cov 7 stopped measuring

docs/notes/pytest-cov-7-subprocess-coverage.html writes up the trap that made crapkit
floor coverage>=7.10.6 and set [tool.coverage.run] patch = ["subprocess"] in the first
place, for readers who will never install crapkit. pytest-cov 7.0.0 (2025-09-09) dropped
its own subprocess measurement, so any suite that drives a CLI through subprocess.run
loses the coverage of every entry point on upgrade, with nothing printed and the tests
still green.

The numbers on the page are not remembered, they are produced.
tools/notes/pytest_cov7_repro.py builds one virtualenv per pytest-cov pin, installs
crapkit editable into each, and runs tests/e2e/test_init_doctor_e2e.py four times: two
pins times the patch key present and absent. It toggles the key through
COVERAGE_RCFILE, so the tree under measurement is never edited, and writes the executed
and total statement counts for src/crapkit/cli/admin.py to
tools/notes/pytest_cov7_repro.json. Committed run: 324/521 statements under pytest-cov
6.3.0 with or without the key, 324/521 under 7.1.0 with it, and 0/521 under 7.1.0 without
it. All four runs exited 0.

tests/unit/test_notes_contract.py joins the two. Every measurement row on the page has
to match the JSON on the pin, the coverage version, the state of the key and the count, so
a number edited by hand fails the suite.

crapkit 0.4.7

Choose a tag to compare

@JeanFrancoisGagne JeanFrancoisGagne released this 01 Sep 00:42

One contributed capability and three fixes. The capability is the per-edit advisory,
which now hears writes that arrive through a shell: PR #45, from @nicolaschapados. The
three fixes are #42, #43 and #44, filed off the review of PR #41, the incident report of
his that became 0.4.6. They are a lane refusal that named the wrong cause, a cause line
hoisted out of a superseded retry attempt, and a commented init template that handed
back the environment bug the live lane no longer has. Nothing here is required of a
consumer on upgrade; Upgrading from 0.4.6 at the end of this
section has the one thing you may want to choose.

The per-edit advisory now hears Bash writes

crapkit claude-hook judged the one file named in tool_input.file_path, which only
Edit, Write and MultiEdit events carry. A Bash PostToolUse event carries
tool_input.command instead, so an agent writing source through a shell heredoc or
python - <<'PY', which is how some harness modes make every write, got no complexity
advice at all. Found running crapkit 0.4.4 over a real milestone, in the same nested-root
repo that surfaced the 0.4.5 diff.relative fixes.

A Bash event now falls back to the working tree: the *.py files git reports as dirty or
untracked, whose mtime sits inside a 12-second freshness window, capped at 25 files. Each
one takes the same per-file ladder an Edit takes, so scope, sequencing, changed ranges,
ratchet marks and the untracked rule all mean what they already meant, a nested crapkit
root judges the same root-relative paths the commit gate will, and exit 2 still means one
thing. The freshness window is what keeps a later ls from re-advising a file that was
already dirty. A clean tree, a stale file and a cwd outside any git repo are all silence.

Python only, and on purpose: every other language stays the commit gate's business,
because only Python is cheap enough to analyze on every shell call. The shipped plugin
still registers Edit|Write alone, so the fallback fires only for a consumer who adds a
Bash matcher; the upgrade note below has the snippet and the cost.

The --protocol check also moved to the top of the ladder. The outcomes are the same, but
a payload from a future protocol is now answered before the root walk rather than after
it.

A lane reporting this tree in absolute paths is no longer "another tree"

_escapes_repo called a measured path outside the checkout whenever it was absolute,
drive-lettered or climbing out, and never compared it against the repo root. A runner that
reports this checkout's own files by absolute path, which is coverage.py whenever
relative_files is off, was refused with the another-tree message and advice about venvs
and path_prefix: none of it the cause, and path_prefix only ever prepends.

The root now reaches the check and the refusal splits in two. Paths outside the root keep
the old message verbatim. Absolute paths that resolve under it get their own exit 5,
naming the cause (the runner spelled paths absolutely, crapkit joins on root-relative
ones) and the runner's own switch: relative_files = true under [tool.coverage.run], or
[run] relative_files = true in .coveragerc, for a coveragepy lane, the reporter's
cwd/root option for an istanbul one. Both sides of the comparison resolve the same
way, symlinks followed and the case folded where the filesystem folds it.

Nothing is rebased: the join contract stays root-relative and only the diagnosis moved. A
../ climb keeps the another-tree refusal, having no recorded working directory to
resolve against, and so does a mixed artifact, where one path from somewhere else decides
for all of them and the count names the outside paths alone. In-tree relative paths that
simply miss every scope still warn and score on, which is the greenfield shape 0.4.6
described. Three readings of zero overlap, three verdicts.

A retried lane quotes the attempt that failed it

The cause hoisted in front of a lane refusal is now read from the final attempt only.
Every attempt appends to one .crapkit/lane-<name>.log, and the scan that looks for a
reason ran over the whole file, so a lane that timed out on an ImportError and then
failed attempt 2 for a different reason reported the ImportError, standing above attempt
2's own output with nothing marking the boundary between them. The final attempt starts
after the last --- attempt N --- banner line; the banner has to be the whole line, so
output that quotes those words mid-text is still output. Attempt 1 writes no banner, so a
log without one is a single attempt and reads exactly as before. The tail itself still
reads the end of the whole log, and the message names no attempt number: the log path it
already quotes is where that lives.

The commented lane template names the python the lockfile pins

0.4.6 taught init to write uv run python -m pytest … off a lockfile, but only where it
detected a live pytest lane. A repo with a lockfile and no pytest marker file
(pyproject.toml, pytest.ini, setup.cfg) gets the coveragepy lane as a commented
template instead, and that template still read a bare python. Uncommenting it handed the
reader back the environment bug the prefix exists to prevent.

The template now carries a {python} placeholder, filled the same way the
[crapkit.scoped_tests] entries already fill theirs, so every python line init writes
names one launcher, whether that is the live lane, the scoped-tests entry, or the
commented template that stands in for a lane the repo did not get. python_launcher takes
the launcher as its fallback for a repo with no lane to read it back off. The js templates
are unchanged; they carry no placeholder. A repo with no lockfile writes python (or
python3, or py) exactly as before.

_warn_missing_pytest_cov now documents the rule it applies rather than the one it used
to. A manager-headed lane names no python in the position the probe reads, so it is never
probed and can never earn the pytest-cov note; it still earns the two notes ahead of the
probe, for a manager that does not resolve on PATH and for a first word the shell cannot
start.

Upgrading from 0.4.6

  • Nothing is required. No config key, no ratchet reseed, no stamp change. Every
    0.4.6 config and every committed ratchet reads the same here.

  • One thing you may want to add: a Bash matcher for the advisory. The shipped
    plugin registers Edit|Write, so out of the box the new fallback never fires. To get
    it, 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 inside a git repo, whether or not crapkit measures it: about
    30 ms together on crapkit's own checkout, and it grows with the size of the tree git
    has to walk. The fallback judges *.py files only, so a repo whose source is TypeScript
    or Go pays those two spawns and gets nothing back.