А что, если я скажу вам, что линтеры для Go можно создавать вот таким декларативным способом?


func alwaysTrue(m dsl.Matcher) {
    m.Match(`strings.Count($_, $_) >= 0`).Report(`always evaluates to true`)
    m.Match(`bytes.Count($_, $_) >= 0`).Report(`always evaluates to true`)
}

func replaceAll() {
    m.Match(`strings.Replace($s, $d, $w, $n)`).
        Where(m["n"].Value.Int() <= 0).
        Suggest(`strings.ReplaceAll($s, $d, $w)`)
}

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


Основные нововведения:






Небольшое введение


ruleguard — это платформа для запуска динамических диагностик. Что-то вроде интерпретатора для скриптов, специализирующихся на статическом анализе.


Вы описываете на DSL свой набор правил (или используете уже готовые наборы) и запускаете их через утилиту ruleguard.




Эти правила интерпретируются во время работы, поэтому нет необходимости повторно собирать анализатор каждый раз, когда вы добавляете новые диагностики. Это особенно важно, если мы рассматриваем интеграцию с golangci-lint. Было бы очень неудобно перекомпилировать golangci-lint при желании использовать свой набор правил.


Если называть наиболее близкие к этой концепции проекты, то в голову приходят CodeQL и Semgrep. Некоторое время назад я проводил сравнение, хотя часть информации из того доклада уже устарела (все проекты получают новые фичи).


Работаю над проектом я в свободное время, когда появляется настроение, поэтому результаты за год могут показаться не такими впечатляющими. Тем не менее проект развивается.


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


Терминология, используемая в статье


Поскольку я иногда использую специфичную для проекта терминологию, приведу здесь несколько расшифровок.


EN RU Значение
Rule Правило AST-шаблон, совмещённый с фильтрами и ассоциированными действиями (чаще всего — создание предупреждения).
Rules group Группа правил Именованный набор правил. Мы могли бы называть группы "диагностиками", как это делается в других линтерах, но группа не обязана выполнять единственную проверку.
Rule set Набор правил Совокупность групп правил.
Rule bundle Бандл (извините) Набор правил, оформленный как Go модуль, доступный для импортирования в другие наборы правил.
Module Модуль Модули Go; каждый бандл — модуль, но сами модули к бандлам не имеют никакого отношения.

Если по мере прочтения статьи вы нашли совершенно непонятный для вас термин, стоит сообщить об этом, возможно он будет добавлен в эту таблицу.




Проблема: переиспользование наборов правил


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


Далее мы понимаем, что хранить всё в одном файле не очень удобно, и я добавляю поддержку множественных файлов правил.


Затем появился хороший набор правил, написанный Damian Gryski. Единственный способ его использовать на своих проектах — это копировать в свой репозиторий.


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


Новый механизм бандлов для правил позволит решить сразу несколько проблем:


  • Установка бандлов через go get
  • Версионирование с помощью Go модулей: удобно делать релизы и закреплять версию
  • Культура оформления правил в модули упрощает тестирование

Всё это возможно благодаря тому, что ruleguard файлы, в которых пишутся правила — это обычный Go код (по этой же причине мы имеем нормальный autocomplete и поддержку редакторов).


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


package gorules

import (
    "github.com/quasilyte/go-ruleguard/dsl"
    damianrules "github.com/dgryski/semgrep-go"
)

func init() {
    // Импорт всех правил, без префикса.
    dsl.ImportRules("", damianrules.Bundle)
}

func emptyStringTest(m dsl.Matcher) {
    m.Match(`len($s) == 0`).
        Where(m["s"].Type.Is("string")).
        Report(`maybe use $s == "" instead?`)

    m.Match(`len($s) != 0`).
        Where(m["s"].Type.Is("string")).
        Report(`maybe use $s != "" instead?`)
}

Если требуется выключить некоторые импортируемые правила, делается это через командную строку параметром -disable.


Проблема: недостаточная выразительность DSL


