Como adicionar cheques ao NoVerify sem escrever uma única linha de código Go

Um recurso matador apareceu no analisador estático NoVerify : uma maneira declarativa de descrever inspeções que não requerem programação Go e compilação de código.


Para intrigá-lo, mostrarei uma descrição de uma inspeção simples, mas útil:


/** @warning duplicated sub-expressions inside boolean expression */ $x && $x; 

Essa inspeção localiza todas as expressões lógicas e && onde os operandos esquerdo e direito são idênticos.


NoVerify é um analisador estático para PHP escrito em Go . Você pode ler sobre isso no artigo “ NoVerify: Linter for PHP da equipe VKontakte ”. E nesta revisão, falarei sobre a nova funcionalidade e como chegamos a ela.



Antecedentes


Quando, mesmo para uma nova verificação simples, você precisa escrever algumas dezenas de linhas de código no Go, começa a se perguntar: é possível o contrário?


On Go, escrevemos a inferência de tipo, todo o pipeline do linter, o cache de metadados e muitos outros elementos importantes sem os quais o NoVerify é impossível. Esses componentes são únicos, mas tarefas como "proibir a chamada de uma função X com um conjunto de argumentos Y" não o fazem. Apenas para tarefas simples, o mecanismo de regras dinâmicas foi adicionado.


As regras dinâmicas permitem separar componentes internos complexos da solução de problemas típicos. O arquivo de definição pode ser armazenado e versionado separadamente - ele pode ser editado por pessoas que não estão relacionadas ao desenvolvimento do próprio NoVerify. Cada regra implementa uma inspeção de código (que às vezes chamaremos de verificação).


Sim, se tivermos um idioma para descrever essas regras, você sempre poderá escrever um modelo semanticamente incorreto ou ignorar algumas restrições de tipo - e isso leva a falsos positivos. No entanto, a corrida de dados ou desreferenciamento do ponteiro nil através do idioma das regras não é inserida.


Idioma da descrição do modelo


A linguagem de descrição é sintaticamente compatível com PHP. Isso simplifica seu estudo e também permite editar arquivos de regras usando o mesmo PhpStorm.


No início do arquivo de regras, é recomendável inserir uma diretiva que acalme seu IDE favorito:


 <?php /** *      , *        PHP-. * * @noinspection ALL */ // ...  —   . 

Minha primeira experiência com sintaxe e possíveis filtros para modelos foi o phpgrep . Pode ser útil por si só, mas dentro do NoVerify se tornou ainda mais interessante, porque agora ele tem acesso ao tipo de informação.


Alguns de meus colegas já tentaram o phpgrep em seus trabalhos, e esse foi outro argumento a favor da escolha dessa sintaxe .


O próprio phpgrep é uma adaptação do gogrep para PHP (você também pode estar interessado no cgrep ). Usando este programa, você pode procurar por código através de modelos de sintaxe .


Uma alternativa seria a sintaxe estrutural de pesquisa e substituição (SSR) do PhpStorm. As vantagens são óbvias - esse é um formato existente, mas descobri esse recurso depois de implementar o phpgrep. É claro que você pode fornecer uma explicação técnica: existe uma sintaxe incompatível com o PHP e nosso analisador não a domina, mas esse convincente motivo "real" foi descoberto depois de escrever a bicicleta.


De fato, havia outra opção


Pode ser necessário exibir um modelo com código PHP quase um para um - ou seguir o outro caminho: inventar uma nova linguagem, por exemplo, com a sintaxe das expressões 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)))) 

No final, pensei que a legibilidade dos modelos ainda é importante, e podemos adicionar filtros através dos atributos phpdoc.


clang-query é um exemplo de uma idéia semelhante, mas usa uma sintaxe mais tradicional.




Criamos e executamos nossos próprios diagnósticos!


Vamos tentar implementar nossos novos diagnósticos para o analisador.


Para fazer isso, você precisa instalar o NoVerify. Faça o lançamento binário se você não tiver uma cadeia de ferramentas Go no sistema (se você tiver uma, poderá compilar tudo a partir da fonte).


Se você não instalar o NoVerify, poderá continuar a ler mais, mas finja reproduzir as etapas listadas e admirar o resultado!

Declaração do problema


O PHP tem muitas funções interessantes, uma delas é parse_str . A assinatura dela:


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

Você entenderá o que está errado aqui se observar este exemplo da documentação:


 $str = "first=value&arr[]=foo+bar&arr[]=baz"; parse_str($str); echo $first; // value echo $arr[0]; // foo bar echo $arr[1]; // baz 

Mmm, os parâmetros da string estavam no escopo atual. Para evitar isso, em nosso novo teste, solicitaremos o uso do segundo parâmetro da função, $result , para que o resultado seja gravado nessa matriz.


Crie seu próprio diagnóstico


Crie o arquivo myrules.php :


 <?php /** @warning parse_str without second argument */ parse_str($_); 

O arquivo de regras em geral é uma lista de expressões no nível superior, cada uma das quais é interpretada como um modelo phpgrep. Um comentário especial do phpdoc é esperado para cada modelo. Somente um atributo é necessário - uma categoria de erro com um texto de aviso.


