NoVerify: um linter PHP que funciona rápido

Existem bons utilitários de análise estática para PHP: PHPStan, Salmo, Phan, Exakat. Os linters fazem seu trabalho bem, mas muito lentamente, porque quase todos são escritos em PHP (ou Java). Para uso pessoal ou um projeto pequeno, isso é normal, mas para um site com milhões de usuários, esse é um fator crítico. Uminterinter lento diminui a velocidade do pipeline do IC e torna impossível usá-lo como uma solução que pode ser integrada a um editor de texto ou IDE.



Um site com milhões de usuários é o VKontakte. Desenvolvimento e adição de novas funções, teste e correção de bugs, revisões - tudo isso deve ocorrer rapidamente, nas condições de prazos rígidos. Portanto, um linter bom e rápido que pode verificar a base de códigos em 5 milhões de linhas em 5 a 10 segundos é algo insubstituível. 

Não existem linters adequados no mercado, por isso Yuri Nasretdinov (você é demais) de VKontakte escreveu sua ajuda para equipes de desenvolvimento - NoVerify. Este é um ponteiro para PHP, escrito em Go. Ele funciona de 10 a 30 vezes mais rápido que os análogos, pode encontrar algo que o PhpStorm não avisa, se estende e se integra facilmente a projetos nos quais eles nunca ouviram falar de análise estática. Iskander Sharipov

contará sobre esse linter . Sob o gato: como escolher um linter e preferir escrever o seu, por que o NoVerify é tão rápido e como é organizado por dentro, por que está escrito em Go, o que pode encontrar e como se expande, quais compromissos você teve que fazer para ele e o que pode ser construído com base nele.


Iskander Sharipov (quasilyte) trabalha na infraestrutura de back-end do VKontakte e conhece bem o NoVerify. No passado, ele estava envolvido no compilador Go da equipe da Intel. Ele não escreve em PHP, mas esta é sua linguagem favorita para análise estática - há muitas coisas que podem dar errado.

Nota. Para entender os antecedentes, leia o artigo de Yuri Nasretdinov, autor do NoVerify on Habré, com um histórico e uma comparação com alguns linters existentes, geralmente escritos em PHP. Todas as declarações na direção do PHP (no artigo de Yuri e aqui) são uma piada. Iskander adora PHP, todo mundo adora PHP.

Desenvolvimento de Produto


No VKontakte, este é um desenvolvimento de site no KPHP. A velocidade é importante para o VKontakte: corrigindo bugs, adicionando e desenvolvendo novas funções desde a primeira fase até a última. Mas a velocidade é acompanhada de erros , especialmente quando há prazos rígidos - estamos com pressa, nervosos e cometemos mais erros do que em uma situação calma.

Erros afetam os usuários . Não queremos que eles sofram, por isso controlamos a qualidade. Mas o controle de qualidade atrasa o desenvolvimento . Isso também não queremos, portanto o efeito deve ser minimizado.

Para fazer isso, poderíamos realizar mais análises de código sem falhas, contratar mais testadorese escreva mais testes. Mas tudo isso é mal automatizado: a revisão deve ser feita e os testes devem ser escritos.

As principais tarefas da minha equipe são diferentes.

Colete métricas, analise e repare rapidamente . Se algo der errado, queremos revertê-lo rapidamente, entender o que está errado, corrigi-lo e adicionar rapidamente o código de trabalho à produção.

Monitore o rigor do pipeline para que o código não operacional não entre em produção - você não precisa revertê-lo. Aqui os linters vêm para o resgate - analisadores de código estático. Nós vamos falar sobre isso.

Escolha um linter


Vamos escolher um linter que adicionamos ao pipeline. Adotamos uma abordagem simples - formulamos os requisitos.

Linter deve trabalhar rápido . Existem várias etapas em nosso pipeline: a operação do linter não deve levar muito tempo e um desenvolvedor demorado, enquanto ele aguarda feedback.

Suporte para "seus" cheques . Provavelmente, o linter não tem tudo o que precisamos - teremos que adicionar nossos próprios cheques. Eles devem encontrar problemas típicos da nossa base de código, verificar o código do ponto de vista do nosso projeto. Nem tudo isso pode ser (ou convenientemente) coberto por testes.

Suporte para verificações "próprias". Podemos escrever muitos testes, mas eles serão bem suportados? Por exemplo, se escrevermos em expressões regulares, elas se tornarão mais complicadas quando você precisar levar em consideração o contexto, a semântica e a sintaxe da linguagem. Portanto, os testes não são uma opção.

A maioria dos linters que analisamos são escritos em PHP. Mas eles não transmitem a demanda. Linters em PHP (ainda não há compilação da AOT) funcionam 10 a 20 vezes mais devagar que em outros idiomas - nosso maior arquivo pode ser analisado por dezenas de segundos. Isso diminui muito e muito o fluxo de trabalho - essa é uma falha fatal . O que os desenvolvedores fazem neste caso? Eles escrevem os seus próprios.

Portanto, escrevemos nosso linter NoVerify PHP on Go. Por quê? Spoiler: não apenas porque Jura decidiu.

Noverify


O Go é um bom compromisso entre velocidade de desenvolvimento e produtividade.
A primeira "prova" na imagem com "infográficos": boa velocidade de execução, suporte fácil. Perdemos na velocidade do desenvolvimento, mas os dois primeiros pontos são mais importantes para nós.


As figuras são tiradas da cabeça, não são apoiadas por nada.

Para a segunda "evidência", ele argumentou de maneira mais simples.


PHP é mais lento, Go é mais rápido e assim por diante. 

Escolhemos Go por três razões.

Ir como um idioma para utilitários é fácil de aprender em um nível básico . Na equipe de desenvolvimento do PHP, com certeza, alguém ouviu falar sobre o Go, olhou para o Docker, sabe que está escrito no Go, talvez até tenha visto a fonte. Com um entendimento básico, após uma ou duas semanas de intenso aprendizado do Go, eles poderão escrever código nele.

Go é bastante eficaz . Mesmo um iniciante não será capaz de cometer muitos erros, porque o Go possui boa sintonia e muita interferência. No Go, o código médio é um pouco melhor do que em outros idiomas, porque há muito menos maneiras de fotografar sua própria perna.

Os aplicativos Go são fáceis de manter.Go é uma linguagem de programação bastante madura para a qual quase todas as ferramentas de desenvolvedor que você pode desejar estão disponíveis.

Verificaremos o NoVerify com nossos requisitos.

  • O NoVerify é várias vezes mais rápido que as alternativas .

  • Para isso, você pode escrever extensões , tanto de código aberto quanto as suas. É importante que possamos separar essas verificações e você pode escrever suas próprias.
  • Fácil de testar e desenvolver. Em parte, porque a distribuição Go padrão tem uma estrutura padrão com criação de perfil e teste. É usado principalmente para testes de unidade. Particularmente hábil pode ser usado para integração, como fazemos - temos testes de integração escritos através de testes Go.

Compromisso de integração


Vamos começar com o problema. Quando você inicia qualquer linter pela primeira vez em um projeto antigo que não usou nenhuma análise, provavelmente verá isso.



Oh meu código! Ninguém jamais corrigirá tantos erros. Quero fechar o projeto, remover o linter e nunca executá-lo novamente. O que fazer para evitar isso?

Integrar


Execute no modo diff . Não queremos executar todas as verificações em todo o projeto com um milhão de erros a cada etapa do IC. Talvez você conheça a linha de base: no NoVerify, isso está pronto para o uso, você não precisa incorporar um utilitário separado. Consideramos imediatamente que esse regime era necessário.

Adicione legado (fornecedores) às exceções . É mais fácil não tocar em algumas coisas, deixar de lado, mesmo com um defeito, para não modificá-lo e não deixar uma marca na história.

Começamos com um subconjunto de verificações . Você não pode conectar tudo o que inclui estilo. Para começar, ele encontra bugs reais: encontraremos, corrigiremos e mudaremos para algo novo.

Coletamos feedback de colegas. Como entender quando é hora de ativar outra coisa? Pergunte aos colegas. Assim que ficarem satisfeitos com o desaparecimento dos erros e quase nada for encontrado, ative outra coisa - é hora de trabalhar.

Configuração do Git


Modo Diff significa que você tem um sistema de controle de versão - Git. Se você possui SVN, a instrução não ajuda, vá para Git.

