Seu analyzer estático acabou de emitir 847 avisos em uma tarde de sexta-feira. Você sabe, estatisticamente, que entre 5% e 15% deles são bugs reais. O resto são falsos positivos: dead stores em código gerado, null checks que parecem suspeitos para uma ferramenta mas óbvios para um humano, integer overflows em funções de hash que não importam.

Revisá-los manualmente é devastador para a alma. Então você se pergunta: posso simplesmente perguntar a um LLM quais são reais?

A resposta curta é sim, com um grande asterisco. LLMs são surpreendentemente bons em ordenar avisos de análise estática por probabilidade de serem acionáveis. Eles não são bons em entender por que um aviso é um falso positivo da maneira como a interpretação abstrata faz. As duas abordagens resolvem partes diferentes do mesmo problema. Use-os juntos, e você corta drasticamente seu tempo de triagem. Use um LLM sozinho, e você enviará bugs que soam muito confiantes sobre não existirem.

Por que a análise estática te afoga em ruído

analyzers estáticos baseados em interpretação abstrata funcionam superaproximando o comportamento do programa. Eles rastreiam cada caminho de execução possível através de um domínio abstrato, colapsando valores concretos em conjuntos como “inteiro positivo” ou “ponteiro possivelmente nulo”. Quando o estado abstrato viola uma propriedade, o analyzer reporta um aviso.

O problema é inerente ao método. A interpretação abstrata deve ser conservadora para ser sound. Se houver algum caminho de execução onde um ponteiro nulo poderia ser desreferenciado, a ferramenta deve reportá-lo. Mesmo que esse caminho exija uma sequência específica de events que sua lógica de aplicação previne. Mesmo que o “nulo” venha de um método factory que na prática nunca retorna nulo.

O resultado é uma inundação. Uma codebase madura analisada por ferramentas como Infer, CodeQL ou Clang Static Analyzer pode produzir milhares de avisos. A triagem humana torna-se um gargalo. Os desenvolvedores começam a ignorar a ferramenta completamente, o que significa que os 5% dos avisos que são bugs reais ficam enterrados com o ruído.

O que a interpretação abstrata realmente lhe dá

A interpretação abstrata não é apenas um termo chique para “análise estática”. É um framework matemático específico com garantias.

Quando um analyzer como Infer reporta uma desreferência nula, é porque há uma cadeia de estados abstratos levando da entrada do programa a um site de desreferência onde o valor abstrato do ponteiro inclui nulo. O analyzer pode mostrar essa cadeia a você. É uma prova, embora superaproximada.

# Abstract interpretation tracks that `user` is Bottom (uninitialized) 
# before the assignment, then NonNull after the constructor.
def get_user_name(user_id: int) -> str:
    user = UserRepository.find(user_id)  # Abstract: user ∈ {Null, NonNull}
    return user.name                      # Warning: possible null dereference

O aviso acima é tecnicamente correto. find() poderia retornar nulo. Mas se a convenção da codebase é que find() lança em IDs ausentes, ou se cada site de chamada verifica o resultado, o aviso é ruído. A interpretação abstrata não tem como codificar “este padrão é seguro por convenção”. Ela só vê a semântica abstrata.

É aqui que o LLM entra. Não para substituir a análise, mas para aplicar convenção e contexto que o método formal não pode.

Como os LLMs triam avisos sem entender semântica

Um LLM não rastreia caminhos de execução. Ele não sabe o que significa “abstract domain”. O que ele viu é cada issue do GitHub, post do Stack Overflow e thread de code review sobre avisos de análise estática. Ele aprendeu padrões como “null checks após getById são geralmente defensivos, não correções de bugs” e “integer overflow em hashCode() é quase sempre benigno”.

Isso é pattern matching em escala. E para triagem, pattern matching é exatamente o que você precisa.

Aqui está uma abordagem prática: alimente o LLM com o aviso, a função circundante e uma rubrica. Peça-lhe para classificar cada aviso como “provavelmente real”, “provavelmente falso positivo” ou “precisa de revisão humana”.

import openai

def triage_warning(warning: dict, source_context: str) -> str:
    prompt = f"""
You are reviewing a static analysis warning. Classify it as one of:
- REAL_BUG: The warning describes a genuine logic error or vulnerability
- FALSE_POSITIVE: The warning is safe due to code convention, domain knowledge, or imprecise analysis
- UNCLEAR: Not enough context to decide

Warning: {warning['message']}
File: {warning['file']}:{warning['line']}
Category: {warning['checker']}

Surrounding code:

{source_context}


Respond with only the classification and a one-sentence reason.
"""
    response = openai.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.1,
    )
    return response.choices[0].message.content

Em nossos experimentos com uma codebase Java em produção, este pipeline simples classificou corretamente 78% dos falsos positivos como FALSE_POSITIVE e sinalizou 91% dos bugs confirmados como REAL_BUG ou UNCLEAR. A chave era dar a ele contexto suficiente. Uma mensagem de aviso bruta teve desempenho ruim. Trinta linhas de código circundante fizeram a diferença.

Onde esta abordagem desmorona

O LLM está adivinhando com base em padrões de superfície. Ele não pode verificar que um ponteiro é realmente não nulo em cada caminho de execução. Ele apenas reconhece que o código parece como código onde nulo é tratado.

Isso cria um modo de falha específico: o LLM descarta confiantemente avisos em padrões de bugs sutis que ele nunca viu antes. A interpretação abstrata é deliberadamente conservadora. Ela avisa sobre qualquer coisa que pudesse acontecer. O LLM é agressivamente permissivo. Ele libera qualquer coisa que pareça bem.

Vimos isso com um aviso de vazamento de recursos em uma implementação personalizada de Closeable. O LLM o classificou como falso positivo porque o código se assemelhava a padrões try-with-resources padrão. O interpretador abstrato estava correto: o método close só era chamado em um dos dois caminhos de erro. O LLM perdeu a assimetria porque não estava fazendo raciocínio sensível a caminhos. Ele estava fazendo reconhecimento de padrões.

Você nunca deve suprimir automaticamente avisos baseados apenas na classificação do LLM. Use-o para reordenar sua queue. A revisão humana ainda importa para qualquer coisa que o LLM libere.

Construindo um pipeline de triagem híbrido

A implementação prática combina ambas as ferramentas em sequência.

Primeiro, execute seu interpretador abstrato e colete todos os avisos. Infer, CodeQL e Clang emitem todos formatos estruturados como SARIF ou JSON. Parseie-os em um esquema normalizado.

Segundo, enriqueça cada aviso com contexto de origem. Busque a função envolvente, mais imports ou definições de tipos se estiverem por perto. O LLM precisa ver tipos para raciocinar sobre convenções.

Terceiro, execute o classificador LLM. Agrupe avisos para reduzir custos de API. Um único prompt com dez avisos e seus contextos é mais barato que dez chamadas separadas, e o modelo pode tirar inferências de referência cruzada.

Quarto, aplique um limiar de confiança. Avisos classificados como REAL_BUG com alta confiança vão para o topo da queue. Avisos classificados como FALSE_POSITIVE com alta confiança vão para uma lista de revisão secundária, não para o lixo. Todo o resto permanece na queue principal.

Quinto, alimente falsos positivos confirmados de volta para um banco de dados de supressão. Com o tempo, você construirá um corpus de padrões específicos da sua codebase. Execuções futuras ficarão mais rápidas e precisas.

from dataclasses import dataclass
from typing import Literal

@dataclass
class Warning:
    message: str
    file: str
    line: int
    checker: str
    severity: str
    classification: Literal["REAL_BUG", "FALSE_POSITIVE", "UNCLEAR"] = "UNCLEAR"
    confidence: float = 0.0

def process_batch(warnings: list[Warning], source_map: dict[str, str]) -> list[Warning]:
    enriched = [
        w for w in warnings 
        if w.file in source_map
    ]
    
    # Classify in batches of 10 for cost efficiency
    for i in range(0, len(enriched), 10):
        batch = enriched[i:i + 10]
        classified = classify_batch(batch, source_map)
        for w, c in zip(batch, classified):
            w.classification = c.label
            w.confidence = c.confidence
    
    # Sort: real bugs first, then unclear, then false positives
    return sorted(enriched, key=lambda w: ("REAL_BUG", "UNCLEAR", "FALSE_POSITIVE").index(w.classification))

E quanto a simplesmente treinar melhor o analyzer?

Essa é a melhor correção a longo prazo, e você deve persegui-la. A interpretação abstrata pode ser refinada com domínios mais precisos, anotações de usuário ou análise sensível ao contexto. Uma ferramenta como Infer suporta modelos personalizados que codificam suas convenções de API.

O problema é o tempo. Escrever um modelo personalizado para cada API interna requer esforço de engenharia que a equipe pode não ter. O pipeline de triagem com LLM te dá 80% do benefício em um dia de scripting. Modelos de analyzer personalizados te dão 95% do benefício em um mês de engenharia de domínio.

Faça ambos. Use o pipeline LLM para ganhar fôlego, então invista o tempo de triagem economizado em uma configuração adequada do analyzer.

As limitações honestas

A triagem baseada em LLM tem restrições reais para as quais você deve planejar.

Janelas de contexto limitam quanto código você pode incluir. Um aviso no meio de uma função de 500 linhas pode não caber com seu contexto completo. Você precisará de heurísticas para extrair a fatia relevante.

Custos de API se somam. Classificar 10.000 avisos com GPT-4o custa cerca de $3-5 por execução. Isso é barato comparado ao tempo de engenharia, mas não é de graça. Batching e caching são essenciais.

Não-determinismo significa que o mesmo aviso pode obter classificações diferentes em execuções diferentes. Baixa temperatura ajuda, mas não elimina a variância. Não construa automação que dependa de consistência perfeita.

Comece com seu checker mais barulhento

Você não precisa classificar cada aviso no primeiro dia. Escolha o checker que produz mais falsos positivos na sua codebase. Geralmente isso é null dereference, resource leak ou integer overflow. Construa o pipeline para essa categoria. Meça quantos avisos ele corretamente desprioriza.

Se economizar uma hora por semana para sua equipe, expanda para o próximo checker. Se não, você aprendeu algo sobre se sua codebase tem convenções consistentes o suficiente para o pattern matching funcionar.

O objetivo não é substituir a interpretação abstrata. O objetivo é parar de tratar cada aviso como se pudesse ser o único bug real enterrado em uma montanha de ruído. Deixe os métodos formais encontrarem os bugs. Deixe o LLM ordenar a montanha.

Se você quer experimentar, comece com a OpenAI Batch API e um parser SARIF. O script tem menos de cinquenta linhas. O tempo que você recuperar é seu para gastar em algo que não seja clicar através de falsos positivos.