- PVSM.RU - https://www.pvsm.ru -
А что, если я скажу вам, что линтеры для 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 — это платформа для запуска динамических диагностик. Что-то вроде интерпретатора для скриптов, специализирующихся на статическом анализе.
Вы описываете на DSL свой набор правил (или используете уже готовые наборы) и запускаете их через утилиту ruleguard.


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

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

Внимание! Лучше всего 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, тогда:
ruleguard для своей платформы (или собираем из исходников)go get -v github.com/quasilyte/go-ruleguard/dsl внутри модуля вашего проектаgo get -v github.com/quasilyte/go-ruleguard/rules внутри модуля вашего проектаrules.go, импортируем там установленный бандлruleguard с параметром -rules rules.go на вашем проекте$ ruleguard -rules rules.go ./...
Если у вас возникают проблемы с запуском или установкой ruleguard, сообщите об этом [20].
Есть только два требования:
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 из директории бандла.

Автор: Искандер
Источник [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
Нажмите здесь для печати.