Tu analizador estático acaba de emitir 847 advertencias en una tarde de viernes. Sabes, estadísticamente, que entre el 5% y el 15% de ellas son bugs reales. El resto son falsos positivos: dead stores en código generado, null checks que parecen sospechosos para una herramienta pero obvios para un humano, integer overflows en funciones hash que no importan.

Revisarlas a mano es devastador para el alma. Así que te preguntas: ¿puedo simplemente preguntarle a un LLM cuáles son reales?

La respuesta corta es sí, con un gran asterisco. Los LLM son sorprendentemente buenos ordenando advertencias de análisis estático por probabilidad de ser accionables. No son buenos entendiendo por qué una advertencia es un falso positivo de la manera en que lo hace la interpretación abstracta. Los dos enfoques resuelven diferentes partes del mismo problema. Úsalos juntos y reduces drásticamente tu tiempo de triage. Usa un LLM solo, y enviarás bugs que suenan muy confiados sobre no existir.

Por qué el análisis estático te ahoga en ruido

Los analizadores estáticos basados en interpretación abstracta funcionan sobreaproximando el comportamiento del programa. Rastrean cada posible camino de ejecución a través de un dominio abstracto, colapsando valores concretos en conjuntos como “entero positivo” o “puntero posiblemente nulo”. Cuando el estado abstracto viola una propiedad, el analizador reporta una advertencia.

El problema es inherente al método. La interpretación abstracta debe ser conservadora para ser sound. Si hay algún camino de ejecución donde un puntero nulo podría desreferenciarse, la herramienta debe reportarlo. Incluso si ese camino requiere una secuencia específica de events que tu lógica de aplicación previene. Incluso si el “nulo” proviene de un método de fábrica que en la práctica nunca devuelve nulo.

El resultado es una inundación. Una codebase madura analizada por herramientas como Infer, CodeQL o Clang Static Analyzer puede producir miles de advertencias. El triaje humano se convierte en un cuello de botella. Los desarrolladores empiezan a ignorar la herramienta por completo, lo que significa que el 5% de las advertencias que son bugs reales quedan enterradas con el ruido.

Qué te da realmente la interpretación abstracta

La interpretación abstracta no es solo un término elegante para “análisis estático”. Es un framework matemático específico con garantías.

Cuando un analizador como Infer reporta una desreferencia nula, es porque hay una cadena de estados abstractos que llevan desde la entrada del programa hasta un sitio de desreferencia donde el valor abstracto del puntero incluye nulo. El analizador puede mostrarte esa cadena. Es una prueba, aunque sobreaproximada.

# 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

La advertencia de arriba es técnicamente correcta. find() podría devolver nulo. Pero si la convención de la codebase es que find() lanza una excepción para IDs faltantes, o si cada sitio de llamada verifica el resultado, la advertencia es ruido. La interpretación abstracta no tiene forma de codificar “este patrón es seguro por convención”. Solo ve la semántica abstracta.

Aquí es donde entra el LLM. No para reemplazar el análisis, sino para aplicar convención y contexto que el método formal no puede.

Cómo los LLM triagean advertencias sin entender semántica

Un LLM no rastrea caminos de ejecución. No sabe qué significa “abstract domain”. Lo que ha visto es cada issue de GitHub, post de Stack Overflow y hilo de code review sobre advertencias de análisis estático. Ha aprendido patrones como “los null checks después de getById suelen ser defensivos, no correcciones de bugs” y “el integer overflow en hashCode() es casi siempre benigno”.

Esto es pattern matching a escala. Y para el triage, el pattern matching es exactamente lo que necesitas.

Aquí hay un enfoque práctico: alimenta al LLM con la advertencia, la función circundante y una rúbrica. Pídele que clasifique cada advertencia como “probablemente real”, “probablemente falso positivo” o “necesita revisión 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

En nuestros experimentos con una codebase de Java en producción, esta pipeline simple clasificó correctamente el 78% de los falsos positivos como FALSE_POSITIVE y marcó el 91% de los bugs confirmados como REAL_BUG o UNCLEAR. La clave era darle suficiente contexto. Un mensaje de advertencia crudo funcionó mal. Treinta líneas de código circundante marcaron la diferencia.

Dónde se desmorona este enfoque

El LLM está adivinando basado en patrones superficiales. No puede verificar que un puntero sea realmente non-null en cada camino de ejecución. Solo reconoce que el código se ve como código donde el nulo se maneja.

Esto crea un modo de falla específico: el LLM descarta con confianza advertencias en patrones de bugs sutiles que no ha visto antes. La interpretación abstracta es deliberadamente conservadora. Advierte sobre cualquier cosa que podría pasar. El LLM es agresivamente permisivo. Limpia cualquier cosa que se vea bien.

Vimos esto con una advertencia de fuga de recursos en una implementación personalizada de Closeable. El LLM la clasificó como falso positivo porque el código se parecía a patrones estándar de try-with-resources. El intérprete abstracto tenía razón: el método close solo se llamaba en uno de dos caminos de error. El LLM perdió la asimetría porque no estaba haciendo razonamiento path-sensitive. Estaba haciendo pattern recognition.

Nunca debes suprimir automáticamente advertencias basadas solo en la clasificación del LLM. Úsalo para reordenar tu cola. La revisión humana sigue siendo importante para cualquier cosa que el LLM limpie.

Construyendo una pipeline de triage híbrida

La implementación práctica combina ambas herramientas en secuencia.

Primero, ejecuta tu intérprete abstracto y recolecta todas las advertencias. Infer, CodeQL y Clang emiten formatos estructurados como SARIF o JSON. Analízalos en un esquema normalizado.

Segundo, enriquece cada advertencia con contexto de código fuente. Obtén la función que la contiene, más imports o definiciones de tipos si están cerca. El LLM necesita ver tipos para razonar sobre convenciones.

Tercero, ejecuta el clasificador LLM. Agrupa las advertencias para reducir costos de API. Un solo prompt con diez advertencias y sus contextos es más barato que diez llamadas separadas, y el modelo puede hacer inferencias de referencia cruzada.

Cuarto, aplica un umbral de confianza. Las advertencias clasificadas como REAL_BUG con alta confianza van al tope de la cola. Las advertencias clasificadas como FALSE_POSITIVE con alta confianza van a una lista de revisión secundaria, no a la basura. Todo lo demás permanece en la cola principal.

Quinto, alimenta los falsos positivos confirmados de vuelta a una base de datos de supresión. Con el tiempo, construirás un corpus de patrones específicos de tu codebase. Las ejecuciones futuras serán más rápidas y 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))

¿Y qué tal simplemente entrenar mejor al analizador?

Esa es la mejor solución a largo plazo, y deberías perseguirla. La interpretación abstracta puede refinarse con dominios más precisos, anotaciones de usuario o análisis sensible al contexto. Una herramienta como Infer admite modelos personalizados que codifican tus convenciones de API.

El problema es el tiempo. Escribir un modelo personalizado para cada API interna requiere esfuerzo de ingeniería que el equipo puede no tener. La pipeline de triage con LLM te da el 80% del beneficio en un día de scripting. Los modelos de analizador personalizados te dan el 95% del beneficio en un mes de ingeniería de dominio.

Haz ambas cosas. Usa la pipeline de LLM para ganar tiempo, luego invierte el tiempo de triage ahorrado en una configuración adecuada del analizador.

Las limitaciones honestas

El triage basado en LLM tiene restricciones reales para las que deberías planificar.

Las ventanas de contexto limitan cuánto código puedes incluir. Una advertencia profunda en una función de 500 líneas puede no caber con su contexto completo. Necesitarás heurísticas para extraer la porción relevante.

Los costos de API se acumulan. Clasificar 10,000 advertencias con GPT-4o cuesta unos $3-5 por ejecución. Eso es barato comparado con el tiempo de ingeniería, pero no es gratis. El batching y el caching son esenciales.

La no determinación significa que la misma advertencia podría obtener clasificaciones diferentes en diferentes ejecuciones. Una temperatura baja ayuda, pero no elimina la varianza. No construyas automatización que dependa de consistencia perfecta.

Empieza con tu checker más ruidoso

No necesitas clasificar cada advertencia el día uno. Elige el checker que produce más falsos positivos en tu codebase. Normalmente es null dereference, resource leak o integer overflow. Construye la pipeline para esa categoría. Mide cuántas advertencias deprioriza correctamente.

Si ahorra a tu equipo una hora por semana, expande al siguiente checker. Si no, has aprendido algo sobre si tu codebase tiene convenciones lo suficientemente consistentes para que el pattern matching funcione.

El objetivo no es reemplazar la interpretación abstracta. El objetivo es dejar de tratar cada advertencia como si pudiera ser el único bug real enterrado en una montaña de ruido. Deja que los métodos formales encuentren los bugs. Deja que el LLM ordene la montaña.

Si quieres experimentar, empieza con la OpenAI Batch API y un parser SARIF. El script tiene menos de cincuenta líneas. El tiempo que recuperes es tuyo para gastar en algo que no sea hacer clic a través de falsos positivos.