Привет! Меня зовут Артём Блохин, я Go-разработчик в команде интеграций Островка. Сегодня поговорим о линтинге кода.
Если бы «Сумерки» были про код, Эдвард — был линтером, а Белла — легаси-кодом, их диалог звучал бы так:
— Линтер смотрел на этот код с болью и отвращением.
— Какая глупая, забытая всеми кодовая база.
— Ну, а разработчик, который взялся её чинить, — просто мазохист.
Любой, кто пытался разобраться в старом коде без статики, знает: чем глубже копаешь, тем страшнее становится. В первой части мы говорили о том, как линтеры помогают поддерживать порядок в проекте. Теперь пора перейти к практике — написать собственный линтер и встроить его в golangci-lint
, чтобы он сам выслеживал проблемные места.
Disclaimer
Линтер довольно объёмный (200+ строк кода), и если бы я попытался запихнуть всё в одну статью, это было бы жестоко — и для вас, и для моей клавиатуры. Поэтому в этой части мы разберёмся только с базовой логикой, остальное можно посмотреть в репозитории.
Пристёгивайтесь, будет интересно!
Как родился ProperOrder, или «разорвать семейные узы»
Перед тем как писать линтер, я долго ломал голову: какой кейс взять? Он должен быть полезным, не слишком сложным и при этом не повторять уже существующие решения. В итоге выбор пал на наш новый линтер — ProperOrder
.
Что делает ProperOrder?
Он проверяет, что код написан в осмысленном порядке:
сначала объявляется структура;
потом конструктор (если есть);
затем методы этой структуры.
А теперь посмотрим на два варианта кода и выберем более читаемый. Это поможет понять, как порядок объявления влияет на восприятие и как ProperOrder
помогает его соблюдать.
Пример 1 (неудачный):
type Man struct {...}
type Woman struct {...}
func (m Man) GetName() string {...}
func (w Woman) GetName() string {...}
Здесь метод GetName
для Man
разрывается объявлением структуры Woman
. Когда код разрастается, подобные вещи снижают читаемость.
Пример 2 (удачный):
type Man struct {...}
func (m Man) GetName() string {...}
type Woman struct {...}
func (w Woman) GetName() string {...}
Теперь всё логично: объявили Man
, затем сразу его метод.
Когда можно нарушать порядок?
Иногда структуры должны появляться не строго друг за другом, а в зависимости от их использования в функциях. Например:
type Man struct {...}
type Health struct {...}
func NewMan(h Health) Man {..}
type Woman struct {...}
func (m Man) FindLove(w []Woman) (Woman) {...}
В этом коде структура Health
идёт до конструктора NewMan
, хотя сам Man
уже объявлен. Почему так? Потому что Health
нужен в параметрах конструктора.
То же самое с FindLove
: здесь массив []Woman
передаётся в аргумент метода Man
, а сам метод возвращает Woman
. В таком случае линтер не будет ругаться, потому что порядок логичен: Типы, используемые в параметрах или возвращаемых значениях, могут объявляться раньше.
А существует ли читаемый шаблон для линтера?
Одна из основных сложностей, с которой я столкнулся в начале своего пути — это отсутствие чёткого образца или шаблона структуры линтеров, которому можно было бы следовать. Исследование чужих репозиториев в поисках оптимального варианта зачастую не давало результата.
Поразительно, но факт: даже линтеры, отличающиеся высокой скоростью и точностью анализа, нередко страдают от неудобной и запутанной архитектуры. Это становится настоящим барьером для тех, кто хочет глубже погрузиться в разбор механизмов их работы. Отсюда и возникло решение создать индивидуально адаптированный подход к структурированию линтеров, уделяя особое внимание прозрачности, логичности организации и user-friendly
интерфейсу. Это позволит облегчить задачу для будущих пользователей и исследователей.

Как итог, я пришел к такому виду:
├── analyzers
│ └── custom_linter
│ ├── analyzer.go
│ ├── analyzer_test.go
│ └── testdata
│ └── src
│ └── src.go
├── cmd
│ └── custom_linter
│ └── main.go
│── internal
│ └── tests
│ └── tests.go
└── plugin
└── main.go
Рассмотрим директории подробнее:
analyzers/ — здесь живёт вся логика линтера
├── analyzer.go — основная логика анализа кода
├── analyzer_test.go — тесты для линтера
└── testdata/ — примеры кода, на которых тестируется линтер
(то, что он должен пропускать и то,
что должен ругать)
cmd/ — точка входа
└── main.go — запускает линтер (если хотим прогнать его
отдельно от golangci-lint)
internal/ — вспомогательные вещи для тестов и других внутренних задач
└── tests.go — поддерживающие функции и сценарии
plugin/ — загрузка линтера в golangci-lint
└── main.go
Запуск линтера
package main
import (
"golang.org/x/tools/go/analysis/singlechecker"
"gitlab.com/common/linter/analyzers/properorder"
)
func main() {
singlechecker.Main(properorder.New())
}
singlechecker.Main(properorder.New())
создаёт интерфейс командной строки для линтера.
Если хочется поддерживать несколько линтеров, вместо singlechecker
можно использовать multichecker.Main()
.
Линтер, или современный Прометей
Код рождается из идеи. Какая у нас идея? Порядок.
Главная проблема, связанная с ProperOrder
, — как запомнить, что за чем следует?
Как мы можем быть уверены, что когда мы видим метод GetName
для структуры Man
, где-то выше уже объявлена эта структура? Или что перед конструктором NewPerson
сначала идёт type Person struct {}
?
Ответ прост: будем использовать стек
. Но самое главное – стек нужно вычищать полностью, когда мы заходим в новый файл.
Стек — ключ к порядку
Когда линтер анализирует файл, он складывает все важные узлы в стек и проверяет, не нарушен ли порядок.
Что мы будем хранить в стеке?
ast.FuncDecl
— функции и методы (func Foo()
илиfunc (r *Receiver) Foo()
);ast.TypeSpec
— объявления типов (type Man struct{}
).
А теперь вернемся к примеру 1 и пройдёмся по коду сверху-вниз, попутно записывая в стек значения:

Что же мы видим? Красный элемент стека (метод Man
) не соответствует нижележащему (тип Woman
).
Как ProperOrder анализирует код?
Весь анализ выполняется в функции run()
, которая вызывается для каждого файла. run()
— это сердце линтера. Именно она отвечает за обработку AST, хранение контекста и вызов проверок.
Но перед тем, как перейдем к функции run()
, нужно инициализировать наш линтер:
package properoder
import "golang.org/x/tools/go/analysis"
func New() *analysis.Analyzer {
return &analysis.Analyzer{
Name: "properorder",
Doc: "short documentation about linter",
Run: run,
URL: "https://documentation.com",
Requires: []*analysis.Analyzer{inspect.Analyzer},
}
}
Узнаем, что скрывается под ним:
Name
– имя линтера.Doc
– документация линтера (что делает, для чего и т.д.). Длинным быть не предполагается.Run
– функция, в которой содержится логика линтера.URL
– опциональное поле, которое содержит ссылку на полную документацию.Requires
– некая оптимизация:golangci-lint
запуститinspect.Analyzer
только один раз для всех анализаторов (наших линтеров). То есть, мы не будем для каждого линтера заново строить AST-дерево.
Как работает run()?
func run(pass *analysis.Pass) (any, error) {
var lastFile *token.File
v := validator{
Pass: pass,
Stack: stack.New(),
}
pass *analysis.Pass
— объект, содержащий всю информацию о коде (файлы, позиции узлов и т. д.).
validator
— структура, которая отвечает за проверки в линтере. В ней есть:
Pass
— переданный объектanalysis.Pass
;Stack
— стек узлов AST, где мы храним, какие структуры и функции уже встретились.
Какие узлы нас интересуют?
nodeTypes := []ast.Node{
(*ast.FuncDecl)(nil), // func Foo() or func (r *Receiver) Foo()
(*ast.TypeSpec)(nil), // type smt (e.g. struct, int, array etc)
}
Здесь мы фильтруем AST и говорим, что нас интересуют только объявления типов и функций.
Как run() анализирует код?
При встрече нового узла мы вызываем enterNode()
, которая решает, что с ним делать:
enterNode := func(n ast.Node) bool {
currentFile := pass.Fset.File(n.Pos())
if lastFile == nil || currentFile != lastFile {
v.flushStack() // Очистка стека при смене файла
}
lastFile = currentFile
Если мы зашли в новый файл — очищаем стек. Это важно, чтобы линтер не сравнивал элементы из разных файлов.
Как enterNode() обрабатывает узлы?
switch node := n.(type) {
case *ast.TypeSpec:
v.CheckTypeSpecPosition(node) // Проверяем, где объявлен тип
v.Stack.Push(node) // Добавляем его в стек
case *ast.FuncDecl:
v.TraverseStack(node) // Проверяем порядок методов
v.Stack.Push(node) // Добавляем метод в стек
}
Если это структура (TypeSpec
) – проверяем её порядок и кладём в стек.
Если это функция или метод (FuncDecl
) – проверяем, стоит ли она на своём месте, затем добавляем в стек.
Как линтер проходит по AST?
inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
inspect.WithStack(nodeTypes, func(n ast.Node, push bool, stack []ast.Node) (proceed bool) {
if push {
return enterNode(n) // Анализируем узел
}
return true
})
WithStack
проходит по AST, анализирует только нужные узлы (TypeSpec
, FuncDecl
) и вызывает enterNode()
для их обработки.
push bool означает:
true
– мы вошли в узел, анализируем его (enterNode(n)
);false
– мы вышли из узла, просто продолжаем обход.
Proceed = true
, мы спускаемся глубже в ast, = false
, прекращаем обход узла.
В итоге получаем такой код:
func run(pass *analysis.Pass) (any, error) {
var lastFile *token.File
v := validator{
Pass: pass,
Stack: stack.New(),
}
nodeTypes := []ast.Node{
(*ast.FuncDecl)(nil), // func Foo() or func (r *Receiver) Foo()
(*ast.TypeSpec)(nil), // type smt (e.g. struct, int, array etc)
}
enterNode := func(n ast.Node) bool {
currentFile := pass.Fset.File(n.Pos())
if lastFile == nil || currentFile != lastFile {
v.flushStack()
}
lastFile = currentFile
switch node := n.(type) {
case *ast.TypeSpec:
v.CheckTypeSpecPosition(node)
v.Stack.Push(node)
case *ast.FuncDecl:
v.TraverseStack(node)
v.Stack.Push(node)
}
return false
}
inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
inspect.WithStack(nodeTypes, func(n ast.Node, push bool, stack []ast.Node) (proceed bool) {
if push {
return enterNode(n)
}
return true
})
return nil, nil
}
На самом деле именно здесь и заканчивается вся сложность. Почему? Потому что дальше будут лишь проверки корректности. Приступим.
Раз мне не дано вселять любовь, я буду вызывать страх
Кажется, именно так можно описать две ключевые структуры в AST, с которыми работает наш линтер:
ast.FuncDecl
— узлы, представляющие функции и методы;ast.TypeSpec
— узлы, представляющие объявления типов.
Разбираем ast.FuncDecl (функции и методы)
Рассмотрим метод:
func (m Man) MakeItGreatAgain(a int, b float64) (enemy int, err error) {..}
Структура FuncDecl
из пакета go/ast
выглядит так:
FuncDecl struct {
Doc *CommentGroup // associated documentation; or nil
Recv *FieldList // receiver (methods); or nil (functions)
Name *Ident // function/method name
Type *FuncType // function signature: type and value parameters, results, and position of "func" keyword
Body *BlockStmt // function body; or nil for external (non-Go) function
}
Визуализация:

На основе этой структуры делаем выводы:
если
Recv = nil
, значит, это обычная функция;если
Recv != nil
, значит, это метод.
Разбираем ast.TypeSpec (объявления типов)
Допустим, у нас есть структура:
type Person[T any] = struct {
Name string
Age int
}
Она в AST представлена как TypeSpec
:
TypeSpec struct {
Doc *CommentGroup // associated documentation; or nil
Name *Ident // type name
TypeParams *FieldList // type parameters; or nil
Assign token.Pos // position of '=', if any
Type Expr // *Ident, *ParenExpr, *SelectorExpr, *StarExpr, or any of the *XxxTypes
Comment *CommentGroup // line comments; or nil
}
Визуализация:

Что важно для нас:
поле
Type
указывает, к какому типу относится объявление — например, это может быть структура, интерфейс или другой тип;линтер может анализировать
Name
иType
, чтобы проверять порядок.
Путешествие на запад. Король порядка
Наш линтер научился разбирать код, находить объявления типов, функций и методов. Теперь пора разобраться, как он следит за порядком объявлений.
Как линтер следит за порядком объявления типов? (CheckTypeSpecPosition)
Представим ситуацию
func NewPerson(name string) Person {
return Person{Name: name}
}
type Person struct {
Name string
}
Ошибка! Конструктор объявлен раньше типа. Линтер должен это отловить и сообщить об ошибке.
Логика проверки
Посмотреть, что на вершине стека (Stack) находится функция.
Убедиться, что это конструктор (NewPerson).
Проверить, совпадает ли возвращаемый тип с объявленным Person.
Если да — вывести ошибку.
Реализация в коде
func (v *validator) CheckTypeSpecPosition(typeSpec *ast.TypeSpec) {
if interfaceValue := v.Stack.Peek(); interfaceValue != nil {
if funcDecl, ok := interfaceValue.(*ast.FuncDecl); ok {
if isFunc(funcDecl) && isFuncHasResults(funcDecl) {
result := funcDecl.Type.Results.List[0]
if isTypeNamedAsExpected(
unstar(v.Pass.TypesInfo.TypeOf(result.Type)),
typeSpec.Name.Name,
) {
v.reportInvalidOrder(result.Pos(), result.Pos(),
"the constructor must be positioned after the type is defined.")
_ = v.Stack.Pop() // Удаляем обработанный элемент из стека
}
}
}
}
}
Как это работает?
Stack.Peek()
берёт последний добавленный элемент (конструктор);isFunc(funcDecl)
проверяет, что это функция, а не метод;isFuncHasResults(funcDecl)
проверяет, что функция что-то возвращает;isTypeNamedAsExpected()
сравнивает имя возвращаемого типа с объявленным.
Если всё совпало, выводим ошибку и удаляем конструктор из стека, чтобы не проверять его дважды.
Тестируем через комментарии
Мы уже написали большую часть кода и готовы проверить, как работает наш линтер. Но как убедиться, что он действительно находит ошибки?
Так как мы пишем линтер, я решил, что удобнее всего сразу рядом с кодом указывать ожидаемые ошибки. Это даёт несколько преимуществ:
мы сразу видим, где должна быть ошибка;
можно проверять разные сценарии, просто добавляя новые файлы;
тесты не требуют внешних зависимостей — всё хранится прямо в репозитории.
Настройка тестов
Тесты запускаются в файле: analyzers/properorder/analyzer_test.go
package properorder_test
import (
"testing"
"gitlab.com/common/linter/analyzers/properorder"
"gitlab.com/common/linter/internal/tests"
)
func TestAll(t *testing.T) {
tests.AnalyzeCodeInTestdata(t, properorder.New())
}
tests.AnalyzeCodeInTestdata(t, properorder.New())
запускает линтер на тестовых файлах;Если линтер не найдёт ошибку там, где должен, или выдаст ошибку не там — тест провалится.
Добавляем тест с ошибкой
Теперь создадим тестовый файл, который будет содержать ошибки. Файлы с тестами хранятся в analyzers/properorder/testdata/src.go
type Car struct {
Model string
}
func (c Car) Drive() { // want "the method must be located below the constructor function."
println("Driving")
}
func NewCar(model string) Car {
return Car{Model: model}
}
комментарий
// want "..."
указывает, что линтер ДОЛЖЕН здесь выдать ошибку;если линтер не выдаст ошибку, тест упадёт;
если ошибка будет в другом месте, тест тоже упадёт.
Результаты запуска тестов
Если тест написан без тега // want
Линтер нашёл ошибку, но тест не знает, ожидали ли мы её, поэтому он провалится:
=== RUN TestAll
linter/analyzers/properorder/testdata/src/properorder.go:7:7
diagnostic the method must be located below the constructor function.
--- FAIL: TestAll (0.02s)
Если тест написан с тегом // want
Линтер нашёл ошибку в нужном месте, тест успешно прошёл:
=== RUN TestAll
--- PASS: TestAll (0.02s)
PASS
Встраивание ошибок в виде комментариев
Функция AnalyzeCodeInTestdata(t testing.T, analyzer analysis.Analyzer)
запускает линтер на тестовых файлах в testdata/src и проверяет, правильно ли он находит ошибки.
Разбор кода
func AnalyzeCodeInTestdata(t *testing.T, analyzer *analysis.Analyzer) {
wd, err := os.Getwd()
if err != nil {
panic(err)
}
var printer errsPrinter
analysistest.Run(&printer, filepath.Join(wd, "testdata/src"), analyzer, "./...")
if printer.PrintedLines > 0 {
t.Fail()
}
}
os.Getwd()
получает текущую директорию, где выполняется тест;analysistest.Run()
запускает линтер на файлах в testdata/src;Если
printer.PrintedLines > 0
(есть ошибки), тест проваливается (t.Fail()
).
Как ошибки перехватываются?
Функция errsPrinter.Errorf()
отвечает за перехват ошибок от линтера и их вывод в консоль.
type errsPrinter struct {
PrintedLines int
}
func (p *errsPrinter) Errorf(format string, args ...interface{}) {
fmt.Println(args...) // Вывод ошибки в консоль
p.PrintedLines++ // Увеличиваем счётчик ошибок
}
если линтер нашёл ошибку, он вызывает
Errorf()
;ошибка выводится в консоль (
fmt.Println()
).
Заключение
В этой части мы разобрали и реализовали основу линтера ProperOrder
. Мы шаг за шагом прошли через ключевые аспекты, включая:
создание структуры проекта для удобной организации кода линтера;
разбор AST и использование стека для отслеживания порядка объявлений;
проверку правильного расположения типов, конструкторов и методов.
Что дальше?
Хотя на данном этапе наш линтер уже работает, в следующей части статьи мы:
добавим поддержку
Golangci-lint
без необходимости merge’а в общий репозиторий;разберём, как правильно интегрировать кастомный линтер в
CI/CD
.
Если вы хотите поэкспериментировать с кодом или доработать линтер под свои нужды — весь код можно найти на GitHub: ProperOrder.
Больше технических новостей из Островка в нашем Telegram-канале.