あなたの静的解析ツールは金曜日の午後に847件の警告を出力した。統計的には、そのうち5%から15%が実際のバグだ。残りは偽陽性だ:生成コード内のデッドストア、ツールには怪しく見えるが人間には明らかなヌルチェック、関係のないハッシュ関数内の整数オーバーフロー。
手作業で仕分けするのは精神的に exhausting だ。そこで思う:LLMにどれが本物か聞けばいいのでは?
短い答えは「はい」、ただし大きな注釈付きだ。LLMは静的解析の警告を実行可能性の高い順に並べ替えるのに驚くほど得意だ。しかし、警告が偽陽性である理由を、抽象解釈のように理解しているわけではない。両アプローチは同じ問題の異なる部分を解決する。組み合わせて使えば、選別時間を劇的に短縮できる。LLM単独では、「存在しない」と自信満々に語るバグを出荷してしまう。
なぜ静的解析はノイズに溺れるのか
抽象解釈に基づく静的解析ツールは、プログラムの振る舞いを過度近似することで動作する。抽象ドメインを通じてすべての可能な実行経路を追跡し、具体的な値を「正の整数」や「ヌルの可能性があるポインタ」といった集合に集約する。抽象状態が性質を違反すると、解析器は警告を報告する。
この問題は方法に固有のものだ。抽象解釈は健全(sound)であるために保守的でなければならない。ヌルポインタが解引用される可能性のある実行経路があれば、ツールはそれを報告しなければならない。アプリケーションのロジックで防いでいる特定のイベントシーケンスを必要とする経路であっても。実際には決してヌルを返さないファクトリメソッドからの「ヌル」であっても。
結果は氾濫だ。Infer、CodeQL、Clang Static Analyzerのようなツールで解析された成熟したコードベースは、数千もの警告を生み出す。人間による選別がボトルネックになる。開発者はツールを完全に無視し始め、本物のバグである5%の警告がノイズと共に埋もれてしまう。
抽象解釈が実際に与えるもの
抽象解釈は、「静的解析」を指す派手な言葉ではない。保証を伴う特定の数学的フレームワークだ。
Inferのような解析器がヌル解引用を報告するのは、プログラムの入口からポインタの抽象値にヌルが含まれる解引用サイトへと至る抽象状態の連鎖があるからだ。解析器はその連鎖を示せる。過度近似された証明ではあるが、証明だ。
# 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
上記の警告は技術的に正しい。find()はヌルを返す可能性がある。しかし、コードベースの規約としてfind()は存在しないIDで例外を投げるか、すべての呼び出し元が結果をチェックするなら、この警告はノイズだ。抽象解釈には「このパターンは規約上安全だ」とエンコードする方法がない。抽象的意味論だけが見える。
ここでLLMが登場する。解析を置き換えるのではなく、形式的な方法では適用できない規約と文脈を適用するためにだ。
LLMが意味論を理解せずに警告を選別する方法
LLMは実行経路を追跡しない。「抽象ドメイン」が何を意味するか知らない。見てきたのは、GitHubのイシュー、Stack Overflowの投稿、静的解析警告に関するコードレビューのスレッドすべてだ。学習したパターンには「getById後のヌルチェックは通常防御的で、バグ修正ではない」「hashCode()内の整数オーバーフローはほぼ常に無害」などがある。
これは大規模なパターンマッチングだ。選別には、パターンマッチングがまさに必要なものだ。
実用的なアプローチ:LLMに警告、周囲の関数、ルーブリックを与える。各警告を「おそらく本物」「おそらく偽陽性」「人間のレビューが必要」のいずれかに分類するよう求める。
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
プロダクションのJavaコードベースを用いた実験では、このシンプルなパイプラインが78%の偽陽性を正しくFALSE_POSITIVEと分類し、確認されたバグの91%をREAL_BUGまたはUNCLEARとマークした。鍵は十分な文脈を与えることだった。生の警告メッセージは性能が悪かった。周囲30行のコードが変わった。
このアプローチが崩壊する場所
LLMは表面的なパターンに基づいて推測している。ポインタがすべての実行経路上で実際に非ヌルであることを検証できない。コードがヌルが処理されるように見えるコードだと認識するだけだ。
これは特定の故障モードを生む:LLMは、これまで見たことのない微妙なバグパターンの警告を自信満々に却下する。抽象解釈は意図的に保守的だ。起こりうることすべてに警告する。LLMは攻撃的に容認的だ。問題なさそうに見えるものすべてをクリアする。
カスタムCloseable実装におけるリソースリーク警告でこれを見た。コードが標準的なtry-with-resourcesパターンに似ていたため、LLMは偽陽性と分類した。抽象解釈は正しかった:closeメソッドは2つのエラーパスのうち1つでのみ呼ばれていた。LLMは非対称性を見落とした。なぜなら、パスに敏感な推論を行っていなかったからだ。パターン認識を行っていた。
LLMの分類だけに基づいて警告を自動抑制してはいけない。キューを並べ替えるために使う。LLMがクリアしたものについても、人間のレビューは依然として重要だ。
ハイブリッド選別パイプラインの構築
実用的な実装は、両方のツールを順番に組み合わせる。
第一に、抽象解釈器を実行してすべての警告を収集する。Infer、CodeQL、ClangはすべてSARIFやJSONのような構造化形式を出力する。これらを正規化されたスキーマに解析する。
第二に、各警告をソース文脈で充実させる。囲んでいる関数を取得し、近くにあればインポートや型定義も含める。LLMは規約について推論するために型を見る必要がある。
第三に、LLM分類器を実行する。APIコストを削減するために警告をバッチ化する。10件の警告とその文脈を含む単一のプロンプトは、10回の個別呼び出しより安く、モデルは相互参照の推論を行える。
第四に、信頼度の閾値を適用する。高い信頼度でREAL_BUGと分類された警告はキューの先頭に行く。高い信頼度でFALSE_POSITIVEと分類された警告は二次レビューリストに行き、ゴミ箱には行かない。その他はメインキューに留まる。
第五に、確認された偽陽性を抑制データベースにフィードバックする。時間の経過とともに、コードベース固有のパターンのコーパスが構築される。将来の実行はより速く、より正確になる。
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))
解析器を単純にトレーニングするのはどうか
それがより良い長期的な修正であり、追求すべきだ。抽象解釈は、より正確なドメイン、ユーザー注釈、文脈に敏感な解析によって洗練できる。Inferのようなツールは、API規約をエンコードするカスタムモデルをサポートする。
問題は時間だ。すべての内部API用のカスタムモデルを書くのは、チームが持っていないかもしれないエンジニアリング労力を必要とする。LLM選別パイプラインは、スクリプティング1日で80%の利益を与える。カスタム解析器モデルは、ドメインエンジニアリング1か月で95%の利益を与える。
両方やれ。LLMパイプラインで息抜きの時間を得て、節約した選別時間を適切な解析器設定に投資しろ。
正直な限界
LLMベースの選別には、計画すべき実際の制約がある。
コンテキストウィンドウは含められるコード量を制限する。500行の関数の奥深くにある警告は、完全な文脈と共には収まらないかもしれない。関連するスライスを抽出するためのヒューリスティクスが必要だ。
APIコストは積み上がる。GPT-4oで10,000件の警告を分類すると、実行あたり約3〜5ドルかかる。エンジニアリング時間と比べれば安いが、タダではない。バッチ処理とキャッシングは不可欠だ。
非決定性は、同じ警告が異なる実行で異なる分類を受ける可能性があることを意味する。低温は助けるが、分散を排除しない。完璧な一貫性に依存する自動化を構築するな。
最もうるさいチェッカーから始めろ
初日にすべての警告を分類する必要はない。コードベースで最も偽陽性を生み出すチェッカーを選べ。通常、それはヌル解引用、リソースリーク、または整数オーバーフローだ。そのカテゴリー1つについてパイプラインを構築する。正しく優先度を下げた警告の数を測定する。
チームの週1時間を節約できるなら、次のチェッカーに拡張しろ。そうでなければ、コードベースにパターンマッチングが機能するほど一貫した規約があるかどうかについて学んだことになる。
目標は抽象解釈を置き換えることではない。目標は、すべての警告がノイズの山に埋もれた唯一の本物のバグかもしれないかのように扱うのをやめることだ。形式的な方法にバグを見つけさせろ。LLMに山を仕分けさせろ。
実験したいなら、OpenAI Batch APIとSARIFパーサーから始めろ。スクリプトは50行未満だ。取り戻した時間は、偽陽性をクリックする以外のことに使える。