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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions