AST Visitor API 迁移,怎么证明 AI 没改坏 SQL 语义?

导读:AST Visitor API 迁移看起来像一类适合 AI 批量生成的工程任务:节点多、模式重复、样板代码占比高。但真正的风险不在"代码能不能写出来",而在"写完之后如何证明没有漏掉节点、重复访问字段或改变遍历顺序"。本文以一次 Visitor API 迁移为线索,介绍一套从源码扫描、运行时 trace、独立比较、失败定位,到真实 SQL 语料和覆盖率反馈逐渐形成的可执行验证体系。


问题不是迁移代码能不能写,而是如何证明它是对的

AST 的旧 visitor API 不太好用,我们希望换成一套新的 API。

单看工程实现,这件事并不算难。AST 节点虽然很多,但大部分迁移代码模式重复,适合批量生成,也适合由 AI 完成大量样板工作。

真正让人不放心的不是代码能不能写出来,而是写完以后怎样确认它没有静悄悄地改变语义。

在数据库内核里,AST Visitor 是一层基础设施。它可能服务于 parser、binder、optimizer、formatter、权限检查、表达式重写等多个下游环节。迁移一旦出现偏差,未必会马上表现为编译错误,更可能是某个节点不再被访问、某个字段多访问了一次,或者原本稳定的遍历顺序发生变化。代码表面上仍然合理,真正的影响却可能在更靠后的逻辑中暴露。

最初我们关心的风险主要有两类:

  • 漏项或重复访问:某个节点、字段或分支没有被遍历到,或者被意外遍历了多次。
  • 顺序变化:需要保持先后关系的 hook 改变了调用顺序。

这类问题很难只靠 code review 兜住。节点数量太多,人工很难在脑子里展开整棵 AST;大量生成代码又高度相似,review 很容易退化成对样板代码的走读。

因此,我们开始尝试建立一套可执行的验证机制,让正确性更多地由程序化证据约束,而不是依赖人逐行确认。

最终形成的系统大致如下:

这个闭环不是一次搭好的。为了讲清各部分的边界,下面先完整说明由源码扫描、运行时观测、独立比较和失败定位组成的内核,再说明真实输入和覆盖率反馈如何接入。


从源码采集 AST 结构

要让后面的验证可信,不是写一套更复杂的逻辑来描述 AST,而是尽量直接地从源码中提取结构。类型、variant 和字段已经写在源码里,采集工具只需要机械地把它们展开,作为后续代码生成的输入。

这里最重要的约束是少写逻辑。如果为了确认 visitor 有没有漏项,又手工实现一套节点分类、递归关系和特殊规则,那么只是把原来的审计问题搬进了验证工具:要相信验证结果,就得先完整审计这套同样复杂的逻辑。

因此,源码分析应该保持短而直接:沿用语法分析得到的类型定义,枚举 struct、enum、variant 和字段,让产出能够回到对应的源码位置。一个声明对应一条结构记录,必要的排除项显式列出,不在采集阶段推断遍历语义,也不复制 visitor 的特殊顺序。

这种做法的可信度来自映射关系足够简单。需要审计的是少量通用的源码提取规则,而不是另一套与 AST 同规模的手写模型。它提供的是一份可追溯、可审计的结构输入;visitor 实际怎样遍历这些结构,则交给运行时观测和后续比较回答。


把运行时观测和等价性判断分开

扫描源码以后,下一件事是把 visitor 的实际运行行为记录下来。这里最重要的设计,是把观测和比较分开,不要生成代码同时承担正确性判断。

  • 生成代码只负责产生 trace,忠实记录进入了哪个 hook、看到了哪个节点以及事件发生的次数和顺序。它不判断某个事件是否应该出现,也不在记录时排序、去重或过滤结果。
  • 等价性判断由另一套独立的比较器完成。比较器读取新旧 visitor 的 trace,再根据遍历契约判断访问项目、次数和关键顺序是否一致。

这两个部分的关系是:

这样,大量生成代码可以保持机械,正确性规则则集中在少量手写代码中。同一份原始 trace 也能继续用于覆盖率统计和失败定位。

这部分的原则可以概括为:

生成事实,手写规则;先完整观测,再独立比较。

