Хабрахабр

Как добавить проверки в NoVerify, не написав ни строчки Go-кода

В статическом анализаторе NoVerify появилась киллер-фича: декларативный способ описания инспекций, который не требует программирования на Go и компиляции кода.

Чтобы вас заинтриговать, покажу описание простой, но полезной инспекции:

/** @warning duplicated sub-expressions inside boolean expression */
$x && $x;

Эта инспекция находит все выражения логического &&, где левый и правый операнд идентичны.

Почитать о нём можно в статье «NoVerify: линтер для PHP от Команды ВКонтакте». NoVerify — статический анализатор для PHP, написанный на Go. А в этом обзоре я расскажу о новой функциональности и том, как мы к ней пришли.

Когда даже для простой новой проверки нужно написать несколько десятков строк кода на Go, начинаешь задумываться: а можно ли как-то иначе?

Эти компоненты уникальны, а вот задачи типа «запретить вызов функции X с набором аргументов Y» — нет. На Go у нас написан вывод типов, весь пайплайн линтера, кеш метаданных и многие другие важные элементы, без которых работа NoVerify невозможна. Как раз для таких простых задач и добавлен механизм динамических правил.

Файл с определениями можно хранить и версионировать отдельно — его могут редактировать люди, не имеющие отношения к разработке самого NoVerify. Динамические правила позволяют отделить сложные внутренности от решения типовых задач. Каждое правило реализует инспекцию кода (которую мы иногда будем называть проверкой).

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

Это упрощает его изучение, а также даёт возможность редактировать файлы с правилами, используя тот же PhpStorm. Язык описания синтаксически совместим с PHP.

В самом начале файла правил рекомендуется вставить директиву, успокаивающую любимую IDE:

<?php /** * Отключаем все инспекции для этого файла, * так как у нас здесь не исполняемый PHP-код. * * @noinspection ALL */ // ...А ниже — уже сами правила.

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

Некоторые мои коллеги уже попробовали phpgrep в работе, и это было ещё одним доводом в пользу выбора именно такого синтаксиса.

С помощью этой программы можно искать код через синтаксические шаблоны. Сам phpgrep является адаптацией gogrep для PHP (вам также может быть интересен cgrep).

Преимущества очевидны — это уже существующий формат, но я узнал об этой фиче после того, как реализовал phpgrep. Альтернативой мог бы быть синтаксис structural search and replace (SSR) из PhpStorm. Можно, конечно, привести техническое объяснение: там несовместимый с PHP синтаксис и наш парсер это не осилит, — но эта убедительная «настоящая» причина обнаружилась после написания велосипеда.

На самом деле, был ещё один вариант

Можно было требовать отображения шаблона с PHP-кодом почти один в один — или пойти другим путём: изобрести новый язык, например с синтаксисом S-выражений.

PHP-like Lisp-like
-----------------------------
$x = $y | (expr = $x $y)
fn($x, 1) | (expr call fn $x 1) Мы могли бы выражать типы и ветвление прямо внутри шаблонов: (or (expr == (type string (expr)) (expr)) (expr == (expr) (type string (expr))))

В итоге я посчитал, что читабельность шаблонов всё же важна, а фильтры мы можем добавлять через атрибуты phpdoc.

clang-query — пример подобной идеи, но он использует более традиционный синтаксис.

Давайте попробуем реализовать свою новую диагностику для анализатора.

Возьмите бинарный релиз, если у вас нет Go-тулчейна в системе (если есть, можете собрать всё из исходников). Для этого вам потребуется установленный NoVerify.

Постановка задачи

Её сигнатура: В PHP много любопытнейших функций, одна из них — parse_str.

// Разбирает строку encoded_string, которая должна иметь формат
// строки запроса URL, и присваивает значения переменным в
// текущем контексте (или в массиве, если задан параметр result). parse_str ( string $encoded_string [, array &$result ] ) : void

Вы поймёте, что здесь не так, если посмотрите на этот пример из документации:

$str = "first=value&arr[]=foo+bar&arr[]=baz"; parse_str($str);
echo $first; // value
echo $arr[0]; // foo bar
echo $arr[1]; // baz

Чтобы такого не допускать, мы будем в своей новой проверке требовать использовать второй параметр функции, $result, чтобы результат записывался в этот массив. М-м-м, параметры из строки оказались в текущей области видимости.

Создание своей диагностики

Создадим файл myrules.php:

<?php /** @warning parse_str without second argument */
parse_str($_);

К каждому такому шаблону ожидается особый phpdoc-комментарий. Файл правил в общем виде представляет собой список выражений на верхнем уровне, каждое из которых интерпретируется как phpgrep-шаблон. Обязательным является только один атрибут — категория ошибки с текстом предупреждения.

