为AI编程智能体构建护栏

如何把反复出现的代码评审意见变成自动化检查、验证它们确实能抓出缺陷,并判断哪些环节仍然需要人工评审。

为AI编程智能体构建护栏
博途PLC工程智能体 | AI智能体博途网关 | 博途PLC程序知识图谱 | 梯形图转SCL | 自然语言生成梯形图 | 自然语言生成SCL | 逆向生成程序块文档 | 梯形图在线查看 | 博途编程文档MCP | AI模型价格对比 | AI工具导航 | ONNX模型库 | Vibe Coding教程 | PLC在线仿真器 | Tripo 3D | Meshy AI

AI 编程智能体可能在上一次评审中抓到一个 bug,下一次却漏掉它。添加提示词或者再加一个评审者,仍然需要有人去读代码并判断它是否满足需求。每一次评审都要消耗时间和 token,哪怕同一条需求已经被检查过。

我搭建了一个 Go 仓库,把选定的代码评审需求转化为自动化检查。它把 linter 配置与行为测试结合起来,还有一个追踪间接导入的架构测试,以及用故意破坏的代码来验证这些检查的脚本。

我把这些检查称为护栏(guardrails):一次代码变更在被接受之前必须满足的条件。例如,一个必须在取消后停止的 goroutine,会得到针对该行为的测试;一条"报表计算必须独立于存储"的规则,会得到对包依赖图的检查。智能体收到一条它可以据此行动的诊断信息,然后在修复后运行同样的检查。

通过的测试可能漏掉缺陷,失败的命令也可能只是构建错误或超时。我既用有缺陷的实现、也用工具自身的故障来验证这些检查。

下面的章节说明如何构造这些检查、确立它们能检测什么,以及识别哪些决策仍然需要人工评审。示例使用一个模拟的报表服务,让每条需求和每种失败都可复现。

本文假设你已经了解 Go 测试、context、channel 和 CI。仓库包含源代码、配置和复现命令。这里展示的命令输出是在 Go 1.27.1 和 golangci-lint 2.13.2 下得到的。这些版本是固定的,以便你能复现结果。

1、各项检查如何配合

每条需求都需要一个能够观察到相关失败的检查。命名规则可以检查源代码;取消测试必须真正执行 goroutine 并等待它结束;对间接依赖的限制则需要遍历包的导入图。我围绕报表服务的需求组合了这些检查:

领域 需求或评审关注点 检查手段
格式与命名 遵循约定的格式和命名规范 gofmt 与命名规则
复杂度与重复 标记超出约定限额或重复现有逻辑的代码 静态分析,随后人工评审
错误与资源 关闭资源,并按契约返回所需错误 Linter 与带受控故障的测试
并发 取消之后仍完成工作;在受测场景中检测泄漏、死锁和数据竞争 同步测试、泄漏检查和竞态检测器
架构 让计算独立于存储,包括经由中间包 导入规则与一个遍历依赖的测试
测试质量 检查所需结果,并检测选定的行为变化 结果比对、字段初始化规则和变异测试

验证脚本会在临时副本中制造有缺陷的变体,运行相关命令,并检查其诊断输出。另有独立的用例检查构建错误和超时会被判定为无效证据,而不能算作"检测到了违规"。

这些脚本验证的是护栏本身。普通的测试和 linter 仍然是用来接受代码变更的命令。把这两种用途分开很重要:一个验证脚本可以通过"成功地让测试在损坏的代码上失败"来判定为通过。

复现这些检查

在示例仓库的根目录运行这些命令。先为终端会话选定 Go 1.27.1。在 fish 中:
set -gx GOTOOLCHAIN go1.27.1
go version
golangci-lint version

第一个版本检查应报告 Go 1.27.1,第二个应报告由 Go 1.27.1 构建的 golangci-lint 2.13.2。README涵盖了工具和依赖的安装。

每个新会话都要重新设置 GOTOOLCHAIN。go.mod 中的 go 1.27.0 只是设定最低版本,并不会把工具链固定在 1.27.1。

2、自动化格式与命名检查

格式与命名是其余检查的基线。一旦团队就某项约定达成一致,工具就可以在每次变更中应用或检查它。gofmt 对当前目录及其子目录下的文件应用标准 Go 格式:

gofmt -w .

命名约定需要单独的规则。例如,首字母缩写 ID 应当大写。下面这个声明违反了该约定:

type request struct {
    UserId int
}

revive 中的 var-naming 规则会报告这个问题以及期望的拼写:

testdata/naming/naming.go:5:2: var-naming: struct field UserId should be UserID (revive)

智能体可以把 UserId 改成 UserID 然后重新运行检查。而选择一个能表达领域含义的名字(比如 account 或 customer),仍然需要开发者的判断。

启用某条规则之前,先和团队达成一致,并在现有代码上试运行。否则,一个小任务可能积累出一堆无关的重命名。

2.1 配置并运行检查。

要只检查格式而不修改文件,使用 gofmt -l .。它会列出需要格式化的文件,但仍然以成功状态退出。应把 CI 配置成在该列表非空时失败。gofmt 文档描述了各个标志。

