生成 AI のコードは「失敗を握り潰す」と思って 120 本測ったら、握り潰しは構文では判定できなかった

結論

  1. 小さな AI(Qwen2.5-Coder 1.5B)が書いた失敗経路 100 本のうち、Python は 78% が try/except を書かず、握り潰し候補は 2 本だけ。「握り潰しが多い」という予想は外れた。
  2. 検出器(Semgrep)が拾った候補 4 本を人手で開くと、真陽性は 0。偽陽性 2、docstring で契約を明記した正当なフォールバックが 2。
  3. 同じ return None でも、数値パースなら正当、HTTP 取得なら欠陥(404 も断線も区別できない)。分けるのは構文ではなく関数の役割と呼び出し側の期待=機械は候補まで、最終判定は人。
120 本AI が書いたコード検出器候補 4構文で拾う人が開く真陽性 0偽陽性 2・正当 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/ に全件凍結)。読者が再現するのは決定的な解析側だけ。

手順

  1. 「失敗時にデフォルト値を返して何も残さない」パターンを Semgrep のルールに書く(except $E: return None/[]/{}/0/False/""except: pass。TypeScript は catch { return null } と空 catch)。
  2. 生成した 120 本に当てる。Python は AST で「try/except を使い log か再送出」「try/except 内で default 返し」「try/except を使わない」に一次分類。
  3. 分類器の出力をそのまま正解にせず、120 本の分類ラベルを全部目視し、候補と境界例はコード本文・docstring・コメントまで開いて人手で裁定する。
  4. 純計算の対照 20 本で「失敗が必然でない土俵では何も出ない」ことを確認する(try/except 0・候補 0)。

うまくいかなかったこと

出発点の仮説は「AI は失敗を N% 握り潰す。ルールで数えれば混入率が出る」でした。崩れた順に書きます。

  • 誤① 握り潰しが支配的だろう → 測ったら、Python の失敗経路 50 本は「try/except を使わない」が 39 本(78%)。その 39 本の内訳は、if/elseraise(むしろ大声で失敗)14 本、if ガードで return None 4 本、失敗経路を書かずに素通り 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 の要約を鵜呑みにして先行研究の方法論を逆に書いていた誤りは、一次資料(論文本体)に当て直して公開前に直した。