Seis párrafos de la pantalla «Cómo funciona» de MeteoHealth salieron a producción en ruso en las seis locales — en un proyecto con 7784 claves × 6 idiomas (en, ru, es, zh-Hans, ja, ar) donde las traducciones formalmente «existían». La culpable es una sola línea de SwiftUI que parece absolutamente inocente:
Text(LocalizedStringKey("today.howitworks.\(topic).title"))La interpolación dentro de LocalizedStringKey no se compila en seis claves concretas —una por cada caso de topic—, sino en una única clave plantilla: today.howitworks.%@.title. Esa clave no existe en ninguna locale, y no puede existir. La búsqueda falla, SwiftUI imprime la cadena interpolada tal cual sin una sola advertencia, y el usuario ve la clave en bruto en lugar del texto.
El compilador aquí es impotente: desde su punto de vista todo es correcto. Los tests también callan — «alguna» cadena sí se renderizó. La respuesta fue un script escrito por mí, scripts/check_localization_coverage_impl.py — 1788 líneas de Python, un analizador estático de fuentes Swift que vive en una Xcode build phase y en CI y no permite compilar el proyecto mientras en el código haya una clave no cubierta por las seis locales.
Por qué grep no funciona#
La primera idea: «eso es solo grep sobre literales». No. Grep no sabe qué literal es una clave de localización y cuál es el nombre de una entrada en UserDefaults; no sabe que una cadena dentro de un comentario no es una cadena; y no sabe que "a.\(x).title" no es una clave, sino una familia.
Por eso el gate tiene su propio eliminador de comentarios Swift, en dos modos: strip y blank. El segundo sustituye los comentarios por espacios conservando la longitud del archivo — para que los números de línea del informe sean correctos. El docstring formula la razón sin rodeos:
def blank_swift_comments(src: str) -> str:
"""Como strip_swift_comments, pero LA LONGITUD SE CONSERVA: comentarios → espacios.
Es necesario para los números de línea en la salida. Eliminar comentarios desplaza
las posiciones y el gate señala una línea que no existe en el archivo — y un gate
que envía a la persona al lugar equivocado gasta exactamente el tiempo que ahorra.
"""Luego viene la semántica de las llamadas. El gate entiende el primer argumento posicional de 24 inicializadores de SwiftUI (Text, Label, Button, Toggle, TextField, Picker, Section, NavigationLink, Link, Menu, ProgressView, ContentUnavailableView…), de diez modificadores (navigationTitle, alert, confirmationDialog, accessibilityLabel, searchable) y los argumentos con nombre prompt:/placeholder:.
Solo esa extensión puso bajo protección +876 claves — y de inmediato encontró un fallo real: Text("common.more") en PregnancyDetailView, sin cadena en ninguna locale.
Claves que se ensamblan en tiempo de ejecución#
Las llamadas explícitas no lo son todo. Lo más interesante son las claves interpoladas del tipo "a.\(x).title". El gate las expande según los casos reales del enum que hay detrás de la interpolación: incluidos los enum con respaldo Int, los ternarios anidados (recursivamente) y las variables asignadas en ramas de switch. Si topic es un enum con seis casos, la plantilla se despliega en seis claves concretas, y cada una debe existir en seis locales.
Y cuando detrás de la interpolación no hay un enum, sino un catálogo de valores — «doce hitos de dejar de fumar», «semanas de embarazo» —, el gate lee la lista del código de producción por dirección (archivo + nombre de la constante), en lugar de guardar una copia propia:
INTERPOLATED_TEMPLATE_CATALOGS = {
"smoking.recovery.*.title": [
{"file": "SmokingDashboardModels.swift", "symbol": "all", "pick": "strings"}
],
}Un comentario en el script explica por qué exactamente así: «de lo contrario el gate empieza a verificar la verdad de ayer — se añade un hito, se olvida la cadena, y el gate calla porque se compara con la copia de la lista dentro de sí mismo». Un resultado vacío en la dirección también tumba la compilación: significa que la dirección quedó obsoleta.
«No es una clave» también es una afirmación#
El gate no tiene derecho a decidir en silencio «esto no es una clave». Si una plantilla se rechaza pero en los .strings realmente hay entradas bajo ella, la compilación falla y exige un veredicto: o una entrada en NON_CONTEXT_ALLOW con una razón verificable, o borrar las cadenas como muertas. La única entrada en esa lista hoy es daily_snapshot.*: es el nombre de una entrada en UserDefaults construida a partir de una fecha; la coincidencia con el prefijo de las cadenas de la tarjeta es casualidad, y la razón lo explica.
También funciona la convención inversa — el nombre como declaración: un literal de cadena asignado a algo terminado en …Key/…Keys (una propiedad, el resultado de una función, un elemento de detailKeys: […]) se considera clave de localización por defecto. La excepción documentada es exactamente una — storageKey en DailySnapshotService, donde el nombre habla honestamente de almacenamiento, no de traducción.
Puntos ciegos y cómo se encontraron#
Incluso un sistema tan meticuloso no lo atrapa todo. Los dos bugs más instructivos los encontré en el propio gate.
Claves compuestas. El patrón let base = "a.\(x)" → "\(base).title" el gate sabía sustituirlo. Pero la validación de la forma de la clave se ejecutaba ANTES de sustituir base, así que las claves que empezaban con un punto comodín del tipo *.title se caían silenciosamente de la verificación. Resultado: unas 200 cadenas vivas — cycle.superpower_detailed.* (120 cadenas), cycle.insight.intimacy.*, onboarding.v3.* — no estaban protegidas por absolutamente nada, con el gate en verde. Corregido en el commit f165ef1.
Homónimos de enumeraciones. GoalType existe dos veces en el proyecto — en Goal.swift y en SmokingEntry.swift; son enum distintos. El índice plano de tipos sobrescribía uno con el otro: el gate exigía claves inexistentes y a la vez dejaba pasar 7 reales. Ahora los tipos se direccionan con el archivo indicado — Goal.swift:GoalType.
A ambos bugs los une lo mismo: el gate estaba en verde. Un gate verde con un agujero es peor que no tener gate — produce una sensación de protección.
Un gate que no puede caducar#
Cualquier allowlist se convierte con el tiempo en un vertedero. Por eso aquí una entrada ALLOW que ya no cubre nada tumba la compilación exigiendo ser eliminada — «de lo contrario, "la lista solo se reduce" se sostiene sobre la palabra de honor». La deuda técnica está codificada en el propio script: DEBT_MISSING_KEYS y DEBT_UNRESOLVED con una razón por cada línea, y la deuda cerrada también debe borrarse — el gate lo comprueba.
La prueba de funcionamiento va en ambas direcciones. En el commit antiguo eba1008 el gate falla exactamente en 12 claves today.howitworks.* × 6 locales — aquel mismo incidente. En master está en verde. Y si se borra pregnancy.detail.baby_size de ar.lproj, falla con el nombre de la clave y de la locale.
El precio de ampliar el análisis: el tiempo de ejecución creció de 3 a 7,6 segundos. Para una build phase, aceptable.
Y honestidad sobre los límites: el plan de refactorización prometía «~2400 claves muertas»; la verificación confirmó solo 62. La lista de claves sin uso del gate escanea las fuentes, y las claves ensambladas en tiempo de ejecución no aparecen enteras en el código — borrar esas cadenas «muertas» habría mostrado al usuario claves en bruto en pantalla (9bfad9b).
Tres bugs de la misma cocina#
La localización se rompía en este proyecto no solo en las claves — y cada caso que analicé confirmaba la misma idea.
stringsdict tumba la aplicación. El fix anterior del crash se revirtió con un diagnóstico erróneo. La causa real: las claves de las reglas estaban escritas como NSStringFormatSpecType/NSStringFormatValueType — sin el sufijo Key. Foundation espera NSStringFormatSpecTypeKey, la regla no se reconoce, y %#@value@ llega al formateo sin expandir — el proceso se cae. Estaban rotas 54 reglas × 6 locales (d841ef9).
Orden de los especificadores. La notificación de cambio brusco de temperatura: el código pasa los argumentos como (Double, String), mientras que la plantilla en las seis locales esperaba el orden inverso — imprimía basura o se caía, según la locale. La cura — especificadores posicionales %2$@ / %1$.1f (c0ba693).
Dos resolutores de cadenas. La aplicación resolvía cadenas a la vez a través de Bundle.main y de su propio mecanismo sobre UserDefaults — y al cambiar el idioma en los ajustes, una pantalla mostraba dos idiomas al mismo tiempo (ee29de2).
Dónde se rompe la localización en realidad#
La localización no se rompe en los archivos .strings — ahí normalmente todo está en su sitio. Se rompe en los lugares donde las claves se ensamblan: interpolación, prefijos compuestos, homónimos de tipos, sufijos de las reglas de formato. Es decir, hay que verificar el código, no solo los archivos de traducción.
Y lo segundo: un gate honesto debe saber decir «no estoy seguro» y exigir un veredicto explícito con una razón — en lugar de dejar pasar en silencio. Todos los agujeros que encontramos no estaban donde el gate fallaba en vano, sino donde callaba con seguridad.



