零依赖 AI 代码审查 CLI 是怎么实现的——ai-review-pipeline 架构拆解
零依赖 AI 代码审查 CLI 是怎么实现的——ai-review-pipeline 架构拆解
两个 fetch 函数适配 6 家 AI、prompt 驱动的三级评分、安全阀防止 AI 删代码、自包含 HTML 报告——全部用 Node.js 内置 API 实现,0 个 npm 依赖。
这是 ai-review-pipeline 推广篇 的技术原理深度拆解。如果你只想试用工具,看推广篇就够了。
整体架构
bin/cli.mjs → CLI 入口,命令路由
src/commands/
pipeline.mjs → 编排:review → fix 循环 → test → report → commit
review.mjs → Prompt 构建 + 结构化解析
test.mjs → 独立测试生成 + 技术栈检测
init.mjs → 配置模板拷贝
src/core/
ai-client.mjs → 多 Provider 统一调用层
config.mjs → JSON 配置加载 + 环境变量解析
env.mjs → .env.local / .env 解析器(不依赖 dotenv)
diff.mjs → git diff + 文件读取
report.mjs → 自包含 HTML 报告生成
logger.mjs → i18n 日志
src/i18n/
zh.mjs / en.mjs → 中英文消息
整个工具大约 1500 行 JavaScript(ESM),没有构建步骤,没有转译器,node bin/cli.mjs 直接跑。
设计决策一:两个 fetch 函数搞定六家 AI
大多数人没意识到的一个事实:几乎所有 LLM API 都兼容 OpenAI 格式。DeepSeek、通义千问、Gemini、Ollama 都接受相同的 /v1/chat/completions 请求结构。唯一的例外是 Anthropic(Claude),它用自己的 /v1/messages 格式,认证方式也不同(x-api-key 而不是 Bearer token)。
所以整个多 Provider 层只需要两个函数:
export async function callAI({ baseUrl, apiKey, model, systemPrompt, prompt, provider }) {
return withRetry(() => {
if (provider === 'claude') {
return callClaude({ baseUrl, apiKey, model, systemPrompt, prompt });
}
return callOpenAICompatible({ baseUrl, apiKey, model, systemPrompt, prompt });
});
}
Provider 检测是自动的——不需要用户手动配置。识别逻辑分两层:
第一层:API Key 前缀
if (env.apiKey?.startsWith('sk-ant-')) return 'claude';
Anthropic 的 Key 有唯一前缀 sk-ant-,一个 startsWith 就能识别。
第二层:Base URL 特征匹配
if (env.baseUrl.includes('deepseek')) return 'deepseek';
if (env.baseUrl.includes('localhost:11434')) return 'ollama';
if (env.baseUrl.includes('dashscope')) return 'qwen';
if (env.baseUrl.includes('generativelanguage.googleapis')) return 'gemini';
一个静态注册表映射每个 Provider 的默认 Base URL 和模型:
const PROVIDERS = {
openai: { baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o-mini' },
deepseek: { baseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat' },
ollama: { baseUrl: 'http://localhost:11434/v1', defaultModel: 'qwen2.5-coder' },
claude: { baseUrl: 'https://api.anthropic.com', defaultModel: 'claude-sonnet-4-20250514' },
qwen: { baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', defaultModel: 'qwen-plus' },
gemini: { baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', defaultModel: 'gemini-2.0-flash' },
};
用户设一个环境变量(DEEPSEEK_API_KEY=sk-xxx),工具自动推断出该用 DeepSeek 的 Base URL 和默认模型。这种”设一个 Key 就能跑”的体验是让用户真正愿意用你工具的关键。
代理支持:不安装就不存在
唯一的 optional peer dependency 是 https-proxy-agent,用于企业代理环境。通过动态 import 加载,不存在就静默跳过:
try {
const { HttpsProxyAgent } = await import('https-proxy-agent');
// 用 agent 包装 globalThis.fetch
} catch {
// 没装代理包,继续运行
}
这个模式——optional peer dependency + dynamic import + silent fallback——是在”不强制依赖”前提下支持边缘场景的标准做法。
设计决策二:Prompt 驱动评分(以及为什么不信 AI 给的分数)
Review prompt 定义了三个严重等级和对应的扣分权重:
🔴 必修(阻塞合并)— 逻辑错误、安全漏洞、数据风险
每个 🔴 扣 20 分
🟡 建议(应该修复)— 边界未处理、类型问题、错误处理缺失
每个 🟡 扣 5 分
🟢 优化(后续改进)— 代码重复、命名不清、性能隐患
每个 🟢 扣 1 分
AI 被要求返回结构化 JSON,包含每个等级的数量。但关键设计:我不用 AI 自己给出的分数,而是从 issue 数量重新计算:
function calcScore(red, yellow, green) {
return Math.max(0, 100 - red * 20 - yellow * 5 - green * 1);
}
export function parseReview(content) {
const jsonMatch = content.match(/```json\s*([\s\S]*?)```/);
const result = JSON.parse(jsonMatch[1]);
// 重算分数——不信模型自己算的
result.score = calcScore(result.red || 0, result.yellow || 0, result.green || 0);
return { markdown: content, ...result, parseError: false };
}
为什么?因为 LLM 做数学不靠谱。同样的 review 结果(3 个 🔴、2 个 🟡),GPT-4o-mini 可能给 85 分,DeepSeek 可能给 72 分。如果 CI 门禁是 score >= 95,这种不一致会让开发者抓狂。
重算后,分数是确定性的——相同的 issue 分类永远得到相同的分数,不管用哪个模型。
自定义规则注入
团队可以在 .ai-pipeline.json 中定义项目规则:
{
"customRules": [
"禁止使用 any 类型",
"API Key / Secret 不得硬编码",
"所有 API 请求必须有错误处理"
]
}
这些规则会被拼接到 system prompt 中,作为额外检查维度。AI 在每次 review 时会将它们与内置的三级严重度定义一起执行。
设计决策三:多轮修复状态机
--fix 模式不是”让 AI 修一下然后祈祷”,而是一个受控的循环:
while (round < maxRounds) {
round++
diff = getDiff(file) // 读当前代码
review = callAI(diff) // AI 审查
if (score >= threshold && redCount === 0) {
passed = true
break // 通过,退出循环
}
if (!fixMode) break // 只读模式:只跑一轮
if (round >= maxRounds) break // 安全退出
autoFix(issues) // AI 生成修复代码
// 下一轮会重新读取修复后的代码进行审查
}
循环结束后,无论结果如何:
- 生成测试用例(除非
--no-test) - 生成 HTML 报告(除非
--no-report) - 如果修复模式通过,自动 git commit(除非
--no-commit)
核心设计:测试和报告总是生成,即使修复失败。报告是给人看的,不是通过门禁的理由。
50% 安全阀
自动修复有一个关键的安全机制。AI 生成”修复后”的文件后,会检查体积:
if (fixed.trim().length < source.trim().length * safetyMinRatio) {
log('⚠️', t('fixTooShort', filePath, Math.round(safetyMinRatio * 100)));
return false; // 丢弃这次修复
}
writeFileSync(fullPath, fixed, 'utf-8');
默认 safetyMinRatio 是 0.5(50%)。如果”修复后”的文件小于原文件的一半,这次修复会被静默丢弃。
这个机制防止 AI 通过”删掉大段代码”来”修复”问题——这是我在开发过程中真实遇到的失败模式。AI 有时会觉得”这段代码问题太多,不如删了”,50% 安全阀挡住了这种行为。
设计决策四:三类测试用例生成
测试生成 prompt 按功能结构化为三个类别:
1. ✅ 功能用例 — 正常业务流程(CRUD、状态流转、API 调用)
2. ⚔️ 对抗用例 — 安全攻击(XSS 注入、SQL 注入、溢出)
3. 🔲 边界用例 — 边界条件(空值、0、负数、MAX_SAFE_INTEGER、超时)
技术栈检测是自动的——根据文件扩展名和内容判断测试框架:
function detectStack(code, file) {
const ext = extname(file).toLowerCase();
if (ext === '.py') return 'Python (pytest)';
if (ext === '.go') return 'Go (testing)';
if (ext === '.vue' || ext === '.tsx') return 'Vitest';
// ...
}
每个文件在发给 AI 之前截断到 200 行,可配置的 maxCases(默认 8)控制输出数量。
设计决策五:自包含 HTML 报告
报告生成器构建一个完整的、自包含的 HTML 页面——没有外部 CSS,没有 JavaScript 依赖,没有 CDN 链接:
export function generateHTML(review, meta) {
const sc = review.score >= 95 ? '#22c55e'
: review.score >= 80 ? '#eab308'
: '#ef4444';
const rows = (review.issues || []).map((i) => {
const c = i.severity === 'red' ? '#ef4444'
: i.severity === 'yellow' ? '#eab308'
: '#22c55e';
const icon = i.severity === 'red' ? '🔴'
: i.severity === 'yellow' ? '🟡'
: '🟢';
return `<tr>
<td style="color:${c};font-weight:600">${icon} ${i.severity.toUpperCase()}</td>
...`;
});
}
为什么自包含?因为这些报告经常作为 CI artifact 上传。在 GitHub Actions 中把报告存为 artifact,任何人都可以下载在浏览器里打开——不需要服务器,不需要依赖。一个 .html 文件,所有样式内联。
报告包含分数环(SVG)、PASS/BLOCKED 状态、时间戳、Provider 信息、和色彩编码的 issue 表格。暗色主题。
设计决策六:零依赖是怎么做到的
package.json 没有 dependencies 字段。这是我用的替代方案:
| 需求 | Node.js 内置 | 没用的外部替代 |
|---|---|---|
| HTTP 请求 | globalThis.fetch(Node 18+) | node-fetch、axios |
| Git 操作 | child_process.execSync | simple-git |
| 文件 I/O | fs | fs-extra |
| CLI 参数 | process.argv 手动解析 | commander、yargs |
| Env 加载 | 自写 15 行解析器 | dotenv |
| 进度/颜色 | console.log + emoji | ora、chalk |
自写的 .env 解析器只有 15 行:
export function loadEnv(cwd = process.cwd()) {
for (const name of ['.env.local', '.env']) {
const p = resolve(cwd, name);
if (!existsSync(p)) continue;
for (const line of readFileSync(p, 'utf-8').split('\n')) {
const m = line.match(/^\s*([^#=]+?)\s*=\s*(.*?)\s*$/);
if (m && !process.env[m[1]]) {
process.env[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
}
}
}
}
能处理 dotenv 的所有边界情况吗?不能。能处理 99% 的场景(简单的 KEY=VALUE)吗?能。15 行代码 vs 一个依赖树。
这是有意为之的取舍:对于一个通过 npx 运行的 CLI 工具,每个依赖都意味着额外下载时间。零依赖意味着 npx ai-review-pipeline 下载 ~30KB 就能直接运行。
Exit Code 契约
CI 集成的核心是严格且可预测的 exit code:
| 场景 | Exit Code | 原因 |
|---|---|---|
| Review 通过(score ≥ threshold,0 个 🔴) | 0 | 放行 |
| 有 🔴 问题 | 1 | 阻断合并 |
--fix 修好了所有问题 | 0 | 已修复 + 自动提交 |
--fix 没修好 | 1 | 仍然阻断,但报告已生成 |
--json 模式 | 0 或 1 | 同样的规则,输出结构化 JSON |
--json 模式输出完整的 review 结果到 stdout(JSON 格式),方便 CI 系统做后续处理。
如果重来,我会改什么
- 流式响应 — 目前等待完整的 AI 响应。对大代码库,流式输出体验更好。
- 并行文件审查 — 目前按文件顺序审查。用
Promise.all可以并行多个文件(在 token 预算内)。 - Review 缓存 — 相同 diff = 相同 review。内容哈希缓存可以在 fix 循环中跳过对未变文件的重复 AI 调用。
这些都在路线图上,但 v3 还没做。当前设计优先保证简单和正确,而不是性能。
开源地址
- GitHub: hyxnj666-creator/ai-review-pipeline
- npm: ai-review-pipeline
整个 src/ 目录大约 1500 行,一杯咖啡的时间就能读完。欢迎提 PR 和 issue。