Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top
>
>
>
Как сделать свой статический...

Как сделать свой статический анализатор для Go?

29 Дек 2025

Go разработчики постоянно сталкиваются с предупреждениями встроенного статического анализатора. А что делать, если его возможностей не хватает или нужно искать что-то специфичное для вашего проекта? Go предоставляет мощные инструменты для разбора и анализа кода. В этой статье мы поговорим о них и даже сделаем своё первое диагностическое правило.

Введение

Опыт работы с Go-инструментами для разбора кода был получен при разработке статического анализатора PVS-Studio для Go.

Статья направлена на начинающих, так что начнём с основ.

Статический анализ — это методология проверки кода без его выполнения. Инструменты, которые проводят статический анализ, помогают находить ошибки ещё на этапе разработки. Их список практически безграничен: некорректные сравнения, недостижимый код, потенциальные паники, утечки памяти и многое другое.

В экосистеме Go есть встроенный анализатор go vet и коллекция линтеров, объединённых в golangci-lint. Эти инструменты работают быстро, легко подключаются в CI и привычны каждому разработчику. Поэтому если вы не создаёте свой собственный продукт со своей экосистемой (как это делаем мы), то любое правило или отдельный анализатор лучше всего интегрировать именно в этот стандартный процесс.

Если вам не хватает возможностей существующих линтеров (нужно проверять специфичные для проекта ошибки, следить за внутренними контрактами или вы просто уверены, что сделаете правило лучше существующего), то в таких случаях можно написать свой собственный анализатор. Стандартная библиотека даёт доступ ко всему, что нужно: синтаксическому дереву, информации о типах, данным о позициях в файле. А поверх этого есть удобный фреймворк go/analysis, который позволяет создавать полноценные линтеры без лишней рутины.

В этой статье мы шаг за шагом разберём, как устроены инструменты анализа в Go, какие пакеты используются для работы с кодом и семантикой, как запускать свой анализатор и как интегрировать его в общую систему линтеров.

Обзор технологий и пакетов Go для анализа

Go предоставляет полный набор инструментов для разбора и анализа исходного кода. Эти пакеты входят в стандартную библиотеку или расположены в официальном репозитории golang.org/x/tools. Пройдёмся по всем, которые нам пригодятся.

go/ast. Синтаксическое дерево

Пакет go/ast описывает структуру абстрактного синтаксического дерева (AST). Оно отражает структуру программы: выражения, операторы, объявления. В нём нет информации о типах переменных или о том, к какому объекту относится идентификатор, — только форма записи. Это один из основных пакетов, с которым вам придётся работать, так как анализаторы так или иначе оперируют узлами AST.

go/types. Семантический анализ

AST показывает форму записи, но не говорит, что означает идентификатор. Это делает пакет go/types. Он позволяет:

  • определить тип выражения;
  • узнать, какой объект (переменная, функция, константа) стоит за ast.Ident;
  • проверить корректность вызовов и приведения типов.

Основной результат работы — это структура types.Info. Она связывает узлы AST с типами и объектами. Этот пакет нужен, когда информации из дерева недостаточно для понимания ошибки.

go/token. Позиции в исходниках

Пакет go/token помогает обрабатывать информацию о позициях в файле: строки, столбцы, смещения.

golang.org/x/tools/go/analysis. Фреймворк для анализаторов

golang.org/x/tools/go/analysis — это официальный фреймворк для создания правил. Он абстрагирует рутину: парсинг, разбор типов, передачу данных между анализаторами, формирование отчётов.

Cтруктура analysis.Analyzer является ключевым элементом. В ней задаются:

  • имя и описание правила;
  • список зависимостей;
  • функция Run, выполняющая проверку;
  • флаги и дополнительные параметры.

Фреймворк также предоставляет вспомогательные пакеты:

  • analysis/singlechecker — запуск одного анализатора;
  • analysis/multichecker — объединение множества анализаторов;
  • analysis/passes/... — готовые проходы (например типизация, распознавание printf-подобных функций).

go/parser. Построение AST (опционально)

Пакет go/parser умеет превращать текст Go-файла в дерево ast.File. Он нужен, когда вы строите анализатор вручную. Если используется фреймворк analysis, то парсинг он берёт на себя.

go/packages. Загрузка пакетов и их зависимостей (опционально)

Пакет go/packages используется для загрузки пакетов, AST и информации о типах. Он решает, как и какие пакеты грузить, корректно учитывает модули, build tags и окружение. Он умеет сразу загружать весь набор пакетов с зависимостями. Фреймворк analysis внутри уже использует механизмы загрузки пакетов, так что обычно при написании правил go/packages не требуется. Но если вы пишете самостоятельный инструмент, то go/packages почти всегда удобнее, чем ручной parser + types.

golang.org/x/tools/go/ssa. Представление в виде SSA (опционально)

В этой статье мы его использовать не будем, но важно упомянуть. SSA (Static Single Assignment) — промежуточное представление, похожее на низкоуровневый псевдокод. SSA позволяет решать задачи DataFlow анализа, делать межпроцедурные проверки и строить более сложные диагностики (например, проверку возможного разыменования nil).

Как всё это сочетается

В типичном анализаторе используется следующая комбинация:

  • AST (go/ast) — чтобы найти нужный фрагмент дерева;
  • Типы (go/types) — чтобы точно определить, что это за переменная или функция;
  • analysis — чтобы красиво объединить всё в одно правило и интегрировать его в линтеры.

Далее мы разберём, как устроено выполнение анализатора и как эти пакеты взаимодействуют между собой.

Как устроены проходы анализа в Go

Фреймворк go/analysis организует выполнение правил так, чтобы автору анализатора не приходилось вручную загружать пакеты, парсить файлы, строить типы и делать много чего ещё. Он делает это автоматически и передаёт вашему правилу уже подготовленный набор данных: AST, типы, информацию о файлах и позициях.

Когда анализатор запускается через singlechecker, multichecker или, например, в составе golangci-lint, первым шагом происходит:

  • Сбор списка файлов в пакете;
  • Парсинг файлов в AST;
  • Типизация пакета через go/types.

На выходе формируется структура Pass — это объект, содержащий всё, что нужно правилу. Подробнее рассмотрим его в разделе про фреймворк analysis.

Каждый анализатор описывает свои зависимости через поле Requires. Например, он может зависеть от nilness. Фреймворк вычисляет правильный порядок выполнения, запускает зависимости и передаёт их результаты вашему правилу через pass.ResultOf.

Основной код проверок находится в Run(pass *analysis.Pass) (any, error).

Обычно сценарий работы правила внутри метода выглядит так:

1. Получаем список файлов:

for _, file := range pass.Files {
    // обходим AST, ищем нужные конструкции
}

2. Используем pass.TypesInfo для уточнения типа переменных, функций, выражений.

3. При нахождении проблемы вызываем:

pass.Reportf(expr.Pos(), "сообщение")

Фреймворк сам соберёт результаты, выведет их в командную строку или передаст дальше, если анализатор работает внутри других инструментов.

AST в деталях

Абстрактное синтаксическое дерево (AST) — это структурированное представление программы. Оно показывает, из каких конструкций состоит код: объявления, операторы, выражения, типы. Работая с AST, анализатор видит код не как текст, а как чётко организованную структуру.

После парсинга файл превращается в дерево, где каждый узел — это конкретный элемент языка. Например:

  • ast.FuncDecl — объявление функции;
  • ast.AssignStmt — оператор присваивания;
  • ast.CallExpr — вызов функции;
  • ast.BinaryExpr — бинарное выражение;
  • ast.Ident — имя переменной или функции.

AST используется всегда, он является основой всех проверок. Если нужно найти вызовы функции, вы ищете узлы ast.CallExpr. Если нужно проверить, как используется переменная, вы ищите ast.Ident.

Как выглядит дерево на примере

Возьмём небольшой фрагмент кода:

x := a + b

Для визуализации можем воспользоваться вспомогательным сервисом.

Вот так будет выглядеть дерево кода выше:

В дереве эти узлы связаны указателями, и анализатор может свободно переходить от одного узла к другому. AST использует типизацию через структуры, поэтому достаточно проверять тип узла:

if call, ok := n.(*ast.CallExpr); ok {
    // function call found
}

Точно так же обрабатываются присваивания, операции, условия и любые другие конструкции. Например, AssignStmt.Lhs и AssignStmt.Rhs содержат левую и правую части оператора.

Дерево не хранит информацию о типах переменных или значениях констант — это будет в следующем разделе про go/types.

Семантика (go/types)

Чтобы анализатор мог понять, какой тип имеет выражение, куда ссылается идентификатор и корректно ли выполнены операции, используется пакет go/types. Он связывает дерево с реальными объектами: переменными, функциями, типами и методами.

Допустим, в коде есть выражение:

x := y + z

AST скажет только, что это бинарное выражение с оператором +, но не скажет, какие именно типы у y и z, где объявлена переменная y. Ответы даёт go/types. Результатом работы go/types является types.Info.

Наиболее важные поля:

  • Types — хранит тип каждого выражения;
  • Defs — где определены объекты;
  • Uses — где используются объекты.

Таким образом, любой идентификатор можно развернуть до конкретного объекта (переменной, функции, поля) вместе с его типом.

Для того, чтобы узнать тип выражения, можно использовать следующий код:

if tv, ok := pass.TypesInfo.Types[call]; ok {
    fmt.Printf("Тип выражения: %s\n", tv.Type.String())
}

Идентификатор содержит только имя (x), но не знает, к чему привязан. Семантика делает эту связь:

obj := pass.TypesInfo.ObjectOf(ident)

obj может быть:

  • переменной (types.Var);
  • функцией (types.Func);
  • константой (types.Const);
  • типом (types.TypeName);
  • пакетом (types.PkgName).

Стандартный фреймворк analysis

Фреймворк golang.org/x/tools/go/analysis — это фундаментальная часть экосистемы статического анализа в Go. Он избавляет от рутины и даёт удобную структуру для написания собственных правил. Благодаря ему можно быстро создать анализатор, интегрировать его в другие инструменты и запускать на реальных проектах.

Каждое правило оформляется как объект типа analysis.Analyzer. В нём описывается всё, что нужно:

  • название и описание диагностики;
  • зависимости от других анализаторов;
  • функция Run, содержащая саму проверку;
  • способ вывода ошибок.

Это делает анализаторы единицами анализа, которые можно комбинировать, объединять в один бинарник и подключать к линтерам.

Простейший анализатор выглядит так:

var Analyzer = &analysis.Analyzer{
    Name: "mycheck",
    Doc:  " Description of the rule",
    Run:  run,
}

Где run — это функция проверки:

func run(pass *analysis.Pass) (any, error) {
    // логика проверки
    return nil, nil
}

Фреймворк передаёт вашему правилу объект analysis.Pass. Он содержит всю необходимую информацию:

  • Files — список AST-файлов (ast.File), готовых к обходу;
  • TypesInfo — информация о типах (types.Info) для выражений в дереве;
  • Pkg — текущий пакет;
  • Reportf и Report — методы для вывода ошибок;
  • Fset — таблица позиций (token.FileSet);
  • ResultOf — данные от других анализаторов, если у правила есть зависимости.

Чтобы сообщить о проблеме, используется метод pass.Reportf. Например:

pass.Reportf(call.Pos(), "Undesirable function call %s", funObj.Name())

Фреймворк предоставляет два удобных пакета singlechecker и multichecker.

singlechecker используется, когда у вас одно правило:

package main

import (
    "golang.org/x/tools/go/analysis/singlechecker"
)

func main() {
    singlechecker.Main(Analyzer)
}

После сборки можно запускать:

go vet -vettool=./mycheck ./...

multichecker используется, если правил много. Их можно объединить в один инструмент:

multichecker.Main(
    rule1.Analyzer,
    rule2.Analyzer,
    rule3.Analyzer,
)

Преимущества analysis достаточно очевидны:

  • не нужно вручную парсить файлы — фреймворк делает это сам;
  • типизация выполняется автоматически;
  • анализатор работает на уровне пакетов и учитывает зависимости;
  • легко подключать к go vet и golangci-lint;
  • простая архитектура: один анализатор = одно правило.

Интеграция со стандартными инструментами

Собственный анализатор ценен только тогда, когда его удобно запускать. Экосистема Go построена так, что любые инструменты анализа можно бесшовно интегрировать в привычные рабочие процессы: запуск через go vet, использование в CI. Рассмотрим, как это делается.

Запуск через go vet

go vet — встроенный в Go механизм статического анализа. Он может загружать внешние анализаторы, если указать их как vettool.

Чтобы подключить ваш анализатор, сначала его нужно собрать:

go build -o mycheck ./cmd/mycheck

Теперь можно запустить:

go vet -vettool=./mycheck ./...

При этом:

  • mycheck будет работать как полноценная часть go vet;
  • результаты диагностики появятся в стандартном формате.

Это удобный способ распространять простой набор правил.

Также анализатор можно встроить и в golangci-lint. Подробнее про это можно почитать в документации и посмотреть на наглядном примере.

Запуск как отдельный инструмент

Если вы собрали анализатор через singlechecker или multichecker, его также можно запускать напрямую:

./mycheck ./...

Этот вариант используется:

  • в CI;
  • в автономном режиме;
  • при отладке новых правил.

Пример простой диагностики

Давайте на самом простом примере разберём написание правила для Go анализатора.

Диагностика должна находить пустые if блоки вида:

if cond {
}

Правило использует пакет go/ast для обхода синтаксического дерева.

Код в файле main.go:

package main

import (
  "emptyif"

  "golang.org/x/tools/go/analysis/singlechecker"
)

func main() {
  singlechecker.Main(emptyif.Analyzer)
}

Код самого правила в файле emptyif.go:

package emptyif

import (
  "go/ast"

  "golang.org/x/tools/go/analysis"
)

var Analyzer = &analysis.Analyzer{
  Name: "emptyif",
  Doc:  "reports empty if statements",
  Run:  run,
}

func run(pass *analysis.Pass) (any, error) {

  for _, file := range pass.Files {
    ast.Inspect(file, func(n ast.Node) bool {
      if stmt, ok := n.(*ast.IfStmt); ok {
        // We check if there is a body and if it is empty
        if stmt.Body != nil && len(stmt.Body.List) == 0 {
          pass.Reportf(stmt.Pos(), "empty if block")
        }
      }
      return true
    })
  }

  return nil, nil
}

Рассмотрим, из чего состоит это правило:

  • Обход AST. Используется ast.Inspect, позволяющий рекурсивно просматривать узлы дерева.
  • Проверка IfStmt. Узел сравнивается через приведение типа: n.(*ast.IfStmt).
  • Анализ тела блока. stmt.Body.List содержит список операторов. Если он пустой, то выдаём предупреждение.
  • Формирование отчёта. pass.Reportf выводит сообщение с привязкой к точке в исходниках.

Если вы сделали всё правильно, то после сборки сможете запустить ваш анализатор:

go build -o emptyif.exe ./cmd/emptyif
go vet -vettool=D:\emptyif\emptyif.exe ./...

Вывод будет примерно таким:

tests\emptyif_test\file1.go:12:2: empty if block

Заключение

В целом, на этом всё. Мы рассмотрели базу для написания своего статического анализатора для Go. Если статья вам понравится, то в будущем можно будет сделать более сложную, с разбором настоящего правила статического анализа, описанием этапа тестирования и прочим.

Кстати говоря, я думаю вы уже поняли, что мы разрабатываем свой статический анализатор для Go. Если вы хотите поучаствовать в EAP, то следите за нашими новостями.

Последние статьи:

Опрос:

book gost

Дарим
электронную книгу
за подписку!



Комментарии (0)

Следующие комментарии next comments
close comment form