Six paragraphs of the "How It Works" screen in MeteoHealth shipped in a release in Russian across all six locales — in a project with 7,784 keys × 6 languages (en, ru, es, zh-Hans, ja, ar) where the translations formally "existed." The culprit is a single line of SwiftUI that looks perfectly innocent:
Text(LocalizedStringKey("today.howitworks.\(topic).title"))Interpolation inside LocalizedStringKey compiles not into six concrete keys — one per case of topic — but into a single template key today.howitworks.%@.title. No locale has that key, and none ever could. The lookup fails, SwiftUI prints the interpolated string as-is without a single warning, and the user sees a raw key instead of text.
The compiler is powerless here: from its point of view everything is correct. Tests stay silent too — some string did render. The answer became a script I wrote, scripts/check_localization_coverage_impl.py — 1,788 lines of Python, a static analyzer of Swift sources that lives in an Xcode build phase and in CI and refuses to build the project while the code contains a key not covered by all six locales.
Why grep doesn't work#
The first thought is "that's just grep over string literals." No. Grep doesn't know which literal is a localization key and which is the name of a UserDefaults entry; it doesn't know that a string inside a comment isn't a string; and it doesn't know that "a.\(x).title" is not one key but a whole family.
So the gate ships its own Swift comment stripper, in two modes: strip and blank. The second replaces comments with spaces, preserving file length — for the sake of correct line numbers in the report. The docstring states the reason plainly:
def blank_swift_comments(src: str) -> str:
"""Like strip_swift_comments, but LENGTH IS PRESERVED: comments → spaces.
Needed for line numbers in the output. Removing comments shifts positions, and
the gate points at a line that doesn't exist in the file — and a gate that sends
a person to the wrong place wastes exactly the time it saves.
"""Next comes call semantics. The gate understands the first positional argument of 24 SwiftUI initializers (Text, Label, Button, Toggle, TextField, Picker, Section, NavigationLink, Link, Menu, ProgressView, ContentUnavailableView…), ten modifiers (navigationTitle, alert, confirmationDialog, accessibilityLabel, searchable), and the named prompt:/placeholder: arguments.
That extension alone brought +876 keys under protection — and immediately found a real gap: Text("common.more") in PregnancyDetailView, with no string for it in any locale.
Keys assembled at runtime#
Explicit calls aren't the whole story. The most interesting part is interpolated keys like "a.\(x).title". The gate expands them by the real cases of the enum behind the interpolation: including Int-backed enums, nested ternaries (recursively), and variables assigned in switch branches. If topic is an enum with six cases, the template unrolls into six concrete keys, and each one must exist in six locales.
And when what stands behind the interpolation isn't an enum but a catalog of values — "twelve smoking-cessation milestones," "pregnancy weeks" — the gate reads the list from production code by address (file + constant name) instead of keeping its own copy:
INTERPOLATED_TEMPLATE_CATALOGS = {
"smoking.recovery.*.title": [
{"file": "SmokingDashboardModels.swift", "symbol": "all", "pick": "strings"}
],
}A comment in the script explains why it's done this way: "otherwise the gate starts checking yesterday's truth — a milestone gets added, a string gets forgotten, and the gate stays silent because it compares against a copy of the list inside itself." An empty result at the address is also a build failure: it means the address has gone stale.
"Not a key" is a claim too#
The gate has no right to silently decide "this isn't a key." If a template is rejected but .strings files actually contain entries under it, the build fails and demands a verdict: either an entry in NON_CONTEXT_ALLOW with a verifiable reason, or delete the strings as dead. The only entry in that list today is daily_snapshot.*: it's the name of a UserDefaults entry assembled from a date, the prefix collision with the card's strings is a coincidence, and the reason says so.
The reverse convention works as well — naming as declaration: a string literal assigned to anything ending in …Key/…Keys (a property, a function result, an element of detailKeys: […]) is treated as a localization key by default. There is exactly one documented exception — storageKey in DailySnapshotService, where the name honestly speaks of storage, not translation.
Blind spots and how they were found#
Even a system this meticulous doesn't catch everything. The two most instructive bugs I found were in the gate itself.
Composite keys. The pattern let base = "a.\(x)" → "\(base).title" was something the gate knew how to substitute. But key-shape validation ran BEFORE the base substitution, so keys that began with a placeholder dot, like *.title, silently fell out of checking. The result: about 200 live strings — cycle.superpower_detailed.* (120 strings), cycle.insight.intimacy.*, onboarding.v3.* — were protected by nothing at all, under a green gate. Fixed in commit f165ef1.
Enum homonyms. GoalType exists twice in the project — in Goal.swift and in SmokingEntry.swift; they are different enums. The flat type index overwrote one with the other: the gate demanded nonexistent keys while missing 7 real ones. Types are now addressed with the file included — Goal.swift:GoalType.
Both bugs have one thing in common: the gate was green. A green gate with a hole is worse than no gate at all — it produces a feeling of being protected.
A gate that can't go stale#
Any allowlist turns into a dump over time. So here, an ALLOW entry that no longer covers anything fails the build with a demand to delete itself — "otherwise 'the list only shrinks' rests on the honor system." Tech debt is encoded in the script itself: DEBT_MISSING_KEYS and DEBT_UNRESOLVED with a reason for every line, and closed debt must be deleted too — the gate checks that.
The proof of operation goes both ways. On the old commit eba1008 the gate fails on exactly 12 today.howitworks.* keys × 6 locales — that very incident. On master it's green. And if you delete pregnancy.detail.baby_size from ar.lproj, it fails with the key name and the locale.
The price of the expanded analysis: runtime grew from 3 to 7.6 seconds. Acceptable for a build phase.
And honesty about the limits: the refactoring plan promised "~2,400 dead keys," verification confirmed only 62. The gate's unused-keys list scans the sources, and keys assembled at runtime never appear in the code in full — deleting those "dead" strings would have shown the user raw keys on screen (9bfad9b).
Three bugs from the same kitchen#
Localization broke in this project not only on keys — and every case I dug into confirmed the same idea.
stringsdict crashes the app. A previous crash fix had been reverted with a wrong diagnosis. The real cause: the rule keys were written as NSStringFormatSpecType/NSStringFormatValueType — without the Key suffix. Foundation expects NSStringFormatSpecTypeKey, the rule isn't recognized, and %#@value@ reaches formatting unexpanded — the process crashes. 54 rules × 6 locales were broken (d841ef9).
Specifier order. The temperature-swing notification: the code passes arguments as (Double, String), while the template in all six locales expected the reverse order — printing garbage or crashing, depending on the locale. The cure — positional specifiers %2$@ / %1$.1f (c0ba693).
Two string resolvers. The app resolved strings simultaneously through Bundle.main and through its own mechanism on top of UserDefaults — and after a language change in settings, one screen showed two languages at once (ee29de2).
Where Localization Actually Breaks#
Localization doesn't break in .strings files — everything is usually there. It breaks at the points where keys are assembled: interpolation, composite prefixes, type homonyms, format-rule suffixes. Which means you have to check the code, not just the translation files.
And second: an honest gate must be able to say "I'm not sure" and demand an explicit verdict with a reason — instead of silently letting things through. Every hole we found was not where the gate failed for nothing, but where it stayed confidently silent.



