Cómo agregar cheques a NoVerify sin escribir una sola línea de código Go

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:


/** @warning duplicated sub-expressions inside boolean expression */ $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 /** *      , *        PHP-. * * @noinspection ALL */ // ...  —   . 

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).


Si no instala NoVerify, puede continuar leyendo más, ¡pero simule reproducir los pasos enumerados y admirar el resultado!

Declaración del problema.


PHP tiene muchas funciones interesantes, una de ellas es parse_str . Su firma:


 //   encoded_string,     //   URL,      //   (  ,    result). parse_str ( string $encoded_string [, array &$result ] ) : void 

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; // value echo $arr[0]; // foo bar echo $arr[1]; // baz 

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 /** @warning parse_str without second argument */ 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


 /** * @name parseStrResult * @warning parse_str without second argument */ 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.


 /** * @warning 3rd arg of in_array must be true when comparing strings * @type string $needle */ 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 :


 /** *     -. * * @warning strings must be compared using '===' operator * @type string $x * @or * @type string $y */ $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.


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

Veamos cómo se comportará esta regla:


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

Del mismo modo, puede realizar una solicitud para usar *_once lugar de require e include :


 /** * @maybe prefer require_once over require * @scope root */ require $_; /** * @maybe prefer include_once over include * @scope root */ 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:


EraSe convirtió (con agrupación)
 / ** @ tal vez no uses exit o die * /
 morir ($ _);

 / ** @ tal vez no uses exit o die * /
 salir ($ _);
 / ** @ tal vez no uses exit o die * /
 {
   morir ($ _);
   salir ($ _);
 }

¡Ahora imagine lo desagradable que sería describir una regla en el siguiente ejemplo sin esta característica!


 /** * @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; } 

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:


 /** @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 ($_); 

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.


El tormento de elección, o Un poco sobre el @type


Tarea: seleccionar una buena sintaxis para los filtros dentro de las anotaciones phpdoc.

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:


  1. La sintaxis legible y concisa de las anotaciones.
  2. 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:


 /** * 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; } 

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 /** @warning define with 3 arguments */ 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 .

Source: https://habr.com/ru/post/473718/


All Articles