🔗 GitHub 地址:github.com/streaker303…
Code Review 是保障代码质量的重要环节,但人工审查效率低、容易遗漏问题。大多数的 AI Code Review 工具要么只是简单地把 Diff 扔给 GPT,要么无法结合团队的编码规范。
本文将分享如何用 AST 分析 + AI 模型 构建一个智能代码审查工具,实现通过 Babel/Vue Compiler 提取完整函数上下文、自定义团队编码规范检查、精准到行的评论定位,以及稳定可靠的 CI/CD 集成。
🎯 现有工具的问题
市面上已经有不少 AI Code Review 工具,但试用下来发现两个核心问题:
问题一:看不到完整上下文
传统工具只把 Git Diff 发给 AI,就像让人只看几行代码片段就下结论。举个真实例子:
// Diff 只显示新增了这一行
( , 50) + return (response.data as any).data;
看起来就是个 any 类型的小问题对吧?但如果你看到完整函数:
function handleResponse(response) {
const code = response.code;
// 新增的逻辑
if (code === "200" && "data" in response.data) {
return (response.data as any).data; // 第 50 行:返回嵌套的 data
}
// 原有的逻辑
if (code == "200" || code == "210") {
return response.data; // 第 54 行:直接返回 data
}
}
问题来了:同一个函数里,code == "200" 的情况下,两个分支返回了完全不同的数据结构!调用方根本不知道该怎么处理返回值。这种 bug 只看 Diff 根本发现不了。
问题二:无法结合团队规范
每个团队都有自己的编码规范,但大多数 AI 工具只能做通用性检查,无法针对性地检查 "我们团队要求所有异步函数必须有错误处理" 这类自定义规则。
基于这些问题,下面开始构建一个真正能理解代码上下文的审查工具。
🛠️ 技术实现
在深入细节之前,先看一下整个系统的工作流程:
graph LR
A[GitLab MR触发] --> B[配置加载]
B --> C[获取Git Diff]
C --> D[AST上下文分析]
D --> E[AI模型审查]
E --> F[结果汇总]
F --> G[报告生成]
G --> H[发布到MR]
style A fill:#e1f5fe
style H fill:#e8f5e8
style E fill:#fff3e0
整个流程分为三个核心阶段:
- 数据采集阶段:从 GitLab MR 获取代码变更,解析 Git Diff
- 上下文分析阶段:通过 AST 提取完整函数上下文,构建精准的审查依据
- AI 审查阶段:调用大模型分析代码,生成结构化的审查报告
下面按照实现流程,详细讲解每个关键环节是如何处理的。
Git Diff 解析:精准的行号映射体系
第一个要解决的问题:如何让 AI 精准评论到具体的代码行?
Git Diff 的原始格式长这样:
diff --git a/src/service.ts b/src/service.ts
@@ -10,7 +10,8 @@ export class Service {
- async fetchData() {
- return await api.get('/data');
+ async fetchData(params: any) {
+ const response = await api.get('/data', params);
+ return response.data;
}
看起来简单,但如果你想实现 "在第 12 行评论这个问题",就会发现一个困境:Diff 里的行号和最终文件的行号是不对应的!
Diff 格式中,- 开头的行只有旧文件行号,+ 开头的行只有新文件行号,普通行同时有两个行号。而 GitLab API 要求你评论时,必须明确指定是 old_line 还是 new_line。如果搞错了,评论就发不出去。
解决思路
核心思路是解析 diff 的每一行,根据 @@ 标记提取起始行号,然后逐行打标:
// 核心逻辑简化示意
function addLineNumbersToDiff(diffText) {
const hunks = splitHunks(diffText); // 先分割成多个 hunk
hunks.forEach(hunk => {
let oldLine = hunk.oldStart; // 从 @@ 中提取
let newLine = hunk.newStart;
hunk.lines.forEach(line => {
if (line.startsWith('-')) {
// 删除行:只有旧行号
addLineNumber(`(${oldLine}, )`, line);
oldLine++;
} else if (line.startsWith('+')) {
// 新增行:只有新行号
addLineNumber(`( , ${newLine})`, line);
newLine++;
} else {
// 上下文行:两个行号都有
addLineNumber(`(${oldLine}, ${newLine})`, line);
oldLine++;
newLine++;
}
});
});
}
实际实现还需要处理文件头、行号对齐等细节,这里只展示核心逻辑。
这样处理后,每一行都带上了精准的行号信息:
(10, 10) async fetchData() {
(11, ) - return await api.get('/data');
( , 11) + async fetchData(params: any) {
( , 12) + const response = await api.get('/data', params);
( , 13) + return response.data;
(12, 14) }
现在 AI 模型可以清楚地知道:
- 第 11 行被删除了 → 使用
old_line: 11 - 第 12-13 行是新增的 → 使用
new_line: 12或new_line: 13
参考:行号映射的设计思路参考了《前端仔如何在公司搭建 AI Review 系统》这篇文章,具体细节可以阅读原文。
AST 上下文提取:让 AI 看到完整函数
有了精准的行号映射,接下来要解决的是上下文提取问题。这是整个工具的核心功能,也是最复杂的部分。
难点一:避免提取过大的代码块
可以用 Babel 的 traverse API 遍历 AST,找到包含新增行的节点就直接提取。但是容易翻车:
export default class UserService {
// ... 省略很多代码
async getUserInfo(id) {
// 只修改了这一行
const user = await db.query(`SELECT * FROM users WHERE id = ${id}`);
return user;
}
// ... 省略其他方法
}
什么问题呢?Babel 遍历到 getUserInfo 方法时,发现包含新增行,就把它提取出来。但同时,外层的 UserService 类也包含这个新增行!
如果不加控制,两个节点都会被提取,而且类的代码量远大于单个方法,会消耗更多Token,代码过多也可能会忽略重点。
解决方案:实现"最小包含块"算法
核心思想:按大小排序,优先选择小代码块,避免提取外层容器。(这部分直接交给 ai 调教就行)
// 核心逻辑(简化)
function selectSmallestSections(sections, addedLines) {
// 按代码块大小排序(小的优先)
const sorted = [...sections].sort((a, b) => a.size - b.size);
const selected = [];
const coveredLines = new Set();
// 贪心选择:优先选小的,避免重复
for (const section of sorted) {
// 检查是否有未覆盖的新增行
const sectionLines = section.added_lines.filter(
line => !coveredLines.has(line)
);
if (sectionLines.length > 0) {
selected.push(section);
section.added_lines.forEach(line => coveredLines.add(line));
}
}
return selected;
}
这样就能优先提取小方法,而不是大类。效果明显,Token 消耗大幅降低。
难点二:支持 Vue 单文件组件
Vue 文件的结构比较特殊:
<template>
<div>{{ userName }}</div>
</template>
<script setup lang="ts">
const userName = ref('John');
</script>
<style scoped>
.container { color: red; }
</style>
Babel 只能解析 <script> 部分,而且 Vue 3 的 <script setup> 语法和普通 JS 还不一样。
解决方案:先拆分 SFC,再解析 script,最后还原行号
// 核心步骤(简化)
async function extractVueAstContext(filePath, addedLines) {
// 1. 用 @vue/compiler-sfc 解析 Vue 文件
const { descriptor } = parseSFC(code, { filename: filePath });
// 2. 处理 script 或 scriptSetup
const scriptBlock = descriptor.scriptSetup || descriptor.script;
const scriptStartLine = scriptBlock.loc.start.line;
// 3. 将整个文件的行号映射到 script 内部
const adjustedLines = Array.from(addedLines)
.filter(line => line >= scriptStartLine)
.map(line => line - scriptStartLine + 1);
// 4. 用 Babel 解析 script 内容
const scriptResult = extractScriptContext(
scriptBlock.content,
new Set(adjustedLines),
scriptStartLine
);
// 5. 还原真实行号
scriptResult.sections.forEach(section => {
section.start_line += scriptStartLine - 1;
section.end_line += scriptStartLine - 1;
});
return scriptResult;
}
难点三:防止 AST 解析卡死
大型 Vue 组件的 AST 遍历可能导致 CI 超时,需要做好性能保护:
递归深度控制:有些代码嵌套层级特别深,可能导致栈溢出。通过深度计数器,超过设定的最大层数就主动抛出异常中断遍历。
解析超时保护:大文件的 Babel 解析比较耗时。使用 Promise.race 实现超时机制,设定时间内解析不完就放弃 AST 分析,降级为普通 Diff 审查。
代码块大小限制:即使成功解析出一个很大的函数,把它全部发给 AI 也会浪费 Token。根据字符数和行数进行分级截断:字符数超限直接截断,行数超限只提取新增行周围的上下文。
通过这些保护措施,即使大文件也能较快完成分析,Token 消耗控制在合理范围。
Prompt 工程:让 AI 返回准确结果
有了 Diff 和 AST 上下文,下一步是如何构建 Prompt 让 AI 给出准确的审查结果。这部分需要不断调整优化。
挑战一:返回精确的行号
最开始直接把 Diff 发给 AI,让它返回问题所在的行号。结果发现行号经常对不上:
{
"type": "new",
"startLine": 50, // ❌ 错误!应该是 51
"description": "..."
}
为什么会错?因为 AI 看到的是 (50, 51) +代码,它不知道应该用第一个数字还是第二个。
解决方案:在 System Prompt 中明确格式规范
在 Prompt 中加入了超详细的说明:
# Diff 格式说明
每一行左侧括号内的两个数字格式为 (旧行号, 新行号):
- 括号后的 + 表示新增行(只有新行号,格式为 ( , 新行号))
- 括号后的 - 表示删除行(只有旧行号,格式为 (旧行号, ))
**重要**:
- 如果评审的是 + 部分的代码,type 必须为 "new",startLine 必须使用新行号(括号中第二个数字)
- 如果评审的是 - 部分的代码,type 必须为 "old",startLine 必须使用旧行号(括号中第一个数字)
示例:
- (50, 51) +代码 → type 应为 "new",startLine 应为 51(不是 50!)
- (50, ) -代码 → type 应为 "old",startLine 应为 50
加了这段说明后,行号准确率明显提升。
挑战二:控制审查力度
一开始,AI 会输出很多主观建议:
- "建议这里改用函数式编程风格"
- "变量名可以更语义化一点"
- "这里可以优化性能"
这些建议没错,但太主观了,而且容易引起争议。我希望 AI 优先检查那些明确违反规范的问题。
解决方案:建立审查优先级体系
在 System Prompt 中明确优先级:
# Issue 判定流程
严格按序执行:
1. 收集候选:仅关注 + 新增行,忽略 - 删除行
2. 规范匹配:只有明确违反某 guideline 才产出 issue,不主观臆测
3. 严重度判定:
- 高:编译错误/运行时崩溃/安全漏洞/内存泄漏/数据破坏
- 中:性能问题/类型不安全/缺错误处理/违反框架最佳实践
- 低:代码风格/命名/可读性建议
4. 去重排序:同 (行号, guideline_id, 描述) 仅保留一条
5. 截断:超过设定数量从末尾丢弃
# 禁止输出
- guideline_id 不在规范集合中
- 无具体行号
- 纯主观建议(无 guideline 支撑)
- 猜测性风险(缺直接证据)
- 删除的代码(- 行)
这样 AI 就会优先检查团队规范,而不是发表主观意见。
挑战三:让 AI 利用 AST 上下文
有了 AST 提取的完整函数,下一步是教会 AI 如何真正利用这些上下文信息。
在 Prompt 中加入了具体的审查方法论:
## 如何利用 AST 进行审查
1. **查看 added_lines**:这些是新增的行号,需要重点审查
2. **阅读 snippet**:这是包含新增代码的完整函数/类,而非仅几行 diff
3. **对比上下文**:将新增代码与 snippet 中的现有代码进行对比,检查:
✓ 变量名拼写是否一致(避免 typo 导致的 bug)
✓ 如果新增 return,检查所有 return 分支的返回值结构是否一致
✓ 如果新增条件分支,检查各分支处理逻辑是否一致
✓ 如果调用函数,检查参数名/属性名是否与上下文中的定义匹配
## 实例说明
假设 Diff 显示:
( , 50) + return (response.data as any).data;
(53, 54) if (code == "200" || code == "210") {
(54, 55) return response.data;
如果只看 Diff,可能只注意到 any 类型问题。但查看 AST 的 snippet:
function handleResponse(response) {
const code = response.code;
if (code === "200" && "data" in response.data) {
return (response.data as any).data; // 第50行(新增)
}
if (code == "200" || code == "210") {
return response.data; // 第54行(已有)
}
}
**关键发现**:同一个函数中,两个 return 在处理 code == "200" 时返回了不同的数据结构!
这段 Prompt 教会了 AI 如何利用 AST 上下文发现只看 Diff 发现不了的问题。
结果发布:两种展示模式
AI 审查完成后,需要将结果展示给开发者。根据不同的使用场景,设计了两种发布模式:
模式对比
报告模式(Report):
- ✅ 统一汇总,方便整体把握问题分布
- ✅ 支持按严重程度排序,优先修复高危问题
- ✅ 生成 HTML 表格,可视化效果好
- ❌ 需要跳转到具体行,多一步操作
行级评论模式(Inline):
- ✅ 精准定位到代码行,直接点击 "Resolve"
- ✅ 支持多轮讨论(可以回复评论)
- ✅ 修复后自动标记为已解决
- ❌ 问题太多时界面会很乱
报告模式会生成 Markdown 表格,包含统计信息和详细的问题列表。
行级评论模式则需要处理 GitLab API 复杂的参数结构。
示例报告:
GitLab 的行级评论 API 比较复杂,需要提供完整的位置参数:
// position 参数结构
const position = {
...diffRefs, // 包含 base_sha, start_sha, head_sha
position_type: 'text',
old_path: 'src/api.ts', // 旧文件路径
new_path: 'src/api.ts', // 新文件路径
old_line: null, // 如果是新增行,这里为 null
new_line: 45 // 新文件的行号
}
示例效果:
稳定性保障:让 CI 流程更可靠
在 CI 环境中,任何一个环节出错都可能导致流水线失败。需要做好容错和降级处理。
并发控制与限流
AI 平台对 API 调用通常有速率限制。如果一个 MR 有很多文件,同时发起大量请求,可能会触发限流:
Error: Rate limit exceeded (429)
解决方案:用 p-limit 控制并发数
// 核心逻辑(简化)
const limit = pLimit(config.maxParallel);
const reviewPromises = files.map(({ path, diff }) =>
limit(async () => {
const result = await reviewSingleFile(path, diff, config, guidelines, systemPrompt);
return { path, result };
})
);
const results = await Promise.all(reviewPromises);
使用 p-limit 库控制并发数(默认为 3),避免触发 API 速率限制。
旧评论清理
每次代码更新后重新审查,都会生成新的评论。如果不清理旧评论,MR 界面会有很多过时的提示:
🤖 AI 代码审查报告 (3小时前)
问题1: xxx
🤖 AI 代码审查报告 (2小时前) ← 这个已经过时了
问题1: xxx
🤖 AI 代码审查报告 (刚刚)
问题1: xxx (已修复)
解决方案:发布前自动清理旧评论
// 核心逻辑(简化)
async function deletePastComments(identifier) {
const response = await client.get(`/projects/${projectId}/merge_requests/${mergeRequestIid}/notes`);
const notesToDelete = response.data.filter(note =>
note.body && note.body.includes(identifier)
);
for (const note of notesToDelete) {
try {
await client.delete(`/projects/${projectId}/merge_requests/${mergeRequestIid}/notes/${note.id}`);
} catch (deleteError) {
console.warn('删除评论失败:', deleteError.message);
}
}
}
关键点是在删除时用 try-catch 包裹,即使某个删除失败,其他删除也会继续执行。
多层容错机制
从 AST 解析到 AI 调用,每个环节都可能失败。容错策略如下:
AST 解析容错:extractAstContext 内部捕获所有错误,失败时返回空的 sections 数组,审查流程继续,只是没有 AST 上下文辅助。
AI 调用容错:外层 try-catch 捕获网络异常、超时等问题,失败时返回 ERROR 状态,但不影响其他文件的审查。
JSON 解析容错:AI 返回的内容可能不符合预期格式,解析失败时同样返回 ERROR 状态,记录错误信息,不抛出异常。
通过这些容错机制,保证即使某个文件的某个环节失败,其他文件仍能正常审查完成,整体流程稳定可靠。
🚀 快速接入指南
想要在自己的项目中使用?配置非常简单,只需要 3 步。
第一步:配置 CI 流水线
在项目根目录创建 .gitlab-ci.yml:
比如指定目标分支,才进行审查
ai_code_review:
stage: review
interruptible: true
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^(dev_(210|215)|stage)$/'
when: on_success
script:
- rm -rf code-review-js
- git clone https://gitlab-ci-token:${GITLAB_TOKEN_AI}@your-gitlab.com/tools/code-review-js.git
- cd code-review-js
- pnpm install
- node src/main.js
allow_failure: true
第二步:设置环境变量
在 GitLab 项目的 Settings → CI/CD → Variables 中添加必要的配置:
GITLAB_TOKEN: glpat-xxxxxxxxxxxx # GitLab 访问令牌
GITLAB_TOKEN_AI: glpat-xxxxxxxxxxxx # GitLab AI 项目访问令牌,缺失无法拉取ai库
OPENAI_API_KEY: sk-xxxxxxxxxxxx # 阿里云百炼 API Key
REVIEW_MODE: inline # report 或 inline
第三步:自定义编码规范(可选)
如果需要检查团队特定的编码规范,可以在项目根目录创建 coding_guidelines.yaml:
guidelines:
- id: "TS-001"
category: "类型安全"
description: "禁止使用 any 类型,应使用具体类型或 unknown"
severity: "中"
- id: "SEC-001"
category: "安全性"
description: "禁止使用 eval() 和 new Function() 执行动态代码"
severity: "高"
配置完成后,每次提交 MR 就会自动触发审查!🎉
💡 使用建议
这个工具是代码审查的"初筛助手",擅长检查编码规范、常见 bug、类型安全等机械性问题。推荐的使用方式:
- AI 初筛:快速过滤明显的规范问题、拼写错误、缺失的错误处理等
- 人工精审:重点关注业务逻辑、架构设计、性能优化等高层次问题
- 协同讨论:把 AI 发现的问题作为讨论起点,结合业务场景决定是否修改
实际使用时,AI Code Review 的价值在于承担重复性的规范检查,让人类审查者把精力聚焦在更高层次的问题上。需要注意的是,AI 对具体业务逻辑的理解有限,建议在关键业务代码处添加注释说明。另外,Prompt 的调优是落地时最核心的工作,需要根据团队特点不断迭代。对于内网环境,需要确保 CI 能访问 AI 服务 API。大型 MR 的 Token 消耗会随文件数量增加,但纯文本成本相对较低。
希望本文能给你一些启发
项目地址:github.com/streaker303…
技术栈:Node.js | Babel AST | Vue Compiler | 阿里云百炼
觉得有帮助的话,给个 Star 呗~~~ ⭐