Skip to content

i18n audit: count plural entries as translated in the coverage summary - #1616

Merged
ryanbr merged 1 commit into
mainfrom
fix/i18n-audit-plural-coverage
Aug 25, 2026
Merged

ryanbr merged 1 commit into
mainfrom
fix/i18n-audit-plural-coverage

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 25, 2026 •

Copy link
Copy Markdown
Owner

The audit's human-readable coverage summary reported gaps that do not exist:

Strand/Resources/Localizable.xcstrings (3601 keys):  de missing=4  es missing=4  fr missing=4  pl missing=5  pt-PT missing=4

Every one of those 21 entries is translated, in every plural form. %lld days is %lld Tage, %lld días, %lld jours, %lld dias, %lld dnia. Syncing strap history, %lld chunks is "Synchronisation de l'historique du bracelet, %lld blocs".

The bug

catalog_summary read the state directly:

state = (v.get("localizations", {}).get(lang) or {}).get("stringUnit", {}).get("state")

A pluralised entry has no stringUnit at that level — its units live under variations.plural.<category>.stringUnit. So the lookup returns None, None != "translated", and a fully-translated plural scores as a gap.

This is worse than a wrong number in two ways.

It sends you to do work that is already done. I went looking for these 21 strings to translate them and found them already translated — that is the only reason the bug surfaced.

And it defames Polish specifically. %lld markers counts as missing for pl alone, because Polish carries one/few/many/other where de/es/fr/pt-PT need only a flat unit. The language with the most correct pluralisation scored worst.

The fix

_string_units and _is_translated already exist for exactly this. The former's docstring:

Reading only the FIRST shape makes every pluralised entry look untranslated to this gate — so converting a hand-rolled ternary into real plural variations would red-flag the string in every language. Walk both shapes.

The gate was fixed then. This summary was the one caller left doing the naive read, which is a familiar shape: #844's own comment notes that showing only LANGS here is "very likely WHY the drift went unnoticed for so long". That pass fixed the locale blind spot and left a structural one behind it.

Routed through _is_translated, which also preserves the strict direction — a plural with one category still new remains a gap.

After

de missing=0  es missing=0  fr missing=0  pl missing=0  pt-PT missing=0

A second, unrelated change in this PR — and a correction

i18n_extra_locale_baseline.txt is lowered on four targets: it 181→179, ru 158→156, zh-Hans 131→129, zh-Hant 181→179.

My first version of this description claimed those improvements came from the plural fix. They do not. I checked against a clean checkout of main, and the gate already reports exactly those four IMPROVED lines with the audit untouched — the gate has been plural-aware since #844, and only the summary was naive. The ratchet was simply stale by two on each, because real translations landed after it was last written, and the tool asks for it to be lowered on every run.

So it is a genuine tidy-up that this PR happens to carry, not a consequence of the fix. Down only, as the file requires; every change verified to decrease. Split it out if you would rather keep the diff to one concern.

The real drift is unchanged in substance: it, ru and zh-* remain the genuinely untranslated locales, and that is separate work.

A sibling blind spot, found in review and deliberately not fixed here

_ios_echoed_counts (the detector for translations that are verbatim English) does the same flat unit.get("stringUnit") read, so it cannot see plural forms either.

I measured it before deciding: zero plural echoes are currently hidden by it. The blind spot is real but costs nothing today, and fixing it would touch the echo baseline for no present gain. Recording it here so it is not rediscovered from scratch.

Tests

Five, and they pin the summary rather than only the shared helper — a revert would restore the flat read while _is_translated's own coverage stayed green:

  • a fully-translated plural is not a gap, across all four focus locales
  • Polish one/few/many/other is not penalised for having more categories
  • a partially-translated plural is still a gap (the fix must not over-correct)
  • flat stringUnit entries still count exactly as before
  • shouldTranslate: false is still skipped

Verification

  • python3 -m unittest discover over Tools/ — all green, which is what tools-python.yml runs
  • i18n_audit.py --ci passes, and --full shows the corrected table above

The human-readable coverage summary read localizations[lang].stringUnit.state
directly. A pluralised entry keeps its units under
variations.plural.<category>.stringUnit, so that flat lookup returned None and
scored a fully-translated plural as a gap.

It reported de/es/fr/pt-PT missing=4 and pl missing=5 on the Strand catalog when
every one of those entries was translated in every plural form - "%lld days" is
"%lld Tage", "%lld días", "%lld jours", "%lld dias", "%lld dnia". Worse than a
wrong number: it sent a reader off to re-translate strings that were already
done, and it made Polish look like the worst-covered shipped language precisely
BECAUSE it correctly carries one/few/many/other where the others need only a
flat unit.

`_string_units` and `_is_translated` were written for exactly this - the former's
docstring says "Reading only the FIRST shape makes every pluralised entry look
untranslated to this gate". The GATE was fixed then (#844); this summary was the
one caller still doing the naive read. It now goes through `_is_translated`,
which also keeps a partially-translated plural counting as a gap.

Separately, and NOT caused by the above: the ratchet in
i18n_extra_locale_baseline.txt was stale by two on four targets (it 181->179,
ru 158->156, zh-Hans 131->129, zh-Hant 181->179). The gate already computed the
lower numbers on unmodified main and asked for the file to be lowered on every
run, because real translations had landed since it was last written. Lowered to
match, down only, as the file requires. The genuine drift is unchanged in
substance: it/ru/zh remain the untranslated locales.

Five tests pin the summary itself rather than only the shared helper, since a
revert would restore the flat read and leave the helper's own tests green.
@ryanbr
ryanbr force-pushed the fix/i18n-audit-plural-coverage branch from db7f800 to 8cf5c72 Compare August 25, 2026 06:42
@ryanbr
ryanbr merged commit bd10e83 into main Aug 25, 2026
3 checks passed
@ryanbr
ryanbr deleted the fix/i18n-audit-plural-coverage branch August 25, 2026 06:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant