- PVSM.RU - https://www.pvsm.ru -
В статическом анализаторе NoVerify [1] появилась киллер-фича: декларативный способ описания инспекций, который не требует программирования на Go и компиляции кода.
Чтобы вас заинтриговать, покажу описание простой, но полезной инспекции:
/** @warning duplicated sub-expressions inside boolean expression */
$x && $x;
Эта инспекция находит все выражения логического &&, где левый и правый операнд идентичны.
NoVerify [1] — статический анализатор для PHP, написанный на Go [2]. Почитать о нём можно в статье «NoVerify: линтер для PHP от Команды ВКонтакте [3]». А в этом обзоре я расскажу о новой функциональности и том, как мы к ней пришли.

Когда даже для простой новой проверки нужно написать несколько десятков строк кода на Go, начинаешь задумываться: а можно ли как-то иначе?
На Go у нас написан вывод типов, весь пайплайн линтера, кеш метаданных и многие другие важные элементы, без которых работа NoVerify невозможна. Эти компоненты уникальны, а вот задачи типа «запретить вызов функции X с набором аргументов Y» — нет. Как раз для таких простых задач и добавлен механизм динамических правил.
Динамические правила позволяют отделить сложные внутренности от решения типовых задач. Файл с определениями можно хранить и версионировать отдельно — его могут редактировать люди, не имеющие отношения к разработке самого NoVerify. Каждое правило реализует инспекцию кода (которую мы иногда будем называть проверкой).
Да, если у нас есть язык описания этих правил, всегда можно написать семантически некорректный шаблон или не учесть какие-то ограничения типов — а это приводит к ложным срабатываниям. Тем не менее гонку данных или разыменование nil-указателя через язык правил не внести.
Язык описания синтаксически совместим с PHP. Это упрощает его изучение, а также даёт возможность редактировать файлы с правилами, используя тот же PhpStorm.
В самом начале файла правил рекомендуется вставить директиву, успокаивающую любимую IDE:
<?php
/**
* Отключаем все инспекции для этого файла,
* так как у нас здесь не исполняемый PHP-код.
*
* @noinspection ALL
*/
// ...А ниже — уже сами правила.
Моим первым экспериментом с синтаксисом и возможными фильтрами для шаблонов был phpgrep [4]. Он может быть полезен и сам по себе, но внутри NoVerify он стал ещё интереснее, потому что теперь он имеет доступ к информации о типах.
Некоторые мои коллеги уже попробовали phpgrep в работе, и это было ещё одним доводом в пользу выбора именно такого синтаксиса [5].
Сам phpgrep является адаптацией gogrep [6] для PHP (вам также может быть интересен cgrep [7]). С помощью этой программы можно искать код через синтаксические шаблоны [8].
Альтернативой мог бы быть синтаксис structural search and replace [9] (SSR) из PhpStorm. Преимущества очевидны — это уже существующий формат, но я узнал об этой фиче после того, как реализовал phpgrep. Можно, конечно, привести техническое объяснение: там несовместимый с PHP синтаксис и наш парсер [10] это не осилит, — но эта убедительная «настоящая» причина обнаружилась после написания велосипеда.
Можно было требовать отображения шаблона с PHP-кодом почти один в один — или пойти другим путём: изобрести новый язык, например с синтаксисом S-выражений [11].
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 [12] — пример подобной идеи, но он использует более традиционный синтаксис.
Давайте попробуем реализовать свою новую диагностику для анализатора.
Для этого вам потребуется установленный NoVerify. Возьмите бинарный релиз [13], если у вас нет Go-тулчейна в системе (если есть, можете собрать всё из исходников).
| Если вы не установите NoVerify, можете продолжить читать дальше, но делайте вид, что воспроизводите перечисляемые шаги и восхищаетесь результатом! |
В PHP много любопытнейших функций, одна из них — parse_str [14]. Её сигнатура:
// Разбирает строку 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 [15]-комментарий. Обязательным является только один атрибут — категория ошибки с текстом предупреждения.
Всего сейчас есть четыре уровня: 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 [16] мы просим аргумент $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 тем предложениям, которые вам нравятся больше остальных. Подробнее — по ссылке [17].

