`number_to_delimited` が非有限浮動小数点数を破壊する不具合を修正

rails/rails

パフォーマンス改善として導入された高速パスが Float::INFINITY"In,fin,ity" に変換してしまうリグレッションを、parts メソッドへの1行のガード節追加で修正しました。

背景

number_to_delimited が非有限浮動小数点数を正しく扱えなくなったのは、パフォーマンス目的で導入された2つのコミットが引き金でした。コミット 2d485aecf5 (CVE-2026-33169対応) で手動スライスによる高速パスが追加され、続くコミット 33fbedb1b1 でこの高速パスをデフォルトにするため delimiter_pattern のデフォルト値が nil に変更されました。

これらの変更以前は、正規表現 gsub!(/(\d)(?=(\d\d\d)+(?!\d))/) を使った実装が偶然にも非有限浮動小数点数を正しく処理していました。Float::INFINITY.to_s"Infinity" という文字列になりますが、この文字列には数字(\d)が含まれないため正規表現がマッチせず、文字列がそのまま返されていたのです。高速パスは "Infinity" を8文字の「数字列」として扱い、右から3文字ごとにカンマを挿入するため、"In,fin,ity" という破損した文字列が生成されてしまいました。

Float::NAN が同じリグレッションを免れたのも意図的な設計ではなく、"NaN" がちょうど3文字であったため offset = 0 となり、1回のイテレーションで同じ文字列が返る偶然によるものでした。この問題は、同じ非有限浮動小数点数に関する #57451significant: true 時に FloatDomainError が発生する問題)と同系統ですが、別のコンバータ・別のコードパスに起因する独立した不具合です。

技術的な変更

NumberToDelimitedConverterparts メソッドの先頭に、非有限浮動小数点数を早期リターンするガード節を1行追加しました。

変更前:

def parts
  left, right = number.to_s.split(".")
  if delimiter_pattern
    left.gsub!(delimiter_pattern) do |digit_to_delimit|

変更後:

def parts
  return [number.to_s] if number.respond_to?(:finite?) && !number.finite?

  left, right = number.to_s.split(".")
  if delimiter_pattern
    left.gsub!(delimiter_pattern) do |digit_to_delimit|

number.respond_to?(:finite?) という条件式によって、Float 以外の型(Integer など finite? を持たないオブジェクト)への影響を完全に排除しています。ガード節を通過した場合は [number.to_s] を返し、後続の join"Infinity""-Infinity""NaN" がそのまま出力されます。

修正後の動作は以下のとおりです:

number_to_delimited(Float::INFINITY)   # => "Infinity"
number_to_delimited(-Float::INFINITY)  # => "-Infinity"
number_to_delimited(Float::NAN)        # => "NaN"

この挙動は number_to_phone(Float::INFINITY) が既に "Infinity" を返す挙動と一致しており、NumberHelper 全体での一貫性が保たれます。テストには3つのヘルパーアクセス経路(インスタンスメソッド、クラスメソッド、ActiveSupport::NumberHelper モジュール直接呼び出し)すべてを対象にしたケースが追加されています。

設計判断

finite? の有無を respond_to? で確認する防御的アプローチが採用されました。

Float::INFINITY を渡したときに例外を発生させる(フェイルファスト)のではなく、文字列表現をそのまま返す方針が選択されています。これは number_to_phone を始めとする他のヘルパーの既存の挙動と整合する選択であり、非有限値を "Infinity" として出力する一貫したAPIを NumberHelper 全体で提供する意図が読み取れます。

ガード節の配置を convert メソッドではなく parts メソッドの先頭にした点も重要です。parts は高速パスと正規表現パスの両方が通る共通の入口であり、ここで短絡することで将来の内部実装変更に対しても保護が機能し続けます。

まとめ

本PRは、パフォーマンス改善コミットが偶発的な正しさに依存していた箇所を顕在化させたリグレッションへの修正です。respond_to?(:finite?) を用いた1行のガード節という最小限の変更で、既存の動作に影響を与えずに非有限浮動小数点数の取り扱いを明示的・意図的に正しく定義しています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
d57cce2e

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文(総論)→背景・技術詳細(各論)→まとめ(結論)の3部構成が明確で、読者が内容を理解しやすい構成になっています。任意項目である「設計判断」も含まれており、変更の意図を深く理解する助けとなっています。

カスタムMarkdown構文 ⚠ WARNING

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライトは正しく使用されていますが、コミットIDのリンクがガイドラインで推奨されている7文字ではなく9文字で記述されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

リグレッション、高速パス、ガード節といった技術用語を適切に使用しており、専門知識を持つエンジニアという対象読者に完全に適合しています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクション、各パラグラフが「総論→各論」の構造で書かれており、すべての段落がトピックセンテンスで始まっています。1段落1トピックの原則も守られており、非常に読みやすいです。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内のコードブロックは、提供されたDiff情報を正確に反映しています。ファイルパスも正しく、変更内容が的確に示されています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「非有限浮動小数点数」「ガード節」「リグレッション」などの技術用語がPR情報と一致しており、文脈に応じて正確に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

リグレッションが発生した原因、`Float::NAN`が偶然影響を免れた理由、修正コードの意図など、すべての説明が技術的に正確で論理的です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内のすべての主張(リグレッションの原因、関連Issue、テスト範囲など)は、PRのDescriptionやDiff内のコードで完全に裏付けられており、ハルシネーションは検出されませんでした。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#57470)、関連Issue番号(#57451)、コミットID、CVE番号など、すべての数値と固有名詞がPR情報と一致しており、正確です。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトルは、PRのタイトル「Fix `number_to_delimited` mangling non-finite floats」の内容を的確に日本語で表現しており、主題が一致しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

PRに記載のない外部知識(LTS、EOLなど)の追加はなく、PR情報に忠実な内容となっています。PRに記載されていた未来の日付のCVE番号をそのまま引用しており、捏造はありません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

「導入された」「以前は」といった時間表現は、PRの文脈と一致しており、時間的な前後関係を正確に伝えています。