关于 closure_drift
closure_drift 是用于检查 Git 版本标签是否指向不同代码状态的只读 Python 工具,适合维护者和发布 Agent 在发布前发现标签冲突,并可接入 MCP 与 CI。
主要功能
- 01比较版本标签及文件闭包
- 02检查提交是否可安全打标签
- 03配置标签、版本来源和文件范围
- 04支持单体仓库按组件检查
- 05输出 JSON 报告和 CI 判定
适用场景
- 01发布前检查版本标签是否被重复使用
- 02在 CI 或 pre-commit 中设置发布门禁
- 03调查同一标签下代码不一致的情况
- 04审计单体仓库的版本标签
MCP 相关
其 MCP 服务提供 `would_tag` 工具,让 Agent 检查给提交打标签是否会复用已有版本标签。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
docker run -i --rm ghcr.io/luizfnsilva/closure_drift:1.2.1本地服务需要在你的电脑上安装运行环境(如 Node.js 18+)。各客户端的配置文件位置不同,请参考客户端文档。
ghcr.io/luizfnsilva/closure_drift:1.2.1stdio工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
closure_drift
在你的代码库中,v1.4.2 是否只代表一个版本?
$ git clone -q https://github.com/psf/requests && cd requests
$ closure-drift --compare v2.16.0 v2.16.1
A v2.16.0 (commit bcd0e170ac20)
label 2.16.0
closure 3d2886b87c2a7b94 17 file(s)
B v2.16.1 (commit f687e9f3d8b0)
label 2.16.0
closure 696b71ed6023f563 18 file(s)
changed requests/__init__.py
only in B requests/packages.py
==============================================================
DIFFERS UNDER ONE LABEL: both declare 2.16.0, and the code differs
in 2 path(s). If both were published, that label names two things.
不使用此工具也能检查:git show v2.16.1:requests/__version__.py 显示的是 2.16.0。closure_drift 读取 Git 历史,而不是软件包索引:它展示标签中包含的内容,而不是已发布的软件包由哪个提交构建。
版本是由人编辑的字符串。如果两个发布版本共享同一个版本号,却包含不同的代码,那么同一个地址就指向两个制品——而这一点不会被发现,因为记录下来的只有版本号。
只需一条命令即可检查。只读,仅检查一个文件,无依赖,无需联网。
运行
curl -sO https://raw.githubusercontent.com/luizfnsilva/closure_drift/v1.2.0/closure_drift.py
python3 closure_drift.py # inside any git repository
这会检查你的代码库。./reproduce.sh 则是另一回事:它会验证 closure_drift 本身(其测试套件和一次长时间运行),不会检查你的代码库。
也可以运行 pipx run --spec git+https://github.com/luizfnsilva/[email protected] closure-drift。
需要 Python 3.9+ 和 git。若想先看看三种可能的结果:运行 python3 examples/demo.py,或阅读 docs/DEMOS.md。
打标签前检查
--would-tag 回答的问题是:如果我现在为这个提交打标签,它的版本号是否已经对应了不同的代码?
# GitHub Actions
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: luizfnsilva/[email protected]
在第一个标签之前推送工作流:与工作流一同推送的标签可能不会触发该工作流(此时可手动运行,并在 on: 中添加 workflow_dispatch:)。mode: measure 只有在存在两个不同标签时才会得出结论;首次发布时结果为 inconclusive,退出码为 2,因此从第一个标签开始就应使用 would-tag 进行检查。
# pre-commit, on git push
- repo: https://github.com/luizfnsilva/closure_drift
rev: v1.2.0
hooks: [{ id: closure-drift-would-tag }]
其他流水线:docs/CI.md。
面向 AI Agent
负责打标签和发布的 Agent 可以先通过 Model Context Protocol(MCP)服务询问同一个问题。该服务仅使用标准库,并提供 would_tag 工具:
claude mcp add closure-drift -- uvx --from git+https://github.com/luizfnsilva/closure_drift closure-drift-mcp
Codex CLI、Gemini CLI、Cursor、VS Code、Windsurf,以及可粘贴到 AGENTS.md、CLAUDE.md 或 .cursorrules 中的规则:
integrations/。该服务让检查随时可用;Agent 必须调用它并遵循结果。真正的关卡是 CI。
结果
| 结论 | 退出码 | |
|---|---|---|
clean | 0 | 每个标签都对应唯一的闭包,且被比较的标签数不少于未被比较的标签数 |
drift | 1 | 一个标签对应多个闭包 |
| 其他任何结论(列表) | 2 | 信息不足,无法判断;报告会说明原因 |
| 拒绝执行 | 2 | 原因会输出到 stderr |
退出码 0 只表示 clean。我们尝试过的输入都不会导致回溯,任何失败的退出码都不是 1。
- 标签 — 项目在每个标签处声明的版本:它可以来自构建文件、构建文件指向的模块,或者在版本号从标签派生时直接取标签本身
- 闭包 — 对决定输出的文件的
(path, git object id)计算 SHA-256 得到的值
报告还会说明有多少个标签无法比较。
选项
--would-tag | 为此提交打标签会复用某个标签吗? |
--tags 'py-*' | 只有这些标签是发布节点(单体仓库) |
--at commits | 在每次提交时发布 |
--closure 'src/**' | 哪些文件决定你的输出 |
--strict | 只有比较了每个节点时才判定为 clean |
--explain LABEL | drift 中某个标签下有哪些路径不同:扫描找到该标签的所有标签后,逐一执行 --compare |
--compare A B | 并排比较两个标签 |
--version-file、--version-regex | 标签所在位置 |
--published FILE | 仅使用你发布过的版本标签:本地列表,每行一个。--tags 按名称筛选;如果匹配的标签从未发布,请使用此选项 |
--label-equality version | 1.0、1.0.0 和 v1 是同一个标签 |
--modes | 文件模式(可执行位、符号链接)属于闭包的一部分 |
--component NAME | 单体仓库的一个组件(见下文) |
--json、--badge、--diagnose | 报告(规范)、README 徽章、错误报告块 |
可以将设置提交到 .closure-drift.json。标志会覆盖文件中的设置,但在有 components 时,必须同时指定 --component;格式错误的文件会被拒绝。
单体仓库会声明其组件,并为每个组件给出一个判定:
{"components": {
"python": {"tags": ["py-*"], "version_file": "py/pyproject.toml", "closure": ["py/**"]},
"rust": {"tags": ["rs-*"], "version_file": "rs/Cargo.toml", "closure": ["rs/**"]}
}}
只要有任何组件处于 drift,结果就是 drift;所有组件都为 clean 时,结果就是 clean;否则为 incomplete。组件必须明确声明,绝不猜测。在此类仓库中,门禁会将 --component NAME 传给 --would-tag。
发现结果
按默认设置,对拥有公开仓库的 PyPI 下载量最高的 100 个项目进行测量,使用 0.10.0 版本构建,输出与发行版相同的报告——规则和方法在首次运行前固定;没有针对任何仓库调整(完整表格和所有冲突):
| 仓库数 | |
|---|---|
clean | 59 |
drift | 29 |
incomplete — 比较的标签数量少于未比较的标签数量 | 4 |
| 未找到版本标签,或结论不明确 | 8 |
在已判定的 88 个项目中,有 29 个项目的版本标签对应两种不同的代码状态;在全部 100 个项目中也有 29 个。87 个标签均列在 collisions.tsv 中,每个相关标签占一行,可手动复现:
$ closure-drift --compare v2.16.0 v2.16.1 # in psf/requests
DIFFERS UNDER ONE LABEL: both declare 2.16.0, and the code differs in 2 path(s).
冲突并不代表对项目的判定。常见原因包括创建标签时未更新版本号、7.x 之类的分支标记,以及单体仓库中的标签系列。首次运行时,只有 63 个项目得到了判定;它在 HEAD 选定的一个文件中读取版本号。本次发行版因此会在每个项目实际存放版本号的位置查找标签。
六个参考仓库,使用 1.0.0 在每个标签处进行测量(tools/reference/):
| 仓库 | 判定 | drift 中的标签数 | 比较的标签数 |
|---|---|---|---|
pallets/click | drift | 66 个中有 3 个 | 71 个中有 71 个 |
psf/requests | drift | 140 个中有 3 个 | 162 个中有 145 个 |
pypa/packaging | clean | 50 个中有 0 个 | 53 个中有 50 个 |
encode/httpx | clean | 88 个中有 0 个 | 88 个中有 88 个 |
impress/impress.js | drift | 4 个中有 2 个 | 15 个中有 6 个 |
lodash/lodash | drift | 107 个中有 60 个 | 440 个中有 209 个 |
两种原因:发布时打了标签但未更新版本号(impress.js),以及多个标签系列共用一个版本文件(lodash)。这两个项目的管理都没有问题。--would-tag 可处理第一种情况,--tags 或组件可处理第二种情况。
局限
-
它不会运行你的代码,也不会为任何内容作出证明。
clean指的是依赖关系处理,而非可复现性。 -
除非使用
--modes,否则仅文件模式变更不会被检测到。 -
以下内容永远不会纳入闭包:文件夹
test/、tests/、spec/、docs/、vendor/、node_modules/、.git/;名称符合*_test.*、*.test.*、*.md的文件。 -
默认闭包 glob 是猜测值。请传入
--closure。 -
标签按原样比较:
1.0和1.0.0是两个标签,除非使用--label-equality version。 -
它读取 git,而非软件包索引:即使某个标签从未发布,也会被计入,除非
--published列出了已发布的标签。未列出的标签会被排除在分析之外,而不是显示为未发布。 -
标签的查找方式由一组规则决定,而非构建过程:
docs/LABELS.md。 -
在门禁中,请传入
--version-file或--published:如果恰好有一个标签与其一致,这些规则仍可能读取错误的文件。
目前发现的所有故障:docs/FAILURES.md。更多信息:SCOPE.md、
docs/WHY.md。
安全性
它会启动 git,并且只会启动你提供的版本模式本身;不会启动其他任何内容。它绝不会写入代码仓库,也不会运行该代码仓库的 git 配置中指定的命令。防护范围与不防护范围:
THREAT_MODEL.md。安全报告:SECURITY.md。
测试
四个测试套件,均在编写代码前预先注册。分数不会相加。
| 套件 | macOS,Python 3.14 |
|---|---|
tests/battery.py — 验收证明 | 声明 216 项 · 215 项通过 · 0 项失败 · 1 项未运行 |
tests/negative_controls.py — 电池测试必须能在检测器损坏时失败 | 60 个变异 · 捕获 60 个 · 漏掉 0 个 |
tests/adversarial.py — 由未参与修复工作的审查者编写 | 315 项攻击 · 304 项符合要求 · 2 项宽松 · 9 项未运行;这 2 项宽松情况属于已声明的局限(docs/FAILURES.md O2a、O2b) |
tests/properties.py — 针对 tests/oracle.py 测试 60 个生成的代码仓库;该第二套实现根据规范编写,编写者未读过本实现 | 17 项属性 · 17 项通过 · 0 项失败 · 10 项控制 · 捕获 10 项 |
CI 会在 Linux(Python 3.9 至 3.14)、macOS 和 Windows 上运行相同的四个套件;各平台无法运行的测试记录在
tests/RECORD.md。这些分数描述的是已执行的测试用例,而非无人尝试过的输入。./reproduce.sh 会运行全部测试。研究中的 100 个项目也作为回归语料库,固定到记录的提交:1.2.0 在全部 100 个项目上都给出了记录中的答案,与运行前的预测一致
(tools/regression/)。
十个大型代码仓库(Linux 内核、LLVM、CPython 及另外七个),先编写协议。内核的每个标签:使用 0.9.1 时,整个进程树占用 6.5 GB;使用 1.0.0 时,占用 900 MB。0.9.1 在这些仓库中给出了三个错误或近乎空白的答案;1.0.0 不再出现这些问题。指定版本文件(docs/LABELS.md)后,会比较内核的每个标签:947 个中 947 个均为 clean,耗时四分钟。
tools/benchmark/READING.md。
提交结果
RESULTS.md 用于记录由作者以外的人进行的测量。目前仍为空。运行 closure-drift --json > result.json,然后
提交 issue
或发送邮件至 [email protected]。能证明工具有误的结果最有价值。
版本
1.2.0 会读取下一级目录中的版本文件,说明脏工作树是否涉及闭包,并在输出目标不是终端时写入 UTF-8(来自 issue #12)。
1.1.3 说明何时使用 --explain 或 --compare,以及 --tags 或 --published;检测器未变。
1.1.2 可以通过一行命令安装 MCP 服务(closure-drift-mcp);检测器未变。
1.1.1 修复了 O11:超过大小限制(1 MB)的版本文件会被明确指出,结果为
incomplete,绝不会是 clean。它还为 AI Agent 添加了 integrations/。
1.1.0(脚本 sha256 e2a67a5a9148fe273d2c95ad4bcce9bc589a43711fc34443a4a9491fde575913)
新增 --published、三种版本来源、更早的拒绝判断和两种选择加入的比较方式;不使用新选项时,只有在 --at commits 下结果才会变化(CHANGELOG.md)。它必须满足的要求先行编写(tests/PREREGISTRATION.md §16),而一位未参与编写的审阅者在发布标签前发现了 13 个问题(docs/REVIEW-1.1.0.md)。
SCOPE.md 说明了在 2.0 之前保持稳定的内容。
Apache-2.0。引用版本 DOI,概念 DOI 为 10.5281/zenodo.21763931
(CITATION.cff)。计划中的下一步:ROADMAP.md。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。