Skip to content

Add ios_lint_localization_placeholder_changes action#738

Draft
jkmassel wants to merge 1 commit into
foundationfrom
jkmassel/issue-736
Draft

Add ios_lint_localization_placeholder_changes action#738
jkmassel wants to merge 1 commit into
foundationfrom
jkmassel/issue-736

Conversation

@jkmassel

@jkmassel jkmassel commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Depends on #741. The .strings parsing and duplicate-key detection this action builds on were extracted there, so this PR is now just the primitive + the action. Review/merge #741 first; GitHub will retarget this PR to trunk once it lands.

What does it do?

Adds a placeholder-compatibility guardrail for localization source strings, as requested in #736.

Existing translations are keyed by a string's key, not its English text. If an existing key's source value changes its format placeholders — count, position, or argument type — every translation filed under that key silently breaks (or crashes) at the new call site. 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).

1. Reusable primitive — Fastlane::Helper::StringPlaceholdersHelper

Pure Ruby, no file I/O, platform-agnostic (the %@ / %1$d placeholder syntax is shared by iOS and Android):

  • placeholder_signature(value) — canonical signature of a value's placeholders ('' if none), e.g. "1:int,2:object".
  • placeholders_compatible?(a, b)signature(a) == signature(b).
  • incompatible_placeholder_changes(old_hash, new_hash) — the keys present in both whose signature differs. New/removed keys are ignored on purpose: copy that needs a fresh translation is expected to land under a new key.
  • mixed_operators?(value) — whether a value invalidly mixes positional (%1$@) and non-positional (%@) specifiers.

The signature is invariant to benign copy edits and to reordering equivalent positional args, but sensitive to count/position/type changes. %d%i are compatible; %% is skipped; the ,/' grouping flags are recognized; * dynamic width/precision and the write-back %n are deliberately left unrecognized. A single literal % followed by whitespace is treated as a literal percent, not a specifier, so ordinary copy like "50% off" doesn't fabricate a phantom placeholder.

2. iOS action — ios_lint_localization_placeholder_changes

Takes old_file / new_file (the previous and newly-generated base .strings), parses them via the existing L10nHelper.read_strings_file_as_hash (plutil), runs the primitive, and reports. Mirrors ios_lint_localizations's abort_on_violations (default true).

It fails closed on any input where the comparison would be unreliable:

  • check_duplicate_keys (default true) — aborts when either file defines a key more than once (plutil silently keeps only the last value, which could otherwise hide a real placeholder change). Delegates to the shared StringsFileValidationHelper.scan_for_duplicate_keys from Improve .strings handling: unquoted strings, inline comments, and merge prefixing #741.
  • Always aborts (independently of abort_on_violations) when a value mixes positional and non-positional placeholders — that's invalid and leaves the signature undefined.

Worked examples (match the issue spec — verified by tests)

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

Notes / scope

  • Builds on Improve .strings handling: unquoted strings, inline comments, and merge prefixing #741, which carries the .strings parsing (unquoted keys/values, inter-token comments) and the scan_for_duplicate_keys helper this action calls.
  • Android wrapper deferred to a follow-up, as the issue permits — the primitive is already platform-agnostic, so the follow-up is a thin android_* parse-and-call wrapper.
  • Reuses L10nHelper.read_strings_file_as_hash rather than adding a second .strings parser — the action is therefore macOS-only by design, like ios_lint_localizations.
  • .stringsdict / ICU plurals are out of scope (likely v2).
  • No MIGRATION.md entry — purely additive.

Test Plan

  • bundle exec rubocop — no violations.
  • bundle exec rspec — all green. Helper specs cover every worked example + edge cases (%%, a literal % in copy, nil/whitespace, non-ASCII, escapes, width/precision/length modifiers, %d%i, the ,/' grouping flags, * dynamic width/precision, %n, %.f, and the invalid positional/non-positional mix). Action specs write real .strings fixtures parsed by plutil: no-op, abort, abort_on_violations: false, add/remove ignored, duplicate-key abort, fail-closed on an un-tokenizable file, mixed-operator abort, and missing file.

Related issues

@dangermattic

Copy link
Copy Markdown
Collaborator
1 Warning
⚠️ This PR is larger than 500 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.
1 Message
📖 This PR is still a Draft: some checks will be skipped.

Generated by 🚫 Danger

A temporal localization guardrail: compares two versions of a base-language
`.strings` file and fails when an existing key's value changes its format
placeholders (count, position, or argument type), which silently breaks every
translation filed under that key.

- `StringPlaceholdersHelper` — a pure-Ruby, platform-agnostic primitive that
  reduces a value's printf/`NSString` specifiers to a canonical signature.
- `ios_lint_localization_placeholder_changes` — the iOS action wrapping it, with
  fail-closed guards for duplicate keys and mixed positional/non-positional
  specifiers.

Builds on the `.strings` parsing and `scan_for_duplicate_keys` helper from #741.
@jkmassel jkmassel force-pushed the jkmassel/issue-736 branch from bb81e99 to cacc5ac Compare June 23, 2026 23:28
@jkmassel jkmassel changed the base branch from trunk to foundation June 23, 2026 23:28
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.

2 participants