mcpField
C
crapkit

crapkit

本地服务
jfgagne.com开发工具14 周前更新

关于 crapkit

crapkit 是用于 Git 仓库的代码风险评分命令行工具,面向开发者和编码 Agent。它结合函数复杂度、覆盖率与文件变更频率,并用 ratchet 机制限制新增风险。

主要功能

  1. 01按函数计算 CRAP 分数
  2. 02结合覆盖率与复杂度
  3. 03按文件变更频率排列风险
  4. 04支持多种语言的代码分析
  5. 05JSON 输出采用版本化 schema
  6. 06提供可选 MCP 服务

适用场景

  1. 01定位高风险函数以安排重构
  2. 02检查改动是否突破复杂度上限
  3. 03在提交门禁中阻止新增风险
  4. 04通过脚本或编码 Agent 读取评分

MCP 相关

crapkit 提供可选 MCP 服务,让编码 Agent 查询代码风险评分和工作列表。README 未说明该服务暴露的具体工具。

配置

本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。

mcp.json · 通用配置
{
"mcpServers": {
"jeanfrancoisgagne-crapkit": {
"command": "uvx",
"args": [
"crapkit"
]
}
}
}
启动命令uvx crapkit

本地服务需要在你的电脑上安装运行环境(如 Node.js 18+)。各客户端的配置文件位置不同,请参考客户端文档。

安装包
pypicrapkit0.8.1

工具

12

收录的工具列表 · 更新于 3 周前

参数类型必填说明
topinteger否返回接下来的 N 个工作包作为 items,而不是单个工作包(N >= 1,默认值为 1)
repostring否已评分仓库的根目录路径(默认值:启动服务时所在的仓库);开头的 ~ 表示主目录
scopearray否将排名限制为这些已声明的 scope(crapkit.toml 中精确的 [[scope]] 名称,每个 --scope 一个)
excludearray否跳过路径或长函数名包含任一指定片段的行(每个 --exclude 一个)

详细介绍

crapkit

ci PyPI Python License: MIT crapkit MCP 服务

在一个小型 Python 仓库中运行 crapkit init、coverage 和 worklist --top 5,然后通过 shell heredoc 在 ccn 7 处添加一个函数:每次编辑的提示报告会指出该函数并以退出码 2 结束,提交门禁则拒绝已暂存的文件并以退出码 6 结束

crapkit 会根据复杂度乘以未覆盖风险,为仓库中的每个函数评分,按文件变更频率排列最差的函数,并阻止引入更多问题的提交。它通过 lizard 读取 Python、TypeScript、TSX、JavaScript、Swift、Go、Rust、shell、PowerShell、C 和 C++、Objective-C、Vue、Java 及 Zig,并将函数级分支覆盖率与您自己的测试命令已经生成的 istanbul 或 coverage.py 产物合并。JSON 命令使用排序后的键和带版本的架构,供脚本、编码 Agent 和可选的 MCP 服务使用。

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

这个名字并非我们创造:C.R.A.P.(Change Risk Anti-Patterns,变更风险反模式)由 Alberto Savoia 和 Bob Evans 于 2007 年为 crap4j 命名。

ccn 是标准圈复杂度和修正圈复杂度中的较小值,两者均从一次 lizard 分析中读取。cov 是函数范围内的分支覆盖率;没有分支时则退回到语句覆盖率,没有语句时则退回到是否调用过,因此只执行了一半的直线型函数不会显示为完全覆盖。

超过上限时,覆盖率也无法挽救你。拆分函数。 默认上限为 6 时,ccn 为 7 且覆盖率为 100% 的函数仍得分 7,仍会触发门禁。唯一能通过的办法是拆分函数。

为什么是 6,而不是 30。 crap4j 的传统阈值 30 是一个 CRAP 分数:它会放过未经测试的 ccn 5(25 + 5 = 30),也会放过完全覆盖的 ccn 30。crapkit 的默认值是复杂度上限,因为覆盖率最多只能将 CRAP 降到 ccn,而无法将 ccn 降到 6 以下的函数就应该拆分。如果你想使用 crap4j 的数值,可在 crapkit.toml 中设置 target = 30。已有债务的仓库无需先清理:ratchet seed 会按当前分数标记今天超过上限的函数,之后门禁只评估变更涉及的函数,标记只能降低,因此采用时不会一开始就面对满屏红色。与 crap4py、radon、xenon、wily 和 SonarQube 的比较:docs/comparison.md。

crapkit 只为 Git 跟踪的文件评分。尚未执行 git add 的源代码对它不可见;init 和 verify 会列出它们找到的前三个此类文件。

从这里开始适用场景
安装和60 秒快速开始你想在现有 Git 仓库中获得第一个评分。
Python或TypeScript快速入门你想查看从设置到成功通过 verify 的完整示例。
采用指南你需要选择范围、接入测试,或为现有债务引入棘轮。
升级你已有保存的运行记录、棘轮标记或已安装的插件。
子命令和JSON/MCP 参考你正在编写命令脚本或连接编码 Agent。

60 秒快速开始

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

不是 Python 仓库?uvx crapkit init 会运行相同的命令,且不会向清单添加任何内容:请参阅非 Python 仓库。Python 仓库跳过 uvx,按上述方式安装 crapkit,也可以使用 uv tool install 或 pipx install;该部分会说明原因。pip 提示 externally-managed-environment 并停止?请参阅pip 拒绝安装时。

coverage 会衡量一个提交,因此新执行 git init 的仓库需要先有首个提交。 在此之前,它会以状态码 4 退出并说明原因:

$ 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 会根据仓库自身的文件检测 pytest、Vitest 和 Jest。运行其命令前,请检查生成的配置。如果检测结果留下了带注释的选项,请使用选项配方填写。提交采用所需的文件,然后运行 crapkit verify,确认结果通过。配置和 ratchet 准备就绪后,安装提交门禁。 每个步骤都会提示下一步:init 会提示 coverage,coverage 会提示 worklist,worklist 会提示 ratchet seed,而 ratchet seed 会提示提交并运行 verify。

coverage 负责评分,worklist 负责排序:

$ 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 是 ccn 乘以 churn 权重 1:只有一个提交的仓库没有可供加权的提交分布,因此每个提交都按一次计算;在历史记录增长之前,排序依据就是复杂度(Risk)。crap 38.5 和 cov 50% 分别是评分及其覆盖率。

ratchet seed 会按今天的评分为今天的债务签名。从此以后,标记只会下降,因此仓库可以不断变好,绝不会变差,同时你逐步清偿债务。

首次运行时最常遇到的问题是覆盖率插件。 init 会生成一个调用你自己的测试运行器的选项,而运行器需要安装其覆盖率包: pytest 需要 pytest-cov,Vitest 需要 @vitest/coverage-v8(版本需与 vitest 的主版本固定一致)。 如果没有该包,选项就不会生成产物,coverage 会引用运行器自身的错误并以状态码 5 退出。pytest 选项还需要 coverage.py 7.13.1 或更高版本,它会写入 crapkit 读取的函数起始行;更旧的版本会导致选项失败并以状态码 5 退出。对于 pytest,如果缺少 pytest_cov,init 会探测选项将要运行的 Python,并打印安装命令;如果 crapkit 和插件共用同一个 venv,pip install "crapkit[py]" 会一并安装两者。在 PATH 中只有 py 启动器的 Windows 上,它会写入 py,而不是选项根本无法运行的 python3;如果 cmd.exe 完全无法启动解释器(退出码 9009,即 Store 别名),它会直接说明这一点,而不会猜测应安装 pytest-cov。如果仓库没有锁文件,但带有自己的 .venv,且该虚拟环境的解释器可以导入 pytest,选项会使用该解释器,而不是 shell 返回的任意 Python。下面的两个快速入门会带你完整走一遍真实仓库。

在 Windows 上,选项命令由 cmd.exe 读取,也就是实际运行命令的 shell,而不是 sh。 双引号是可移植的引号写法。配置加载时,单引号括起的值会被拒绝并以状态码 3 退出,因为 cmd.exe 会将其交给 pytest 作为五个单词,导致选项无法生成产物:

# 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

写成 -m "not live and not perf"。插入符、&& 和 | 分段、重定向及空引号参数都会按 shell 的解析方式读取,因此链式选项(cd tests && python -m pytest --cov ...)会逐段检查。doctor 也会以相同方式读取选项,并将无法启动运行器的选项标记为 FAIL。

安装

pip install crapkit

这是 PyPI 上的发布版本。要安装尚未发布的 main 最新版本,或从本地克隆安装(在克隆仓库的根目录运行):

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

main 上两个发布版本之间的提交使用相同的版本字符串,而 pip 会保留版本匹配的已安装 crapkit,因此在新提交后再次运行 git 命令,旧代码仍会保留。使用以下命令将其替换为最新版本:

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

在克隆仓库中运行 pip install . 每次都会重新构建,无需任何标志。

当 pip 拒绝安装时

Debian 12、Ubuntu 23.04 及更高版本、Homebrew 和 uv 会将它们安装的 Python 标记为归其所有(PEP 668),而 pip 会拒绝写入其中,并显示 error: externally-managed-environment:

$ pip install crapkit
error: externally-managed-environment

为 crapkit 提供独立环境。以下任一行都会将其安装到独立环境中,并在 ~/.local/bin(Windows 上为 %USERPROFILE%\.local\bin)中放置一个 crapkit 命令,Agent 插件会启动该命令:

pipx install crapkit
uv tool install crapkit

如果该目录尚未加入 PATH,安装程序会说明这一点,并指出解决方法:pipx ensurepath 或 uv tool update-shell。执行任一命令后,都请打开新的 shell。PATH 中的 python 不包含此安装,而 提交钩子 会先运行 crapkit 命令,再尝试运行 python,因此它会按显示的方式找到任一安装。

Python 仓库也可以将 crapkit 安装在自己的 venv 中,与其工作流运行的测试依赖放在一起:

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

在 Windows 的 cmd.exe 中执行相同操作:

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

--break-system-packages 会让 pip 仍然写入系统 Python,之后操作系统的软件包更新可能会破坏 crapkit 或操作系统自带的工具。以上几种方式都不需要使用它。

非 Python 仓库

crapkit 是命令行工具,绝不是它评分代码的依赖项。TypeScript、Go 或 Rust 仓库无需在自己的清单中添加任何内容。如果机器上有 uv,uvx 会将 crapkit 下载到独立缓存中并运行:

$ 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 不会将 crapkit 放入 PATH,因此 crapkit 在 uvx 下打印的后续每条命令都以 uvx crapkit 开头。工作流仍会运行你自己的测试运行器,因此 Vitest 或 Jest 及其覆盖率包仍像现在一样来自仓库的 node_modules。uv tool install crapkit 或 pipx install crapkit 会将 crapkit 命令一次性放入 PATH,提交门禁 和 Claude Code 插件调用的就是这个命令。Ratchet 的合并驱动支持这两种形式;git 合并驱动提供 uvx 命令行。若机器上没有 Python,uv 会自带 Python。

Python 仓库则应安装 crapkit:安装在运行测试套件的环境中(pip install "crapkit[py]"),或者使用 uv tool install crapkit 或 pipx install crapkit。uvx 会将自己的环境放在工作流继承的 PATH 最前面,因此工作流中的 python 会启动 uv 缓存的解释器;该解释器既没有测试套件的软件包,也没有 pytest-cov。此时 init 会提示该解释器无法导入 pytest_cov,而将 pytest-cov 安装到 uv 缓存中也无法解决测试套件所需的问题。

需要 Python 3.11 或更高版本,以及 PATH 中的 Git。使用较旧的 Python 时,pip 会报错 No matching distribution found for crapkit;uvx crapkit 则会在 uv 找到或下载的 Python 3.11 或更高版本上运行 crapkit。CLI 只有一个运行时依赖项:lizard>=1.24.0,<1.25;软件包镜像需要包含这两个发行包。请安装到你打算使用的环境中,然后检查 crapkit --version。开发下的 pip install -e ".[dev,accuracy-push]" 是另一回事:它会添加测试额外依赖,供修改 crapkit 的人员使用。

pip install --user crapkit 会将 crapkit 命令放到用户脚本目录中:Linux 上为 ~/.local/bin;macOS 上 python.org 版 Python 3.12 为 ~/Library/Python/3.12/bin;Windows 上 Python 3.12 为 %APPDATA%\Python\Python312\Scripts。如果该目录尚未加入 PATH,pip 会在安装结束时显示 WARNING: The script crapkit is installed in '...' which is not on PATH。请将提示的目录添加到 PATH,然后打开新的 shell。在此之前,shell 会对 crapkit 回复 command not found(Windows 上为 is not recognized),而 Claude Code 插件会将其服务列为 Failed to connect。

Python 项目可以在测试环境中安装 pip install "crapkit[py]",以包含 pytest-cov 和支持子进程的 coverage.py。单独安装工具时,运行测试套件的环境中仍需安装覆盖率插件。 分析和评分均在本地运行,不会发送任何遥测数据。配置的 lane、mutation 和 alert 命令会使用你的权限运行,并可能联系服务或更改文件。在不信任的仓库中运行 crapkit 前,请先检查这些命令 (SECURITY.md)。

$ crapkit --version
crapkit 0.8.1

python -m crapkit 的运行效果与控制台脚本完全相同,可在源码检出目录中使用。crapkit 打印后续步骤时,如果 PATH 中能找到此安装的控制台脚本,就会打印 crapkit;否则会打印正在运行它的解释器,并使用正斜杠表示路径(C:/venv/Scripts/python.exe -m crapkit coverage),因此 Git Bash、cmd.exe 和 PowerShell 都能按打印内容运行。包含空格的路径段会单独加引号(C:/"Program Files"/Python312/python.exe -m crapkit coverage),因此命令行开头不会是引号,在 PowerShell 中也能运行。有一种情况不适用于 cmd.exe:虚拟环境中的 python.exe 位于含空格的目录,且没有 8.3 短文件名时,你需要手动将整个路径放在双引号内(docs/adr/0003)。每个子命令都接受 --repo PATH(默认值:当前目录或其上级目录中最近的 crapkit.toml,因此单体仓库工作区会找到根目录下的配置文件),指定后就无需 cd 到要评分的仓库;子命令介绍了该标志的放置位置。PATH 开头的 ~ 表示你的主目录;在 cmd.exe 和 MCP 客户端的 args 中也是如此,因为这些环境不会由 shell 展开它。

升级

保持 CLI 和插件版本一致,升级后重新测量覆盖率,并在重新播种前检查任何 ratchet 身份拒绝情况。当前读取器的分析版本为 13。它会从 coverage.py 7.13.1 及更高版本写入的 start_line 中读取覆盖率函数的代码范围;如果报告中没有该字段,则会拒绝读取。它可以对以 UTF-16 保存的源码评分,并会为标识符中的五个 cp1252 未定义字节之一保留其名称作为键。它会调整除 Python 以外所有语言中的数字,也会调整 Python 的部分 cognitive 和 nesting 数值;将 JavaScript 和 TypeScript 的覆盖率归到所属函数上,并在所有平台上一致计算 CRAP。因此,每个标记文件都需要重新播种一次。版本 11(0.8.0)为每个带有 PEP 695 类型参数列表的 Python def,以及每个嵌套深度达到三层或以上的 def,各重命名了一次;还会列出函数体位于冒号所在行的 def;crapkit ratchet prune 会清除旧名称下遗留的标记。旧版 JavaScript 和 TypeScript 回调的标记可能需要经过审核的映射。 请参阅升级指南,了解各安装方式(pip、pip --user、pipx、uv tool、uvx 和 git 安装)所需的升级命令,以及已保存状态、可移植记录和 Windows 启动器锁定的相关说明。 CHANGELOG.md 介绍了各个版本的变更内容。

从 0.8.0 升级到 0.8.1
  1. 在每个 Python 执行通道运行的环境中安装 coverage.py 7.13.1 或更高版本: pip install -U "coverage>=7.13.1";或者当 crapkit 与测试套件共用 venv 时,运行 pip install -U "crapkit[py]"。
  2. 在每个克隆中升级 crapkit,然后为每个仓库重新初始化一次:运行 crapkit coverage、 crapkit ratchet prune、crapkit ratchet seed。如果失败的验证固定了基线, 则向 prune 和 seed 传入 --baseline N。
  3. 将重新初始化的结果与所有运行 crapkit 的版本固定项一起提交:CI 安装版本、Action 的 uses: 版本,以及 pre-commit 的 rev。0.8.1 的 verify 会拒绝带有分析版本 11 标记的 数据,而 0.8.0 会拒绝带有分析版本 13 标记的数据。
  4. 在所有克隆都运行 0.8.1 之前,不要将 {python} 和 {python:DIR} 放入已提交的 crapkit.toml:0.8.0 会原样将此令牌传给 shell,导致执行通道失败。

在重新初始化之前,crapkit verify 会以退出码 3 拒绝 0.8.0 记录的标记:

crapkit:标记记录时使用的是 [crapkit-analysis=11 lizard=1.24.0],而本次运行使用的是 [crapkit-analysis=13 lizard=1.24.0] —— 不同指标版本的 CRAP 分数不可比较;请运行 `crapkit coverage`,然后运行 `crapkit ratchet prune`,再使用 `crapkit ratchet seed` 重新设置基线

升级指南列出了按顺序执行的所有步骤, 而分析版本 13 说明了变更内容。较早版本升级导致的指标标记拒绝信息引用于分析版本 8。

Windows 上的 exe 锁定

正在运行的 crapkit.exe mcp 会占用其启动器,每种安装程序处理此锁定的方式各不相同。从 pip 23.3 开始,pip 可以成功安装:它会将正在使用的 crapkit.exe 移开并安装新版本,而运行中的服务会继续提供旧代码,直到重启其客户端。通过 pip 安装时,pipx 也会采取相同做法。较旧的 pip(例如 Python 3.11.2 venv 自带的 22.3.1)会失败并显示 ERROR: Could not install packages due to an OSError: [WinError 5] Access is denied,然后恢复旧版本:请先运行 python -m pip install --upgrade pip,或者像使用 uv 时那样停止服务。uv tool upgrade crapkit 会因 os error 32 失败。uv tool install crapkit@latest,以及 pipx 通过 uv 安装时的 pipx upgrade crapkit,会因 Access is denied. (os error 5) 失败;随后,uv tool install 安装的 crapkit 命令会报错 ModuleNotFoundError: No module named 'crapkit',直到再次运行安装。发生上述任一故障后,请停止服务或其 Agent 会话,重新运行相同的命令,然后重启客户端。 Windows 升级步骤 列出了每种安装程序输出的内容。

移除 crapkit

请先移除提交钩子和合并驱动,再卸载软件包:两者都会调用 crapkit。 仅运行 pip uninstall crapkit 后,sh 钩子路线 1 和路线 2 中写明的操作仍会通过 uvx crapkit 检查每次提交:在安装了 uv 的机器上,会使用 uv 缓存或可下载的任意 crapkit 版本;在未安装 uv 的机器上,则会因 No module named crapkit 阻止每次提交;如果机器上完全没有 python(例如只安装了 python3 的 Debian、Ubuntu 或 macOS),则会因 exec: python: not found 阻止每次提交。PowerShell 钩子路线 1 和手册中写明的启动器会被 pip uninstall crapkit 删除,因此无论是否安装 uv,都会因 No such file or directory 阻止每次提交。 每当合并涉及 crapkit-ratchet.tsv 时,驱动程序都会因 crapkit: not found 而发生冲突。升级指南中的 移除 crapkit 列出了仓库和机器上需要移除的每个部分。

Claude Code 插件

claude plugin marketplace add JeanFrancoisGagne/crapkit --sparse .claude-plugin plugin
claude plugin install crapkit@crapkit

每位用户只需安装一次,运行两条命令,机器上的每个仓库都能使用。该插件包含三个技能、只读 MCP 服务,以及一个提示性 PostToolUse hook,用于指出编辑导致哪些函数超出上限。Claude 会自行调用其中两个技能:crapkit 和 crapkit-recover;第三个需要你输入 /crapkit:crapkit-onboard,因为仓库只需配置一次,而且它的描述没必要占用每轮对话的窗口。 它不会向你的仓库添加任何文件,并且需要将 crapkit CLI 添加到 PATH。

hook 是一条 shell 命令:crapkit claude-hook --protocol 1。因此,它可以按原样运行于任何支持插件的 Claude Code 版本,以及加载 Claude Code 插件的其他 Agent:Cursor(可导入这些插件)、GitHub Copilot CLI 和 VS Code。Claude Code 会通过 stderr 接收提示,并以退出码 2 唤醒模型。这三个工具会将退出码 2 分别视为拒绝、仅发送给用户的消息或阻塞错误,因此在它们中,hook 会以退出码 0 退出,并将相同的行作为附加上下文交给模型(其他运行环境)。

--sparse .claude-plugin plugin 会从插件仓库中检出它包含的两个小目录。如果不使用此参数,Claude Code 会在 120 秒的克隆超时限制下克隆整个仓库;有一次测量安装耗时就超时了。未使用 --sparse 添加的 marketplace 会保留整个克隆;claude plugin marketplace remove crapkit 会移除它并卸载插件,而上面的两行命令可以将两者重新添加。

没有 crapkit.toml 的仓库在每次编辑时都会静默空操作一次:在 Windows 上通过 crapkit.exe 启动器运行需要 68 毫秒,而在同一台机器上,单独运行 python -c pass 需要 32 毫秒。每次编辑都会启动它,无论文件类型是什么;如果编辑的文件类型不是 crapkit 会测量的类型,则会在读取任何配置前通过后缀检查停止。在 Windows 上,Claude Code 会通过 Git Bash 启动 hook,因此还会叠加 Git Bash 自身的启动耗时;同时它会异步运行,所以编辑无需等待。升级 CLI 后,先刷新 marketplace,再更新已安装的插件:

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

已安装的插件只会在发布新版本时更新。claude plugin update 会比较版本字符串,而 main 在下一个版本发布前会沿用上一个版本的版本号,因此两个版本之间它会报告插件已是最新版本。重启现有的 Claude Code 会话以应用插件更新。上面的检查会比较已安装文件与 PATH 上的 CLI;它不会重新加载正在运行的会话。如果两者版本不一致,它会打印落后的一方所需运行的命令;如果 claude plugin update 报告“already at the latest version”,但 main 中的文件已经更新,则会打印用于替换这些文件的卸载和安装命令。上面的更新命令适用于用户范围的安装。如果使用 --scope project 或 --scope local 安装,doctor 会指出相应的范围以及应在哪个项目目录中运行命令;如果插件是从你添加的本地目录 marketplace 安装的(Claude Code 会直接从该目录加载),它会指出 git -C <that directory> pull。

无头模式下的 claude -p 没有人可以授权工具调用,因此会拒绝所有 crapkit 调用,模型会看到 Claude requested permissions to use ..., but you haven't granted it yet.。为插件的 MCP 服务传入 --allowedTools mcp__plugin_crapkit_crapkit,或为你自行添加的 MCP 服务传入 --allowedTools mcp__crapkit。

hook 会在 Edit|Write 上注册,这会涵盖所有指定文件的写入操作。如果 Agent 通过 shell heredoc 写入源文件,就不会指定文件,因此会根据工作树来判断 Bash 事件。是否注册这部分由你决定,因为它会导致每次 shell 调用多启动两次 git。将第二个 PostToolUse 条目添加到你自己的设置中,使用相同的命令,并将 matcher 设为 Bash:

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

在任何 Git 仓库中,每次 shell 调用都会运行一次 git rev-parse --show-toplevel 和一次 git status --porcelain -z -uall,无论 crapkit 是否对此进行测量。两者在 Windows 上的 crapkit 自身检出目录中总计耗时约 50 毫秒,在更大的目录树中则会更久。返回的结果是过去 12 秒内写入的脏文件或未跟踪的 *.py 文件,最多 25 个;每个文件都会按编辑操作的方式进行判断。仅支持 Python,因此 TypeScript 或 Go 仓库只会承担这两次进程启动的开销,不会收到任何结果。

此钩子会按 Claude Code 会话记住每次判断读取的字节:在 .git/crapkit/claude-hook/<session_id>/ 下为每个文件保存一条小记录;当另一个会话启动时,空闲 7 天的会话会被清理。若近期文件的字节与记录相同,就会跳过;因此,touch、重写相同字节的内容,或在 Edit 后立即运行测试,都不会重复给出本会话已收到的提示。mtime 较早的内容不会由 Bash 部分进行判断:例如移动或复制进来的文件(mv、cp -p、解压的归档),或由长时间运行的命令在返回前很久写入的文件。提交门禁仍会判断这些文件。

Codex

Codex 0.131.0 或更高版本可通过其自身的管理器安装同一 marketplace 的插件;0.130.0 没有 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

在 Codex 中使用这三个技能和 MCP 服务。Codex 不会加载 crapkit 钩子:插件的 Codex 清单中不包含钩子,因为 Codex 会将编辑报告为 apply_patch 补丁文本,而提示功能不会读取它。Codex 会自行向模型提供 crapkit 和 crapkit-recover;在你输入 $crapkit:crapkit-onboard 之前,crapkit-onboard 不会出现在模型的列表中,就像 Claude Code 会等待 /crapkit:crapkit-onboard 一样。0.8.0 或更早版本的插件没有 Codex 清单:Codex 0.156.1 会将其钩子列为不受信任的 PostToolUse 钩子,并直接运行 crapkit;这些钩子应保持不受信任状态。

--ref 会将 marketplace 固定到本次发行版的标签,crapkit 的发行步骤会将其改写为新建的标签。Codex 0.156.1 每次启动时都会检查每个 Git marketplace;若 marketplace 有变更,就会重新安装其中的插件。不带 ref 添加的 marketplace 会跟随 main;向 main 推送后,插件会更新到你从 PyPI 安装的 CLI 版本之外,而你无需执行任何命令。--sparse 只会获取插件所需的两个小目录,而不是整个仓库。

在某个标签上添加的 marketplace 会保持在该标签:codex plugin marketplace upgrade 会继续将其固定在该标签。升级 CLI 后,请移除 marketplace,再在新标签上添加,并重新安装插件。列表命令的 --json 需要 Codex 0.137.0;较旧的 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

已安装的插件只会在发行版发布时更新。移除 marketplace 不会移除插件;在 codex plugin add 安装新版本之前,插件会停留在旧版本。对于不带 --ref 添加的 marketplace,同样的四行命令可将其切换到该标签。

使用 crapkit doctor --plugin-root 检查已安装的 Codex 插件:若没有 PATH,且 Claude Code 未安装插件,它会读取 Codex 的插件缓存;若 PATH 指向某个安装,则会显示 ~/.codex/plugins/cache/crapkit/crapkit/ 下的安装路径。若此处不匹配,会指出这些刷新命令,而不是 claude 命令。 有关如何选择该路径并启动新的 MCP 会话,请参阅插件升级。若运行环境有技能目录,但没有兼容的 marketplace,也可将 plugin/skills/* 复制到相应目录:Claude Code 使用 ~/.claude/skills,Codex 使用 $CODEX_HOME/skills(默认是 ~/.codex/skills),Gemini CLI 使用 ~/.gemini/skills。Gemini CLI 会与另外两个技能一起向模型列出 crapkit-onboard;采用该仓库后,运行 gemini skills disable crapkit-onboard --scope user 即可将其移除。

其他 Agent

每个其他 Agent 都会从自己的配置文件启动 crapkit mcp,并读取自己的密钥和字段。Cursor 使用的 mcpServers 块在 OpenCode、Amp 或 VS Code 的 .vscode/mcp.json 中不会启动任何内容,而且这些工具都不会说明这一点;在 Gemini CLI 中它会连接,但无头模式下的 gemini -p 不会向模型提供任何工具,除非设置 "trust": true。 将 crapkit 接入你的 Agent列出了 27 个 Agent 各自对应的配置块,其中包括 Gemini CLI、Goose、Zed 和 Continue,并说明配置文件的位置以及 Agent 启动服务的方式。

语言

支持 14 种语言,使用两种覆盖率解析器。存在对应解析器时会合并覆盖率;其他情况仅按复杂度评分。

语言文件覆盖率
Python.pycoverage.py
TypeScript.tsistanbul
TSX.tsxistanbul
JavaScript.js .jsx .mjs .cjsistanbul
Vue.vueistanbul;前提是你的 vitest 运行会报告 .vue 文件
Swift.swift无:仅 cc
Go.go无:仅 cc
Rust.rs无:仅 cc
shell.sh .bash无:仅 cc
PowerShell.ps1 .psm1无:仅 cc
C 和 C++.c .cc .cpp .cxx .h .hpp无:仅 cc
Objective-C.m .mm无:仅 cc
Java.java无:仅 cc
Zig.zig无:仅 cc

仅 cc 的作用域会声明 coverage_optional = true,按 ccn 计算 crap,且不需要覆盖率通道。这并非临时方案:上限仍然有效,门禁仍会拒绝超过上限的函数。有了解析器后,添加覆盖率通道,同一作用域就会开始合并覆盖率。

对于所有语言都没有解析器的作用域,crapkit init 会自行写入这个键;而对于仍可通过覆盖率通道测量的作用域,则不会启用它。因此,上文提到的 60 秒快速入门在 Go、Rust 或 shell 项目中也无需改动:crapkit coverage 无需覆盖率通道就能为其评分,而该次运行生成的基线会供 worklist、next-item、ratchet seed 和 verify 读取。

Shell、PowerShell 和 Rust 使用 crapkit 自带的读取器。lizard 没有 shell 或 PowerShell 读取器,因此 crapkit 会自行统计这些语言中的函数,并把带引号的 "$(...)" 内部命令读取为代码:其中的 &&、||、-and 和 -or 都会按未加引号时的方式计数。lizard 的 Rust 读取器会将有 7 个分支的 match 评分为 ccn 2(已作为 lizard #494 提交问题),因此 crapkit 会像处理 C case 一样统计每个非通配符分支。它还会正确读取 Rust 函数签名、闭包中的空 ||、let-else,以及并非 Rust 循环的 for(参见各语言的注意事项),并会在上游修复之日移除相应的修正。它还会统计写在同一行的 #[inline] fn f() {,因为 lizard 将其误当作 C 预处理器指令。认知复杂度列会像 Sonar 处理 switch 一样,仅对 match 计数一次。Rust、shell 和 PowerShell 读取器也会在修改列中统计每个 match、case 或 switch 分支,因此两列结果一致,且这些分支会受到门禁限制:有 7 个分支的 match 会在 ccn 8 时触发门禁;而 lizard 对有 7 个 case 的 C switch 计算出的修改后计数(也就是受门禁限制的 ccn)为 2。

Go 和 Zig 使用 crapkit 基于 lizard 读取器构建的子类读取代码,并根据语言规则确定签名的结束位置。函数类型(例如 var cb func(int) error)不会开启函数,结果类型中的花括号不属于函数体,函数类型参数只计数一次。 Python 和 Swift 也通过 crapkit 对 lizard 的读取器进行子类扩展。lizard 的 Python 读取器会在跨行签名中遇到第一个 ) 时结束 def,因此 crapkit 会一直读取签名,直到函数体的冒号。lizard 的 Swift 读取器会把 super.init(...)、r.get() 和 Socket(protocol: p) 当作声明,把 #fileID 当作预处理器行的起始,这会隐藏它们后面的函数;它也不会列出由带空格的原始标识符命名的函数(func `keeps onboarding if offline`()),因此 crapkit 会将它们各自读取为名称或字面量。它还会把字符串中 \( ) 里的表达式当作代码,而 lizard 会将其视为字符串的一部分。

C、C++、Objective-C 和 Java 使用 lizard 的读取器,并应用 crapkit 的修复。lizard 会隐藏某些结构之后的所有函数,将行命名为属性或宏名称,还会把未命名参数和数组参数排除在 params 之外;各语言说明列出了 crapkit 的不同读取方式。

数组和参数列表中的表达式箭头会分别计算。在 TypeScript 中,如果箭头函数体在逗号前包含 <,请用括号将其括起来,例如 x => (pair<T,U>(x)) 或 x => (x < 0)。如果没有这个分隔符,分析器会拒绝该文件,因为此读取器无法区分类型参数和表达式分隔符。运行结果会将被拒绝的文件计为 0 个函数并列出文件名;任何更改触及该文件时,所有门禁都会拒绝它(门禁)。泛型箭头函数参数声明仍受支持。

同一行上的函数具有独立的出现标识符。带有有歧义的旧标识符的现有棘轮标记需要经过审核的映射;请参阅同一行函数标识。

门禁

编辑时使用提示,提交时使用门禁,并使用 verify 获取完整判定。预览和钩子可用来证明结论的证据各不相同:

界面触发时机作用
crapkit claude-hookAgent 的编辑写入后提示。 向 Agent 指明违规:在 Claude Code 中通过 stderr 提示,在 Cursor、Copilot CLI 和 VS Code 中作为新增上下文提供。不阻止任何操作,因为 PostToolUse 在写入之后才运行
crapkit rescore FILE --gate你发起请求时,在首次覆盖率运行之后预览。 提交门禁的更严格预览,耗时不到一秒,可在暂存前运行:棘轮标记只会在函数的 CRAP 不超过标记值时豁免该函数。若没有运行结果作为依据,则退出码为 1,并显示 no snapshot
crapkit hook-precommit执行 git commit 时,或手动运行时阻止。 钩子退出码为 6;git 报告 1。在提交过程中,它只检查暂存的 blob,因此耗时与提交大小相关,且不需要覆盖率数据。若在提交之外运行且没有暂存内容(例如 pre-commit run --all-files),它会检查每个已跟踪文件,并因未被棘轮标记覆盖的已提交债务而失败;运行 crapkit coverage,然后运行 crapkit ratchet seed,即可记录这些债务
crapkit verify推送前以及在 CI 中最终判定。 针对可信基线检查门禁、棘轮、新测试失败和差异覆盖率
两个钩子都会豁免工作树中的 marks 文件为某个函数标记的情况,无论该标记是否已暂存或提交,因此修改带有签名债务的代码永远不会导致提交被拒绝。在 CI 中,磁盘上的文件就是已提交的文件,因此如果某个函数的标记从未进入提交,verify 就会失败。verify 也会在标记上升时失败。自 0.4.5 起,它的门禁会豁免已修改函数的最新 CRAP 值小于或等于其标记的情况;这与 rescore --gate 已采用的规则一致。若 CRAP 值超过标记,门禁就会再次触发。预提交钩子仍会仅根据标记是否存在来豁免,这是有意为之:暂存的 blob 没有覆盖率,因此也没有最新 CRAP 值可供比较。它会在 stderr 上报告豁免数量(staged function(s) carry a ratchet mark and were not gated);如果某个暂存文件没有被任何 [[scope]] 声明,也会发出同样的提示,因此新的顶层目录不会在无提示的情况下绕过门禁。

如果更改的文件无法被读取,任何门禁都会拒绝该文件并以退出码 6 退出,因为它没有评估文件中的任何函数:crapkit gate: 1 staged file(s) could not be read, so no function in them was judged:,然后显示 UNREAD src/a.ts: ... 以及读取器给出的原因,最后说明应如何处理。常见情况是 TypeScript 表达式箭头函数体中的逗号前出现了 <;请将函数体放入括号或代码块中。编辑后,claude-hook 会指出同一个文件;只要更改中包含这类文件,覆盖选项就不会生效。crapkit coverage 会将此类文件计为 0 个函数,并在 stderr 上指出文件名;crapkit doctor 也会针对最新一次运行中无法读取的每个文件发出 WARN,这样你就能在提交门禁拒绝之前处理该文件。更改中未触及的文件仍可通过,因此旧的、无法分析的文件不会造成阻碍,直到有人编辑它为止。

crapkit 根目录可以位于 Git 顶层目录之下。 packages/api 中的配置会以项目相对路径(例如 app/m.py)对该包中已暂存的文件实施门禁。CI 步骤从顶层目录启动,而那里没有 crapkit.toml,因此 Route 4 和GitHub Action 会使用 working-directory: packages/api。在顶层目录运行的钩子以外的任何命令都会以退出码 3 退出,并指出下方的根目录:

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

路径和根目录规则还涵盖绝对参数、字面文件名和 Git diff 设置。在此类 monorepo 中,若在 git 顶层目录运行,下面的 Route 1 到 3 无需 --repo。Git 会在该目录运行钩子;若顶层没有 crapkit.toml,门禁就会在下方各个拥有已暂存文件的 crapkit 根目录中运行,并使用顶层目录下的路径(packages/api/app/m.py)。如果提交没有暂存任何位于 crapkit.toml 下的文件、仅修改文档,或者在运行 crapkit init 之前就已启用钩子,那么提交会通过,并在 stderr 上显示一条提示。若要将门禁固定到某一个根目录,请在下方钩子的每一行 hook-precommit 后加上 --repo packages/api,例如 exec python -m crapkit hook-precommit --repo packages/api;Route 3 则在 id: crapkit-gate 下添加 args: [--repo, packages/api]。Route 4 的 crapkit verify 使用 --repo packages/api。

Route 1 和 Route 2 会写入以下钩子正文。它会按顺序运行钩子的 PATH 中提供的三种方式中的第一种:pipx、uv tool 或 venv 安装放入其中的 crapkit 命令,然后是 uvx crapkit,最后是 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

对于只通过 uvx 运行 crapkit 的机器,门禁会使用 uvx 那一行。它会运行 uv 首先获取的正式版本,或者在 uv 没有缓存时运行最新版本;这未必是团队其他成员使用的版本。只要机器上有 uv,即使运行 pip uninstall crapkit 后,门禁仍会继续运行,因此卸载软件包之前,请先移除钩子(移除 crapkit)。 路线 1 的 PowerShell 形式会改为写入启动器自身的路径,路线说明了这一点。 路线 3 的框架会自行写入一个钩子,从其安装 crapkit 的环境中运行 crapkit hook-precommit;路线 4 则在 CI 中设置门禁,不安装钩子。

Git 会将运行 git commit 的程序所使用的 PATH 传给钩子。已激活 venv 的终端会将 venv 传下去;而不是从该终端启动的 IDE 或 GUI 客户端则不会。如果传入的 PATH 中既没有 crapkit 命令,也没有 uvx,也没有能导入 crapkit 的 python,git 就会拒绝所有提交。如果完全没有 python,例如只安装了 python3 的 Debian、Ubuntu 或 macOS,钩子会以退出码 127 退出,并显示 exec: python: not found。如果有 python,但它无法导入 crapkit,例如系统自带的 python,则会以退出码 1 退出,并显示 No module named crapkit。请为该客户端明确写出路径:exec /path/to/venv/bin/crapkit hook-precommit;Windows 上则写 Scripts/crapkit.exe。

选择路线前,请运行 git config core.hooksPath。 如果输出的是一个目录,说明该路径是通过全局设置、husky、lefthook 或其他钩子管理器配置的;git 会从那里运行钩子,不会读取 .git/hooks:路线 1 不会启用任何钩子,而 husky 会在下次 npm install 时将该路径重新设回,覆盖路线 2 的设置。请将门禁添加到该目录已在运行的 pre-commit 文件中。使用 husky 时,应在 .husky/pre-commit 中单独写一行 crapkit hook-precommit,不要加 exec:husky 会在 sh -e 下运行该文件,因此门禁的退出码 6 会阻止提交;通过检查后,后续行仍会运行。

路线 1:.git/hooks/pre-commit(本地文件,不会提交)
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 可以从任意子目录和关联工作树中找到仓库的 .git 目录。在关联工作树中,.git 是一个文件,因此执行 cat > .git/hooks/pre-commit 会失败,并显示 Directory nonexistent。每个工作树都会运行保存在该处的钩子。它有意忽略 core.hooksPath:--git-path hooks 会遵循全局钩子路径,这会把此仓库的门禁写入这台机器上的每个仓库。

在 PowerShell 中使用同一个钩子。Windows PowerShell 5.1 会在 shebang 前加上字节顺序标记;> 和 Out-File 会写入 UTF-16,而 Out-File -Encoding utf8 会写入 UTF-8。随后 git 会拒绝所有提交,并显示 error: cannot spawn .git/hooks/pre-commit: No such file or directory(在 Windows 上使用 git 2.43 测得),门禁根本不会运行。PowerShell 7 不会写入标记,而 Set-Content -Encoding ascii 在这两个版本中都不会写入标记。Git 会用自己的 sh 运行钩子,因此启动器的路径要使用正斜杠并加上引号;在 Windows 上无需 chmod:

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

此钩子会调用当前 shell 能找到的 crapkit,因此看不到你的 venv 的 GUI 客户端也会使用同一个版本。它不包含 uvx 命令:如果该路径失效,例如你移动了安装位置或运行了 pip uninstall crapkit,每次提交都会因 .git/hooks/pre-commit: line 2: .../crapkit.exe: No such file or directory 而停止。移动或重建该安装后,请重新粘贴此代码块。

如果 PATH 中没有 crapkit(仅使用 uvx 的机器),Get-Command 会停止执行代码块:请使用 uv tool install crapkit 或 pipx install crapkit 安装一个,或在 Git Bash 中写入上面的 sh 版本。

如果 git 要运行的钩子文件以字节顺序标记开头,crapkit doctor 会发出警告;如果在其他位置设置的 core.hooksPath(全局设置或 husky 的 .husky/_)让 git 离开 .git/hooks,从而完全不运行任何钩子,它也会发出警告。

无论使用什么方式保存文件,PowerShell 5.1 的 > 都会在相同标记后写入 UTF-16;crapkit 会根据各文件所属程序的处理方式读取这些文件:

  • 这样保存的源文件,其函数评分与 UTF-8 版本相同。git 会将其 diff 显示为二进制文件,因此 crapkit doctor 会在提示中列出它。
  • UTF-16 格式的 .gitignore 是 git 无法读取的文件。init 会在 stderr 中指出该文件并提供修复方法,同时保持文件原样。
  • UTF-16 格式的根目录 package.json 无法让 init 判断应使用哪条 lane:它会在写入任何文件前以状态码 3 退出,并指出该文件和修复方法。
  • 这样保存的 marks 文件(crapkit-ratchet.tsv)会按原有行读取;写入时会以 UTF-16 格式、带原有标记并保留原有换行符。名称中含有 cp1252 字节的 mark 会将该字节读取为 U+FFFD。此时,ratchet seed、prune、move、merge、verify 的 tighten 和两种 override 都会以状态码 3 拒绝操作并指出该字节,而不会将 U+FFFD 写入名称来替换原字节: marks 文件的读取方式。

Set-Content -Encoding utf8 会将其中任何文件保存为 UTF-8。

路线 2:提交到仓库的 hooks 目录

从尚无 githooks/ 的仓库开始,完整流程如下:

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 会在 heredoc 处停止执行该代码块,此时还没有写入任何 hook,接下来的提交便会绕过门禁。在 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

提交到仓库的 hook 保留了 PATH 查找方式,而不是某台机器上的启动器路径,因为每个克隆都会运行它。

--chmod 要放在 add 和 commit 之间。 它会将可执行位写入索引,因此已经完成的提交不会包含它:在提交后运行该命令,再执行 git ls-tree HEAD,仍会显示 100644;Unix 检出时,hook 会因此被静默跳过。.gitattributes 中的这一行是同一问题更棘手的一半:在 Windows 默认的 core.autocrlf 设置下,hook 检出时会变成 CRLF,#!/bin/sh\r 会在 Linux 和 macOS 上因解释器错误而无法运行。若 core.hooksPath 下的文件在索引中不是 100755,crapkit doctor 会发出警告并打印对应的 update-index 命令。

Git 不会从已提交的文件中读取 hooks 路径,因此这条 git config 命令应写入 CONTRIBUTING 设置步骤。每个克隆都需要用它启用门禁。crapkit doctor 会指出跳过此步骤的克隆:

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

husky 的 npm install 将 core.hooksPath 改回 .husky/_ 后,同一条 WARN 会指出应从 .husky/pre-commit 调用 crapkit。

路线 3:pre-commit 框架

crapkit 提供了声明 id: crapkit-gate 的 .pre-commit-hooks.yaml。在你的 .pre-commit-config.yaml 中:

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

该文件本身不会启用任何内容。只有在你指定后,框架才会写入 .git/hooks/pre-commit;在此之前,git commit 不会运行门禁,也不会提示任何信息:

pip install pre-commit
pre-commit install

每个克隆都需要运行 pre-commit install,就像路线 2 中每个克隆都需要设置 git config core.hooksPath 一样。prek 读取相同的 .pre-commit-config.yaml:prek install 会启用相应 hook,门禁拒绝和接受的提交也相同。如果配置中声明了 crapkit-gate,但 Git 运行的 hook 不是框架写入的 hook,crapkit doctor 会发出 WARN。

pre-commit run --all-files(pre-commit.ci 和 pre-commit/action 运行的形式)既不会开始提交,也不会暂存任何内容。在提交之外且没有暂存内容时,hook 会检查每个受跟踪文件,而不是空的暂存差异:超过上限的函数会检查失败,除非工作树中的 marks 文件对其进行了标记;在 CI 中,这就是已提交的文件。因此,如果某个克隆从未运行 pre-commit install,却提交了超限内容,CI 作业就会失败。提交过程中,hook 仍然只检查已暂存的内容。它通过 GIT_INDEX_FILE 区分这两种情况;Git 会为提交运行的 hooks 设置该变量。若要在 CI 门禁中同时检查覆盖率和 marks,请使用路线 4。

rev 是 pre-commit 会在对应远程仓库中解析的 Git 引用。请固定到发行标签,而不是分支:pre-commit autoupdate 只会在标签之间更新,而不断变化的 main 会在你不知情时改变门禁。

路线 4:CI

CI 作业会在全新克隆上运行,其中没有 .crapkit/ 存储,因此直接运行 crapkit verify 会以状态码 1 退出。若先运行 coverage,PR 自身的代码树就会成为基线,这样的门禁 永远不会失败。可移植基线才是解决方案:

# 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 会输出 ::error file=... 注释,这些注释会显示在 PR 差异中;--sarif PATH 会写入 SARIF 2.1.0 文件,供代码扫描上传。默认分支的 verify 通过后,请刷新已提交的基线。

该文件会在首行记录每个测试通道的测试数量和失败列表,因此 PR 作业会容忍默认分支上 已经失败的测试,并在某个测试通道运行的测试数量减少时发出警告。由 crapkit 0.8.0 或更早 版本写入的文件不包含这些信息:与之比较时,每个失败的测试都会被视为新失败,退出码为 8; verify 会说明这一点,并指出负责重写该文件的行:

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

运行这些行之前,作业必须先完成两件事。安装 crapkit,即运行 pip install crapkit,并像路线 3 固定 rev 一样固定版本:不固定版本,发布一旦上线,你的门禁就会 随之改变。获取完整历史记录。 actions/checkout 默认只克隆一个提交,verify 会从 git 中读取基线提交的差异,而浅克隆不包含该提交:

$ 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

如果在一个基线在完整深度下能通过 verify 的仓库中执行 git clone --depth 1,就会得到退出码 4。 如果完整克隆中包含该提交,同样的退出码信息会指出包含它的分支;若没有分支包含它,则会指出 历史记录被 rebase 或 amend 改写,并要求重新生成基线。如果克隆中完全没有该提交——因为 .crapkit/ 来自另一个克隆,或来自按分支设定键值的 CI 缓存——则会明确说明这一点并指出需要执行的 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 和 hook-precommit --base 会使用 git merge-base 查找分叉点; 在上述浅克隆中,它们会以退出码 4 拒绝运行,并给出相同的解决方法:

$ 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

如果克隆中包含两个提交,却不包含它们分叉所依据的提交,提示行会显示 no merge base between REF and HEAD in ROOT,后面附上相同的解决方法。 如果设置了 debt_max_age_months 或 repayment_min_per_30d, ratchet report --enforce 在浅克隆中也会拒绝运行,并以退出码 4 给出相同的解决方法:其中每条标记 都会显示为 0 天前,且不会显示任何偿还记录,因此完整克隆中会失败的期限限制在此处会通过,而完整 克隆中会通过的偿还配额在此处反而会失败。 请在 checkout 步骤中设置 fetch-depth: 0,crapkit 自己的 .github/workflows/ci.yml 也采用了这种设置。

完整的 PR 作业如下,适用于 GitHub Actions:

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

第二次安装最容易被遗漏。verify 会重新运行你的测试通道,因此作业需要测试命令所需的一切: coverage 插件、npm ci、数据库,等等。缺少这些内容时,测试通道不会写入制品,verify 会以退出码 5 退出, 并引用运行器自身的错误信息;这表示作业配置有误,而不是给出了判定结果。

拒绝运行时的提示
$ 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).

该提交以 1 退出,而非 6。Git 会把任何失败的钩子都归并为 1,因此只有手动运行钩子时才会看到 6:crapkit hook-precommit 发现违规时以 6 退出,否则以 0 退出。无论哪种情况,标准错误输出 区块都相同。

CRAPKIT_OVERRIDE_REASON 不是绕过机制。设置它会让提交经过完整的三项审计:通过 alert_command 输出警报行、将 ratchet 条目暂存到提交中,以及在覆盖日志中添加一行。三项要么 全部写入,要么一项也不写入;如果未设置 alert_command,则会直接拒绝覆盖。请参阅 docs/ratchet.md。

GitHub Action

本仓库根目录中的 action.yml 是一个复合 Action,因此审查者无需安装任何东西,就能在拉取请求中看到 crapkit 的数据。只需在工作流中添加四行,并且每个输入都有默认值:

      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: JeanFrancoisGagne/[email protected]

这四行所在的完整作业:

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/[email protected]
        with:
          gate: "false"

人们常常漏掉的就是这个 pip install 步骤,它与上文的路线 4 中提到的是同一个步骤:该 Action 只安装 crapkit,不安装其他任何东西,因此你的各条通道仍然需要测试命令所需的一切。缺少它时,该通道不会写入任何制品,评论也会说明这一点。

另一个容易漏掉的是 fetch-depth: 0。actions/checkout 只克隆单个提交;该 Action 会从 git 中读取拉取请求里有更改的文件,而 verify 会读取与基线提交的差异。浅克隆缺少基线提交,因此 git 会拒绝生成差异:该步骤会记录 git 的错误,而评论会对整个仓库进行评级,并在标题下说明原因,同时引用 git 的第一行错误信息并指出 fetch-depth: 0。浅克隆还会压平所有从历史记录中统计的数值:变更量只会看到每个文件有一次提交,因此工作清单只按 ccn 排序;棘轮标记的存续时间也会显示为 0 天。worklist、next-item、brief 和 ratchet report 会在 stderr 中打印一行,并在 JSON 中包含 shallow: true(完整克隆中为 false);评论也会在表格上方重复工作清单中的这一行:

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 会在这一行中列出变更量统计和标记存续时间,ratchet report 则会列出标记存续时间和偿还量。

该 Action 会从 $GITHUB_ACTION_PATH 安装 crapkit;这个路径指向你在 uses: 中固定的引用所对应的 Action 自身检出版本。因此,如果固定版本仍是上个月的标签,你的代码树就会由上个月的 crapkit 评分,而不是由此后发布的版本评分。固定标签就是全部的版本策略;上面的代码片段使用的是当前版本。采用新的分析版本重新播种标记时,请在同一个提交中更新固定版本:比标记版本落后一个版本的 Action 会在每个拉取请求中拒绝这些标记,并以退出码 3 退出(某团队的升级)。

评论的样子

每个拉取请求只会有一条评论,并在每次推送时原位编辑。隐藏的 `` 行会作为开头,下一次运行会编辑第一条以该行开头的评论,因此一个推送 15 次的分支只会有一条评论,而不是 15 条。审查者引用评论撰写的回复不会以该行开头,因此不会受到影响。如果 GitHub 在返回一页评论列表时出错,该步骤会编辑出错前找到的 crapkit 评论。如果没有找到评论,它会再次列出评论,然后再发布新评论;如果再次列出时也失败,它就会发布一条新评论,这样拉取请求仍会收到此次推送的判定结果,并且作业日志会说明列表读取连续失败两次。如果是 push 事件,则没有拉取请求来承载评论,因此相同的文本会改为写入作业日志。

此内容由三个保存的负载渲染而成:一个拉取请求添加了未经测试的 route()(ccn 8),并与带棘轮标记的 legacy_router() 并列;该仓库的 diff_uncovered_max 为 3。负载位于 tests/fixtures/action_comment/ 下,单元测试会将此区块固定为它们的渲染结果:



## 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) |

第一行是 crapkit coverage 写入的运行结果:函数和文件、超出上限的数量(over ceiling 6;如果作用域设置了各自的上限,则为 over their ceilings (6; reports 12, util 4))、CRAP 负载和评级;之后,对于每个未评出任何内容的作用域,附上 ; scope `src` scored no function: it claims no file;对于失败的通道,则追加其第一行错误信息,例如 ; lane 'js' failed: ...。如果 coverage --json 在摘要生成前就已失败,该行会改为引用它打印的错误对象:`crapkit coverage` exited 5: lane 'py' cannot import pytest-cov; pip install pytest-cov. 判定结果以退出代码及其代表的规则开头(complexity gate、 ratchet regressions、new test failures、diff-coverage ceiling N),随后每条发现各占一条项目符号:每项门禁违规都列出其函数、ccn、覆盖率、CRAP 和补救措施;每个没有读者能读懂的变更文件都按 - unread: `src/a.ts`, so the gate judged none of its functions: 的格式列出,并说明读者给出的原因;每项棘轮回归都按记录值 -> 新值列出;每项新的测试失败都按 ID 列出;每个文件列出前 20 行未覆盖的变更行,并统计其余行数。每种类型最多列出 50 条项目符号,之后显示 - and 1450 more new test failures; crapkit verify lists them all。统计行收尾,列出最多 3 个 verify 判定过的文件,并统计其余文件: 1 changed file (`app/calc.py`)。未读文件和超出上限的函数一样会导致门禁失败,因此统计行会将其计入门禁违规,并说明其中有多少是文件: 1 gate violation (1 unread file)。通过的 verify 只占一行:**verify passed.** Run 2 against baseline 1, 7 changed files (a.py, b.py, c.py and 4 more). 早于 0.8.1 的 verify 不会列出 changed_paths,其行只给出数量。若某个 lane 没有记录测试结果(它未声明 results_artifact),则在该行附加 New test failures went unchecked in lane `py`: it recorded no test results.;若某个 lane 的基线没有记录失败列表,而该 lane 出现新的失败,则单独列出一条项目符号:- lane `py`: the baseline recorded no failure list, so its new failures may predate this change:分叉点的 lane 未声明 results_artifact,因此无法区分拉取请求中的失败和更早出现的失败,而 verify 仍会将它们计入。

GitHub 不接受超过 65,536 个字符的评论,拉取请求届时将完全没有评论。如果评论正文仍然超长(例如 top 较大且名称很长),则会截断到最后一行能放下的位置,标记行仍放在最前,并以 the comment stopped at GitHub's 65,536-character limit; the job log above holds the whole text. 结尾。该步骤会在发布评论前,将完整评论打印到作业日志中。

这些行是拉取请求所变更文件的排名工作列表,按风险从高到低排列,取前 top 项;发现中提及的行会列在最前。risk 是 ccn 乘以变更权重,即 crapkit worklist 用来排名的数值;remedy 是本次运行对此函数给出的判定:decompose、split-lines、add-tests 或 ok。(accepted debt) 表示已提交的棘轮为某个函数标记了债务,因此未被触及的 legacy_router 不会被误认为是拉取请求新增的函数。如果拉取请求没有涉及任何排名函数,则会显示标题和 No ranked function in these files.。当 crapkit worklist 未生成排名时,表格的位置会说明原因,而不是显示为空表:它打印的错误(`crapkit worklist` exited 1: no snapshot in ...: run `crapkit coverage` first.),或者,如果它没有打印任何内容,则显示 `crapkit worklist` printed no ranking; its error is in the job log.

这两个文件计数描述的是同一份差异,只是统计了两次。39 changed files 是在 working-directory 中运行 git diff --name-only --relative base.sha...HEAD 得到的,包含该分支在其下的提交;表格也只筛选这些文件。该步骤的日志最多会列出其中 3 个文件。判定行中的数量是 verify 从同一分叉点测得的。若 delta: "false",第二个数量为 0,因为检出内容之后没有可供测量的提交。

输入项
输入项默认值作用
gate"false""true" 时以 crapkit verify 自身的退出码退出,因此发现问题会使检查失败。在启用了 delta 的拉取请求中,如果未运行基准版本,也会以 1 退出,因为没有基准版本的判定不会评估任何变更函数。其他情况下均以 0 退出,评论就是全部输出
delta"true"先评估拉取请求基准提交的分数,使判定覆盖拉取请求新增的提交。代价是多运行一次 lane;"false" 时只评估检出版本,判定不会评估任何变更函数
top"5"表格中显示的工作清单行数。空值会显示 5。不是整数的值("ten"、"5.0"、"-1")会显示 5,并在本次运行的摘要页上发出一条指出该输入项的警告
python-version"3.12"actions/setup-python 用来安装 crapkit 的解释器版本。请将其设为与自己的 setup-python 步骤指定的版本一致,否则各 lane 会在依赖从未覆盖到的解释器上运行
working-directory"."相对于检出目录,存放 crapkit.toml 的目录。每个运行 crapkit 或列出变更文件的步骤都会在此目录中运行,基准版本运行也会在分叉点对同一目录进行评估。如果 crapkit.toml 位于仓库根目录以下,请设置此项:在根目录运行时,crapkit coverage 找不到 crapkit.toml,并以 3 退出

对于 crapkit.toml 位于 packages/api 的单体仓库,crapkit 步骤需要在 with: 下设置 working-directory: packages/api,而作业自身的 pip install -e ".[dev]" 步骤需要在步骤级别设置相同的键,因为 .[dev] 指向该软件包的 pyproject.toml,而仓库根目录没有这个文件。随后会从 packages/api 列出变更文件, 与工作清单中列出的路径一致;仓库其他位置的变更不会纳入表格。请将其指向实际存放 crapkit.toml 的目录,而不是它下方的目录。此输入项从 0.8.1 开始提供:固定到 较旧标签的 action 会发出 Unexpected input(s) 'working-directory' 警告,并评估根目录。

默认值特意设为 gate: "false"。团队会先采用此 action,再决定哪些发现应阻止合并; 如果检查第一天就失败,第二天它就会被关闭。

判定行涵盖的内容

对于拉取请求,涵盖的是拉取请求新增的提交。action 会先评估分叉点,再评估检出版本, 然后运行 crapkit verify --base <fork>,以此衡量从分叉点开始的差异,并将分叉点的运行 结果作为基准。因此,门禁会评估审阅者正在查看的差异中的函数;如果仓库在分支创建前 就已超出上限,那么触及该仓库的每个拉取请求都不会因此失败。

分叉点是 base.sha 与 HEAD 的 git merge-base,而不是 base.sha 本身。 base.sha 是事件触发时基准分支的最新提交,因此,如果基准分支在分支分叉后又有新提交, HEAD 就没有见过这些提交;在该提交上运行既不是 verify 所需的基准,也不是任何人正在审阅的差异。

基准版本会在 RUNNER_TEMP 下的分离工作树中运行,并在其中相同的 working-directory 中执行;其存储会复制到检出目录的存储中,使两次运行的数据位于同一处。 代价是拉取请求会运行两次 lane:测试套件会先在分叉点运行一次,再在检出版本上运行一次。 设置 delta: "false" 可跳过基准版本运行;此时判定会回退为将检出版本与其自身运行结果比较, 报告代码树自身的健康状况,并且不会评估任何变更函数。评论会显示以下内容,而不是 verify passed:

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

以下三种情况会导致基础运行未完成:浅克隆未包含分叉点、分叉点早于你的 crapkit.toml,以及某个通道无法在该树上运行。该步骤会记录 crapkit base scoring exited N,并按注释随后引用的原文将原因写入 crapkit-base.reason: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>: ...,或 lane failed at the fork point <sha>: ...,并附上 crapkit 打印的第一行 lane '<name>' FAILED:,因此通过的通道发出的警告不会取代失败通道的信息。如果 crapkit 退出时没有打印任何内容,则会显示 crapkit coverage exited <code> and printed nothing。判定会像 delta: "false" 那样回退,ratchet 仍会运行,因此此处的退出码 7 表示发现了问题。不同之处在于作业状态。对于基础运行已尝试但未完成的拉取请求,如果设置了 gate: "true",退出步骤会以 1 退出并打印原因,因为否则 actions/checkout 默认的深度为 1 的克隆会让每个拉取请求都通过检查,却没有进行任何评估。push 事件和 delta: "false" 都不会尝试基础运行,因此会保留 verify 自身的退出码;push 没有基础提交,也没有可供评论的拉取请求。

基础运行新增的一项要求是:通道必须评估其运行所在的树。如果通道访问的是你软件包的已安装副本,而不是当前检出内容,它就会站在基础提交上评估拉取请求的代码,于是两次运行描述的是同一棵树。crapkit verify 会拒绝包含树外文件的产物(退出码 5),这能捕获明显的情况;将通道固定到工作树之外的路径则是较隐蔽的情况。让通道指向该树,或设置 delta: "false"。

--reuse-artifacts 可确保每次运行只需执行一遍测试套件。coverage 刚刚已在该树上运行过各个通道,而 verify 会解析这些产物,不会为了相同的数据再次运行整个测试套件。

因此,只有当 crapkit coverage 以 0 退出时,才会产生判定。若它以其他代码退出(例如某个通道失败或某个产物被拒绝),该操作就不会运行 verify:在作业之间保留工作区的运行器上(clean: false),对失败的测量结果执行 verify --reuse-artifacts,会读到失败通道从先前运行遗留下来的产物,通过对它的检查,并将该次运行设为可信基线。此时评论会以 coverage 的失败信息代替判定:

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

括号中的内容是摘要所包含的通道失败信息的第一行。如果所有通道都失败,coverage 会打印错误对象,而不是摘要;通道错误只会出现在作业日志中,因此该行会说明这一点:(every lane failed (1 of 1); the lane errors are in the job log)。如果 coverage 完全没有打印任何内容,说明它在评分前崩溃或被终止:(it printed no run summary, so it crashed or was killed before scoring; its error is in the job log)。如果 coverage 拒绝了某个范围覆盖率所涵盖的文件,而其文件名不是 UTF-8 编码,括号中会列出第一个文件名并统计其余文件;该行下方的每个项目符号则会列出错误对象在 unread_files 中列出的每个文件: - refused: `src/caf\xe9.py`: its name is not UTF-8, ...。如果 coverage 运行所在目录中没有 crapkit.toml,括号中会指出该目录,并以 set the action's `working-directory` input to the directory that holds crapkit.toml 结尾。设置 gate: "true" 后,作业会以 coverage 的退出码退出。

另一个用于评估差异的门控方案是 Route 4 中的可移植基线:在默认分支上提交 crapkit-baseline.tsv,并在你自己的某个步骤中运行 crapkit verify --baseline-tsv crapkit-baseline.tsv。它不需要再次运行通道,但需要有人持续更新该文件。 评论通过 gh api 发布,并使用作业自己的 GITHUB_TOKEN。该令牌需要 pull-requests: write 权限,而检出代码需要 contents: read 权限;一旦作业指定了任何权限,其他所有权限都会被设为 none。它有两件事做不到:来自 fork 的拉取请求会获得只读令牌,因此 POST 会返回 403;而未将 gh CLI 加入 PATH 的自托管运行器不会发布任何内容。在这两种情况下,该步骤仍会显示为成功,记录 gh 的退出代码,并将渲染后的文本保留在作业日志中。

子命令

crapkit clean --dry-run --json 可预览已放弃的临时变更恢复操作。有关共享分析工作进程、进程生命周期和有界日志,请参阅资源策略。

每个子命令都接受 --repo PATH,且该标志必须放在子命令之后。如果不指定,该工具会将当前目录或其上级目录中最近的 crapkit.toml 所在目录作为根目录 (ADR 0002):在单体仓库工作区中,crapkit worklist 会读取声明该工作区的根配置,在 stderr 上显示 crapkit: using crapkit.toml at /repo,并从当前所在目录读取相对路径参数。claude-hook 是唯一的例外:它没有 --repo,因为它会从所读取的 hook 负载中指定的文件获取根目录。

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

如果把它放在前面,argparse 会将该路径解析为子命令名称,并以退出码 2 退出,且完全不会提及 --repo:

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

--json 会在 stdout 上输出一个键已排序的 JSON 对象,其中始终包含 schema 字段。

命令功能说明
clean [--dry-run] [--json]恢复被遗弃的临时变更工作树。保留活跃运行和有意保留的变更池。--dry-run 报告计划进行的恢复。它不会处理测试证据:crapkit 自己的开发运行器 tools/testing/run.py 会使用 --retention-days 和 --retention-count 清理 .crapkit/test-runs/。--json 会保留 test_runs,其中每个数组都为空。
init从已跟踪的源文件中识别每个目录的作用域,写入经过自验证的起始配置 crapkit.toml,其各个执行通道会将结果报告到 .crapkit/cov/,并将 .crapkit/ 以及每个运行器自身产生的文件追加到 .gitignore。能检测到测试运行器时会写入有效的 [[lane]],否则写入注释模板。绝不重写现有配置:如果配置已存在,只会补上之前运行时遗漏的 .gitignore 条目。
doctor [--show-files] [--json] [--tune] [--plugin-root [PATH]]检查配置是否仍准确描述仓库:未知键(附带可接受的拼写)、不包含文件的作用域、未被作用域声明覆盖的已跟踪源文件、没有执行通道覆盖的作用域、无法解析的执行通道工作目录和命令、无法导入的 lizard,以及过大的文件。如果执行通道的运行器无法解析或 shell 无法启动,doctor 会将该通道标记为 FAIL;如果 pytest --cov 执行通道使用的 coverage.py 版本低于 7.13.1,或标记文件由更新版本的 crapkit 或 lizard 写入,也会标记为 FAIL。如果执行通道、钩子和标记文件的设置会被后续命令拒绝或跳过,doctor 会发出 WARN,例如某个执行通道测量的作用域没有对应的 [crapkit.scoped_tests] 模板;如果 PATH 上存在两个或更多 crapkit 启动器,还会逐一列出名称和版本。--show-files 会列出每个作用域匹配到的所有文件。--json 会为每个执行通道提供 refusal:即 --reuse-artifacts 会用来拒绝其产物的句子;若不会拒绝,则为 null。--tune 会打印建议的并行度调节项,不写入任何内容。--plugin-root PATH 完全不会读取仓库:它会将已安装的 插件 与 PATH 上的 crapkit 在版本和钩子 --protocol 方面进行检查;每处不一致占一行,并指出用于解决问题的命令。所有检查和警告见:docs/commands.md: doctor。JSON 格式见:docs/agent-json.md。
inventory [--db PATH] [--export PATH] [--json]对作用域内的所有文件运行一遍 lizard,并将结果写入 SQLite 快照运行,按内容哈希缓存。只有通过 --db 才能将 crapkit 指向 .crapkit/ 以外的存储,而且只有此命令接受该参数。
coverage [--lane NAME] [--reuse-artifacts] [--reuse-unchanged] [--export PATH] [--sarif PATH] [--github] [--json]运行各个执行通道,将分支覆盖率合并到最新清单,并写入评分运行。执行通道失败会被记录,但不会导致命令失败:其作用域会回退为 no-lane,运行类型标记为 partial,因此绝不能用作基线。--reuse-unchanged 会复用证明仍然有效的执行通道,并说明该证明未涵盖的内容;--reuse-artifacts 会读取已保存的文件并发出警告,最多列出三个自执行通道测量后字节发生变化的文件。失败尝试留下的产物只要字节未变,就会继续被拒绝:touch 不会解除拒绝,字节发生变化才会。见 docs/lanes.md。
verify [--baseline ID | --base REF | --baseline-tsv PATH] [--emit-baseline PATH] [--override REASON] [--reuse-artifacts] [--reuse-unchanged] [--no-tighten] [--sarif PATH] [--github] [--json]对可信基线给出完整判定:对变更函数设门禁、执行棘轮检查、确保没有新增测试失败,并可选设置差异覆盖率上限。三个基线选择器互斥;--baseline ID 还会绕过污染规则(可信基线),而 --baseline-tsv 会读取带有提交标记的文件,因此全新克隆无需存储即可进行验证。--no-tighten 会通过判定,但不重写棘轮。脏工作树产生的发现会标记为 dirty 并单独计数。判定结果下方会列出最先出现的三个变更文件;--json 会将所有文件列为 changed_paths;作用域内未跟踪的源文件会在 stderr 中列出,并注明未参与判定。无法读取的变更文件会导致门禁失败,退出码为 6,并作为 UNREAD 发现列出(在 --json 中为 unread_files);只要存在此类文件,--override 就不会授予任何豁免。它会为覆盖率、死行和摘要各读取一次 istanbul 产物,并在差异为空时跳过产物遍历;实测后否决了在工作树未变时跳过整个运行的做法,因为由 HEAD 和脏文件名组成的键无法识别已处于脏状态的文件再次发生编辑。
worklist [--top N] [--scope NAME] [--batches N] [--json]风险图:按 ccn * churn weight 对所有纳入的函数排序,分数下限为 worklist_floor;高变动的简单代码以及超过上限的代码也会纳入,不受该下限限制。它也会对已完成的行和 no-lane 行排序,并分别标记为 ok 和 no-lane,因此列表不会变空;next-item 会提供停止条件。每一行都包含该函数在排序运行中的 crap 和 cov,以及当工作树中的标记文件能为其背书时的 ratchet_mark;标头会统计上限隐藏的活跃行数:50 of 3980 active (worklist_top 50)。--scope NAME(可重复)要求精确匹配,不是子字符串;未由 [[scope]] 声明的名称会导致配置错误,退出码为 3,并列出已声明的作用域。--batches N 会额外添加 batches[] 视图,将活跃列表切分为最多 N 个文件互不重叠的批次,并将共同变更的文件放在一起;数据来自与 coupling 相同的缓存配对。常规键仍会保留。文件按风险总和从大到小分配到当前风险总和最小的批次(LPT 调度)。文本末尾会给出下一步要运行的命令:当排序运行不能用作基线时运行 coverage;仓库尚无标记文件时运行 ratchet seed;之后运行 next-item。
next-item [--top N] [--exclude FRAG] [--scope NAME] [--claim]以 JSON 格式提供可执行任务队列,包含变动信息、预算估算和未覆盖行。它与 worklist 使用相同的运行和纳入下限,只是展示方式不同:会跳过 no-lane 行并计入 skipped_no_lane,其余内容则按 crap 降序排列,而不是按风险排序,因此分配的任务往往不是工作列表的首行。--exclude FRAG(可重复)会跳过路径或函数名中包含 FRAG 的条目;路径片段的读取方式与文件参数相同,因此 ./pkg/legacy 会被视为 pkg/legacy,函数名则保留大小写。--scope NAME(可重复)要求精确匹配,不是子字符串;未由 [[scope]] 声明的名称会导致配置错误,退出码为 3,并列出已声明的作用域。--claim 会锁定分配的任务,以便第二个会话跳过它。每个条目都带有一个 handle,即编辑该任务后仍然有效的名称形式。docs/agent-json.md: next-item 说明了排序规则、stale、scored_changes 和每个字段。
claims [list | release PATH NAME | release --all] [--json]查看待处理的任务领取记录,以及无需等待 verify 即可归还任务的方法。release 接受简短标识符、完整长名称,或领取时使用的 handle;只有后一种方式能唯一指定单个 (anonymous) 任务领取记录。在分析版本 11 之前领取的嵌套 Python def,也可以用该版本为 def 指定的名称释放。
brief FILE NAME [--batch N] [--json]为单个函数提供开始编辑所需的信息包:该函数自身的 source 文本、文件中的所有函数、评分行和作用域上限、棘轮标记及门禁会检查的内容、未覆盖行、重复代码对应项、文件变动情况、耦合对象、配置备注,以及剩余工作流程所需的原样命令。还会提供 handle、remedy,以及队列打印的相同 est_splits / est_uncovered_paths,并提供一个会写入运行(refresh_writes_run),而非重新读取过期运行的 commands.refresh。NAME 可使用简短标识符、next-item 打印的长名称、函数起始行号、用于指定打印为 (anonymous) 的函数的 (anonymous)#N(按文件中匿名函数从上到下计数),或用于指定一个文件内多个同名函数中第二个函数的 NAME#2。--batch N 会移除位置参数,并改为输出 packets[]:队列前 N 个条目;整个批次只读取一次存储,并对快照只运行一次重复代码检测(5 个条目的批次从 11.8 秒降至 5.2 秒,输出字节与分别调用五次完全相同)。
explain FILE NAME [--history] [--tests] [--json]展示函数在各次运行中的评分及其标记。NAME 会优先进行精确匹配:裸标识符或长名称与 NAME 完全相同的函数优先;只有不存在精确匹配时才会回退到前缀匹配,因此 route 会解释 route,而不是旁边所有 route_* 函数。也可以使用函数起始行号,即 brief 接受的形式,用于打开打印为 (anonymous) 的函数。--history 会添加修改过该函数的提交(对运行测量的代码范围执行 git log -L,并将其映射到当前未提交编辑之后的 HEAD 行号),每条记录都包含提交时的消息 body;如果该范围只包含未提交的行或 git 无法提供结果,也会说明。--tests 会添加覆盖该函数的测试;这需要启用 coverage.py 上下文(见配方);每当该文件的暗行被隐藏时,也会附上相同的说明并隐藏测试结果。--json 会以单个 schema 1 对象输出相同内容。
rescore FILE ... [--gate] [--json]根据最新运行中过期的覆盖率,按名称合并,为指定文件重新计算复杂度。与其他函数共享行范围的函数,以及函数体从签名结束所在行开始的 Python def,都会按覆盖率运行中的计分方式计为未测试。运行中没有对应行的函数(之后新增或重命名)以及位于没有执行通道测量的作用域中的函数,其 cov 会打印为 -,末尾标注 (coverage not measured);--json 会将其标记为 unmeasured: true,并保留 cov、crap 和 remedy。这是建议性检查,不写入运行。--gate 会对与钩子相同的选定范围应用提交前钩子的策略(自 HEAD 起发生变更的工作树函数),但排除 CRAP 不高于其棘轮标记的函数,并以退出码 6 退出。无法读取的变更文件也会导致退出码 6,并在 --json 中列于 gate.unread_files 下。超过标记的函数会被门禁拦截;提交前钩子则只要标记存在就予以豁免,因为暂存内容没有可供评分的覆盖率。
ratchet seed | prune | merge | move | report [--baseline ID] [--enforce] [--json]标记的生命周期:seed 为新债务建立标记,prune 清除已删除的代码(如果 git 重命名了带标记的文件,标记会随之迁移;prune 行会列出它跟随的最多三个重命名;如果此克隆不包含重命名起始提交,且带标记的文件在其最早提交之前就已删除,prune 会以退出码 4 退出且不写入任何内容),merge 作为 git 驱动程序合并,move 更改标记路径,report 则从文件自身的 git 历史中读取清理进度。seed 结束时会指出后续要使用的提交和 verify 命令。seed 和 prune 可使用 --baseline ID,从指定运行读取,而不是采用 verify 的选择;如果指定的基线符合 verify --baseline 的拒绝条件,就会被拒绝。见 docs/ratchet.md。
runs [list | prune [--keep N]] [--json]查看运行历史并管理保留策略。list 会将 verify 今天用于比较的运行标记为 baseline;对于不会产生判定结果的运行,会打印 verdict=-,而不是标记为失败。见可信基线。--keep(默认为 5)是最新可信运行的保留数量下限,而非上限:摘要配对、每个通过的 verify 基线、每个被覆盖豁免引用的运行,以及最新的非钩子运行也都会保留。之后 prune 会执行 VACUUM。
overrides [--json]查看覆盖豁免审计记录:谁在何时因何原因授予了什么豁免。
trend [--json]显示各个可信运行的汇总数据:函数数、超出目标的数量、CRAP 总量、平均值和按作用域汇总的数据。它会读取每次运行的汇总表,而非重新扫描每一条评分记录;任何缺少汇总表的运行都会补齐,因此会写入存储(尽力而为:.crapkit/ 只读时,只会损失速度,不会导致命令失败)。
digest [--alert]比较执行通道集合相同的最新两次运行之间的差异。没有变化时不输出内容。除汇总行外,每种变化最多列出五个函数:CRAP 增幅超过 0.01 的函数(按增幅降序);超过上限的新函数(按 CRAP 降序);以及原先超过上限、现已下降超过 0.01 的函数(按降幅降序)。变更和精确到小数点后 4 位相同的评分按路径列出。如果较旧运行中没有某个超过上限函数的对应行,当该运行测量过其作用域时会标为 new over ceiling;若该运行未测量该作用域,则会标为 newly scored over ceiling in scope NAME:如果两次运行之间向 crapkit.toml 添加了作用域,旧代码便会被纳入,这不属于新增债务。--alert 会将正文通过 stdin 传给 alert_command。只输出普通文本行,从不输出 JSON。
report [--out PATH]生成一个独立的 HTML 页面,写入 .crapkit/report.html(或写入你指定的 --out PATH;该路径可以是相对于仓库的路径,也可以是绝对路径),并在 stdout 中打印路径。它会呈现 worklist --json 和 trend --json 在默认参数下已有的信息:受 worklist_top 限制的排序工作列表、最新运行中的各作用域评级、趋势序列,以及一个标记,列出所有过期执行通道和根据 scored_changes 统计的运行评分后发生变更的文件数。它不进行测量,也不会建立网络连接。每一行都包含函数的 CRAP 和覆盖率,并打印用于查看其余信息的 crapkit explain 命令:暗行、历史记录和标记。它会读取 trend 使用的相同运行汇总表,并按相同条件写入这些表。
duplication [--min-lines N] [--similarity F] [--top N] [--json]按归一化行片段和包含度评分查找近似重复函数。默认值:--min-lines 8、--similarity 0.8、--top 50。哈希种子不同,平分时仍保持稳定顺序。正数 --top 会限制保留的候选项和输出数量;密集输入仍需比较函数对。每个函数都只使用自身的行生成片段:嵌套函数中从该函数首行之后开始的行,属于嵌套函数。函数与其嵌套闭包不会组成一对。空行和注释行会被排除,以三个引号开头的 Python 行也会被排除。块注释结束符之后的代码仍会被视为代码(/* tag */ acc += 1;、*/ x = a)。s = "http://x"; /* note 会开启块注释,而 x = 1; // see /* here 不会。读取器只识别普通字符串和字符字面量,因此会误读 r"C:\"、r#"a"b"#、R"(a"b)"、/"/、re = /a /* b/; 和 echo a#b <# note:docs/commands.md: duplication 说明了注释读取器的规则,以及这些行各自会如何处理。
coupling [--min-support N] [--min-confidence F] [--top N] [--json]查找总是在同一批提交中出现的文件对。默认值:--min-support 5 个共同提交,--min-confidence 0.5 最大方向比例,--top 50。大量文件一同变更的提交不会形成耦合关系;较新的仓库在默认支持度下不会返回结果。文件对按支持度乘以置信度从高到低排序,分数相同则按路径排序。排序后的文件对会缓存在 .crapkit/coupling-cache-v2.json 中,缓存键包括 HEAD、变动时间窗口、今天的 UTC 日期、路径格式、克隆的历史深度以及已跟踪文件集的摘要,并与 brief 和 worklist --batches 共用(缓存预热后,在包含 72k 次提交的仓库中用时从 1.05 秒降至 0.11 秒)。日期是缓存键的一部分,因此 UTC 午夜后的首次运行会在 HEAD 未变时重新构建文件对,但结果相同:时间窗口截止于 HEAD 提交日期,而非今天。历史深度也是缓存键的一部分,因此当天执行 git fetch --unshallow 后会重新构建。--top 会读取缓存,因为它只截取同一排序结果;使用非默认值的 --min-support 或 --min-confidence 会查询文件未包含的更大范围,因此会绕过缓存并重新计算。
mutate [--files F ...] [--max-mutants N] [--drop-pool] [--json]按差异范围执行变异测试:翻转变更行中的比较运算、边界、布尔连接符和布尔字面量;对每个变异体运行 mutation_command,并列出存活者。--files 会用整个文件替代差异范围。两个文件列表都会先通过评分语料库的筛选,使用与 coverage 相同的谓词:语料库外的文件会在 stderr 中列出,且不会进行变异;--json 会在 outside_corpus 下列出这些文件;若没有剩余文件,stdout 会输出 nothing to mutate,退出码为 0。--max-mutants(默认值为 100)限制运行数量,--json 中的 mutants 为限制后的数量。测试套件超时或退出码为 5 且未运行任何测试的变异体均计为已杀死;--json 还会分别在 timed_out 或 no_verdict 下计数,这些数量包含在 killed 中。汇总会单独用一行统计无判定结果的变异体:no verdict: N of the K killed ran no test (exit 5), so no test caught them。Shell 和 PowerShell 文件会在 stderr 中按名称拒绝;某些运算符在某些语言中不会产生任何变异体:见 docs/commands.md: mutate。每个工作进程都会使用保留的工作树,包括只有一个工作进程时;见变异工作树。--drop-pool 会删除这些工作树并退出。
test-scoped FILE ...针对指定文件运行所属作用域的 [crapkit.scoped_tests] 模板(路径会加引号,最长前缀匹配的作用域优先)。如果某文件不属于任何作用域,则仅当它位于 test、tests 或 __tests__ 目录下时才运行;并且只使用声明了模板的那个作用域。如果模板中没有 {files},则会按原样运行;这适用于测试文件位于作用域自身路径之外、需要运行整个测试套件的作用域。只返回退出代码;运行器退出码非零时,本命令退出码为 1。
hook-precommit [--base REF]仅对暂存内容执行 cc 门禁。不使用覆盖率、快照或全仓库缓存。违反规则或暂存文件无法读取时,退出码为 6;后者会在 UNREAD 行中列出文件名和读取器给出的原因;只要有此类文件处于暂存状态,CRAPKIT_OVERRIDE_REASON 就不会授予任何豁免。--base REF 会将索引与 REF 和 HEAD 的合并基底进行比较,这是 CI 检出的运行方式。提交之外且没有暂存内容时(例如运行 pre-commit run --all-files),会检查每个已跟踪文件。如果运行路径中或其上级目录中没有 crapkit.toml,则会在每个拥有暂存文件的下级根目录中分别执行门禁。
claude-hook [--protocol N]从 stdin 读取一个 PostToolUse 负载,格式与 Claude Code、Copilot CLI、Cursor 或 VS Code 发送的内容相同,并检查它编辑过的每个文件:对编辑修改的函数,依据作用域上限检查 ccn,并排除棘轮标记已覆盖的函数。仅提供建议:编辑已经完成,而 hook-precommit 仍是强制检查点。对于 Claude Code,建议会写入 stderr 并以退出码 2 退出;对于 Copilot CLI、Cursor 和 VS Code,建议会以单个 JSON 对象写入 stdout 并以退出码 0 退出,因为这些工具会将其他退出码视为错误。未归属任何作用域的文件,或上级目录中没有 crapkit.toml 的文件,会以退出码 0 静默退出。Bash 事件会检查工作树,而你需要注册一个 Bash 匹配器(Claude Code 插件)。它不会打开快照,只会将该会话已判定字节的记录写入 git 目录。它会打印的内容及保持静默的条件见:docs/commands.md: claude-hook。
watch [--interval SECONDS] [--cycles N]在文件内容变更时重新评估作用域声明的文件(默认每 2 秒轮询一次,并与子进程隔离,因此临时保存的语法错误不会终止监视器)。每次轮询时都会重新列出作用域路径下的已跟踪和未跟踪文件,因此运行过程中创建的文件也会重新评分。mtime 发生变化的文件只有在字节内容不同时才会读取并重新评分,因此单纯执行 touch 或编辑器保存相同字节不会触发重新评分;如果文件在保留原 mtime 的情况下被编辑(cp -p、touch -r),则不会被发现,这是分析缓存共有的限制。--cycles N 会精确轮询 N 次后以退出码 0 退出;不指定此参数时,循环会一直运行到 ctrl-c。在监视器运行期间执行 pip install -U 后,下次重新评分会以退出码 1 退出,并提示重启监视器。
help [TOPIC]提供 git、npm 和 docker 都有的帮助命令。不指定 TOPIC 时会打印命令列表;指定 TOPIC 时会打印相应子命令的帮助,即 crapkit TOPIC --help 所显示的内容。TOPIC 不是子命令时,退出码为 3。
mcp提供无需额外依赖的 stdio MCP 服务,公开十二个名为 verb_noun 的只读工具,每个工具都带有标题和输出模式。工具会调用 CLI 检查当前评分、源代码和编辑文件门禁。它们不会领取任务,也不会运行验证;调用可能会写入缓存或存储元数据。见 MCP contract,以及 将 crapkit 接入你的 Agent,了解各 Agent 的配置文件需要添加的代码块。

阅读输出

crapkit 自身输出的每一行都是 ASCII,因此在 Windows PowerShell 5.1 中,$x = crapkit doctor 或 $x = crapkit worklist 2>&1 在任何控制台代码页下都能完整捕获输出。crapkit 引用的路径或函数名会保留自身字符,并以 UTF-8 输出;若要捕获这些内容,请先设置 [Console]::OutputEncoding = [Text.Encoding]::UTF8。

标志:为什么缺少覆盖率数值
标志含义计分
measured某个 lane 的制品提到了此函数。实际的 cov。
untested某个 lane 覆盖了此范围,但其制品没有提及此函数,通常意味着没有测试导入该文件。cov = 0。这是测试缺口,uncovered_lines 会返回 null,因为没有制品能指出它从未见过的行。
excluded某个 lane 的制品测量了该文件,并被要求排除此函数:在 coverage.py 下,使用 # pragma: no cover、会涵盖其中每条语句的 exclude_lines 模式,或从 coverage.py 7.10.1 开始,函数体为 ... 的存根;在 istanbul 下,使用 /* istanbul ignore next */ 或 /* v8 ignore next */。istanbul 会从文件的 fnMap 中移除此类函数,因此,如果一个已插桩文件的 fnMap(与其 statementMap 并列)列出了其他函数,那么其中缺失的函数也会被标记为 excluded。crap = ccn,且 remedy 只能是 ok 或 decompose:测试无法改变其数值。uncovered_lines 会返回 []。如需测量此函数,请移除排除设置。
no-lane没有任何 lane 的 scopes 列表列出此函数所属的范围。cov = 0。这是工具链缺口,而非测试缺口。next-item 不会派发此类函数,并会将其计入 skipped_no_lane;worklist 会为其排序并将行标记为 no-lane,因为接线缺口是你必须看到的风险。
cc-only此范围设置了 coverage_optional = true,因此无法有覆盖率数值。crap = ccn,且 remedy 只能是 ok 或 decompose。uncovered_lines 会返回 null,并附带说明该设置的备注。

覆盖率摘要会将这五类分别统计为 measured / untested / excluded / no_lane / cc_only。

修复建议:如何处理
修复建议条件操作
decomposeccn > ceiling拆分函数。无论覆盖率多高都无法解决。
split-linesccn <= ceiling、crap > ceiling,且另一个函数与它共用源代码行,或者 Python def 的函数体从其签名结束的那一行开始将每个定义放在各自的行上,然后重新测量。覆盖率无法区分同一行上的函数,因此无论测试如何,分数都会保持为未覆盖。单行 Python def 与其 def 语句共用一行,而该语句会在导入时运行,因此 coverage.py 无法显示函数调用。同样,如果函数体位于跨多行签名的最后一行,或冒号所在行的括号内,或反斜杠之后,请将函数体移到签名之后的单独一行。
add-testsccn <= ceiling 且 crap > ceiling覆盖各个分支。
okcrap <= ceiling无需处理。

比较时使用精确的 CRAP,而不是计算它的浮点数。CRAP(18, 2/3) 恰好为 30,而其浮点数值是 30.000000000000004;当 target = 30 时,它显示为 ok。超出目标的计数、等级、ratchet seed 和 verify 的门槛判断也都采用相同规则。

等级和 CRAP 负载

等级表示超过上限的函数所占比例:恰好为零时为 A+,低于 2% 为 A,低于 5% 为 B,低于 10% 为 C,低于 20% 为 D,达到或超过 20% 为 F。旁边的 crap_load 是所有函数 CRAP 分数之和,因此即使字母等级不变,函数有所改善时它也会变动。负载使用精确求和(math.fsum),最后只舍入一次,保留 2 位小数,因此行的顺序以及 Python 或 SQLite 版本都不会影响它。两者都基于本次运行测量的范围:所属通道失败或因 --lane 被排除的范围按 cov-0 代值计分,且仅在 by_scope 下报告其负载。trend、coverage、摘要以及 brief 的文件总数,对于相同的行都会显示相同的负载。