dsl.Matcher предоставляет несколько фильтров, которые часто нужны в типичных для ruleguard правилах.


Но бывают моменты, когда требуется создать довольно сложное условие или фильтр, имеющий промежуточные результаты. В этой ситуации можно использовать новый метод Filter(), который принимает Go функцию-предикат в качестве аргумента. Эта функция будет вызываться во время применения фильтра.


package gorules

import (
    "github.com/quasilyte/go-ruleguard/dsl"
    "github.com/quasilyte/go-ruleguard/dsl/types"
)

// implementsStringer является пользовательским фильтром.
// Этот фильтр проверяет, реализуют ли T или *T интерфейс `fmt.Stringer`.
func implementsStringer(ctx *dsl.VarFilterContext) bool {
    stringer := ctx.GetInterface(`fmt.Stringer`)
    return types.Implements(ctx.Type, stringer) ||
        types.Implements(types.NewPointer(ctx.Type), stringer)
}

func sprintStringer(m dsl.Matcher) {
    // Если бы мы использовали m["x"].Type.Implements(`fmt.Stringer`), тогда
    // мы бы не получили все желаемые результаты: если тип $x реализует
    // fmt.Stringer как *T, то значения типа T не будут считаться реализациями.
    // Наш кастомный фильтр примеряет обе версии: с указателем и без укатателя.
    m.Match(`fmt.Sprint($x)`).
        Where(m["x"].Filter(implementsStringer) && m["x"].Addressable).
        Report(`can use $x.String() directly`)
}

Запускать эти правила будем на следующем файле:


package main

import "fmt"

func main() {
    fooPtr := &Foo{}
    foo := Foo{}

    println(fmt.Sprint(foo))
    println(fmt.Sprint(fooPtr))

    println(fmt.Sprint(0))    // Не fmt.Stringer
    println(fmt.Sprint(&foo)) // Отбрасывается условием addressable
}

type Foo struct{}

func (*Foo) String() string { return "Foo" }

Результат запуска:


$ ruleguard -rules rules.go main.go
main.go:9:10: can use foo.String() directly
main.go:10:10: can use fooPtr.String() directly

Флаг -debug-filter позволяет посмотреть, во что скомпилировался выбранный фильтр:



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


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


Поскольку в Where() может использоваться довольно сложное выражение, не всегда понятно, почему правило не срабатывает на анализируемых фрагментах кода.


На помощь приходит новый флаг debug-group, включающий детальную информацию о неуспешно выполнившихся фильтрах для выбранной группы правил.


Допустим, вы описали следующее правило:


func offBy1(m dsl.Matcher) {
    m.Match(`$s[len($s)]`).
        Where(m["s"].Type.Is(`[]$elem`) && m["s"].Pure).
        Report(`index expr always panics; maybe you wanted $s[len($s)-1]?`)
}

И запустили его на следующем файле:


func lastByte(s string) byte {
    return s[len(s)]
}

func f() byte {
    return randString()[len(randString())]
}

И не получили ни одного предупреждения… Давайте попробуем включить отладочную печать.


$ ruleguard -rules rules.go -debug-group offBy1 test.go
test.go:6: [rules.go:6] rejected by m["s"].Type.Is(`[]$elem`)
  $s string: s
test.go:10: [rules.go:6] rejected by m["s"].Pure
  $s []byte: randBytes()

Мы видим конкретное выражение из Where(), которое не дало сработать правилу. Мы также видим все захваченные Go выражения в именованных частях AST шаблона (в данном случае это $s), а также их тип.


В первом случае условие типа []$elem требует произвольного слайса, а в коде — строка. Во втором случае правило не срабатывает из-за вызова функции (нарушается условие pure).


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


- Where(m["s"].Type.Is(`[]$elem`) && m["s"].Pure).
+ Where((m["s"].Type.Is(`[]$elem`) || m["s"].Type.Is(`string`)) && m["s"].Pure).

Повторный запуск с обновлённой версией найдёт ошибку в индексировании строки:


test.go:6:9: offBy1: index expr always panics; maybe you wanted s[len(s)-1]?