我通过 golangci-lint 运行 revive,它可以运行多个分析器。linter 目录列出了可用的检查项。为了单独验证命名规则,这份配置只启用它而不启用无关规则。将其保存为 naming.yml:

version: "2"
linters:
  default: none
  enable:
    - revive
  settings:
    revive:
      enable-default-rules: false
      rules:
        - name: var-naming

这会禁用两组默认项:default: none 禁用 golangci-lint 的默认 linter;enable-default-rules: false 禁用 revive 的默认规则。这样得到的诊断就可以针对具体的命名违规进行核对。配置文档解释了其结构。

在示例模块目录中运行:

golangci-lint run --config naming.yml ./testdata/naming

路径要写明确,因为 ./... 会跳过 testdata。该命令会报告上面那条诊断,包括文件、位置和 linter 名称。

3、明确复杂度检查要约束什么


能工作的代码在智能体修改之后仍可能变得更难维护。嵌套条件增加了需要追踪的路径,重复的逻辑制造出多处需要同步更新的地方。静态分析可以标记这些改动供评审,但规则必须表达出团队想要控制的内容。

复杂度阈值为函数设定一个上限。要防止复杂度上升,就需要与之前的版本做比较。这是两种不同的验收标准:阈值设为 30 时,分数从 20 升到 25 依然通过。

3.1 让阈值违规可复现。

对于仓库中的复杂度检查,我用了 gocognit,以及一个嵌套条件得分可预测的函数。CanExport 在三个条件都成立时允许导出:

func CanExport(active, allowed, ready bool) bool {
    if active {
        if allowed {
            if ready {
                return true
            }
        }
    }
    return false
}

gocognit 为控制流分配一个认知复杂度分数。在这里,三层嵌套的 if 语句分别贡献 1、2 和 3,合计 6。每增加一层嵌套,贡献值就会增加。gocognit 规则解释了这一计算方式。

我把阈值设为 3,让这个实现触发一条已知诊断:

cognitive complexity 6 of func `CanExport` is high (> 3)

这个阈值是测试用例的一部分。为项目选择阈值时,应先考察其现有代码,并与团队就限额达成一致。

合并这些条件可以保持行为不变:

func CanExport(active, allowed, ready bool) bool {
    return active && allowed && ready
}

诊断指出了智能体需要修改的函数。但评审仍须评估产出的代码:把一个函数拆成许多辅助函数可以降低分数,却可能让执行流程更难追踪。

3.2 让发现在 diff 中保持可见。

现有项目在某条规则刚启用时可能已有大量违规。把发现结果过滤到变更行有助于聚焦补丁,但复杂度诊断的位置可能让这个过滤器隐藏掉新的违规。

诊断可能指向函数声明。如果智能体只改了函数体,声明就在 diff 之外。linter 能检测到违规,而过滤器可能把它从上报结果中移除。

在修订过滤模式下,--whole-files 会针对整个已变更文件报告发现。这包含了未改动的声明,但也会把文件中其他位置的既有违规一起带回来。CLI 文档解释了这些过滤器。

如果需求是阻止复杂度的任何增长,就需要以 main 之类的分支为基线,比较函数改动前后的分数。这里展示的阈值检查并没有实现这种比较。要一起确定验收标准和报告范围,确保相关发现能够到达智能体和评审者。

3.3 用其他指标聚焦评审。

复杂度只是维护工作的一个来源。其他分析器能识别出需要不同评审决策的代码:

工具 它发现什么
funlen 超出配置长度上限的函数
dupl 可能是重复的相似代码片段
unused 被分析包中未使用的声明

重复发现是一个提示,让人决定这些片段是否应该共享一份实现。引入接口或包装器仍然需要设计上的理由。分析器也有技术局限:例如 unused 不会仅仅因为项目中没人调用就标记一个导出的声明。

4、验证资源清理与错误传播


资源检查需要覆盖操作失败时的状况。对于 Download,我在成功的 HTTP 请求之后定义了契约:恰好关闭一次响应体,保留已读取的任何数据,并在读取错误和关闭错误同时发生时把两者都返回。

我把针对缺失 close 的 linter 检查与该契约的行为测试配对。测试可以独立控制读取失败和关闭失败,包括"读取先返回数据、随后失败"的情况。

4.1 关闭 body 并保留两个错误。

这个实现读取了响应体但没有关闭它:

func Download(url string) ([]byte, error) {
    response, err := http.Get(url)
    if err != nil {
        return nil, err
    }
    return io.ReadAll(response.Body)
}

bodyclose 能在这个实现中检测到缺失的关闭。通过 defer 关闭响应体,可以在读取成功或失败时都完成清理。defer 的函数还需要保留契约所要求的关闭错误:

func Download(url string) (data []byte, err error) {
    response, err := http.Get(url)
    if err != nil {
        return nil, err
    }
    defer func() {
        err = errors.Join(err, response.Body.Close())
    }()
    return io.ReadAll(response.Body)
}

return 语句把 io.ReadAll 的结果赋给具名返回变量 data 和 err。随后 defer 的函数关闭响应体,并用 errors.Join 把关闭错误与读取错误合并。调用方在该 defer 函数执行完毕后才拿到结果。

当两个操作都成功时,返回的错误是 nil。当其中任一失败时,调用方可以用 errors.Is 检查返回的错误。函数可能同时返回数据和错误,所以拿到数据本身并不能说明成功。

4.2 测试各种失败组合。

我用 TestDownloadReadAndClose 检查读取与关闭结果的全部四种组合:

读取结果 关闭结果 期望错误
成功 成功 nil
返回数据后失败 成功 包含读取错误
成功 失败 包含关闭错误
返回数据后失败 失败 同时包含两个错误

每个用例还会检查 Close 恰好被调用一次,以及返回的数据与响应体提供的内容一致。检查部分数据很重要:一个丢弃这些字节的改动即使正确关闭了响应体并返回了预期错误,也仍然违反契约。

测试提供一个 http.RoundTripper,它返回一个受控的响应而不发起网络请求。其响应体会统计 Close 的调用次数,并能返回配置好的关闭错误。对于读取失败,reader 先提供数据、再返回配置的错误。这样每种失败组合都可以复现,而不依赖网络行为。

因为 Download 调用了 http.Get,测试会临时替换 http.DefaultClient。子测试按顺序运行,每个用例之后恢复原始客户端。运行测试:

go test -race -count=1 -run '^TestDownloadReadAndClose$' ./internal/download

修复之后,bodyclose 的发现消失,函数通过了仓库的基线 linter。行为测试覆盖上述四种场景中的契约。bodyclose 识别的是已知的代码模式,通过这些检查并不能证明在每一条可能的执行路径上都完成了清理。

这里的范围是响应体清理与错误传播。超时、context 传播和 HTTP 状态处理需要各自的需求和检查。

4.3 把清理检查应用到其他资源。

带超时的 context 同样需要清理。context.WithTimeout 返回一个 cancel 函数;把它 defer 起来会在函数返回时释放相关资源,而不必等待超时:

ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()

把 cancel 换成 _,会让 go vet 的 lostcancel 检查报告缺失的取消调用。

errcheck linter 有助于发现被忽略的错误。如果代码检查了错误却返回成功,nilerr 能检测出其中一部分情况。

对每个依赖,要定义它的失败对调用方意味着什么。然后在测试中让该依赖失败,并检查所需的响应。这份契约决定了函数应当传播错误、保留部分结果,还是返回另一个约定的结果。

5、测试取消、死锁与数据竞争


对于并发代码,我分别为"取消后完成工作"、"被阻塞的 goroutine"和"对共享数据的冲突访问"构建了检查。每个检查都需要一个能到达该失败的场景,以及一条能标识它的诊断。仅凭超时无法区分死锁和执行缓慢。

取消测试从一个可观察的契约开始:在没有接收方可用时,发送方等待;取消之后,它结束并发出完成信号。

5.1 让完成状态可观察。

一个 goroutine 在无缓冲 channel 上发送结果。如果接收方停止读取,发送就会一直阻塞。处理 context 取消给了 goroutine 一条停止等待的出路。

Forward 向 out 发送一个值,并在完成时关闭 done。调用方可以在 done 上等待完成:

func Forward(ctx context.Context, out chan<- int, value int) <-chan struct{} {
    done := make(chan struct{})
    go func() {
        defer close(done)
        select {
        case out <- value:
        case <-ctx.Done():
        }
    }()
    return done
}

如果有接收方就绪,goroutine 可以发送该值并返回;如果发送被阻塞,取消让它得以返回。两条路径都会关闭 done。

调用方在不再需要结果时必须取消该操作,并用 <-done 等待完成。Forward 不关闭 out,因为该 channel 由调用方拥有。

如果发送和取消同时就绪,select 可能选择发送。这个实现不保证取消之后不再发送任何值。

我用 testing/synctest 来确认发送方在取消前确实处于阻塞状态。它让测试与 goroutine 同步,而不必挑选任意的 sleep 时长:

func TestForwardCancellation(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        ctx, cancel := context.WithCancel(t.Context())
        defer cancel()

        out := make(chan int) // There is deliberately no receiver.
        done := Forward(ctx, out, 42)

        synctest.Wait()
        select {
        case <-done:
            t.Fatal("sender completed before cancellation without a receiver")
        default:
        }

        cancel()
        <-done
    })
}

在 synctest.Wait() 之后,测试检查 done 仍然处于打开状态。没有这个断言,一个不发送任何内容就直接返回的函数也能通过。

随后测试取消 context 并等待 done 关闭。如果发送方仍然阻塞,synctest 会报告组内死锁。在这个场景中,该失败检测到的是"取消之后没有结束的 goroutine"。

另一个 TestForwardDelivery 检查值 42 的投递以及发送后的完成。两个测试合起来覆盖了成功投递和取消后完成这两种情况。

5.2 理解同步边界。

synctest.Test 在一个称为 bubble 的隔离组中运行测试及其创建的 goroutine。synctest.Wait() 会等待其他 goroutine 要么结束、要么进入持久阻塞。它无法区分"正在等待的发送方"和"已经结束的发送方",因此测试另外检查 done。

channel 是在 bubble 内部创建的。等待外部 channel 可能依赖 bubble 之外的事件,不计入持久阻塞。在这个示例中没有接收方,发送方在测试取消 context 之前无法继续。

如果发送方忽略取消,或者没有关闭 done,测试就无法完成 <-done 的等待。在这种隔离场景下,由于 bubble 中所有 goroutine 都处于阻塞,synctest 会报告死锁。

为了检查测试本身,我在验证脚本中加入了五个有缺陷的实现:不发送就直接结束、缺少取消分支、缺少对 done 的关闭、只在阻塞发送之前检查取消,以及无条件发送。脚本为每个变体验证预期的失败。另有一个改动把 42 换成 43,以确认投递测试确实检查了值。

这个测试确立了对显式取消的响应。当接收方提前退出时调用方是否取消,需要单独的测试。

t.Context() 的自动取消可能掩盖这里的 bug。该 context 在测试函数返回后被取消。如果被测代码没有取消,而测试又在未等待完成的情况下返回,测试自身的取消可能让它通过。在测试体内等待 <-done 才能暴露死锁。

5.3 在普通测试中发现残留的 goroutine。

操作结束后,goroutine 可能仍在运行或等待。goleak 有助于在 synctest 之外的测试中检测这些泄漏。

首先运行场景并等待工作结束,例如等待 done 关闭;然后让 goleak 检查是否还有残留的 goroutine。它的重试和短等待不能替代测试内部对完成的等待。

根据测试的运行方式来选择检查:

  • 对于顺序执行的测试,VerifyNone 检查单个测试之后残留的 goroutine。
  • 对于并行测试,VerifyTestMain 在包内所有测试结束后检查。否则,属于另一个运行中测试的 goroutine 可能被误判为泄漏。

取消测试用 synctest 检测阻塞的发送方;我在普通的投递测试中使用了 goleak,并加入了一个故意泄漏的发送方,以验证泄漏检查确实能发现它。

5.4 复现 channel 与互斥锁死锁。

假设两个 goroutine 通过无缓冲 channel 交换数据。双方都先发送、并计划随后接收。在 synctest 中复现这种顺序:

func TestDeadlock(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        left := make(chan struct{})
        right := make(chan struct{})
        go func() {
            left <- struct{}{}
            <-right
        }()
        right <- struct{}{}
        <-left
    })
}

新创建的 goroutine 在向 left 发送时阻塞;测试 goroutine 在向 right 发送时阻塞。双方都需要一个接收方,但谁也到不了自己的接收操作。

在模块目录中运行该示例:

go test -count=1 -run '^TestDeadlock$' ./testdata/deadlock

synctest 会让测试失败,报告 deadlock: all goroutines in bubble are blocked。栈中显示了两处阻塞的发送。进行这项检查时,要像示例那样在 bubble 内部创建 channel 和 goroutine。

互斥锁的等待需要不同的处理。这里,一个 goroutine 试图获取它自己已持有的互斥锁:

func TestMutexDeadlock(t *testing.T) {
    var mu sync.Mutex
    mu.Lock()
    defer mu.Unlock()

    mu.Lock() // Waits for the mutex that is already locked.
}

第二次 Lock 阻止了函数返回,于是 defer 的 Unlock 永远不会执行。对 sync.Mutex 的等待不被 synctest 视为持久阻塞。带超时运行这个测试:

go test -count=1 -timeout=2s -run '^TestMutexDeadlock$' ./testdata/deadlock

超时后,栈中显示第二个 Lock。超时本身只告诉我们测试没有按时完成;原因要由代码和栈来确定。要测试多个锁之间顺序不一致导致的死锁,就需要复现那个特定的顺序。

外部 I/O 和系统调用同样不属于持久阻塞。testing/synctest 文档描述了这些限制。

5.5 检测数据竞争。

两个 goroutine 对同一个计数器做自增。WaitGroup 让测试能等待它们,但并不同步它们对计数器的访问:

func TestRace(t *testing.T) {
    var value int
    var wg sync.WaitGroup
    for range 2 {
        wg.Go(func() { value++ })
    }
    wg.Wait()
    t.Log(value)
}

value++ 包含读取、自增和写入。这些 goroutine 在不同步的情况下访问同一个变量,造成数据竞争。即使某次运行打印出 2,代码依然是错的。

用竞态检测器运行该示例:

go test -race -count=1 ./testdata/race

检测器会报告 WARNING: DATA RACE,并指向 value++ 处的冲突访问。即使测试打印出了预期的计数值,它仍然失败。要用互斥锁保护自增,或者使用原子操作。

-race 启用检测器,-count=1 让测试重新运行而不使用缓存结果。要测试你项目中的包,把 ./testdata/race 换成 ./...。通配符会跳过 testdata,这就是本示例使用显式路径的原因。

竞态检测器观察的是测试执行过程。如果冲突的访问组合在那次运行中没有出现,竞争可能检测不到。通过的运行不能保证代码没有竞争,也完全不能说明死锁问题。竞态检测器文档解释了它的用法。

验证脚本把泄漏、channel 死锁和数据竞争三类用例分开,并各自检查对应的诊断。它还运行一个互斥锁超时用例,并确认验证器把它判为无效证据,即不能算作 synctest 死锁的证据。这样可以防止"一条失败的命令"被报告成"成功检测到了错误类型的失败"。

6、强制执行包依赖规则

对于报表服务,我编码了一条架构规则:internal/reportcalc 下的包必须独立于 internal/storage 及其子包。该限制既覆盖直接导入,也覆盖经由中间包产生的依赖。

我对直接导入使用 depguard,并为完整的导入链构建了一个架构测试。两项检查都基于包边界,因此规则能覆盖 storage 的子包,却不会误伤名字相似的无关包。

6.1 用 linter 检查直接导入。

depguard 检查所选文件中的导入。我把它配置为:当计算代码导入 storage 时报告违规。

这份 golangci-lint 2.13.2 配置覆盖计算包及其子包,排除测试文件:

version: "2"
linters:
  default: none
  enable:
    - depguard
  settings:
    depguard:
      rules:
        calculation:
          files:
            - '**/internal/reportcalc/*.go'
            - '**/internal/reportcalc/**/*.go'
            - '!$test'
          deny:
            - pkg: example.com/guardrails/internal/storage$
              desc: calculations must not depend on storage
            - pkg: example.com/guardrails/internal/storage/
              desc: calculations must not depend on storage

files 选择要检查的源文件。前两个模式覆盖计算目录及其所有后代;!$test 排除测试。如果架构规则同样适用于测试文件,就去掉这个排除项。

deny 使用完整的导入路径。example.com/guardrails 是示例模块的名称。下面两条条目区分了这些情况(相对该模块路径展示):

导入 结果
internal/storage 禁止:$ 要求精确匹配
internal/storage/reader 禁止:结尾的 / 覆盖子包
internal/storagecache 该规则允许:它是另一个独立的包

只用一个以 storage 结尾的前缀,同样会匹配到 storagecache;而仅用精确匹配又会漏掉 storage/reader。仓库对这三种情况都做了检查。depguard 文档解释了匹配规则。

6.2 通过导入图测试间接依赖

智能体可能会复用 internal/shared,而它本身导入了 storage:

internal/reportcalc -> internal/shared -> internal/storage
架构规则覆盖整条导入链,包括经由共享包产生的依赖。

架构规则覆盖整条导入链,包括经由共享包产生的依赖。

已配置的 depguard 规则会接受这个改动,因为计算代码并没有直接导入 storage。但依赖确实经由 shared 存在。

架构测试加载这些包、确认所需包存在,然后遍历它们的导入。如果计算代码能够到达 storage,测试就会失败并报告依赖路径:

ARCH001: calculation depends on storage: example.com/guardrails/internal/reportcalc -> example.com/guardrails/internal/shared -> example.com/guardrails/internal/storage

智能体可以看到是哪个包引入了被禁止的依赖。修复它可能需要移动一份共享的计算逻辑,或者在保持程序行为不变的前提下调整依赖方向。

我还让配置失败变得可见。包被重命名之后,旧路径可能不再匹配任何东西,导致这条规则没有检查任何代码。如果规则两侧有任何一侧缺失,或者包图无法加载,测试都会失败。因此,成功的结果意味着预期的包确实存在并被检查了。

它的范围是所选构建的导入图。如果平台或构建标签改变了包含的文件,需要单独检查那些配置。通过网络访问 storage 可以在没有 Go 包依赖的情况下发生,所以这个测试抓不到它。

6.3 加载并验证包图。

测试用 golang.org/x/tools/go/packages 加载包。NeedDeps 提供遍历所需的依赖。我还请求了类型信息并检查加载错误,让不完整或无效的图无法产生通过的结果。

在这段代码中,calculationRoot 是 example.com/guardrails/internal/reportcalc,storageRoot 是 example.com/guardrails/internal/storage。Tests: false 排除测试文件,与上面的 depguard 配置保持一致。辅助函数及其测试位于 architecture_test.go。

func TestArchitecture(t *testing.T) {
    pkgs, err := packages.Load(&packages.Config{
        Mode:  packages.NeedName | packages.NeedImports | packages.NeedDeps | packages.NeedTypes,
        Tests: false,
    }, "./internal/...")
    if err != nil {
        t.Fatal(err)
    }
    if len(pkgs) == 0 || packages.PrintErrors(pkgs) > 0 {
        t.Fatal("cannot load the production package graph")
    }
    targetFound := false
    for _, pkg := range pkgs {
        if pkg.PkgPath == storageRoot {
            targetFound = true
        }
    }
    if !targetFound {
        t.Fatal("architecture target package missing; review storageRoot")
    }
    checked := 0
    for _, pkg := range pkgs {
        if !inside(pkg.PkgPath, calculationRoot) {
            continue
        }
        checked++
        if chain := forbiddenPath(pkg, storageRoot, make(map[string]bool)); chain != nil {
            t.Errorf("ARCH001: calculation depends on storage: %s", strings.Join(chain, " -> "))
        }
    }
    if checked == 0 {
        t.Fatal("no calculation packages checked; review the architecture rule")
    }
}

inside 选择一个包及其子包,并尊重 / 分隔符。因此,针对 storage 的规则不会包含 storagecache。

forbiddenPath 遍历导入并返回一条通往被禁止包的链。它跳过已访问过的包,并在遍历前对导入排序。当存在多条被禁止的路径时,诊断始终报告同一条。

我用独立的用例验证了这些防护措施:storage 包缺失、计算包集合为空、包图加载失败。包边界的用例检查了直接依赖、对 storage/reader 的依赖,以及被允许的相邻包 storagecache。这些用例同时测试了漏检和误拒两种情况。

构建标签很容易丢失。go test -tags 不会自动把它的标签传给嵌套的 packages.Load 调用。测试可能带着标签运行,加载的却是没有这些标签的图。要通过 BuildFlags 或共享的 GOFLAGS 环境变量来设置它们;仓库检查的是后者。如果你想检查测试文件,也需要显式包含它们。参见 go/packages 配置。

7、检查测试是否真能抓出缺陷

智能体的测试会成为验收标准的一部分,因此它们本身也需要审视。我加入了有缺陷的实现,用来暴露测试可能错误通过的两种方式:从同一个有缺陷的表达式推导期望值,以及只比较结果的一部分。变异测试随后检查选定的行为变化是否会导致测试失败。

7.1 从需求推导期望值。

当智能体既写实现又写测试时,它可能把同一个错误犯两遍。假设需求允许取值到 10(含 10),但函数拒绝了 10:

func Allowed(size int) bool {
    return size < 10
}

测试用同一个表达式计算期望值:

size := 10
want := size < 10
got := Allowed(size)

if got != want {
    t.Fatalf("Allowed(%d) = %v, want %v", size, got, want)
}

want 和 got 都是 false。即使函数违反了需求,测试依然通过。改为从需求设定期望值:

want := true // The requirement allows 10.
got := Allowed(10)

if got != want {
    t.Fatalf("Allowed(10) = %v, want %v", got, want)
}

现在测试能抓出这个 bug:它期望 true,却得到 false。如果计算期望值的依据来自需求、约定的例子或独立的性质,而不是重复被测算法,那么计算期望值同样是有效的。

7.2 比较真正重要的字段。

ToView 应当把一个标识符和一个区域从记录复制到报表视图中。只检查 got.ID == 7 的测试会漏掉丢失的区域。

type View struct {
    ID     int
    Region string
}

给出完整的结果期望:

input := Record{ID: 7, Region: "north"}
want := View{ID: 7, Region: "north"}

if got := ToView(input); got != want {
    t.Fatalf("ToView() = %+v, want %+v", got, want)
}

现在测试比较了两个字段,能检测出区域为空而不是 "north"。对于这个结构体,!= 运算符是可用的。对于其他数据,要选择与其含义相匹配的比较方式。

之后的 schema 变更可能制造出另一个缺口。假设报表新增了一个货币字段:

type View struct {
    ID       int
    Region   string
    Currency string
}

如果 ToView 和期望结果都没有变化,那么两侧的 Currency 都是零值(空字符串),测试再次通过。

exhaustruct 要求在 View{...} 这类字面量中列出所有字段,包括实现的结果和测试的期望值。我加入了一个用例:新的 Currency 字段让相等性测试依然通过,却触发了 linter。这检查了结果类型的变更是否会迫使实现和测试期望被重新审视。

linter 无法选择正确的值。在两处都显式写上 Currency: "" 可以消除该发现。要检查字段是否被复制,测试需要一个从需求得出的具体期望货币值。如果空字符串是合法的,那就不是 bug。

7.3 配置显式字段初始化。

比较语义很重要。指针相等比较的是指针,而不是对象的内容。对 time.Time 使用 == 会同时比较其内部表示和时间点。包含切片或 map 的结构体根本无法用 == 比较。忽略字段或元素顺序同样必须遵循需求。

在 golangci-lint 2.13 中,早期的 v4 exhaustruct 分析器已废弃,因此仓库使用 exhaustruct_v5:

version: "2"
linters:
  default: none
  enable:
    - exhaustruct_v5
  settings:
    exhaustruct_v5:
      explicit-mode: true
      enforce-patterns:
        - '^example\.com/guardrails/internal/report\.View$'

这个正则通过完整的包路径和类型名选中 View。没有 explicit-mode: true 时,分析器默认会检查结构体字面量(在其排除规则之下)。错误的路径可能一个类型都选不中——配置语法校验抓不到这一点,所以要用一个已知缺失字段的用例来测试规则。

这项检查覆盖所选类型的字面量,即 View{...}。它不保证字段通过 var、new、后续赋值、复制或类型转换被填充。抑制指令和排除项同样会削弱这项检查;本示例两者都没有使用。

这些设置适用于 v5,其行为记录在它的 README 中。升级分析器之后,要重新确认它覆盖哪些类型和初始化形式。verify.py 展示了输出限制和其他执行细节。

7.4 用变异暴露缺失的用例。

正确的期望值仍然需要正确的测试用例。回到那个接受取值到 10(含 10)的函数,此时它的实现是正确的:

func Allowed(size int) bool {
    return size <= 10
}

假设测试只检查 Allowed(9) 并期望 true。把 <= 换成 < 之后,该测试依然通过,尽管函数现在拒绝了 10。

