crapkit 0.4.9
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.