这个分层很关键。AI 可以生成大量迁移代码,也可以生成一部分观测代码,但判断正确性的规则应该足够集中、明确、可审计


明确 Trace 应该怎样比较

观测层能够稳定地产生数据以后,下一个问题才真正具体起来:什么样的差异算 visitor 语义变化?

最初最容易想到的是比较"访问到了哪些项目"。这可以发现某个节点或 hook 在一边出现、另一边没有出现。

但单纯比较集合不够。集合会丢掉访问次数:一个节点访问一次和访问两次,在集合里看起来完全相同。因此,检查漏项和重复访问时,至少需要比较多重集,或者直接比较保留次数的事件记录。

顺序则是另一类约束。有些 visitor 逻辑只关心最终是否见过某些节点,顺序并不重要;另一些逻辑却依赖隐含的遍历顺序,例如:

  • 先见到定义,再处理引用;
  • 按进入顺序进行归一化;
  • 遇到第一个匹配项便提前退出;
  • 依靠 enter 和 exit 的嵌套关系维护状态。

所以比较器不能只有一种全局规则。它需要根据 hook 的语义分别检查:

  • 访问项目是否一致;
  • 访问次数是否一致;
  • 对顺序敏感的事件序列是否一致;
  • enter、exit、skip 和提前退出等控制行为是否一致。

这时,"等价"的定义才逐渐变得准确:不是要求新旧代码实现相同,而是要求它们在约定的可观察遍历行为上没有未解释差异。

旧 API 在这里是历史行为基线,但不必被假定为天然正确。如果新实现有意修复旧行为,或者新 API 明确改变了某项契约,这类差异应该显式记录,而不是为了让比较通过而被悄悄过滤。


让失败能够直接定位

比较器能够报出不一致以后,新的问题又出现了:只知道两份 trace 不相等,对修复迁移帮助有限。

如果失败信息只是一个布尔值,开发者仍然需要重新翻阅大量 visitor 代码,寻找究竟是哪一个节点或 hook 产生了偏差。这样虽然有了自动检查,定位成本却仍然很高。

因此,trace 和比较器继续增加了面向诊断的信息

  • 触发问题的输入或测试名称;
  • 缺失或重复的是哪个事件;
  • 哪个 hook 的顺序发生了变化;
  • 事件对应的 AST 节点路径;
  • 新旧 trace 第一次产生分歧的位置;
  • 差异前后的少量上下文。

这项工作看起来发生在比较器完成之后,实际上会反过来影响观测层。要报告 AST 路径,记录事件时就必须携带路径;要定位第一个顺序差异,就不能提前把 trace 排序;要发现重复访问,就不能在观测阶段去重。

这也是为什么观测层应该尽量保留事实,而把规范化和判断留给比较器。失败定位不是最终输出上的装饰,而是 trace 协议需要从一开始支持的能力。

到这里,最初的可用内核才算完整:源码扫描提供 AST 结构,观测层把实际遍历行为记录成 trace,比较层根据遍历契约判断新旧行为是否一致,差异报告则让失败能够直接进入修复。

只要给定一棵 AST,这个内核就已经能够运行。

后面的工作不再是在补齐这个内核的基本组成,而是在扩大它能处理的输入,并让验证边界持续可见。


接入真实 SQL 语料:让验证面对真实输入形状

有了可用内核,并不意味着验证已经充分。它只能比较实际送进去的 AST。

如果依靠手工构造 AST,很快会遇到两个现实问题:

  • AST 构造代码本身很重;
  • 人工样例容易只覆盖编写者刚好想到的结构。

因此,我们开始复用 parser 已有的 SQL 语料:

  1. 用现有 SQL 解析出 AST;
  2. 对同一棵 AST 运行旧 visitor;
  3. 运行新 visitor;
  4. 收集双方的 trace;
  5. 由独立比较器检查差异。

parser golden 是一个自然的输入来源。一条 SQL 往往能同时覆盖多个节点和嵌套关系,这些语料也是项目已经认可的真实输入形状。相比专门维护一批庞大的手写 AST,它们更接近 visitor 实际会遇到的数据。

