ast-grep:用 Tree-sitter 把结构搜索变成幂等重构
正则漏掉换行版本,文本替换又改坏了同名函数
一次 HTTP 客户端迁移看似只是把 legacyFetch(url) 改成 httpClient.get(url)。团队先用正则批量替换,单行调用全部修改成功,换行调用却漏了;补上多行正则后,测试辅助函数里一个同名局部参数也被改写。两次结果数量都“符合预期”,编译却说明候选集既有漏命中,也有误命中。
ast-grep 把源码交给 Tree-sitter parser,匹配的是语法节点而不是字符排列,所以空格、注释和换行通常不会破坏同一结构。但结构相同不等于符号身份相同:它知道这是一次 call_expression,并不知道被调用函数究竟来自哪个模块。可靠用法不是拿 AST 代替编译器,而是用结构规则稳定地产生候选和 patch,再用反向样本、类型检查、测试与重复执行证明改动可接受。
从 parser、pattern 到 patch,中间发生了什么
ast-grep run 接收一条临时 pattern,在指定路径中解析源文件并搜索节点;带 --rewrite 时,它用匹配到的 metavariable 生成替换文本。ast-grep scan 读取项目根的 sgconfig.yml,发现规则目录,再把规则的 rule、constraints、files、ignores、severity 与 fix 应用到代码库。ast-grep test 则把规则和正反样本绑定,防止规则演化时静默扩大或缩小候选集。
Tree-sitter 的优势是容错、速度和统一节点接口。pattern 本身也会用目标语言 parser 解析,$URL 匹配单个语法节点,$$$ARGS 匹配零个或多个节点。匹配成功后,ast-grep 保存目标节点的源码范围和 metavariable 文本;fix 只替换这个范围,不会像 IDE rename 那样自动更新所有引用,也不会证明新调用在类型上合法。
这条边界解释了两个常见现象。第一,同一调用无论写成一行还是三行都能命中,因为它们的语法形状相同。第二,导入函数、局部变量和对象方法只要形成相同节点形状,也可能同时命中;没有 classpath、模块解析或类型归因时,零误伤必须由规则约束和后续编译共同建立。
安装时固定发行物,命令统一写 ast-grep
团队可复现实验基线固定为稳定发行线 0.44.1;该版本及源码采用 MIT 许可证。官方同时提供 npm、pip、Cargo、cargo-binstall、Homebrew、Scoop、mise、MacPorts 与 Nix 入口,0.44.1 的发布页还提供 Windows、Linux、macOS 的 x86_64 构建,以及这三个平台的 ARM64 构建。包管理器“能安装”与项目“已验证”是两件事:团队项目更适合把 CLI 固定为开发依赖,让本地与 CI 消费同一版本:
npm install --save-dev --save-exact @ast-grep/cli@0.44.1
npx ast-grep --version
npx ast-grep --help--save-exact 避免 semver 范围在不同安装时间拉到不同 parser 或规则行为,并应与 lockfile 一起提交。使用 Cargo 时可执行 cargo install ast-grep --locked --version 0.44.1;Homebrew 入口是 brew install ast-grep。0.44.1 把 Tree-sitter runtime 更新到 0.26.10,但各语言 grammar 仍有自己的版本与节点命名;runtime 相同不代表所有语言树形稳定。升级到后续版本前先在 fixture 和代表性仓库上比较节点命中与 patch;不同渠道不要在同一 CI 镜像里混装,否则 PATH 先命中哪一份会变成隐式状态。版本、平台资产和许可证分别以 Release 0.44.1、Quick Start 与 MIT License 为准。
安装后的官方二进制名是 ast-grep,部分平台也提供 sg。Linux 常有同名的系统 sg 命令用于 setgroups,因此脚本、文档和 CI 统一使用完整命令 ast-grep。执行 sg --help 看到用户组说明时,不是 ast-grep 安装损坏,而是命令冲突。
当前 CLI 没有 --list-languages 选项。先用完整子命令帮助确认参数,再用最小源码探针验证目标语言 parser 确实可用:
npx ast-grep run --help
printf 'const probe = legacyFetch(url);\n' | \
npx ast-grep run --stdin --lang ts --pattern 'legacyFetch($URL)'预期探针返回 legacyFetch(url) 命中;未知语言会直接报错,而不是给出可信的零命中。支持语言跟随发行物与 parser 变化,项目基线以这类固定探针和 Language List 为准。自定义 Tree-sitter 动态库还会引入平台 ABI 与本地代码供应链风险,不能因为扩展名能注册就视为跨平台可用。
用 run 看见结构匹配的第一份证据
建立一个隔离目录:
ast-grep-lab/
fixtures/
positive.ts
negative.tsfixtures/positive.ts 故意使用三种格式:
const first = legacyFetch(url);
const second = legacyFetch(
buildUrl(accountId),
);
const third = legacyFetch( fallbackUrl );fixtures/negative.ts 放入相似文本但不同结构:
const name = "legacyFetch(url)";
const legacyFetchCount = 3;
const result = client.legacyFetch(url);
const parserEdge = legacyFetch /* keep this comment */ (fallbackUrl);在目录根执行:
npx ast-grep run \
--pattern 'legacyFetch($URL)' \
--lang ts \
fixtures单引号阻止 shell 展开 $URL。预期 positive.ts 的三次直接调用全部命中,字符串、变量名与成员调用不命中。parserEdge 也是直接调用,但当前 parser/pattern 不会返回它;注释虽然是 trivia,出现位置仍会影响这条简写 pattern 的结果,因此该变体要作为 missing 样本保留。实验说明结构匹配能跨越普通空白和换行,却不是“排版永远无关”,更没有证明三次命中属于同一个符号。
把 pattern 改成 '$CLIENT.legacyFetch($URL)' 再执行,预期只命中 client.legacyFetch(url)。$CLIENT 与 $URL 各绑定一个节点,打印结果仍保留原源码文本。若命中为零,先检查 --lang 与文件语法;错误语言可能把 pattern 或源码解析成不同节点,语法严重损坏也可能让目标落入 ERROR 节点。
要预览改写,用交互模式而不是直接全量写入:
npx ast-grep run \
--pattern 'legacyFetch($URL)' \
--rewrite 'httpClient.get($URL)' \
--interactive \
--lang ts \
fixtures/positive.ts--interactive 逐个展示 patch,接受后才写文件;--update-all 或 -U 会不经确认应用全部 rewrite,适合已经通过规则测试的自动化,而不是探索阶段。改写后预期三个调用变成 httpClient.get(...),参数表达式保持不变。注释位置可能因替换范围与 formatter 发生变化,所以还要检查 diff,不能只看匹配数。
sgconfig.yml 把个人命令升级为项目契约
临时 pattern 适合探索,团队规则需要版本化目录:
ast-grep-lab/
sgconfig.yml
rules/
legacy-fetch.yml
rule-tests/
legacy-fetch-test.yml
src/
services/
checkout.ts
compat/
shim.ts根配置如下:
ruleDirs:
- rules
testConfigs:
- testDir: rule-tests
snapshotDir: __snapshots__ruleDirs 与 testDir 都相对 sgconfig.yml 解析。scan 从当前目录向上寻找根配置,也可用 --config path/to/sgconfig.yml 显式指定;找不到配置时 scan 会报错,而 run 不要求项目配置。snapshotDir 保存诊断输出基线,适合审查 message、标签和范围变化。完整字段见 sgconfig.yml reference。
仓库根运行 ast-grep new 可以交互生成配置与规则骨架。成熟项目更适合直接审查这些文件,因为脚手架默认值仍需结合源码目录、语言与 CI 约束修改。
一条可修复规则怎样影响候选与退出码
rules/legacy-fetch.yml:
id: legacy-fetch-to-http-client
language: TypeScript
severity: error
message: Replace legacyFetch with the injected HTTP client
note: The caller must provide an httpClient binding before applying this fix.
files:
- src/**/*.ts
ignores:
- src/compat/**
rule:
pattern: legacyFetch($URL)
fix: httpClient.get($URL)id 是测试、过滤、抑制与 CI 输出使用的稳定身份,改名相当于新增一条规则。language 决定 parser 和默认扩展名;files 只允许 src/**/*.ts,ignores 再从中排除兼容层。路径相对项目根书写,前面不要加 ./。这些 glob 是规则级过滤,和 CLI 默认遵守 .gitignore、.ignore 及隐藏文件是两层机制。
severity: error 让命中成为非零扫描结果,适合在迁移冻结期阻止新增旧调用。message 出现在诊断里,note 说明应用 fix 之前的代码约束。rule.pattern 产生候选,fix 使用同一个 $URL 生成替换。fix 是源码文本 patch,不会自动添加 httpClient import 或参数;项目必须通过现有依赖注入方式先提供该绑定。
执行只读扫描:
npx ast-grep scan
echo "scan_exit=$?"若 src/services/checkout.ts 含旧调用,预期输出规则 ID、文件与源码范围,并因 error 规则返回非零。src/compat/shim.ts 中相同调用应被忽略。若两处都没报告,依次检查当前工作目录、配置发现、文件扩展名、files/ignores 与 .gitignore;不要立刻加 --no-ignore,它可能把依赖、构建产物和秘密文件带入扫描。
单独调试一个规则可绕过项目规则发现:
npx ast-grep scan --rule rules/legacy-fetch.yml src--rule 与 --config 冲突,因为前者明确只运行一个规则。规则库很大时,可用 --filter '<rule-id-regex>' 从项目配置中选一组规则。CI 仍应执行完整集合,避免开发者只验证了当前规则却破坏共享 utility 或 parser 配置。
正向样本先固定该命中的不同写法
rule-tests/legacy-fetch-test.yml 先写成功样本:
id: legacy-fetch-to-http-client
valid:
- httpClient.get(url)
- client.legacyFetch(url)
- const text = "legacyFetch(url)"
invalid:
- legacyFetch(url)
- |
legacyFetch(
buildUrl(accountId),
)在根目录运行:
npx ast-grep test --skip-snapshot-tests预期看到一条规则通过、零失败。valid 表示不应报告,invalid 表示必须报告;名字描述的是代码是否符合规则,而不是 fixture 文件能否编译。首轮用 --skip-snapshot-tests 只验证命中集合,规则稳定后再生成并提交快照:
npx ast-grep test --update-all
npx ast-grep test--update-all 在 test 子命令中更新所有快照,在 scan/run 子命令中却表示应用所有修复;同名选项的副作用随子命令变化,自动化脚本必须把完整命令写清楚。后续诊断范围变化时,普通 test 会失败;维护者审查 diff 后才可再次更新快照,不能把 --update-all 放进每次 CI 来自动接受变化。
现在把 legacyFetch /* keep this comment */ (fallbackUrl) 加进 invalid,再运行跳过快照的测试。0.44.1 基线下,这条简写 pattern 会漏掉该调用,测试应把它标成 missing。这个失败不是删掉样本的理由,而是提醒维护者选择:用 pattern.context/selector 或节点关系规则修正后让主测试集恢复全绿;若业务明确接受盲区,则把样本与原因留在规则旁的限制记录,并用独立回归脚本显式断言当前行为。ast-grep 测试没有通用的 xfail 标记,不能把必败项混进 CI 主测试后再宣称规则健康。
反向样本揭示 Tree-sitter 无法回答的符号身份
从全绿基线出发,把下面一项加入 valid:
- |
function adapter(legacyFetch: (url: string) => string) {
return legacyFetch(url)
}再次执行:
npx ast-grep test --skip-snapshot-tests预期测试失败,并把该样本标记为 noisy:规则在“本应不报告”的代码中找到了调用。它稳定复现了同形异义问题。这里的 legacyFetch 是局部参数,不是准备迁移的旧模块导入;两者在局部语法树上都是标识符调用,Tree-sitter 不会构建跨文件模块图或 TypeScript 类型身份。
最简单的补救是只对已经证明绑定一致的目录运行规则,例如迁移清单中的服务目录,并把兼容层、fixture、生成代码排除。若调用者都必须有特定 import,可以用关系规则把候选限制在含该 import 的文件,但它仍需处理别名、遮蔽、re-export 和条件生成。候选一旦跨越这些边界,应切换 TypeScript compiler API、ts-morph、语言服务器 rename 或其他具备符号解析的工具,而不是不断堆 AST 近似条件。
反向样本应该保留在规则库里,即使最终决定通过路径排除绕开。它记录了规则无法证明的语义,也能阻止后续维护者误删保护条件。宏、反射、动态属性、重载、代码生成和不完整工程模型也遵循同一原则:局部结构命中只是候选,不是全局调用关系。
fix 能生成 patch,不替你证明 patch 合法
规则通过正反样本后,先在干净工作树上扫描,再交互应用:
git status --short
npx ast-grep scan --interactive
git diff --check
git diff -- srcscan --interactive 只展示带 fix 的命中供选择;没有 fix 的规则仍只报告。git diff --check 捕获空白错误,源码 diff 用于检查 import、注释、括号与格式。确认候选稳定后才允许:
npx ast-grep scan --update-all
npm run format
npm run typecheck
npm test顺序不能倒置。formatter 可能改变 ast-grep 的输出形态,类型检查能发现缺失绑定与签名不兼容,测试验证行为。只统计“改了多少处”无法发现参数求值、副作用顺序或错误处理协议改变。
fix 对缩进敏感:多行模板中的相对缩进会带入替换文本,而且 fix 字符串本身不会再交给 Tree-sitter 解析;ast-grep 只是把 metavariable 文本插入模板并替换目标范围,语法合法性仍由 formatter 和编译器证明。一个 fix 每次替换目标节点的一个连续源码范围;删除对象属性时若还要处理逗号,可使用 fix.template 配合 expandStart 或 expandEnd 扩展范围。扩展规则写错会吞掉相邻 trivia,必须给首项、中项、末项和唯一项分别建 fixture。详细语义见 Rewrite Code 与 Fix configuration。
幂等不是口号,要证明第二次执行不再改变 diff
这条迁移规则天然有一个好性质:fix 生成 httpClient.get($URL),不再满足 legacyFetch($URL)。在隔离分支执行第一次全量修复后,记录 diff 摘要,再执行第二次:
npx ast-grep scan --update-all
npm run format
first_patch_hash="$(git diff --binary | sha256sum)"
npx ast-grep scan --update-all
npm run format
second_patch_hash="$(git diff --binary | sha256sum)"
test "$first_patch_hash" = "$second_patch_hash"
npx ast-grep scan
npm run typecheck
npm test两次 patch hash 相同,说明第二轮没有继续改变工作树;最后一次只读 scan 应对该 error 规则零命中并退出 0。若 hash 变化,常见原因有三类:fix 仍能匹配自身输出,多个规则按顺序来回改写,或 formatter 与 fix 对同一文本反复调整。把每条规则单独执行并比较 diff,可以定位振荡来源。
幂等只证明变换达到固定点,不证明行为正确。错误地把局部参数调用改成 httpClient.get 也可能一次达到固定点,所以幂等门必须和 noisy/missing 样本、编译与测试并列。大型迁移还要保存误命中抽样、漏命中对照查询和业务验证,不以固定点替代语义验收。
接入真实项目时,把规则与依赖顺序一起提交
package.json 可以提供稳定入口:
{
"scripts": {
"ast:check": "ast-grep scan",
"ast:test": "ast-grep test",
"ast:fix": "ast-grep scan --interactive"
}
}日常开发运行 npm run ast:check,规则维护者运行 npm run ast:test,人工修复使用交互脚本。不要把 --update-all 藏进名字模糊的 lint,因为它会写文件。大迁移可新增显式的 codemod:apply,并要求干净工作树和专用分支。
若目标文件使用非标准扩展,sgconfig.yml 的 languageGlobs 可以重映射 parser,例如把特定 .ts 交给 TSX parser。这个字段优先于默认映射,改错会让整个文件族使用另一棵语法树,因此必须增加 parser 探针与规则测试。嵌入式语言和自定义 parser 的行为更复杂,先在代表性文件上验证,再扩大扫描面。
monorepo 有两种合理布局。统一语言与规则政策时,在仓库根维护一份配置,规则用 files 收窄 package;团队自治且 parser 版本不同,则每个 package 放独立配置,CI 用 --config 明确执行。不要依赖“向上寻找碰到哪份配置算哪份”,否则从根目录与子目录运行会得到不同结果。
CI 先做规则自测,再做只读扫描
一个最小 CI 作业可以写成:
steps:
- uses: actions/checkout@<approved-immutable-reference>
- uses: actions/setup-node@<approved-immutable-reference>
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run ast:test
- run: npm run ast:check
- run: npm run typecheck
- run: npm test规则测试在前,能先区分“规则自己坏了”和“业务代码出现违规”。扫描保持只读;CI 不运行 scan --update-all,否则工作区里的修改既没有进入提交,也可能污染后续缓存。需要产出机器可读结果时使用 CLI 当前支持的 JSON 输出模式,再由流水线转换为注释或制品;不要让完整源码片段无条件进入公开日志。
error 规则命中会让 scan 非零,warning、info 与 hint 的退出行为不同。迁移通常经历观察、阻止新增、集中修复、长期守卫四个阶段:先用 warning 统计基线和误报,再把已验证规则升为 error;存量修复完成后保留 error,防止旧写法回流。命令行 severity override 适合试验,正式策略应进入规则文件审查,避免不同 CI job 以不同等级解释同一规则。
CI 还应检查未使用的抑制注释和禁止无规则 ID 的全局抑制。抑制是代码的一部分,要写明规则 ID 与理由;规则退役后,陈旧抑制必须被清理,否则它可能掩盖后续复用同一位置的新诊断。
权限、凭证与敏感数据藏在“只读扫描”周围
ast-grep 本身不需要代码宿主 token,但 CI checkout、私有 npm registry 和制品上传需要。CLI 可以读取扫描路径内的源码并把命中片段写到终端或 JSON,所以日志访问权至少等同于仓库读取权。不要为了“全量”随意使用 --no-ignore;隐藏目录、.env、生成配置、vendored 源码和缓存可能因此进入候选与日志。
自动 fix 拥有工作树写权限。开发机上先检查 git status --short,CI 或批量 executor 使用一次性工作区和最小权限身份,只允许向专用分支提 PR,不直接推受保护分支。规则 YAML 不能执行任意 shell,但 npm 安装脚本、自定义 Tree-sitter 动态库、formatter 和迁移后测试都能执行代码;依赖固定 lockfile,CI 镜像固定 digest,自定义 parser 视为本地原生依赖做供应链评审。
规则命中可能揭示凭证字面量。ast-grep 不是秘密扫描器,也不应承担泄露判定;若规则用于普通重构,排除秘密目录并限制输出。若目标是安全漏洞或密钥,应进入专门 SAST/secret scanning 流程,使用对应的污点、数据流、误报与事件响应能力。
性能优化从减少无效语料开始
扫描成本主要受候选文件数、总字节、parser、规则数量、关系规则搜索范围和线程数影响。简单 pattern 通常很快;大量 inside、has、follows 关系、跨整棵树的 stopBy 与多份 parser 会增加节点遍历。--threads 可设置近似线程数,默认 0 由工具启发式选择。线程增多会争用 CPU、内存和文件系统,不保证线性加速。
先用 .gitignore、规则级 files/ignores 和 CI 路径过滤移除依赖、构建物、生成代码与无关 package,再调整线程。记录代表性基线:扫描文件数、总耗时、峰值内存、各规则命中数和 CI 队列时间。长期判断看趋势,例如代码量相近时扫描时长是否突增、某规则命中是否异常归零、parser 升级后 noisy/missing 是否变化,而不是复制一个脱离仓库规模的固定秒数。
大仓库可把快速禁止规则放在每次 PR,把高成本迁移盘点放在定时或按路径触发的作业;但合并门上的规则集合必须足以阻止旧写法回流。缓存 node_modules 能减少安装时间,不能缓存上次扫描结果冒充当前提交。parser、ast-grep 版本、sgconfig.yml 或规则变化时,所有 fixture 都要重跑。
故障先按解析、发现、匹配、修复四层分型
命令报“找不到 sgconfig.yml”,属于项目发现失败:确认工作目录或显式 --config。文件完全不出现,属于语料过滤:检查扩展名、.gitignore、隐藏属性、CLI 路径和规则 files/ignores。文件出现但 pattern 零命中,属于解析或结构差异:固定 --lang,在 playground 或最小 fixture 中查看实际节点形状。命中正确但 patch 破坏代码,属于 fix 范围、缩进或语义问题:回到交互 diff、formatter、编译和反向样本。
语法错误不会总让整个文件停止解析。Tree-sitter 可以产生包含 ERROR 节点的部分树,目标附近仍可能命中,也可能因为恢复路径不同而漏掉。把一份故意缺括号的 fixture 放进实验,记录规则是继续命中还是零命中;生产上以编译器错误阻断迁移,不把容错解析当成源码有效证明。
命中数突然归零也可能来自 parser 或 languageGlobs 变化。此时先执行 ast-grep test,再用固定单文件 run --lang 探针区分“规则行为变了”和“项目发现变了”。命中数突然暴涨则检查 files glob、ignore、生成目录和 metavariable 是否从 $ARG 放宽成 $$$ARGS。先分类再改规则,比在复杂 YAML 上盲加 not 更可靠。
回滚、清理与规则退役都要保留证据
交互或批量 fix 尚未提交时,用 Git 检查当前 diff并只恢复本次迁移涉及的文件;存在用户其他改动时,不使用整仓 reset。已经提交但未合并,关闭专用 PR 并删除临时分支即可;已经合并,则以反向 codemod 或代码宿主 revert 产生新的可审查提交,再运行 formatter、类型检查与测试。若迁移同时改了依赖、生成代码或配置,回滚也必须覆盖这些副作用。
规则完成使命后不要直接删除。先确认全仓 error 扫描零命中,移除或更新相关抑制,保留关键正反 fixture,再决定将规则降级为长期守卫还是退役。退役 PR 应同时删除 rule、test、snapshot、脚本入口和 CI 引用;残留 testConfigs 指向空目录虽然可能不阻断构建,却会制造虚假的保护感。
团队为每条规则维护 owner、用途、目标语言、批准版本、已知 noisy/missing、fix 是否安全、验证命令和退役信号。parser 或 CLI 升级先在规则库分支运行全部测试与代表性仓库扫描,比较命中集合和 patch hash,再升级 lockfile。这样 ast-grep 才从一次性的聪明命令,变成能解释候选、能拒绝误伤、能证明固定点并可安全退出的结构化重构工具。
