Skip to content

New action: localization placeholder-compatibility check (catch placeholder-breaking changes to existing keys) #736

Description

@jkmassel

Summary

Add a reusable placeholder-shape primitive plus an action that fails when an existing localization key's source-language value changes its format placeholders — count, position, or argument type — between two versions of the source strings. Such a change silently breaks every existing translation for that key.

This is a different axis from ios_lint_localizations, which compares each translation against the base language at a single point in time. This compares base ↔ base across versions (temporal).

Filed for the WordPress-iOS continuous-localization work ("Faster Releases" RFC). Reference implementation: wordpress-mobile/WordPress-iOS#25675fastlane/helpers/string_placeholders.rb + the validate_string_placeholders lane. WPiOS will adopt the toolkit version once it lands and delete the project-side copy.

Motivation

The continuous-localization model uploads regenerated source strings on every trunk merge and downloads whatever human translations exist daily — so whatever translations exist ship as-is. That is only safe if a translation key is never reused for placeholder-incompatible copy:

  • GlotPress (and most TMS) key translations by the source string's key, not its current English text.
  • If a key's English changes from "%1$@ liked your post" to "%1$d people liked your post" (object → int), existing translations stay under the same key, and the app formats the new call site's Int argument with a translation that still expects an object → wrong output or a crash.

Today this invariant is convention-only. We need an automated guardrail in CI.

The invariant

For every key present in both the previous and the newly-generated source-language strings, the set/sequence of format specifiers — accounting for positional args and argument type — must be identical. New and removed keys are ignored: copy that needs a fresh translation is expected to land under a new key (which shows up as remove-old + add-new).

Proposed API

1. Reusable primitive (the important part — also consumed by the companion AI-translation backfill issue)

A platform-agnostic helper operating on raw strings / { key => value } hashes:

  • placeholder_signature(value) -> String — canonical signature of the placeholders in a string ('' if none).
  • placeholders_compatible?(a, b) -> Booleansignature(a) == signature(b).
  • incompatible_placeholder_changes(old_hash, new_hash) -> Array<Hash> — keys present in both whose signature differs, each { key:, old:, new:, old_signature:, new_signature: }.

2. Action

A Fastlane action that fails (or reports) on incompatible changes between two versions of a base strings file. Suggested options (mirroring ios_lint_localizations):

Option Description
old_file / new_file Paths to the previous and newly-generated base strings.
abort_on_violations Abort the lane on any violation (default true).
allow_retry Interactive retry prompt (parity with ios_lint_localizations).

The signature algorithm (please implement exactly)

The signature must be invariant to benign copy edits and to reordering equivalent positional args, but sensitive to count/position/type changes.

  1. Extract format specifiers with a printf/NSString regex: %, optional positional N$, flags [-+ 0#], width, .precision, length modifier (hh|h|ll|l|q|L|z|t|j), conversion char [@diouxXeEfgGaAcCsSpn%].
  2. Skip %% (literal percent — not a placeholder).
  3. Map the conversion char to an argument-type CLASS (so %d%i are compatible but %d%@ are not):
    • @object
    • d i u o x Xint
    • e E f g G a Afloat
    • c Cchar
    • s Scstring
    • ppointer
  4. Key each specifier by position: explicit (%1$@ → 1) or, for non-positional specifiers, by appearance order (1, 2, 3…). Sort by position, render each as position:class, join with ,. Empty string if there are no specifiers.

Worked examples

Value Signature
Just text (empty)
Hello %@ 1:object
%1$d items in %2$@ 1:int,2:object
%2$@ told by %1$@ 1:object,2:object
100%% sure about %@ 1:object
%d likes 1:int

Compatibility (same signature ⇒ compatible):

Old New Compatible? Why
Hello %@ Hi %@ text-only change
%1$@ said %2$@ %2$@ told by %1$@ positional reorder, same types
%d likes %@ likes int → object
%1$d in %2$@ %1$d only dropped a placeholder

Parsing the strings files

iOS .strings are old-style property lists. The WPiOS reference parses them with plutil -convert json (most robust — handles escapes, comments, encodings). Important for a cross-platform toolkit: plutil is macOS-only, but the toolkit's Android actions run on Linux. Keep the signature primitive pure-Ruby (portable); gate the .strings parser behind macOS, and provide a pure-Ruby .strings parser (or reuse Fastlane::Helper::Ios::L10nHelper) for portability. Android (strings.xml) needs its own parser.

Cross-platform considerations

printf placeholders are shared by iOS (%@, %1$d) and Android (%1$s, %d), so the signature primitive is platform-agnostic. Recommend the toolkit's usual split:

  • common/ — the signature primitive + the diff.
  • ios_* / android_* wrappers — parse the platform's file format into { key => value } and call the common diff.

Relationship to existing toolkit actions

  • ios_lint_localizations / L10nLinterHelper — cross-locale (base ↔ translations), single point in time, SwiftGen-based. Complementary; this is the temporal axis. (The lightweight primitive here could later also back a SwiftGen-free re-implementation of that linter — out of scope.)
  • an_validate_lib_strings — related Android validation.

Edge cases to cover

  • Positional reorder of same-typed args → compatible.
  • %% literal → not a placeholder.
  • Mixed positional + non-positional in one string (rare/invalid) → be conservative/deterministic.
  • New / removed keys → ignored.
  • Duplicate keys → defer to the existing duplicate-key check (StringsFileValidationHelper).
  • Empty / whitespace-only values; non-ASCII; escaped quotes/backslashes.
  • Width / precision / length modifiers → ignored for type-classing.
  • .stringsdict / ICU plurals → open question (different structure; likely v2).

Obtaining the previous version

The action compares old vs new base strings. WPiOS does: regenerate strings (generate_strings_file_for_glotpress skip_commit:true), snapshot the committed version via git show HEAD:<path> to a temp file, then diff. Recommend the action take two paths (pure), with an optional helper for the git-ref case.

Testing

  • RSpec covering every example/edge case above (port the WPiOS fixtures).
  • Pure-Ruby signature tests run anywhere; gate file-parsing tests by platform / use a pure-Ruby parser so Android/Linux CI passes.

Acceptance criteria

  • common/ signature primitive (placeholder_signature, placeholders_compatible?, incompatible_placeholder_changes).
  • Action that fails/reports on incompatible base↔base changes, with abort_on_violations / allow_retry.
  • iOS file-format wrapper; Android wrapper (or a follow-up issue).
  • RSpec, CHANGELOG entry, actions/README.md entry.
  • Signature output matches the worked examples exactly.

Open questions

  • Naming (lint_localization_placeholder_changes? check_translations_placeholder_consistency?).
  • Strictness of type classes (%d%i compatible — yes; revisit unsigned/hex).
  • .stringsdict / Android plurals / ICU MessageFormat support.
  • Portable .strings parser vs plutil (macOS-only) — required for cross-platform.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions