Saltar para o conteúdo

Inteligência de código

O Control Center dá a um agente quatro formas de compreender código para além de o ler. Respondem a perguntas genuinamente diferentes, e a razão de serem quatro em vez de uma é que nenhum mecanismo isolado responde a todas elas.

Mecanismo Responde Custos
Grafo de código O que existe aqui e do que depende Uma passagem de indexação; consulta barata
Servidor de linguagem Isto compila mesmo, e que tipo é este Um processo de servidor por projeto
Pesquisa estrutural Onde aparece esta FORMA Uma análise sintática por ficheiro
Depurador O que era verdade naquele momento, naquele frame Um processo parado

O grafo de código é construído a partir do tree-sitter: símbolos, arestas de chamada, um raio de impacto. É rápido, é barato e não sabe nada sobre tipos. Não consegue dizer se a chamada que acabou de escrever compila, se aquele campo existe no objeto ou se a sua renomeação deixou escapar um re-export.

Sem um servidor de linguagem, um agente só descobre isso na altura dos testes — ou nunca. Escreve algo plausível, segue em frente, e a falha aparece três turnos depois, associada a uma alteração diferente.

Portanto, o ganho não está propriamente na ferramenta lsp. Está no facto de write, edit e apply_patch serem envolvidas: depois de cada escrita, o ficheiro passa pelo servidor de linguagem e os diagnósticos introduzidos recentemente são incorporados no resultado da ferramenta. O agente fica a saber que quebrou a build no momento em que a quebrou, quer tenha pensado em perguntar, quer não.

Repetir o que já foi dito ao agente é pior do que não dizer nada: enche o resultado de ruído que o agente aprende a ignorar, e o único erro novo vai com ele. Um registo por execução acompanha o que já foi reportado, indexado por gravidade, código e mensagem — não pela posição, de modo que um diagnóstico que se limitou a descer quatro linhas não volta a ser reportado como novo.

Nenhum servidor arranca no arranque. Um deles arranca no primeiro pedido que precisa dele, que é quase sempre a primeira edição num ficheiro que ele cobre, e os que ficam ociosos são limpos. Uma máquina que nunca toca em Dart nunca inicia um analisador.

A deteção é uma interseção: é preciso existir um marcador de raiz no checkout e o binário tem de resolver. Um marcador sozinho diz “este projeto é Python” e nada sobre se o servidor está instalado; um binário que resolve, sozinho, diz que a máquina tem uma cadeia de ferramentas e nada sobre este checkout. Só os dois em conjunto significam que uma consulta pode ter êxito, e uma ferramenta que é oferecida e falha sempre é pior do que uma que está ausente.

O grep faz corresponder caracteres. É a ferramenta certa para um nome e a errada para uma forma.

Um padrão estrutural é analisado pela mesma gramática do ficheiro, pelo que dispose($X) encontra a chamada independentemente do espaçamento, das quebras de linha ou do envolvimento, e nunca faz corresponder os mesmos caracteres dentro de um literal de string ou de um comentário.

A propriedade que o torna mais do que uma expressão regular com passos extra é que uma metavariável repetida no mesmo padrão tem de capturar o mesmo texto:

if ($X != null) $X.dispose()

corresponde a uma guarda que descarta aquilo que testou e não corresponde a uma que descarta outra coisa. É uma classe de erros que não consegue exprimir textualmente de forma alguma.

Não há um segundo motor. O cc_natives já carrega o tree-sitter e as suas gramáticas para o grafo de código, com uma cache de consultas compiladas e uma tabela de linguagens. O comparador é Dart puro sobre árvores que já estavam a ser analisadas de qualquer maneira. Um segundo motor estrutural significaria um segundo analisador sintático, um segundo conjunto de gramáticas para compilar e distribuir em todas as plataformas, e duas respostas a “o que é uma expressão de chamada”.

Uma gramática só define o que é um ficheiro inteiro. Analisado isoladamente no nível superior do Dart, dispose(x) é uma assinatura de função perfeitamente válida — uma declaração chamada dispose que recebe um parâmetro x — sem nenhum erro que o denuncie. Um comparador construído sobre essa análise procura declarações, não encontra chamadas e reporta “sem correspondências” para um padrão que nunca estava a ser lido tal como parece.

Assim, um padrão é experimentado numa escada de andaimes — instrução, expressão, membro de classe, nível superior — e vence o primeiro que o aceitar. Um fragmento que nenhum contexto aceita é reportado como não analisável, em vez de ser procurado e silenciosamente não encontrado.

Porque é que uma reescrita passa por preparação em vez de escrever diretamente

Secção intitulada “Porque é que uma reescrita passa por preparação em vez de escrever diretamente”

Uma reescrita estrutural é a edição mais ampla que um agente pode fazer: um padrão, quarenta ficheiros, e ninguém lê o resultado linha a linha — nem o modelo, nem a pessoa que a pediu. Escrevê-la e reportar “concluído” significa que o primeiro olhar honesto para a alteração é um git diff feito depois.

Por isso, o ast_edit reporta aquilo que faria — a contagem, os ficheiros, os locais correspondentes — e a alteração só se concretiza quando o resolve a confirma. É também isso que permite que um diff e um Aceitar/Descartar fiquem diante de uma pessoa antes de algo chegar ao disco.

A confirmação é tudo ou nada, e cada ficheiro é verificado em relação ao conteúdo capturado no momento da preparação. Entre a preparação e a confirmação, o agente pode ter editado à mão um desses ficheiros, uma passagem de diagnósticos pode tê-lo reescrito, ou um formatador pode tê-lo alterado — confirmar mesmo assim descartaria esse trabalho e o diff pareceria intencional. Qualquer divergência recusa a alteração inteira, porque uma reescrita estrutural aplicada em parte deixa uma árvore que não compila em nenhuma das duas formas.

O que um depurador responde que uma instrução de impressão não consegue

Secção intitulada “O que um depurador responde que uma instrução de impressão não consegue”

O ciclo da instrução de impressão é: acrescentar uma impressão, executar tudo de novo, ler a saída, apagar a impressão. Custa uma execução completa dos testes por pergunta, e só responde à pergunta que pensou em fazer antes de executar.

Um frame parado responde a todas as perguntas sobre aquele momento de uma só vez — cada variável local, cada chamador e uma expressão avaliada no próprio âmbito do frame. Para um processo encravado ou uma falha que se reproduz uma vez em cada dez execuções, é a diferença entre uma tarde e um minuto.

Um adaptador de depuração é dono de um processo parado que detém tudo o que esse processo detém — uma porta, um bloqueio, uma ligação a uma base de dados. Uma sessão que ninguém está a conduzir é uma fuga que sobrevive ao turno que a iniciou. Por isso existe um TTL rígido, exatamente uma sessão por conversa, e um segundo launch é recusado em vez de substituir silenciosamente o primeiro: um agente que inicia um segundo sem terminar o primeiro quase de certeza perdeu o rasto do primeiro.

O Debug Adapter Protocol partilha o enquadramento Content-Length do Language Server Protocol e nada acima disso — chaves de correlação diferentes, uma forma de falha diferente e um terceiro tipo de mensagem (eventos) sem equivalente no LSP. O enquadramento vive num só lugar; os protocolos assentam nele separadamente.

Escrever o enquadramento duas vezes significaria duas respostas a “uma mensagem ocupa dois blocos”, “um bloco contém três mensagens” e “o servidor escreveu um banner para stdout antes do primeiro frame”, e só uma delas receberia a correção.