Comby 结构化改写:从模板命中到可回滚批改
一次正则替换为什么把字符串也改了
一个前端仓库要把 legacyFetch(url, options) 迁移成 http.request(url, options)。维护者先用正则捕获括号里的参数,简单调用都成功了;遇到嵌套的 buildUrl(base, path)、字符串里的右括号和注释后,匹配边界开始漂移。更糟的是,测试夹具里也有一段同形文本,替换数量看起来正确,代码语义却已经被误伤。
Comby 解决的是正则与完整编译器之间的那一段距离:它用语言 matcher 识别括号、方括号、花括号、字符串和注释等通用语法边界,再用 hole 捕获模板中的可变部分。它能比纯文本替换更稳地跨越格式与嵌套结构,却不知道某个名字最终解析到哪个符号,也不拥有完整 AST 或类型归因。可靠使用方式因此不是“一条模板改全仓”,而是先冻结语料、预览 diff、用反例收紧模板,最后才允许写盘。
先判断稳定发行是否满足组织要求
Comby 源码采用 Apache-2.0 许可证,仓库没有归档,并已有面向 OCaml 5 与 v2 的后续提交;但可下载的最新正式发行仍是 1.8.1。这意味着“源码仓库仍有活动”不能替代稳定版升级、漏洞响应和平台制品承诺。生产采用时应把 1.8.1 作为明确基线,由团队承担镜像重建、依赖漏洞、平台兼容和替代方案评估;无法承担这份所有权时,优先选仍持续发布且有对应语言语义的工具。
官方发布资产只提供 x86_64 Linux 二进制;macOS 可使用 Homebrew,Windows 官方路径是 WSL 中的 Ubuntu,另有 Docker 镜像。没有官方 Windows 原生可执行文件,也不能从“支持大量语言”推导出所有宿主平台都有同等制品。版本、资产与许可证可分别核对 Release 1.8.1、官方仓库 与 Apache-2.0 License。
在 Windows 主机上选定唯一执行环境
不要让 Windows 原生未知构建、WSL 二进制和容器镜像轮流修改同一工作树;团队应选定一种执行环境,并记录二进制版本、安装来源与路径语义。
首次使用 WSL 时,在管理员 PowerShell 安装 Ubuntu,重启并完成 Linux 用户初始化:
wsl --install -d Ubuntu
wsl --status
wsl -d Ubuntu官方 Get Started 仍给出网络脚本入口,但该脚本当前把 RELEASE_VERSION 固定为 1.7.0,会安装早于最新正式发行的版本。不要因为域名是官方入口就跳过内容与版本检查。x86_64 WSL/Ubuntu 更可控的做法是直接取固定发布资产,把组织批准的摘要作为门禁:
curl -fL \
-o /tmp/comby-1.8.1-x86_64-linux \
https://github.com/comby-tools/comby/releases/download/1.8.1/comby-1.8.1-x86_64-linux
echo '<approved-sha256> /tmp/comby-1.8.1-x86_64-linux' | sha256sum -c -
sudo install -m 0755 /tmp/comby-1.8.1-x86_64-linux /usr/local/bin/comby
command -v comby
comby -version<approved-sha256> 必须来自组织首次引入时的独立核验与内部制品记录,不能用“下载后现场计算”的摘要自证同一份文件;官方 release 没有附带独立 checksum 资产,这是供应链门禁需要补齐的责任。预期 command -v 返回 WSL 内的可执行文件路径,版本输出为 1.8.1。企业网络若拦截下载,应把经过摘要校验的发布制品放进内部工具仓库,而不是长期依赖个人代理。源码最好位于 WSL Linux 文件系统;从 /mnt/c 扫描大型仓库会跨文件系统,性能、权限位和大小写行为都可能与 CI 不同。
Docker 不在主机安装 Comby,只拉取镜像并把标准输入交给一次性容器做验证:
docker pull comby/comby
docker image inspect comby/comby --format '{{index .RepoDigests 0}}'
'swap(x, y)' | docker run --rm -i comby/comby `
'swap(:[left], :[right])' 'swap(:[right], :[left])' -stdin .js预期输出是一段把 swap(x, y) 变成 swap(y, x) 的 diff。首次验证后应把 RepoDigests 中批准的 comby/comby@sha256:... 写入自动化,并在镜像变更时重跑后文样例;浮动标签适合探索,不适合让批量变更可复现。镜像平台清单也要在目标 runner 上预检,不能假设每个 CPU 架构都有同一 image manifest。
matcher 与 hole 怎样决定边界
模板 legacyFetch(:[args]) 中,固定文本必须按字面出现,:[args] 是具名 hole。位于成对分隔符内部时,hole 可以跨行,并在 matcher 理解的字符串、注释和嵌套分隔符中寻找平衡边界。因此下面三种格式可以由同一个模板处理:
legacyFetch(url, options)
legacyFetch(buildUrl(base, path), options)
legacyFetch(
buildUrl(base, path),
options
)matcher 由文件扩展名推断,也可以显式指定。指定 .js 的意义不是启用 JavaScript 类型检查,而是选择一套能识别该语言注释、字符串和分隔符的通用语法定义。未知 DSL 可退到 generic matcher 或自定义 matcher;一旦语义依赖 import、重载、继承或调用目标,就应换用带符号或类型归因的工具。
hole 有几种常用形态。:[name] 懒惰捕获零个或多个字符;:[[name]] 捕获标识符形态;:[name:e] 捕获表达式形态;:[name~regex] 用 PCRE 约束内容。正则 hole 若使用能吞掉 ) 的贪婪表达式,会破坏 matcher 原本维护的平衡边界。先让结构模板提取内容,再用规则约束,通常比在 hole 内塞 .* 更容易解释。
引号属于两层语法。Bash、Zsh 和 PowerShell 中优先用单引号包住命令行模板,避免 shell 处理特殊字符;模板要匹配字符串字面量时,把目标语言的双引号留在单引号内部,例如 'log(":[value]")'。TOML 中,单引号是字面字符串,适合含双引号的 where;多行模板使用 '''...''',不会把反斜杠当转义。改成 TOML 双引号后,反斜杠和内部双引号必须按 TOML 规则转义,不能把 shell 中可运行的引号原样搬进配置。
正向实验:先看 diff,再允许写盘
在 WSL、Linux 或 macOS 的临时目录创建一个独立 Git 仓库:
mkdir comby-lab && cd comby-lab
git init -q
mkdir -p src fixtures
cat > src/client.js <<'EOF'
export function load(base, path, options) {
return legacyFetch(buildUrl(base, path), options);
}
EOF
cat > fixtures/example.js <<'EOF'
export const documentedResult = legacyFetch(url, options);
EOF
git add . && git commit -m 'baseline'先只处理 src,不加 -i:
comby 'legacyFetch(:[args])' 'http.request(:[args])' \
-matcher .js -d src
git status --short预期终端显示 src/client.js 的候选 diff,嵌套的 buildUrl(base, path) 完整保留;git status --short 仍为空。这就是 Comby 的 dry run:默认输出候选,只有显式 -i 才原地改文件。
候选较少时还可以加 -review 逐项查看并接受、拒绝或编辑;接受的项目会写回文件,所以它是交互写盘,不是只读 dry run。要生成供代码评审系统消费的统一补丁,使用 -diff 并把输出保存为受限制品,不要用终端彩色输出冒充稳定 patch。
审查候选后执行写盘,再运行项目门禁:
comby 'legacyFetch(:[args])' 'http.request(:[args])' \
-matcher .js -d src -i
git diff --check
git diff -- src/client.js预期只有调用名变化,参数文本与格式保持。真实项目继续执行格式化、类型检查、单元测试和构建;Comby 退出成功只能证明模板执行完成,不能证明新 API 的参数合同与旧 API 等价。
用 where 阻断一个可稳定复现的误命中
现在把搜索根放大到实验仓库:
git restore .
comby 'legacyFetch(:[args])' 'http.request(:[args])' \
-matcher .js -d .预期候选同时包含 src 中的真实调用和 fixture 中的示例调用。.js matcher 会把字符串和注释当作受保护语法区,不会把其中的同形文本当作代码调用;但 fixture 里的真实代码仍与业务调用同形,模板本身无法根据目录用途或符号身份区分两者。
把规则写进 comby.toml,要求参数不能与样例中的固定文本完全相等:
[10-upgrade-call]
match = 'legacyFetch(:[args])'
rewrite = 'http.request(:[args])'
rule = 'where :[args] != "url, options"'comby -config comby.toml -f .js -d .预期只剩 src/client.js。where 比较的是 hole 捕获到的语法文本,不会做类型判断;这里的双引号属于 Comby rule,外层单引号属于 TOML。若真实业务代码恰好写成 legacyFetch(url, options),这个规则也会把它排除,形成漏改。正确动作是用更有区分力的上下文,例如限定到 return legacyFetch(...),或先把 fixtures 从受控文件清单排除,而不是继续追加越来越神秘的文本黑名单。
反向实验再加入一个同名本地函数:
function legacyFetch(value) {
return value;
}
export const localResult = legacyFetch("local");模板仍会命中本地函数调用。这个失败证据说明,Comby 不解析符号身份。重构结论依赖“只改导入自某个包的函数”时,应使用 LSP、AST 或带类型归因的 codemod,或者先用编译器级查询冻结准确候选,再让 Comby 处理已经人工确认的文件集合。
TOML 多规则按名字排序,不按作者直觉排序
配置文件中每个 section 包含 match、rewrite 和可选 rule。Comby 按 pattern 名称的字典序运行多条规则,因此名称就是执行计划的一部分。使用固定宽度数字前缀,避免 step-10 排在 step-2 前面。
[10-upgrade-call]
match = 'legacyFetch(:[args])'
rewrite = 'http.request(:[args])'
rule = 'where :[args] != "url, options"'
[20-add-client]
match = 'http.request(:[args])'
rewrite = 'client.http.request(:[args])'第一条先制造第二条的输入。预览时应看到最终结果 client.http.request(...)。把 section 改名为 [90-upgrade-call] 与 [20-add-client] 后重新预览,第二条先运行时看不到 http.request,最终只得到 http.request(...)。这不是随机行为,而是规则顺序改变了中间状态。
多配置文件可以用逗号传给 -config,所有 section 名仍必须全局唯一。项目应把顺序、matcher、文件过滤和排除项一起纳入评审;不要依赖目录枚举顺序,也不要把会互相消费输出的规则拆成无人知道先后的脚本。
配置模式必须显式给 -f,例如 -f .js;官方文档说明,省略它时 Comby 可能把后续位置参数误当作命令行模板。-f .js 只定义扩展名集合,-d src 才限定扫描根,-exclude-dir fixtures 等排除条件再从根中减去目录。三者要一起进入脚本和评审记录。不要用 find | xargs 拼普通换行文件名扩大文件集;确需外部枚举时使用 NUL 分隔,并先保存、审查最终文件清单。文件数、总字节和候选数都应来自这份确定清单,而不是从全仓目录大小猜测。
项目接入:规则仓库与业务仓库分开承担责任
小型改写可把 tools/comby/comby.toml、正反 fixture 和执行脚本放在业务仓库。脚本先确认 Git 根与干净基线,再运行 dry run;写盘动作由操作者显式传入,不能默认开启。
#!/usr/bin/env bash
set -euo pipefail
root=$(git rev-parse --show-toplevel)
cd "$root"
test -z "$(git status --porcelain)" || {
echo '工作树非空,拒绝批量改写' >&2
exit 2
}
mode=${1:-preview}
args=(-config tools/comby/comby.toml -f .js -d src)
if [ "$mode" = apply ]; then
args+=(-i)
elif [ "$mode" != preview ]; then
echo 'usage: tools/run-comby [preview|apply]' >&2
exit 2
fi
comby "${args[@]}"CI 适合运行 fixture 与候选检测,不适合在受保护分支上原地提交。可先运行 preview,把 diff 作为受限构建制品交给评审;确认迁移窗口后在临时分支执行 apply,再跑仓库既有格式化、编译、测试与静态检查。规则跨多个仓库复用时,将规则包固定到不可变提交或制品摘要,每个仓库仍保留自己的文件清单、排除项和验证命令。
模板边界、失败证据与排障顺序
零命中首先检查文件有没有进入扫描:根目录、-f 后缀、排除目录、容器挂载路径和文件权限都可能让 matcher 根本看不到目标。随后检查 matcher 是否与扩展名和语言结构一致,再检查固定模板与 hole。不要一看到零命中就把 matcher 换成 generic 或把 hole 改成贪婪正则,那会同时扩大误命中面。
候选边界在嵌套括号、字符串或注释附近断裂时,保存最小失败文件和当前 matcher;若 custom matcher 没有正确描述字符串、注释或分隔符,修正语言定义并加入 fixture。输出包含定义、字符串或不同符号身份时,说明模板上下文或工具精度不足。规则语法失败、TOML 引号错误和 section 重名应作为执行失败处理,不能记成“没有需要修改的文件”。
写盘后编译失败时,先保留 git diff 和失败日志,再判断是模板漏掉 import、参数顺序不兼容,还是生成代码需要重跑。重复执行仍产生 diff,通常意味着规则互相震荡、rewrite 再次匹配自身,或格式化器与模板来回改格式;在幂等问题解决前禁止扩大批次。
幂等要在与正式批改相同的规则顺序、文件集和 formatter 下验证。隔离分支第一次写盘并格式化后记录 patch,再原样执行第二次:
comby -config tools/comby/comby.toml -f .js -d src -i
npm run format
git diff --binary | sha256sum > /tmp/comby-first.sha256
comby -config tools/comby/comby.toml -f .js -d src -i
npm run format
git diff --binary | sha256sum > /tmp/comby-second.sha256
cmp /tmp/comby-first.sha256 /tmp/comby-second.sha256cmp 成功只说明第二轮没有继续改变 patch。它不能发现第一次就误改的本地同名函数,所以仍要把文件清单、正反样本、候选 diff、类型检查和业务测试并列为准入证据。若两份摘要不同,先把 TOML section 逐条运行,找出自匹配或规则振荡,再检查 formatter 是否把输出改回下一轮可命中的形态。
回滚不是删除一条命令
本地实验可先用 git diff 确认只含预期文件,再执行 git restore --worktree -- src fixtures 回到基线;若改动已提交,优先创建反向提交而不是重写共享历史。多规则迁移回滚要按副作用倒序处理:先停止继续写盘,撤销代码 diff,再恢复生成物与锁文件,重新执行旧版本构建和测试,最后删除临时分支与受限 diff 制品。
容器清理只删除已确认的镜像引用:
docker image ls comby/comby
docker image rm comby/combyWSL 中通过安装脚本落地的文件位置应先用 command -v comby 确认,再按组织软件清理流程移除;不要复制一条猜测路径的 rm -rf。同时删除临时安装脚本、撤销代理凭证并清理 shell 历史中可能出现的敏感参数。
权限、敏感数据、容量与长期成本
Comby 本地执行只需要源码读权限;-i 额外需要目标文件写权限。它不需要仓库 push token、云账号或生产凭证。容器预览使用只读挂载 -v "$PWD:/work:ro",正式写盘才切换为可写;CI 的预览 job 与提交 job 使用不同身份,后者仍受分支保护和代码评审约束。
模板、diff 和日志会暴露源码片段、文件路径与内部 API 名称。扫描根不得包含 .env、私钥、生产配置、依赖缓存或相邻工作区;候选制品应设置访问控制和保留期。安装脚本、容器 registry 与私有依赖代理所需凭证通过 secret store 注入,不能写进 TOML、命令历史或示例 fixture。
容量主要消耗 CPU、内存、磁盘遍历和评审时间。先记录受控文件数、总字节、候选文件数、diff 行数、执行耗时和峰值内存,再决定是否按模块分批。-jobs n 可以增加并行进程数,但会同步放大内存、文件系统读取和 runner 争用,应从小并发逐级测量,不按 CPU 核数直接拉满。深层嵌套、过宽模板、复杂正则 hole 与多规则串联也会放大成本。生产门禁使用趋势和不变量:同一提交与同一规则重复预览结果稳定,写盘后再次运行没有新增 diff,候选集不会因个人配置或浮动镜像漂移。
长期治理需要规则 owner、版本基线、matcher 选择、fixture、允许目录、排除目录、规则顺序、验证命令与退出方案。升级 Comby 或自定义 matcher 时,重跑嵌套结构、字符串同形、本地同名函数、TOML 顺序和幂等样例。只要变更开始依赖符号身份、跨文件数据流或类型可赋值关系,就应停止扩充 where 字符串条件,升级到更高语义层级的工具。
Comby 最适合“结构边界重要、类型身份不重要”的批改:API 外形迁移、配置 DSL 调整、重复样板收敛和跨格式模板改写。它比正则更理解通用语法,比完整编译器更轻;这两点同时成立,才是它在重构工具链中的准确位置。命令、hole 与配置的细节可继续查阅 Comby Syntax Reference 和 Configuration Files。