Первые два — критические: линтер вернёт ненулевой код после выполнения, если хотя бы одно из критических правил сработает. Всего сейчас есть четыре уровня: error, warning, info и maybe. После самого атрибута идёт текст предупреждения, который будет выдаваться линтером в случае срабатывания шаблона.

Мы могли бы назвать её, например, $x, но поскольку ничего с этой переменной мы не делаем, можем дать ей «пустое» название. В шаблоне, который мы написали, используется $_ — это безымянная переменная шаблона. Это удобно: нам гораздо чаще нужно искать неизвестные выражения, а не конкретные переменные. Отличие переменных шаблона от переменных PHP в том, что первые совпадают с абсолютно любым выражением, а не только с «дословной» переменной.

Запуск новой диагностики

Создадим небольшой тестовый файл для отладки, test.php:

<?php function f($x) { parse_str($x); // Здесь наш линтер должен ругаться
}

Далее запустим NoVerify с нашими правилами на этом файле:

$ noverify -rules myrules.php test.php

Наше предупреждение будет выглядеть примерно так:

WARNING myrules.php:4: parse_str without second argument at test.php:4 parse_str($x); ^^^^^^^^^^^^^

В нашем случае это myrules.php:4. Названием проверки по умолчанию выступает имя rules-файла и строчка, которая определяет эту проверку.

Можно задать своё имя, используя атрибут @name <name>.

Пример использования @name

/** * @name parseStrResult * @warning parse_str without second argument */
parse_str($_);

WARNING parseStrResult: parse_str without second argument at test.php:4 parse_str($x); ^^^^^^^^^^^^^

Именованные правила поддаются законам остальным диагностик:

  • Можно отключить через -exclude-checks
  • Уровень критичности можно переопределить через -critical

Предыдущий пример хорош для hello world — но часто нам нужно знать типы выражений, чтобы сократить количество срабатываний диагностики

Например, для функции in_array мы просим аргумент $strict=true тогда, когда первый аргумент ($needle) имеет строковой тип.

Для этого у нас есть фильтры результата.

Он позволяет отбрасывать всё то, что не подходит под перечисляемые типы. Один из таких фильтров — @type <type> <var>.

/** * @warning 3rd arg of in_array must be true when comparing strings * @type string $needle */
in_array($needle, $_);

Предупреждение будет выдаваться только тогда, когда тип $needle равен string. Здесь мы дали имя первому аргументу вызова in_array, чтобы привязать к нему фильтр типа.

Наборы фильтров можно комбинировать оператором @or:

/** * Каждой проверке можно дать комментарий-описание. * * @warning strings must be compared using '===' operator * @type string $x * @or * @type string $y */
$x == $y;

Можно считать, что без @or все фильтры комбинируются через @and, но явно это указывать не нужно. В примере выше шаблон будет совпадать только с теми выражениями ==, где любой из операндов имеет тип string.

Для каждой проверки можно указать @scope <name>:

  • @scope all — значение по умолчанию, проверка работает везде;
  • @scope root — запуск только на верхнем уровне;
  • @scope local — запуск только внутри функций и методов.

В PHP это иногда имеет смысл — например, когда файл подключается из функции… Но в рамках этой статьи мы такое осуждаем. Предположим, мы хотим докладывать о return вне тела функции.

/** * @warning don't use return outside of functions * @scope root */
return $_;

Посмотрим, как будет вести себя это правило:

<?php function f() { return "OK";
} return "NOT OK"; // Gives a warning class C
}

Аналогично можно сделать просьбу использовать *_once вместо require и include:

/** * @maybe prefer require_once over require * @scope root */
require($_); /** * @maybe prefer include_once over include * @scope root */
include($_);

Когда появляется дублирование phpdoc-комментариев у шаблонов, на помощь приходит возможность комбинирования шаблонов.

Простой пример для демонстрации:

А теперь представьте себе, как было бы неприятно описывать правило в следующем примере без этой особенности!

/** * @warning don't compare arrays with numeric types * @type array $x * @type int|float $y * @or * @type int|float $x * @type array $y */
{ $x > $y; $x < $y; $x >= $y; $x <= $y; $x == $y;
}

Если вы хотите поучаствовать в выборе, то у вас есть такая возможность: нужно ставить +1 тем предложениям, которые вам нравятся больше остальных. Формат записи, указанный в статье, — всего лишь один из предложенных вариантов. Подробнее — по ссылке.

В момент запуска NoVerify пытается найти файл с правилами, который указан в аргументе rules.

Далее этот файл разбирается как обычный PHP-скрипт, и из полученного AST собирается набор объектов-правил с привязанными к ним phpgrep-шаблонами.

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

