FULLALLGOL 技術ノート

検査は「誤検出」ではなく「空振り」で壊れる

2026-08-31

ガードの失敗には 2 種類ある。誤検出(本物でないものを赤くする)と、
空振り(守りたかった状態が壊れているのに緑のまま)だ。

誤検出はうるさいので必ず気づく。放っておくと開発が止まるから、その日のうちに直る。
空振りは違う。「緑だから安全」に化けるので、永久に気づかない。
しかも「テストを足した」という事実が、安全の証拠として誤って読める。

ここ数か月で踏んだ空振りを並べる。全部、書いた本人が「守れている」と思っていたものだ。

位置関係を「文字列の距離」で見ると、別の場所に当たる

「この代入は、必ず権限チェックの内側にあること」を検査したかった。実装はこう書いた。

body.lastIndexOf("scope.level === 'all'", assignmentIndex) > 0

これは同じ関数の別の場所にある var options = scope.level === 'all' ? … : [] に当たった。
つまり、権限チェックの外に置いた代入をそのまま通した。門を実際に外して走らせるまで気づけない。

同じ病気を、別の顔で 3 回繰り返した。

書き方どう外れるか
lastIndexOf(目印, 位置)手前にある無関係な一致に当たる
/function f\(\)[\s\S]*?return null;/遅延マッチが手前の早期 return で止まり、目的の catch 節を一度も見ない
body.slice(body.indexOf(目印), +200)文字数の窓が隣の分岐を巻き込む

3 つとも「関数の中に在るか」を距離で見ており、「どの分岐に在るか」を見ていない。
2 番目は catch 節を return []; に壊しても緑のままだった。

位置関係を見たくなったら、まず範囲を作る。 波かっこの対応で本体を切り出す小関数を書き、
インデックスがその区間に入っているかで判定する。
そしてその小関数自身にも空振り検査を 1 本付ける
blockAfter("if (a) {x} else {y}") が else を含まないこと)。

そもそも走っていない ── 一番強い空振り

あるアプリの CI が、テストの実行行を 1 本だけ持っていた。

- run: node --test "tests/*.test.mjs"

この glob はサブディレクトリを辿らない。 tests/acceptance/ の 3 本=**148 件が
CI で一度も走っていなかった**。ローカルで実行したときだけ緑になるので、
「通っている」という記憶だけが残る。

他のアプリは引数なしの node --test(再帰探索)だったので、
ワークフローを横並びで読んでも差に気づけない形をしていた。

前節までは「走ったが見ていなかった」。これは「走っていない」だ。
中身をいくら強くしても効かない。

書いたテストが CI の実行対象に入っていることを、テストで検査する。
tests/***.test.mjs を再帰的に数え、ワークフローの glob を集め、
どの glob にも当たらないファイルを落とす。あわせてジョブの起動条件(paths:)に
ソースとテストの両方が載っていることも見る。条件の取りこぼしはジョブごと丸ごとスキップする。

さらにその判定関数 matches(glob, path) は、一度も直接テストされていなかった
return true; に壊しても上位のテストは全て緑だった ── 取りこぼしが 0 件と報告されるだけだからだ。
単体テストを 6 本足し、6 通りに壊して全て赤を確認した。副産物として
? がエスケープも変換もされず素通りしていたことが分かった。素の ? は正規表現で
「直前を省略可」なので、当たっていないものを「当たった」と数える=空振りの向きへ倒れる実装ミスだ。

0 件で緑は、合格ではない

シートのセル型を監査するツールが、名前から仕様を引けなかったら continue で飛ばす作りだった。
レポートは 走査したシート: N / 指摘 合計: 0件 としか出さない。
見なかったものは報告に現れないので、0 件を「全部きれい」と読んでしまう。
実際、前回の実行結果「0 件」をそのまま記録として残していた。過去の 0 件は無効だ。

どちらにも無い名前は未分類として NG で報告し、指摘としても数える

はじめて 0 件が読める数字になる。件数だけを出す報告は、範囲が縮んだときに黙って良くなる

フィルタを「正常系の語」で書くと、検査対象が母集団から消える

端末認証を強制する前に、「稼働中かつ未承認の端末」を数えた。稼働判定に
ログイン履歴の result = 'success' を使ったところ、締め出し対象 0 台と出た。

嘘だった。登録処理は未承認端末のログインを pending で記録する
success で絞ると、まさに数えたかった未承認端末だけが母集団から消える
実際は 12 台・6 名で、うち 1 名は承認済み 0 台=強制した瞬間に完全にログイン不能になるところだった。

矛盾は同じ表の中に出ていた。端末の「最終アクセス」が今日なのに「最終成功ログイン ―」。
使われている証拠と、使われていない結論が同居していた。

絞るなら絞って落ちた件数を必ず出すresult の内訳
{success: 1208, pending: 391} を出した瞬間に嘘が割れた

同じ調査でもう 1 つ踏んだ。REST 経由の取得が 1 リクエスト 1000 行で黙って打ち切られる
limit=5000 と書いても 1000 行しか来ず、履歴 1599 行のうち 599 行が静かに欠けていた
エラーも警告も出ない。⇒ ページングして、取得件数を出力に書く
API の暗黙上限は空振りの代表的な発生源で、件数を印字しない集計は信じられない

検査の目を潰すのは、たいてい自分が書いた散文

if f.name == Path(__file__).name: continue と書いた結果、
そのテスト自身の docstring に例として書き写した当の文字列を見逃した。
検査対象から自分自身を外すと、自分が生んだコピーが見えなくなる

原因はセレクタ列の直前に添えた // … は属性名の完全一致なので … という注意書き
抽出範囲にコメントが入り、コメントの中の文字列が当たっていた

ブロックコメント内の識別子を「復活している」と誤検出した。ここで
/\*[\s\S]*?\*\//g の一括除去に直すと、文字列や正規表現の中の /* を食って本物のコードを消す
=誤検出が空振りへ化ける

「うるさいから緩める」修正は、必ず空振りの方向へ進む。
緩めるときは、緩めた側が空振りにならないかを先に確かめる。

許可リストを「含むか」で書かない

外部への送信先ホストを検査する 1 行を、シェルでこう書いた。

[[ $v != https://*example.com/* ]] && exit 1

実測で、通してはいけない値が 3 つとも通った。

なぜ通るか
https://evil.com/example.com/xパスに置けば部分一致する
https://notexample.com/message/1接尾辞として部分一致する

ホスト名は「含まれるか」ではなく「ホスト部そのものか」 ── 先頭と終端の両方を留める
^https://([A-Za-z0-9-]+\.)*example\.com(/|\?|#|$))。両端に * を置いた glob は許可リストにならない。

嫌な点は、同じ穴を自分で塞いだ翌日に、別のファイルで開けたことだ。
前日に別の通知先について前方一致を強制し、テストまで書いていた。
「この種の穴を知っている」ことは、次に書くコードで避けられることを意味しない。

否定ベクタが、配列展開で 1 件だけ消えていた

「通してはいけない値」を並べる検査を書いた。

bad=( "短すぎる" "接頭辞が違う" "URLが混ざる" "" )
for v in $bad; do …; done

zsh のクォート無し配列展開は、空文字の要素を黙って落とす。

a=("x" "" "y")
for v in $a        → [x] [y]        ← 消える
for v in "${a[@]}" → [x] [] [y]

4 件目(空文字)は一度も判定関数に渡っていなかった。将来この検査が空文字を通すように壊れても、
自己テストは緑のままだ。空振りを塞ぐために書いた検査自身に、同じ空振りが入っていた。

気づけた唯一の手がかりは件数だった。宣言は 4 要素なのに、出力の「拒否」は 3 行しか出ていない。
⇒ 否定ベクタを持つ検査は、ベクタ数と出力の判定行数が一致することを人が読める形で出す

規律

  1. 欠陥を戻して赤を見るまでガードを信じない。 新しいガードを足したら、必ず一度

「守りたかった状態」を壊して落ちることを確認する

  1. 前提が変わったらガードごと見直す。 検査を避けるために変数名を変えてはいけない

(危険な代入がガードの目から静かに消える)

  1. 母集団を明示して数える。 assert checked > 0 を置く。0 件で緑は合格ではない
  2. skip は「安全」ではなく「測れていない」。 理由をメッセージに書く
  3. 位置関係は範囲で見る。 距離・遅延マッチ・窓は全部同じ穴
  4. 否定ベクタを持たない検査は、検査ではない

最後に一段上げた言い方をしておく。

「テストが緑」は「回した分が緑」でしかない。
母集団(何本あるか)と実行集合(何本回したか)が一致していることを、別に言う必要がある。