Votre analyseur statique vient d’émettre 847 avertissements un vendredi après-midi. Vous savez, statistiquement, que quelque part entre 5% et 15% d’entre eux sont de vrais bugs. Le reste sont des faux positifs : dead stores dans du code généré, null checks qui semblent suspects à un outil mais évidents à un humain, integer overflows dans des fonctions de hachage qui n’ont pas d’importance.
Les trier à la main est écrasant pour l’âme. Alors vous vous demandez : puis-je simplement demander à un LLM lesquels sont réels ?
La réponse courte est oui, avec un gros astérisque. Les LLM sont étonnamment bons pour classer les avertissements d’analyse statique par probabilité d’être actionnables. Ils ne sont pas bons pour comprendre pourquoi un avertissement est un faux positif de la manière dont le fait l’interprétation abstraite. Les deux approches résolvent différentes parties du même problème. Utilisez-les ensemble, et vous réduisez drastiquement votre temps de triage. Utilisez un LLM seul, et vous livrerez des bugs qui sonnent très confiants sur le fait de ne pas exister.
Pourquoi l’analyse statique vous noie dans le bruit
Les analyseurs statiques basés sur l’interprétation abstraite fonctionnent en sur-approximant le comportement du programme. Ils tracent chaque chemin d’exécution possible à travers un domaine abstrait, réduisant les valeurs concrètes en ensembles comme “entier positif” ou “pointeur possiblement nul”. Quand l’état abstrait viole une propriété, l’analyseur rapporte un avertissement.
Le problème est inhérent à la méthode. L’interprétation abstraite doit être conservatrice pour être sound. S’il y a un chemin d’exécution où un pointeur nul pourrait être déréférencé, l’outil doit le signaler. Même si ce chemin nécessite une séquence spécifique d’événements que votre logique d’application empêche. Même si le “nul” provient d’une méthode factory qui ne retourne jamais nul en pratique.
Le résultat est une inondation. Une codebase mature analysée par des outils comme Infer, CodeQL ou Clang Static Analyzer peut produire des milliers d’avertissements. Le triage humain devient un goulot d’étranglement. Les développeurs commencent à ignorer complètement l’outil, ce qui signifie que les 5% d’avertissements qui sont de vrais bugs sont enterrés avec le bruit.
Ce que l’interprétation abstraite vous donne réellement
L’interprétation abstraite n’est pas juste un terme sophistiqué pour “analyse statique”. C’est un framework mathématique spécifique avec des garanties.
Quand un analyseur comme Infer signale une déréférence nulle, c’est parce qu’il y a une chaîne d’états abstraits menant de l’entrée du programme à un site de déréférence où la valeur abstraite du pointeur inclut le nul. L’analyseur peut vous montrer cette chaîne. C’est une preuve, bien que sur-approximée.
# 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
L’avertissement ci-dessus est techniquement correct. find() pourrait retourner nul. Mais si la convention de la codebase est que find() lève une exception pour les IDs manquants, ou si chaque site d’appel vérifie le résultat, l’avertissement est du bruit. L’interprétation abstraite n’a aucun moyen d’encoder “ce pattern est sûr par convention”. Elle ne voit que la sémantique abstraite.
C’est ici que le LLM entre en jeu. Non pas pour remplacer l’analyse, mais pour appliquer la convention et le contexte que la méthode formelle ne peut pas.
Comment les LLM trient les avertissements sans comprendre la sémantique
Un LLM ne trace pas les chemins d’exécution. Il ne sait pas ce que signifie “abstract domain”. Ce qu’il a vu, c’est chaque issue GitHub, post Stack Overflow et fil de code review sur les avertissements d’analyse statique. Il a appris des patterns comme “les null checks après getById sont généralement défensifs, pas des corrections de bugs” et “l’integer overflow dans hashCode() est presque toujours bénin”.
C’est du pattern matching à l’échelle. Et pour le triage, le pattern matching est exactement ce dont vous avez besoin.
Voici une approche pratique : donnez au LLM l’avertissement, la fonction environnante et une rubrique. Demandez-lui de classer chaque avertissement comme “probablement réel”, “probablement faux positif” ou “nécessite une révision humaine”.
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
Dans nos expériences avec une codebase Java en production, cette pipeline simple a correctement classé 78% des faux positifs comme FALSE_POSITIVE et a signalé 91% des bugs confirmés comme REAL_BUG ou UNCLEAR. La clé était de lui donner assez de contexte. Un message d’avertissement brut a mal performé. Trente lignes de code environnant ont fait la différence.
Où cette approche s’effondre
Le LLM devine basé sur des patterns de surface. Il ne peut pas vérifier qu’un pointeur est réellement non nul sur chaque chemin d’exécution. Il reconnaît simplement que le code ressemble à du code où le nul est géré.
Cela crée un mode de défaillance spécifique : le LLM rejette avec confiance des avertissements dans des patterns de bugs subtils qu’il n’a jamais vus auparavant. L’interprétation abstraite est délibérément conservatrice. Elle avertit sur tout ce qui pourrait arriver. Le LLM est agressivement permissif. Il dégage tout ce qui semble correct.
Nous avons vu cela avec un avertissement de fuite de ressources dans une implémentation personnalisée de Closeable. Le LLM l’a classé comme faux positif parce que le code ressemblait à des patterns standard try-with-resources. L’interprète abstrait avait raison : la méthode close n’était appelée que sur l’un des deux chemins d’erreur. Le LLM a manqué l’asymétrie parce qu’il ne faisait pas de raisonnement sensible au chemin. Il faisait de la reconnaissance de patterns.
Vous ne devriez jamais supprimer automatiquement des avertissements basés uniquement sur la classification du LLM. Utilisez-le pour réorganiser votre file. La révision humaine compte toujours pour tout ce que le LLM dégage.
Construction d’une pipeline de triage hybride
L’implémentation pratique combine les deux outils en séquence.
Premièrement, exécutez votre interprète abstrait et collectez tous les avertissements. Infer, CodeQL et Clang produisent tous des formats structurés comme SARIF ou JSON. Analysez-les dans un schéma normalisé.
Deuxièmement, enrichissez chaque avertissement avec le contexte source. Récupérez la fonction englobante, plus les imports ou définitions de types s’ils sont proches. Le LLM a besoin de voir les types pour raisonner sur les conventions.
Troisièmement, exécutez le classificateur LLM. Regroupez les avertissements pour réduire les coûts API. Un seul prompt avec dix avertissements et leurs contextes est moins cher que dix appels séparés, et le modèle peut tirer des inférences de référence croisée.
Quatrièmement, appliquez un seuil de confiance. Les avertissements classés comme REAL_BUG avec une confiance élevée vont en haut de la file. Les avertissements classés comme FALSE_POSITIVE avec une confiance élevée vont sur une liste de révision secondaire, pas à la poubelle. Tout le reste reste dans la file principale.
Cinquièmement, alimentez les faux positifs confirmés dans une base de données de suppression. Avec le temps, vous construirez un corpus de patterns spécifiques à votre codebase. Les exécutions futures seront plus rapides et plus précises.
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))
Et si on entraînait simplement mieux l’analyseur ?
C’est la meilleure solution à long terme, et vous devriez la poursuivre. L’interprétation abstraite peut être affinée avec des domaines plus précis, des annotations utilisateur ou une analyse sensible au contexte. Un outil comme Infer prend en charge des modèles personnalisés qui encodent vos conventions d’API.
Le problème est le temps. Écrire un modèle personnalisé pour chaque API interne demande un effort d’ingénierie que l’équipe n’a peut-être pas. La pipeline de triage LLM vous donne 80% du bénéfice en une journée de scripting. Les modèles d’analyseur personnalisés vous donnent 95% du bénéfice en un mois d’ingénierie de domaine.
Faites les deux. Utilisez la pipeline LLM pour vous donner du répit, puis investissez le temps de triage économisé dans une configuration appropriée de l’analyseur.
Les limitations honnêtes
Le triage basé sur LLM a de vraies contraintes pour lesquelles vous devriez planifier.
Les fenêtres de contexte limitent la quantité de code que vous pouvez inclure. Un avertissement au fond d’une fonction de 500 lignes peut ne pas tenir avec son contexte complet. Vous aurez besoin d’heuristiques pour extraire la tranche pertinente.
Les coûts API s’accumulent. Classer 10 000 avertissements avec GPT-4o coûte environ 3 à 5 dollars par exécution. C’est bon marché comparé au temps d’ingénierie, mais ce n’est pas gratuit. Le batching et la mise en cache sont essentiels.
La non-déterminisme signifie que le même avertissement pourrait obtenir des classifications différentes selon les exécutions. Une basse température aide, mais n’élimine pas la variance. Ne construisez pas d’automatisation qui dépend d’une cohérence parfaite.
Commencez par votre checker le plus bruyant
Vous n’avez pas besoin de classer chaque avertissement dès le premier jour. Choisissez le checker qui produit le plus de faux positifs dans votre codebase. C’est généralement le null dereference, la fuite de ressources ou l’integer overflow. Construisez la pipeline pour cette catégorie. Mesurez combien d’avertissements elle dépriorise correctement.
Si elle économise une heure par semaine à votre équipe, étendez-vous au checker suivant. Si ce n’est pas le cas, vous avez appris quelque chose sur le fait que votre codebase ait des conventions assez cohérentes pour que le pattern matching fonctionne.
Le but n’est pas de remplacer l’interprétation abstraite. Le but est d’arrêter de traiter chaque avertissement comme si elle pourrait être le seul vrai bug enterré dans une montagne de bruit. Laissez les méthodes formelles trouver les bugs. Laissez le LLM trier la montagne.
Si vous voulez expérimenter, commencez avec l’OpenAI Batch API et un parser SARIF. Le script fait moins de cinquante lignes. Le temps que vous récupérez est le vôtre pour dépenser dans quelque chose qui ne consiste pas à cliquer à travers des faux positifs.