`number_to_phone`の誤検知バグを`gsub`ブロックで根本解決
number_to_phoneにおいて、区切り文字と同じ文字列から始まる電話番号を渡すと先頭の桁が欠落するバグを、gsubブロックによるキャプチャグループの直接評価で修正しました。文字列の事後操作に依存していた設計を撤廃し、問題の発生源で正しく分岐する実装に置き換えています。
背景
本PRは #57375 のフォローアップとして作成されました。#57375 では、複数文字の区切り文字を使用した際に先頭に余分な文字が残るバグを修正しています。具体的には、7桁以下の番号(エリアコードなし)に複数文字の区切り文字を指定した場合、最初のキャプチャグループ (\d{0,3}) が空になることで先頭に余分な区切り文字が生成されていました。その修正では slice!(0, delimiter.length) で先頭の区切り文字を除去する方式を採用しました。
しかし、この方式には新たな誤検知の問題が潜んでいました。先頭に余分な区切り文字が付いているかどうかの判定を start_with_delimiter? メソッドが担っており、このメソッドは単純に number.start_with?(delimiter) という文字列比較を行います。そのため、最初のキャプチャグループが空でなくても、変換後の文字列がたまたま区切り文字と同じ文字列で始まる場合に誤って true を返してしまいます。
以下の例がその誤動作を端的に示しています:
number_to_phone(9001234, delimiter: "900")
# gsub後: "900900-1234" (最初のグループ = "900"、空ではない)
# start_with_delimiter?("900900-1234") → true("900"で始まるため!)
# slice!(0, 3) → "900-1234" ← 誤り。エリアコード相当の桁が失われる
start_with_delimiter? は「先頭の区切り文字が空グループ由来か、実際の数字由来か」を区別できない構造になっており、文字列の内容だけでは判定が不可能です。
技術的な変更
convert_without_area_code メソッドの実装を、文字列置換後の事後処理から gsub ブロック内での直接分岐に変更し、start_with_delimiter? メソッドを削除しました。
変更前:
def convert_without_area_code(number)
default_pattern = /(\d{0,3})(\d{3})(\d{4})$/
number.gsub!(regexp_pattern(default_pattern),
"\\1#{delimiter}\\2#{delimiter}\\3")
number.slice!(0, delimiter.length) if start_with_delimiter?(number)
number
end
def start_with_delimiter?(number)
delimiter.present? && number.start_with?(delimiter)
end
変更後:
def convert_without_area_code(number)
default_pattern = /(\d{0,3})(\d{3})(\d{4})$/
number.gsub!(regexp_pattern(default_pattern)) do
$1.empty? ? "#{$2}#{delimiter}#{$3}" : "#{$1}#{delimiter}#{$2}#{delimiter}#{$3}"
end
number
end
ブロック形式の gsub! を使用することで、置換時点でキャプチャグループ $1 が空かどうかを直接評価できます。$1 が空の場合は先頭に区切り文字を付けずに $2#{delimiter}$3 を返し、空でない場合は従来通り $1#{delimiter}$2#{delimiter}$3 を返します。
テストには2件の回帰テストケースが追加されています:
assert_equal("9009001234", number_helper.number_to_phone(9001234, delimiter: "900"))
assert_equal("9009009009001234", number_helper.number_to_phone(9009001234, delimiter: "900"))
これらは区切り文字と一致する数字列を含む番号を扱うケースで、変換前後を通じて桁が失われないことを検証しています。
設計判断
問題の発生源での分岐 という原則に沿った設計変更が採用されました。
従来の実装は「正規表現で置換してから、結果が正しくなければ後から修正する」という二段階処理でした。この方式の根本的な問題は、一度文字列として合成されてしまうと、先頭の区切り文字がどこから来たものかを再判定できない点にあります。一方、gsub ブロックでは置換の瞬間にキャプチャグループ $1 の状態を直接参照できるため、文脈情報を失わずに正確な分岐が可能です。
この変更により start_with_delimiter? メソッドが不要になり、コードも削減されています。事後修正のためだけに存在していたヘルパーメソッドを削除することで、変換ロジックが convert_without_area_code の一箇所に集約されました。
まとめ
本PRは、#57375 で導入された事後修正パターンが新たな誤検知を引き起こすことを発見し、根本的な設計を見直した変更です。gsub ブロックでキャプチャグループを直接評価する方式に統一することで、文字列の内容に依存した脆弱な判定ロジックを完全に排除しています。