← Volver al Blog

Grafo estático pierde 61% de llamadas dinámicas

Un grafo de llamadas construido con puro análisis estático se ve limpio. Cada nodo es una función, cada arista es una invocación que el parser encontró en el código fuente. El problema son las llamadas que faltan.

El paper de ISSTA 2024 sobre análisis estático en Android comparó 13 herramientas populares contra la ejecución real en dispositivos, y encontró que, en promedio, el 61% de los métodos ejecutados dinámicamente no fue capturado por análisis estático. En Python, PyCG (ICSE 2021) reporta 69.9% de recall en su benchmark. Suena mejor hasta que uno se da cuenta de que los peores incidentes viven en el 30% que se pierde.

Ese 30% se concentra en zonas específicas del código: reflection, dispatch dinámico, inyección de dependencias y hooks de framework. Justo los caminos que uno querría que el revisor de código con IA mirara con más cuidado.

Por qué importa esto para revisión de código con IA

Hoy el uso típico de un grafo de llamadas es servir de capa de recuperación (retrieval) para un revisor de código con IA. Cada PR abre una pregunta al grafo: “¿qué depende de los símbolos que este diff toca?”. Si el grafo responde “nada”, el revisor con IA concluye “radio de impacto cero, todo bien”.

Ahí está la falla. Un radio de impacto de cero se lee como luz verde. Pero si el grafo se armó sin ver la ruta de cancelación por reflection que llamaba al método hace 2 años, esa luz verde estaba mintiendo.

Si uno trabaja con una IA de revisión como Claude Code o ChatGPT Codex apoyada en un grafo de código, conviene saber dónde el grafo mismo se está quedando ciego, para que el revisor pueda avisar “acá no veo hasta el final” y no cerrar con un “todo bien” prematuro.

Las seis formas del dispatch dinámico

Las llamadas dinámicas cubren varios patrones, y cada uno abre un tipo distinto de agujero en el grafo:

PatrónEjemploLo que ve el análisis estático
Acceso por atributogetattr(obj, name)(), obj[key]()Nada, salvo que name sea literal
Imports dinámicosimportlib.import_module(name)Solo si name es constante
ReflectionJava Method.invoke(), C# MethodInfo.Invoke()Nada
Inyección de dependenciasSpring @Autowired, FastAPI Depends()Parcial, si además lee la configuración
Eventos y callbacksNode.js EventEmitter, DOM eventsParcial, vía pattern matching
MetaprogramaciónMetaclases de Python, method_missing de RubyNada

Cada fila es un tipo distinto de agujero y cada uno pide un parche propio. Por eso la consigna genérica de “mejoremos el analizador estático” nunca termina de funcionar: mezcla patrones que en el fondo piden herramientas separadas.

Cuatro estrategias para cerrar el hueco (sin resolverlo todo)

Ninguna estrategia sola cierra el 61%. Cuatro capas encimadas lo bajan bastante y, sobre todo, dejan explícito lo que no se pudo cerrar.

Estrategia 1: pattern matching para los literales visibles

El parche más barato es un conjunto de reglas AST para los casos en los que el nombre del target aparece escrito en el código fuente. No ayuda cuando alguien guarda el nombre del método en una variable, pero atrapa la fruta más baja.

# pseudocódigo tipo tree-sitter
# Reconoce: getattr(<x>, "<literal>")(...)
def detect_static_getattr(node):
    if node.type == "call" and node.children[0].type == "call":
        outer = node.children[0]
        if (outer.children[0].text == b"getattr"
            and outer.arguments[1].type == "string"):
            method_name = outer.arguments[1].text.decode().strip('"')
            return ("dynamic_call", method_name)

Cuánto recupera esto depende de con qué frecuencia el segundo argumento es un string literal, así que conviene contarlo en el repositorio antes de adoptarlo. PyCG hace una versión más inteligente rastreando asignaciones simples (m = "save"; getattr(obj, m)). Techo similar, cerca del 70% de recall.

Estrategia 2: trazas de ejecución conectadas al grafo

Todo lo que sea estático va a chocar con un techo alrededor del dispatch que solo existe en tiempo de ejecución. El paso siguiente es obvio: observar la ejecución y devolver esa información al grafo.

Tres fuentes prácticas:

  • sys.settrace durante las ejecuciones de pytest. Cada entrada de función es una arista candidata.
  • Grep en logs de producción para logs estructurados del estilo method=<name>. Nunca hacer grep del cuerpo del log por argumentos, solo por el target de la llamada — de otra forma se arrastran datos personales al grafo.
  • Datos intermedios de coverage.py. Ya tiene que enganchar cada llamada para calcular cobertura; el subproducto es una lista de “quién llamó a quién” que no cuesta nada extra guardar.

Estas entran al grafo como un tipo de arista aparte, CALLS_DYNAMIC, con confianza de 0.9 a 1.0. La llamada se observó de verdad, y esa es evidencia mucho más fuerte que “el parser cree que podría pasar”. Con ese margen de confianza el revisor con IA puede graduar sus advertencias y dejar de hablar siempre con el mismo peso.

DyPyBench (2024) merece la referencia: armaron un benchmark ejecutable de 681K LOC con 50 paquetes de Python, específicamente para medir cuánto recupera la traza dinámica encima del análisis estático. Recupera mucho, y la brecha crece en librerías que se apoyan en frameworks.

Los problemas son reales:

  • Tests que no se ejecutan no aparecen. Si el dispatch dinámico solo se ejercita en una suite de integración que se lanza una vez por semana, uno recibe actualizaciones semanales del grafo.
  • sys.settrace es lento. Ejecutarlo en CI en cada PR es doloroso; ejecutarlo de noche sobre main va bien.
  • Los logs de producción tampoco son gratis. Preocupaciones de datos personales y cardinalidad son reales, así que solo se extrae la columna del nombre del método.

Estrategia 3: inferencia con LLM para lo que ninguna otra atrapa

La estrategia 1 se cae cuando el nombre del target es una variable. La 2 se cae cuando la ruta de código no se ejercita en tests. Queda pedirle a un modelo que adivine, con el código alrededor y el tipo del receptor como pistas.

prompt = f"""
Dado este código, lista hasta 3 métodos que probablemente
llame `{dynamic_call_source}`. Devuelve un array JSON con
{{method, confidence}}.

Contexto:
{code_snippet}

Métodos disponibles en el receptor (según el grafo):
{method_list_from_kg}
"""

El truco central es pasarle al modelo la lista de métodos del receptor sacada del grafo. Sin ese anclaje, alucina nombres plausibles que no existen. Con él, las suposiciones quedan restringidas a la superficie real de la API, y los números de confianza empiezan a ser útiles.

El estudio de EMSE 2025 sobre LLMs para análisis de tipos y grafos de llamadas evaluó 24 modelos en Python y JS. El reparto es curioso: los LLMs le ganan a las herramientas tradicionales en inferencia de tipos, pero en construcción del grafo de llamadas los analizadores estáticos clásicos (PyCG, Jelly) todavía ganan. De ahí que el LLM rinda mal como constructor principal del grafo, aunque sí rinde bien para tapar los agujeros que el constructor principal ya marcó.

Estas entran al grafo como CALLS_INFERRED con confianza 0.3 a 0.7. El revisor con IA las trata como pistas para leer el código alrededor, sin asumir que la llamada realmente ocurre.

Estrategia 4: archivos de configuración como fuente de verdad

La inyección de dependencias parece dinámica hasta que uno se da cuenta de que la respuesta está escrita en algún archivo: en una clase @Configuration de Spring, en una cadena Depends() de FastAPI, en un módulo de NestJS. Si el framework declara qué implementación se conecta a qué interfaz, toca parsear al framework.

# estilo Spring: leer métodos @Bean y conectarlos con implementaciones
for cls in classes_with_annotation("@Configuration"):
    for method in cls.methods:
        if method.has_annotation("@Bean"):
            bean_type = method.return_type
            for impl in find_implementations(bean_type):
                add_edge(method, impl, type="INJECTS", confidence=0.95)

Confianza 0.95 porque la configuración es la fuente de verdad para un sistema en producción. Solo se equivoca cuando el runtime sobreescribe la configuración, y ese es justamente un bug que uno quiere que el grafo ayude a encontrar.

Los huecos que no se pueden cerrar: marcarlos y seguir

Incluso con las cuatro estrategias, algo del dispatch queda sin resolver. La reflection en Java sola tiene un paper de TOSEM 2019 entero sobre por qué resiste al análisis sólido. Ese frente no se cierra, y uno tampoco lo va a cerrar solo.

El truco está en qué hacer con el residuo. Se marca con una bandera UNKNOWN_DYNAMIC cualquier nodo donde se sabe que hay dispatch pero el target no se pudo resolver. Cuando el revisor con IA calcula el radio de impacto cerca de un nodo marcado, cambia su salida de “seguro, sin llamadores” a algo como “el análisis estático no ve más allá de este punto; acá hay N sitios de reflection dentro de M saltos”. Ese cambio, chico como suena, es el que hubiera cerrado el hueco que enciende las alarmas a las 3 de la madrugada.

Una arista sin resolver deja una verruga en el grafo. Un cero silencioso deja un incidente esperando a caer un domingo. Prefiero convivir con la verruga.

Cierre

Los grafos de llamadas estáticos pierden alrededor del 61% de las llamadas dinámicas en Android (ISSTA 2024) y cerca del 30% en Python (PyCG). Ese número tapa seis mecanismos distintos de dispatch, cada uno con su propia técnica de cierre. Las trazas de ejecución vía sys.settrace o coverage.py son la señal más fuerte que uno puede sumar, aunque cuestan lo suficiente como para vivir en un job nocturno sobre main y no en el CI de cada PR. La inferencia con LLM rinde solo después de que el constructor principal marcó los huecos, y solo si se le pasa la lista de métodos del receptor para que deje de alucinar nombres.

Lo único que no conviene saltarse es la bandera UNKNOWN_DYNAMIC. Cualquier cosa que uno no logre resolver, la marca, y deja que el revisor baje su propia confianza frente al humano. Los grafos que hacen daño son los que nunca dicen “acá no veo”.


ken imoto · WebRTC & Voice AI engineer · kenimoto.dev