Instalamos o gancho pré-empurre com um linter e verificamos antes de iniciar o código. Verificamos na máquina local com uma opção --no-verifypara ignorar o linter . Provavelmente seria mais conveniente usar um gancho de pré-recebimento e desativar o linter do lado do servidor, mas, por razões históricas, muitas coisas acontecem no VK em um gancho de pré-envio, portanto o NoVerify também foi construído lá.

Após o envio, as verificações de IC são iniciadas. O NoVerify possui dois modos de operação: com análise completa e sem ela. No IC, você provavelmente deseja (e pode) executar--git-full-diff- as máquinas no IC podem ser carregadas com mais força e verificam até os arquivos que não foram alterados. Em máquinas locais, podemos executar uma análise menos rigorosa, mas mais rápida, apenas dos arquivos alterados (5-15 segundos mais rápido). 

Falso-positivo




Considere o exemplo abaixo: nesta função, algo que contém um campo é aceito, mas o tipo não é descrito de forma alguma. Não é fato que exista um campo quando uma função é chamada de diferentes contextos. Em uma versão estrita, o linter poderia reclamar: "Não está claro de que tipo é, como posso retornar um campo sem verificações?" Mas isso não é necessariamente um erro.

function get_foo($obj) {
    return $obj->foo;
    ^^^
}

Warning:
Property "foo" does not exist

Os falsos positivos interferem. 
Esta é a principal razão para abandonar o linter. As pessoas escolhem outras opções que encontram menos erros, mas produzem menos falsos positivos.

Eles costumam insistir quando algo funcionou, mas isso não é um erro. Muitos têm um mecanismo para ignorar o linter - para executar com a bandeira sem verificar o linter. Em nosso país, essa bandeira foi chamada no-verifyde gancho pré-push. Nós o usamos frequentemente e o nome foi imortalizado no nome do linter.

Pickiness


Outra propriedade do linter. Por exemplo, muitos não entendem aliases. No PHP, sizeofisso é um análogo count: não calcula o tamanho, mas retorna o número de elementos. O modelo mental dos desenvolvedores de C tem um sizeofsignificado diferente. Se na base de código houver sizeof, provavelmente, média count. Mas isso é nitpicking.

$len = sizeof($x);
    ^^^^^^

Warning:
use "count" instead of "sizeof"

O que fazer com isso?


Seja rigoroso e force a governar tudo, sem exceção . Impor regras, exigir, observar e não permitir que elas contornem - nunca funciona. Para que essa rigidez funcione, a equipe deve ser composta pelas mesmas pessoas: caráter, nível de cultura, pedantismo e percepção da qualidade do código. Se não for assim, haverá um tumulto. É mais fácil montar uma equipe com seus clones do que forçar a seguir todas as regras. 

Não bloqueie push / comprometer em comentários como a fixação sizeofem count. Provavelmente, isso não é um erro, mas uma escolha de nit e não afeta o código. Porém, 99% das respostas serão ignoradas (pela equipe) e sempre haverá outras extras no código sizeof.

Permita algum nível de configuração para diferentes equipes e desenvolvedores.Você pode configurar a configuração para cada comando para que aqueles que não querem a mudança sizeofpara countnão posso fazer isso. Deixe todo mundo seguir as regras. Uma boa opção, mas a consistência diminuirá e, em alguns diretórios, o código será um pouco pior.

Execute essas verificações uma vez por mês, nos subbotniks . As verificações podem ser executadas não sempre no CI ou no gancho de pré-push, mas na rotina Cron uma vez por mês. Execute e edite tudo o que encontrar após o desenvolvimento ativo. Mas este trabalho requer recursos para automação e verificação.

Fazer nada. Desativar verificações estilísticas também é uma opção.

Compromisso




Sempre haverá um compromisso entre um desenvolvedor feliz e um linter feliz. É fácil fazer um linter feliz: o modo mais rigoroso e a falta de soluções alternativas. Talvez depois disso ninguém permaneça na equipe; portanto, se o interferência interfere no trabalho, isso é um problema.
Ação útil acima de tudo.

Detalhes técnicos NoVerify


