はじめに
自分の運用を回している点検スクリプトが「NG 0件」と出したので、合格だと思って次の作業に進みました。
これを4日で4回やりました。4回とも、実際には検査が1行も読めていませんでした。
0件だったのは、欠陥が無かったからではなく、検査のほうが死んでいたからです。しかも4回とも、画面に出ていた文字は「正常に合格したとき」と1文字も違いませんでした。
この記事は、その4回を最小の再現コードに落として、シェルの偽陰性を4種類つくって実測した記録です。最後に、同じ穴に落ちないためのガードを1本のファイルに畳んで置いておきます。
目次
- 「0件」には親が2ついる
- 型1 — zsh は変数を単語分割しない(4シェルで
argcを実測) - 型1の続き — grep は正しく失敗していた
-
set -euo pipefailは止めてくれるのか(6つの書き方で実測) -
-qは、実装によってエラーを飲む - 残りの3型(別シェルの組込み/エラー本文/属性順に依存した式)
- 4型は、画面上で区別できない
- 直し方 — 0件を言う前に、3つ答えさせる
- 貼って使えるガード(コピペできる
guard.sh) - 自分の手元を数えてみた
- まとめ
この記事で測ったこと
-
zshとbashで、変数に詰めたファイル名リストの引数の数が変わること(4シェルで実測) -
grepがその状況で何を返して、何を返さないか(終了コード・標準出力・標準エラー) -
set -euo pipefailが止めてくれるのか(6つの書き方で実測。2つは止まりませんでした) -
-qを付けると grep の実装によってエラーを飲むかどうかが変わること - 残り3型(別シェルの組込み・HTTPエラー本文・属性順に依存した正規表現)の再現
- 4型が画面上で区別できないことの確認
- 陽性対照つきのガードを書いて、壊れた入力と正常な入力の両方で1回ずつ走らせること
測った環境(測っていないものも書く)
| 項目 | 実体 |
|---|---|
| OS | macOS 26.5(Darwin 25.5.0・arm64) |
| ログインシェル | zsh 5.9 (arm64-apple-darwin25.0) |
| 比較したシェル |
bash 3.2.57(1)-release / /bin/sh / dash
|
| grep 実装 |
BSD grep 2.6.0-FreeBSD(/usr/bin/grep)と ugrep 7.8.4
|
| 測っていないもの | GNU grep と ShellCheck。この環境にパッケージマネージャが無く導入できませんでした |
最後の行が、この記事の主題そのものです。GNU grep と ShellCheck については測っていないので書きません。唯一の例外は、GNU grep のマニュアルに明記されている終了ステータスの記述を引用する箇所で、そこは「実測」ではなく「文書の引用」と分けて書きます。
「0件」には親が2ついる
検査が 0 を返したとき、その 0 の出どころは2通りあります。
- 測って、欠陥が無かった(=合格)
- 測っていない(=対象を1件も読めていない)
この2つは意味が正反対なのに、画面に出る文字が同じです。NG 0件、新規なし、ヒット0。どれも「うまくいったとき」の見た目をしています。
合格より、未実測のほうが危ない
欠陥を見逃すだけなら、次の回で見つかる可能性が残ります。ところが検査が死んでいる場合、どんな入力を与えても必ず0です。次の回も0、その次も0。一生見つかりません。
さらに悪いのは、検査があると人が疑わなくなることです。検査が無ければ「まだ見ていないな」と思いますが、検査が「0件」と言うと、そこで確認が終わります。偽陰性を出す検査は、検査が無い状態より危険だと考えるようになりました。
だから「0」を印字させる前に、別の質問をさせる
対処の方向は最初から決まっています。0件と言う前に、「0件と言える経路が生きているか」を答えさせること。以下はその答え方を探した記録です。
型1 — zsh は変数を単語分割しない
いちばん多く踏んだのがこれです。既出判定のワンライナーを、こう書いていました。
F="a.md b.md c.md"
for s in "店A" "店B" "店C"; do
n=$(grep -c "$s" $F 2>/dev/null)
echo "$s → ${n:-0}件"
done
7項目を判定して7件すべて0。ところが対象の1つを単独で grep すると 58ヒットします。ファイルには確実に載っていました。
4つのシェルで引数の数を数える
引数をそのまま表示するだけのスクリプトを用意して、同じ1行を4つのシェルで走らせました。
# argc.sh
echo "argc=$#"
i=1; for a in "$@"; do echo " arg$i=[$a]"; i=$((i+1)); done
for sh in /bin/zsh /bin/bash /bin/sh /bin/dash; do
"$sh" -c 'F="a.md b.md c.md"; ./argc.sh $F'
done
| シェル | argc |
渡った1つ目の引数 |
|---|---|---|
/bin/zsh |
1 |
a.md b.md c.md(3つが1つのファイル名) |
/bin/bash |
3 | a.md |
/bin/sh |
3 | a.md |
/bin/dash |
3 | a.md |
zsh だけが分割しません。これは不具合ではなく仕様で、zsh のオプション一覧に SH_WORD_SPLIT として載っています。
Causes field splitting to be performed on unquoted parameter expansions.
つまりこの分割は明示的に有効化するもので、既定では行われません。実測でも確認できます。
[[ -o shwordsplit ]] && echo ON || echo "OFF(既定)" # → OFF(既定)
setopt shwordsplit; ./argc.sh $F # → argc=3
一方 POSIX のシェルの仕様と bash のマニュアルでは、引用符の無いパラメータ展開はフィールド分割されるのが既定です。bash の癖で書いた1行を zsh に貼ると、静かに意味が変わります。
なぜ macOS で踏みやすいのか
Apple の公開ドキュメントに、macOS 10.15 以降は zsh が既定のログインシェルであると書かれています。
Starting with macOS 10.15, your Mac uses zsh as the default login shell and interactive shell.
ところが世に出回っているシェル断片の多く、そしてGitHub Actions の既定シェルは bash 系です。書かれた場所と貼られる場所でシェルが違うので、この差は日常的に踏めます。
型1の続き — grep は正しく失敗していた
grep 側は何を返しているか
分割されなかった文字列は、a.md b.md c.md という1つのファイル名として渡ります。存在しないので、grep は失敗します。
| 実装 | 終了コード | 標準出力 | 標準エラー |
|---|---|---|---|
| BSD grep 2.6.0 | 2 | 空 | grep: a.md b.md c.md: No such file or directory |
| ugrep 7.8.4 | 2 | 空 | ugrep: warning: a.md b.md c.md: No such file or directory |
2実装とも、失敗はきちんと終了コード2と標準エラーで表明しています。POSIX の grep もそう定めています。grep は何も悪いことをしていません。
空の標準出力が、下流で 0 に化ける
問題は受け取る側です。-c を付けているので「0 が返ってくる」と思い込んでいましたが、返ってくるのは空文字です。
F="a.md b.md c.md"
N=$(/usr/bin/grep -c livepocket $F 2>/dev/null)
echo "N=[$N]" # → N=[] (空文字)
echo "$((N))" # → 0 (算術展開で0になる)
printf "NEW %d\n" "${N:-0}" # → NEW 0 (画面には正常な0が出る)
${N:-0} は「値が無ければ0」という善意の既定値です。それがここでは「測れなかったことを0件と報告する」機械に変わります。
2>/dev/null が最後の手がかりを消す
標準エラーには「そんなファイルは無い」と出ていました。それを消していたのが 2>/dev/null です。
「ノイズを消すため」に付けたものが、検査が壊れた唯一の証拠を消していました。以後、点検系のコマンドでは 2>/dev/null を使わないことにしました。
同じ型を2回続けて踏んだ理由
1回目に気づいたとき、原因を「引用符の付け忘れ」と書いて済ませました。症状を原因と取り違えたので、翌日また同じことをやりました。
「うっかり」に帰した教訓は、機械で落ちない形で残るので再発します。再現する原因まで書き切るまでは、終わっていません。
set -euo pipefail は止めてくれるのか
ここがいちばん知りたかったところです。「ガードを入れていれば気づけたのでは」という疑いがあったので、6つの書き方で同じ壊れた1行を包んで、スクリプトが止まるかどうかを測りました。
#!/bin/zsh
set -euo pipefail
G=/usr/bin/grep; F="a.md b.md c.md"; q=livepocket
case "$1" in
A) N=$($G -c "$q" $F); echo "A reached N=[$N]" ;;
B) if $G -q "$q" $F; then echo "B hit"; else echo "B reached: no hit"; fi ;;
C) N=$($G "$q" $F | wc -l | tr -d ' '); echo "C reached N=[$N]" ;;
D) N=$($G -c "$q" $F 2>/dev/null); echo "D reached N=[$N]" ;;
E) $G -c "$q" $F; echo "E reached" ;;
F) N=$($G -c "$q" $F) || N=0; echo "F reached N=[$N]" ;;
esac
結果 — 6つのうち2つは止まらなかった
| # | 書き方 | rc | 止まったか | 画面に出た文字 |
|---|---|---|---|---|
| A | N=$(grep -c … $F) |
2 | 止まった | (なし) |
| B | if grep -q … $F; then |
0 | 通過 | B reached: no hit |
| C | grep … $F | wc -l |
2 | 止まった | (なし) |
| D | N=$(… 2>/dev/null) |
2 | 止まった | (なし) |
| E | 素の grep -c … $F
|
2 | 止まった | (なし) |
| F | N=$(…) || N=0 |
0 | 通過 | F reached N=[0] |
set -euo pipefail は4つを止めました。役に立っています。
問題は、止まらなかった2つが、点検スクリプトで最もよく使う2つの形だということです。
- B=「既出かどうか」を見る
if grep -q。既出判定・重複チェックの定番 - F=「失敗したら0にしておく」
|| N=0。落ちないスクリプトを書くための定番
B は set -e の対象外です。条件式として評価された終了コードは、set -e が拾いません(bash マニュアルの set の節に、条件文脈で失敗したコマンドは対象外だと書かれています)。F は || で自分から握り潰しています。
つまり壊れたことを隠す2つの構文が、そのまま「検査らしい書き方」として広まっているわけです。
ガードを外すと、6つ全部が黙る
現場の実態に近い set +e でも測りました。
| 書き方 | A | B | C | D | E | F |
|---|---|---|---|---|---|---|
| rc | 0 | 0 | 0 | 0 | 0 | 0 |
| 画面 | N=[] |
no hit |
N=[0] |
N=[] |
(無言) | N=[0] |
6つ全部が終了コード0。つまりその場で打つワンライナーには、この型を落とす仕組みが何も無いというのが実測の結論です。
-q は、実装によってエラーを飲む
B がなぜ危ないかを追っていて、想定していなかった差に当たりました。
GNU grep のマニュアルに、こう書かれています(これは実測ではなく文書の引用です)。
However, if the -q or --quiet or --silent option is used and a line is selected, the exit status is 0 even if an error occurred.
-q を付けて1行でも当たると、エラーが起きていても終了ステータスは0になる。手元の2実装で測りました。
| 実装 |
-q・当たる+存在しないファイル |
-q なし・同じ状況 |
|---|---|---|
| BSD grep 2.6.0 | rc=0(エラーを飲む) | rc=2 |
| ugrep 7.8.4 | rc=2(飲まない) | rc=2 |
| GNU grep(マニュアル記載) | rc=0 と明記 | rc=2 |
同じスクリプトが、入っている grep によって違う答えを出します。
実害は「ファイル名を1つ打ち間違える」だけで出る
変数の話を抜きにしても成立します。3つのファイルを渡して、1つを打ち間違えた場合です。
set -euo pipefail
if /usr/bin/grep -q livepocket a.md b.md typo.md; then
echo "既出と判定"
fi
# → 既出と判定(typo.md は読めていない。set -e も止めない)
a.md に当たったので、BSD grep は typo.md が読めなかったことを終了コードに出しません。set -euo pipefail も止めません。「3ファイルを見た」と思っているのに、実際は2ファイルしか見ていない検査が、正常に合格したように振る舞います。
これに気づけたのは、以前に「可視だと思っていた要素が一度も発火しない」件を実測したときと同じ疑い方をしたからでした。0件を見たら、まず数え方を疑う。
残りの3型
型2 — 別のシェルの組込みを呼ぶ
監視スクリプトが毎回「NEW 0」を出していました。原因は print が zsh の組込みで、スクリプトを bash から起動していたことです。
NEW="alpha
beta"
n=$(print -r -- "$NEW" 2>/dev/null | grep -c .)
echo "$n" # → 0 (実際は2件)
| 起動シェル | 書き方 | 結果 |
|---|---|---|
| zsh | print -r -- "$NEW" | grep -c . |
2(正しい) |
| bash | 同じ1行(2>/dev/null あり) |
0 |
| bash | 同じ1行(2>/dev/null なし) |
0 + bash: print: command not found
|
| bash / zsh | printf '%s\n' "$NEW" | grep -c . |
2(両対応) |
ここでも 2>/dev/null が手がかりを消しています。そして grep -c . は空入力を渡されたら素直に0を数えるので、下流は何も気づけません。
型3 — HTTPエラー本文を、本文として流す
足切りツールが、除外すべき対象を「候補」と判定しました。原因は、同一ホストへの連打で 429 Too Many Requests(RFC 6585)が返り、エラー文の短い本文が保存されていたことです。
[ -s "$f" ] && echo "中身あり" || echo "空"
| ファイル | バイト数 | <title> |
[ -s ] の判定 |
|---|---|---|---|
| エラー応答 | 53 | (無し) | 中身あり |
| 正常な応答 | 2,568 | MEGA Pack BOX |
中身あり |
[ -s ] は「空でないこと」しか見ません。53バイトのエラー文は、空ではありません。HTTP 200 が返ったことも、本文が取れたことを意味しません。
型4 — 属性順に依存した正規表現
画像の点検ツールが「画像 0種 / NG 0件」と出しました。対象の画像は113種ありました。正規表現が属性の順番に依存していたのが原因です。
BAD = r'<img[^>]*src="([^"]+)"[^>]*width=' # src が先に来る前提
OK = r'<img[^>]*src="([^"]+)"'
| 入力 | 順序に依存した式 | 順序に依存しない式 |
|---|---|---|
<img src="a.jpg" width="640" …> |
1件 | 1件 |
<img width="640" … src="b.jpg"> |
0件 | 1件 |
HTML の構文仕様に、属性の順番の定めはありません。どちらの順で書かれても正しい HTML なので、出力側が順番を変えた日に、検査だけが静かに0になります。
4型は、画面上で区別できない
ここまでの4型を、それぞれ「現場で書いていた形」のまま走らせて、終了コード・標準出力・標準エラーを並べました。
| 型 | 画面に出る文字 | rc | 標準エラー |
|---|---|---|---|
| 型1 zsh の単語分割 | NEW 0 |
0 | 無 |
| 型2 組込みを別シェルで呼ぶ | NEW 0 |
0 | 無 |
| 型3 エラー本文を本文扱い | 対象外 判定完了 |
0 | 無 |
| 型4 属性順に依存した式 | NG 0 件 |
0 | 無 |
4型とも、終了コード0・標準エラー無し・画面には正常な0。
合格したときの出力と見分ける手がかりが1つもありません。「エラーが出ていないから大丈夫」という読み方が、ここで完全に崩れます。
直し方 — 0件を言う前に、3つ答えさせる
見た目で区別できないなら、区別できる情報を検査自身に出させるしかありません。3つに絞りました。
① 母数を必ず印字させる
「NG 0件」と「対象0件」は、画面上で区別がつきません。母数を並べて出せば、一目で分かります。
-
NG 0件 / 母数 113件→ 測って合格 -
NG 0件 / 母数 0件→ 測っていない
母数が0のときは「0件」と印字させず、未実測として異常終了させます。
② 陽性対照を当てる
実験でいう対照です。必ず当たると分かっている文字列を、本番の判定と同じ経路で1回測ります。それが0件なら、返ってきた結果ではなく経路を疑う。
これはミューテーションテストやカオスエンジニアリングと同じ考え方で、わざと当たるものを流して、検査が反応することを確かめるだけです。実装は数行で済みます。
取得失敗を、判定結果から分ける
戻り値を2値にしない
いちばん効いたのがこれでした。戻り値を2値にしないことです。
| 戻り値 | 意味 |
|---|---|
| 0 | 判定できた(合格) |
| 1 | 判定できた(不合格) |
| 2 | 判定できなかった(取得失敗・母数0・対照が死んでいる) |
2値だと、取得失敗が必ず「合格」か「不合格」のどちらかに丸められます。3値にすると、「測っていない」が独立した出口を持ちます。
HTTP 200 以外・本文が短すぎる・<title> が取れない、はすべて 2 に落とします。
対照には何を選ぶか
陽性対照は、選び方を間違えると意味がなくなります。実際に回してみて、条件が3つあると分かりました。
1つめは、判定と同じ経路を通ることです。別の関数やコマンドで確かめても、壊れているのは判定側の経路なので通り抜けます。対照は、判定に使うコマンド・オプション・引数の渡し方をそのまま使って測ります。
2つめは、消えない文字列であることです。特定の1行や特定の日付を対照にすると、その行が編集された日に対照だけが落ちて、経路が生きているのに警報が鳴ります。狙ってうるさい検査を作ることになるので、対象ファイルに何百回も出てくるような、構造の一部になっている文字列を選びます。
3つめは、件数を覚えておくことです。0件かどうかだけを見ると、経路が半分死んだとき、たとえば3ファイルのうち1つだけ読めていないときに気づけません。対照の件数を控えておいて、桁が変わったら疑うようにしました。
この4型に共通していたもの
4つの原因はまったく別物でしたが、後から並べると同じ性質を持っていました。どれも失敗を上流で表明していて、下流がそれを受け取らずに数値へ丸めていました。
grep は終了コード2を返していました。bash は command not found と言っていました。サーバーは429を返していました。HTMLは属性の順番を変える権利を仕様として持っていました。誰も黙っていません。
黙っていたのは、こちらの受け取り方です。空文字を0にする既定値、成否を握り潰す論理和、空でなければ中身ありとみなす条件、そして標準エラーを捨てる指定。4つとも、スクリプトを落ちにくくするために自分で入れたものでした。落ちにくいスクリプトを書こうとして、間違ったまま走り続けるスクリプトを書いていたことになります。
貼って使えるガード
ライブラリ本体
# guard.sh — 「0件」を信じてよいかを判定してから0件と言うための最小ライブラリ
# bash / zsh 両対応。source して使う。
# 戻り値: 0=判定できた(合格) / 1=判定できた(不合格) / 2=判定できなかった
GUARD_FAILS=0
# ① 母数を必ず出す。母数0なら「0件」ではなく「未実測」
guard_count() { # guard_count <母数> <NG件数> <ラベル>
local den="$1" ng="$2" label="$3"
if [ "${den:-0}" -eq 0 ]; then
echo " [未実測] ${label} — 母数0=測れていない"
GUARD_FAILS=$((GUARD_FAILS+1)); return 2
fi
echo " [測定済] ${label} — NG ${ng}件 / 母数 ${den}件"
[ "$ng" -eq 0 ] && return 0 || return 1
}
# ② 陽性対照。必ず当たる文字列が当たらなければ、経路が死んでいる
guard_canary() { # guard_canary <必ず当たる文字列> <file...>
local pat="$1"; shift
local n
n=$(/usr/bin/grep -o -- "$pat" "$@" | /usr/bin/wc -l | tr -d ' ')
if [ "${n:-0}" -eq 0 ]; then
echo " [CANARY NG] 経路が死んでいる(対照 ${pat} が0件)"
GUARD_FAILS=$((GUARD_FAILS+1)); return 2
fi
echo " [CANARY OK] 対照 ${n}件 → 経路は生きている"; return 0
}
# ③ 取得の成否を判定と分ける。短い本文・title無しは「取得失敗」
guard_body() { # guard_body <file> [最小バイト数]
local f="$1" min="${2:-2000}" b
[ -f "$f" ] || { echo " [取得失敗] ファイル無し: $f"
GUARD_FAILS=$((GUARD_FAILS+1)); return 2; }
b=$(/usr/bin/wc -c < "$f" | tr -d ' ')
if [ "$b" -lt "$min" ]; then
echo " [取得失敗] ${b}バイト < ${min}: $f"
GUARD_FAILS=$((GUARD_FAILS+1)); return 2
fi
/usr/bin/grep -q '<title>[^<]' "$f" || {
echo " [取得失敗] titleが無い: $f"
GUARD_FAILS=$((GUARD_FAILS+1)); return 2; }
return 0
}
# ④ ファイルは必ず配列で渡す。文字列に詰めたら止める(型1の直撃を防ぐ)
guard_files() { # guard_files "$FILES_STRING"
case "$1" in
*" "*) echo " [誤用] 複数ファイル名を1つの文字列に入れている(配列で渡す)"
GUARD_FAILS=$((GUARD_FAILS+1)); return 2;;
esac
return 0
}
guard_canary の中で 2>/dev/null を使っていないのは意図的です。対照が死んだときの標準エラーは、いちばん読みたい情報です。
呼び出し側は、配列で渡す
型1の直し方は3通りあり、どれも実測で argc=3 になりました。
| 書き方 | zsh | bash |
|---|---|---|
F=(a.md b.md c.md) → "${F[@]}"
|
3 | 3 |
F="a.md …" → ${=F}
|
3 | (zsh 専用) |
F="a.md …" → "${(@s: :)F}"
|
3 | (zsh 専用) |
両対応にしたいなら配列一択です(zsh のパラメータの節、展開の節)。setopt shwordsplit で bash に寄せる手もありますが、そのスクリプト全体の展開の意味が変わるので、点検スクリプトの都合で入れるものではないと判断しました。
逆性能テスト — 両方向で1回ずつ走らせる
ここを飛ばすと、ガード自体が偽陰性を出すようになります。過去に、追加したオプションを「渡さない側の入力」で一度も走らせておらず、必ず偽のNGを出すゲートを出荷したことがあります。以後、検査を書いたら2方向で走らせています。
壊れた入力(4型をまとめて通す)
source lib/guard.sh
F="a.md b.md c.md"
guard_files "$F"; guard_canary livepocket $F
guard_body err429.html; guard_count 0 0 "img属性順RE"
echo "GUARD_FAILS=$GUARD_FAILS"
[誤用] 複数ファイル名を1つの文字列に入れている(配列で渡す)
[CANARY NG] 経路が死んでいる(対照 livepocket が0件)
[取得失敗] 53バイト < 2000: err429.html
[未実測] img属性順RE — 母数0=測れていない
GUARD_FAILS=4
正常な入力(誤射しないこと)
[CANARY OK] 対照 2件 → 経路は生きている
[取得OK] ok.html
[測定済] 画像の幅/高さ属性 — NG 0件 / 母数 113件
GUARD_FAILS=0
| 入力 | zsh | bash | 期待 |
|---|---|---|---|
| 壊れた入力(4型) | GUARD_FAILS=4 |
4 | 4型すべてで鳴る |
| 正常な入力 | GUARD_FAILS=0 |
0 | 1件も鳴らない |
合格している入力で鳴る検査は、必ず無視されるようになります。鳴らないことの確認まで含めて、1本の検査だと考えています。
自分の手元を数えてみた
最後に、手元の点検スクリプトに同じ型がどれだけ潜んでいるかを数えました。
| 項目 | 実測 |
|---|---|
点検スクリプト(.sh) |
21本 |
| うち1行目が bash | 10本 |
| うち1行目が zsh | 10本 |
| うち1行目が sh | 1本 |
grep のファイル位置に引用符無しの変数を渡している行 |
0件 |
危ないのは、コミットされたスクリプトではなかった
意外だったのが最後の行です。ファイルとして残っているスクリプトには、型1が1つもありませんでした。
4回踏んだのは全部、その場で打ったワンライナーです。ファイルに書いたものはレビューされ、引数の渡し方も丁寧になります。ワンライナーは、書いた5秒後に結果だけを読んで捨てられます。
そして bash 10本・zsh 10本が混在していました。同じ1行が、貼る先のファイルによって意味を変える状態です。この混在が、bash の癖で書いた断片を zsh のファイルに貼る事故の温床でした。
落ちるようにした置き場
対処はスクリプト側ではなく、使い方に置きました。
- 点検の判定はワンライナーで済ませず、
guard.shをsourceするファイルにする - 点検系のコマンドでは
2>/dev/nullを使わない - 複数ファイルは必ず配列で渡す(文字列に詰めたら
guard_filesが落とす) - 「0件」を結論に使う検査は、非0のときに非0と出ることを1度だけ実測しておく
まとめ
- 検査の
0には「測って合格」と「測っていない」の2つの親がいて、画面上は同じ -
zshは引用符の無い変数を単語分割しない(SH_WORD_SPLITが既定で無効。実測argc=1vs bashargc=3) - そのとき grep は終了コード2・標準出力は空を返すので、受け側で
${N:-0}を通るとNEW 0になる -
set -euo pipefailは6つの書き方のうち4つを止めたが、if grep -qと|| N=0は通した。この2つが点検スクリプトで最頻の形 -
-qは実装差がある。BSD grep は当たればエラーを飲み(rc=0)、ugrep は飲まない(rc=2) - 型2〜型4(別シェルの組込み・エラー本文・属性順に依存した式)も、4型そろって rc=0・標準エラー無しで区別できない
- 対処は3つ。母数を印字する/陽性対照を当てる/取得失敗を判定から3値で分ける
- 検査を書いたら壊れた入力と正常な入力の両方で1回ずつ走らせる(実測
GUARD_FAILS=4と0)
いちばん残ったのは、偽陰性を出す検査は、検査が無い状態より危険だという感覚です。無ければ人が疑いますが、あると信じてしまいます。
「検査を通した結果を報告に書く」という自分のルールは、検査が本当に動いていることを前提にしていました。その前提を測る手段を持っていなかったのが、4回繰り返した理由でした。
同じ「数えているつもりで数えていない」型を、ブラウザの仮想リストで取りこぼす件と書いたのに画面に出ていない文章の件でも実測しています。
次にやること
手元の点検スクリプトを1本だけ選んで、そこに guard_canary を1行足して試してみてください。必ず当たる文字列を1つ決めて、判定と同じ経路で数えさせるだけです。対照が0件と出たら、その検査は今まで何も測っていなかったことになります。私は4本のうち4本がそうでした。
自分の事業の自動化は、全部この手の点検スクリプトで回しています。「動かしているつもりで回っていない」状態の切り分けを、AI業務自動化の90分スポット相談で受けています。