但这里需要把结论说准确:这种方法证明的是"在当前 SQL 语料实际覆盖到的 AST 上,新旧遍历行为一致",而不是整个 AST 状态空间都已经被穷举。


用覆盖率暴露验证盲区:测试通过不等于空间已覆盖

运行时等价比较始终依赖一个前提:输入必须真正触达我们关心的 AST 分支。

最初复用 parser SQL 语料时,我们只能知道测试运行了,却不知道它究竟覆盖了多少手写 walk 分支。如果某个 enum variant、可选字段或特殊路径从未出现,那么新旧 visitor 即使在所有测试上完全一致,也不能说明那部分迁移得到了验证。

这时,覆盖率成为验证输入是否充分的反馈信号

  1. 用现有 SQL 语料运行新旧 visitor 的等价比较;
  2. 统计手写 walk 和相关分支的覆盖情况;
  3. 找出一直没有执行到的 AST 路径;
  4. 反推缺少什么样的 SQL;
  5. 补充相应语料;
  6. 再运行等价比较和覆盖率。

覆盖率在这里不负责证明代码正确 。它回答的是另一个问题:我们用来比较新旧行为的输入,实际触达了多少待验证空间。

这样就形成了两个相互配合但不能互相替代的证据:

  • trace 比较说明已触达输入上的行为是否一致;
  • 覆盖率说明还有哪些行为尚未被输入触达。

发现缺口后,能够由真实 SQL 表达的,优先补到 parser 上游,使语料同时服务 parser、AST 构造和 visitor;难以通过 SQL 稳定构造的边界状态,再用局部定向测试覆盖。


这不是五个步骤,而是一个持续反馈系统

回头看,这套系统包含源码扫描、运行时 trace、独立比较、失败定位、真实 SQL 输入和覆盖率反馈,但它们承担的角色并不相同。前四项构成基本可用的验证内核:给定一棵 AST,它们能够运行比较并把差异直接交给修复。真实 SQL 输入扩大了内核的适用范围,覆盖率则建立了继续扩展验证范围的反馈机制。

它们分别解决不同的问题:

  • 源码扫描约束 AST 结构是否被纳入考虑;
  • 观测层记录 visitor 实际做了什么;
  • 比较层定义什么叫行为等价;
  • SQL 语料提供真实 AST 输入;
  • 差异报告降低修复和解释成本;
  • 覆盖率暴露输入空间中的空白;
  • 新增语料继续扩大可验证范围。

这些部分在后续迁移中会同时工作,并彼此反馈:

Plain 复制代码
迁移代码变化 ─→ trace 差异 ─→ 修正迁移或记录有意变化

AST 定义变化 ─→ 生成结果变化 ─→ 暴露未处理结构

覆盖率缺口 ─→ 新增 SQL 语料 ─→ 扩大等价比较范围

定位信息不足 ─→ 扩展 trace 协议 ─→ 改善后续差异报告

因此,更准确的说法不是"按照五个步骤完成 visitor 迁移",而是 "围绕迁移不断补齐不同种类的证据,直到这些证据形成可以持续运行的闭环"


这套验证能证明什么?

最终,我们希望得到的结论包括:

  • 已知 AST 类型、variant 和字段都已被纳入分类;
  • 在当前语料触达的 AST 上,新旧 visitor 的访问项目和次数一致;
  • 对顺序敏感的 hook 没有出现未解释的顺序变化;
  • skip、递归和提前退出行为没有出现未解释差异;
  • 没有覆盖到的结构仍然可见,而不是被一次测试通过掩盖;
  • 有意变化和旧实现已有问题都有明确记录。

它仍然不是对整个 AST 状态空间的数学证明。 有限 SQL 语料上的 trace 比较,只能约束实际触达的输入;覆盖率可以暴露没有运行的路径,却不能单独证明已经运行的代码正确;生成器和比较器本身也仍然需要审计。

因此,把它称为 "有明确覆盖边界的可执行等价性验证" 更加准确。它不承诺一次性证明一切,而是把原本依赖人工信心的迁移,变成一个能够持续发现缺口、补充证据并逐渐收敛的工程过程。


AI 负责生成,人和验证系统负责正确性边界

这套机制最初是为了降低一次 visitor 迁移的风险,但它也形成了一种更清晰的人、AI 和程序之间的分工。

  • AI 擅长大规模生成重复而结构化的代码,也能快速修正局部 diff。它不擅长的是,在一个规模很大、结构稀疏并且包含隐含契约的 AST 世界里,自发保证全局完备性。

因此,更合适的分工是:

  • AI 负责生成和迁移大量实现代码;
  • 观测系统负责忠实记录运行行为;
  • 比较器负责执行明确的等价规则;
  • 负责定义遍历契约、解释差异以及决定哪些变化是有意的。

这里的重点不是为 AI 单独设计一套较低的正确性标准。无论代码由 AI 还是人完成,大规模基础设施迁移都应该接受同一套独立验证。 AI 只是提高了代码生产速度,也让"不能把正确性寄托在逐行 review 上"这件事更加明显。


对 Databend 数据库工程的启发

Databend 是一个面向现代数据工作负载的云原生数仓。SQL parser、AST、binder、optimizer、表达式系统和执行计划生成,都是支撑复杂查询能力的基础设施。

这类基础设施的长期演进,需要同时满足两个要求:

  • 快速迭代,让新语法、新函数、新查询模式和 AI 辅助开发能够进入工程流程;
  • 保持 correctness,让迁移、重构和 API 演进不会悄悄改变 SQL 语义。

AST Visitor 迁移验证体现的是 Databend 工程体系中更底层的一类能力:不是只追求"代码生成得快",而是把复杂变更放进可观测、可比较、可诊断、可持续扩展的验证闭环里

这和 Databend 的用户心智是一致的。作为开放、Rust-native、面向云原生分析场景的数据仓库,Databend 不只是提供 SQL 功能本身,也需要在内核层面保证这些能力可以长期演进、可维护、可验证。

对于正在把 AI 引入工程流程的团队来说,这类实践也提供了一个更实际的判断标准:AI 是否真正提升工程效率,不取决于它能生成多少代码,而取决于系统是否能验证这些代码进入基础设施之后仍然保持正确。


总结

旧 visitor API 换成新 API,真正困难的从来不是批量写出代码,而是建立足够可信的迁移证据

这套方法也不是一开始就以最终形态出现的。围绕"怎样验证一棵给定 AST 上的新旧行为",最终形成了四个相互配合的内核能力:源码扫描、运行时观测、独立比较和失败定位。在此之上,真实 SQL 语料扩大了输入范围,覆盖率反馈又让尚未验证的范围保持可见,最终形成完整闭环。

AI 可以加速迁移代码的生产,但它不负责为自己的输出提供可信度。真正让这种大规模基础设施迁移变得可控的,是独立观测、集中判断、真实输入、可诊断差异以及持续可见的覆盖边界

相关推荐
TDengine (老段)1 小时前
TDengine DDL 完整参考 — Database/STable/Table/Column/Tag
大数据·数据库·物联网·时序数据库·tdengine·涛思数据
xlxxy_1 小时前
sap获取批次特性报表
java·linux·开发语言·前端·数据库·abap·mm
栋***t1 小时前
从“工具”到“基建”,麦塔在线考试系统经历的时代变局与定位逻辑
java·大数据·数据库·开源软件·无纸化
SelectDB技术团队1 小时前
Agent 可观测性:Apache Doris / SelectDB 的技术能力、选型对比与实践
数据库·人工智能·agent·可观测·ai-native·apache doris·selectdb
欢喜躲在眉梢里1 小时前
用飞算Java做了一套新能源充电运营管理系统:把站点、设备、订单拆成可运营的微服务
java·开发语言·微服务·ai编程·技术分享·java开发·飞算javaai炫技赛
csdn2015_1 小时前
kafka如何确保消息不丢失
数据库·分布式·kafka
wefg11 小时前
【MySQL】MySQL C API 访问 MySQL
数据库·mysql
l1t2 小时前
DeepSeek总结的RegreSQL 2.0测试通过了。计划却没通过。
linux·数据库·postgresql
无责任此方_修行中2 小时前
面对 300+ 仓库的重复劳动,我用 Guideline 优化了 Vibe Coding 工作流
后端·ai编程·vibecoding