Cheques particulares VKontakte. O Noverify está escrito assim. No GitHub, o repositório NoVerify é dividido em duas partes: a estrutura usada para implementar o linter e separar verificações, vklints . Isso é feito para que o linter carregue verificações de terceiros: você pode escrever um módulo separado no Go e eles se registram na estrutura. Após iniciar a partir do binário NoVerify, a estrutura carrega todos os conjuntos de verificações registrados e eles funcionam como um todo. 



NoVerify é uma biblioteca e um binário (linter).

Nossos cheques são chamados vklints . Eles acham que não vêem o PhpStorm e o Open Source NoVerify - erros importantes que não são adequados para uso geral.

O que é vklints?

Verificar as especificidades do uso de determinadas funções , classes e até variáveis ​​globais que não seguem nossas convenções. Isso é algo que não pode ser usado em locais especiais por várias razões descritas no guia de estilo.

Verificações de estilo adicionais. Eles não correspondem ao que é aceito na comunidade PHP, não são descritos na Recomendação Padrão do PHP nem a contradizem, mas para nós é o padrão. Não faz sentido adicioná-los ao código aberto porque você não deseja segui-los.

Requisitos de comparação estritos para alguns tipos . Por exemplo, temos uma verificação que exige a comparação de strings com um operador de comparação ===. Em particular, é necessário passar um sinalizador para comparação estrita de funções para comparar seqüências de caracteres.

Chaves de matriz suspeitas.Outro erro interessante: às vezes, quando os desenvolvedores cometem, eles podem pressionar as combinações de teclas antes de salvar o arquivo. Às vezes, esses caracteres permanecem em uma sequência ou em um pedaço de código. Uma vez, na chave da matriz estava a letra russa "Y". Provavelmente, o desenvolvedor pressionou CTRL-S no layout russo, salvou o arquivo e confirmou. Às vezes, encontramos essas chaves em matrizes, mas novos erros não passam mais.

Regras dinâmicas são um mecanismo de extensão NoVerify mais simples, descrito em PHP. Um artigo separado foi escrito sobre isso: como adicionar cheques ao NoVerify sem escrever uma única linha de código Go .

Como funciona o NoVerify


Para analisar o PHP, você precisa de um analisador . Não podemos usar o analisador de PHP no PHP: é lento, do Go só pode ser usado através de um wrapper em C. Portanto, usamos o analisador no Go.

Este analisador tem vários problemas. Infelizmente, ele só pode trabalhar com UTF-8 e precisamos distinguir entre UTF-8 e não UTF-8 . Além do UTF-8, o Windows-1251 é frequentemente encontrado em projetos PHP russos. Nós também temos esses arquivos. Como os reconhecemos? 

O arquivo encodings.xmllista todos os caminhos onde estão os arquivos com UTF-8. Se encontrarmos um arquivo fora desses caminhos, em tempo real, transmitiremos para UTF-8 por streaming stream (sem converter antecipadamente).


Análise e análise


Concluído em algumas etapas. No primeiro - carregamos metadados do phpstorm-stubs . São dados que se parecem com o código PHP, mas nunca são executados e descrevem os tipos de entradas / saídas de funções padrão. Os metadados do phpStorm têm uma diretiva de substituição útil para o linter ... Ele nos permite descrever, por exemplo, que aceitamos uma matriz de tipos T[]e retornamos o tipo (útil para funções array_pop).


O phpstorm-stubs é carregado primeiro. Usamos metadados como as informações iniciais do tipo - a base. Essa base é absorvida pelo linter e começamos a analisar as fontes.

Carregamos o mestre atual antes da absorção. Verificamos o código em dois modos:

  • mudanças locais : em relação à linha de base, encontramos novos erros no código;
  • indicamos a gama de revisões : a primeira e a última revisão, e entre elas tudo é inclusivo - esse é o novo código e tudo o que “antes” é antigo.

Em seguida, vem a etapa de análise.



Análise AST . Agora temos metadados, digite informações. Pegamos todo o código-fonte do PHP, analisamos e analisamos diretamente acima do AST - não temos uma representação intermediária no momento. A análise de um AST bruto não é muito conveniente, especialmente se você depende das bibliotecas e tipos de dados que ele representa. 



Os resultados da análise são armazenados no cache . É usado na reanálise, que é muito mais rápida.

Relatórios e filtragem . Em seguida, geramos relatórios ou avisos duas vezes : primeiro encontramos avisos para a versão antiga do código (antes da linha de base) e depois para a nova. Os relatórios são filtrados por comparação (diff) - procuramos avisos que apareceram na nova versão do código e os passamos para o usuário. Em alguns analisadores estáticos, isso é chamado de "modo de linha de base".



A análise de código duplo (no modo diff) é muito lenta. Mas podemos pagar - o NoVerify ainda é dezenas de vezes mais rápido que outros vinculadores PHP. Ao mesmo tempo, possui uma reserva para aceleração adicional, pelo menos em 30%.

Como analisamos arquivos? No PHP, você pode chamar uma função antes que ela seja definida - você precisa conhecer as informações sobre essa função antes de analisá-la. Portanto, primeiro examinamos o arquivo inteiro no AST, indexamos, identificamos os tipos de todas as funções, registramos as classes e só depois as analisamos. 



A análise é a segunda passagem pelo arquivo . A maioria dos intérpretes e compiladores também trabalha com duas passagens e mais. Para não "digitalizar" o arquivo uma segunda vez, você deve ter declarações antes de usar, como em C, por exemplo.

Inferência de tipo


A parte mais interessante é que os erros são mais frequentemente encontrados aqui. Ainda não corresponde à correção do sistema de tipos PHP, que é difícil de definir formalmente.

Como é o modelo.


Modelo semântico (demo).

Tipos de tipos:

  • Esperado é o que descrevemos nos comentários. Esperamos alguns tipos no programa, mas isso não significa que eles sejam realmente usados ​​nele.
  • Real - reais que estão no programa. Por exemplo, se atribuirmos um número a alguma coisa, é óbvio que intou float(se esse for um número de ponto flutuante) serão tipos reais. 

Os tipos reais parecem ser "mais fortes" - são reais, verdadeiros. Mas, às vezes, podemos obter um tipo apenas por anotação.

As anotações (nos tipos esperados) podem ser divididas em duas categorias: confiança e desconfiança . Por exemplo, o phpstorm-stubs pertence à primeira categoria. Eles são considerados moderados (sem erros) antes de usá-los. Os não confiáveis ​​são aqueles que outros desenvolvedores escrevem, porque podem ter erros.

Os tipos reais também podem ser divididos em várias partes: valores, asserção, predicados e dica de tipo, o que estende os recursos do PHP 7. Mas há um problema que a dica de tipo não resolve.

Esperado x Real


Digamos que uma classe Footenha um herdeiro. Da classe descendente, podemos chamar métodos que não estão no Foo, porque o descendente estende o pai. Mas se conseguirmos o herdeiro Foode new static()com essa anotação do tipo de retorno (de self), em seguida, um problema irá surgir. Podemos chamar esse método, mas o IDE não solicitará - você precisa especificar static(). Esta é uma ligação estática tardia no PHP , quando o Fooherdeiro da classe não pode retornar

class Foo {
    /** @return static */
    public function newStatic() : self {
        return new static();
    }
}
// actual = Foo
// expected = static

Quando escrevemos new static(), não apenas a turma pode retornar new Foo. Por exemplo, se uma Fooclasse é herdada de bar, então pode haver new bar. Portanto, precisamos de pelo menos duas informações de tipo. Nenhum deles é redundante - ambos são necessários.

Portanto, o tipo Real aqui será self- para o intérprete PHP. Mas para o IDE e para os linters funcionarem, precisamos static. Se chamarmos esse código do contexto da classe herdeiro, precisaremos saber as informações de que essa não é a mesma classe base e que ela possui mais métodos.

class Foo {
    /** @return static */
    public function newStatic() : self {
        return new static();
    }
}
// actual -  PHP 
// expected -   IDE/

Digite dica


Digitação estática e dica de tipo não são a mesma coisa.
Você deve ter ouvido falar que só pode verificar os limites das funções. Nas fronteiras, verificamos as entradas e saídas, onde a entrada é o argumento da função. Dentro da função, você pode fazer qualquer bobagem: atribua um foovalor int, embora tenha descrito o que é T. Você pode reclamar que está violando os tipos que declarou, mas para o PHP não há erro

declare(strict_types=1);
    function f(T $foo) {
        $foo = 10; //  int
        return $foo;
}

Um exemplo é mais difícil - voltamos foo? No início da função, determinamos que fooera Te não há informações sobre o retorno. 

declare(strict_types=1);
function f(T $foo) {
    $foo = 10; //  int
    return $foo;
}
// ? 1. f -> int
// ? 2. f -> T|int
// ? 3. f -> T

Que tipo está correto? Os dois primeiros, analisaremos a diferença entre eles. PhpStorm e linter produzem a segunda opção. Apesar de sempre retornar int, o tipo T|inté deduzido - a "união" dos tipos. Este é um tipo que pode ser atribuído a esses dois valores: primeiro, tivemos informações sobre o tipo T, depois as atribuímos 10; portanto, o tipo da variável foodeve ser compatível com esses dois tipos.

Anotações


Comentários e anotações podem mentir.
No exemplo abaixo, escrevemos que estamos retornando um número, mas retornando uma string. Se o linter funcionasse apenas no nível das anotações e da dica de tipo, consideraríamos que ele sempre retorna int. Mas tipos reais apenas ajudam a se afastar disso: aqui, o tipo esperado é esse inte o tipo real é uma string. Linter sabe que a string é retornada e pode avisar que você prometeu retornar int. Essa separação é importante para nós.

/** @return int */
function f() { return "I lied!"; }

Herança de anotações. Aqui, quero dizer que uma classe que implementa algum tipo de interface possui um método. O método possui bons comentários, documentação, tipos - é necessário implementar a interface. Mas não há comentários na implementação: existe apenas @inheritdocou nada.

interface IFoo {
    /** @return int */
    public function foo();
}
class Fooer implements IFoo {
    /** @inheritdoc */
    public function foo() { return "10"; }
}

O que retorna esse método? Parece que o que é descrito na interface + é retornado int, mas na verdade uma string. Isso não é bom: o PHP é o mesmo, mas a convergência é importante para nós.

Existem duas opções para corrigir esse código. O óbvio é voltarint . Mas talvez você precise retornar um tipo diferente. O que fazer? Escreva que retornamos a string . Nesse caso, informações explícitas de tipo são necessárias para o IDE e o linter para analisar corretamente o código.

interface IFoo {
    /** @return int */
    public function foo();
}
class Fooer implements IFoo {
    /** @return string */
    public function foo() { return "10"; }
}

Esta informação não seria necessária se as pessoas escrevessem comentários, não @inheritdoc. Não é necessário para o PhpStorm entender quais tipos você possui. Mas se os tipos não forem descritos corretamente, haverá um problema.

PhpStorm e o linter têm conjuntos de bugs disjuntos quando usamos os mesmos arquivos para metadados (tipos). Se corrigirmos tudo o que precisamos no phpstorm-stubs do repositório JetBrains, o IDE provavelmente será interrompido. Se você deixar tudo por padrão, nem tudo funcionará corretamente

para nós, portanto, temos um pequeno fork -  VKCOM / phpstorm-stubs . Alguns patches foram adicionados para corrigir algo que não se encaixa. Não posso recomendá-lo para o PhpStorm, mas é necessário que o linter funcione.

Código aberto


O Noverify é um projeto de código aberto. É postado no GitHub .

Instruções breves "se algo der errado".

Se algo estiver quebrado ou não iniciar. A reação errada é se ressentir e remover o NoVerify. A reação correta: emitir um ticket no GitHub e falar sobre o seu problema. Provavelmente, ele será resolvido em 1-2 dias.

Está faltando algum recurso. Reação incorreta: remova o NoVerify e escreva seu próprio linter (embora escrever seu próprio linter seja sempre legal). A reação correta: emitir um ticket no GitHub e, talvez, adicionaremos uma nova função. É mais complicado com recursos do que com erros - surge uma discussão e cada pessoa tem uma visão diferente da implementação na equipe. Mas no final, eles ainda estão sendo implementados.

Se você estiver interessado no desenvolvimento do projeto ou apenas quiser falar sobre análise estática, acesse nossa sala de bate-papo - noverify_linter .

PHP-, , , , PHP Russia.

, . , , . telegram- @PHPRussiaConfChannel. , .

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


All Articles