В статическом анализаторе NoVerify появилась киллер-фича: декларативный способ описания инспекций, который не требует программирования на Go и компиляции кода.
Чтобы вас заинтриговать, покажу описание простой, но полезной инспекции:
/** @warning duplicated sub-expressions inside boolean expression */
$x && $x;
Эта инспекция находит все выражения логического &&
, где левый и правый операнд идентичны.
NoVerify — статический анализатор для PHP, написанный на Go. Почитать о нём можно в статье «NoVerify: линтер для PHP от Команды ВКонтакте». А в этом обзоре я расскажу о новой функциональности и том, как мы к ней пришли.
Предпосылки
Когда даже для простой новой проверки нужно написать несколько десятков строк кода на Go, начинаешь задумываться: а можно ли как-то иначе?
На Go у нас написан вывод типов, весь пайплайн линтера, кеш метаданных и многие другие важные элементы, без которых работа NoVerify невозможна. Эти компоненты уникальны, а вот задачи типа «запретить вызов функции X с набором аргументов Y» — нет. Как раз для таких простых задач и добавлен механизм динамических правил.
Динамические правила позволяют отделить сложные внутренности от решения типовых задач. Файл с определениями можно хранить и версионировать отдельно — его могут редактировать люди, не имеющие отношения к разработке самого NoVerify. Каждое правило реализует инспекцию кода (которую мы иногда будем называть проверкой).
Да, если у нас есть язык описания этих правил, всегда можно написать семантически некорректный шаблон или не учесть какие-то ограничения типов — а это приводит к ложным срабатываниям. Тем не менее гонку данных или разыменование nil
-указателя через язык правил не внести.
Язык описания шаблонов
Язык описания синтаксически совместим с PHP. Это упрощает его изучение, а также даёт возможность редактировать файлы с правилами, используя тот же PhpStorm.
В самом начале файла правил рекомендуется вставить директиву, успокаивающую любимую IDE:
<?php
/**
* Отключаем все инспекции для этого файла,
* так как у нас здесь не исполняемый PHP-код.
*
* @noinspection ALL
*/
// ...А ниже — уже сами правила.
Моим первым экспериментом с синтаксисом и возможными фильтрами для шаблонов был phpgrep. Он может быть полезен и сам по себе, но внутри NoVerify он стал ещё интереснее, потому что теперь он имеет доступ к информации о типах.
Некоторые мои коллеги уже попробовали phpgrep в работе, и это было ещё одним доводом в пользу выбора именно такого синтаксиса.
Сам phpgrep является адаптацией gogrep для PHP (вам также может быть интересен cgrep). С помощью этой программы можно искать код через синтаксические шаблоны.
Альтернативой мог бы быть синтаксис structural search and replace (SSR) из PhpStorm. Преимущества очевидны — это уже существующий формат, но я узнал об этой фиче после того, как реализовал phpgrep. Можно, конечно, привести техническое объяснение: там несовместимый с 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 — пример подобной идеи, но он использует более традиционный синтаксис.
Создаём и запускаем свою диагностику!
Давайте попробуем реализовать свою новую диагностику для анализатора.
Для этого вам потребуется установленный NoVerify. Возьмите бинарный релиз, если у вас нет 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($_);
Файл правил в общем виде представляет собой список выражений на верхнем уровне, каждое из которых интерпретируется как phpgrep-шаблон. К каждому такому шаблону ожидается особый phpdoc-комментарий. Обязательным является только один атрибут — категория ошибки с текстом предупреждения.
Всего сейчас есть четыре уровня: 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);
^^^^^^^^^^^^^
Названием проверки по умолчанию выступает имя rules-файла и строчка, которая определяет эту проверку. В нашем случае это myrules.php:4
.
Можно задать своё имя, используя атрибут @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, $_);
Здесь мы дали имя первому аргументу вызова in_array
, чтобы привязать к нему фильтр типа. Предупреждение будет выдаваться только тогда, когда тип $needle
равен string
.
Наборы фильтров можно комбинировать оператором @or
:
/**
* Каждой проверке можно дать комментарий-описание.
*
* @warning strings must be compared using '===' operator
* @type string $x
* @or
* @type string $y
*/
$x == $y;
В примере выше шаблон будет совпадать только с теми выражениями ==
, где любой из операндов имеет тип string
. Можно считать, что без @or
все фильтры комбинируются через @and
, но явно это указывать не нужно.
Ограничиваем область действия диагностики
Для каждой проверки можно указать @scope <name>
:
@scope all
— значение по умолчанию, проверка работает везде;@scope root
— запуск только на верхнем уровне;@scope local
— запуск только внутри функций и методов.
Предположим, мы хотим докладывать о return
вне тела функции. В PHP это иногда имеет смысл — например, когда файл подключается из функции… Но в рамках этой статьи мы такое осуждаем.
/**
* @warning don't use return outside of functions
* @scope root
*/
return $_;
Посмотрим, как будет вести себя это правило:
<?php
function f() {
return "OK";
}
return "NOT OK"; // Gives a warning
class C {
public function m() {
return "ALSO OK";
}
}
Аналогично можно сделать просьбу использовать *_once
вместо require
и include
:
/**
* @maybe prefer require_once over require
* @scope root
*/
require($_);
/**
* @maybe prefer include_once over include
* @scope root
*/
include($_);
Группирование шаблонов
Когда появляется дублирование phpdoc-комментариев у шаблонов, на помощь приходит возможность комбинирования шаблонов.
Простой пример для демонстрации:
Было | Стало (с группированием) |
---|---|
/** @maybe don't use exit or die */ die($_); /** @maybe don't use exit or die */ exit($_); |
/** @maybe don't use exit or die */ { die($_); exit($_); } |
А теперь представьте себе, как было бы неприятно описывать правило в следующем примере без этой особенности!
/**
* @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 ($_);
Если у нас N элементов и M правил, при наивном подходе имеем N*M операций для выполнения. В теории эту сложность можно свести к линейной и получить O(N)
— если объединить все шаблоны в один и выполнять матчинг так, как это делает, например, пакет regexp из Go.
Однако на практике я пока остановился на частичной реализации такого подхода. Он позволит правила из файла выше разделить на три категории, а тем элементам AST, которым не соответствует никакое правило, присвоить четвёртую, пустую категорию. Благодаря этому на каждый элемент выполняется не более одного правила.
Если у нас появятся тысячи правил и мы будем ощущать значительное замедление, алгоритм будет доработан. А пока простота решения и полученное ускорение меня устраивают.
Муки выбора, или Немного о форме записи @type
Задача: выбрать для фильтров хороший синтаксис в рамках phpdoc-аннотаций. |
Текущий синтаксис дублирует @var
и @param
, но нам могут понадобиться новые операторы, например, "тип не равен". Пофантазируем, как это могло бы выглядеть.
У нас как минимум два важных приоритета:
- Читабельный и лаконичный синтаксис аннотаций.
- Максимально возможная поддержка от IDE без дополнительных усилий.
Для PhpStorm есть плагин php-annotations, который добавляет автодополнение, переход к классам-аннотациям и прочие полезности для работы с phpdoc-комментариями.
Приоритет (2) на практике означает, что вы принимаете решения, которые не противоречат ожиданиям IDE и плагинов. Например, можно сделать аннотации в таком формате, который сможет распознавать плагин php-annotations:
/**
* 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
Как одно из преимуществ Phan в статье "Статический анализ PHP-кода на примере PHPStan, Phan и Psalm" указывается расширяемость.
Вот то, что было реализовано в плагине-примере:
Мы захотели оценить, насколько наш код готов к PHP 7.3 (в частности, узнать, нет ли в нём case-insensitive-констант). Мы практически были уверены в том, что таких констант нет, но за 12 лет могло произойти всякое — следовало проверить. И мы написали плагин для Phan, который бы ругался, если бы в define() использовался третий параметр.
Так выглядит код плагина (форматирование оптимизировано по ширине):
<?php
use PhanASTContextNode;
use PhanCodeBase;
use PhanLanguageContext;
use PhanLanguageElementFunc;
use PhanPluginV2;
use PhanPluginV2AnalyzeFunctionCallCapability;
use astNode;
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 на Хабре
- Статья про phpgrep на Хабре
- Видео доклада о phpgrep с IT Nights
- gogrep — поиск Go кода по синтаксическим шаблонам
- Близкая к синтаксическим шаблонам концепция AST selectors из ESLint
Если вам нужны ещё примеры правил, которые можно реализовать, можете подглядеть в тестах NoVerify.
Автор: Искандер