А что, если я скажу вам, что линтеры для 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
. Некоторое время назад я проводил сравнение, хотя часть информации из того доклада уже устарела (все проекты получают новые фичи).
Работаю над проектом я в свободное время, когда появляется настроение, поэтому результаты за год могут показаться не такими впечатляющими. Тем не менее проект развивается.
Большая часть нововведений адресует какую-то конкретную проблему, отсюда и формат заголовков.
Поскольку я иногда использую специфичную для проекта терминологию, приведу здесь несколько расшифровок.
Если по мере прочтения статьи вы нашли совершенно непонятный для вас термин, стоит сообщить об этом, возможно он будет добавлен в эту таблицу.
Раньше всё было относительно просто: есть файл с правилами, утилита принимает его на вход и применяет его к проверяемой кодовой базе.
Далее мы понимаем, что хранить всё в одном файле не очень удобно, и я добавляю поддержку множественных файлов правил.
Затем появился хороший набор правил, написанный Damian Gryski. Единственный способ его использовать на своих проектах — это копировать в свой репозиторий.
У этого подхода с полным копированием есть преимущество: всё лишнее можно удалить, а свои правила добавлять в этот же файл. Но это не самый частый сценарий использования. Как оказалось, чаще люди хотят взять уже готовый набор правил и запускать его с минимальными усилиями.
Новый механизм бандлов для правил позволит решить сразу несколько проблем:
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
предоставляет несколько фильтров, которые часто нужны в типичных для 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]?
Когда у вас на руках только документация, которая зачастую направляет вас читать исходные коды, то освоение технологии будет требовать многих усилий.
Мне нравится подход Go by Example. В нём введение производится через набор примеров с пояснениями, от простого к более продвинутому. Это полезно как начинающим, так и продолжающим.
Ruleguard by Example написан в таком же стиле. Он позволяет достаточно быстро получить все необходимые знания в наглядной форме.
Внимание! Лучше всего 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
, тогда:
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
, сообщите об этом.
Есть только два требования:
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
из директории бандла.
Центр управления связью общего пользования (ЦМУ ССОП) Роскомнадзора рекомендовал компаниям из реестра провайдеров ограничить доступ поисковых ботов к информации на российских сайтах.…
Apple возобновила переговоры с OpenAI о возможности внедрения ИИ-технологий в iOS 18, на основе данной операционной системы будут работать новые…
Конкурсный управляющий российской «дочки» Google подготовил 23 иска к участникам рекламного рынка. Общая сумма исков составляет 16 млрд рублей –…
Google завершил обновление основного алгоритма March 2024 Core Update. Раскатка обновлений была завершена 19 апреля, но сообщил об этом поисковик…
У частных продавцов на Авито появилась возможность составлять текст объявлений с помощью нейросети. Новый функционал доступен в категории «Обувь, одежда,…
24 апреля 2024 года в Москве состоялась церемония вручения наград международного конкурса Workspace Digital Awards. В этом году участниками стали…