风险:工作列表的排序依据

risk = ccn * churn weight。权重是文件在变更窗口内各次提交的时间加权总和:每次提交的逻辑权重随时间上升,最新提交的权重为 0.5,最旧提交的权重则接近零,因此上个月的五次编辑会排在两年前的五十次编辑之前。窗口从 HEAD 的提交日期向前延伸 churn_window_months,而不是从当前时间开始,因此固定的代码树永远会得到相同的排序。

输入依据是提交在日志中的位置,而不是时间跨度。若日志中的所有提交时间戳都相同,就没有可供加权的时间范围,因此每次提交都计为 1 次:只有一次提交的仓库中,每个文件的权重都是 1.0,按 ccn 排序,且不会有文件因触及下限而晋升,因为权重相同的前 10% 就是所有文件。相隔几分钟的提交就足以形成排序。本仓库当时只有八次提交,且都在同一天完成:

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

ccn 为 5 的 bucket 排在 ccn 为 9 的 curve 之前,因为前者被五次提交修改,后者只被一次提交修改。按变更频率加权,目的就在于此。spread 带有 ok 标记:它已达到或低于上限,但仍会列出,且 next-item 不会将其分配出去。

列表分为两部分:活跃(窗口内有提交的文件)和休眠(变更频率为零,不进入队列,但仍计数)。有两条规则可以突破 worklist_floor。变更权重位于前 10% 的文件会被提升至 ccn 3,这就是为什么上面的 spread 会以 ccn 4 出现。超过上限的函数无论 ccn 如何都会被纳入,因此下限永远无法阻挡待处理问题。

可信基线

每次 verify 都会将工作树与一次较早的运行——可信基线——进行比较。crapkit runs list 会标明当前使用的是哪一次运行。

哪些运行符合条件。 coverage 运行,或通过的 verify 运行。失败的 verify 永远不符合条件,partial 运行也不符合(通道失败,因此某些范围回退为 no-lane);hook 覆盖记录同样不符合,因为它不包含任何已评分的行。在 runs list 中,verdict=- 表示该运行不会生成 verdict,而不是运行失败:只有 verify 才会生成 verdict。四个读取方都会询问同一个问题,并得到同一个答案:此处的基线选择、ratchet seed、prune,以及将标记与同一提交的先前运行进行比较的收紧阻尼逻辑。现在,不能再依据 verify 拒绝的运行来签署标记。

哪些运行会推进基线。 任何提交位于 HEAD 之前或与 HEAD 相同、且符合条件的运行。coverage 会在 HEAD 所在位置写入运行,因此仪表盘定时任务与 CI 一样会推进基线。通过的 verify 会推进基线,并在此过程中收紧 ratchet。

其他分支上的运行绝不会被采用。 存储会保留每个分支的运行记录,因此执行 git checkout main 后,最新的合格运行可能来自某个功能分支。verify、ratchet seed、ratchet prune 和 runs list 都会跳过该运行,改为读取 main 自身历史记录中最新的合格运行。如果 HEAD 之前没有合格运行,verify 会以状态码 4 退出,并说明最新的运行为何不符合条件:它是在 HEAD 不包含的分支上生成的(在此分支上运行 coverage),或者其提交因变基或 amend 而被改写。 污点规则。 失败的 verify 会针对一棵代码树记录发现的问题。在某次 verify 通过之前,该失败之后运行的任务不会成为基线:选择这样的运行会让比较点越过这些发现的问题,被标记的函数也就不再算作受影响的函数,之后便不会再检查它。verify 会说明它拒绝了哪个运行,并回退到失败之前最新的运行。

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

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

