Написать статический анализатор кода для поиска ошибок в коде

Это сообщение в блоге основано на семинаре, который я провел год назад на GopherCon по статическому анализу в Go. Семинар охватывает гораздо больше, чем я мог охватить в этой статье, поэтому вы можете ознакомиться с мастер-классом здесь.

Мотивация

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

  • Линтер для обнаружения ошибок стилей и их исправления.
  • Встроенные анализаторы IDE для обнаружения различных проблем, таких как бесконечные циклы, недостижимый код и избыточность объявлений.
  • Анализатор хода staticcheck и платформа с открытым исходным кодом SonarQube.

Почему вы должны заботиться? Анализаторы кода незаменимы при работе в командах со многими разработчиками. Они используются в процессе CI/CD для обеспечения того, чтобы в основной репозиторий не попадали плохие практики, ошибки и анти-шаблоны.

Язык Go предоставляет пакет go/analysis для создания статических анализаторов кода. Прежде чем говорить о том, как писать эти инструменты, нам нужно ввести немного теории.

АСТ

Абстрактное синтаксическое дерево (AST) — это способ представления синтаксиса языка программирования в виде иерархической древовидной структуры. Давайте посмотрим на следующую программу:

Это AST для программы.

[]ast.Decl содержит все объявления верхнего уровня в файле — импорт и основную функцию. Внутри ast.FuncDecl, который является объявлением функции main, у нас есть файл ast.blockStmt. Это список операторов функций - фактический код основной функции. Мы можем продолжать спускаться по дереву, но вы можете легко сопоставить узел дерева с фактическим кодом. (Я удалил некоторые узлы из дерева, чтобы сделать его кратким).

Go предоставляет пакет go/ast. Пакет AST содержит типы, используемые для представления синтаксических деревьев в Go, как в дереве выше. Давайте посмотрим на ast.AssignStmt. Следующий код взят из пакета go/ast:

Выражение a := 5 является присваиванием, поэтому в AST оно будет представлено как ast.AssignStmt следующим образом:

  • Lhs is [a]
  • TokPos [2] (позиция символа «:»)
  • Tok [:=]
  • Rhs is [5]

Список всех типов в пакете длинный, но соответствует тому, что вы на самом деле ожидаете. Также на этом сайте можно просмотреть AST-дерево кода.

Написание анализатора

В этой части мы напишем статический анализатор, который определяет перезапись аргументов функции. Это считается запахом кода, поскольку затрудняет определение того, какие аргументы были переданы функции во время отладки. Полный код анализатора вы можете посмотреть здесь.

Во-первых, давайте рассмотрим типичную структуру анализатора. Мы создаем каталог с именем passes для каждого прохода. Каждый проход — это проверка нашего кода. Каждый из них живет в своем собственном пакете, включая свою логику и тесты. Затем мы создаем файл main, который выполняет проходы модуля.

│── README.md
│── cmd
│   └── analyzerName
│       └── main.go
│── go.mod
│── go.sum
└── passes
    └── passName
        │── analyzer.go
        │── analyzer_test.go
        └── testdata

Пакет go/analysis определяет API для модульных инструментов статического анализа. Для начала все проходы должны создавать экземпляр analysis.Analyzer. Он описывает функцию анализа: ее имя, документацию, флаги, связь с другими анализаторами и, конечно же, ее логику.

Мы начнем с добавления анализатора в analyzer.go. Мы также добавим его имя и документы.

Ниже показана функция run внутри файла analyzer.go. Он содержит собственно логику анализатора. Он получает аргумент типа *analysis.Pass. *analysis.Pass предоставляет информацию и операции для сообщения диагностики функции Run анализатора об анализируемом пакете.

Давайте посмотрим на строки 51–53 в приведенном ниже коде. Мы перебираем все файлы и запускаем функцию ast.Inspect для каждого файла. Эта функция просматривает дерево AST данного файла в порядке глубины. Функция также получает функцию посетителя, которая получает узел AST, и именно здесь происходит логика.

В строках 3–18 мы проверяем соответствие данного узла нашим условиям:

  • В строках 3–15 проверяется, является ли тип узла функцией — обычной функцией или лямбда-функцией. Также в строках 13–15 мы исключаем функции без тела, например ассемблерные процедуры.
  • В строках 16–18 исключены функции без параметров.

Мы перебираем каждый аргумент в строках 20-25 и строим из них набор. В строках 27–47 происходит волшебство.

Мы проходим по дереву, начиная с тела функции в качестве корневого узла. Затем в теле функции ищутся операторы, в которых изменяются переменные — AssignStmt (например, a = 5) и IncDecStmt (например, i++).

Затем проверяем идентификатор, участвующий в операторе, и если это один из аргументов в наборе (arguments), то он считается ошибкой, и вызываем функцию отчета. Это вспомогательная функция, которую я написал, и вы можете увидеть ее код ниже. Все, что он делает, это оборачивает вызов pass.Report, который фактически уведомляет пользователя об ошибке.

Наконец, мы добавим новый файл main и функцию main, использующую только что написанный нами анализатор. Чтобы запустить анализатор, вы должны использовать одиночную/многократную проверку для одиночного/многократного прохода.

Запускаем наш анализатор

Теперь, когда наш анализатор готов, мы хотим запустить его на реальном файле. Мы можем запустить его с помощью следующих команд:

go install path/to/analyzer
go vet -vettool=$(which analyzername) path/to/files

Первая команда устанавливает анализатор как бинарник, а вторая запускает его с помощью go vet. Таким образом, вы можете легко интегрировать его в свою среду IDE или CI/CD.

Мы также можем протестировать наш код с помощью пакета analysistest. Используя analysistest.Run, можно запустить анализатор пакета с именем testdata с тестовыми файлами и убедиться, что он сообщил все ожидаемые диагностические данные. Ожидания выражаются с помощью комментариев // хочу ... во входном коде, например:

Наконец, мы должны вызвать analysistest.Run из тестового файла, как показано ниже.

analysistest.Run(t, analysistest.TestData(), Analyzer)

Первый аргумент — *testing.T, второй — путь к тестовым файлам, которые нам легко предоставляет analysistest.TestData(), а третий — собственно сам анализатор.