ある品質ゲート改修の analyze フェーズが、注意書きとしてこう書きました。「安全floor(欠落・out-of-enum・breaking→complex)を変更しないこと」。ごく普通の制約説明のはずなのに、この一文自体が breaking change と誤判定されてゲートを発火させました。守れと書いた不変条件の説明文が、まさにその判定基準に引っかかったわけです(正直、笑うところなのか青ざめるところなのか一瞬迷いました)。
私たちが運用している自動開発パイプラインでは、Issue を渡すと analyze(分析)→ 計画 → 実装 → テスト → 評価 → PR 作成まで自動で進みます。この analyze フェーズが生成する scope と summary という自由記述フィールドに対して、isBreakingText という判定関数(_lib/triviality.mjs)が /breaking|incompatible|migration|破壊的|非互換/i というキーワード正規表現を当てて、破壊的変更かどうかを判定していました。
この記事では、LLM が書いた自由文にキーワード正規表現を当てる品質ゲートがなぜ自己言及的に誤検知するのか、実際に起きた誤検知の中身と、検出入力を構造化フィールドへ移して直した設計を紹介します。
この記事で学べること
- LLM が生成した自由文にキーワード正規表現を当てる品質ゲートが、なぜ自己言及的な誤検知を起こすのか
- 実際にどんな文章が、どんな gate 発火を招いたか(具体例)
- 品質ゲートを改修する issue ほど誤爆しやすいという構造的な皮肉の理由
- 検出入力を「LLM の自由文」から「構造化フィールド」へ移す設計パターン
- 決定論 floor を後退させずに誤検知だけを消す OR 構成の作り方
前提: パイプラインの "breaking" 検出とは
このパイプラインには、変更の規模に応じて評価の深さを切り替える shape 判定(micro / standard / complex)があります。この shape 判定には「breaking change が疑われる場合は無条件で complex 扱いにする」という safety floor があり、真に破壊的な変更で floor が発火しなくなることは絶対に避けたい設計です(floor を緩めた瞬間、破壊的変更がすり抜けるリスクをそのまま引き受けることになるので)。
その floor を立てていたのが isBreakingText でした。analyze フェーズの LLM が生成した scope(変更範囲の説明)と summary(要約)という自由記述フィールドの文字列に、正規表現をそのまま当てる実装です。
/breaking|incompatible|migration|破壊的|非互換/i
シンプルで、実装コストも低い(そしてこの手の「とりあえず正規表現で」実装は、大体あとで牙を剥きます)。ただし弱点も一目瞭然で、LLM の書く日本語・英語の自由文には、この単語が「破壊的変更が起きた報告」以外の文脈でもいくらでも出てきます。
何が起きたか — 不変条件の説明文が、その条件自体を破った
実際に問題が起きたのは、まさに「このパイプラインの品質ゲートを改修する」issue の run でした。analyze フェーズは scope に、こう書きました。
「安全floor(欠落・out-of-enum・breaking→complex)を変更しないこと」
これは「今回の改修で、breaking 判定されたときに floor を complex に上げる仕組み自体は変更しない」という制約の説明であって、実際の変更が破壊的だという報告ではありません。ところがこの文中の「breaking」という単語一つに、先述のキーワード正規表現がそのままヒットしました。結果、shape=complex floor と merge tier の HOLD(人間が確認するまで merge 不可)が誤って発火しました。
つまり構造としては、「breaking floor を守れ」と書いたら、その文自体が breaking 判定されるという自己言及的な誤検知です。ゲートを守れと言っただけで、ゲートに撃たれる(律儀すぎるにも程がある)。
なぜ品質ゲート改修ほど誤爆しやすいのか
ここには構造的な皮肉があります。このパイプラインの運用語彙——breaking floor、migration、非互換の禁止、といった言葉——は、リポジトリの運用ルールや issue 本文、実装計画のあちこちに頻出します。そして「その語彙が頻出する場面」の代表格が、パイプライン自身や品質ゲートを改修する issueなのです。
言い換えると、品質ゲートの誤検知を直そうとする作業ほど、その作業を説明する文章自体が誤検知しやすい語彙で埋まっている。品質を守るための語彙が、品質ゲートの検出条件と重なってしまう。「LLM が書いた文章のキーワードを拾うだけの判定、実際どうなの?」と聞かれれば、この一件を見る限り答えは明確です。自分の尻尾を追いかける犬のような話で、この手の自己言及は「直そうとする対象が、直す作業そのものに紛れ込む」系のバグに共通する厄介さがあります。
HOLD の誤検知がなぜ看過できないのか
HOLD は「人間が必ず確認するまで merge しない」という、本来は月に数回光れば十分なはずが、誤検知が混じると毎回のように点灯するシグナルになりかねないものです。頻発すればレビューする人間の側は「どうせまた誤検知だろう」と流し読みするようになります。誤検知が混じるたびに HOLD への信頼がすり減り、本物の HOLD まで見逃されるリスクが上がる——いわゆる alert fatigue です。毎回鳴る非常ベルを、そのうち誰も振り向かなくなるのと同じ理屈です。
しかも今回のケースは、頻度がそこそこ高くなりやすい構造でした。「breaking 対策への言及」自体が誤検知の引き金になる以上、品質ゲート周りの改修が続く限り、同じ落とし穴に何度でも落ちます。放置すれば、HOLD は「そのうち誰も見なくなる警告」になっていたはずです。
修正方針: LLM の自由文から構造化フィールドへ
対策の骨子は、検出入力を「LLM が書いた自由文」から「LLM に構造化して答えさせるフィールド」に移すことでした。具体的には次の3点です。
- analyze の REQ スキーマに
breaking_change: booleanとbreaking_evidence: stringを追加する - LLM への指示に「変更が外部契約・公開 API・既存データ形式を非互換に変える場合のみ true。既存の breaking 対策・不変条件への言及は false」と明示する
isBreakingTextのキーワードスキャンは、issue のタイトルと本文(ユーザーが書いたテキストのみ)に対象を限定して残す
ポイントは3番目です。isBreakingText を完全に廃止するのではなく、LLM が生成する自由文(scope/summary)からは外すが、人間が書いた issue 本文には決定論的なフォールバックとして残すという判断をしています。LLM が構造化判定で見落とした breaking change でも、issue 側に「breaking」「非互換」といった言葉があれば floor が立つようにするためです。
決定論と確率的判定を安全に組み合わせるOR構成
実際のコードでは、shape floor は breaking_change === true || breaking_keyword_scan === true という OR 構成で発火します。
function classifyShape(req) {
// ...(estimated_change_file_count / acceptance_criteria / issue_type の
// floor 判定が先行する)
if (req.breaking_change === true || req.breaking_keyword_scan === true) {
const floor = 'complex';
const srcs = [];
if (req.breaking_change === true) srcs.push('analyze structured breaking_change=true');
if (req.breaking_keyword_scan === true) srcs.push('issue title/body keyword scan hit');
const reason = `breaking change detected (${srcs.join(' + ')}) → floor=complex`;
const shape = mergeShape(floor, req.shape);
return { shape, reason: shape !== floor ? `LLM raised ${floor}→${shape}` : reason };
}
// ...(以降は file count / AC 件数による通常の floor 判定)
}
breaking_change(LLM の構造化判定)と breaking_keyword_scan(issue 本文への決定論キーワードスキャン)のどちらか一方が true なら floor は complex に上がります。LLM 判定が false でも決定論スキャンが拾えば floor は立つし、逆に決定論スキャンが素通ししても LLM が構造化判定で拾えば floor は立つ。二重の網にすることで、「LLM 判定に一本化して精度を上げたつもりが、見落としで floor が後退する」という最悪の事態を避けています。
そして重要なのは、この変更で floor そのものは一切緩めていないということです。緩めたのは「何を入力として breaking を判定するか」であって、「breaking と判定されたら complex + HOLD にする」という決定そのものは変わっていません。誤検知率を下げる作業と、safety floor を緩める作業は別物で、混同すると本末転倒になります。
再発防止: 旧実装への参照ゼロをテストで固定化
直しただけでは、いずれ誰かが「元の実装に戻せば楽そうだから」と isBreakingText の呼び出しを scope/summary に対して復活させてしまうかもしれません。それを防ぐために、修正のテストコードには次のようなテストが追加されています。
// isBreakingText(旧 LLM 自由文 regex 実装)の参照が 0 件
test('isBreakingText: pipeline.js 内の参照が 0 件', () => {
assert.equal(countOccurrences(src, 'isBreakingText'), 0);
});
「LLM の自由文に対する isBreakingText の呼び出しが完全に消えていること」を、実装ファイルの文字列出現回数という機械的な方法で固定化しています。旧実装の完全除去を、レビュー担当者の記憶ではなくテストに保証させる形です(人間の記憶力に依存する設計は、大体どこかで裏切られます)。
注意点・Tips
- LLM の自由文にキーワード正規表現を当てる設計は、自己言及に弱い — 「〜してはいけない」「〜を守れ」という禁止・制約の説明文には、禁止したい単語そのものが高確率で登場します。検出対象がその単語を含む文章を書く可能性がある場面では、この落とし穴を疑ってください(今回がまさにそれでした)
- LLM には true/false + evidence を書かせる — 「breaking_change: boolean」だけでなく
breaking_evidence: stringを必須にすることで、LLM が理由なく true/false を返す事故を減らせます。理由を書かせる一手間が、判定の質を底上げします - 決定論と確率的判定はOR、AND どちらで組むか設計判断が要る — safety floor のように「見落としが致命的」な判定は OR(どちらかが引っかかれば発火)、誤検知の抑制を優先したい判定は AND(両方が引っかかったときだけ発火)が向きます。今回は blast-radius クラスの floor だったので迷わず OR にしました
- 一度も赤くならないテストは信用しない — 「旧実装への参照ゼロ」のような固定化テストは、追加した直後にわざと
isBreakingTextを呼ぶコードを書いてテストが落ちることまで確認するのが安全です
こうした品質ゲートの設計そのものを外部に相談したい場合は、AI開発の実装支援のようなスモールスタートから始めるのも一つの手だと思います。ゲートを1枚追加するだけでも、今回のような自己言及の罠は普通に踏みます。
まとめ
LLM が生成した自由文にキーワード正規表現を当てる品質ゲートは、検出したい単語そのものを含む「不変条件の説明文」に自己言及的に誤爆するという構造的な弱点を持ちます。今回のケースでは「breaking floor を守れ」という注意書きが、その floor 自体を誤って発火させました。
対策は、検出入力を LLM の自由文から構造化フィールドに移すことでした。
- LLM には
breaking_change: boolean+breaking_evidence: stringで構造化して答えさせる - 決定論キーワードスキャンは、人間が書いた issue タイトル・本文にだけ残す
- 両者は AND ではなく OR で結び、safety floor を後退させない
- 旧実装への参照ゼロを、テストで機械的に固定化する
品質ゲートを直す作業ほど、その品質ゲートに自分自身が誤爆されやすい——という皮肉な構造は、LLM の自由文に正規表現を当てる設計を採用している限り、どこでも再現し得ます。もし同じパターンのゲートを運用しているなら、一度「検出対象の単語を含む、無害な説明文」を意図的に流し込んで、誤検知しないか試してみることをおすすめします。



