开源项目 JAN 20, 2026

零依赖 AI 代码审查 CLI 是怎么实现的——ai-review-pipeline 架构拆解

#Node.js#架构#Code Review#开源#原理

零依赖 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 生成修复代码
    // 下一轮会重新读取修复后的代码进行审查
}

循环结束后,无论结果如何:

  1. 生成测试用例(除非 --no-test
  2. 生成 HTML 报告(除非 --no-report
  3. 如果修复模式通过,自动 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-fetchaxios
Git 操作child_process.execSyncsimple-git
文件 I/Ofsfs-extra
CLI 参数process.argv 手动解析commanderyargs
Env 加载自写 15 行解析器dotenv
进度/颜色console.log + emojiorachalk

自写的 .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 模式01同样的规则,输出结构化 JSON

--json 模式输出完整的 review 结果到 stdout(JSON 格式),方便 CI 系统做后续处理。


如果重来,我会改什么

  1. 流式响应 — 目前等待完整的 AI 响应。对大代码库,流式输出体验更好。
  2. 并行文件审查 — 目前按文件顺序审查。用 Promise.all 可以并行多个文件(在 token 预算内)。
  3. Review 缓存 — 相同 diff = 相同 review。内容哈希缓存可以在 fix 循环中跳过对未变文件的重复 AI 调用。

这些都在路线图上,但 v3 还没做。当前设计优先保证简单和正确,而不是性能。


开源地址

整个 src/ 目录大约 1500 行,一杯咖啡的时间就能读完。欢迎提 PR 和 issue。

评论