Go lintpack: менеджер компонуемых линтеров


    lintpack — это утилита для сборки линтеров (статических анализаторов), которые написаны с использованием предоставляемого API. На основе него сейчас переписывается знакомый некоторым статический анализатор go-critic.


    Сегодня мы подробнее разберём что такое lintpack с точки зрения пользователя.


    В начале был go-critic...


    go-critic начинался как экспериментальный проект, который являлся песочницей для прототипирования практически любых идей в области статического анализа для Go.


    Приятным удивлением было то, что некоторые люди действительно отправляли реализации детекторов тех или иных проблем в коде. Всё было под контролем, пока не начал накапливаться технический долг, устранять который было практически некому. Люди приходили, добавляли проверку, а затем исчезали. Кто после этого должен исправлять ошибки и дорабатывать реализацию?


    Знаменательным событием было предложение добавить проверки, требующие дополнительной конфигурации, то есть такие, которые зависят от локальных для проекта договорённостей. Примером является выявление наличия copyright заголовка в файле (license header) по особому шаблону или запрет импортирования некоторых пакетов с предложением заданной альтернативы.


    Другой трудностью была расширяемость. Отправлять свой код в чужой репозиторий удобно не каждому. Некоторым хотелось динамического подключения своих проверок, чтобы не нужно было модифицировать исходные коды go-critic.


    Резюмируя, вот проблемы, которые стояли на пути развития go-critic:


    • Груз сложности. Слишком много поддерживать, наличие бесхозного кода.
    • Низкий средний уровень качества. experimental означал как "почти готово к использованию", так и "лучше не запускать вообще".
    • Иногда трудно принимать решение включения проверки в go-critic, а отклонять их противоречит исходной философии проекта.
    • Разные люди видели go-critic по-разному. Большинству хотелось иметь его в виде CI линтера, который идёт в поставке с gometalinter.

    Чтобы хоть как-то ограничить количество разночтений и несовпадающих интерпретаций проекта, был написан манифест.


    Если вам хочется дополнительного исторического контекста и ещё больше размышлений на тему категоризации статических анализаторов, можете послушать запись GoCritic — новый статический анализатор для Go. В тот момент lintpack ещё не существовал, но часть идей родилась именно в тот день, после доклада.

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


    Встречайте — lintpack




    go-critic состоит из двух основных компонентов:


    1. Реализация самих проверок.
    2. Программа, которая загружает проверяемые Go пакеты и запускает на них проверки.

    Наша цель: иметь возможность хранить проверки для линтера в разных репозиториях и собирать их воедино, когда это необходимо.


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


    Пакеты, которые реализованы с использованием lintpack как фреймворка, будем называть lintpack-совместимыми или lintpack-compatible пакетами.

    Если бы сам go-critic был реализован на основе lintpack, все проверки можно было бы разделить на несколько репозиториев. Одним из вариантов разделения может быть следующий:


    1. Основной набор, куда попадают все стабильные и поддерживаемые проверки.
    2. contrib репозиторий, где лежит код, который либо слишком экспериментальный, либо не имеет меинтейнера.
    3. Что-то вроде go-police, где могут находится те самые настраиваемые под конкретный проект проверки.

    Первый пункт имеет особо важное значение в связи с интеграцией go-critic в golangci-lint.


    Если оставаться на уровне go-critic, то для пользователей практически ничего не изменилось. lintpack создаёт почти идентичный прежнему линтер, а golangci-lint инкапсулирует все различающиеся детали реализации.


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


    Quick start



    Для начала, нужно установить сам lintpack:


    # lintpack будет установлен в `$(go env GOPATH)/bin`.
    go get -v github.com/go-lintpack/lintpack/...

    Создадим линтер, используя тестовый пакет из lintpack:


    lintpack build -o mylinter github.com/go-lintpack/lintpack/checkers

    В набор входит panicNil, который находит в коде panic(nil) и просить выполнить замену на что-то различимое, поскольку в противном случае recover() не сможет подсказать, был ли вызван panic с nil аргументом, или паники не было вовсе.


    Пример с panic(nil)


    Код ниже пытается описать значение, полученное из recover():


    r := recover()
    fmt.Printf("%T, %v\n", r, r)

    Результат будет идентичен для panic(nil) и для программы, которая не паникует.


    Запускаемый пример описываемого поведения.




    Запускать линтер можно на отдельных файлах, аргументами типа ./... или пакетах (по их import пути).


    ./mylinter check bytes
    $GOROOT/src/bytes/buffer_test.go:276:3: panicNil: panic(nil) calls are discouraged

    # Далее делается предположение, что go-lintpack есть под вашим $GOPATH.
    mylinter=$(pwd)/mylinter
    
    cd $(go env GOPATH)/src/github.com/go-lintpack/lintpack/checkers/testdata
    
    $mylinter check ./panicNil/
    ./panicNil/positive_tests.go:5:3: panicNil: panic(nil) calls are discouraged
    ./panicNil/positive_tests.go:9:3: panicNil: panic(interface{}(nil)) calls are discouraged

    По умолчанию данная проверка также реагирует на panic(interface{}(nil)). Чтобы переопределить это поведение, нужно установить значение skipNilEfaceLit в true. Сделать это можно через командную строку:


    $mylinter check -@panicNil.skipNilEfaceLit=true ./panicNil/
    ./panicNil/positive_tests.go:5:3: panicNil: panic(nil) calls are discouraged

    usage для cmd/lintpack и генерируемого линтера


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


    lintpack
    not enough arguments, expected sub-command name
    
    Supported sub-commands:
        build - build linter from made of lintpack-compatible packages
            $ lintpack build -help
            $ lintpack build -o gocritic github.com/go-critic/checkers
            $ lintpack build -linter.version=v1.0.0 .
        version - print lintpack version
            $ lintpack version

    Предположим, мы назвали созданный линтер именем gocritic:


    ./gocritic
    not enough arguments, expected sub-command name
    
    Supported sub-commands:
        check - run linter over specified targets
            $ linter check -help
            $ linter check -disableTags=none strings bytes
            $ linter check -enableTags=diagnostic ./...
        version - print linter version
            $ linter version
        doc - get installed checkers documentation
            $ linter doc -help
            $ linter doc
            $ linter doc checkerName

    Для некоторых подкоманд доступен флаг -help, который предоставляет дополнительную информацию (я вырезал некоторые слишком широкие строки):


    ./gocritic check -help
    # Информация о всех доступных флагах.



    Документация установленных проверок


    Ответ на вопрос "как узнать о том самом параметре skipNilEfaceLit?" — read the fancy manual (RTFM)!


    Вся документация об установленных проверках находится внутри mylinter. Доступна эта документация через подкоманду doc:


    # Выводит список всех установленных проверок:
    $mylinter doc
    panicNil [diagnostic]
    
    # Выводит детальную документацию по запрашиваемой проверке:
    $mylinter doc panicNil
    panicNil checker documentation
    URL: github.com/go-lintpack/lintpack
    Tags: [diagnostic]
    
    Detects panic(nil) calls.
    
    Such panic calls are hard to handle during recover.
    
    Non-compliant code:
    panic(nil)
    
    Compliant code:
    panic("something meaningful")
    
    Checker parameters:
      -@panicNil.skipNilEfaceLit bool
            whether to ignore interface{}(nil) arguments (default false)

    Подобно поддержке шаблонов в go list -f, вы можете передать строку шаблона, которая отвечает за формат вывода документации, что может быть полезным при составлении markdown документов.


    Где искать проверки для установки?


    Для упрощения поиска полезных наборов проверок есть централизованный список lintpack-совместимых пакетов: https://go-lintpack.github.io/.


    Вот некоторые из списка:



    Этот список периодически обновляется и он открыт для заявок на добавление. Любой из этих пакетов может использоваться для создания линтера.


    Команда ниже создаёт линтер, который содержит все проверки из списка выше:


    # Сначала нужно убедиться, что исходные коды всех проверок
    # доступны для Go компилятора.
    go get -v github.com/go-critic/go-critic/checkers
    go get -v github.com/go-critic/checkers-contrib
    go get -v github.com/Quasilyte/go-police
    
    # build принимает список пакетов.
    lintpack build \
      github.com/go-critic/go-critic/checkers \
      github.com/go-critic/checkers-contrib \
      github.com/Quasilyte/go-police

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


    Динамическое подключение пакетов


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


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


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


    1. Создаём linterPlugin.go:

    package main
    
    // Если требуется включить в плагин более одного набора проверок,
    // просто добавьте требуемые import'ы.
    import (
        _ "github.com/go-lintpack/lintpack/checkers"
    )

    1. Собираем динамическую библиотеку:

    go build -buildmode=plugin -o linterPlugin.so linterPlugin.go

    1. Запускаем линтер с параметром -pluginPath:

    ./linter check -pluginPath=linterPlugin.so bytes

    Предупреждение: Поддержка динамических модулей реализована через пакет plugin, который не работает на Windows.

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


    Пример с -verbose


    Обратите внимание, что panicNil отображается в списке включенных проверок. Если мы уберём аргумент -pluginPath, это перестанет быть истиной.


    ./linter check -verbose -pluginPath=./linterPlugin.so bytes
        debug: appendCombine: disabled by tags (-disableTags)
        debug: boolExprSimplify: disabled by tags (-disableTags)
        debug: builtinShadow: disabled by tags (-disableTags)
        debug: commentedOutCode: disabled by tags (-disableTags)
        debug: deprecatedComment: disabled by tags (-disableTags)
        debug: docStub: disabled by tags (-disableTags)
        debug: emptyFallthrough: disabled by tags (-disableTags)
        debug: hugeParam: disabled by tags (-disableTags)
        debug: importShadow: disabled by tags (-disableTags)
        debug: indexAlloc: disabled by tags (-disableTags)
        debug: methodExprCall: disabled by tags (-disableTags)
        debug: nilValReturn: disabled by tags (-disableTags)
        debug: paramTypeCombine: disabled by tags (-disableTags)
        debug: rangeExprCopy: disabled by tags (-disableTags)
        debug: rangeValCopy: disabled by tags (-disableTags)
        debug: sloppyReassign: disabled by tags (-disableTags)
        debug: typeUnparen: disabled by tags (-disableTags)
        debug: unlabelStmt: disabled by tags (-disableTags)
        debug: wrapperFunc: disabled by tags (-disableTags)
        debug: appendAssign is enabled
        debug: assignOp is enabled
        debug: captLocal is enabled
        debug: caseOrder is enabled
        debug: defaultCaseOrder is enabled
        debug: dupArg is enabled
        debug: dupBranchBody is enabled
        debug: dupCase is enabled
        debug: dupSubExpr is enabled
        debug: elseif is enabled
        debug: flagDeref is enabled
        debug: ifElseChain is enabled
        debug: panicNil is enabled
        debug: regexpMust is enabled
        debug: singleCaseSwitch is enabled
        debug: sloppyLen is enabled
        debug: switchTrue is enabled
        debug: typeSwitchVar is enabled
        debug: underef is enabled
        debug: unlambda is enabled
        debug: unslice is enabled
    # ... результат работы линтера.



    Сравнение с gometalinter и golangci-lint


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


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


    lintpack упрощает создание новых линтеров, предоставляет фреймворк, делающий разные пакеты, реализованные на его основе, совместимыми в пределах одного исполняемого файла. Эти проверки (для golangci-lint) или исполняемый файл (для gometalinter) далее могут быть встроены в вышеупомянутые мета-линтеры.


    Допустим, какая-то из lintpack-совместимых проверок является частью golangci-lint. Если существует какая-то проблема, связанная с удобством её использования — это может быть зоной ответственности golangci-lint, но если речь идёт об ошибке в реализации самой проверки, то это проблема авторов проверки, lintpack экосистемы.


    Иными словами, эти проекты решают разные проблемы.


    А что там с go-critic?


    Процесс портирования go-critic на lintpack уже почти завершён. work-in-progress можно найти в репозитории go-critic/checkers. После завершения перехода, проверки будут перемещены в go-critic/go-critic/checkers.


    # Установка go-critic до:
    go get -v github.com/go-critic/go-critic/...
    
    # Установка go-critic после:
    lintpack -o gocritic github.com/go-critic/go-critic/checkers

    Большого смысла использовать go-critic вне golangci-lint нет, а вот lintpack может позволить установить те проверки, которые не входят в набор go-critic. Например, это могут быть диагностики, написанные вами.


    Продолжение следует


    Как создавать свои lintpack-совместимые проверки вы узнаете в следующей статье.


    Там же мы разберём какие преимущества вы получаете при реализации своего линтера на основе lintpack по сравнению с реализацией с чистого листа.


    Надеюсь, у вас появился аппетит к новым проверкам для Go. Дайте знать, как статического анализа станет слишком много, будем оперативно решать эту проблему вместе.

    Поделиться публикацией

    Комментарии 11

      +2

      На Go вообще что-нибудь пишут кроме линтеров и металинтеров?

        0
        А на основе чего у вас сложилось такое впечатление?
          +3
          На Go вообще что-нибудь пишут кроме линтеров и металинтеров?

          Конечно, пишут. Библиотеки для написания линтеров и металинтеров, например:
          https://go-toolsmith.github.io/

            0
            не понял вопроса. Пишут много чего. Вся продукция небезызвестной HashiCorp на go написана.
            И это не относится к категории «линтеров и металинтеров»
              0
              Docker, Kubernetes, приложения из HashiCorp и много-много сервисов для микросервисной архитектуры для компаний по всему миру.
              0
              Это очень круто.

              Выше вот удивляются, почему в Go обилие линтеров и металинтеров – и это как раз потому, что их чертовски легко писать (в сравнении с другими языками). Немного опыта работы с AST плюс фантазия и можно автоматизировать нахождение и исправление очень много чего – вплоть до линтеров специфичных для конкретного проекта. Проблема как раз в diminished returns – чем больше линтеров, тем больше оверхед на каждый из них, и тем больше время линтинга в целом. То есть, если я хочу добавить 15 линтеров, каждый из них будет парсить исходный код и колдовать над ним, и затраты на это могу превысить порог, когда это вообще оправданно. Я так, понимаю, линтер собранный lintpack-ом берет задачу парсинга и прохода по коду на себя, а линтеры ответственны только за саму проверку и ничего больше. Это сильно уменьшает затраты на написание (и включение в pipeline) нового линтера.

              Буду пробовать, спасибо!
                +1
                Я так, понимаю, линтер собранный lintpack-ом берет задачу парсинга и прохода по коду на себя, а линтеры ответственны только за саму проверку и ничего больше. Это сильно уменьшает затраты на написание (и включение в pipeline) нового линтера.

                Да, всё так.
                Плюс из коробки всё для тестирования (работает с coverage) и интеграционного тестирования.

                  +1
                  Непонятно, как линтеры и металинтеры можно легко писать в сравнении с другими языками на языке без алгебраических типов данных, паттерн-матчинга, вещей вроде uniplate (для беготни по деревьям) и идиом типа таких.
                    +1
                    Логично, что более выразительные (или сложные) языки анализировать не так просто.

                    Одна из причин, почему мне Go нравится и почему я пробую работать над статическим анализом для него — мне это по силам.
                      +1
                      Ну, не обязательно же анализаторы для $language писать на $language. Поэтому лучше всего писать анализаторы на выразительных языках для простых языков.
                        +1
                        Вы правы, мой аргумент был не о том, что анализаторы проще писать для Go, а не на Go. Но простота Go (которая делает его удобной мишенью для статических анализаторов) она не только в грамматике, но и в тулинге, скорости, опыте использования, вероятности контрибьютинга, стабильности языка и т.д. Я знаю, что если я напишу анализатор на Go, то через 4 года он будет всё так же работать, код будет всё так же читабелен, и с ним будет так же удобно работать.

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

                Самое читаемое