运行 3 是有人在运行 2 拒绝的代码树上执行的 coverage 运行,其结果与那个 ccn-8 函数相同。没有这条规则,它就会成为基线,legacy_router 也不再算作受影响的函数,这条门禁信息便再也不会打印。

没有某个通道测试结果的基线。 可信运行可能包含一个没有测试计数、也没有失败列表的通道:当时该通道没有声明 results_artifact,coverage --reuse-artifacts 找到的 junit 文件已丢失或无法读取,或者 0.8.1 之前的 crapkit 在这类 junit 文件上存储了一次 verify --reuse-artifacts 运行。verify 不会将其视为一个没有测试、也没有失败的测试套件。它会将该通道与基线提交版本上或其之前最新的、记录了该通道的可信运行进行比较,并在信息中指出该运行:

warning: lane 'py': baseline run 2 recorded no test results, so its failures are compared with run 1's
warning: lane 'py' runs 8 fewer tests than run 1 (baseline run 2 recorded no test count for it)

如果没有运行记录过该通道的失败列表,那么该通道的每项失败仍会被视为新增,退出码为 8,并显示一条信息,说明它可能早于本次更改;verify --json 会在 lanes_without_baseline_results 下列出该通道。

verify 无法读取的 junit 文件。 对于声明了 results_artifact、但该文件缺失或无法读取的通道,运行 verify --reuse-artifacts 会以退出码 5 退出,且不会存储运行记录;真实运行该通道时遇到同一文件也会返回该退出码。拒绝信息会指出通道和文件,并以 run verify without --reuse-artifacts so the lane writes it again 结尾。在 0.8.1 之前,这种 verify 会以退出码 0 通过,没有检查任何测试,并存储一次通过的运行,之后它就成了下一个基线。Action 不会遇到这种情况:它的 coverage 步骤会运行各通道,并先拒绝同一个 junit 文件。对于该 junit 文件运行 coverage --reuse-artifacts 仍会发出警告并继续评分。未声明 results_artifact 的通道会通过,并输出一条 stderr 信息;verify --json 会在 lanes_without_results 下列出该通道。

两种退出方式。 修复发现的问题并让 verify 通过,这样就能永久清除污点。或者使用 verify --baseline 3,有意接受较新的运行:显式指定 ID 会绕过该规则,运行历史会记录判定使用了哪个运行。这里的任何规则都不会影响从未运行过 verify 的仓库:没有需要保护的失败时,单独运行 coverage 总会推进基线。

ratchet seed --baseline ID 和 ratchet prune --baseline ID 接受与 verify --baseline ID 相同的名称,并遵循相同的规则。当失败的 verify 将 seed 固定到它无法读取或签名的运行时,可以通过这种方式解决 (docs/ratchet.md)。

指定的 ID 无法作为基线时。 如果 --baseline ID 指定了一个真实存在、但不符合候选条件的运行,信息会指出该运行、原因以及可用的运行:

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

退出代码

代码含义
0正常。对于 verify 和 hook-precommit:门禁已通过。
1过载。 有三种无关情况,列于表格下方。
2argparse 用法错误:未知标志、缺少位置参数。在 crapkit 自身的错误处理开始前引发。
3配置错误:缺少或无法解析 crapkit.toml、未知语言或解析器、运行该命令的 shell 将某个 lane 命令识别为缩小后的测试套件、ratchet 指标戳不匹配(升级)、test-scoped 文件不属于任何作用域,或位于没有模板的作用域下、作用域内文件的名称不是 UTF-8,或路径参数指定的文件名称不是 UTF-8(这两种情况都会以 rename it (git mv) to a UTF-8 name 结尾)、根目录中的 package.json 无法被 init 作为单个 UTF-8 JSON 对象读取(init wrote no file: ...,在写入任何文件之前)、根目录位于 Windows 网络共享上(提示信息会给出将其映射为驱动器号的 net use 命令)。
4Git 错误:当前目录不是仓库、仓库尚无提交、git 拒绝打开的仓库(拒绝信息会引用 git 自己的修复方法,例如 safe.directory 异常)、基线提交已被改写出历史记录、基线提交是在当前分支 HEAD 不包含的分支上创建的,或当前克隆中缺少该提交、浅克隆中没有基线提交或分叉点、浅克隆中使用 ratchet report --enforce 且设置了债务键(标记的年限和还款情况需要完整历史记录)、ratchet prune 无法判断标记文件是否被重命名,因为当前克隆缺少这些重命名所基于的提交。浅克隆相关的拒绝信息以 set fetch-depth: 0 on the checkout or run git fetch --unshallow 结尾。
5工具错误:无法导入 lizard、某个 lane 未生成产物、某个 lane 测量了不同的代码树、某个 lane 测量了当前代码树却以绝对路径报告结果(合并使用的是相对于根目录的路径,因此这些路径也无法匹配;拒绝信息会指出运行器自身的开关:对于 coveragepy lane,是 [tool.coverage.run] 下的 relative_files = true;对于 istanbul lane,则是报告器的 cwd/root 选项)、某个 lane 超时且重试后仍未完成、verify --reuse-artifacts 使用的 lane 声明了缺失或不可读的 results_artifact(该 lane 不会存储任何运行结果)、覆盖命令失败、进程没有主目录(Windows 上未设置 USERPROFILE,或 POSIX 上未设置 HOME,且操作系统也未指定该目录;消息会指出需要设置的变量)。timeout_seconds 会终止整个进程树,因此失败后不会有孤立的测试套件继续运行。
6门禁违规。差异改动涉及的函数超过其上限,且超过其携带的任何 ratchet 标记:如果编辑后带有标记的函数分数仍不高于标记值,则属于仓库已承诺承担的债务,不予追究。此外还包括 rescore --gate(应用同一规则)以及 hook-precommit(只要标记存在就不予追究)。这三者也都会拒绝任何无法读取的已更改文件(UNREAD 行),因为它们无法对其中任何函数进行评估。
7差异未触及的 ratchet 回退。带标记的函数分数劣于其记录的历史最高值;已触及且超过标记值的函数会报告 6。
8相对于基线运行结果出现新的测试失败。基线中已有的失败不计入,包括使用 --baseline-tsv 文件时也一样:其戳记行会列出每个 lane 的失败(可移植记录)。
9差异覆盖率上限被突破:已设置 diff_uncovered_max,且未运行的已更改行数超过该值。对于未被任何 lane 产物提及的已更改文件,其函数中的每一行都计入未运行行数。
退出码 1 有三种可能含义

CI 无法仅凭代码区分崩溃和正常的策略判定。具体是哪一种,取决于所运行的命令:

命令退出码 1 的含义
doctor一个 FAIL 发现项。这是判定,不是崩溃。WARN(未测量的目录,或某个通道将其产物写入仓库根目录)和 note(文件超过 max_file_bytes,或未声明任何通道)都会以退出码 0 结束。
ratchet report --enforce违反了债务策略。这也是判定。在浅克隆中,如果设置了债务键,它不会作出任何判定,并以退出码 4 结束。
其他任何命令意外错误:"no snapshot yet, run crapkit coverage first"、找不到匹配函数的 brief 名称,或以非零状态退出的 test-scoped 运行器。

verify 会按 6、7、8、9 的顺序报告第一个触发的错误。门禁违规和棘轮回退同时发生时,会报告 6。触发其中任何一项的运行都会失败,因此既不会推进基线,也不会收紧棘轮,退出码为 9 时也一样。

快速开始:Python

仓库中有 calc/grade.py、tests/test_grade.py 和 pyproject.toml。请先提交;crapkit 会读取 git ls-files。先安装覆盖率插件,因为通道 init 写入的运行命令会执行 pytest --cov,这些标志来自 pytest-cov:

pip install pytest-cov "coverage>=7.13.1"

请为覆盖率要求加上引号,否则 shell 会将 > 视为重定向符。若不加引号,pip 会保留虚拟环境中已有的较旧 coverage.py,因为 pytest-cov 接受它,而通道会在报告阶段以退出码 5 失败。

(如果 crapkit 与测试套件共用同一个虚拟环境,pip install "crapkit[py]" 会一次性安装这两者。)

如果测试套件通过 subprocess.run 驱动自己的 CLI,请在 pyproject.toml 中添加 [tool.coverage.run] patch = ["subprocess"],并确保使用 coverage>=7.13.1:pytest-cov 7.0.0 移除了对子进程测量的支持,因此若没有这个键,每个入口点的得分都会是 0%,且不会发出任何警告。7.13.1 也是 crapkit 能读取其报告的最旧 coverage 版本;更旧的版本会导致通道以退出码 5 失败。docs/lanes.md 介绍了完整规则。

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

init 会扫描已跟踪的源代码,并为每个顶层源代码目录创建一个范围;还会根据仓库现有内容检测覆盖率通道:pytest 标记文件(pyproject.toml、pytest.ini、setup.cfg)会写入一个启用的 [[lane]];package.json 中有 test 脚本或 vitest/jest 也会如此。它们旁边的锁文件会决定环境:如果有 uv.lock、poetry.lock、pdm.lock 或 Pipfile.lock,通道就会使用 uv run python -m pytest …(其他情况也会使用对应的 run),因为裸 python 会绑定到 shell 当前激活的 venv,而不是仓库指定的环境;请参阅通道绑定的解释器。无论检测到什么,它也会为未发现的运行器保留注释模板,这些模板使用相同的启动器,因此取消注释时不会让裸 python 重新生效。它写入的每个通道都会将报告保存到 .crapkit/cov/,这也是 .gitignore 列表如此简短的原因:请参阅产物存放位置。没有锁文件时,通道会通过启动器标记指定解释器,因此提交的一行配置在所有操作系统上都能运行:这里的 {python} 在 Windows 上会被 crapkit 读取为 python,在 Linux 和 macOS 上则为 python3;如果仓库中的 .venv 包含 pytest,则使用 {python:.venv}。启动器标记列出了各种形式。

init 会像 npm 一样,读取每个 package.json 中 UTF-8 字节顺序标记之后的内容。如果根目录下的 package.json 不是一个 UTF-8 JSON 对象,init 会在写入任何文件之前以退出码 3 停止,并指出文件和修复方法,因为根据这样的文件读取通道配置只能靠猜:例如,文件采用 UTF-16 编码(PowerShell 5.1 的 Out-File 会写入这种编码)、包含非 UTF-8 字节、无法解析,或者内容为数组或其他值时,会显示类似 init wrote no file: package.json is not UTF-8 (byte e9 at offset 36); save it as UTF-8 的信息。嵌套的 package.json(例如测试夹具)会被跳过,并输出一行警告说明文件名。

init 会先写入 .gitignore,再写入 crapkit.toml;它会使用该文件本身的换行符追加内容,不改动已有的任何字节。它会像 git 一样读取 UTF-8 字节顺序标记之后的内容,因此不会重复添加已有条目。git 同样无法读取的 UTF-16 .gitignore 会在标准错误输出中注明文件名和修复方法,并保持原样。对已有的 crapkit.toml 再次运行 init,它会添加通道所需的 .gitignore 条目,说明已完成,退出码为 0,并且逐字节保留 crapkit.toml。如果 .gitignore 为 UTF-16 编码,它会以退出码 3 退出,并输出说明文件名及待添加条目的信息;如果没有缺失内容,则会提示 already exists。

[crapkit]
target = 6

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

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

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

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

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

最后一个区块是 Agent 循环所需的配置。对于声明了模板的范围,crapkit test-scoped 会正常运行;如果文件所属范围未声明模板,则会以退出码 3 退出。AGENTS.md将此设为逐步清理循环的第 4 步。所有键都列在docs/configuration.md中。

2. 根据仓库检查配置
$ crapkit doctor
resources: up to 8 analysis worker(s) per pool, 8 shared slot(s); lane log limit 16777216 bytes per file
ok   config keys all recognized
ok   scope 'calc': 1 file
ok   every tracked source file belongs to a scope
ok   1 lane(s) declared
ok   lane 'py': python3 -> /home/you/.venvs/ledger/bin/python3 (pytest 8.3.3, pytest-cov 7.1.0, coverage 7.13.1)
ok   lizard 1.24.0
doctor: no problems found

doctor 会为每项检查打印一行,只有出现 FAIL 时才以退出码 1 退出。WARN 和 note 会报告问题并以退出码 0 退出,末尾一行会统计上方的 WARN 数量(doctor: no problems found, 1 warning above)。

3. 为仓库评分,并读取队列
$ 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、ccn、函数在已排序运行中的 crap 和 cov(仅清点运行时两者均为 -;在 no-lane 或 cc-only 行中,cov 为 -,因为没有任何 lane 测量它)、变更窗口中的 <commits>c/<authors>a、path:line、函数的长名称,然后是不会由消减队列分发的行标记(ok、no-lane)。标题会将活跃行数与总数一起列出,因此 50 of 3980 active (worklist_top 50) 表示上限隐藏了多少内容;如果该上限由标志设置,则显示为 (--top N)。 --json 还包含 ccn_std、weight 和 ratchet_mark。

最后一行会指出这一步之后的步骤。仓库尚无标记文件时,它是 ratchet seed;有标记文件后,它是 next-item;如果已排序运行无法作为基线(清点运行、部分运行或验证失败),则是 coverage。本流程会在第 4 步先查看排名最高的项目,并在第 5 步进行种子初始化。

worklist 是风险地图,不是待办清单。 它也会对已完成的行进行排序,因此消减工作完成后它也不会清空。next-item 是该运行的另一个视图:它会移除 no-lane 行,按 crap 排序,而其中的 empty: true 就是停止条件。

4. 处理排名最高的项目
$ crapkit next-item
{"commands": {"refresh": "crapkit coverage --reuse-unchanged"}, "commit": "fae4db93108b4841a00959f9117430679e7250ca", "empty": false, "item": {"authors": 1, "ccn": 14, "ccn_std": 14, "cognitive": 13, "commits": 1, "cov": 0.5, "crap": 38.5, "end": 28, "est_splits": 3, "est_uncovered_paths": 7, "flag": "measured", "function": "classify( score , attempts , late , bonus )", "handle": "classify", "nesting": 3, "nloc": 22, "occurrence": 1, "path": "calc/grade.py", "remedy": "decompose", "scope": "calc", "start": 7, "target": 6, "uncovered_lines": [9, 11, 15, 17, 19, 24, 25, 26, 27, 28], "unmeasured": false}, "run_id": 1, "schema": 1, "scored_changes": 0, "shallow": false, "skipped_no_lane": 0, "stale": false}

remedy: "decompose"、est_splits: 3(大约需要拆成三部分,才能低于 6),以及列出没有任何测试覆盖的十行的 uncovered_lines。handle 是应传回的名称形式。scored_changes: 0 表示本次运行评分的文件现在没有其他内容,因此这些数字描述的是磁盘上的文件;任何非 0 的值(包括 null)都意味着执行操作前要运行 commands.refresh。stale: false 表示 HEAD 仍是本次运行所用的提交;这是关于提交的问题,而不是关于文件的问题。所有字段都见docs/agent-json.md。

5. 初始化 ratchet

修复任何内容之前,先启用债务门禁。ratchet seed 会记录每个超过目标的函数当前的评分,此后任何项目都不能变得更糟。

$ crapkit ratchet seed
crapkit-ratchet.tsv: added 1, tightened 0 - 1 mark(s) vs run 1 (fae4db93108)
-> next: commit crapkit-ratchet.tsv, then run `crapkit verify`

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

此时运行 verify 会通过,为仓库带来首次通过的判定。本流程会在修复后、第 6 步运行它。

6. 修复并验证

持续拆分,直到每个部分都不超过上限。这里,classify 被拆成了 _validate、_adjusted、_band,以及一个只负责按顺序调用它们的 classify;案例表则移入了参数化测试。提交修复,然后运行:

$ crapkit verify
verify OK @ 8d10c13303d vs baseline fae4db93108 (5 changed files) ratchet: 1 dropped, 0 tightened -> git add crapkit-ratchet.tsv
  changed files: calc/grade.py, .gitignore, crapkit-ratchet.tsv and 2 more

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

CRAP 负担从 41.0 降至 19.0,等级从 F 升至 A+。verify 会重新运行各个 lane,并根据可信基线检查三件事:diff 涉及的每个函数都不超过其上限,没有标记函数变得更糟,而且基线中通过的测试现在没有失败。退出码为 0 时会推进基线,并就地收紧 crapkit-ratchet.tsv,因此已偿还的标记会从文件中移除:接着运行 git commit -am "ratchet: classify repaid" 提交。完整的标记生命周期见docs/ratchet.md。

现在,crapkit next-item 会返回 empty: true,并附带一个说明你遇到哪种结束情况的 reasons 对象。这是停止条件的大部分,但并非全部: AGENTS.md 中写明了完整规则,并会读取 reasons 的其余内容。

快速入门:TypeScript

一个包含 src/grade.ts 和 test/grade.test.ts 的 vitest 仓库。

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

init 写入的 lane 是 npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --coverage.reportOnFailure --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml。 它会读取 vitest 的 json reporter 生成的 .crapkit/cov/js/coverage-final.json;reportsDirectory 标志可将该报告保留在根目录之外。junit 部分是 lane 的 results_artifact,崩溃工作进程和无新增失败检查都会读取它;两个 reporter 都要显式命名,因为单独使用 --reporter=junit 会替换你用来查看测试套件运行情况的控制台输出。凡是能生成 istanbul coverage-final.json 的工具都可以;请参阅 docs/lanes.md 中的 jest 和 pytest 配置示例、下一级目录中的包,以及位于仓库顶层以下的 crapkit 根目录。

init 会将 vitest 命令写在 [crapkit.scoped_tests] 下方并注释掉,因为它无法判断文件范围运行需要哪个 vitest 配置。在取消注释该命令或自行编写之前,crapkit doctor 会发出 WARN:scope 'src' has a lane but no [crapkit.scoped_tests] template。该 WARN 不会影响 doctor 的退出码,退出码仍为 0;它表示针对 src/ 下文件运行 crapkit test-scoped 时会以 3 退出。

2. 安装覆盖率提供程序

这一步会让大多数 TypeScript 用户卡住。 vitest 默认不附带覆盖率提供程序。若未安装,init 和 doctor 都会正常通过,但 coverage 会以退出码 5 失败:

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

> [email protected] test
> vitest run --coverage --coverage.reportsDirectory=.crapkit/cov/js --coverage.reportOnFailure --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml

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

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

这两行以 > 开头的内容是 npm 的提示信息:第一行是 package.json 中的 > <name>@<version> test(没有 name 或 version 时为 > test),第二行是 test 脚本运行的命令。

此失败不会写入任何运行记录。所有 lane 都失败,因此 coverage 会在打开存储之前退出:此时还没有 .crapkit/crap.sqlite,下方的运行 ID 仍会从 1 开始。

安装提供程序时,请自行固定主版本。若不固定,npm 会根据较旧的 vitest 解析出最新提供程序,并拒绝安装依赖树:

npm i -D "@vitest/coverage-v8@<your vitest major>"
问题答案
选择哪个提供程序?两者都可以。@vitest/coverage-v8 是 vitest 的默认提供程序,无需配置。@vitest/coverage-istanbul 也可以使用,但需要在 vitest 配置中设置 coverage.provider = "istanbul"。
选择哪个 crapkit 解析器?两者都会使用 parser = "istanbul"。提供程序名称与解析器名称彼此无关:v8 输出会先映射到 istanbul JSON 架构,再写入文件。
选择哪个版本?提供程序的主版本必须与 vitest 一致。通过 npm ls vitest 查看 vitest 的主版本,然后安装对应主版本的提供程序:例如,vitest 5 可运行 npm i -D "@vitest/coverage-v8@5"。去掉版本固定后,npm 会返回 ERESOLVE unable to resolve dependency tree,并指出无法满足的 peer 依赖。

crapkit 需要的产物是 coverage-final.json,由 vitest 的 json 覆盖率 reporter 写入,且默认启用。如果你的 vitest 配置显式设置了 coverage.reporter,请确保列表中包含 "json"。

vitest 在运行失败时完全不会生成覆盖率报告。init 写入的 lane 已包含 --coverage.reportOnFailure,因此即使测试失败,也会生成该产物。如果你手动编写 lane,或者更愿意将此开关与其他覆盖率设置放在一起,也可以在 vitest 配置中设置 coverage.reportOnFailure = true;两者任选其一即可。完整配置见 docs/lanes.md。

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

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

classify 的 ccn 为 15,超过 6 的上限:一个函数同时处理延迟和重试惩罚、字母等级区间、降级规则以及 null 情况。

4. 播种棘轮并提交

ratchet seed 会按每个超出目标函数当前的分数记录它们,这样在你逐步解决问题时,没有任何函数会变得更糟。

$ crapkit ratchet seed
crapkit-ratchet.tsv: added 1, tightened 0 - 1 mark(s) vs run 1 (8bfbe613fcd)
-> next: commit crapkit-ratchet.tsv, then run `crapkit verify`

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

分数高于上限时,覆盖率无济于事,因此要拆分 classify,而不是为它编写测试。 penalty、band 和 demote 会作为独立的导出函数提取出来,而 classify 保留空值情况和奖励逻辑:

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

rescore --gate 会在耗时步骤之前,仅根据复杂度评估这项修改:

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

退出码为 0,且出现了 gate: 行:每个已更改部分的分数都不高于 6。crap 列数值偏高,是因为其中的覆盖率部分仍是运行 1 的结果,也就是这三个函数尚未存在时的结果。运行 1 中没有 band、demote 或 penalty 的行,因此它们各自的 cov 显示为 -,以 (coverage not measured) 结束,分数也会按没有测试运行过该函数来计算。add-tests 就是第 6 步要执行的指令。

6. 覆盖新部分

rescore --gate 通过了复杂度检查,但没有通过覆盖率检查。penalty、band 和 demote 是三个从未被任何测试调用过的函数,因此每个函数都要编写一个表格测试:

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

在耗时步骤之前运行一次测试套件:

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

跳过这一步,第 7 步就会失败,而不会通过。在按步骤 1 至 5 操作的本仓库副本上运行,保持第 5 步未提交,并省略第 6 步,verify 会针对实际工作树重新运行各个检查通道,结果发现旧测试套件从未调用过的三个函数仍然超出上限。每条 GATE 行都会以 [dirty] 结尾,因为修改尚未提交,最后一行也会按此统计发现的问题。以下内容省略了 verify 在结果之前打印的两条警告:一条针对没有测试覆盖的已更改行,另一条针对没有棘轮标记签署的债务:

$ crapkit verify
verify FAILED @ 0296156ff21 vs baseline 8bfbe613fcd (6 changed files)
  changed files: src/grade.ts, .gitignore, crapkit-ratchet.tsv and 3 more
  GATE  crap     17.8  ccn   5 cov 20%  src/grade.ts:38  demote ( letter , row Row )  -> add-tests  [dirty]
  GATE  crap     12.4  ccn   5 cov 33%  src/grade.ts:22  band ( score )  -> add-tests  [dirty]
  GATE  crap     10.8  ccn   4 cov 25%  src/grade.ts:8  penalty ( attempts , late )  -> add-tests  [dirty]
  findings: 0 committed / 3 dirty (uncommitted edits and untracked files)
7. 验证
$ crapkit verify
verify OK @ 2af3433d979 vs baseline 8bfbe613fcd (7 changed files) ratchet: 1 dropped, 0 tightened -> git add crapkit-ratchet.tsv
  changed files: src/grade.ts, .gitignore, crapkit-ratchet.tsv and 4 more

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

CRAP 负担从 56.68 降至 22.0,等级从 F 升至 A+,第 4 步播种的标记也消失了:classify 的分数低于上限后,verify 就将其移除,并原地重写受跟踪的 crapkit-ratchet.tsv。请将它与修改一同提交。标记只会减少。

这七个已更改文件包含自运行 1 的提交以来的所有改动:第 4 步提交的三个文件、第 2 步安装时更改的 package.json 和 package-lock.json,以及第 5 步和第 6 步编辑的两个文件。

verify 也可能在结果之前打印 warning: N changed line(s) have no coverage;除非设置了 diff_uncovered_max,否则此警告仅供参考 (docs/configuration.md)。 如果工作树中存在 ratchet seed 从未签署的债务,它还会打印 warning: N function(s) over the ceiling carry no ratchet mark,并列出前 3 个函数:门禁仅评估已触及的函数,棘轮检查只比较标记,因此此类函数的覆盖率下降会被漏检。--json 中的计数项为 unmarked_over_target,不会触发退出码;没有债务的仓库中该值为 0。

文档

页面内容
手册深入了解,请从这里开始。 图文并茂的手册:介绍 crapkit 是什么、各部分如何运作,以及每条命令的用途。仓库中也提供同一页面,路径为 docs/handbook.html,内容独立完整,可直接从克隆的仓库中打开。
docs/adoption.md在快速入门基础上的判断指南:范围粒度、排除还是使用 lane、scoped_tests 配置、首次验证的污染风险。
docs/configuration.md每个 crapkit.toml 配置项:类型、默认值及其作用。
docs/lanes.mdlane 模型、vitest、jest 和 pytest 配置示例、产物复用、失败重测、容器。
docs/resources.md工作进程预算、命令清理、日志轮转和安全清理。
docs/ratchet.md初始化、精简、git 合并驱动、指标标记、债务策略、覆盖设置。
docs/upgrading.md已有安装:分析与关键版本、已保存状态、插件对齐和 Windows 升级。
docs/portable-records.md无损导出、可移植基线和 ratchet,包括文件名中带有分隔符的情况。
docs/commands.mddoctor、mutate 和 claude-hook 会检查、打印和拒绝什么,以及 duplication 会比较哪些代码行,涵盖 Subcommands 行所能表达的内容之外的情况。
docs/agent-json.md机器可读接口:schema、每个负载字段,以及实际捕获的示例。
docs/harnesses.md将 crapkit 接入 27 种 Agent:各自的 MCP 配置、是否运行建议性钩子,以及升级后如何重启。
docs/comparison.mdcrapkit 与 radon、xenon、wily、coverage.py 和 SonarQube 的定位,以及如何协同运行。
docs/accuracy.mdcrapkit 如何验证自身数据:将每项计算与外部工具、手工表格和模型进行对照,运行这些检查的各个层级,以及 crapkit 有意以不同于基准工具的方式读取某些结构的所有情况。
AGENTS.mdAgent 运行的清理循环,以及修改 crapkit 本身时须遵守的规则。
CHANGELOG.md各版本的变更内容及升级方法。
plugin/为 Claude Code 和 Codex 提供的三个技能与 MCP 服务,以及 Claude Code、Cursor、Copilot CLI 和 VS Code 会运行的建议性 PostToolUse 钩子。

crapkit.schema.json 是配置文件结构的权威依据。

开发

pip install -e ".[dev,accuracy-push]"
git config core.hooksPath git-hooks
git config merge.crapkit-ratchet.driver "python -m crapkit ratchet merge %O %A %B"
python tools/testing/run.py

dev extra 包含 pytest、pytest-cov、pytest-xdist 和 coverage.py;accuracy-push extra 包含计算精度套件中固定版本的 oracle。共享运行器负责单元测试和端到端测试的执行计划;使用 --unit-workers 1 按串行方式复现单元测试,或使用 --coverage 获取合并后的分支覆盖率和 JUnit 报告。core.hooksPath 行会在提交时启用复杂度门禁,并在推送时启用变更控制;merge.crapkit-ratchet.driver 行会通过 crapkit ratchet merge 合并 crapkit-ratchet.tsv,而不是将其作为文本合并。开发相关信息请参阅 CONTRIBUTING.md,了解 crapkit 如何检查自身计算结果请参阅 docs/accuracy.md,各版本发布前通过的安装测试请参阅 tools/deploy/README.md。

维护者和项目背景

crapkit 由 Jean-François Gagné 创建并维护。请阅读项目背景,了解它要解决的问题,以及它如何融入他在软件和 AI 领域的工作。

许可证

MIT。请参阅 LICENSE。

评论

支持 Markdown

第一次接触 MCP?

从协议是什么,到在客户端里装上第一个服务,这几条回答覆盖了大多数入门问题。

一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。