变异测试把这一实验自动化:工具修改代码并运行测试。每个被修改的版本是一个变异体(mutant)。当测试检测到行为变化时,变异体被杀死;当测试通过时,它存活。

我用两套测试对 Allowed 运行了 go-mutesting:一套只覆盖 9,另一套还覆盖边界值。它产生了三个变异,由仓库的变异执行器分别检查:

被变异的条件 检测它的用例 期望结果与变异后结果
size < 10 Allowed(10) 期望 true,得到 false
size <= 9 Allowed(10) 期望 true,得到 false
size <= 11 Allowed(11) 期望 false,得到 true

这三个条件对 9 都返回 true,所以最初的测试漏掉了每一个变异。补上针对 10 和 11、期望值取自需求的用例后,三个变异全部被杀死。这个实验找出了缺失的边界用例。

这一结果确立了:新增的边界用例能够检测这三处改动。该变异集只覆盖一个函数,并不能确立某个服务的完整测试覆盖。

7.5 验证什么才算"检测到变异"。

运行器如何解读一次失败,会影响结果。在本文使用的版本中,go-mutesting 内置的运行器可能把构建失败或超时计为 PASS,即认为变异已被检测到。

我实现了一个单独的执行器来读取 go test -json 事件。只有当目标测试运行并以预期的断言消息失败时,它才把变异体计为被杀死;该测试成功运行意味着变异体存活。构建失败、超时、panic、目标测试缺失或无关断言,都是无效结果。

执行器为每次运行保存变异后的源码、分类和命令输出,之后恢复原始文件。我用刻意制造的构建失败、超时、无关断言,以及一次不含目标测试的运行来检查分类。这四种情况都必须被拒绝为无效证据。

存活的变异可能保持了程序的行为,因此并不总是需要新测试。反过来,一个期望值错误的测试可能在保护一个有缺陷的实现。变异测试无法确立需求或期望结果本身是否正确。

每个变异体都需要一次测试运行。从小块代码开始,测量耗时并检查结果。过大的变异集在每次编辑后都跑,代价可能太高。

示例使用 avito-tech/go-mutesting 分支。仓库 README 固定了版本并提供了命令。内置运行器测试的是包含被改动文件的那个包。如果其他包中的消费方测试覆盖了该行为,就需要另行安排运行。在本示例中,函数及其测试位于同一个包。 变异算子也因工具而异。某个工具可能不支持添加结构体字段,或不支持移除你想要检查的那条赋值。因此,仓库用一处独立的代码改动来检查前面示例中丢失的字段。

Practical Mutation Testing at Scale: A View from Google(2021)描述了一种评审工作流:面向变更后的代码、过滤掉可能无关的变异,并限制每行和每次评审中的变异数量。该论文并非针对 Go 或编程智能体,但它解释了随着工作量增长,变异选择为何重要。

8、给智能体一个可复现的验收测试

这些检查现在可以为编程智能体定义一个具体任务。对于 Forward,任务要指明所需行为,以及用来判定改动是否满足该行为的命令。

我复现了该故障、应用了修复,并在仓库上手动重跑了这些检查。下面的输出来自那些命令运行;智能体指令则是使用同样检查的一种建议方式。

从 Forward 中移除取消处理:

select {
case out <- value:
}

函数的其余部分(包括 defer close(done))保持不变。有接收方时投递依然正常;没有接收方时,goroutine 即使在取消之后也继续等待。

8.1 说明改动与验收标准。

项目中已经有 TestForwardCancellation。给智能体这样的任务:

修复 internal/report/report.go 中的 Forward:当没有接收方时,context 取消必须让发送方停下来。保留成功投递和 done 的关闭。先运行取消测试。修复之后重跑它,并用竞态检测器运行模块测试。如果某项检查你跑不起来,说明原因。对测试或契约的任何改动都要单独给出理由。

测试的改动需要自己的理由,因为智能体可以在不修复根因的情况下消除一个失败。比如加上 t.Skip,问题被隐藏了,goroutine 却依然阻塞。

不用 synctest 重写测试,也可能让它失去检测该违规的能力。仅仅从上面的测试中删掉 <-done 不足以隐藏它:synctest 仍会报告阻塞的 goroutine。要对照最初的验收标准来评审测试改动。

8.2 在修复之前先运行测试。

在模块目录中、依赖已安装的情况下运行:

go test -count=1 -timeout=10s -run '^TestForwardCancellation$' ./internal/report

对于没有取消处理的版本,命令以退出码 1 结束。输出开头是:

--- FAIL: TestForwardCancellation (0.00s)
panic: deadlock: all goroutines in bubble are blocked [recovered, repanicked]

测试取消了 context 并等待发送方结束。发送方仍阻塞在 channel 发送上,所以 done 永远不会关闭。synctest 报告了死锁。

下面是同一次运行的栈摘录,机器路径和地址已用标记替换:

goroutine 7 [chan receive (durable), synctest bubble 1]:
example.com/guardrails/internal/report.TestForwardCancellation.func1(<address>)
    <experiment>/example/internal/report/report_test.go:33 +<address>

goroutine 8 [chan send (durable), synctest bubble 1]:
example.com/guardrails/internal/report.Forward.func1()
    <experiment>/example/internal/report/report.go:26 +<address>

测试 goroutine 正在等待从 done 接收;发送方阻塞在 out <- value。只要发送处于阻塞状态,defer close(done) 就无法运行。栈标明了等待的双方。

8.3 修复根因并重跑命令

给 select 加上取消分支:

 select {
 case out <- value:
+case <-ctx.Done():
 }

这就恢复了第 4 节中的实现。再次运行同一条命令:

go test -count=1 -timeout=10s -run '^TestForwardCancellation$' ./internal/report

修复之后,它以退出码 0 结束。某次运行的结果是:

ok      example.com/guardrails/internal/report  0.258s

执行时间取决于机器。

接下来,检查修复是否保留了其他行为。运行整个模块的测试,包括成功投递:

go test -race -count=1 -timeout=30s ./...

那次运行同样通过。在建议的工作流中,CI 会用相同的 Go 版本在被评审的修订上重复这条命令。取消测试是完整套件的一部分。

9、在把护栏设为强制之前先验证它

仓库的验证脚本既检查代码规则,也检查对其结果的解读。在项目中把新检查设为强制之前,可以应用同样的验证流程:验证配置、测试一个已知违规、并在现有代码上检查发现结果。

9.1 验证规则本身。

从配置验证开始。以复杂度示例为例:

golangci-lint config verify --config complexity.yml

该命令依据 schema 校验设置,并能捕捉 min-complexty 这类拼写错误——普通的 run 可能忽略它们。但合法的设置仍可能指向错误的代码。要用"本应通过的代码"和"本应产生预期诊断的已知违规"各跑一遍规则。

既要看退出码,也要看消息。一个启动失败的工具同样可能返回非零状态。

我在 verify.py 和 verify_mutation.py 中实现了这项验证。它们在临时副本中运行有缺陷的变体,并检查结构化输出。Go 测试验证器先检查目标包和测试确实运行了,再检查预期的诊断;linter 验证器检查分析器名称、源码位置和诊断文本。

保存的 verification.json 报告记录了在 macOS arm64 上、Go 1.27.1 与 golangci-lint 2.13.2 下的 50 个成功验证步骤。这些步骤包括配置验证、基线检查、已知违规,以及必须被拒绝为无效证据的用例。另一份 mutation-verification.json 报告记录了弱测试套件、边界测试套件和四组无效结果对照。

对这些脚本来说,检测到一次预期的测试失败就是一次成功的验证。而接受一次代码变更,仍然要求普通的测试和 linter 通过。

要特别留意那些用于选择文件、包或类型的设置。一个合法但错误的路径可能让目标代码完全没被检查——上面的 storageRoot 路径和 View 正则就是例子。README 涵盖了离线配置验证与复现命令。

9.2 阻断变更之前先评审发现结果

先在现有代码上运行规则。修正配置错误、就例外达成一致、评审已报告的违规,然后再让它阻断变更。

如果违规很多,先收集一份报告,然后在逐步修复旧违规的同时阻止新增违规。记住,按变更行过滤可能隐藏新的发现,正如复杂度示例所示。

9.3 打通智能体与 CI

把检查命令交给智能体。它的诊断应当指明原因:

  • linter 应当给出规则名、文件和行号。
  • 测试应当给出场景、期望结果和实际结果。
  • 架构测试应当展示被禁止的依赖链。

工作流是一个短循环:改代码、跑检查、看诊断、修根因、重跑同一项检查。检查通过之后,再评审这次变更。

编辑之后跑快速检查,把较慢的测试和变异运行安排在选定的阶段。用于接受变更的检查,也必须在 CI 中对被评审的修订运行。

如果某个工具跑不起来,先修好执行问题再重跑。工具故障本身说明不了代码是否正确。

反馈回路区分了规则违规、检查通过和检查执行失败三种情况。

反馈回路区分了规则违规、检查通过和检查执行失败三种情况。

10、让代码评审聚焦于契约

仓库把可执行的需求,与针对其范围和诊断的检查结合起来。它覆盖了取消与资源清理之类的行为、追踪间接依赖的架构规则,以及"护栏是否真能检测已知违规"的测试。

评审仍然需要确认这份契约对该应用是否正确。这包括决定:当取消也已就绪时是否允许发送,以及报表计算是否应当依赖存储。

还要检查范围。Forward 的取消测试并不覆盖另一个函数中新起的 goroutine。linter 和架构测试只覆盖其配置所选中的代码。

对检查的改动应当获得与对代码的改动同等的关注。提高阈值、添加排除项、删掉断言,或者仅仅为了降低分数而拆分函数,都可能在消除一个发现的同时把原始问题留下。

已记录的运行确立了:所选检查能检测预期的违规,且验证器会拒绝已测试的各类无效证据。智能体行为和 token 节省则未做测量。

要应用这一方法,选一条反复出现的评审需求,写下它的验收标准。把一个已知违规与检查放在一起,验证它的诊断,并给智能体一条修复后可以重跑的命令。对那项检查的改动,要用同一条需求来评审。


原文链接: Building Guardrails for AI Coding Agents in Go

汇智网翻译整理,转载请标明出处