Русский
Русский
English
Статистика
Реклама

Noverify

Статический анализ baseline файлы vs diff

25.06.2020 12:16:27 | Автор: admin

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


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



baseline или так называемый suppress profile


Этот метод имеет несколько названий: baseline файл в Psalm и Android Lint, suppress база (или профиль) в PVS-Studio, code smell baseline в detekt.


Данный файл генерируется линтером при запуске на проекте:


superlinter --create-baseline baseline.xml ./project

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


При запуске статического анализатора с baseline.xml, все предупреждения, которые содержатся в этом файле, будут проигнорированы:


superlinter --baseline baseline.xml ./project

Прямолинейный подход, где предупреждения сохраняются целиком вместе с номером строки будет работать недостаточно хорошо: стоит добавить новый код в начало файла, как все строки вдруг перестанут совпадать и вы получите все предупреждения, которые планировалось игнорировать.


Обычно, мы хотим достичь следующего:


  • На новый код выдаются все предупреждения
  • На старый код предупреждения выдаются только если его редактировали
  • (опционально) Переносы файлы не должны выдавать предупреждения на весь файл

То, что мы можем конфигурировать в этом подходе это какие поля предупреждения формируют хеш (или "сигнатуру") срабатывания. Чтобы уйти от проблем смещения строк кода, лучше не добавлять номер строки в эти поля.


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


  • Название или код диагностики
  • Текст предупреждения
  • Название файла
  • Строка исходного кода, на которую сработало предупреждение

Чем больше признаков, тем ниже риск коллизии, но при этом выше риск неожиданного срабатывания из-за инвалидации сигнатуры. Если любой из используемых признаков в коде изменяется, предупреждение перестанет игнорироваться.


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


Ещё один менее очевидный признак имя функции или метода, в которой выдавалось предупреждение. Это позволяет снизить количество коллизий, но провоцирует шквал предупреждений на всю функцию, если вы её переименуете.


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


Хорошо подобранный набор признаков увеличивает эффективность baseline подхода.


Коллизии в методе baseline


Допустим, диагностика W104 находит вызовы die в коде.


В проверяемом проекте есть файл foo.php:


function legacy() {  die('test');}

Предположим, используются признаки {имя файла, код диагностики, строка исходного кода}.


Наш анализатор при создании baseline добавляет вызов die('test') в свою базу исключений:


{  "filename": "foo.php",  "diag": "W104",  "line": "die('test');"}

Теперь добавим немного нового кода:


+ function newfunc() {+   die('test');+ }  function legacy() {    die('test');  }

По всем используемым признакам новый вызов die('test') будет распознаваться как игнорируемый код. Совпадение сигнатур для потенциально разных кусков кода мы и называем коллизией.


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


Что делать, если вызов die('test') был добавлен в ту же функцию? Этот вызов может иметь одинаковые соседние строки в обоих случаях, поэтому добавление предыдущей и следующей строки в сигнатуре не поможет.


Здесь нам на помощь приходит счётчик сигнатур с коллизиями. Таким образом мы можем определить, что внутри функции ожидалось одно срабатывание, а получили два или более все, кроме первого, нужно сообщать.


При этом мы теряем некоторую точность: нельзя определить, какая из строк новая, а какая старая. Мы будем ругаться на ту, что будет идти после проигнорированных.


Метод, основанный на diff возможностях VCS


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


Утилита revgrep принимает на stdin поток предупреждений, анализирует git diff и выдаёт на выход только те предупреждения, которые исходят от новых строк.


golangci-lint использует форк revgrep как библиотеку, так что в основе его вычисления diff'а лежат те же алгоритмы.

Если выбран этот путь, придётся искать ответ на следующие вопросы:


  • Как выбрать окно коммитов для вычисления diff?
  • Как будем обрабатывать коммиты, пришедшие из основной ветки (merge/rebase)?

Вдобавок нужно понимать, что иногда мы всё же хотим выдавать предупреждения, выходящие за рамки diff. Пример: вы удалили класс директора по мемам, MemeDirector. Если этот класс упоминался в каких-либо doc-комментариях, желательно, чтобы линтер сообщил об этом.


Нам нужно не только получить правильный набор затронутых строк, но и расширить его так, чтобы найти побочные эффекты изменений во всём проекте.


