CI や自動テストの実行結果がすべて「緑(PASS)」に染まっているとき、開発者はシステムが安全に守られていると信じがちです。しかし、テストが成功しているという事実は、必ずしも「対象が正しく検証された」ことを意味しません。

特にシェルスクリプトや CI パイプラインにおいて、検査の前提となる 基準値(しきい値や cutoff 日時)の動的な算出 に失敗し、変数が空文字列のまま後続の比較式に渡された場合、検査コード自体は正常終了のステータスを保ちながら、検査対象を一件も捕捉しない「射程ゼロ」の状態に陥ることがあります。

いわば 空虚なテスト合格(vacuous pass) の状態です。検査ロジックの前提条件や評価基準が消失したことで、どのような異常データが入力されても無条件に「合格」と判定されてしまい、テストの防波堤としての役割が完全に消滅している状態です。過去に URL 検証における様々な落とし穴を検証した記事 でも触れたように、自動化された検査基盤では「エラーが発生して止まる」ことよりも「エラーが隠蔽されて緑のまま素通りする」ことの方がはるかに検知が難しく、深刻なサイレント障害を招きます。

本記事では、当リポジトリの URL 検証台帳スクリプトにおいて、macOS 向けに書かれた date -v コマンドが GNU coreutils の date で実行されて空文字になり、キャッシュの鮮度チェックが無言で全件通過し続けた実インシデントを取り上げます。Bash における空文字の辞書順比較仕様と、stderr にエラーが出力されていても見落としてしまう心理的死角を解剖し、「環境差異への個別対応(原因の予防)」に留まらず、「比較直前での非空アサーション(結果の検収)」を配置すべき設計原則について論じます。

URL 検証台帳の 30 日鮮度判定が素通りし続けた実事故

当リポジトリの運用基盤には、外部文献や調査資料として引用する Web サイトの死活状態やステータスコードを定期的に検証し、ローカルの台帳に記録する検証スクリプトが存在します。毎回すべての URL に対してネットワークアクセスを行うと検証コストが増大するため、「過去 30 日以内に検証成功(200 OK または 403)が確認できている URL は、台帳のキャッシュ結果を再利用してスキップする」という鮮度判定ルールが組み込まれていました。

このスクリプトは当初 macOS(BSD 系環境)上で開発され、30 日前の境界日時(cutoff)を算出するために BSD 固有の date -v コマンドが使われていました。しかし、Homebrew 経由で GNU coreutils が導入され、PATH の優先順位によって date コマンドの実体が /opt/homebrew/opt/coreutils/libexec/gnubin/date(GNU date)に向いていた開発環境で実行された際、鮮度判定が静かに破壊されました。

実際の検証ログでは、実行日(2026-09-21)から見て 32 日前(2026-08-20)に記録された URL が、30 日の鮮度期限を超過しているにもかかわらず、再検証されずに台帳の古い結果をそのまま再利用し続けていたのです。

以下は、このサイレントな素通りを引き起こした修正前の鮮度判定コードの抜粋です。

# URL 検証スクリプト(修正前抜粋)
cutoff=$(date -v-"${FRESH_DAYS}"d +%Y-%m-%d)
now=$(date -Iseconds)

while IFS= read -r url; do
  [ -n "$url" ] || continue
  prev=$(lookup "$url")
  prev_at=$(cut -f1 <<<"$prev")
  prev_status=$(cut -f2 <<<"$prev")
  prev_verdict=$(cut -f3 <<<"$prev")

  if [ -n "$prev" ] && { [ "$prev_status" = 200 ] || [ "$prev_status" = 403 ]; } \
     && [[ "${prev_at:0:10}" > "$cutoff" ]]; then
    printf '%s\t%s\t台帳: %s の結果を再利用\n' "$prev_status" "$url" "${prev_at:0:10}"
    continue
  fi
  # ... 実際の URL 再取得処理 ...
done

このスクリプトを実行した際、当リポジトリの一次検証ログには以下のような記録が残されていました。

date: invalid option -- 'v'
Try 'date --help' for more information.
200	https://arxiv.org/abs/2306.05685	台帳: 2026-08-20 の結果を再利用

FreeBSD や macOS の date コマンドであれば、-v-30d を指定することで基準日から 30 日前の日付文字列(例: 2026-08-22)が正常に出力されます1。しかし、POSIX 標準規格において date コマンドで規定されているオプションは -u(UTC 指定)のみであり2、相対日付の計算構文は各 OS の独自拡張に依存しています。

GNU date において -v は未定義の無効オプションであるため、コマンドは stderr にエラーメッセージを出力し、終了ステータス 1 で失敗しました。その結果、コマンド置換 $(date -v...) の標準出力は空となり、変数 cutoff には空文字列 "" が代入されたまま後続のループ処理へと突入したのです。

なぜ空文字の基準値で検査が素通りするのか:辞書順比較と結果行の死角

基準値である cutoff が空文字列になったとき、直後の比較式 [[ "${prev_at:0:10}" > "$cutoff" ]] は、なぜエラーにもならず真(合格)と判定され続けてしまったのでしょうか。そこには、シェルスクリプトの言語仕様と人間の認知特性という二重の死角が存在していました。

辞書順比較における空文字の最小値性

POSIX(Issue 7)の test([)ユーティリティでは、文字列比較演算子として =(一致)と !=(不一致)のみが規定されています3。これに対し、Bash などの拡張シェルで提供されている [[ ... ]] 構文では、> や < による文字列の辞書順比較(ロケールの照合順序に基づく比較)がサポートされています4。

空文字列 "" はあらゆる文字列の接頭辞なので、辞書順ではどの非空文字列よりも前に並びます。

したがって、評価式が [[ "2026-08-20" > "" ]] と展開された場合、左辺の日付文字列は右辺の空文字よりも必ず辞書順で後ろに並ぶため、どのような過去の日付であっても無条件に真(終了ステータス 0) と評価されます。

本来この比較式は「記録日時が 30 日前より新しいものだけを通す」というフィルタとして設計されたはずでした。しかし、基準値が空になった瞬間、そのフィルタは「空文字より大きい全件を通す」という、何の意味も持たない素通りゲートへと反転してしまったのです。

この挙動は、以下の最小コードで容易に再現できます。

# 空文字との辞書順比較の最小実証
cutoff=""
prev_at="2026-08-20"

if [[ "$prev_at" > "$cutoff" ]]; then
  echo "TRUE: 基準値が空文字の場合、過去の日付でも常に真と評価される"
else
  echo "FALSE"
fi

prev_at に 1 年前の日付が入っていようと 10 年前の日付が入っていようと、評価結果は常に TRUE となります。検査の射程は完全にゼロになっていました。

set -u をすり抜ける変数展開の性質

シェルスクリプトの品質を高めるプラクティスとして、未定義変数の展開を検知する set -u(nounset)を指定することが推奨されます。しかし、今回の事故では set -u は何の防波堤にもなりませんでした。

-u が検知するのは、あくまで「未定義(unset)の変数」が展開された場合です。コマンド置換が失敗して標準出力が空になった結果、代入された cutoff="" は、シェルにとっては「空文字列という有効な値が代入された(bound な)変数」として認識されます。したがって、set -u は一切の警告も停止も行わず、正常な変数展開として素通りさせました。

自社事故の直接要因とコマンド置換の落とし穴

今回の実事故においてスクリプトが停止しなかった直接の要因は、スクリプトの冒頭が set -uo pipefail となっており、エラー時にスクリプトを停止させる -e(errexit)が付与されていなかった点にあります。そのため、cutoff=$(date -v...) の代入が終了ステータス 1 を返しても、スクリプトは停止せずにそのまま実行を継続しました。

なお、仮に set -e を指定していたとしても、シェルスクリプトには別の落とし穴が潜んでいます。関数の内部などで local cutoff=$(date -v...) や export cutoff=$(date -v...) のように宣言と代入を同一行で行った場合、行全体の終了ステータスは local や export コマンド自体の戻り値(常に 0)で上書きされ、コマンド置換の失敗がマスクされてしまうという仕様が存在します5。また、set -e の挙動自体が極めて複雑で直感に反する場合があることも、シェルスクリプトの代表的な落とし穴として挙げられています6。

結果行が正常に見えるときの心理的死角

技術的な仕様以上に深刻だったのが、人間の観察における死角です。

前述のログを振り返ると、画面の stderr には確かに date: invalid option -- 'v' というエラーが出力されていました。しかし、その直後の行には 200 https://arxiv.org/... 台帳: 2026-08-20 の結果を再利用 と、あたかも正常に台帳が機能しているかのような結果行が整然と出力されていました。

目視できる出力が一見して期待通りの形式を保っていたため、同じスクリプトを使った複数のセッションで、先頭のエラーメッセージは読み流されたまま、誰も異常に気づきませんでした。

「前提の消失で射程がゼロになる」同型の失敗パターン

「基準値や前提条件が実行時に消失したことで、エラーで止まらずに検査の射程だけがゼロになる」という構造的欠陥は、単一のシェルスクリプトの日付比較に限った話ではありません。検査カバレッジの死角を扱った記事 で論じたように、ソフトウェアテストや自動化パイプラインの至るところで繰り返し発生する同型の失敗パターンです。

当リポジトリの運用実務で発生した他の 2 つの同型事例を振り返ると、その共通構造が浮き彫りになります。

事例A:テストモックの注入先消失による素通り

社内パイプラインの記録作成スクリプトにおいて、失敗系のテストケースとして PATH の先頭にモック用の偽コマンド(exit 1 を返すダミースクリプト)を配置し、ツールが異常終了した際のロールバック処理(残置ファイルゼロ、採番重複なし)を検証していました。

しかし、本体スクリプトの内部リファクタリングによって該当ツールへの依存が廃止された際、テストコード側のモック注入構成が見直されませんでした。その結果、失敗の注入自体が発火しなくなり、本体処理が正常に実行されて完了するようになりました。

正常に完了した処理は当然ながら「残置ファイルゼロ」などの条件を満たすため、検証手順は「失敗系テストの検証項目をすべて満たした」と判定してしまいます。テストの前提条件(モックの注入先)が消滅したことで、約 1 ヶ月もの間、失敗系テストが実質的に何も検証しないまま成功し続けていたのです。

事例B:条件行の欠損によるループの沈黙終了

未処理レコードを抽出して一覧化するスクリプトにおいて、各ファイル内の特定フィールド(再判断条件行)を grep で走査するループ処理が存在していました。このスクリプトには set -e と pipefail が指定されていました。

ある日、条件行そのものが存在しない特殊な未処理レコードが投入された際、grep が一致なしとして終了ステータス 1 を返しました。その瞬間、pipefail と set -e の組み合わせによってループ全体がその場で沈黙終了しました。

対象レコード全 51 件のうち、実際には 29 件までしか処理されていませんでした。しかし、出力された 29 件のフォーマット自体は完全に正常だったため、出力を確認した側は、行数を元の件数と突き合わせないまま「出力自体は正常」と判断して作業を進めてしまいました。

2 つの失敗に共通する教訓

事例A は本件と同じく「終了ステータス 0 で成功の見た目を保ちながら射程が消えた」事故であり、事例B は「終了ステータス 1 で異常停止していたにもかかわらず結果行の体裁に騙された」事故です。

両者に共通しているのは、「出力された結果の体裁が整然としていると、読む側は前提が崩れていることや検査の射程が消失していることに気付けない」 という点です。検査や自動処理が正常に動いているかどうかを「見た目の綺麗さ」で判断してはならず、結果の正当性を機械的に保証するアサーションが不可欠であることを示しています。

処方箋:原因空間の「予防」と結果空間の「検収」

このようなサイレント障害を防ぐために、開発者はどのような対策を講じるべきでしょうか。

まず思い浮かぶのは、「macOS の BSD date と Linux の GNU date の両方で動くように、コマンドの構文を分岐させる」というアプローチです。

原因空間の予防とその限界

実際に、事故直後に検討された対症療法は次のようなフォールバック構文でした。

# 原因空間の予防(構文のフォールバック)
cutoff=$(date -d "${FRESH_DAYS} days ago" +%Y-%m-%d 2>/dev/null || date -v-"${FRESH_DAYS}"d +%Y-%m-%d)

GNU date であれば -d が成功し、BSD date であれば || の後ろの -v が実行されるため、一見すると両環境に対応できたように見えます。

しかし、これは「GNU と BSD の構文差」という 原因の空間(無限) に対する予防策に過ぎません。前述の通り、POSIX 規格の date コマンドには -d も -v も規定されていません2。

例えば、軽量なコンテナ環境(Alpine Linux など)で標準的に使われる BusyBox の date コマンドは GNU とも BSD とも別の実装で、-d が受け付ける書式が GNU date と同じとは限りません。もしスクリプトが将来そうした環境で実行され、このフォールバック構文の両方の分岐が失敗すれば、cutoff は再び空文字になってサイレント障害が再発します。

原因空間には、未知の OS、BusyBox、PATH の欠落、ツールの未インストール、引数の typo など、無限のバリエーションが存在します。原因を一つひとつ潰していく予防的アプローチは、どれだけ積み重ねても「想定外の第 3 の環境」で容易に破綻します。

結果空間の検収による根治

これに対し、検査ロジックが正常に機能するために本質的に必要な性質は、結果の空間 に着目すると、少数の性質に絞って定義できます。

求める性質はただ一つ、「後続の比較式に渡される基準値(cutoff)が、空文字列でないこと」 です。

したがって、根本的な防波堤となるのは、比較式の直前に配置する明示的な非空アサーションです。

# URL 検証スクリプト(修正後抜粋)
# GNU date(coreutils を PATH 前段に置いた環境)と BSD date の両方で動かす。
# 失敗して空のまま進むと、比較が常に真になり台帳が永久に再利用される(無言で鮮度判定が消える)
cutoff=$(date -d "${FRESH_DAYS} days ago" +%Y-%m-%d 2>/dev/null || date -v-"${FRESH_DAYS}"d +%Y-%m-%d)
[ -n "$cutoff" ] || { echo "cutoff の算出に失敗しました(date が -d も -v も受け付けません)" >&2; exit 1; }

この 1 行のアサーションを挟むことで、cutoff が空になった原因が GNU/BSD の差異であろうと、BusyBox の非互換であろうと、PATH の設定ミスであろうと、「基準値が空のまま後続の比較式に進む経路」は確実に遮断 できます。ただし、このアサーションが見ているのは空かどうかだけです。空ではないが日付の形をしていない値までは止められないため、書式まで確かめたい場合は正規表現での照合を足します。

GNU date 環境下であれば -d が成功して正しい基準日(例: 2026-08-22)が得られ、32 日前の古いレコードは正しく期限切れと判定されて再検証されます。そして未知の環境で算出に失敗した場合には、スクリプトが即座に非ゼロ終了して異常を知らせるため、空の基準値による偽合格は起きなくなります。

予防と検収の比較

検査スクリプトの堅牢化において、環境差異への構文対応(予防)と、算出結果のバリデーション(検収)は役割が根本的に異なります。

観点原因空間の予防(構文両対応)結果空間の検収(基準値アサーション)
アプローチdate -d と date -v のフォールバック分岐[ -n "$cutoff" ] || exit 1 の非空ガード
守備範囲自分が想定した既知の OS / コマンド差分のみ未知の環境・未インストール・typo 等、算出結果が空になる失敗
限界Alpine/BusyBox 等の第 3 の環境で再び空になる失敗時にスクリプトが停止するため、予防と併用が望ましい
設計思想原因の空間(無限)を潰そうとするアプローチ結果の空間(空でないこと)を閉じるアプローチ

したがって、構文両対応による予防と、直後の非空アサーションによる検収を組み合わせた二重防御が、検査の射程を保つための処方箋になります。

そもそも Bash で書かなければ済むのか

ここまでの議論は、基準値を Bash で算出することを前提にしています。実のところ筆者は、Bash のバージョン差や GNU/BSD の差といった細かいシェルの問題でハマりやすくなったため、3 行以上のスクリプトは Python で書くよう指示しています。日付の計算を Python の標準ライブラリに任せれば、date -v と date -d の差という原因はそもそも消えます。

ただし、言語を移して消えるのは原因の一つにすぎません。辞書順で比べる限り、空文字列がどの非空文字列よりも前に並ぶという性質は言語を問いません。Python でも基準値を設定ファイルや環境変数から読み、読み取りに失敗して空文字列が入れば、同じ形で検査の射程はゼロになります。言語を変えても、比較の前に基準値が空でないことを確かめる一行は必要なままです。

よくある質問(FAQ)

Q. set -u(nounset)を指定しておけば、空文字による比較の素通りは防げないのですか?

A. 防げません。set -u は「未定義(unset)の変数」を展開しようとしたときにエラーを発生させるオプションです。コマンド置換が失敗して標準出力が空になった場合でも、変数には「空文字列」という有効な値が代入(bound)されるため、set -u のチェックを完全にすり抜けます。空文字の混入を防ぐには、変数展開後に [ -n "$var" ] による明示的な長さ判定を行う必要があります。

Q. シェルスクリプトで動的にしきい値を計算する際、最も安全な実装パターンは何ですか?

A. 基準値を算出するコマンド置換の直後、かつ比較処理に入る直前に、必ず [ -n "$var" ] || exit 1(数値比較であれば [[ "$var" =~ ^[0-9]+$ ]] || exit 1)によるアサーションを配置することです。環境依存のフォールバックを書く場合でも、それを過信せず、「万一算出が失敗して変数が空・無効値になった瞬間に処理を安全に停止させるガード」を省略してはなりません。

まとめ

ここまでの知見は、シェルスクリプトの日付計算にとどまらず、あらゆるテストコードや自動化パイプラインの設計に通じる普遍的な原則です。現場のコードを点検する際は、以下の 4 つの命題を意識してください。

  1. 動的に算出する基準値は、空文字になった際の比較挙動を疑う: しきい値、許容範囲、cutoff 日時が空文字や null になったとき、評価式が意図せず「全通し(無条件合格)」側に倒れないかを必ず検証する。
  2. Bash の [[ > ]] は空文字を最小値として扱う: 文字列の辞書順比較において空文字 "" はあらゆる非空文字列より小さいため、任意の日付 > "" は常に真となり、鮮度チェックなどの境界検査が無効化される。
  3. set -u も結果行の正常な見た目も異常を教えてくれない: 空文字は未定義変数ではないため set -u をすり抜け、stderr にエラーが出ていても結果行のフォーマットが整然としていれば人間も AI も見落とす。
  4. 予防(原因空間)と検収(結果空間)の二重防御を徹底する: 環境差へのフォールバック(予防)だけに頼らず、比較直前に [ -n "$cutoff" ] || exit 1 を置くことで、未知の環境で基準値が空になっても検査が黙って素通りする経路を塞ぐ。

テストが緑のまま通過していることに満足せず、その検査が「本当に検証すべき射程を捕捉しているか」を疑う姿勢こそが、システムの堅牢性を支える基盤となります。

開発プロセスの標準化、CI/CD パイプラインの品質ゲート設計、自動化スクリプトの堅牢化に関するご相談は、コンタクトフォーム よりお気軽にお問い合わせください。

Footnotes

  1. FreeBSD General Commands Manual: date(1) The FreeBSD Project, 2026年。DESCRIPTION 節、-v オプション構文(-v [+|-]val[y|m|w|d|H|M|S] による相対日時調整)。 ↩

  2. The Open Group Base Specifications Issue 7: date IEEE and The Open Group, 2018年版。OPTIONS 節(サポートが義務付けられているオプションは -u のみであり、相対日付計算 -d や -v は POSIX 規格外の独自拡張であること)。 ↩ ↩2

  3. The Open Group Base Specifications Issue 7: test IEEE and The Open Group, 2018年版。OPERANDS 節(文字列比較演算子として規定されているのは = と != のみであり、-n string による非ゼロ長判定の仕様)。 ↩

  4. [BashFAQ/031 - What is the difference between test, [ and [? Greg’s Wiki (Wooledge), 2026年。比較表の “string comparison >” 行および解説([[ 構文における > 演算子の辞書順文字列比較仕様)。 ↩

  5. ShellCheck Wiki: SC2155 ShellCheck Project (Vidar Holen), 2026年。“Problematic code in the case of export/local” 節(export や local を伴うコマンド置換代入において終了コードがマスクされる問題の解説)。 ↩

  6. BashPitfalls Greg’s Wiki (Wooledge), 2026年。Pitfall #60(set -euo pipefail における errexit の挙動と、エラーハンドリング過信の注意点)。 ↩