MeteoHealth の「仕組み」画面の6段落が、6ロケールすべてでロシア語のままリリースに乗った — プロジェクトには 7784 キー × 6言語(en、ru、es、zh-Hans、ja、ar)があり、翻訳は形式上「存在していた」にもかかわらず。犯人は、一見まったく無害に見える SwiftUI の一行だ:
Text(LocalizedStringKey("today.howitworks.\(topic).title"))LocalizedStringKey 内の補間は、topic のケース数に応じた6つの具体的なキーにはコンパイルされず、today.howitworks.%@.title という単一のテンプレートキーになる。そんなキーはどのロケールにも存在しないし、存在しようがない。ルックアップは失敗し、SwiftUI は警告ひとつ出さずに補間済み文字列をそのまま表示し、ユーザーはテキストの代わりに生のキーを見ることになる。
ここではコンパイラは無力だ:コンパイラから見ればすべて正しい。テストも沈黙する — 「何かしらの」文字列は描画されたからだ。答えになったのが、私が書いた scripts/check_localization_coverage_impl.py — 1788行の Python による Swift ソースの静的アナライザで、Xcode build phase と CI に常駐し、6ロケールすべてでカバーされていないキーがコードにある限りプロジェクトのビルドを許さない。
なぜ grep では駄目なのか#
最初に浮かぶのは「リテラルを grep すればいいだけでは」。違う。grep は、どのリテラルがローカライズキーで、どれが UserDefaults のエントリ名なのかを知らない。コメントの中の文字列が文字列でないことも知らない。そして "a.\(x).title" が1つのキーではなくキーの一族であることも知らない。
そのためゲートは独自の Swift コメントストリッパーを持ち、それも strip と blank の2モードだ。後者はコメントを空白に置き換えてファイル長を保つ — レポート内の行番号を正確にするためだ。docstring には理由が率直に書かれている:
def blank_swift_comments(src: str) -> str:
"""strip_swift_comments と同じだが、長さを保持する:コメント → 空白。
出力の行番号のために必要。コメントを削除すると位置がずれ、ゲートは
ファイルに存在しない行を指し示す — そして人を間違った場所へ送るゲートは、
節約したはずの時間をそっくりそのまま浪費する。
"""次は呼び出しのセマンティクスだ。ゲートは、24個の SwiftUI イニシャライザ(Text、Label、Button、Toggle、TextField、Picker、Section、NavigationLink、Link、Menu、ProgressView、ContentUnavailableView…)、10個のモディファイア(navigationTitle、alert、confirmationDialog、accessibilityLabel、searchable)の第1位置引数と、名前付きの prompt:/placeholder: を理解する。
この拡張だけで +876 キーが保護下に入り、すぐに実際の抜けが見つかった:PregnancyDetailView の Text("common.more") は、どのロケールにも文字列が存在しなかった。
実行時に組み立てられるキー#
明示的な呼び出しがすべてではない。最も面白いのは "a.\(x).title" のような補間キーだ。ゲートは補間の背後にある enum の実際のケースに沿ってこれを展開する:Int ベースの列挙型、入れ子の三項演算子(再帰的に)、switch の分岐で代入される変数まで含めてだ。topic が6ケースの enum なら、テンプレートは6つの具体的なキーに展開され、それぞれが6ロケールに存在しなければならない。
そして補間の背後にあるのが enum ではなく値のカタログ — 「禁煙の12のマイルストーン」「妊娠週数」 — の場合、ゲートは自前のコピーを持たず、アドレス(ファイル + 定数名)でプロダクションコードからリストを読み取る:
INTERPOLATED_TEMPLATE_CATALOGS = {
"smoking.recovery.*.title": [
{"file": "SmokingDashboardModels.swift", "symbol": "all", "pick": "strings"}
],
}スクリプト内のコメントが、なぜこの形なのかを説明している:「さもなければゲートは昨日の真実を検証し始める — マイルストーンを追加し、文字列を忘れても、ゲートは自分の中のリストのコピーと照合しているから黙ったままだ」。アドレスの参照結果が空の場合もビルド失敗になる:アドレスが古くなったということだからだ。
「キーではない」もまた主張である#
ゲートには「これはキーではない」と黙って判断する権利がない。テンプレートを却下したのに .strings にその配下のエントリが実在する場合、ビルドは失敗し、判定を要求する:検証可能な理由付きで NON_CONTEXT_ALLOW に記載するか、死んだ文字列として削除するかだ。現在このリストにある唯一のエントリは daily_snapshot.*:これは日付から組み立てられる UserDefaults のエントリ名であり、カード文字列のプレフィックスとの一致は偶然にすぎない — 理由欄がそれを説明している。
逆向きの規約も機能している — 宣言としての命名だ:…Key/…Keys で終わる何か(プロパティ、関数の戻り値、detailKeys: […] の要素)に代入される文字列リテラルは、デフォルトでローカライズキーとみなされる。文書化された例外はちょうど1つ — DailySnapshotService の storageKey で、この名前は翻訳ではなくストレージのことを正直に語っている。
盲点と、その見つけ方#
これほど念入りなシステムでも、すべてを捕まえられるわけではない。最も教訓的な2つのバグを、私はゲート自身の中で見つけた。
複合キー。 let base = "a.\(x)" → "\(base).title" というパターンの代入をゲートは扱えていた。だがキー形状のバリデーションが base の代入の「前」に実行されていたため、*.title のようなドットのプレースホルダーで始まるキーは検査から静かに抜け落ちていた。結果:約200本の生きた文字列 — cycle.superpower_detailed.*(120本)、cycle.insight.intimacy.*、onboarding.v3.* — が、ゲートが緑のまま、何ひとつ保護されていなかった。コミット f165ef1 で修正。
列挙型の同名衝突。 GoalType はプロジェクトに2回存在する — Goal.swift と SmokingEntry.swift にあり、これらは別々の enum だ。フラットな型インデックスが一方をもう一方で上書きしていた:ゲートは存在しないキーを要求しつつ、実在する7つのキーを見逃していた。今では型はファイル指定付きでアドレスされる — Goal.swift:GoalType。
2つのバグに共通するのは1点:ゲートが緑だったことだ。穴のある緑のゲートは、ゲートがないより悪い — 守られているという感覚を生んでしまうからだ。
腐ることのできないゲート#
どんな allowlist も時間とともにゴミ捨て場と化す。だからここでは、もう何もカバーしていない ALLOW エントリは、自分を削除せよと要求してビルドを落とす — 「さもなければ『リストは減る一方』は口約束の上に成り立つことになる」。技術的負債はスクリプト自身にコード化されている:DEBT_MISSING_KEYS と DEBT_UNRESOLVED に1行ごとの理由が付き、解消済みの負債も削除が義務 — ゲートがそれを検査する。
動作の証明は双方向だ。古いコミット eba1008 ではゲートは today.howitworks.* の12キー × 6ロケール、まさにあのインシデントでぴったり落ちる。master では緑。そして ar.lproj から pregnancy.detail.baby_size を削除すると、キー名とロケール名を挙げて落ちる。
解析拡張の代償:実行時間は3秒から7.6秒へ。build phase としては許容範囲だ。
そして限界についての誠実さ:リファクタリング計画は「約2400個の死んだキー」を約束したが、検証で確認できたのは62個だけだった。ゲートの未使用キーリストはソースを走査するが、実行時に組み立てられるキーはコードに完全な形では現れない — そうした「死んだ」文字列を削除していたら、ユーザーの画面に生のキーが表示されていただろう(9bfad9b)。
同じ台所から出た3つのバグ#
このプロジェクトでローカライズが壊れたのはキーだけではない — そして私が調べたどのケースも同じ考えを裏付けた。
stringsdict がアプリを落とす。 以前のクラッシュ修正は誤った診断で差し戻されていた。本当の原因:ルールのキーが NSStringFormatSpecType/NSStringFormatValueType と書かれていた — Key サフィックスなしで。Foundation は NSStringFormatSpecTypeKey を期待するためルールが認識されず、%#@value@ が展開されないままフォーマットに到達し、プロセスが落ちる。壊れていたのは54ルール × 6ロケール(d841ef9)。
指定子の順序。 気温急変の通知:コードは引数を (Double, String) の順で渡すが、6ロケールすべてのテンプレートは逆順を期待していた — ロケールによってゴミを表示するかクラッシュするかだった。治療法は位置指定子 %2$@ / %1$.1f(c0ba693)。
2つの文字列リゾルバ。 アプリは Bundle.main 経由と、UserDefaults 上の独自メカニズム経由の両方で文字列を解決していた — 設定で言語を切り替えると、1つの画面に2つの言語が同時に表示された(ee29de2)。
ローカライズが実際に壊れる場所#
ローカライズは .strings ファイルの中では壊れない — そこには大抵すべて揃っている。壊れるのはキーが組み立てられる場所だ:補間、複合プレフィックス、型の同名衝突、フォーマットルールのサフィックス。つまり検査すべきはコードであって、翻訳ファイルだけではない。
もう1つ:誠実なゲートは「自信がない」と言えなければならず、黙って通す代わりに、理由付きの明示的な判定を要求できなければならない。私たちが見つけた穴はすべて、ゲートが無駄に落ちた場所ではなく、自信満々に沈黙していた場所にあった。