Окно коммитов тоже можно определить по-разному. Скорее всего, вы не захотите проверять только последний коммит, потому что тогда будет возможно пушить сразу два коммита: один с предупреждениями, а второй для обхода CI. Даже если это не будет происходить умышленно, в системе появляется возможность пропустить критический дефект. Также стоит учесть, что предыдущий коммит может быть взят из основной ветки, в этом случае проверять его тоже не следует.


diff режим в NoVerify


NoVerify имеет два режима работы: diff и full diff.


Обычный diff может найти предупреждения по файлам, которые были затронуты изменениями в рамках рассматриваемого диапазона коммитов. Это работает быстро, но хорошего анализа зависимостей изменений не происходит, поэтому новые предупреждения из незатронутых файлов мы найти не можем.


Full diff запускает анализатор дважды: один раз на старом коде, затем на новом коде, а потом фильтрует результаты. Это можно сравнить с генерацией baseline файла на лету с помощью того, что мы можем получить предыдущую версию кода через git. Ожидаемо, этот режим увеличивает время выполнения почти вдвое.


Изначальная схема работы предполагалась такая: на pre-push хуках запускается более быстрый анализ, в режиме обычного diff'а, чтобы люди получали обратную связь как можно быстрее. На CI агентах полный diff. В результате время от времени люди спрашивают, почему на агентах проблемы были найдены, а локально всё чисто. Удобнее иметь одинаковые процессы проверки, чтобы при прохождении pre-push хука была гарантия прохождения на CI фазы линтера.


full diff за один проход


Мы можем делать приближенный к full diff аналог, который не требует двойного анализа кода.


Допустим, в diff попала такая строка:


- class Foo {

Если мы попробуем классифицировать эту строку, то определим её как "Foo class deletion".


Каждая диагностика, которая может как-то зависеть от наличия класса, должна выдавать предупреждение, если имеется факт удаления этого класса.


Аналогично с удалениями переменных (глобальных или локальных) и функций, мы генерируем набор фактов о всех изменениях, которые можем классифицировать.


Переименования не требуют дополнительной обработки. Мы считаем, что символ со старым именем был удалён, а с новым добавлен:


- class MemeManager {+ class SeniorMemeManager {

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


Выводы


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


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


baseline diff
+ легко сделать эффективным + не требует хранить файлов
+ простота реализации и конфигурации + проще отличать новый код от старого
- нужно решать коллизии - сложно правильно приготовить

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


Лично для меня, diff режим выглядит более изящным подходом, но однопроходная реализация полного анализа может оказаться слишком затруднительной и хрупкой.

Подробнее..

Ускоряем кеш проекта в NoVerify (линтер для PHP) в 10 раз

18.09.2020 20:23:44 | Автор: admin

Одним вечером, обсуждая с Искандером @quasilyte сложности в разработке линтера для PHP на Go, Искандер упомянул, что тесты как-то долго идут при локальном прогоне (около минуты, и, как мне кажется, для Go это довольно долго). Стали копать, и быстро выяснилось, что в основном тормозят тесты, которые запускают NoVerify (название линтера) в режиме со включенным race-детектором. На каждый запуск индексируется репозиторий phpstorm-stubs, в котором содержатся все определения встроенных функций/классов/констант, которые есть в PHP, и индексация этого репозитория занимает около 4 секунд на 4-ядерной машине (замечу, что без race detector всё существенно быстрее). Поскольку таких прогонов делается несколько, по одному на каждый тестируемый open-source проект, суммарное время исполнения всех тестов может занимать минуты. NoVerify позиционирует себя как очень быстрый линтер для PHP, поэтому, конечно же, такая производительность несколько печалит и нужно было найти какое-то решение.

Архитектура NoVerify

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

Индексация проекта заключается в том, что из всех PHP-файлов извлекаются определения и типы всех функций, классов, методов, констант и глобальных переменных, и вся эта информация сохраняется в оперативной памяти для быстрого доступа. Также эта информация сохраняется в кеш на диске в формате gob, чтобы избежать необходимости парсить весь проект заново каждый раз. Важно, что даже для анализа одного файла должен быть проиндексирован весь проект, потому что если для классов в PHP есть autoload, то для функций, констант и уж тем более глобальных переменных такого нет, и определены они могут быть где угодно. Безусловно, в современных проектах на PHP обычно используются только классы и таких проблем не возникает, но для проектов с длинной историей это всё ещё бывает актуально. Именно необходимость индексации всего проекта для его анализа и послужила причиной написания NoVerify на Go, поскольку этот язык хорошо поддерживает многопоточность, а значит сможет индексировать проект намного быстрее, чем это возможно на PHP.

Анализ может производиться как для выбранных файлов (например, тех, что изменились в текущем коммите), так и для всего проекта сразу. В первом случае анализ занимает меньше времени, чем построение индекса, иногда значительно (как в примере выше с тестами, где индексация phpstorm-stubs занимает 90+% времени работы). Для анализа используется информация о типах всех объявленных функций/классов/методов, которая была собрана на этапе индексации.

Ускорение индексации phpstorm-stubs

Первым интересным, на мой взгляд, наблюдением, было то, что репозиторий phpstorm-stubs, который, напомню, содержит просто заглушки (т.е. функции без реализации) для всех встроенных функций/констант/классов в PHP, индексируется на 25% быстрее, если не используется дисковый кеш для индекса проекта, то есть когда все файлы просто целиком парсятся заново. Это было немного неожиданно, но в целом объяснимо: поскольку в phpstorm-stubs содержатся только заглушки для функций, без реализации, то особого выигрыша от кеша и не может быть, ведь в кеше хранятся только определения функций, их типы, расположение в файле, и т.д., и основная экономия достигается за счет того, что не хранится само тело функции.

Есть несколько возможностей ускорить загрузку заглушек в тестах:

  1. Загружать только те файлы, которые реально нужны в конкретном проекте. Поскольку это тестовые данные, то возможно просто составить весь список нужных файлов из phpstorm-stubs заранее.

  2. Скопировать только нужные объявления из phpstorm-stubs в отдельный файл для конкретного проекта, чтобы ещё сильнее уменьшить количество файлов, которые нужно обрабатывать.

  3. Попробовать использовать более эффективный формат для дискового кеша, чем gob.

  4. Учитывая, что phpstorm-stubs тоже статичны, можно сгенерировать Go-код из структур для кеша в памяти и инициализировать индекс для phpstorm-stubs на старте программы. Неизвестно, какой это выигрыш может дать, но кажется, что он может быть в 2-3 раза при прочих равных за счёт отсутствия необходимости парсить файлы.

Были испробованы все варианты, кроме (2), то есть копирования только нужных определений функций. В конечном итоге, вариант (1), то есть парсить только нужные файлы, оказался самым простым в поддержке и давал шестикратный прирост производительности, которого оказалось вполне достаточно. Однако, было бы странно умолчать о том, что вариант с генерацией Go кода тоже был протестирован и с помощью компиляции кода удалось сократить время загрузки phpstorm-stubs до ~200 мс (т.е. ускорить в 20 раз загрузку статичных данных), однако даже со всеми оптимизациями для уменьшения времени компиляции, сборка всё равно занимала порядка 18 секунд, что сводило весь выигрыш от ускорения загрузки заглушек на нет, и добавляло много сложного в поддержке кода для генерации максимально компактного кода для инициализации кеша.

Как насчёт ускорения кеша для всего проекта?

Несмотря на то, что для ускорения golden-тестов кодогенерация была сомнительным решением, сложно игнорировать тот факт, что этот подход дает настолько большой прирост скорости, что наверняка можно применить его для чего-то кроме тестов. Так у меня родилась идея попробовать ускорить вообще весь кеш проекта, а не только первую его часть, то есть загрузку phpstorm-stubs. Так сказать, сделать турбо-кеш.

Кеш проекта настолько быстрый, что для индексации даже весьма большого проекта (я тестировал на пустом проекте Laravel, созданном с помощью командыcomposer create-project --prefer-dist laravel/laravel blog, которая генерирует 1.6 млн строк кода на PHP) на одном ядре процессора требуется порядка 450 мс (с текущим файловым кешом требуется порядка 4 секунд), что дает возможность запускать NoVerify локально, например, при каждом сохранении файла и сразу видеть новые предупреждения, без необходимости использовать его, как language server.

Недостатки/особенности решения

Время сборки Go-кода для кеша для проекта в 1.6 млн строк занимает 20-60 секунд, в зависимости от режима сборки, поэтому использовать его для ускорения цикла разработки и тестирования самого линтера, возможно, и не получится. Время сборки не являлось первым приоритетом при разработке кеша на основе кодогенерации.

Сам кеш также сильно увеличивает размер бинарника: на вышеупомянутом проекте бинарник noverifyturbo вырос в размере с 20 мб до 100 мб, то есть прирост составил ~80 мб на 1.6 млн строк PHP-кода.

Также, из-за особенностей реализации кеша в NoVerify, пути до файлов являются частью пути до файла с кешом и они должны в точности совпадать у пользователя с путями, использовавшихся во время сборки NoVerify. Эта проблема решаема, но требует доработок в механизме работы кеша в линтере.

Как работает турбо-кеш

Основная идея кеша на основе кодогенерации очень проста: берем структуры данных, в которых хранится кеш в памяти и печатаем их как Go-код с помощью обычного fmt.Printf("%#v", value).

пример сгенерированного кодапример сгенерированного кода

Для этого, правда, все поля структур в кеше должны быть публичными, но это при желании тоже можно обойти, если определять свой метод GoStringer(), в котором вызывать конструкторы для инициализации значений.

Для каждого PHP-файла создадим свой Go-файл, и объявим один большой map[string]func()*PerFileCache, где в качестве ключа будет указываться обычный путь до файла с кешом (этот путь зависит от имени PHP-файла и от хеша от его содержимого, и он у нас уже есть, поскольку файловый кеш всё ещё полезен), а в качестве значения будет функция, возвращающая уже инициализированную структуру с кешом для конкретного PHP-файла. Вместо того, чтобы возвращать функции, можно сразу сделать указатели на значения, но не факт, что нам понадобятся все записи из этого map (поскольку турбо-кеш можно использовать только для тех файлов, которые не менялись с момента создания этого кеша), и заодно, при желании, объекты можно создавать из нескольких горутин одновременно, а если мы сразу инициализируем весь map значениями, то эту логику нам придется писать самим.

В качестве дополнительной оптимизации можно кешировать соответствие между размером файла+временем его модификации и хешом от его содержимого, чтобы не требовалось читать содержимое всех файлов в проекте во время индексации, а всего-лишь звать системный вызов stat() на каждый файл, который обычно работает намного быстрее чтения файла целиком.

Результаты

Для пустого проекта Laravel на 1.6 миллиона строк, созданного с помощью команды composer create-project --prefer-dist laravel/laravel blog , получаются следующие цифры:

  1. Индексация с помощью файлового кеша, 1 ядро процессора: 4 секунды

  2. Индексация с помощью турбо-кеша, 1 ядро: 400 мс

  3. Файловый кеш, 12 логических ядер: 1 секунда (10 секунд процессорного времени)

  4. Турбо-кеш, 12 логических ядер: 240 мс (800 мс процессорного времени)

Можно заметить, что турбо-кеш не так хорошо использует многопоточность, и это связано с тем, что для составления кеша всё равно нужно в какой-то момент обновлять глобальный map со всеми классами, функциями и константами, поэтому общая производительность будет ограничена этим. Тем не менее, если даже турбо-кеш станет узким местом, всегда можно разделить глобальный кеш на несколько частей и обновлять их независимо, что должно тоже дать некоторый прирост.

Реальное использование

Сделанный мной кеш это лишь эксперимент, который мне было интересно провести в своё личное время, поэтому применимость этой конкретной реализации на практике может быть никакой, всё зависит от мейнтейнеров проекта, коим я не являюсь (я в данный момент работаю в Google, но этот эксперимент делался в свободное от работы время и не имеет отношения к компании).

Поскольку NoVerify написан на Go, то он может компилироваться в один нативный бинарный файл, в который уже встроен phpstorm-stubs и также может быть встроен прогретый кеш проекта. Такой бинарник может стартовать и проверять отдельные файлы достаточно быстро даже на слабых ноутбуках. Это, в теории, позволяет разработчикам легко встроить NoVerify в свой workflow разработки, например запускать его локально каждый раз при сохранении файла в PHPStorm или VS Code и сразу видеть новые предупреждения линтера, если таковые имеются.

Ссылки

  1. NoVerify быстрый линтер PHP от ВКонтакте.

  2. phpstorm-stubs репозиторий с заглушками для встроенных в PHP функций и классов.

  3. Пример реализации кеша на основе кодогенерации в NoVerify

  4. Эксперимент с кодогенерацией для статичных phpstorm-stubs

  5. Сравнение различных форматов сериализации данных в Go

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru