Guardrails для ИИ-агента на Go: от инструкций к автоматическим проверкам
- среда, 30 сентября 2026 г. в 00:00:11
Мы пишем инструкции и скиллы для ИИ-агентов, просим их проверять свою работу, запускаем отдельных агентов на код-ревью. Для важных изменений добавляем ещё одного проверяющего. Каждый такой запуск требует времени и токенов.
При этом замечание, которое агент нашёл однажды, он может пропустить при следующем ревью. Инструкцию можно дополнить, но проверяющему снова придётся прочитать код и решить, выполнено ли требование. Хочется сократить эту повторную работу и закрепить хотя бы часть требований проверками с явным критерием.
Например, если горутина должна завершаться после отмены операции, это поведение можно проверить тестом. Такой тест останется в проекте и будет проверять тот же сценарий при следующих изменениях. Агент сможет запустить его, получить диагностику и проверить исправление той же командой.
Такие автоматические проверки в этой статье я называю guardrails: они задают условия, которым должно соответствовать изменение кода перед принятием.
В статье разберу, какие требования к Go-коду можно закрепить линтерами и тестами: от обработки ошибок до зависимостей между пакетами. Покажу, как убедиться, что проверка обнаруживает нужное нарушение, и что всё равно останется для ревью.
Статья рассчитана на Go-разработчиков, знакомых с тестами, контекстами, каналами и CI. Исходники, конфигурации и команды воспроизведения лежат в репозитории с примерами. Весь вывод команд получен на Go 1.27.1 и golangci-lint 2.13.2. Версии зафиксированы, чтобы у вас получился тот же вывод, что в статье.

Область | Что разберём |
|---|---|
1. Стиль и имена | Автоматическая проверка оформления кода и соглашений об именовании |
2. Сложность и дублирование | Статический анализ сложности, повторяющегося и неиспользуемого кода |
3. Ошибки и ресурсы | Проверки обработки ошибок и освобождения ресурсов |
4. Конкурентность | Тестирование отмены операций, поиск утечек горутин, дедлоков и гонок данных |
5. Архитектурные зависимости | Архитектурные тесты и ограничения на прямые и косвенные зависимости между пакетами |
6. Качество тестов | Проверка ожидаемых результатов, полноты сравнения данных и мутационное тестирование |
Команды ниже запускаются из корня репозитория примеров. Перед запуском выберите Go 1.27.1 для всей сессии терминала. Для fish:
set -gx GOTOOLCHAIN go1.27.1 go version golangci-lint version
Первая проверка версии должна показать Go 1.27.1, вторая — golangci-lint 2.13.2, собранный Go 1.27.1. Установка инструментов и зависимостей описана в README. Настройку GOTOOLCHAIN нужно повторить в новой сессии: строка go 1.27.0 в go.mod задаёт минимальную версию и сама по себе не закрепляет именно 1.27.1.
Замечания об оформлении кода и написании имён можно передать инструментам. Например, агенту не нужно вручную выравнивать отступы: gofmt приведёт файлы в текущем каталоге и его подкаталогах к стандартному оформлению Go:
gofmt -w .
Для имён нужны отдельные правила. Например, сокращение ID принято писать заглавными буквами. В этом коде правило нарушено:
type request struct { UserId int }
Правило var-naming линтера revive обнаруживает такое написание и показывает, что исправить:
testdata/naming/naming.go:5:2: var-naming: struct field UserId should be UserID (revive)
Агент может заменить UserId на UserID и повторить проверку. Выбор имени по смыслу — например, account или customer — остаётся задачей разработчика.
Перед включением правила согласуйте его с командой и проверьте на существующем коде. Иначе небольшая задача может обрасти несвязанными переименованиями.
Для проверки форматирования без изменения файлов используйте gofmt -l .: команда перечисляет файлы, которые нужно отформатировать. Сама она при этом завершается успешно, поэтому падение шага CI на непустом списке придётся настроить отдельно. Поведение флагов описано в документации gofmt.
Я запускаю revive через golangci-lint, который объединяет запуск нескольких анализаторов. Их названия и назначение приведены в каталоге линтеров. Для примера включим только нужное правило и сохраним конфигурацию в naming.yml:
version: "2" linters: default: none enable: - revive settings: revive: enable-default-rules: false rules: - name: var-naming
Здесь отключены два набора правил: default: none убирает стандартный набор линтеров golangci-lint, а enable-default-rules: false отключает стандартные правила самого revive. Так другие замечания не смешиваются с проверкой имён. Структура настроек описана в документации конфигурации.
Из каталога модуля примеров запустим:
golangci-lint run --config naming.yml ./testdata/naming
Каталог указан явно, поскольку шаблон ./... пропускает testdata. Команда выдаст показанное выше замечание с файлом, позицией и названием линтера.
Агент может написать работающий код, который трудно читать: добавить несколько вложенных условий или повторить уже существующий фрагмент. Линтеры помогают находить такие места для ревью.
Например, эта функция разрешает экспорт, если выполнены все три условия:
func CanExport(active, allowed, ready bool) bool { if active { if allowed { if ready { return true } } } return false }
Линтер gocognit оценивает сложность ветвлений. Вложенные условия увеличивают оценку. Если она превышает настроенный предел, линтер выдаёт замечание.
Для этой функции оценка равна 6. В примере установлен предел 3, поэтому проверка сообщает:
cognitive complexity 6 of func `CanExport` is high (> 3)
Низкий предел выбран для демонстрации. В проекте его стоит подбирать после просмотра существующего кода.
Здесь все три условия можно объединить, сохранив поведение:
func CanExport(active, allowed, ready bool) bool { return active && allowed && ready }
Агент получает конкретное место для исправления и может повторить проверку после правки. Но снижение оценки само по себе не гарантирует понятный код: например, дробление функции на множество мелких помощников может затруднить чтение.
Другие признаки помогают искать следующие инструменты:
Инструмент | Что помогает найти |
|---|---|
| Функции, превышающие настроенные ограничения длины |
| Похожие фрагменты, которые могут оказаться дублированием |
| Неиспользуемые объявления в анализируемых пакетах |
Нужен ли новый интерфейс или обёртка, всё равно решают на ревью. У анализаторов есть и технические ограничения: например, unused не считает экспортированное объявление лишним только потому, что в проекте нет его вызовов.
Для показанной функции gocognit вычисляет сложность 6: вложенные if добавляют соответственно 1, 2 и 3. Каждый следующий уровень вложенности увеличивает вклад на единицу. Подробнее механизм описан в правилах расчёта gocognit.
Проверка порога не отслеживает рост сложности после изменения. Например, при пороге 30 рост показателя с 20 до 25 не вызовет замечания. Если требование команды — запретить рост этой метрики, нужно сравнивать её значение для функции до и после изменения. Базой сравнения может служить основная ветка, например main.
При подключении линтера к существующему проекту он может найти много прежних нарушений. Чтобы сосредоточиться на текущем изменении, замечания можно фильтровать по изменённым строкам. Но для проверки сложности такой фильтр способен скрыть и новое нарушение.
Сообщение линтера может указывать на строку объявления функции. Если агент изменил только её тело, эта строка не попадёт в изменения, и замечание будет отфильтровано.
Режим --whole-files вместе с фильтрацией по ревизии выдаёт замечания для всего изменённого файла, включая прежние нарушения. Семантика флагов фильтрации описана в CLI-документации.
Начнём с освобождения ресурсов. Эта функция читает тело HTTP-ответа, но не закрывает его. Закрытие нужно и при успешном чтении, и при ошибке:
func Download(url string) ([]byte, error) { response, err := http.Get(url) if err != nil { return nil, err } return io.ReadAll(response.Body) }
Отсутствие закрытия в этом примере обнаруживает линтер bodyclose. Добавим вызов Close через 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) }
Теперь функция закрывает тело ответа через defer, даже если чтение завершилось ошибкой. Если ошибка возникнет и при закрытии, errors.Join объединит её с ошибкой чтения. Вызывающий код получит обе.
В нашем примере bodyclose обнаруживает пропущенное закрытие, а после исправления замечание исчезает. Поведение функции проверяет TestDownloadReadAndClose: успешные чтение и закрытие, ошибка чтения, ошибка закрытия и обе ошибки одновременно.
В каждом случае тест проверяет, что Close вызван один раз, прочитанные данные сохранены, а ожидаемые ошибки доступны через errors.Is. Так проверяется и освобождение ресурса, и передача ошибок вызывающему коду.
return записывает результаты io.ReadAll в именованные переменные data и err. Затем отложенная функция закрывает тело ответа и добавляет ошибку закрытия к err через errors.Join. После этого вызывающий код получает результат. Если обе ошибки равны nil, итоговая ошибка тоже будет nil. Функция может вернуть непустые данные вместе с ошибкой, поэтому по наличию данных нельзя судить об успехе.
Тест подставляет свой http.RoundTripper, возвращающий ответ с заданными ошибками чтения и закрытия, и подсчитывает вызовы Close. Сетевые запросы не выполняются. Запустить тест отдельно можно так:
go test -race -count=1 -run '^TestDownloadReadAndClose$' ./internal/download
Исправленная функция проходит базовый набор линтеров репозитория. При этом bodyclose анализирует код по известным ему шаблонам и не доказывает закрытие на всех путях выполнения.
В примере разобрано закрытие тела ответа. Тайм-ауты, передача контекста и проверка HTTP-статуса требуют отдельных решений.
При создании контекста с тайм-аутом тоже нужно освободить ресурсы. Для этого context.WithTimeout возвращает функцию cancel. Её вызов через defer освобождает связанные с контекстом ресурсы при выходе из функции, не дожидаясь тайм-аута:
ctx, cancel := context.WithTimeout(parent, timeout) defer cancel()
Если вместо cancel написать _, проверка lostcancel из go vet сообщит о пропущенном вызове отмены.
Необработанную возвращённую ошибку помогает найти линтер errcheck. Если код проверил ошибку, но затем вернул успех, некоторые такие случаи обнаруживает nilerr.
Однако линтер не знает, какой результат нужен вызывающей стороне при сбое зависимости. Это проверяет отдельный тест: зависимость возвращает ошибку, а тест сверяет результат с контрактом, например ждёт ошибку вместо успешного ответа.
В коде с горутинами нужно проверить несколько вещей: завершается ли работа после отмены, не зависают ли горутины в ожидании друг друга и нет ли гонок при обращении к общим данным. Для этих проверок понадобятся разные инструменты. Начнём с отмены операции.
Горутина отправляет результат в небуферизованный канал. Если получатель больше не читает из него, отправка останется ждать. Чтобы горутина могла завершиться, добавим обработку отмены контекста.
Функция Forward отправляет значение в out, а при завершении закрывает канал 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 }
Если получатель готов, горутина может отправить значение и завершиться. Если отправка заблокирована, отмена контекста позволит прекратить ожидание. В обоих случаях закроется done.
Вызывающий код должен отменить операцию, когда результат больше не нужен, и дождаться завершения через <-done. Сам канал out функция не закрывает: им управляет вызывающая сторона.
Если отправка и отмена готовы одновременно, select может выбрать отправку. Этот пример не гарантирует запрет отправки после отмены.
Проверим сценарий без получателя: отправитель должен ждать до отмены и завершиться после неё. Для синхронизации используем testing/synctest: он позволяет дождаться, когда горутина остановится на ожидании внутри изолированной группы.
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 ещё открыт. Иначе проверку могла бы пройти функция, которая сразу завершается, ничего не отправляя.
Затем тест отменяет контекст и ждёт закрытия done. Если отправитель останется заблокированным, synctest сообщит о дедлоке внутри тестовой группы. В этом сценарии сообщение обнаруживает горутину, которая не завершилась после отмены.
Успешную передачу значения 42 и завершение после отправки проверяет отдельный TestForwardDelivery. Вместе эти тесты проверяют оба исхода: доставку значения и завершение после отмены.
synctest.Test запускает тест и созданные им горутины в изолированной группе, которую документация называет bubble. Вызов synctest.Wait() ждёт, пока остальные горутины завершатся или окажутся устойчиво заблокированы. Сам по себе он не отличает ожидающего отправителя от уже завершившегося, поэтому тест отдельно проверяет, что done ещё открыт.
Каналы создаются внутри группы: ожидание на внешнем канале может зависеть от событий за её пределами и не считается устойчивой блокировкой. В нашем примере читателя нет, поэтому ожидающий отправитель не сможет продолжить работу, пока тест не отменит контекст.
Если отправитель не обрабатывает отмену или не закрывает done, тест не завершит ожидание <-done. В этом изолированном сценарии synctest сообщит о deadlock: так он называет ситуацию, когда все горутины группы заблокированы. Здесь причина — незавершившийся отправитель. В репозитории проверены оба нарушения, а также преждевременное завершение отправителя.
Тест подтверждает реакцию на явную отмену. Вызывает ли отмену сам вызывающий код, когда получатель завершился раньше, нужно проверять отдельно.
В таком тесте автоматическая отмена t.Context() может замаскировать ошибку. Этот контекст отменяется, когда функция теста уже вернула управление. Если проверяемый код не вызывает отмену, а тест не ждёт завершения работы внутри своего тела, отмену выполнит сам тест, и он может пройти. Если же тест дожидается <-done до выхода, synctest сообщит о deadlock.
После завершения операции её горутины могут продолжать работать или ждать. Обнаружить такую утечку в тестах вне synctest помогает библиотека goleak.
Сначала тест выполняет сценарий и дожидается завершения работы, например закрытия канала done. Затем goleak проверяет, не остались ли горутины. Библиотека делает несколько попыток с небольшими ожиданиями, но это не заменяет ожидание завершения в самом тесте.
Способ запуска зависит от того, как выполняются тесты:
Последовательно: VerifyNone проверяет оставшиеся горутины после отдельного теста.
Параллельно: VerifyTestMain выполняет проверку после завершения всех тестов пакета. Иначе работающая горутина соседнего теста может быть принята за утечку.
В предыдущем примере незавершённого отправителя уже обнаруживает synctest. В репозитории goleak показан отдельно: в тесте успешной доставки и в примере утечки.
Две горутины должны обменяться данными через небуферизованные каналы. Но обе сначала отправляют значение и только потом собираются читать. Воспроизведём этот порядок внутри 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 }) }
Запущенная горутина останавливается на отправке в left, а горутина теста — на отправке в right. Каждая ждёт получателя, но ни одна не доходит до чтения. Продолжить работу они не могут: это дедлок.
Из каталога модуля примеров запустим:
go test -count=1 -run '^TestDeadlock$' ./testdata/deadlock
synctest завершит тест с сообщением deadlock: all goroutines in bubble are blocked. В стеке будут видны обе остановившиеся отправки. Для такой проверки каналы и горутины должны быть созданы внутри группы synctest, как в примере.
С мьютексами проверка устроена иначе. Вот минимальный пример: горутина повторно захватывает мьютекс, который сама ещё не освободила.
func TestMutexDeadlock(t *testing.T) { var mu sync.Mutex mu.Lock() defer mu.Unlock() mu.Lock() // Ждёт освобождения уже занятого мьютекса. }
Второй Lock не даёт функции завершиться, поэтому отложенный Unlock не выполнится. Ожидание sync.Mutex не считается для synctest устойчивой блокировкой. Запустим этот тест из репозитория с ограничением времени:
go test -count=1 -timeout=2s -run '^TestMutexDeadlock$' ./testdata/deadlock
После тайм-аута в стеке будет виден второй вызов Lock. Сам по себе тайм-аут означает только, что тест не завершился вовремя; причину устанавливаем по коду и стеку. Для дедлока из-за разного порядка захвата нескольких мьютексов тест должен воспроизвести именно этот порядок.
Ожидание внешнего ввода-вывода и системных вызовов также не считается устойчивой блокировкой. Границы механизма описаны в документации testing/synctest.
Две горутины увеличивают общий счётчик. 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++ читает значение, увеличивает его и записывает обратно. Горутины обращаются к одной переменной без синхронизации этих операций: это гонка данных. Даже если в конкретном запуске счётчик стал равен 2, код остаётся ошибочным.
Запустим пример с race detector из каталога модуля:
go test -race -count=1 ./testdata/race
Проверка выдаст WARNING: DATA RACE и укажет конфликтующие обращения в строке value++. Тест завершится с ошибкой, даже если вывел ожидаемое значение счётчика. В таком коде увеличение можно защитить мьютексом или выполнить атомарной операцией.
Флаг -race включает проверку, а -count=1 заставляет выполнить тесты заново, без использования сохранённых результатов. Для запуска по пакетам своего проекта используйте ./... вместо ./testdata/race. Каталог testdata в этот шаблон не входит, поэтому пример запускаем явно.
Race detector ищет гонки во время выполнения тестов. Если опасное сочетание обращений не возникло в этом запуске, ошибка останется незамеченной. Поэтому успешный запуск не гарантирует отсутствия гонок и ничего не говорит об отсутствии дедлоков. Подробнее о применении инструмента — в документации race detector.
В репозитории есть отдельные примеры утечки горутины, дедлока на каналах и гонки данных. Для каждого проверяется, что выбранный инструмент обнаруживает ожидаемое нарушение.
Допустим, расчёт отчёта должен работать независимо от хранилища. Закрепим это правило: пакеты расчётов internal/reportcalc не должны зависеть от internal/storage и его подпакетов — ни напрямую, ни через другие пакеты.
Линтер depguard проверяет импорты выбранных файлов. Настроим его так, чтобы он сообщал о нарушении, если код расчётов импортирует хранилище.
В этом примере проверяем код расчётов и его подпакетов, исключая тестовые файлы. Конфигурация приведена во врезке ниже.
Конфигурация для 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 — имя модуля примеров. Две записи различают следующие случаи. В таблице пути сокращены относительно имени модуля:
Импорт | Результат проверки |
|---|---|
| Запрещён: запись с |
| Запрещён: запись с завершающим |
| Этим правилом не запрещён: это отдельный пакет |
Один префикс …/storage без разделителя затронул бы и storagecache. Только точное совпадение, наоборот, пропустило бы storage/reader. Все три случая проверяются в репозитории. Правила сопоставления описаны в документации depguard.
Агент может использовать общий вспомогательный пакет internal/shared, который сам импортирует хранилище:

Прямого импорта хранилища в расчётах нет, поэтому настроенное правило depguard пропустит изменение. Но зависимость уже появилась: reportcalc → shared → storage.
Архитектурный тест проверяет всю цепочку. Он загружает пакеты, убеждается, что нужные пакеты найдены, и проходит по их импортам. Если от расчётов можно дойти до хранилища, тест завершается с ошибкой и показывает путь:
ARCH001: calculation depends on storage: example.com/guardrails/internal/reportcalc -> example.com/guardrails/internal/shared -> example.com/guardrails/internal/storage
Теперь агент видит, через какой пакет возникла запрещённая зависимость. Исправление может потребовать переноса общего вычисления или изменения направления зависимости. Поведение программы при этом должно сохраниться.
Тест должен сообщать и об ошибках собственной настройки. Например, после переименования пакета старый путь в правиле может перестать что-либо проверять. Поэтому тест падает, если нужные пакеты не найдены или их не удалось загрузить.
Проверка охватывает импорты файлов, вошедших в выбранную сборку. Если платформа или теги сборки меняют состав файлов, такие конфигурации нужно проверять отдельно.
Обращение к хранилищу по сети этот тест не обнаружит: оно может происходить без зависимости между Go-пакетами.
Для загрузки используем пакет 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 выбирает указанный пакет и его подпакеты, учитывая разделитель /. Поэтому storagecache не попадёт под правило для storage.
forbiddenPath проходит по импортам и возвращает найденную цепочку до запрещённого пакета. Уже посещённые пакеты не обходятся повторно. Импорты перед обходом сортируются, чтобы при нескольких запрещённых путях тест стабильно показывал один и тот же.
Поэтому тест падает, если не найден пакет storageRoot или не осталось ни одного пакета расчётов, подходящего под правило. Такое падение говорит об устаревших путях в настройке, а не о нарушении архитектуры.
В репозитории отдельно проверены ошибки загрузки, отсутствие нужных пакетов и работа правила с подпакетами.
Теги сборки легко потерять. go test -tags сам по себе не передаёт их внутреннему вызову packages.Load: тест с тегами запускается, а граф импортов загружается без них. Их нужно задать через BuildFlags либо общую переменную GOFLAGS; в репозитории проверен второй вариант. Тестовые файлы тоже потребуют отдельного включения. Настройки описаны в документации go/packages.
Тест может проходить и при ошибке в коде. Например, если он повторяет ошибку реализации или проверяет только часть результата. Разберём, как обнаруживать такие пропуски.
Если агент пишет и реализацию, и тест, он может повторить одну ошибку в обоих местах. Например, требование разрешает значения до 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 // Значение 10 разрешено по требованию. got := Allowed(10) if got != want { t.Fatalf("Allowed(10) = %v, want %v", got, want) }
Теперь тест обнаружит ошибку: ожидаем true, получаем false. Ожидание можно и вычислять, если расчёт опирается на требование, согласованный пример или независимое свойство, а не повторяет проверяемый алгоритм.
Функция 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" функция вернёт пустую строку. Для этой структуры подходит оператор !=; в общем случае способ сравнения должен соответствовать смыслу данных.
Допустим, в отчёт добавили валюту:
type View struct { ID int Region string Currency string }
Если не обновить ни реализацию ToView, ни ожидаемый результат теста, Currency с обеих сторон получит нулевое значение — пустую строку. Тест снова пройдёт.
Линтер exhaustruct помогает заметить такой пропуск: он требует перечислить поля в записях вида View{...}. Это относится и к результату в реализации, и к ожидаемому значению в тесте. В примере из репозитория тест проходит, а линтер сообщает о пропущенном Currency.
Но линтер не выбирает правильное значение. Если явно написать Currency: "" и в коде, и в тесте, замечание исчезнет. Проверить перенос валюты должен тест с конкретным ожидаемым значением, выбранным по требованию. Если пустая строка допустима, это не ошибка.
Способ сравнения зависит от смысла данных. Для этой структуры достаточно обычного сравнения. Но сравнение указателей через == не сравнивает содержимое объектов, а time.Time через == учитывает внутреннее представление, не только момент времени. Для структур со срезами или map оператор == вообще неприменим. Исключение полей или игнорирование порядка элементов тоже должно следовать из требований.
В golangci-lint 2.13 прежний exhaustruct на v4 объявлен устаревшим, поэтому в репозитории используется 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 проверка по умолчанию распространялась бы на литералы всех структур, с учётом исключений анализатора. Если ошибиться в пути, правило может не выбрать ни одного типа. Проверка синтаксиса конфигурации этого не обнаружит, поэтому настройку проверяем на примере с заведомо пропущенным полем.
Проверка exhaustruct ограничена литералами View{...} выбранного типа. Она не гарантирует заполнение полей при создании через var или new, последующих присваиваниях, копировании либо преобразовании типа. Директивы подавления и настройки исключений также могут ослабить проверку; в показанном примере они не используются.
Настройки относятся к v5: её поведение описано в README этой версии. После обновления анализатора стоит заново проверить, какие типы и способы инициализации охватывает правило. Лимиты вывода и другие детали запуска видны в verify.py.
Даже правильные ожидания не помогут, если тест не проверяет нужный случай. Вернёмся к функции, разрешающей значения до 10 включительно. Теперь она написана правильно:
func Allowed(size int) bool { return size <= 10 }
Тест проверяет только Allowed(9) и ожидает true. Если заменить <= на <, он продолжит проходить, хотя функция перестанет принимать 10.
Мутационное тестирование автоматизирует такую проверку: инструмент изменяет код и запускает тесты. Изменённую версию называют мутантом. Если тесты обнаружили изменение поведения, мутант считается уничтоженным; если прошли — выжившим.
В локальном эксперименте go-mutesting создал три мутации. Каждая проверялась отдельно исполнителем из репозитория, описанным во врезке ниже:
Изменённое условие | Случай, который обнаружит ошибку | Ожидание и результат мутанта |
|---|---|---|
|
| Ожидается |
|
| Ожидается |
|
| Ожидается |
Для значения 9 все три изменённых условия возвращают true, поэтому первоначальный тест пропустил все мутации. После добавления случаев 10 и 11 с ожиданиями из требования тесты обнаружили все три. Так эксперимент показал, каких проверок границы не хватало.
Из результата «обнаружено три мутации из трёх» нельзя сделать вывод о полноте тестирования произвольного сервиса.
Мутационный отчёт тоже требует разбора:
Ошибка сборки или тайм-аут не доказывают, что тест обнаружил изменение поведения.
Выжившая мутация может сохранять поведение программы, поэтому не каждый такой случай требует нового теста.
Тест с неверным ожиданием может защищать ошибочную реализацию. Мутационная проверка не подтверждает правильность требований или ожидаемых результатов.
Каждый мутант требует запуска тестов. Начать стоит с небольшого участка кода, оценить время и разобрать результаты. Большой набор мутаций может быть слишком дорогим для запуска после каждой правки.
Встроенный исполнитель go-mutesting в использованной ревизии не отличает обнаруженную тестом мутацию от ошибки сборки или тайм-аута. Он может засчитать такие исходы как PASS, то есть как обнаруженную мутацию. Поэтому в репозитории подключён отдельный исполнитель: он засчитывает обнаруженную мутацию, только если нужный тест упал с ожидаемым сообщением утверждения. Ошибка сборки, тайм-аут или panic отмечаются как недостоверный результат.
Для воспроизведения используется форк avito-tech/go-mutesting; ревизия и команды указаны в README репозитория. Встроенный запуск проверяет пакет изменённого файла. Если поведение проверяется тестами потребителей из других пакетов, их запуск придётся организовать отдельно. В нашем примере функция и тесты находятся в одном пакете.
Набор изменений также зависит от инструмента. Он не обязательно умеет добавлять поля в структуру или удалять нужное присваивание. Поэтому потеря значения поля из предыдущего примера проверяется в репозитории отдельным изменением кода.
В работе «Practical Mutation Testing at Scale: A View from Google» описан подход для ревью: система проверяет изменённый код, фильтрует вероятно нерелевантные мутации и ограничивает их количество на строку и на ревью. Google Research, 2021.
Работа Google не посвящена специально Go или агентам; она показывает, почему отбор мутаций важен при увеличении масштаба.
Вернёмся к Forward: дадим агенту задачу исправить отмену и укажем тест, которым можно проверить результат.
Сценарий я воспроизвёл вручную. Это пример задания агенту; команды и их вывод получены на коде репозитория.
Уберём из Forward обработку отмены:
select { case out <- value: }
Остальная функция, включая defer close(done), остаётся прежней. Передача получателю работает, но без получателя горутина продолжает ждать даже после отмены.
В проекте уже есть TestForwardCancellation, который проверяет этот сценарий. Агент получает задание:
Исправь
Forwardвinternal/report/report.go: при отсутствии читателя отмена контекста должна завершать отправителя. Сохрани успешную доставку значения и закрытиеdone. Сначала запусти тест отмены, после исправления повтори его и запусти тесты модуля с race detector. Если проверку не удалось выполнить, сообщи причину. Изменения тестов или контракта обоснуй отдельно.
Изменения теста нужно обосновывать отдельно: агент может убрать падение, не исправив причину. Например, t.Skip скроет проблему, но горутина по-прежнему не будет завершаться.
Переписывание теста без synctest может лишить его способности обнаруживать нарушение. При этом просто убрать ожидание <-done в показанном тесте недостаточно, чтобы скрыть ошибку: synctest всё равно сообщит о горутине, оставшейся заблокированной. Изменение проверки нужно оценивать по тому, сохраняет ли она исходный критерий приёмки.
Из каталога модуля с подготовленными зависимостями агент выполняет:
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]
Тест отменил контекст и ждёт завершения отправителя. Но отправитель остался на отправке в канал, поэтому 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>
В стеке горутина теста ожидает чтения из done, а отправитель остановился на out <- value. Пока отправка заблокирована, defer close(done) не выполняется. Позиции в стеке указывают на обе стороны ожидания.
В 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. Тест отмены входит в общий набор.
Перед включением проверки в CI убедимся, что она обнаруживает нужную ошибку, и посмотрим, какие замечания выдаёт на существующем коде.
Сначала проверим конфигурацию. Для примера со сложностью:
golangci-lint config verify --config complexity.yml
Команда проверяет допустимость настроек и помогает обнаружить опечатки. Но правильная конфигурация ещё не означает, что правило проверяет нужный код: например, путь к пакету может быть указан неверно.
Поэтому запустим правило на двух примерах:
допустимый код должен пройти проверку;
код с известной ошибкой должен вызвать ожидаемое замечание.
Важно проверить само сообщение: ошибка запуска инструмента тоже может завершить команду неуспешно.
Команда golangci-lint config verify проверяет конфигурацию по схеме, которая описывает допустимые настройки и формат их значений. Она обнаружит, например, опечатку min-complexty, которую обычный run может проигнорировать.
Скрипты verify.py и verify_mutation.py проверяют сами демонстрационные проверки: создают ошибочные версии кода во временных копиях и убеждаются, что нарушения обнаружены. Для скрипта ожидаемое падение теста — это успех. Поэтому его зелёный результат не заменяет обычный go test при приёмке изменений.
Особенно это касается настроек, выбирающих файлы, пакеты или типы. Конфигурация может быть допустимой, но из-за неверного пути не охватывать нужный код, как в примерах с storageRoot в архитектурном тесте и с регулярным выражением для View в настройке exhaustruct. Офлайновая проверка конфигурации и команды для примеров описаны в README репозитория.
Теперь запустим правило на существующем коде. Прежде чем блокировать изменения, нужно исправить ошибки настройки, согласовать исключения и разобрать найденные нарушения.
Если нарушений много, можно сначала собирать отчёт, затем запретить новые, а старые устранять постепенно. При этом фильтрация по изменённым строкам способна скрыть новые проблемы — этот случай разобран в разделе о сложности.
Укажем агенту команду проверки. Сообщение об ошибке должно помогать найти причину:
у линтера — правило, файл и строка;
у теста — сценарий, ожидаемый и полученный результат;
у архитектурной проверки — запрещённая цепочка зависимостей.

Быстрые проверки можно выполнять после каждой правки, долгие тесты и мутационные прогоны — на выбранных этапах работы. Проверки, от которых зависит приёмка изменения, должны повторяться в CI на проверяемой ревизии.
Если инструмент не запустился, сначала нужно устранить причину и повторить команду. Такой сбой ничего не говорит о корректности кода.
Линтеры и тесты закрепляют выбранные требования к коду. На ревью нужно проверить:
Правильно ли выбрано поведение. Например, допустима ли отправка одновременно с отменой и должны ли расчёты зависеть от хранилища.
Охватывают ли проверки нужный код и сценарии. Тест отмены для Forward не проверяет новую горутину в другой функции. У линтеров и архитектурного теста охват зависит от настроек.
Сохранилось ли требование после изменения проверки. Повышение порога, добавление исключения, удаление утверждения из теста или дробление функции ради метрики могут убрать замечание, оставив исходную проблему.
Примеры показывают, как обнаружить конкретное нарушение и проверить исправление той же командой. Как реальный агент использует диагностику и сколько токенов это экономит, здесь не измерялось.
Начать можно с одного замечания, которое вы регулярно повторяете на ревью. Сформулируйте условие нарушения и убедитесь, что проверка обнаруживает его на заведомо ошибочном коде.
Какие замечания ревью вы уже превратили в автоматические проверки? Сталкивались ли с тем, что агент менял проверку вместо исправления кода?