Срабатыванием считается успешное сопоставление phpgrep-шаблона и прохождение хотя бы одного из наборов фильтров (они разделены @or).

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

Алгоритм матчинга

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

Это похоже на идею параллельного матчинга, только вместо честного построения NFA мы выполняем «параллелизацию» только первого шага вычислений.

Рассмотрим это на примере с тремя правилами:

/** @warning duplicated then/else parts of ternary */
$_ ? $x : $x; /** @warning don't call explode with delim="" */
explode("", ${"*"}); /** @maybe suspicious empty body of the if statement */
if ($_);

В теории эту сложность можно свести к линейной и получить O(N) — если объединить все шаблоны в один и выполнять матчинг так, как это делает, например, пакет regexp из Go. Если у нас N элементов и M правил, при наивном подходе имеем N*M операций для выполнения.

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

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

Муки выбора, или Немного о форме записи @type

Пофантазируем, как это могло бы выглядеть. Текущий синтаксис дублирует @var и @param, но нам могут понадобиться новые операторы, например, "тип не равен".

У нас как минимум два важных приоритета:

  1. Читабельный и лаконичный синтаксис аннотаций.
  2. Максимально возможная поддержка от IDE без дополнительных усилий.

Для PhpStorm есть плагин php-annotations, который добавляет автодополнение, переход к классам-аннотациям и прочие полезности для работы с phpdoc-комментариями.

Например, можно сделать аннотации в таком формате, который сможет распознавать плагин php-annotations: Приоритет (2) на практике означает, что вы принимаете решения, которые не противоречат ожиданиям IDE и плагинов.

/** * Type is a filter that checks that $value * satisfies the given type constraints. * * @Annotation */
class Filter { /** Variable name that is being filtered */ public $value; /** Check that value type is equal to $type */ public $type; /** Check that value text is equal to $text */ public $text;
}

Тогда применение фильтра для типов выглядело бы как-то так:

@Type($needle, eq=string)
@Type($x, not_eq=Foo)

Пользователи могли бы переходить к определению Filter, подсказывался бы список возможных параметров (type/text/etc).

Альтернативные способы записи, некоторые из которых были предложены коллегами:

@type $needle == string
@type $x != Foo @type(==) string $needle
@type(!=) Foo $x @type($needle) == string
@type($x) != Foo @filter type($needle) == string
@filter type($x) != Foo

Потом мы немного отвлеклись и забыли, что это всё внутри phpdoc, — и появилось такое:

(eq string (typeof $needle))
(neq Foo (typeof $x))

Язык для описания ограничений типов и значений можно было бы назвать sixth: Хотя вариант с постфиксной записью в шутку тоже прозвучал.

@eval string $needle typeof =
@eval Foo $x typeof <>

Поиски самого лучшего варианта всё ещё не закончены...

Как одно из преимуществ Phan в статье "Статический анализ PHP-кода на примере PHPStan, Phan и Psalm" указывается расширяемость.

Вот то, что было реализовано в плагине-примере:

3 (в частности, узнать, нет ли в нём case-insensitive-констант). Мы захотели оценить, насколько наш код готов к PHP 7. И мы написали плагин для Phan, который бы ругался, если бы в define() использовался третий параметр. Мы практически были уверены в том, что таких констант нет, но за 12 лет могло произойти всякое — следовало проверить.

Так выглядит код плагина (форматирование оптимизировано по ширине):

<?php use Phan\AST\ContextNode;
use Phan\CodeBase;
use Phan\Language\Context;
use Phan\Language\Element\Func;
use Phan\PluginV2;
use Phan\PluginV2\AnalyzeFunctionCallCapability;
use ast\Node; class DefineThirdParamTrue extends PluginV2 implements AnalyzeFunctionCallCapability { public function getAnalyzeFunctionCallClosures(CodeBase $code_base) { $def = function(CodeBase $cb, Context $ctx, Func $fn, $args) { if (count($args) < 3) { return; } $this->emitIssue( $cb, $ctx, 'PhanDefineCaseInsensitiv', 'define with 3 arguments', [] ); }; return ['define' => $def]; }
} return new DefineThirdParamTrue();

А вот как это можно было бы сделать в NoVerify:

<?php /** @warning define with 3 arguments */
define($_, $_, $_);

Примерно такого результата мы и хотели добиться — чтобы тривиальные вещи можно было делать максимально просто.

  • Попробуйте NoVerify в своём проекте.
  • Если у вас будут идеи для доработок или отчёты о багах, расскажите нам об этом.
  • Если вы хотите поучаствовать в разработке, welcome!

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

Если вам нужны ещё примеры правил, которые можно реализовать, можете подглядеть в тестах NoVerify.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть