Шесть абзацев экрана «Как это работает» в MeteoHealth уехали в релиз русскими на все шесть локалей — при том что в проекте 7784 ключа × 6 языков (en, ru, es, zh-Hans, ja, ar) и переводы формально «были». Виновата одна строка SwiftUI, которая выглядит абсолютно невинно:
Text(LocalizedStringKey("today.howitworks.\(topic).title"))Интерполяция внутри LocalizedStringKey компилируется не в шесть конкретных ключей по числу кейсов topic, а в один ключ-шаблон today.howitworks.%@.title. Такого ключа нет ни в одной локали — и не может быть. Лукап падает, SwiftUI без единого ворнинга печатает интерполированную строку как есть, и пользователь видит сырой ключ вместо текста.
Компилятор здесь бессилен: с его точки зрения всё корректно. Тесты тоже молчат — строка «какая-то» отрисовалась. Ответом стал написанный мной scripts/check_localization_coverage_impl.py — 1788 строк Python, статический анализатор Swift-исходников, который живёт в Xcode build phase и в CI и не даёт собрать проект, пока в коде есть ключ, не покрытый всеми шестью локалями.
Почему grep не работает#
Первая мысль — «это же grep по литералам». Нет. Grep не знает, какой литерал — ключ локализации, а какой — имя записи в UserDefaults; не знает, что строка внутри комментария — не строка; и не знает, что "a.\(x).title" — это не один ключ, а семейство.
Поэтому в гейте свой стриппер Swift-комментариев, причём в двух режимах: strip и blank. Второй заменяет комментарии пробелами, сохраняя длину файла, — ради корректных номеров строк в отчёте. В докстринге причина сформулирована прямо:
def blank_swift_comments(src: str) -> str:
"""Как strip_swift_comments, но ДЛИНА СОХРАНЯЕТСЯ: комментарии → пробелы.
Нужно ради номеров строк в выводе. Удаление комментариев сдвигает позиции, и
гейт показывает строку, которой в файле нет, — а гейт, отправляющий человека
не туда, тратит ровно то время, которое экономит.
"""Дальше — семантика вызовов. Гейт понимает первый позиционный аргумент 24 SwiftUI-инициализаторов (Text, Label, Button, Toggle, TextField, Picker, Section, NavigationLink, Link, Menu, ProgressView, ContentUnavailableView…), десяти модификаторов (navigationTitle, alert, confirmationDialog, accessibilityLabel, searchable) и именованные prompt:/placeholder:.
Одно это расширение добавило под защиту +876 ключей — и тут же нашло реальный пропуск: Text("common.more") в PregnancyDetailView, для которого строки не было ни в одной локали.
Ключи, которые собираются в рантайме#
Явные вызовы — не всё. Самое интересное — интерполированные ключи вида "a.\(x).title". Гейт раскрывает их по реальным кейсам enum, стоящего за интерполяцией: включая Int-backed перечисления, вложенные тернарники (рекурсивно) и переменные, присвоенные в ветках switch. Если topic — enum с шестью кейсами, шаблон разворачивается в шесть конкретных ключей, и каждый обязан существовать в шести локалях.
А когда за интерполяцией не enum, а каталог значений — «двенадцать вех отказа от курения», «недели беременности» — гейт читает список из продакшн-кода по адресу (файл + имя константы), а не хранит копию у себя:
INTERPOLATED_TEMPLATE_CATALOGS = {
"smoking.recovery.*.title": [
{"file": "SmokingDashboardModels.swift", "symbol": "all", "pick": "strings"}
],
}Комментарий в скрипте объясняет, почему именно так: «иначе гейт начнёт проверять вчерашнюю правду — добавили веху, забыли строку, а гейт молчит, потому что сверяется с копией списка в самом себе». Пустой результат по адресу — тоже падение сборки: значит, адрес устарел.
«Не ключ» — тоже утверждение#
Гейт не имеет права молча решить «это не ключ». Если шаблон отклонён, но в .strings под ним реально лежат записи, — сборка падает и требует вердикта: либо запись в NON_CONTEXT_ALLOW с проверяемой причиной, либо удалить строки как мёртвые. Единственная запись в этом списке сегодня — daily_snapshot.*: это имя записи в UserDefaults, собранное из даты, а совпадение с префиксом строк карточки — случайность, и причина это объясняет.
Работает и обратное соглашение — именование как декларация: строковый литерал, присвоенный чему-то на …Key/…Keys (свойству, результату функции, элементу detailKeys: […]), считается ключом локализации по умолчанию. Документированное исключение ровно одно — storageKey в DailySnapshotService, где имя честно говорит о хранилище, а не о переводе.
Слепые пятна и как их находили#
Даже такая дотошная система ловит не всё. Два самых поучительных бага я нашёл в самом гейте.
Составные ключи. Паттерн let base = "a.\(x)" → "\(base).title" гейт умел подставлять. Но валидация формы ключа выполнялась ДО подстановки base, поэтому ключи, начинавшиеся с точки-заглушки вида *.title, тихо выпадали из проверки. Итог: около 200 живых строк — cycle.superpower_detailed.* (120 строк), cycle.insight.intimacy.*, onboarding.v3.* — не были защищены вообще ничем, при зелёном гейте. Починено в коммите f165ef1.
Омонимы перечислений. GoalType в проекте существует дважды — в Goal.swift и в SmokingEntry.swift, это разные enum'ы. Плоский индекс типов перезаписывал один другим: гейт требовал несуществующие ключи и при этом пропускал 7 реальных. Теперь типы адресуются с указанием файла — Goal.swift:GoalType.
Оба бага объединяет одно: гейт был зелёным. Зелёный гейт с дырой хуже отсутствия гейта — он выдаёт ощущение защищённости.
Гейт, который не может протухнуть#
Любой аллоулист со временем превращается в свалку. Поэтому здесь запись ALLOW, которая больше ничего не покрывает, роняет сборку с требованием удалить себя — «иначе „список только сокращается" держится на честном слове». Техдолг закодирован в самом скрипте: DEBT_MISSING_KEYS и DEBT_UNRESOLVED с причиной на каждую строку, и закрытый долг тоже обязан быть удалён — гейт это проверяет.
Доказательство работоспособности — в обе стороны. На старом коммите eba1008 гейт падает ровно на 12 ключах today.howitworks.* × 6 локалей — тот самый инцидент. На master он зелёный. А если удалить pregnancy.detail.baby_size из ar.lproj — падает с именем ключа и локали.
Цена расширения анализа: время работы выросло с 3 до 7.6 секунд. Для build phase — приемлемо.
И честность про границы: план рефакторинга обещал «~2400 мёртвых ключей», проверка подтвердила только 62. Unused-список гейта сканирует исходники, а ключи, собираемые в рантайме, в коде целиком не встречаются — удаление таких «мёртвых» строк показало бы пользователю сырые ключи на экране (9bfad9b).
Три бага одной кухни#
Локализация ломалась в этом проекте не только на ключах — и каждый случай, который я разбирал, подтверждал ту же мысль.
stringsdict роняет приложение. Прошлый фикс краша откатили с неверным диагнозом. Настоящая причина: ключи правил были записаны как NSStringFormatSpecType/NSStringFormatValueType — без суффикса Key. Foundation ждёт NSStringFormatSpecTypeKey, правило не распознаётся, и %#@value@ доходит до форматирования нераскрытым — процесс падает. Сломано было 54 правила × 6 локалей (d841ef9).
Порядок спецификаторов. Уведомление о перепаде температуры: код передаёт аргументы как (Double, String), а шаблон во всех шести локалях ждал обратный порядок — печатало мусор либо падало, в зависимости от локали. Лечение — позиционные спецификаторы %2$@ / %1$.1f (c0ba693).
Два резолвера строк. Приложение одновременно резолвило строки через Bundle.main и через собственный механизм поверх UserDefaults — и при смене языка в настройках один экран показывал два языка сразу (ee29de2).
Где на самом деле ломается локализация#
Локализация ломается не в .strings-файлах — там всё обычно лежит. Она ломается в местах сборки ключей: интерполяция, составные префиксы, омонимы типов, суффиксы форматных правил. Значит, проверять надо код, а не только файлы переводов.
И второе: честный гейт обязан уметь сказать «я не уверен» и потребовать явного вердикта с причиной — вместо того чтобы молча пропустить. Все дыры, которые мы находили, были не там, где гейт падал зря, а там, где он молчал уверенно.



