AI代码审查工具Enola的设计思路——将函数调用、路由注册、类型引用等关系建模为类型化事实,而非通用图连接,从而保证架构分析的可靠性。
在第一部分,我解释了为什么 Enola 在 AI Agent 开始推理之前就要提取确定性的架构事实。
这就引出了下一个设计问题:
这些事实应该如何表示?
最直接的答案是构建一个包含所有符号、依赖、路由、服务和仓库的图。
但困难的部分不是把节点和边放入图中。
而是如何保留这些关系本身的含义。
函数调用和包依赖不是一回事。路由注册和导入不是一回事。类型引用不能作为两个服务共享契约的证明。
如果每种关系都变成一种通用的连接,生成的图也许是可遍历的,但就不再足够可靠来进行架构分析了。
因此,Enola 从一个类型化的架构事实模型开始。
解析给我们的是证据,不是架构
解析器可以告诉我们函数调用、字符串字面量、导入、注解或方法声明是否存在。
这是必要的,但还不足以成为架构事实。
以一个 Go 应用为例:
api := router.PathPrefix("/api").Subrouter()
registerCourseRoutes(api)
func registerCourseRoutes(router *mux.Router) {
router.HandleFunc("/courses", listCourses)
}
解析器可以暴露这两个字符串字面量和两个函数调用。
架构事实是:
GET /api/courses
-> handled_by listCourses
生成这个事实需要理解框架本身、理解路由器如何组合路径、以及值如何在函数调用中传递。
同样的问题也出现在 Spring 注解、Rails scopes、Axum 路由、Next.js 文件约定、生成的客户端、依赖注入框架和消息总线配置中。
因此,Enola 将解析和架构提取分开:
源代码
↓
语言和框架解释
↓
架构事实
解析器提供语法。
提取器决定这些语法在架构中的含义。
仓库边界是提取作用域
Git 仓库是开始分析的一个便利起点。
它提供了源代码版本、配置边界、文件、构建元数据以及稳定的源代码位置。
但它不一定是一个架构真实单元。
一个 monorepo 可能包含多个独立部署的服务。一个小仓库可能依赖在别处维护的 schema、基础设施或生成的客户端。运行时行为也可能依赖于应用仓库外部的部署配置。
因此,Enola 将仓库视为一个可独立寻址的提取作用域。
在该作用域内,Enola 可以建立如下事实:
Repository contains module
Module contains file
File declares symbol
Function calls function
Package imports package
Type implements interface
Route is handled by symbol
Producer publishes to topic
即使没有加载其他仓库,这些事实仍然有用。
作用域赋予每个实体一个本地标识和来源。它并不意味着整个架构都包含在这个仓库内。
这个区别很重要,因为源代码所有权边界和架构边界很少一致。
不同的事实需要不同的身份规则
没有任何通用标识符能适用于每一种架构概念。
一个符号可能需要:
一个 HTTP 路由可能需要:
一个 gRPC 方法可能需要:
一个 Kafka topic 可能需要:
命名空间或环境上下文。
这就是为什么仅靠名称是不够的。
两个仓库可能都包含 UserDTO,但引用的不是同一个契约。相反,一个 Go 类型叫 PublicUser 和一个 Swift 类型叫 ProfileResponse 可能代表同一个 API 的两端。
Enola 将这些实体分开保存,直到有证据建立它们之间的关系。
类型化关系保留了两者连接的原因
一个通用图可以表示:
A -> B
但架构分析需要知道这条边存在的原因。
CheckoutController
-> calls PaymentService.authorize
checkout
-> imports payments
POST /checkout
-> handled_by CheckoutController
这些关系支持不同的问题。
调用边可能对可达性有用。
导入边可能对依赖环检测有用。
路由到处理器的边可能对追踪请求执行有用。
方向也很重要。客户端消费一个路由。路由不消费客户端。
因此,Enola 将关系表示为类型化的、有向的事实,而不是通用的连接。
这仍然不能使每条提取的边都具有同等强度。
一个关系还应该保留用于生成它的证据,包括:
它是直接提取的还是派生而来的。
没有这些证据,图就变成了另一个不透明的答案。
一个模型,多种分析投影
完整的事实模型包含的关系比任何单一分析应该遍历的更多。
问题决定了哪个子集是相关的。
对于包环检测:
节点:包
边:包依赖
对于符号可达性:
节点:符号
边:调用和引用
节点:路由、处理器、服务
边:handled_by 和 calls
这很重要,因为使用每一条可用的边可能产生技术上连通但架构上无意义的路径。
假设一个类型属于一个包,那个包依赖另一个包,第二个包包含一个 HTTP 路由。
通过完整图存在一条路径。
但这不意味着该类型参与了这个路由的执行。
因此,Enola 为特定分析构建受限的投影,而不是将每种遍历都视为等价的。
一个具体例子:为什么通用依赖图会失败
考虑一个前端和后端存储在同一个 monorepo 中。
前端导入一个 API 客户端包:
web
-> depends_on api-client
后端导入一个路由框架:
backend
-> depends_on router
一个传统的依赖图正确地记录了这两个关系。
但它无法回答:
哪个前端方法消费了 POST /api/orders?
答案需要几个额外的事实:
submitOrder
-> makes_request POST /api/orders
POST /api/orders
-> handled_by CreateOrderHandler
CreateOrderHandler
-> calls OrderService.Create
有用的架构路径不是包依赖路径。
而是一个结合了请求、路由、处理器和调用关系的投影。
这就是 Enola 需要一个类型化架构事实模型而不是仅仅一个仓库依赖图的原因。
模型的局限
Enola 从加载的源代码和配置输入构建可以确定的架构。
它并不声称源代码 alone 描述了生产系统的每个方面。
部署清单、网关、服务网格、运行时配置、反射、功能开关和基础设施可能会改变运行时可见的架构。
因此,模型应该区分:
派生关系;
未解析关系;
超出分析范围的信息。
这对于开发者和 Agent 都重要。
一条缺失的边并不总是意味着不存在关系。可能意味着相关的源代码、配置或解析器不可用。
一个类型化的事实模型可以解释一个提取作用域内的架构。
生产系统跨越这些作用域。
一个移动客户端调用在别处维护的后端。一个服务发布被另一个仓库消费的事件。一个生成的客户端实现了在独立代码库中定义的契约。
下一篇文章将解释 Enola 如何连接这些独立提取的模型,而不仅仅基于名称或相似性来合并实体。