В момент запуска NoVerify пытается найти файл с правилами, который указан в аргументе rules.
Далее этот файл разбирается как обычный PHP-скрипт, и из полученного AST [18] собирается набор объектов-правил с привязанными к ним phpgrep-шаблонами.
Затем анализатор начинает работу по обычной схеме — разница только в том, что для некоторых проверяемых участков кода он запускает набор привязанных правил. Если правило срабатывает, выводится предупреждение.
Срабатыванием считается успешное сопоставление phpgrep-шаблона и прохождение хотя бы одного из наборов фильтров (они разделены @or).
На данном этапе механизм правил не вносит значительного замедления в работу линтера, даже если динамических правил достаточно много.
При наивном подходе для каждого AST-узла нам нужно применять все динамические правила. Это очень неэффективная реализация, потому что большая часть работы будет проделана впустую: многие шаблоны имеют определённый префикс, по которому мы можем кластеризовать правила.
Это похоже на идею параллельного матчинга [19], только вместо честного построения 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 [20] из Go.
Однако на практике я пока остановился на частичной реализации такого подхода. Он позволит правила из файла выше разделить на три категории, а тем элементам AST, которым не соответствует никакое правило, присвоить четвёртую, пустую категорию. Благодаря этому на каждый элемент выполняется не более одного правила.
Если у нас появятся тысячи правил и мы будем ощущать значительное замедление, алгоритм будет доработан. А пока простота решения и полученное ускорение меня устраивают.
@type| Задача: выбрать для фильтров хороший синтаксис в рамках phpdoc-аннотаций. |
Текущий синтаксис дублирует @var и @param, но нам могут понадобиться новые операторы, например, "тип не равен". Пофантазируем, как это могло бы выглядеть.
У нас как минимум два важных приоритета:
Для PhpStorm есть плагин php-annotations [21], который добавляет автодополнение, переход к классам-аннотациям и прочие полезности для работы с 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 [22] в статье "Статический анализ PHP-кода на примере PHPStan, Phan и Psalm [23]" указывается расширяемость.
Вот то, что было реализовано в плагине-примере:
Мы захотели оценить, насколько наш код готов к 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 [28].
Автор: Искандер
Источник [29]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/vkontakte/335788
Ссылки в тексте:
[1] NoVerify: https://github.com/VKCOM/noverify
[2] Go: https://golang.org/
[3] NoVerify: линтер для PHP от Команды ВКонтакте: https://habr.com/ru/company/vk/blog/442284/
[4] phpgrep: https://github.com/quasilyte/phpgrep
[5] синтаксиса: https://github.com/quasilyte/phpgrep/blob/master/pattern_language.md
[6] gogrep: https://github.com/mvdan/gogrep
[7] cgrep: http://awgn.github.io/cgrep/
[8] синтаксические шаблоны: https://www.youtube.com/watch?v=34Rk4uLPn1A
[9] structural search and replace: https://www.jetbrains.com/help/phpstorm/structural-search-and-replace.html
[10] наш парсер: https://github.com/z7zmey/php-parser
[11] S-выражений: https://en.wikipedia.org/wiki/S-expression
[12] clang-query: https://devblogs.microsoft.com/cppblog/exploring-clang-tooling-part-2-examining-the-clang-ast-with-clang-query/
[13] бинарный релиз: https://github.com/VKCOM/noverify/releases/tag/v0.1.0
[14] parse_str: https://www.php.net/manual/ru/function.parse-str.php
[15] phpdoc: https://www.phpdoc.org/
[16] in_array: https://www.php.net/manual/ru/function.in-array.php
[17] ссылке: https://github.com/VKCOM/noverify/issues/276
[18] AST: https://en.wikipedia.org/wiki/Abstract_syntax_tree
[19] параллельного матчинга: https://swtch.com/~rsc/regexp/regexp1.html
[20] regexp: https://golang.org/pkg/regexp/
[21] php-annotations: https://plugins.jetbrains.com/plugin/7320-php-annotations
[22] Phan: https://github.com/phan/phan
[23] Статический анализ PHP-кода на примере PHPStan, Phan и Psalm: https://habr.com/ru/company/badoo/blog/426605/
[24] расскажите нам об этом: https://github.com/VKCOM/noverify/issues/new
[25] welcome: https://github.com/VKCOM/noverify/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22
[26] Статья про phpgrep на Хабре: https://habr.com/ru/post/464893/
[27] AST selectors: https://eslint.org/docs/developer-guide/selectors
[28] тестах NoVerify: https://github.com/VKCOM/noverify/blob/master/src/linttest/rules_test.go
[29] Источник: https://habr.com/ru/post/473718/?utm_source=habrahabr&utm_medium=rss&utm_campaign=473718
Нажмите здесь для печати.