生成 AI のコードは「失敗を握り潰す」と思って 120 本測ったら、握り潰しは構文では判定できなかった
結論
- 小さな AI(Qwen2.5-Coder 1.5B)が書いた失敗経路 100 本のうち、Python は 78% が try/except を書かず、握り潰し候補は 2 本だけ。「握り潰しが多い」という予想は外れた。
- 検出器(Semgrep)が拾った候補 4 本を人手で開くと、真陽性は 0。偽陽性 2、docstring で契約を明記した正当なフォールバックが 2。
- 同じ
return Noneでも、数値パースなら正当、HTTP 取得なら欠陥(404 も断線も区別できない)。分けるのは構文ではなく関数の役割と呼び出し側の期待=機械は候補まで、最終判定は人。
前提
- 誰向け: 生成 AI に書かせたコードを受け取る側。「エラーを黙って飲み込むコードが混ざっているのでは」と疑っている人。
- 使ったもの: Ollama 上の Qwen2.5-Coder 1.5B(補足で 7B)、Python の
ast、TypeScript は正規表現、Semgrep。すべて無料・ローカル・Windows/CPU。 - 対象: 失敗経路が必然の業務 I/O タスクを Python / TypeScript 各 5 本(設定読み込み・HTTP 取得・環境変数・数値パース・ファイル先頭行)と、純計算の対照を各 1 本。各タスク 10 生成(seed 0〜9・temperature 0.7)で 120 本=失敗経路 100 本+対照 20 本。
- 所要: 生成は 1 回だけ(非決定なので
raw/に全件凍結)。読者が再現するのは決定的な解析側だけ。
手順
- 「失敗時にデフォルト値を返して何も残さない」パターンを Semgrep のルールに書く(
except $E: return None/[]/{}/0/False/""とexcept: pass。TypeScript はcatch { return null }と空 catch)。 - 生成した 120 本に当てる。Python は AST で「try/except を使い log か再送出」「try/except 内で default 返し」「try/except を使わない」に一次分類。
- 分類器の出力をそのまま正解にせず、120 本の分類ラベルを全部目視し、候補と境界例はコード本文・docstring・コメントまで開いて人手で裁定する。
- 純計算の対照 20 本で「失敗が必然でない土俵では何も出ない」ことを確認する(try/except 0・候補 0)。
うまくいかなかったこと
出発点の仮説は「AI は失敗を N% 握り潰す。ルールで数えれば混入率が出る」でした。崩れた順に書きます。
- 誤① 握り潰しが支配的だろう → 測ったら、Python の失敗経路 50 本は「try/except を使わない」が 39 本(78%)。その 39 本の内訳は、
if/elseでraise(むしろ大声で失敗)14 本、ifガードでreturn None4 本、失敗経路を書かずに素通り 21 本。握り潰し候補は 2 本。TypeScript は 33 本(66%)が try/catch+log/再送出で、握り潰していない。 - 誤② 検出器が挙げた 4 件=握り潰しの実数 → 4 件とも偽陽性(関数外の使用例の空 catch・TS 2 件)か、docstring で契約を明記した正当なフォールバック(Python 2 件)。真陽性は 0 と書く。
- 誤③ 偽陽性はルールが下手なだけ → 精緻化で TS の偽陽性は消えたが、
return Noneが正当か欠陥かは文書化の有無でも決まらないと分かった。しかも自分の検出器は try/except しか見ておらず、ifガードでNoneを返す 4 本(すべて HTTP 取得の非 200 時)を最初から素通りしていた。 - 実装の落とし穴: Semgrep がルールの YAML を Windows の cp932 で読み、メッセージ内の全角ダッシュで
UnicodeDecodeError(exit 2)。ルールを ASCII 化し、実行時にPYTHONUTF8=1。 - 執筆の落とし穴: 下調べを AI に任せ、先行研究(AIRA)を「人手評価で判定した」と書いていた。論文本体を読むと逆で、決定的な静的スキャナで候補を出し、是正の前に人手レビューが要ると明記していた。公開前に一次資料へ当て直して直した。
最小反例はこの 2 つです。返す部分の構文はほぼ同型なのに、意味が違います。
# (A) 数値パース: 変換できなければ None(docstring で契約を明記)
def parse_int_field(data, key):
"""Returns the int, or None if it cannot be converted."""
try:
return int(data[key])
except ValueError:
return None # 役割上、妥当なフォールバック
# (B) HTTP 取得: 非 200 なら None
def fetch_json(url):
response = requests.get(url)
if response.status_code == 200:
return response.json()
else:
return None # 404 も 500 も断線も、区別なく None に潰れる
(A) は「変換の可否を返す」関数の役割に照らして妥当。(B) は呼び出し側が「データが空」なのか「取得に失敗した」のかを永久に区別できません。この (B) はコーパスに 4 本のクラスタとして現れ、try/except を見る検出器は構造的に取り逃します。
数値
| try/except の使い方(失敗経路・各言語 n=50) | Python | TypeScript |
|---|---|---|
| try/except を使い、log か再送出 | 9 | 33(66%) |
| try/except 内で default 返し(握り潰し候補) | 2 | 0 |
| try/except を使わない | 39(78%) | 17(34%) |
| 検出器の候補 → 人手確認 | 件数 |
|---|---|
| Semgrep のヒット | 4(Python 2・TS 2) |
| うち偽陽性 | 2 |
| うち文書化された正当なフォールバック | 2 |
| 意図を伏せた握り潰し | 0 |
- 純計算の対照 20 本: try/except 0・候補 0。
- 決定性: temperature 0・スレッド数固定なら、同一 seed 2 回でバイト単位まで一致(sha 一致を実測)。分布を出すには temperature > 0 が要る。
- 7B での補足(各 3 生成・Python 15・TS 15): Python は不使用 10/15・proper 3・候補 2、TS は不使用 8/15・proper 7・候補 0。候補 2 件はどちらも数値パースで docstring に契約あり。
まとめ
握り潰しの候補は機械で出せます。しかしそれが正当な契約か欠陥かを分ける情報——関数の役割・呼び出し側の期待・仕様——は構文の外にあります。CI に入れるなら、緑は「合格」ではなく「人が見るべき候補が 0」という通知として扱う。生成 AI のコードを受け取るときの点検も同じで、道具で当たりを付け、最後は人が意図を読みます。
元の記事(手順の全文・データ・再現手順): Qiita(技術版・全データと再現手順)・2026-07-01
生成 AI に書かせたコードが途中から壊れたときの点検は Works で受けています。
この記事での AI の利用
監査対象のコード 120 本はローカル LLM(Qwen2.5-Coder 1.5B / 7B・Ollama)に生成させたもの。分類器と検出ルールの下書き、先行研究の下調べにも生成 AI を使った。全 120 本の分類ラベルの目視確認、候補と境界例の裁定、数値の再計算、この記事の文章は人間が決めた。下調べで AI の要約を鵜呑みにして先行研究の方法論を逆に書いていた誤りは、一次資料(論文本体)に当て直して公開前に直した。