Agora existem quatro níveis no total: error , warning , info e maybe . Os dois primeiros são críticos: o linter retornará um código diferente de zero após a execução se pelo menos uma das regras críticas funcionar. Após o próprio atributo, há um texto de aviso que será emitido pelo linter, caso o modelo seja acionado.


O modelo que escrevemos usa $_ - essa é uma variável de modelo sem nome. Poderíamos chamá-lo, por exemplo, $x , mas como não estamos fazendo nada com essa variável, podemos dar um nome "vazio". A diferença entre variáveis ​​de modelo e variáveis ​​de PHP é que a primeira coincide com absolutamente qualquer expressão, e não apenas com uma variável "literal". Isso é conveniente: geralmente precisamos procurar expressões desconhecidas, em vez de variáveis ​​específicas.


Iniciando um novo diagnóstico


Crie um pequeno arquivo de teste para depuração, test.php :


 <?php function f($x) { parse_str($x); //      } 

Em seguida, execute o NoVerify com as nossas regras neste arquivo:


 $ noverify -rules myrules.php test.php 

Nosso aviso será mais ou menos assim:


 WARNING myrules.php:4: parse_str without second argument at test.php:4 parse_str($x); ^^^^^^^^^^^^^ 

O nome da verificação padrão é o nome do arquivo de regras e a linha que define essa verificação. No nosso caso, este é myrules.php:4 .


Você pode definir seu nome usando o atributo @name <name> .


Exemplo @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); ^^^^^^^^^^^^^ 

As regras nomeadas sucumbem às leis de outros diagnósticos:


  • Pode ser desativado através de -exclude-checks
  • -critical nível de -critical pode ser redefinido via -critical



Trabalhar com tipos


O exemplo anterior é bom para o hello world - mas geralmente precisamos conhecer os tipos de expressões para reduzir o número de operações de diagnóstico


Por exemplo, para a função in_array, solicitamos o argumento $strict=true quando o primeiro argumento ( $needle ) é do tipo string.


Para isso, temos filtros de resultados.


Um desses filtros é @type <type> <var> . Ele permite descartar tudo o que não se encaixa nos tipos enumerados.


 /** * @warning 3rd arg of in_array must be true when comparing strings * @type string $needle */ in_array($needle, $_); 

Aqui, demos o nome do primeiro argumento à chamada in_array para vincular um filtro de tipo a ele. Um aviso será emitido apenas quando o tipo de $needle for string .


Os conjuntos de filtros podem ser combinados com o operador @or :


 /** *     -. * * @warning strings must be compared using '===' operator * @type string $x * @or * @type string $y */ $x == $y; 

No exemplo acima, o padrão corresponderá apenas às expressões == , onde qualquer um dos operandos é do tipo string . Pode-se supor que sem @or todos os filtros sejam combinados por @and , mas isso não precisa ser indicado explicitamente.


Limitar o escopo do diagnóstico


Para cada teste, você pode especificar @scope <name> :


  • @scope all - o valor padrão, a validação funciona em qualquer lugar;
  • @scope root - inicie apenas no nível superior;
  • @scope local - execute apenas dentro de funções e métodos.

Suponha que desejamos relatar return fora do corpo da função. No PHP, isso às vezes faz sentido - por exemplo, quando um arquivo é conectado a partir de uma função ... Mas neste artigo, condenamos isso.


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

Vamos ver como esta regra se comportará:


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

Da mesma forma, você pode fazer uma solicitação para usar *_once vez de require e include :


 /** * @maybe prefer require_once over require * @scope root */ require $_; /** * @maybe prefer include_once over include * @scope root */ include $_; 

Agora, ao combinar padrões, os colchetes não são levados em consideração de maneira bastante consistente. O padrão (($x)) não encontrará “todas as expressões entre colchetes”, mas simplesmente quaisquer expressões, ignorando os colchetes. No entanto, $x+$y*$z e ($x+$y)*$z se comportam como deveriam. Esse recurso vem das dificuldades de trabalhar com tokens ( e ) , mas há uma chance de que o pedido seja restaurado em um dos próximos lançamentos.

Modelos de agrupamento


Quando a duplicação dos comentários do phpdoc aparece nos modelos, a capacidade de combinar modelos chega ao resgate.


Um exemplo simples para demonstrar:


WasTornou-se (com agrupamento)
 / ** @ talvez não use exit ou morra * /
 morrer ($ _);

 / ** @ talvez não use exit ou morra * /
 exit ($ _);
 / ** @ talvez não use exit ou morra * /
 {
   morrer ($ _);
   exit ($ _);
 }

Agora imagine como seria desagradável descrever uma regra no exemplo a seguir sem esse recurso!


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

O formato de gravação especificado no artigo é apenas uma das opções propostas. Se você deseja participar da escolha, tem uma oportunidade: você precisa colocar +1 nas ofertas que você mais gosta do que outras. Para mais detalhes, clique aqui .


Como as regras dinâmicas são integradas



No momento do lançamento, o NoVerify tenta localizar o arquivo de regras especificado no argumento de rules .


