En el analizador estático NoVerify ha aparecido una característica excelente : una forma declarativa de describir las inspecciones que no requieren programación Go y compilación de código.
Para intrigarte, te mostraré una descripción de una inspección simple pero útil:
$x && $x;
Esta inspección encuentra todas las expresiones lógicas &&
donde los operandos izquierdo y derecho son idénticos.
NoVerify es un analizador estático para PHP escrito en Go . Puede leer sobre esto en el artículo " NoVerify: Linter para PHP del Equipo VKontakte ". Y en esta revisión hablaré sobre la nueva funcionalidad y cómo llegamos a ella.

Antecedentes
Cuando incluso para una nueva verificación simple necesita escribir unas pocas docenas de líneas de código en Go, comienza a preguntarse: ¿es posible lo contrario?
En Go, hemos escrito la inferencia de tipos, toda la canalización de la interfaz, el caché de metadatos y muchos otros elementos importantes sin los cuales NoVerify es imposible. Estos componentes son únicos, pero tareas como "prohibir llamar a una función X con un conjunto de argumentos Y" no lo hacen. Solo para tareas tan simples se ha agregado el mecanismo de las reglas dinámicas.
Las reglas dinámicas le permiten separar elementos internos complejos de la resolución de problemas típicos. El archivo de definición se puede almacenar y versionar por separado; puede ser editado por personas que no están relacionadas con el desarrollo de NoVerify. Cada regla implementa una inspección de código (que a veces llamaremos verificación).
Sí, si tenemos un lenguaje para describir estas reglas, siempre puede escribir una plantilla semánticamente incorrecta o ignorar algunas restricciones de tipo, y esto conduce a falsos positivos. Sin embargo, no se ingresa la carrera de datos o la desreferenciación del puntero nil
través del lenguaje de las reglas.
Lenguaje de descripción de plantilla
El lenguaje de descripción es sintácticamente compatible con PHP. Esto simplifica su estudio y también hace posible editar archivos de reglas usando el mismo PhpStorm.
Al comienzo del archivo de reglas, se recomienda insertar una directiva que alivie su IDE favorito:
<?php
Mi primer experimento con sintaxis y posibles filtros para plantillas fue phpgrep . Puede ser útil por sí solo, pero dentro de NoVerify se ha vuelto aún más interesante, porque ahora tiene acceso a la información de tipo.
Algunos de mis colegas ya han intentado phpgrep en su trabajo, y este fue otro argumento a favor de elegir tal sintaxis .
Phpgrep en sí es una adaptación de gogrep para PHP (también te puede interesar cgrep ). Con este programa, puede buscar código a través de plantillas de sintaxis .
Una alternativa sería la sintaxis estructural de búsqueda y reemplazo (SSR) de PhpStorm. Las ventajas son obvias: este es un formato existente, pero descubrí esta característica después de implementar phpgrep. Por supuesto, puede proporcionar una explicación técnica: hay una sintaxis incompatible con PHP y nuestro analizador no lo dominará, pero esta razón convincente "real" se descubrió después de escribir la bicicleta.
De hecho, había otra opción.
Podría ser necesario mostrar una plantilla con código PHP casi uno a uno, o ir a la inversa: inventar un nuevo lenguaje, por ejemplo, con sintaxis de expresión 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))))
Al final, pensé que la legibilidad de las plantillas sigue siendo importante, y podemos agregar filtros a través de los atributos phpdoc.
clang-query es un ejemplo de una idea similar, pero utiliza una sintaxis más tradicional.
¡Creamos y ejecutamos nuestros propios diagnósticos!
Intentemos implementar nuestros nuevos diagnósticos para el analizador.
Para hacer esto, necesita instalar NoVerify. Tome la versión binaria si no tiene una cadena de herramientas Go en el sistema (si tiene una, puede compilar todo desde la fuente).
Declaración del problema.
PHP tiene muchas funciones interesantes, una de ellas es parse_str . Su firma:
Comprenderá lo que está mal aquí si mira este ejemplo de la documentación:
$str = "first=value&arr[]=foo+bar&arr[]=baz"; parse_str($str); echo $first;
Mmm, los parámetros de la cadena estaban en el alcance actual. Para evitar esto, necesitaremos en nuestra nueva prueba usar el segundo parámetro de la función, $result
, para que el resultado se escriba en esta matriz.
Crea tus propios diagnósticos
Cree el archivo myrules.php
:
<?php parse_str($_);
El archivo de reglas en general es una lista de expresiones en el nivel superior, cada una de las cuales se interpreta como una plantilla phpgrep. Se espera un comentario phpdoc especial para cada plantilla. Solo se requiere un atributo: una categoría de error con un texto de advertencia.
Ahora hay cuatro niveles en total: error
, warning
, info
y maybe
. Los dos primeros son críticos: el linter devolverá un código distinto de cero después de la ejecución si al menos una de las reglas críticas funciona. Después del atributo en sí, el linter emitirá un texto de advertencia en caso de que se active la plantilla.
La plantilla que escribimos usa $_
- esta es una variable de plantilla sin nombre. Podríamos llamarlo, por ejemplo, $x
, pero como no estamos haciendo nada con esta variable, podemos darle un nombre "vacío". La diferencia entre las variables de plantilla y las variables de PHP es que las primeras coinciden con absolutamente cualquier expresión, y no solo con una variable "literal". Esto es conveniente: a menudo necesitamos buscar expresiones desconocidas, en lugar de variables específicas.
Comenzando un nuevo diagnóstico
Cree un pequeño archivo de prueba para depurar, test.php
:
<?php function f($x) { parse_str($x);
A continuación, ejecute NoVerify con nuestras reglas en este archivo:
$ noverify -rules myrules.php test.php
Nuestra advertencia se verá así:
WARNING myrules.php:4: parse_str without second argument at test.php:4 parse_str($x); ^^^^^^^^^^^^^
El nombre de la verificación predeterminada es el nombre del archivo de reglas y la línea que define esta verificación. En nuestro caso, esto es myrules.php:4
.
Puede establecer su nombre utilizando el @name <name>
.
@Name ejemplo
parse_str($_);
WARNING parseStrResult: parse_str without second argument at test.php:4 parse_str($x); ^^^^^^^^^^^^^
Las reglas nombradas sucumben a las leyes de otros diagnósticos:
- Se puede deshabilitar mediante
-exclude-checks
-critical
nivel de -critical
se puede redefinir a través de -critical
Trabajar con tipos
El ejemplo anterior es bueno para hello world, pero a menudo necesitamos conocer los tipos de expresiones para reducir el número de operaciones de diagnóstico.
Por ejemplo, para la función in_array, pedimos el argumento $strict=true
cuando el primer argumento ( $needle
) es de tipo cadena.
Para esto tenemos filtros de resultados.
Uno de estos filtros es @type <type> <var>
. Le permite descartar todo lo que no se ajusta a los tipos enumerados.
in_array($needle, $_);
Aquí le dimos el nombre del primer argumento a la llamada in_array
para vincularle un filtro de tipo. Se emitirá una advertencia solo cuando el tipo de $needle
sea string
.
Los conjuntos de filtros se pueden combinar con el operador @or
:
$x == $y;
En el ejemplo anterior, el patrón solo coincidirá con las expresiones ==
, donde cualquiera de los operandos es de tipo string
. Se puede suponer que sin @or
todos los filtros se combinan a través de @and
, pero esto no necesita indicarse explícitamente.
Limitar el alcance del diagnóstico.
Para cada prueba, puede especificar @scope <name>
:
@scope all
: el valor predeterminado, la validación funciona en todas partes;@scope root
: inicie solo en el nivel superior;@scope local
: ejecuta solo funciones y métodos internos.
Supongamos que queremos informar el return
fuera del cuerpo de la función. En PHP, esto a veces tiene sentido, por ejemplo, cuando un archivo está conectado desde una función ... Pero en este artículo condenamos esto.
return $_;
Veamos cómo se comportará esta regla:
<?php function f() { return "OK"; } return "NOT OK";
Del mismo modo, puede realizar una solicitud para usar *_once
lugar de require
e include
:
require $_; include $_;
Ahora, cuando se combinan patrones, los corchetes no se tienen en cuenta de manera bastante consistente. El patrón (($x))
no encontrará "todas las expresiones entre corchetes dobles", sino simplemente cualquier expresión, ignorando los corchetes. Sin embargo, $x+$y*$z
y ($x+$y)*$z
comportan como deberían. Esta característica proviene de las dificultades de trabajar con tokens (
y )
, pero existe la posibilidad de que el orden se restablezca en una de las próximas versiones.
Plantillas de agrupamiento
Cuando la duplicación de comentarios phpdoc aparece en las plantillas, la capacidad de combinar plantillas viene al rescate.
Un ejemplo simple para demostrar:
¡Ahora imagine lo desagradable que sería describir una regla en el siguiente ejemplo sin esta característica!
{ $x > $y; $x < $y; $x >= $y; $x <= $y; $x == $y; }
El formato de grabación especificado en el artículo es solo una de las opciones propuestas. Si desea participar en la elección, entonces tiene esa oportunidad: necesita poner +1 en las ofertas que más le gusten que otras. Para más detalles, haga clic aquí .
Cómo se integran las reglas dinámicas

En el momento del lanzamiento, NoVerify intenta encontrar el archivo de reglas que se especifica en el argumento de rules
.
A continuación, este archivo se analiza como un script PHP normal y, a partir del AST resultante, se recopila un conjunto de objetos de regla con plantillas phpgrep vinculadas a ellos.
Luego, el analizador comienza el trabajo de acuerdo con el esquema habitual: la única diferencia es que, para algunas secciones de código verificadas, inicia un conjunto de reglas vinculadas. Si se activa la regla, se muestra una advertencia.
Se considera que el éxito es la coincidencia de la plantilla phpgrep y el paso de al menos uno de los conjuntos de filtros (están separados por @or
).
En esta etapa, el mecanismo de reglas no ralentiza significativamente el funcionamiento de la interfaz, incluso si hay muchas reglas dinámicas.
Algoritmo a juego
Con el enfoque ingenuo, para cada nodo AST, necesitamos aplicar todas las reglas dinámicas. Esta es una implementación muy ineficiente, porque la mayor parte del trabajo se hará en vano: muchas plantillas tienen un prefijo específico por el cual podemos agrupar las reglas.
Esto es similar a la idea de la coincidencia paralela , pero en lugar de construir honestamente el NFA, solo "paralelizamos" el primer paso de los cálculos.
Considere esto con un ejemplo con tres reglas:
$_ ? $x : $x; explode("", ${"*"}); if ($_);
Si tenemos N elementos y M reglas, con un enfoque ingenuo tenemos N * M operaciones que realizar. En teoría, esta complejidad se puede reducir a lineal y obtener O(N)
, si combina todos los patrones en uno y realiza la coincidencia como lo hace, por ejemplo, el paquete regexp de Go.
Sin embargo, en la práctica, hasta ahora me he centrado en la implementación parcial de este enfoque. Permitirá que las reglas del archivo anterior se dividan en tres categorías, y para aquellos elementos AST a los que no corresponde ninguna regla, para asignar una cuarta categoría vacía. Debido a esto, no se ejecuta más de una regla por elemento.
Si tenemos miles de reglas y sentiremos una desaceleración significativa, el algoritmo se finalizará. Mientras tanto, la simplicidad de la solución y la aceleración resultante me convienen.
La sintaxis actual duplica @var
y @var
, pero es posible que necesitemos nuevos operadores, por ejemplo, "tipo no es igual". Imagina cómo se vería.
Tenemos al menos dos prioridades importantes:
- La sintaxis legible y concisa de las anotaciones.
- El mayor apoyo posible del IDE sin esfuerzo adicional.
Hay un complemento php-annotations para PhpStorm, que agrega autocompletado, transición a clases de anotación y otra utilidad para trabajar con comentarios phpdoc.
La prioridad (2) en la práctica significa que usted toma decisiones que no contradicen las expectativas del IDE y los complementos. Por ejemplo, puede hacer anotaciones en un formato que el complemento php-annotations pueda reconocer:
class Filter { public $value; public $type; public $text; }
Luego, aplicar un filtro a los tipos se vería así:
@Type($needle, eq=string) @Type($x, not_eq=Foo)
Los usuarios pueden ir a la definición de Filter
, se les solicitará una lista de posibles parámetros (tipo / texto / etc.).
Métodos de grabación alternativos, algunos de los cuales fueron sugeridos por colegas:
@type string $needle @type !Foo $x @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
Luego nos distrajimos un poco y olvidamos que todo estaba dentro de phpdoc, y esto apareció:
(eq string (typeof $needle)) (neq Foo (typeof $x))
Aunque la opción con grabación de postfix por diversión también sonó. Un lenguaje para describir las restricciones de tipo y valor podría llamarse sexto:
@eval string $needle typeof = @eval Foo $x typeof <>
La búsqueda de la mejor opción aún no ha terminado ...
Comparación de extensibilidad con Phan
Como una de las ventajas de Phan , el artículo " Análisis estático de código PHP usando el ejemplo de PHPStan, Phan y Psalm " indica extensibilidad.
Esto es lo que se implementó en el complemento de muestra:
Queríamos evaluar qué tan listo está nuestro código para PHP 7.3 (en particular, para averiguar si tiene constantes que no distinguen entre mayúsculas y minúsculas). Estábamos casi seguros de que no había tales constantes, pero cualquier cosa podría suceder en 12 años, debería verificarse. Y escribimos un complemento para Phan que juraría si el tercer parámetro se usara en define ().
Así es como se ve el código del complemento (el formato está optimizado para el ancho):
<?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();
Y así es como se podría hacer esto en NoVerify:
<?php define($_, $_, $_);
Queríamos lograr aproximadamente el mismo resultado, para que las cosas triviales se pudieran hacer de la manera más simple posible.
Conclusión
Enlaces, materiales útiles
Aquí se recopilan enlaces importantes, algunos de los cuales pueden haber sido mencionados en el artículo, pero para mayor claridad y conveniencia, los reuní en un solo lugar.
Si necesita más ejemplos de reglas que puedan implementarse, puede echar un vistazo a las pruebas de NoVerify .