0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

誤検出する検査は必ず無視される。自作の静的検査を1日に4回作り直してわかったこと

0
Last updated at Posted at 2026-09-05

AIエージェントに大量のコードを書かせている。人のレビューは追いつかないので、機械で止めるゲート(静的検査)を足していくことになる。

ところがある日、自分で作った画面について「背景とほとんど同じ色で見づらい」と指摘された。CSSの静的検査は、2026-09-05 時点で NG 0件で通っていた。見ていなかったのは、キャンバスに描く色だった。

そこで検査を書いた。そして同じ日に4回作り直した。4回とも理由は同じで、通報が多すぎて使い物にならなかったからだ。

この記事は、その作り直しの記録と、最後に落ち着いた設計原則をまとめたもの。結論を先に書くと、検査の価値は「見つける力」ではなく「黙る力」で決まる。適合しているものが鳴る検査は必ず無視されるようになり、無視された検査は、無い検査とまったく同じ働きしかしない。

前景と背景が同じ色でコントラスト比1.00対1だった箇所の図解

目次

1. 事故そのもの

1-1. 前景と背景が同じ色だった

作っていたのは、稼働状況を俯瞰するダッシュボード風の画面だった。背景や什器はHTMLで組み、その上に乗るオブジェクトだけを canvas に描いていた。指摘された箇所のパレットは、要約するとこうなっていた。

var P = {
  floorA: '#2b3442',   // 床
  floorB: '#2b3442',
  cat:    '#2b3442',   // 床の上に描くオブジェクト
};

前景と背景が同じ値なので、コントラスト比は 1.00:1。目で見て気づかなかったのが不思議に思えるが、縁のハイライトだけがうっすら乗っていたので「そこに何かある」ことは分かる。分かるが、探さないと見つからない。指摘の言葉が「見づらい」であって「消えている」ではなかったのは、そういう状態だったからだ。

自分が悪かったのは、色をその場の感覚で決めて、背景との関係を一度も測らなかったことだった。数値で決められるものを感覚で決めると、こうなる。

1-2. なぜ既存の検査を素通りしたのか

そのとき使っていた検査は、CSSの font-sizebackground / color を読んで、文字サイズ・コントラスト・タップ標的の大きさを見るものだった。以前に、AIに書かせたHTMLとCSSを168ファイル分まとめて測って作ったものだ(そのときの実測は別記事に書いた)。

素通りした理由は単純で、キャンバスに描く色はCSSではないからだ。fillStyle に入る文字列はJavaScriptの変数であって、CSSのプロパティとしてはどこにも現れない。検査は正しく動いていた。ただ、対象の外を走っていた。

同じ型の見落としは、そのあと別の形式でも出た。

持ち込んだ表現 既存の検査が見ていたもの 実際に起きたこと
canvasfillStyle CSSの color / background 背景と同色のオブジェクトが素通り
インラインSVGの <text font-size="14"> CSSの font-size 宣言 属性は走査対象外。しかも縮尺後は実効11.7px
transform: scale() の内側 宣言された数値 書いた値と描かれた値が食い違う

一般化するとこうなる。自分で新しい表現形式を持ち込んだら、既存の検査はそれを見ていないと考える。 キャンバスに描いた文字が化けたときも、SVGの注記が右端で切れていたときも、原因は同じだった。表現を1つ増やしたら、それを機械で全数測る手も1本増やす。ここが今回の出発点になった。

1-3. まず逆から作った

検査を書くとき、最初にやったのは「わざと壊した入力で鳴るか」の確認だった。同じ色を2つ並べたパレットを食わせて、ちゃんと1.00:1が出ることを見る。ここは問題なく通った。

問題は、その逆をやっていなかったことなのだが、それに気づくのは4回目の作り直しのあとになる。

先に断っておくと、コントラスト比という同じ判定を、キャンバス側とCSS側の2本のスクリプトに分けて実装していた。以下の作り直しは、その2本にまたがっている。別々に書いた2本の検査に、同じ穴が空いていたというのが、この記録のもう一つの中身になる。

2. 総当たりで28件出した1版

検査そのものはすぐ書けた。難しかったのは何を対象にするかで、そこを繰り返し間違えた。

静的検査を4回作り直した記録。28件、注意4件、大量の誤検出を経て198ページ中3件になった表

出た件数 何を間違えたか
1版 全色 × 全背景で 28件 什器の上に描く色まで床と比べた(適用範囲を広げすぎ)
2版 適合している色4件を「注意」で誤射 推奨帯を合否ティアにも掛けた
3版 全サイト掃引で「1.0:1 不可視」が大量 rgba(...,.12) を不透明色として読んだ
4版 暗いテーマのページで誤検出 半透明のトークンを「下地」に採用した
確定版 198ページ中 3件 下地が確定できないときは判定を見送る

2-1. 直す価値があったのは1件だけだった

最初の実装は、パレットの全色と全背景色を総当たりで比較した。結果は2026-09-05の実測で28件。このうち直す価値があったのは1件だけだった。

残り27件は、たとえば machHi(機械の上に乗せるハイライト)と floorA(床)の比較のように、そもそも重ならない色どうしの比較だった。機械のハイライトが床の上に描かれることはないので、この比率には意味がない。人が見れば0.5秒で分かる。ツールには分からない。

Googleが社内の静的解析について書いた文章では、コードレビューに出す検査の実効誤検出率(effective false positive rate)を10%未満に保つ、という基準(出典は下記)が置かれている。「実効」というのは、ツール作者から見て正しいかどうかではなく、開発者がそれを見て何も行動しなかったものは誤検出として数える、という考え方だ(Software Engineering at Google 第20章、および論文の要約ページ。いずれも2026-09-06に閲覧)。

この基準(出典=上記2件・2026-09-06 閲覧)で自分の1版を採点すると、27/28で 96% が実効誤検出になる。10%未満どころか桁が違う。開発者が静的解析ツールを使わなくなる理由を20人にインタビューした2013年発表の研究でも、誤検出と警告の見せ方が最大級の障壁として挙げられている(Johnson et al., ICSE 2013 の論文PDF)。自分は、その障壁を自分の手で作っていた。

3. 合否の混同と、色の読み方で3回間違えた

3-1. 2版は合格しているものを鳴らした

対象を絞り直したら、件数は8件まで減った。今度は別の失敗をした。「できれば7:1あるとよい」という推奨帯を、合否の判定にも掛けてしまったのだ。

結果、4.5:1を満たしていて合格している色が4件、黄色の「注意」として出力された。

これは件数以上に悪い。合格しているものが出力に混ざると、人は上から下まで読まなくなる。「注意は無視してよい」という習慣がつくと、その習慣は本物のNGにも適用される。 最初に事故った経路、つまり「出た警告を誰も読んでいない」状態を、検査を作ることでもう一本増やしていたわけだ。

3-2. 3版は rgba を不透明色として読んだ

対象と閾値が固まったので、他のページにも掛けてみた。すると「1.0:1 不可視」が大量に出た。

原因は色のパーサだった。rgba(47, 158, 106, .12) のような色からアルファ値を捨てていたので、12%の不透明度で薄く敷いた緑を「べったりした緑」として読み、その上の同系色の文字と比べて「同色だから不可視」と判定していた。実際には、半透明の背景は下地と合成された色になる。比較する相手が違っていた。

直した実装がこれ。アルファを持ち回り、合成してから輝度を出す。

def parse_color(c):
    """(r, g, b, alpha) を返す。alpha が 1 未満なら半透明。"""
    m = re.fullmatch(
        r'rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)\s*(?:[,/]\s*([\d.%]+))?\s*\)', c)
    if not m:
        return None
    a = m.group(4)
    al = 1.0 if a is None else (float(a[:-1]) / 100 if a.endswith('%') else float(a))
    return (int(float(m.group(1))), int(float(m.group(2))), int(float(m.group(3))), al)

def over(fg, base):
    """半透明色 fg を下地 base の上に合成した実効色を返す。"""
    r, g, b, a = fg
    if a >= 0.999:
        return (r, g, b)
    return tuple(round(a * c + (1 - a) * d) for c, d in zip((r, g, b), base))

rgb() の記法は今日ではスペース区切りとスラッシュ区切りも許されている(MDN: rgb())ので、正規表現側でどちらも受けるようにした。

3-3. 4版は半透明のトークンを下地にした

合成するには下地が要る。ここで4回目の間違いをした。下地を探すとき、CSS変数の名前を優先順に見て最初に見つかったものを採用していたのだが、そのリストに --panel が入っていた。

:root { --panel: rgba(255, 255, 255, .055); }

これを下地として採用すると、半透明の白を「白」として読むことになる。暗いテーマのページで「下地は真っ白」という前提のまま全部の比率を計算したので、軒並み誤検出になった。

面白いのは、これが3版で直したのと同じバグだということだ。半透明を不透明として扱う、という間違いを、パーサでは直したのに下地の選定では作り直していた。同じ症状を2回手で直したら、それは運用ミスではなく設計の穴だと考えたほうがいい。

3-4. 確定版は198ページ中3件

最終的にこう直した。

# 下地は body / html の「不透明な」背景を最優先で探す
base = None
for sel in (r'body', r'html'):
    m = re.search(sel + r'\s*\{[^}]*?background(?:-color)?\s*:\s*([^;}]+)', src, re.I)
    if m:
        c = parse_color(resolve_var(m.group(1)))
        if c and c[3] >= 0.999:      # 半透明は下地にしない
            base = c[:3]
            break

そして下地が確定できなかったときは、比率を出さずに判定を見送ることにした。ここが一番効いた。推測で下地を決めて鳴らすくらいなら、黙って見送るほうがいい。

手元のHTML198ページに掛けた結果は、2026-09-06の実測でNG 3件。3件とも実物を開いて確認し、3件とも本物だった。

4. 誤検出が出る3つの原因

4回分の失敗を並べてみると、原因は3種類に収束した。新しく検査を書くときは、この3つを先に潰しておけばだいたい足りる。

誤検出の3つの原因。適用範囲の広げすぎ、推奨と合否の混在、入力の前提未確認

4-1. 適用範囲を広げすぎる

一番多いのがこれ。閾値そのものは正しくても、それを掛けてよい対象の定義をサボると誤検出になる。

コントラストの基準でいえば、WCAG 2.2 の「非テキストのコントラスト」は「情報を伝える視覚要素」の下限であって、純粋な装飾には掛からないことが明記されている。床の塗り、什器の面、オブジェクトの模様は装飾で、縁のハイライトがあれば形は分かる。ここに3:1を要求すると、模様を消すしかなくなる。実際、2版で作りかけたのはそういうツールだった。

判断基準は1行で書ける。それが見えないと情報が失われるか。 失われるなら対象、失われないなら対象外。迷ったら対象外にしておいて、事故が起きたときに1件ずつ足すほうが結果的に速い。

4-2. 推奨と合否を混ぜる

「あるとよい」と「無いと落とす」を同じ出力に混ぜると、出力全体が「あるとよい」の色に染まる。推奨帯は、合否とは別のコマンド、あるいは別のフラグに分ける。既定の実行では合否だけを出す。

ESLintの設定off / warn / error を明確に分けているのも同じ発想だと思っている。warn を大量に出すルールセットは、結局 error まで読まれなくなる。

4-3. 入力の前提を確かめない

rgba() のアルファ、CSS変数、color-mix()、Tailwind v4 が吐く oklab()。パーサが読めない記法を「読めた」ことにして計算すると、堂々と間違った数字を出す。

読めなかったときに返すべきはNGではなく「判定不能」だ。ここを分けていないと、パーサの守備範囲が狭いことが、そのまま誤検出の量になる。

5. 落ち着いた設計

5-1. 対象はツールに探させず、コード側に宣言させる

総当たりをやめて、何の上に描く色かをHTML側のコメントで宣言させる方式にした。

<!-- 左が前景、右が「その下にある背景」 -->
/* PALETTE-AUDIT: cat,skin on floorA,floorB,lounA,lounB */
/* PALETTE-AUDIT: screen on monOn */

読み取り側はこれだけ。

def grab(tag):
    return re.findall(r'PALETTE-AUDIT' + tag + r':\s*([\w,\s]+?)\s+on\s+([\w,\s]+?)\s*\*/', src)

decls = [(f, b, 4.5, 4.5) for f, b in grab('')] \
      + [(f, b, 7.0, 7.0) for f, b in grab('-FINE')] \
      + [(f, b, 3.0, 4.5) for f, b in grab('-ADJ')]

if not decls:
    print('NG: PALETTE-AUDIT の宣言が無い')
    sys.exit(2)

宣言のコストは1行。得られるものは大きくて、「これは対象か」という議論が全部消える。書き手が「見つけてほしいもの」を宣言し、検査はそれだけを見る。宣言が1件も無ければ、それ自体をNGにする。宣言を書き忘れたまま黙って通ることはない。

この方式には副作用もあって、宣言が嘘だと検査も嘘になる。ただ、嘘の宣言は書いた本人が読めば分かる。ツールが勝手に推測した対象より、はるかに直しやすい。

5-2. しきい値はティアで分ける

しきい値を4.5対1、7対1、3対1の3ティアに分けた表と、対象をコード側に宣言させる書き方

宣言 対象 閾値 根拠
PALETTE-AUDIT: 標準サイズの重要な視覚要素 4.5:1 標準サイズにはSC 1.4.3相当を要求する
PALETTE-AUDIT-FINE: 1〜2pxの細部 7:1 image-rendering: pixelated は縮小時に平滑化される
PALETTE-AUDIT-ADJ: 隣接オブジェクトの区別・輪郭 3:1 SC 1.4.11の非テキスト下限

ティアを分けたのは緩めるためではない。逆で、標準サイズの要素に3:1を許していたのが最初の穴だった。問題のオブジェクトは375px幅の画面で12.9 × 8.6 CSSピクセルしかなく、「大型の視覚要素」ではまったくなかった。

5-3. 判定できないときは鳴らさずに見送る

未判定は、NGにもOKにもしない第3の状態として持つ。件数は表示するが、終了コードには載せない。

これをやらないと、パーサを1つ拡張するたびに誤検出が増える、という妙な事態になる。読めない入力を黙って捨てるのは怖いが、読めない入力を読めたことにするほうがずっと怖い

5-4. 出力は通らないものだけにする

if ng:
    print(f'NG {len(ng)} 件(背景に埋もれて見えない)')
    show(ng, 'NG  ')
if not a.fail_only:
    print(f'OK {len(ok)}')       # 既定では出さない
sys.exit(1 if ng else 0)

--fail-only を実質の既定にして、合格したものは1行も出さない。終了コードはNG件数に対応させ、CIから使えるようにする。

出力が空であることが「合格」の表現になる。これは地味だが効く。画面に何も出ない検査は、出たときだけ読まれる。

6. 束ねたら壊れた検査を混ぜた

ここからは同じ日にやったもう1つの失敗。上の教訓を書いた数時間後に、自分でその型を再生産した話なので、恥ずかしいが一番役に立つと思う。

6-1. 出荷ゲートを1コマンドにまとめた

検査が増えると「今回はどれを掛けるべきか」を毎回判断することになり、その判断が飛ばす口実になる。判断を無くせば飛ばせないと考えて、7項目を1本のシェルスクリプトに束ねた。

bash tools/ship_check.sh <ファイル> [--repo <gitリポジトリ>] [--url <公開URL>]

CSS検査、キャンバス色検査、記事品質検査、個人情報のgrep、公開してはいけない名前のgrep、コミット著者の検査、375px実描画、の7つ。最後にNG件数を出して終了コードにする。

動機は素直なもので、その週に2回、自分で作ったゲートを自分で通さずに出荷していたからだった。ルールが足りなかったのではなく、ルールを通す手順が毎回判断制だったのが原因だと考えた。

6-2. その7番目が常に偽のNGを出していた

7番目は「375px幅での実描画を測る」項目で、既存の計測スクリプトを node で呼んでいた。ところがそのスクリプトは、ブラウザの開発者コンソールに貼って動かす前提のコードで、1行目が window.__A = function ... から始まっている。node から実行すれば当然こうなる。

ReferenceError: window is not defined

つまり --url を渡すたびに、必ず偽のNGが1件出る検査になっていた。

出荷ゲート7項目のうち7番目だけが常に偽のNGを出していた出力例

危ないのは、この失敗が「NG」という、いかにも正しそうな形で出てくることだ。検査が壊れていることに気づけない。そして数時間前に、自分でこう書いていた。

適合しているものが鳴る検査は、必ず無視されるようになる。無視された検査は、無い検査と同じ。

出荷ゲートを作った行為そのものが、ゲートを飛ばす習慣の原因になりかけていた。

6-3. 通る入力で黙るかを試していなかった

原因はテストのしかたに穴が1つあったこと。

  • わざと壊した入力を食わせて、鳴ることを確認する(逆性能テスト)← やっていた
  • 正常な入力を食わせて、黙ることを確認する ← やっていなかった

7番目の項目は、一度も「合格する状態」で実行されていなかった。検査は両方向で試さないと意味がない。そして束ねたツールは、1本壊れると束ね全体が信用を失う。7項目のうち6項目が正しくても、毎回出る偽NGが1件あれば、人は出力全体を読み飛ばすようになる。

6-4. 直し方は「まだ通していない」と正直に出すこと

確定版では、7番目の node 実行をやめた。代わりに手順だけを表示して、手動扱いにしている。

hr "7. 375px 実描画"
if [ -n "$URL" ]; then
  printf '   - 手動: ブラウザのコンソールに design_probe.js を貼り、\n'
  printf "     JSON.stringify(await __probe375('%s')) の結果を報告に書く\n" "$URL"
else
  printf '   - 未実施(実機で確認して報告に「未実施」と書く)\n'
fi

偽の合格も、偽の不合格も出さない。「まだ通していない」と正直に出すのが正解だった。自動化できない項目を無理に自動化した形にすると、そこが一番先に腐る。

7. まとめ

検査を書くときのチェックリスト8項目

一連の作り直しから固定した手順が、最終的にこの8つになった。

  1. 対象を先に決める。「それが見えない、あるいは無いと情報が失われるか」で線を引き、線の外は測らない
  2. 対象はツールに推測させず、コード側に宣言させる。宣言が無ければNGにする
  3. 推奨と合否を混ぜない。既定の出力は合否だけにする
  4. 入力を読めなかったときは「判定不能」を返す。読めたことにして計算しない
  5. 逆性能テスト(壊れた入力で鳴る)を先にやる
  6. 通る入力で黙ることを必ず確かめる。ここを飛ばすと必ず騒がしくなる
  7. 鳴った件は、報告する前に1件ずつ実体を見る。実物を見て初めて誤検出だと分かることが多い
  8. 他人のスクリプトを呼ぶ検査は、その実行系で動くかを1行目で確かめる

一番言いたいことをもう一度書くと、検査の価値は「見つける力」ではなく「黙る力」で決まる。見つける力だけを上げると、誰も読まない出力が増えて、結果として検査が無かったのと同じ状態に戻る。自分は同じ日にそれを4回やった。

見つける力だけを上げた検査と、黙る力を上げた検査の対比

AIエージェントを並走させて開発している人ほど、この問題は早く来ると思う。人間が書く量では出力の総量が知れているので、少々うるさい検査でも読めてしまう。エージェントが1日に何十ファイルも触り始めると、うるさい検査は初日で読まれなくなる。エージェントを並走させたときに何が壊れたかはこちらの記事に書いた。

次にやることを1つだけ挙げるなら、いま持っている検査を、正常なファイル1本に掛けてみてほしい。何も出なければ合格。1行でも「注意」や「情報」が出たなら、その検査はいずれ読まれなくなる側にいる。


同じような「AIに書かせたコードを、人手を増やさずに機械で止める」設計を仕事として引き受けています。監査・ゲート設計・運用の伴走の窓口はこちら → 制作・自動化のご相談(毎日ラボ)

本文の数値は、すべて自分の環境での実測値です(2026-09-05〜09-06時点)。198ページの掃引結果もNG 3件も、他所で再現される保証はありません。再現に必要なコードは本文にそのまま載せてあります。

0
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?