Em seguida, esse arquivo é analisado como um script PHP regular e, a partir do AST resultante, um conjunto de objetos de regra com modelos phpgrep vinculados a eles é coletado.


Em seguida, o analisador inicia o trabalho de acordo com o esquema usual - a única diferença é que, para algumas seções verificadas do código, ele inicia um conjunto de regras vinculadas. Se a regra for acionada, um aviso será exibido.


Considera-se sucesso a combinação do modelo phpgrep e a passagem de pelo menos um dos conjuntos de filtros (eles são separados por @or ).


Nesse estágio, o mecanismo de regras não diminui significativamente a operação do linter, mesmo se houver muitas regras dinâmicas.


Algoritmo de Correspondência


Com a abordagem ingênua, para cada nó AST, precisamos aplicar todas as regras dinâmicas. Esta é uma implementação muito ineficiente, porque a maior parte do trabalho será realizada em vão: muitos modelos têm um prefixo específico pelo qual podemos agrupar as regras.


Isso é semelhante à idéia de correspondência paralela , mas, em vez de construir honestamente a NFA, apenas “paralelizamos” a primeira etapa dos cálculos.


Considere isso com um exemplo com três regras:


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

Se temos N elementos e regras M, com uma abordagem ingênua, temos operações N * M para executar. Em teoria, essa complexidade pode ser reduzida para linear e obter O(N) - se você combinar todos os padrões em um e executar a correspondência como ocorre, por exemplo, o pacote regexp do Go.


No entanto, na prática, até agora, concentrei-me na implementação parcial dessa abordagem. Isso permitirá que as regras do arquivo acima sejam divididas em três categorias e para os elementos AST aos quais nenhuma regra corresponde, para atribuir uma quarta categoria vazia. Por esse motivo, não é executada mais de uma regra para cada elemento.


Se tivermos milhares de regras e sentiremos uma desaceleração significativa, o algoritmo será finalizado. Enquanto isso, a simplicidade da solução e a aceleração resultante me agradam.


O tormento da escolha, ou Um pouco sobre o formulário de @type


Tarefa: selecionar boa sintaxe para filtros nas anotações do phpdoc.

A sintaxe atual duplica @var e @var , mas podemos precisar de novos operadores, por exemplo, "o tipo não é igual". Imagine como pode parecer.


Temos pelo menos duas prioridades importantes:


  1. A sintaxe legível e concisa das anotações.
  2. O suporte mais alto possível do IDE sem esforço extra.

Para o PhpStorm, existe um plug - in de anotações php que adiciona preenchimento automático, transição para classes de anotação e outras utilidades para trabalhar com comentários do phpdoc.


Prioridade (2) na prática significa que você toma decisões que não contradizem as expectativas do IDE e dos plugins. Por exemplo, você pode fazer anotações em um formato que o plugin php-annotations possa reconhecer:


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

Em seguida, aplicar um filtro aos tipos seria algo como isto:


 @Type($needle, eq=string) @Type($x, not_eq=Foo) 

Os usuários podem ir para a definição de Filter , eles receberão uma lista de possíveis parâmetros (tipo / texto / etc).


Métodos de gravação alternativos, alguns dos quais foram sugeridos pelos 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 

Então nos distraímos um pouco e esquecemos que estava tudo dentro do phpdoc, e isso apareceu:


 (eq string (typeof $needle)) (neq Foo (typeof $x)) 

Embora a opção com a gravação do postfix por diversão também tenha sido tocada. Uma linguagem para descrever restrições de tipo e valor pode ser chamada de sexta:


 @eval string $needle typeof = @eval Foo $x typeof <> 

A busca pela melhor opção ainda não está concluída ...


Comparação de extensibilidade com Phan


Como uma das vantagens do Phan , o artigo " Análise estática do código PHP usando o exemplo do PHPStan, Phan e Salmo " indica extensibilidade.


Aqui está o que foi implementado no plug-in de amostra:


Queríamos avaliar como nosso código está pronto para o PHP 7.3 (em particular, para descobrir se ele tem constantes que não diferenciam maiúsculas de minúsculas). Estávamos quase certos de que não existiam essas constantes, mas tudo poderia acontecer em 12 anos - deveria ser verificado. E nós escrevemos um plugin para o Phan que juraria se o terceiro parâmetro fosse usado em define ().

É assim que o código do plugin se parece (a formatação é otimizada para largura):


 <?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(); 

E aqui está como isso pode ser feito no NoVerify:


 <?php /** @warning define with 3 arguments */ define($_, $_, $_); 

Queríamos alcançar aproximadamente o mesmo resultado - para que coisas triviais pudessem ser feitas da maneira mais simples possível.


Conclusão


  • Experimente o NoVerify no seu projeto.
  • Se você tiver alguma idéia para melhorias ou relatórios de erros, conte-nos .
  • Se você quiser participar do desenvolvimento, seja bem-vindo !

Links, materiais úteis


Links importantes são coletados aqui, alguns dos quais já podem ter sido mencionados no artigo, mas para maior clareza e conveniência, eu os coletei em um só lugar.



Se você precisar de mais exemplos de regras que podem ser implementadas, dê uma olhada nos testes do NoVerify .

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


All Articles