Agent 写完代码不等于写对了。本文设计一套标准化的质量门禁流水线,将单元测试、集成测试、Lint 检查、安全扫描和代码审查串联成自动化闭环,确保 Agent 的每一行产出都经过与人类开发者同等标准的验证。

Agent 质量门禁:测试、Lint、安全扫描与代码审查闭环

Agent 写完代码不等于写对了。本文设计一套标准化的质量门禁流水线,将单元测试、集成测试、Lint 检查、安全扫描和代码审查串联成自动化闭环,确保 Agent 的每一行产出都经过与人类开发者同等标准的验证。

目录

一、为什么 Agent 的产出需要门禁

Agent 写代码的速度是人类的 5-10 倍,但它犯错误的速度也是 5-10 倍。一个没有质量门禁的 Agent 平台,本质上是在用"速度"换"质量债务"——写得越快,积累的问题越多。

1.1 Agent 产出的典型质量问题

text
任务:为 UserService 添加批量删除功能

Agent 产出:
  ✅ 实现了 batchDelete() 方法
  ✅ 添加了 API 路由 /api/users/batch-delete
  ❌ 没有写单元测试
  ❌ 没有做输入验证(可以传入空数组、负数 ID)
  ❌ 没有检查权限(普通用户可以删除其他用户)
  ❌ 硬编码了数据库查询(SQL 注入风险)
  ❌ 没有更新 API 文档
  ❌ 代码风格与项目不一致(camelCase vs snake_case 混用)

这些问题不是 Agent 故意犯的——它没有被要求做这些事。质量门禁的作用就是在 Agent 交付之前,自动检查这些维度,把问题拦在 PR 之前。

1.2 门禁 vs 人类 Review 的协作

检查维度 门禁(自动) 人类 Review
单元测试通过 ✅ 自动运行 不需要
Lint 规范 ✅ 自动检查 不需要
安全漏洞扫描 ✅ 自动扫描 不需要
架构合理性 ❌ 无法判断 ✅ 人工审查
业务逻辑正确性 ⚠️ 部分(测试覆盖) ✅ 人工审查
代码可读性 ⚠️ 部分(Lint) ✅ 人工审查
性能影响 ⚠️ 部分(Benchmark) ✅ 人工审查

门禁不是替代人类 Review,而是把机械性检查自动化,让人类 Reviewer 专注于需要判断力的部分。

二、质量门禁流水线架构

质量门禁是一条串联的流水线,Agent 产出的代码必须依次通过每一道门才能提交 PR:

text
Agent 产出代码
    │
    ▼
┌──────────────────────────────────────────────────────────────┐
│                     Quality Gate Pipeline                     │
│                                                               │
│  Gate 1          Gate 2          Gate 3          Gate 4      │
│  ┌─────────┐    ┌─────────┐    ┌─────────┐    ┌─────────┐  │
│  │  编译    │ →  │  测试    │ →  │  Lint   │ →  │  安全   │  │
│  │  检查    │    │  运行    │    │  检查    │    │  扫描   │  │
│  └────┬────┘    └────┬────┘    └────┬────┘    └────┬────┘  │
│       │              │              │              │          │
│  fail → 回退    fail → 回退    fail → 回退    fail → 回退    │
│                                                               │
│  Gate 5                                                       │
│  ┌─────────┐                                                  │
│  │  Review │ → 通过 → 创建 PR                                  │
│  │  摘要   │         失败 → 回退给 Agent 修复                   │
│  └─────────┘                                                  │
└──────────────────────────────────────────────────────────────┘

每道门禁独立运行,输出结构化的通过/失败结果。任何一道门禁失败,代码会被回退给 Agent 修复(如果配置了自动修复),或者标记为需要人工介入。

三、测试门禁:单元测试与集成测试

3.1 测试门禁配置

yaml
# quality-gates.yaml
gates:
  compile:
    enabled: true
    command: "npm run build"
    timeout_secs: 120
    blocking: true              # 阻塞级:失败立即终止

  unit_test:
    enabled: true
    command: "npm test -- --coverage --reporter=json"
    timeout_secs: 300
    blocking: true
    coverage:
      enabled: true
      min_line_coverage: 80     # 最低行覆盖率
      min_branch_coverage: 70   # 最低分支覆盖率
      # 只检查 Agent 修改的文件
      changed_files_only: true
    # Agent 修改了哪些文件,就运行对应的测试
    smart_test:
      enabled: true
      # 文件到测试的映射规则
      test_patterns:
        - source: "src/**/*.ts"
          test: "src/**/*.test.ts"
        - source: "src/**/*.py"
          test: "tests/**/test_*.py"

  integration_test:
    enabled: true
    command: "npm run test:integration"
    timeout_secs: 600
    blocking: false             # 非阻塞:失败记录但不终止
    # 只在特定目录有改动时运行
    trigger_paths:
      - "src/api/**"
      - "src/db/**"
      - "src/services/**"

3.2 智能测试选择

全量运行测试套件在大项目中可能耗时 10+ 分钟。智能测试选择只运行与 Agent 修改文件相关的测试:

python
import subprocess
import json
from pathlib import Path

class SmartTestSelector:
    """智能测试选择器:只运行与变更文件相关的测试"""

    def __init__(self, repo_root: str, config: dict):
        self.repo_root = Path(repo_root)
        self.test_patterns = config.get("test_patterns", [])

    def select_tests(self, changed_files: list[str]) -> list[str]:
        """根据变更文件选择需要运行的测试"""
        tests = set()

        for changed in changed_files:
            for pattern in self.test_patterns:
                if self._matches(changed, pattern["source"]):
                    # 找到对应的测试文件
                    test_glob = pattern["test"]
                    # 从源文件路径推导测试文件路径
                    test_files = self._derive_test_files(changed, test_glob)
                    tests.update(test_files)

        return sorted(tests)

    def run_selected_tests(self, tests: list[str], timeout: int = 300) -> dict:
        """运行选中的测试,返回结构化结果"""
        if not tests:
            return {"status": "skipped", "reason": "no relevant tests"}

        cmd = ["npx", "jest", "--json", "--testPathPattern",
               "|".join(tests)]

        result = subprocess.run(
            cmd, cwd=self.repo_root,
            capture_output=True, text=True,
            timeout=timeout
        )

        try:
            report = json.loads(result.stdout)
        except json.JSONDecodeError:
            return {"status": "error", "output": result.stdout + result.stderr}

        return {
            "status": "passed" if report.get("success") else "failed",
            "total": report.get("numTotalTests", 0),
            "passed": report.get("numPassedTests", 0),
            "failed": report.get("numFailedTests", 0),
            "failed_tests": [
                {
                    "name": t["fullName"],
                    "message": t["failureMessages"][0] if t.get("failureMessages") else ""
                }
                for suite in report.get("testResults", [])
                for t in suite.get("assertionResults", [])
                if t.get("status") == "failed"
            ],
            "coverage": self._extract_coverage(report),
        }

    def _derive_test_files(self, source: str, test_glob: str) -> list[str]:
        stem = Path(source).stem
        directory = Path(source).parent
        # 在同目录和 tests 目录中查找测试文件
        candidates = [
            str(directory / f"{stem}.test.ts"),
            str(directory / f"{stem}.spec.ts"),
            str(directory / f"test_{stem}.py"),
            str(directory / f"{stem}_test.go"),
            f"tests/**/test_{stem}*.py",
        ]
        found = []
        for c in candidates:
            matches = list(self.repo_root.glob(c))
            found.extend(str(m.relative_to(self.repo_root)) for m in matches)
        return found

    def _matches(self, path: str, pattern: str) -> bool:
        from fnmatch import fnmatch
        return fnmatch(path, pattern)

    def _extract_coverage(self, report: dict) -> dict:
        cov = report.get("coverageMap", {})
        if not cov:
            return {}
        total_lines = sum(v.get("lines", {}).get("total", 0) for v in cov.values())
        covered = sum(v.get("lines", {}).get("covered", 0) for v in cov.values())
        return {
            "line_coverage": (covered / total_lines * 100) if total_lines else 0,
            "files_covered": len(cov),
        }

四、静态分析门禁:Lint 与安全扫描

4.1 Lint 检查

yaml
  lint:
    enabled: true
    tools:
      - name: "eslint"
        command: "npx eslint --format json {changed_files}"
        blocking_level: "error"    # 只有 error 阻塞,warning 记录
      - name: "ruff"
        command: "ruff check --output-format json {changed_files}"
        blocking_level: "error"
      - name: "prettier"
        command: "npx prettier --check {changed_files}"
        blocking_level: "warning"  # 格式问题不阻塞
    # Agent 可以尝试自动修复 Lint 错误
    auto_fix:
      enabled: true
      max_fix_rounds: 2           # 最多尝试 2 轮自动修复
      fix_commands:
        - "npx eslint --fix {changed_files}"
        - "ruff check --fix {changed_files}"
        - "npx prettier --write {changed_files}"

4.2 安全扫描

安全扫描分为两个层面:依赖漏洞扫描和代码安全扫描。

python
import subprocess
import json
from dataclasses import dataclass

@dataclass
class SecurityFinding:
    severity: str         # critical, high, medium, low
    category: str         # sqli, xss, hardcoded-secret, dependency-vuln
    file: str
    line: int
    message: str
    rule_id: str

class SecurityScanner:
    """安全扫描门禁"""

    def __init__(self, repo_root: str, config: dict):
        self.repo_root = repo_root
        self.blocking_severity = config.get("blocking_severity", ["critical", "high"])

    def scan_all(self, changed_files: list[str]) -> dict:
        """运行所有安全扫描"""
        findings = []

        # 1. 依赖漏洞扫描
        findings.extend(self._scan_dependencies())

        # 2. 代码安全扫描(Semgrep)
        findings.extend(self._scan_code_semgrep(changed_files))

        # 3. 密钥泄露检测
        findings.extend(self._scan_secrets(changed_files))

        # 判断是否阻塞
        blocking = [f for f in findings if f.severity in self.blocking_severity]

        return {
            "status": "failed" if blocking else "passed",
            "total_findings": len(findings),
            "blocking_findings": len(blocking),
            "findings": [
                {
                    "severity": f.severity,
                    "category": f.category,
                    "file": f.file,
                    "line": f.line,
                    "message": f.message,
                    "rule_id": f.rule_id,
                }
                for f in findings
            ],
        }

    def _scan_dependencies(self) -> list[SecurityFinding]:
        """依赖漏洞扫描:npm audit / pip-audit"""
        findings = []
        try:
            result = subprocess.run(
                ["npm", "audit", "--json"],
                cwd=self.repo_root, capture_output=True, text=True, timeout=60
            )
            report = json.loads(result.stdout) if result.stdout else {}
            for vuln in report.get("vulnerabilities", {}).values():
                findings.append(SecurityFinding(
                    severity=vuln.get("severity", "medium"),
                    category="dependency-vuln",
                    file="package.json",
                    line=0,
                    message=f"{vuln['name']}@{vuln['range']}: {vuln.get('title', '')}",
                    rule_id=f"npm-audit-{vuln['name']}",
                ))
        except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
            pass
        return findings

    def _scan_code_semgrep(self, changed_files: list[str]) -> list[SecurityFinding]:
        """代码安全扫描:使用 Semgrep"""
        findings = []
        try:
            cmd = ["semgrep", "--json", "--config", "auto"] + changed_files
            result = subprocess.run(
                cmd, cwd=self.repo_root, capture_output=True, text=True, timeout=120
            )
            report = json.loads(result.stdout) if result.stdout else {}
            for finding in report.get("results", []):
                findings.append(SecurityFinding(
                    severity=finding.get("extra", {}).get("severity", "medium"),
                    category=finding.get("check_id", "unknown"),
                    file=finding.get("path", ""),
                    line=finding.get("start", {}).get("line", 0),
                    message=finding.get("extra", {}).get("message", ""),
                    rule_id=finding.get("check_id", ""),
                ))
        except (subprocess.TimeoutExpired, FileNotFoundError):
            pass
        return findings

    def _scan_secrets(self, changed_files: list[str]) -> list[SecurityFinding]:
        """密钥泄露检测"""
        findings = []
        try:
            cmd = ["gitleaks", "detect", "--no-git", "--report-format", "json",
                   "--report-path", "/tmp/gitleaks-report.json"]
            subprocess.run(cmd, cwd=self.repo_root, timeout=60)
        except (subprocess.TimeoutExpired, FileNotFoundError):
            pass
        return findings

五、代码审查门禁:自动化 Review

5.1 Review 摘要生成

在通过测试和安全扫描后,门禁会为人类 Reviewer 生成一份结构化的 Review 摘要:

python
class ReviewSummaryGenerator:
    """为人类 Reviewer 生成审查摘要"""

    def generate(self, task_id: str, diff: str, gate_results: dict) -> str:
        """生成 Markdown 格式的 Review 摘要"""
        lines = [
            f"## Agent 任务审查摘要 (Task #{task_id})",
            "",
            "### 变更概览",
            f"- 修改文件数: {gate_results.get('files_changed', 'N/A')}",
            f"- 新增行数: {gate_results.get('lines_added', 'N/A')}",
            f"- 删除行数: {gate_results.get('lines_removed', 'N/A')}",
            "",
            "### 门禁结果",
        ]

        for gate_name, result in gate_results.get("gates", {}).items():
            status = "✅" if result["status"] == "passed" else "❌"
            lines.append(f"- {status} **{gate_name}**: {result.get('detail', '')}")

        lines.extend([
            "",
            "### 需要人工审查的要点",
            "- [ ] 业务逻辑是否正确",
            "- [ ] 架构决策是否合理",
            "- [ ] 边界条件是否覆盖",
            "- [ ] 是否有过度工程化",
        ])

        # 附加安全扫描发现
        security = gate_results.get("gates", {}).get("security", {})
        if security.get("total_findings", 0) > 0:
            lines.extend([
                "",
                f"### 安全扫描发现 ({security['total_findings']} 项)",
            ])
            for f in security.get("findings", [])[:10]:
                lines.append(f"- [{f['severity']}] {f['file']}:{f['line']} - {f['message']}")

        return "\n".join(lines)

5.2 GitHub Actions 集成

将质量门禁集成到 CI 流水线中:

yaml
# .github/workflows/agent-quality-gate.yml
name: Agent Quality Gate
on:
  push:
    branches: ["agent/task-*"]

jobs:
  quality-gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci

      - name: Gate 1 - Build check
        run: npm run build
        timeout-minutes: 2

      - name: Gate 2 - Unit tests
        run: npm test -- --coverage --reporter=json --outputFile=test-report.json
        timeout-minutes: 5

      - name: Gate 3 - Lint check
        run: |
          npx eslint --format json $(git diff --name-only origin/main...HEAD -- '*.ts' '*.js')
        timeout-minutes: 3
        continue-on-error: true

      - name: Gate 4 - Security scan
        run: |
          npm audit --json > audit-report.json || true
          npx semgrep --json --config auto $(git diff --name-only origin/main...HEAD) > semgrep-report.json || true
        timeout-minutes: 5

      - name: Gate 5 - Generate review summary
        if: always()
        run: |
          python scripts/generate-review-summary.py \
            --test-report test-report.json \
            --audit-report audit-report.json \
            --semgrep-report semgrep-report.json \
            --output summary.md

      - name: Post summary to PR
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const summary = fs.readFileSync('summary.md', 'utf8');
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: summary
            });

六、门禁编排与失败策略

6.1 门禁编排器

python
import asyncio
from dataclasses import dataclass
from enum import Enum

class GateStatus(Enum):
    PASSED = "passed"
    FAILED = "failed"
    SKIPPED = "skipped"
    WARNING = "warning"

@dataclass
class GateResult:
    name: str
    status: GateStatus
    detail: str
    duration_secs: float
    auto_fixable: bool = False

class QualityGatePipeline:
    """质量门禁流水线"""

    def __init__(self, config: dict):
        self.config = config
        self.results: list[GateResult] = []

    async def run(self, changed_files: list[str]) -> dict:
        """运行整条门禁流水线"""
        gates = [
            ("compile", self._run_compile),
            ("unit_test", self._run_unit_tests),
            ("lint", self._run_lint),
            ("security", self._run_security),
            ("review_summary", self._generate_review),
        ]

        for gate_name, gate_func in gates:
            gate_config = self.config.get("gates", {}).get(gate_name, {})
            if not gate_config.get("enabled", True):
                self.results.append(GateResult(gate_name, GateStatus.SKIPPED, "disabled", 0))
                continue

            result = await gate_func(changed_files, gate_config)
            self.results.append(result)

            # 阻塞级门禁失败 → 终止流水线
            if result.status == GateStatus.FAILED and gate_config.get("blocking", True):
                # 尝试自动修复
                if result.auto_fixable and gate_config.get("auto_fix", {}).get("enabled"):
                    fixed = await self._try_auto_fix(gate_name, changed_files, gate_config)
                    if fixed:
                        # 重新运行门禁
                        result = await gate_func(changed_files, gate_config)
                        self.results[-1] = result

                if result.status == GateStatus.FAILED:
                    break

        return self._build_report()

    async def _try_auto_fix(self, gate_name: str, files: list[str], config: dict) -> bool:
        """尝试自动修复门禁失败"""
        max_rounds = config.get("auto_fix", {}).get("max_fix_rounds", 2)
        fix_commands = config.get("auto_fix", {}).get("fix_commands", [])

        for round_num in range(max_rounds):
            for cmd in fix_commands:
                formatted = cmd.replace("{changed_files}", " ".join(files))
                proc = await asyncio.create_subprocess_shell(
                    formatted, cwd=self.config.get("repo_root", ".")
                )
                await proc.wait()

            # 重新检查
            return True  # 让调用者重新运行门禁

        return False

    def _build_report(self) -> dict:
        """构建流水线报告"""
        all_passed = all(r.status in (GateStatus.PASSED, GateStatus.SKIPPED, GateStatus.WARNING)
                        for r in self.results)
        return {
            "status": "passed" if all_passed else "failed",
            "gates": {r.name: {
                "status": r.status.value,
                "detail": r.detail,
                "duration_secs": r.duration_secs,
            } for r in self.results},
            "total_duration": sum(r.duration_secs for r in self.results),
        }

6.2 失败回退策略

门禁失败后,系统按以下策略处理:

yaml
# failure-strategy.yaml
on_gate_failure:
  # 策略 1:自动修复(适用于 Lint、格式化问题)
  auto_fix:
    enabled: true
    applicable_gates: ["lint", "compile"]
    max_rounds: 2
    # 修复后重新运行门禁

  # 策略 2:回退给 Agent(适用于测试失败)
  agent_retry:
    enabled: true
    applicable_gates: ["unit_test"]
    max_retries: 2
    # 把测试失败的错误信息注入 Agent 上下文,让它修复
    inject_failure_context: true
    context_template: |
      以下测试用例失败了,请修复:
      {{#each failed_tests}}
      - {{name}}: {{message}}
      {{/each}}

  # 策略 3:标记人工介入(适用于安全问题)
  human_intervention:
    enabled: true
    applicable_gates: ["security"]
    # 通知团队安全审查
    notify:
      channel: "#security-alerts"
      template: "Agent 产出的代码存在安全问题,需要人工审查"

  # 策略 4:放弃任务(多次修复都失败)
  abort:
    trigger: "all_retries_exhausted"
    # 标记任务失败,保留现场
    keep_artifacts: true

七、真实经验与踩坑

7.1 测试覆盖率门槛不能一刀切

场景:我们最初把行覆盖率设为 80%,结果 Agent 为了凑覆盖率写了大量无意义的测试——比如测试 console.log 是否被调用、测试构造函数是否正确赋值。这些测试不验证任何业务逻辑,纯粹是为了数字。

问题:覆盖率是一个"虚荣指标"。80% 的覆盖率 + 0% 的断言质量 = 0% 的信心。

解决:改为检查"变更文件的测试是否存在"和"关键路径的断言数量",而不是追求覆盖率数字。

python
# 错误做法:只看覆盖率
if coverage < 80:
    return GateResult.FAILED

# 正确做法:检查测试是否存在且有意义的断言
def check_test_quality(test_file: str) -> bool:
    content = Path(test_file).read_text()
    # 检查是否有真正的断言(不是 toBeDefined)
    meaningful_asserts = re.findall(
        r'expect\(.*\)\.(toBe|toEqual|toContain|toThrow|toHaveProperty)',
        content
    )
    return len(meaningful_asserts) >= 2  # 至少 2 个有意义的断言

7.2 安全扫描的误报会拖垮整个流水线

场景:Semgrep 把测试文件中的 SQL 字符串(用于测试 ORM)标记为 SQL 注入风险。每次 Agent 修改测试文件,安全门禁都会失败,Agent 尝试"修复"后反而破坏了测试逻辑。

问题:安全扫描对测试文件的误报率很高——测试中的硬编码密钥、SQL 字符串、XSS payload 都是测试数据,不是真实漏洞。

解决:安全扫描默认排除测试文件和 mock 数据目录,同时提供误报抑制配置。

yaml
security:
  scan_paths:
    include: ["src/**"]
    exclude:
      - "src/**/*.test.*"
      - "src/**/*.spec.*"
      - "tests/**"
      - "**/__mocks__/**"
      - "**/*.fixture.*"
  suppress_rules:
    - id: "hardcoded-secret"
      paths: ["**/*.test.*", "**/fixtures/**"]
      reason: "测试中的假密钥不是真实漏洞"

7.3 门禁流水线太慢会拖慢 Agent 效率

场景:完整的门禁流水线(编译 + 测试 + Lint + 安全扫描)耗时 12 分钟。Agent 的修复任务本身只用了 3 分钟。门禁比工作还慢,而且每次 Agent 自动修复后都要重新跑一遍。

问题:门禁流水线应该根据变更范围动态调整——改一行 CSS 不需要跑安全扫描。

解决:引入"门禁分级"机制,根据变更文件类型和范围动态选择运行哪些门禁。

python
def select_gates(changed_files: list[str]) -> list[str]:
    """根据变更文件选择需要运行的门禁"""
    gates = ["compile"]  # 编译检查永远运行

    has_code = any(f.endswith(('.ts', '.js', '.py', '.go')) for f in changed_files)
    has_deps = any(f in ('package.json', 'package-lock.json', 'requirements.txt') for f in changed_files)
    has_config = any(f.endswith(('.yaml', '.yml', '.json', '.toml')) for f in changed_files)

    if has_code:
        gates.extend(["unit_test", "lint", "security"])
    if has_deps:
        gates.append("dependency_audit")
    if has_code or has_config:
        gates.append("review_summary")

    return gates

八、参数说明表

参数 类型 默认值 说明
gate.enabled Boolean true 门禁是否启用
gate.command String 门禁执行命令
gate.timeout_secs Integer 300 门禁超时秒数
gate.blocking Boolean true 是否为阻塞级门禁(失败终止流水线)
gate.blocking_level String error Lint 门禁中,触发阻塞的级别(error / warning)
coverage.min_line_coverage Integer 80 最低行覆盖率百分比
coverage.changed_files_only Boolean true 是否只检查变更文件的覆盖率
smart_test.enabled Boolean true 是否启用智能测试选择
auto_fix.enabled Boolean true 是否尝试自动修复
auto_fix.max_fix_rounds Integer 2 自动修复最大轮数
security.blocking_severity List ["critical", "high"] 触发阻塞的安全问题级别
security.scan_paths.exclude List 测试文件 安全扫描排除路径
on_failure.agent_retry.max_retries Integer 2 Agent 自动修复最大重试次数

九、落地检查清单

  • 单元测试通过:Agent 修改的代码对应的单元测试全部通过
  • 集成测试通过:涉及 API、数据库变更时,集成测试通过
  • Lint 零 Error:代码风格检查无 Error 级别问题(Warning 可接受)
  • 安全扫描通过:无 Critical / High 级别安全发现
  • 密钥无泄露:变更文件中无硬编码的密钥、Token、密码
  • 测试有断言:Agent 新增的测试用例包含有意义的断言(不是空壳测试)
  • Review 摘要生成:人类 Reviewer 能看到结构化的审查摘要和门禁结果
  • 失败自动修复:Lint 类问题 Agent 能自动修复(最多 2 轮)
  • 失败回退 Agent:测试失败时,错误信息注入 Agent 上下文让它修复
  • 人工介入通知:安全门禁失败时,团队收到通知并能快速定位问题
  • 门禁报告归档:每次门禁运行的完整报告(测试结果、Lint 输出、安全发现)被归档