- PVSM.RU - https://www.pvsm.ru -

Релиз ruleguard v0.3.0

А что, если я скажу вам, что линтеры для 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 [1]. Сегодня хотелось бы поделиться тем, что нового появилось за это время.

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

Релиз ruleguard v0.3.0 - 1


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

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

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

Релиз ruleguard v0.3.0 - 2

Релиз ruleguard v0.3.0 - 3

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

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

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

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

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


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

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

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


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

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

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

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

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

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

  • Установка бандлов через 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 [10] предоставляет несколько фильтров, которые часто нужны в типичных для ruleguard правилах.

Но бывают моменты, когда требуется создать довольно сложное условие или фильтр, имеющий промежуточные результаты. В этой ситуации можно использовать новый метод Filter() [11], который принимает 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 позволяет посмотреть, во что скомпилировался выбранный фильтр:

Релиз ruleguard v0.3.0 - 4

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

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

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

На помощь приходит новый флаг 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 [14]. В нём введение производится через набор примеров с пояснениями, от простого к более продвинутому. Это полезно как начинающим, так и продолжающим.

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

Релиз ruleguard v0.3.0 - 5

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

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

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

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

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

Допустим, вы выбрали 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, сообщите об этом [20].

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

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

  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 [21].

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

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

Рядом с модулем создаётся директория 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 из директории бандла.

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

Релиз ruleguard v0.3.0 - 6

Автор: Искандер

Источник [31]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/programmirovanie/360954

Ссылки в тексте:

[1] ruleguard: https://github.com/quasilyte/go-ruleguard

[2] Go модули: https://github.com/golang/go/wiki/Modules

[3] ruleguard by example: https://go-ruleguard.github.io/by-example/

[4] реальные пользователи: https://github.com/grafana/grafana/pull/28419

[5] наборы правил: https://github.com/dgryski/semgrep-go

[6] Онлайн песочница: https://go-ruleguard.github.io/play/

[7] golangci-lint: https://github.com/golangci/golangci-lint

[8] проводил сравнение: https://speakerdeck.com/quasilyte/ruleguard-vs-semgrep-vs-codeql

[9] Damian Gryski: http://twitter.com/dgryski

[10] dsl.Matcher: https://pkg.go.dev/github.com/quasilyte/go-ruleguard/dsl#Matcher

[11] Filter(): https://pkg.go.dev/github.com/quasilyte/go-ruleguard/dsl#Var.Filter

[12] yaegi: https://github.com/traefik/yaegi

[13] Where(): https://pkg.go.dev/github.com/quasilyte/go-ruleguard/dsl#Matcher.Where

[14] Go by Example: https://gobyexample.com/

[15] linux/amd64: https://github.com/quasilyte/go-ruleguard/releases/download/v0.3.0/ruleguard-linux-amd64.zip

[16] linux/arm64: https://github.com/quasilyte/go-ruleguard/releases/download/v0.3.0/ruleguard-linux-arm64.zip

[17] darwin/amd64: https://github.com/quasilyte/go-ruleguard/releases/download/v0.3.0/ruleguard-darwin-amd64.zip

[18] windows/amd64: https://github.com/quasilyte/go-ruleguard/releases/download/v0.3.0/ruleguard-windows-amd64.zip

[19] github.com/quasilyte/go-ruleguard/rules: https://github.com/quasilyte/go-ruleguard/tree/master/rules

[20] сообщите об этом: https://github.com/quasilyte/go-ruleguard/issues/new

[21] ruleguard-rules-test: https://github.com/quasilyte/ruleguard-rules-test

[22] go/analysis: https://godoc.org/golang.org/x/tools/go/analysis

[23] analysistest: https://godoc.org/golang.org/x/tools/go/analysis/analysistest

[24] Список похожих проектов: https://github.com/quasilyte/go-ruleguard/issues/36

[25] Сайт проекта ruleguard: https://go-ruleguard.github.io/

[26] Телеграм чатик, где обсуждается go-critic и ruleguard: https://t.me/go_critic_ru

[27] Использование ruleguard из golangci-lint: https://quasilyte.dev/blog/post/ruleguard/#using-from-the-golangci-lint

[28] DSL мануал: https://github.com/quasilyte/go-ruleguard/blob/master/_docs/dsl.md

[29] go-critic: https://github.com/go-critic/go-critic

[30] Введение в бандлы: https://quasilyte.dev/blog/post/ruleguard-modules/

[31] Источник: https://habr.com/ru/post/538930/?utm_source=habrahabr&utm_medium=rss&utm_campaign=538930