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

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

Большинство IDE и LSP имеют ту или иную форму статического анализа. Многие линтеры кода выполняют своего рода статический анализ. Инструменты контроля качества кода, такие как Sonarqube, полагаются на статический анализ для поиска проблем в вашем коде.

Имея это в виду, становится очевидным, что знание того, как они работают, является полезным навыком, и с ним даже не так уж сложно начать!

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

Но прежде чем мы начнем…

Давайте заложим основу. Это лишь некоторые вещи, которые вам следует знать, прежде чем приступить к этому начинанию. Во-первых, давайте узнаем об AST.

Абстрактные синтаксические деревья

Одна из вещей, с которой вы познакомитесь при работе с анализаторами, — это AST (абстрактное синтаксическое дерево).

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

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

Основная задача любого инструмента статического анализа — просмотреть AST и найти проблемы.

Чтобы быстро изучить AST, вы можете посетить сайт https://astexplorer.net/, который поддерживает несколько языков.

Зачем идти?

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

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

Разобравшись с этим, давайте начнем.

Наш первый линтер

Наш первый линтер будет делать что-то очень простое. Мы хотим обнаружить и сообщить, не используется ли функция отмены, возвращаемая context.WithCancel.

package main

import "context"

func main() {
 ctx, _ := context.WithCancel(context.Background())

 // just to discard ctx
 _ = ctx
}

Поскольку то, что мы хотим проанализировать (вызов context.WithCancel), всегда будет внутри функции, мы можем сосредоточиться на этой части AST на asexplorer.

Мы видим, что AST содержит список операторов внутри BlockStmt. Одно из этих утверждений — AssignStmt, а на его правой стороне (правая сторона) находится CallExpr, что является нашим вызовом WithCancel.

Настройка проекта

Давайте создадим структуру каталогов, подобную следующей. Не стесняйтесь подписаться на что-нибудь другое, если оно вам больше нравится.

├── go.mod
├── linters
│   └── ignored_cancel
│       └── ignored_cancel.go
├── main.go
└── testdata
    └── ignored_cancel.go

testdata содержит наши тестовые файлы для проверки нашего анализатора. linters содержит наш текущий линтер (и любые будущие, которые мы можем написать.

Для начала давайте сосредоточимся на ignored_cancel.go. Мы определим наш анализатор, объявив тип, подобный следующему:

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

var IgnoredCancelAnalyzer = &analysis.Analyzer{
   Name: "ignoredcancel", // name of our analyzer
   Doc: "linter for detecting ignored cancel function returned from context.CancelFunc",
   Run: func(p *analysis.Pass) (interface{}, error) {
    // write our logic here
   }, // the logic for our analyzer
   Requires: []*analysis.Analyzer{inspect.Analyzer}, // declare analyzers that ours is dependent on
}

Большинство этих полей говорят сами за себя, но давайте посмотрим, что делает Requires. Согласно документации, он принимает анализаторы, которые рассматриваются как зависимости от того, который мы объявляем, и должны запускаться до запуска нашего. Здесь мы передаем очень интересный анализатор inspect.Analyzer.

Что делает inspect.Analyzer, так это то, что он анализирует код в AST и предоставляет нам результат в функции Run через аргумент analysis.Pass. Давайте посмотрим, как мы можем получить к нему доступ.

func(p *analysis.Pass) (interface{}, error) {
    i, ok := p.ResultOf[inspect.Analyzer].(*inspector.Inspector)
    if !ok {
     return nil, errors.New("analyzer is not of type *inspector.Inspector")
    }

    return nil, nil
}

Здесь мы пытаемся утверждать, что результат анализатора имеет тип *inspector.Inspector. Этот тип обеспечивает доступ к узлам AST. Если мы не успешны, мы возвращаем ошибку.

Теперь приступим к обходу AST. Нам нужны только узлы AssignStmt, поэтому давайте напишем для них фильтр и воспользуемся удобной функцией Preorder на *inspector.Inspector для выполнения поиска в глубину AST.

func(p *analysis.Pass) (interface{}, error) {
    i, ok := p.ResultOf[inspect.Analyzer].(*inspector.Inspector)
    if !ok {
       return nil, errors.New("analyzer is not of type *inspector.Inspector")
    }
  
    // dfs of AST
    filter := []ast.Node{(*ast.AssignStmt)(nil)}
    i.Preorder(filter, func(n ast.Node) {
       // logic
    })
  
    return nil, nil
 }

Теперь давайте начнем писать основную логику, позволяющую выяснить, игнорируется ли cancelFunc или нет. Сначала мы подтверждаем, что node действительно является *ast.AssignStmt, а затем проверяем его.

i.Preorder(filter, func(n ast.Node) {
   foundIgnoredCancel := false // flag
  
   // confirm node is indeed an AssignStmt
   node, ok := n.(*ast.AssignStmt)
   if !ok {
      return
   }
  
   // DFS on the node's children
   ast.Inspect(node, func(n ast.Node) bool {
      // len(RHS) can only be 1 if it's a multi-return function
      // ignore all other cases
      if len(node.Rhs) > 1 {
         return false
      }
    
      // assert that the RHS is a function call expression
      e, ok := node.Rhs[0].(*ast.CallExpr)
      if !ok {
         return false
      }
    
      // assert that the function call is a selector expression
      fExpr, ok := e.Fun.(*ast.SelectorExpr)
      if !ok {
         return false
      }
    
      // assert that the expression in selector is an identifier
      // because it is an import of "context"
      sExpr, ok := fExpr.X.(*ast.Ident)
      if !ok {
         return false
      }
    
      // if the function signature matches
      if sExpr.Name != "context" || fExpr.Sel.Name != "WithCancel" {
         return false
      }
    
      // if lhs has more or less variables, something is very wrong
      if len(node.Lhs) != 2 {
         return false
      }
    
      // assert that the lhs is just an identifier
      lExpr, ok := node.Lhs[1].(*ast.Ident)
      if !ok {
         return false
      }
    
      if lExpr.Name == "_" {
         foundIgnoredCancel = true
         return false
      }
    
      return true
   })
   if foundIgnoredCancel {
      p.Reportf(n.Pos(), "found ignored cancelFunc on context.WithCancel")
   }
})

Давайте разберемся с этим.

  • Сначала мы утверждаем, что узел действительно является AssignStmt .
  • Далее мы проверяем, содержит ли RHS более 1 выражения. В многофункциональном возврате в правой части может быть только одно выражение. По сути, мы проверяем, был ли вообще вызван context.WithCancel.
  • Если выражение на правой стороне было CallExpr, мы идем дальше и проверяем, что это был за вызов функции.
  • Go проводит различие между функциями, которые являются частью языка, и функциями, которые импортируются. Функция, являющаяся частью языка, будет представлена ​​выражением Ident. Импортированная функция будет обозначаться цифрой SelectorExpr, которая представляет собой выбор определенного поля выражения. Например, выбор поля в экземпляре структуры.
  • В этом случае выражение (X) будет именем пакета, а селектор (Sel) будет именем функции.
  • Наконец, мы проверяем, равно ли выражение (имя пакета) «контексту», а имя селектора (имя функции) — «WithCancel». Если да, мы проверяем, равен ли 2-й элемент LHS «_», что указывает на игнорируемое возвращаемое значение. Если это так, мы устанавливаем флаг foundIgnoredCancel.
  • Наконец, мы сообщаем о положении ворса, используя p.Reportf.

Запуск линтера

Подключим анализатор в main.go.

Мы гарантируем выполнение линтера, добавив его в функцию multichecker.Main.

package main

import (
   ignoredcancel "analyzer/linters/ignored_cancel"
  
   "golang.org/x/tools/go/analysis/multichecker"
)

func main() {
   multichecker.Main(ignoredcancel.IgnoredCancelAnalyzer)
}

Мы можем запустить анализатор на примере файла, содержащего игнорируемый оператор отмены, используя следующий оператор:

go run main.go -- testdata/ignored_cancel.go

# output
<path>/testdata/ignored_cancel.go:6:2: found ignored cancelFunc on context.WithCancel
exit status 3

Оно работает!

Выводы и не только

Мы продемонстрировали, как просто написать собственный анализатор для Go. Это был очень простой анализатор, но принципы, тем не менее, применимы.

Следует отметить, что эта реализация немного наивна и не учитывает все возможные крайние случаи. Например, если пакет «контекст» был импортирован под другим именем, наш lint проигнорирует его. Смягчение этого оставлено в качестве упражнения для читателя :)

Наконец, я настоятельно рекомендую проверить остальные возможности пакета анализ. Он довольно мощный и удобный в использовании.

Сопроводительный код можно найти здесь.