You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[i18n] A string that never enters the String Catalog is invisible to the audit #844
Rescoped after #850 landed. The original report bundled three blind spots; the biggest is fixed, one is a genuine limitation rather than a to-do, and this is what is left.
The gap
The Apple pass checks that each entry already inLocalizable.xcstrings has every language. A LocalizedStringKey or String(localized:) literal that was never extracted into the catalog is simply not seen — English ships to every locale, and the audit reports green.
It measures coverage within the catalog, never completeness of it.
case .connectedNoData:return"No live heart rate or synced history yet this session."String(localized:"Connected. No live heart rate or synced history yet this session.")
Neither is in Strand/Resources/Localizable.xcstrings. Both sibling chip strings ("Strap not connected. Tap to connect.", "History sync is experimental on 5.0.") are. CI is green on that PR.
What a fix looks like
Scan app-target Swift for String(localized: "…") and LocalizedStringKey literals, and fail when one has no catalog entry.
The hard part is false positives, and this gates every PR — so it needs care:
interpolated literals ("\(n) pages behind") key differently in the catalog
String(localized:) with table:/bundle:/comment: arguments
Text(variable) invisibility (original item 3) — a static scan cannot resolve Text(someVar) without type inference, and the tool already documents this class of blind spot in its own comments. Recording it as a permanent limitation rather than an open task.
The locale-list gap (original item 1) — fixed in #850: both language lists are now discovered from what ships, with a ratcheting allowance over the 1019 pre-existing gaps.
Whether it/ru/zh-* should ship at all in their current state — that is a product decision, not a tooling one, and it now has its own issue.
Scoping this properly — it is substantially worse than I filed. The Android values-zh gap is one symptom of a general one.
Tools/i18n_audit.py:34 is LANGS = ["de", "es", "fr", "pt-PT"]. The Apple catalogs actually ship eight non-English localizations. So the gate covers four of eight, and the four it does not cover have drifted exactly as you would expect:
catalog
de/es/fr/pt-PT
it
ru
zh-Hans
zh-Hant
Strand (3255 keys)
100 %
3086
3110
3136
3086
StrandDesign (95)
100 %
14
41
20
14
NOOPWatch (52)
100 %
49
50
49
49
NOOPWatchComplications (30)
100 %
5
30
5
5
The four gated languages are at 100 % in every catalog. The four ungated ones are missing up to 85 % of their strings — StrandDesign ships 14 of 95 Italian, and the watch complications 5 of 30. That is the gate doing its job where it is pointed, and nothing happening where it is not.
Android is the same shape: ANDROID_LOCALE_DIRS hardcodes the same four, values-zh exists on disk and is never compared, and it sits 43 keys behind.
So the one-line framing from the original report holds, but the fix is not "add zh": it is derive the language list from what is actually shipped — the union of localizations across the catalogs, and ls values-*/ on Android — so a locale is covered the day it appears rather than the day someone remembers to add it to a constant.
That cannot be switched on as-is: hundreds of keys are missing, so the gate would go red for everyone immediately. Same answer as #849 — a per-(catalog, language) ratcheting baseline so it starts green, blocks NEW omissions, and reports IMPROVED when a count drops. PR coming.
Worth being explicit about what this does not do: it does not translate anything. It stops the hole growing and makes the existing debt countable. Whether it/ru/zh-* should be shipped at 14/95 or removed until they are filled is a separate call, and one I would rather you make than have implied by a linter.
Item 1 is fixed and merged as 0dffd7bb (#850). Leaving this open for items 2 and 3.
What landed. Both language lists are now discovered rather than declared — Apple from each catalog's own localizations, Android from values-* on disk. LANGS keeps zero tolerance (those four are perfect because they are hard-gated); the 1019 gaps in the newly covered locales sit behind a ratcheting allowance so the gate starts green and blocks only growth.
catalog_summary() turned out to have the same blind spot and is fixed too — it reported four of eight languages, so the coverage view read missing=0 across the board. That is very likely why this survived: anyone who ran the audit to check coverage was shown a clean sheet. It now doubles as the burn-down view.
Still open here:
Item 2 — a LocalizedStringKey / String(localized:) literal that never reached the catalog is invisible. Distinguish "connected, no data" from "not connected" (#612) #826 is the live example. This is the harder one: it needs a source scan that distinguishes real literals from interpolations and variables, so it has genuine false-positive risk against a gate that blocks every PR.
And a product question this surfaced, which is yours rather than the linter's: it, ru, zh-Hans and zh-Hant currently ship at up to 85 % untranslated — StrandDesign is 14 of 95 Italian, the watch complications 5 of 30. The options are to fill them, or to stop shipping them until they are filled. Right now a user with an Italian device gets a mostly-English app that looks half-broken rather than honestly English.
changed the title [-][i18n] The audit validates what's already tracked, not whether tracking is complete — values-zh is 42 keys behind and unchecked[/-][+][i18n] A string that never enters the String Catalog is invisible to the audit[/+]on Jul 27, 2026
Activity
ryanbr commentedon Jul 27, 2026
Scoping this properly — it is substantially worse than I filed. The Android
values-zhgap is one symptom of a general one.Tools/i18n_audit.py:34isLANGS = ["de", "es", "fr", "pt-PT"]. The Apple catalogs actually ship eight non-English localizations. So the gate covers four of eight, and the four it does not cover have drifted exactly as you would expect:Strand(3255 keys)StrandDesign(95)NOOPWatch(52)NOOPWatchComplications(30)The four gated languages are at 100 % in every catalog. The four ungated ones are missing up to 85 % of their strings —
StrandDesignships 14 of 95 Italian, and the watch complications 5 of 30. That is the gate doing its job where it is pointed, and nothing happening where it is not.Android is the same shape:
ANDROID_LOCALE_DIRShardcodes the same four,values-zhexists on disk and is never compared, and it sits 43 keys behind.So the one-line framing from the original report holds, but the fix is not "add zh": it is derive the language list from what is actually shipped — the union of
localizationsacross the catalogs, andls values-*/on Android — so a locale is covered the day it appears rather than the day someone remembers to add it to a constant.That cannot be switched on as-is: hundreds of keys are missing, so the gate would go red for everyone immediately. Same answer as #849 — a per-(catalog, language) ratcheting baseline so it starts green, blocks NEW omissions, and reports IMPROVED when a count drops. PR coming.
Worth being explicit about what this does not do: it does not translate anything. It stops the hole growing and makes the existing debt countable. Whether
it/ru/zh-*should be shipped at 14/95 or removed until they are filled is a separate call, and one I would rather you make than have implied by a linter.ci(i18n): cover every shipped locale, not the four in a constant (#850)
ryanbr commentedon Jul 27, 2026
Item 1 is fixed and merged as
0dffd7bb(#850). Leaving this open for items 2 and 3.What landed. Both language lists are now discovered rather than declared — Apple from each catalog's own
localizations, Android fromvalues-*on disk.LANGSkeeps zero tolerance (those four are perfect because they are hard-gated); the 1019 gaps in the newly covered locales sit behind a ratcheting allowance so the gate starts green and blocks only growth.catalog_summary()turned out to have the same blind spot and is fixed too — it reported four of eight languages, so the coverage view readmissing=0across the board. That is very likely why this survived: anyone who ran the audit to check coverage was shown a clean sheet. It now doubles as the burn-down view.Still open here:
LocalizedStringKey/String(localized:)literal that never reached the catalog is invisible. Distinguish "connected, no data" from "not connected" (#612) #826 is the live example. This is the harder one: it needs a source scan that distinguishes real literals from interpolations and variables, so it has genuine false-positive risk against a gate that blocks every PR.Text(variable)invisibility, already understood.And a product question this surfaced, which is yours rather than the linter's:
it,ru,zh-Hansandzh-Hantcurrently ship at up to 85 % untranslated —StrandDesignis 14 of 95 Italian, the watch complications 5 of 30. The options are to fill them, or to stop shipping them until they are filled. Right now a user with an Italian device gets a mostly-English app that looks half-broken rather than honestly English.[-][i18n] The audit validates what's already tracked, not whether tracking is complete — values-zh is 42 keys behind and unchecked[/-][+][i18n] A string that never enters the String Catalog is invisible to the audit[/+]Escalate strap battery alerts: a critical 12% crossing + a bedtime ni…
Localize the battery notification strings that never reached the Appl…
ci(i18n): cover every shipped locale, not the four in a constant (rya…
Escalate strap battery alerts: a critical 12% crossing + a bedtime ni…
Localize the battery notification strings that never reached the Appl…
i18n audit: count plural entries as translated in the coverage summary
i18n audit: count plural entries as translated in the coverage summary (
i18n(android): add a Russian locale
i18n(android): add a Russian locale
i18n: add a Russian locale to Android, and backfill Apple's from it (#…
i18n: translate "NOOP (in Files)", the other half of folderLabel
i18n: translate "NOOP (in Files)", the other half of folderLabel
backup: say WHICH folder backups go to, iCloud Drive included (#1876)