Проблема: трудности изучения DSL


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


Мне нравится подход Go by Example. В нём введение производится через набор примеров с пояснениями, от простого к более продвинутому. Это полезно как начинающим, так и продолжающим.


Ruleguard by Example написан в таком же стиле. Он позволяет достаточно быстро получить все необходимые знания в наглядной форме.



Как начать использовать ruleguard?


Внимание! Лучше всего ruleguard работает с проектами, которые используют Go модули.

Лучше всего дождаться момента, когда в golangci-lint появится новая версия.


Однако, если вы не используете golangci-lint или хотите попробовать уже сегодня, то можно скачать бинарник ruleguard со страницы релиза {linux/amd64, linux/arm64, darwin/amd64, windows/amd64}.


Вам также понадобится набор правил. Здесь есть как минимум два варианта: использовать минималистичный набор github.com/quasilyte/go-ruleguard/rules или более обширный github.com/dgryski/semgrep-go. Вы также можете импортировать оба этих бандла или не импортировать ничего и использовать лишь свои наработки.


Допустим, вы выбрали github.com/quasilyte/go-ruleguard/rules, тогда:


  1. Скачиваем ruleguard для своей платформы (или собираем из исходников)
  2. Выполняем go get -v github.com/quasilyte/go-ruleguard/dsl внутри модуля вашего проекта
  3. Выполняем go get -v github.com/quasilyte/go-ruleguard/rules внутри модуля вашего проекта
  4. Создаём свой файл правил rules.go, импортируем там установленный бандл
  5. Запускаем ruleguard с параметром -rules rules.go на вашем проекте

$ ruleguard -rules rules.go ./...

Если у вас возникают проблемы с запуском или установкой ruleguard, сообщите об этом.


Создаём свой бандл


Есть только два требования:


  1. Бандл должен быть отдельным Go модулем
  2. Пакет должен определять экспортируемую переменную Bundle

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


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


package gorules

import "github.com/quasilyte/go-ruleguard/dsl"

// Bundle содержит метаданные о наборе правил.
var Bundle = dsl.Bundle{}

func boolComparison(m dsl.Matcher) {
    m.Match(`$x == true`,
        `$x != true`,
        `$x == false`,
        `$x != false`).
        Report(`omit bool literal in expression`)
}

В качестве примера, можно посмотреть на репозиторий ruleguard-rules-test.


Тестируем свой бандл


Тестирование основано на фреймворке go/analysis и вспомогательном пакете analysistest.


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


Для запуска тестов нужно написать некоторый шаблонный код:


// file rules_test.go

package gorules_test

import (
    "testing"

    "github.com/quasilyte/go-ruleguard/analyzer"
    "golang.org/x/tools/go/analysis/analysistest"
)

func TestRules(t *testing.T) {
    // Если у вас несколько файлов с правилами, то вместо "rules.go"
    // нужно указать имена всех файлов через запятую, например: "style.go,perf.go".
    if err := analyzer.Analyzer.Flags.Set("rules", "rules.go"); err != nil {
        t.Fatalf("set rules flag: %v", err)
    }
    analysistest.Run(t, analysistest.TestData(), analyzer.Analyzer, "./...")
}

Структура бандла будет выглядеть примерно так:


mybundle/
  go.mod        -- файл, создаваемый "go mod init"
  rules.go      -- здесь ваши правила (можно назвать файл иначе)
  rules_test.go -- запускатель тестов
  testdata/     -- файлы, на которых будем запускать анализ
    target1.go
    target2.go
    ...

Тестовые файлы будут содержать магические комментарии:


// file testdata/target1.go

package test

func f(cond bool) {
    if cond == true { // want `omit bool literal in expression`
    }
}

После want идёт регулярное выражение, которое должно матчить выдаваемое предупреждение. Могу рекомендовать использовать \Q в начале, чтобы не приходилось ничего экранировать.


Тест запускается обычным go test из директории бандла.


Ссылки и дополнительные материалы