Commit 9067e31
Localization: stage translations into the String Catalog (manual) (#25713)
* Stage regular-string translations into Localizable.xcstrings
Adds the localize half of the String Catalog pipeline: a manual (non-CI) lane that folds current GlotPress translations for regular, non-plural strings into the existing Localizable.xcstrings as `human ?? AI ?? English`. The catalog isn't the runtime store yet (the app still ships Localizable.strings), so this only pre-populates it for the eventual cutover — nothing users see changes.
- `localize_catalog` lane: download current GlotPress translations into a throwaway dir, fold them in (human => `translated`), then AI-fill the rest (=> `needs_review`). Manual only — it calls the translation API (cost) and commits a large catalog; never wired into `download_localized_strings` or CI.
- `CatalogStrings.fold_translations!`: pure, reuse-aware fold. An existing valid cell is kept, so re-runs only translate genuinely-new gaps — the catalog's `needs_review` state IS the persistence (no side-store). A human translation from GlotPress supersedes a kept cell on the next fold.
- Immutable-key enforcement replaces `reconcile_changed_sources`: reworded English in place is now a hard error, since `xcstringstool sync` silently keeps the old key's translations and would ship stale text. Rewording requires a new key.
- `translate_all` keeps completed batches when one fails mid-run, so a partial API failure degrades to English for the remainder instead of dropping the lot.
- docs/localization-pipeline.md documents the regular-string fold and the `human ?? AI ?? English` floor — any translation, human or machine, that fails the placeholder gate falls through to the next rung.
* Failing tests: placeholder gate rejects valid reordered human translations (#25802)
* Add failing tests: placeholder gate rejects valid reordered human translations
TranslationValidator compares a source's positional and sequential specifier
views independently, so it only permits reordering when the source is ALREADY
positional. A non-positional source (`%@ … %@`) whose translation reorders via
positional specifiers (`%2$@ … %1$@`) is rejected — even though that is the
standard, Apple-documented iOS reordering mechanism and is correct at runtime
(`String(format:)` honors positional specifiers regardless of the source shape).
The human-translation gate added for the String Catalog fold
(`CatalogStrings.trusted_human`, `PluralStrings.human_forms_for`) inherits this,
so it rejects and downgrades valid, currently-shipping human translations to
machine/English. This is not hypothetical: the committed translated `.strings`
contain 23 key-as-source strings across 34 locales (513 cells) that positionalize
a bare-`%@` English source this way, e.g. ar "%@ of %@ used on your site" =>
"%1$@ من %2$@ على موقعك".
Two failing tests pin the correct behavior:
- translation_validator_test.rb — the root cause at the validator.
- catalog_strings_helper_test.rb — the user-facing downgrade in the fold.
The fix belongs in TranslationValidator (shared by AI, plurals, and regular
human strings): treat a non-positional source as implicitly numbered 1..N by
appearance order and accept a fully-positional candidate whose index→type map
matches. Existing tests already keep genuine breakage (sequential flip, type/
count change) rejected, so a correct fix cannot just drop the distinction.
* Fix placeholder gate: accept positional reorder of a non-positional source
TranslationValidator compared a source's positional and sequential specifier
views independently, so it only permitted reordering when the source was ALREADY
positional. A non-positional source (`%@ … %@`) whose translation reorders via
positional specifiers (`%2$@ … %1$@`) was rejected — even though that is the
standard, Apple-documented iOS mechanism and is correct at runtime, because
`String(format:)` honors positional specifiers regardless of the source's shape.
Normalize a purely-sequential signature into the implicit 1..N positional map its
bare specifiers already carry (`%@ %d` => `{1 => object, 2 => int}`) before
comparing, applied to both source and candidate. Once both sides are index→type
maps, `%@ - %@` and `%2$@ - %1$@` compare equal and the reorder is accepted.
Genuine breakage stays rejected: a sequential flip `%@: %d` => `%d : %@` yields
`{1 => object, 2 => int}` vs `{1 => int, 2 => object}` — a disagreement on which
index owns which type — and type/length/count changes are unaffected. A signature
that already uses positional specifiers, or one that mixes bare and positional
specifiers (malformed), is left unchanged, so mixed strings still require an exact
structural match.
Fixes the two failing tests added in the previous commit and propagates to all
three gate consumers (AI, plurals, regular human strings) via the shared
validator. All 7 fastlane/lanes suites pass (113 tests); rubocop clean.
* Add failing tests: gate and coverage compare miss format-argument edge cases
Three currently-red tests documenting real defects found auditing the
placeholder gate and the build-free coverage compare:
- A candidate that binds the same positional index to two different types
(%1$@ %1$d) is accepted: signature() collapses it last-wins, and this PR's
implied_positional widened the reach to bare-specifier sources (%d), which
regressed from reject to accept.
- Dynamic field width/precision (%*d, %.*f) consume an extra int vararg the
signature never counts, so %d and %*d reduce to the same signature.
- coverage_gap conflates keys that differ only in argument type (%d days vs
%@ days), masking a genuinely dropped key behind a same-prose sibling.
* Fix placeholder gate: reject a positional index reused with a conflicting type
signature() stored positional specifiers in a Hash keyed by index with plain
assignment, so a second reference to the same index (`%1$@` then `%1$d`)
silently overwrote the first, collapsing an object/int conflict to one token.
A candidate that references argument 1 as both an object and an int passes an
int to a `%@` conversion at runtime — a wrong-vararg read. This PR's
implied_positional widened the reach: a bare-specifier source like `%d` now
normalizes to {1 => int} and matched the collapsed candidate, regressing that
case from reject to accept.
Record a conflicting reuse as a distinct token so it can never equal a
well-formed source's single type, while still allowing a consistent reuse
(`%1$d … %1$d`, which legally reads one argument twice).
* Fix placeholder gate: count dynamic field width/precision arguments
A `*` in a specifier's field width or precision (`%*d`, `%.*f`) consumes its
own int argument before the value, so `%d` and `%*d` are not interchangeable.
The FORMAT_SPECIFIER regex matched the `*` but signature() emitted no token
for it, so the two reduced to the same signature and a translation could add
or drop a dynamic-width/precision argument undetected — reading one vararg
past what the call site supplies. Emit an int argument for each `*`, in
appearance order before the value.
* Fix coverage compare: keep argument type when canonicalizing keys
canonical() replaced every format specifier with one sentinel regardless of
type, so keys differing only in argument type (`%d days` vs `%@ days`)
collapsed to the same coverage cell — a genuinely-dropped key was masked by a
same-prose sibling of a different type, defeating the same-basename overwrite
regression the coverage gate exists to catch. Strip only the positional index
(the sole difference between the genstrings source-form `%li` and the
xcstringstool-normalized `%1$li` for the same source literal), so `%li` and
`%1$li` still compare equal while `%d` and `%@` stay distinct.
* Satisfy RuboCop: extract signature helpers and merge the catalog test class
Danger's RuboCop pass flagged two offenses from the previous commits:
- Metrics/AbcSize: signature() exceeded the ABC limit once it gained the
positional-conflict and dynamic-width handling. Extract record_specifier
and add_positional so signature() is a thin loop again; behavior unchanged.
- Style/OneClassPerFile: the coverage_gap test added a second top-level class
to catalog_helper_test.rb. Fold it into the file's single test class,
renamed CatalogHelperTest since it now covers both reword detection and the
coverage compare.
---------
Co-authored-by: Jeremy Massel <1123407+jkmassel@users.noreply.github.com>
* Require ANTHROPIC_API_KEY for localize_catalog
A keyless run has no AI tier, so every gap folds to an English placeholder and the lane would commit a dense, near-zero-value catalog (every translatable key × every locale materialized). Fail fast up front instead of staging it.
The waste is specific to the keyless path — with AI on the same cells carry machine translations, and any English fallback is a sparse, self-healing "retry" signal the next run fills. fold_translations! stays nil-tolerant (unit-tested, and a future human-only flag would want it); only the lane is gated.
Raised in review of the no-API-key run.
---------
Co-authored-by: Oguz Kocer <oguzkocer@users.noreply.github.com>1 parent 72dab72 commit 9067e31
13 files changed
Lines changed: 1167 additions & 113 deletions
File tree
- docs
- fastlane/lanes
- fixtures
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
5 | | - | |
| 5 | + | |
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
| |||
45 | 45 | | |
46 | 46 | | |
47 | 47 | | |
48 | | - | |
| 48 | + | |
49 | 49 | | |
50 | | - | |
| 50 | + | |
51 | 51 | | |
52 | | - | |
| 52 | + | |
53 | 53 | | |
54 | | - | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
55 | 62 | | |
56 | 63 | | |
57 | 64 | | |
| |||
77 | 84 | | |
78 | 85 | | |
79 | 86 | | |
80 | | - | |
| 87 | + | |
81 | 88 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
160 | 160 | | |
161 | 161 | | |
162 | 162 | | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
163 | 168 | | |
164 | 169 | | |
165 | 170 | | |
166 | 171 | | |
167 | 172 | | |
168 | 173 | | |
169 | 174 | | |
170 | | - | |
171 | | - | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
172 | 181 | | |
| 182 | + | |
173 | 183 | | |
174 | 184 | | |
175 | 185 | | |
| |||
297 | 307 | | |
298 | 308 | | |
299 | 309 | | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
300 | 318 | | |
301 | 319 | | |
302 | 320 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| 7 | + | |
7 | 8 | | |
8 | 9 | | |
9 | 10 | | |
| |||
17 | 18 | | |
18 | 19 | | |
19 | 20 | | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
20 | 31 | | |
21 | 32 | | |
22 | 33 | | |
| |||
182 | 193 | | |
183 | 194 | | |
184 | 195 | | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
185 | 227 | | |
186 | 228 | | |
187 | 229 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
27 | | - | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
28 | 32 | | |
29 | | - | |
| 33 | + | |
30 | 34 | | |
31 | 35 | | |
32 | | - | |
| 36 | + | |
33 | 37 | | |
34 | | - | |
35 | | - | |
36 | | - | |
37 | | - | |
38 | | - | |
39 | | - | |
40 | | - | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
41 | 46 | | |
42 | | - | |
43 | | - | |
44 | | - | |
45 | | - | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
46 | 50 | | |
47 | | - | |
| 51 | + | |
48 | 52 | | |
49 | | - | |
50 | | - | |
| 53 | + | |
| 54 | + | |
51 | 55 | | |
52 | | - | |
53 | | - | |
54 | | - | |
55 | | - | |
56 | | - | |
57 | | - | |
58 | | - | |
59 | | - | |
60 | | - | |
61 | | - | |
62 | | - | |
63 | | - | |
64 | | - | |
65 | | - | |
66 | | - | |
67 | | - | |
68 | | - | |
69 | | - | |
70 | | - | |
71 | | - | |
72 | | - | |
73 | | - | |
74 | | - | |
75 | | - | |
76 | | - | |
77 | | - | |
78 | | - | |
79 | | - | |
80 | | - | |
81 | | - | |
82 | | - | |
83 | | - | |
84 | | - | |
85 | | - | |
86 | | - | |
87 | | - | |
88 | | - | |
89 | | - | |
90 | | - | |
91 | | - | |
92 | | - | |
93 | | - | |
94 | | - | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
95 | 59 | | |
96 | | - | |
97 | 60 | | |
98 | 61 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
0 commit comments