NoVerify: el linter para PHP del equipo VKontakte ahora est√° en el dominio p√ļblico



Te diré cómo logramos escribir un linter que resultó ser lo suficientemente rápido como para verificar los cambios durante cada git push y hacerlo en 5-10 segundos con una base de código de 5 millones de líneas en PHP. Lo llamamos NoVerify.

NoVerify admite cosas b√°sicas, como la transici√≥n a la definici√≥n y la b√ļsqueda de usos, y puede funcionar en modo Servidor de idiomas . En primer lugar, nuestra herramienta se centra en la b√ļsqueda de posibles errores, pero tambi√©n puede verificar el estilo. Hoy, su c√≥digo fuente apareci√≥ en c√≥digo abierto en GitHub. Busque el enlace al final del art√≠culo.

¬ŅPor qu√© necesitamos nuestro linter?


A mediados de 2018, decidimos que era hora de implementar un linter para el código PHP. Había dos objetivos: reducir la cantidad de errores que ven los usuarios y supervisar más estrictamente el cumplimiento del estilo de código. El énfasis principal estaba en la prevención de errores típicos: la presencia de variables no declaradas y no utilizadas en el código, código inalcanzable y otros. También quería que el analizador estático funcionara lo más rápido posible en nuestra base de código (5-6 millones de líneas de código PHP en el momento de la escritura).

Como probablemente sepa, el código fuente de la mayor parte del sitio está escrito en PHP y compilado con KPHP , por lo que sería lógico agregar estas comprobaciones al compilador. Pero, de hecho, no todo el código tiene sentido para ejecutarse a través de KPHP; por ejemplo, el compilador es débilmente compatible con bibliotecas de terceros, por lo que para algunas partes del sitio todavía se usa PHP normal. También son importantes y deben ser verificados por el linter, por lo que, desafortunadamente, no hay forma de integrarlo en KPHP.

Por qué NoVerify


Dada la cantidad de código PHP (le recordaré que esto es de 5 a 6 millones de líneas), no es posible "arreglarlo" de inmediato para que pase nuestros controles en la interfaz. Sin embargo, quiero que el código cambiante se vuelva gradualmente más limpio y siga más estrictamente los estándares de codificación, y también contenga menos errores. Por lo tanto, decidimos que la interfaz debería ser capaz de verificar los cambios que el desarrollador va a lanzar, y no maldecir el resto.

Para hacer esto, el linter necesita indexar todo el proyecto, analizar completamente los archivos antes y después de los cambios, y calcular la diferencia entre las advertencias generadas. Se muestran nuevas advertencias al desarrollador, y requerimos que se corrijan antes de que se pueda realizar la inserción.

Pero hay situaciones en las que este comportamiento no es deseable, y luego los desarrolladores pueden presionar sin enlaces locales, utilizando el git push --no-verify . Opción --no-verify y le dio un nombre a un linter :)

¬ŅCu√°les fueron las alternativas?


La base de código en VK usa poca POO y básicamente consiste en funciones y clases con métodos estáticos. Si las clases en PHP admiten la carga automática, entonces las funciones no. Por lo tanto, no podemos usar analizadores estáticos sin modificaciones significativas, que basan su trabajo en el hecho de que la carga automática cargará todo el código faltante. Tales linters incluyen, por ejemplo, el salmo de Vimeo .

Examinamos las siguientes herramientas de an√°lisis est√°tico:

  • PHPStan : un solo subproceso, requiere carga autom√°tica, el an√°lisis de base de c√≥digo ha alcanzado el 30% en media hora;
  • Phan : incluso en modo r√°pido con 20 procesos, el an√°lisis se detuvo en un 5% despu√©s de 20 minutos;
  • Salmo : requiere carga autom√°tica, el an√°lisis tom√≥ 10 minutos (todav√≠a me gustar√≠a ser mucho m√°s r√°pido);
  • PHPCS : comprueba el estilo, pero no la l√≥gica;
  • phpcf : solo verifica el formato.

Como puede adivinar por el título del artículo, ninguna de estas herramientas satisface nuestros requisitos, por lo que escribimos el nuestro.

¬ŅC√≥mo se cre√≥ el prototipo?


Primero, decidimos construir un peque√Īo prototipo para comprender si vale la pena intentar hacer un linter completo. Dado que uno de los requisitos importantes para el linter es su velocidad, en lugar de PHP, elegimos Go. "R√°pido" es dar retroalimentaci√≥n al desarrollador lo m√°s r√°pido posible, preferiblemente en no m√°s de 10-20 segundos. De lo contrario, el ciclo "corrige el c√≥digo, ejecuta el linter de nuevo" comienza a ralentizar significativamente el desarrollo y estropea el estado de √°nimo de las personas :)

Como Go está seleccionado para el prototipo, necesita un analizador PHP. Hay varios de ellos, pero el proyecto php-parser nos pareció el más maduro. Este analizador no es perfecto y todavía se está desarrollando, pero para nuestros propósitos es bastante adecuado.

Para el prototipo, se decidió intentar implementar una de las inspecciones más simples, a primera vista: acceso a una variable indefinida.

La idea básica para implementar dicha inspección parece simple: para cada rama (por ejemplo, para if), cree un ámbito anidado separado y combine los tipos de variables a la salida de él. Un ejemplo:

 <?php if (rand()) { $a = 42; //  : { $a: int } } else { $b = "test"; $a = "another_test"; //  : { $b: string, $a: string } } //   : { $b: string?, $a: int|string } echo $a, $b; //       , //   $b    

Se ve simple, ¬Ņverdad? En el caso de las declaraciones condicionales ordinarias, todo funciona bien. Pero debemos manejar, por ejemplo, cambiar sin interrupci√≥n;

 <?php switch (rand()) { case 1: $a = 1; // { $a: int } case 2: $b = 2; // { $a: int, $b: int } default: $c = 3; // { $a: int, $b: int, $c: int } } // { $a: int?, $b: int?, $c: int } 

El código no aclara de inmediato que $ c siempre se definirá. Específicamente, este ejemplo es ficticio, pero ilustra bien qué momentos difíciles son para la peluquera (y para la persona en este caso también).

Considere un ejemplo m√°s complejo:

 <?php exec("hostname", $out, $retval); echo $out, $retval; // { $out: ???, $retval: ??? } 

Sin conocer la firma de la función ejecutiva, no se puede decir si se definirán $ out y $ retval. Las firmas de las funciones integradas se pueden tomar del repositorio github.com/JetBrains/phpstorm-stubs . Pero se producirán los mismos problemas al llamar a funciones definidas por el usuario, y su firma solo se puede encontrar indexando todo el proyecto. La función exec toma los argumentos segundo y tercero por referencia, lo que significa que se pueden definir las variables $ out y $ retval. Aquí, acceder a estas variables no es necesariamente un error, y la interfaz no debe jurar ante dicho código.

Problemas similares con el paso implícito de enlaces surgen con los métodos, pero al mismo tiempo, se agrega la necesidad de deducir los tipos de variables:

 <?php if (rand()) { $a = some_func(); } else { $a = other_func(); } $a->some_method($b); echo $b; 

Necesitamos saber qu√© tipos devuelven las funciones some_func () y other_func () para luego encontrar un m√©todo llamado some_method en estas clases. Solo entonces podemos decir si la variable $ b se definir√° o no. La situaci√≥n se complica por el hecho de que a menudo las funciones y m√©todos simples no tienen anotaciones phpdoc, por lo que a√ļn debe poder calcular los tipos de funciones y m√©todos en funci√≥n de su implementaci√≥n.

Al desarrollar el prototipo, tuve que implementar aproximadamente la mitad de toda la funcionalidad para que la inspección más simple funcionara como debería.

Trabaja como un servidor de idiomas


Para facilitar la depuración de la lógica de la interfaz y ver las advertencias que emite, decidimos agregar el modo operativo como un servidor de lenguaje para PHP . En modo de integración con Visual Studio Code, se ve así:



En este modo, es conveniente probar hipótesis y probar casos complejos (después de eso, debe escribir pruebas, por supuesto). También es bueno probar el rendimiento: incluso en archivos grandes, php-parser on Go muestra buena velocidad.

El soporte del servidor de idiomas está lejos de ser ideal, ya que su propósito principal es depurar las reglas de la interfaz. Sin embargo, en este modo hay varias características adicionales:

  1. Consejos para nombres de variables, constantes, funciones, propiedades y métodos.
  2. Resalte los tipos derivados de variables.
  3. Ve a la definición.
  4. B√ļsqueda de usos.

Inferencia de tipo "perezosa"


En el modo de servidor de idiomas, se requiere lo siguiente para trabajar: cambia el código en un archivo y luego, cuando cambia a otro, debe trabajar con información ya actualizada sobre qué tipos se devuelven en funciones o métodos. Imagine que los archivos se editan en el siguiente orden:

 <?php //  A.php,  1 class A { /** @var int */ public $prop; } //  B.php,   class B { public static function something() { $obj = new A; return $obj->prop; } } //  C.php,   $c = B::something(); // $c   int //  A.php,  2 class A { /** @var string <---   string */ public $prop; } //  C.php,   $c = B::something(); // $c   string,   B.php,  C.php   

Dado que no forzamos a los desarrolladores a escribir siempre PHPDoc (especialmente en casos tan simples), necesitamos una forma de almacenar información sobre qué tipo devuelve la función B :: something (). De modo que cuando el archivo A.php cambia, la información de tipo en el archivo C.php está inmediatamente actualizada.

Una posible solución es almacenar "tipos perezosos". Por ejemplo, el tipo de retorno del método B :: something () es en realidad un tipo de expresión (nueva A) -> prop. De esta forma, el linter almacena información sobre el tipo y, gracias a esto, puede almacenar en caché toda la metainformación para cada archivo y actualizarla solo cuando este archivo cambie. Esto debe hacerse con cuidado para que accidentalmente no se filtre información demasiado específica sobre los tipos. También es necesario cambiar la versión de caché cuando cambia la lógica de inferencia de tipo. Sin embargo, tal caché acelera la fase de indexación (que analizaré más adelante) entre 5 y 10 veces en comparación con el análisis repetido de todos los archivos.

Dos fases de trabajo: indexación y análisis.


Como recordamos, incluso para el análisis de código más simple, se requiere información al menos sobre todas las funciones y métodos en el proyecto. Esto significa que no puede analizar solo un archivo por separado del proyecto. Y, sin embargo, que esto no se puede hacer de una sola vez: por ejemplo, PHP le permite acceder a funciones que se declaran más adelante en el archivo.

Debido a estas limitaciones, el funcionamiento de linter consta de dos fases: indexación primaria y posterior análisis de solo los archivos necesarios. Ahora más sobre estas dos fases.

Fase de indexación


En esta fase, se analizan todos los archivos y se realiza un análisis local del código de métodos y funciones, así como el código en el nivel superior (por ejemplo, para determinar los tipos de variables globales). La información sobre las variables globales declaradas, constantes, funciones, clases y sus métodos se recopila y escribe en la memoria caché. Para cada archivo en el proyecto, el caché es un archivo separado en el disco.

Se compila un diccionario global de toda la metainformación sobre el proyecto, que no cambia en el futuro, * a partir de piezas individuales.

* Además del modo de operación como servidor de idiomas, cuando se realiza la indexación y el análisis del archivo modificado para cada edición.

Fase de an√°lisis


En esta fase, podemos usar metainformación (sobre funciones, clases ...) y ya analizar directamente el código. Aquí hay una lista de lo que NoVerify puede verificar por defecto:

  • c√≥digo inalcanzable;
  • acceso a objetos como una matriz;
  • n√ļmero insuficiente de argumentos al llamar a la funci√≥n;
  • llamar a un m√©todo / funci√≥n indefinido;
  • acceso a la propiedad de clase faltante / constante;
  • falta de clase;
  • PHPDoc inv√°lido
  • acceso a una variable indefinida;
  • acceso a una variable que no siempre est√° definida;
  • falta de "descanso"; despu√©s del caso en las construcciones switch / case;
  • error de sintaxis
  • variable no utilizada

La lista es bastante corta, pero puede agregar controles específicos para su proyecto.

Durante el funcionamiento del linter, result√≥ que la inspecci√≥n m√°s √ļtil es solo la √ļltima (variable no utilizada). Esto sucede a menudo cuando refactoriza el c√≥digo (o escribe uno nuevo) y lo sella con el nombre de la variable: este c√≥digo es v√°lido desde el punto de vista de PHP, pero es err√≥neo en l√≥gica.

Velocidad de trabajo


¬ŅCu√°nto tiempo se verifica el cambio que queremos impulsar? Todo depende de la cantidad de archivos. Con NoVerify, el proceso puede tomar hasta un minuto (fue cuando cambi√© 1400 archivos en el repositorio), pero si hubo pocas ediciones, generalmente todas las comprobaciones pasan en 4-5 segundos. Durante este tiempo, el proyecto est√° completamente indexado, analizando nuevos archivos, as√≠ como su an√°lisis. Pudimos crear un linter para PHP, que funciona r√°pidamente incluso con nuestra gran base de c√≥digo.

Cual es el resultado?


Dado que la solución está escrita en Go, es necesario usar el repositorio github.com/JetBrains/phpstorm-stubs para tener definiciones de todas las funciones y clases integradas en PHP. A cambio, obtuvimos una alta velocidad de trabajo (indexando 1 millón de líneas por segundo, análisis de 100 mil líneas por segundo) y pudimos agregar cheques con un linter como uno de los primeros pasos en ganchos de empuje git.

Se desarrolló una base conveniente para crear nuevas inspecciones y se logró un nivel de comprensión del código cercano a PHPStorm. Debido al hecho de que es compatible con el modo con cálculo de diferencias, es posible mejorar gradualmente el código, evitando nuevas construcciones potencialmente problemáticas en el nuevo código.

Contar diff no es lo ideal: por ejemplo, si un archivo grande se dividi√≥ en varios peque√Īos, entonces git y, por lo tanto, NoVerify, no podr√°n determinar si el c√≥digo se movi√≥, y el linter requerir√° corregir todos los problemas encontrados. En este sentido, el c√°lculo de diff evita la refactorizaci√≥n a gran escala, por lo que en tales casos a menudo se desactiva.

Escribir un linter en Go tiene una ventaja más: no solo el analizador AST es más rápido y consume menos memoria que en PHP, sino que el análisis posterior también es muy rápido en comparación con cualquier cosa que se pueda hacer en PHP. Esto significa que nuestro linter puede realizar un análisis más complejo y profundo del código, mientras mantiene un alto rendimiento (por ejemplo, la función "tipos diferidos" requiere una cantidad bastante grande de cálculos en el proceso).

Código abierto


NoVerify disponible en código abierto en GitHub

¬°Disfruta de tu uso en tu proyecto!

UPD: prepar√© una demostraci√≥n que funciona a trav√©s de WebAssembly . La √ļnica limitaci√≥n de esta demostraci√≥n es la falta de definiciones de funciones de phpstorm-stubs, por lo que linter jurar√° sobre las funciones integradas.

Yuri Nasretdinov, desarrollador del departamento de infraestructura de VKontakte

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


All Articles