Skip to content

[i18n] A string that never enters the String Catalog is invisible to the audit #844

Open

Description

@ryanbr

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 in Localizable.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.

Live example

#826 adds two strings via the correct mechanism:

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:

Not tracked here

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.

Activity

  1. ryanbr commented on Jul 27, 2026

    @ryanbr
    OwnerAuthor

    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.

  2. ryanbr commented on Jul 27, 2026

    @ryanbr
    OwnerAuthor

    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.
    • Item 3 — Text(variable) invisibility, already understood.

    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.

  3. 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions