Skip to content

Add ios_lint_localization_placeholder_changes action#738

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

Add ios_lint_localization_placeholder_changes action#738
jkmassel wants to merge 1 commit into
trunkfrom
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
Base automatically changed from foundation to trunk July 8, 2026 01:21
@iangmaia iangmaia requested a review from Copilot July 13, 2026 14:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new guardrail to prevent translation-breaking changes by detecting incompatible format-placeholder changes for existing localization keys between two versions of an iOS base .strings file, backed by a reusable, platform-agnostic placeholder-signature primitive.

Changes:

  • Introduces Fastlane::Helper::StringPlaceholdersHelper to compute and compare canonical placeholder “signatures”.
  • Adds ios_lint_localization_placeholder_changes Fastlane action to compare old vs new base .strings and abort on incompatible placeholder changes (and on mixed positional/non-positional placeholders).
  • Adds comprehensive RSpec coverage for the primitive and the new action, and documents the feature in CHANGELOG.md.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
spec/string_placeholders_helper_spec.rb Unit tests covering placeholder signature extraction, compatibility, and edge cases.
spec/ios_lint_localization_placeholder_changes_spec.rb Action-level tests validating parsing, duplicate-key behavior, abort/report behavior, and failure modes.
lib/fastlane/plugin/wpmreleasetoolkit/helper/string_placeholders_helper.rb New pure-Ruby placeholder signature/compatibility primitive.
lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_lint_localization_placeholder_changes.rb New iOS action that parses two .strings versions and reports/aborts on incompatible placeholder changes.
CHANGELOG.md Adds a Trunk entry describing the new primitive and action.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +81 to +92
def self.placeholder_signature(value)
# Collapse the specifiers into argument *slots* keyed by position: an explicit `%1$…` keys by
# its number, a non-positional `%…` by its order of appearance. Keying (rather than listing)
# means a positional argument referenced more than once (`%1$@ … %1$@`) collapses to a single
# slot — it still consumes one argument, so it stays compatible with a single `%1$@`. Sorting
# by position keeps the signature invariant to reordering equivalent positional arguments.
slots = {}
extract_specifiers(value).each_with_index do |specifier, index|
slots[specifier[:position] || (index + 1)] = specifier[:class]
end
slots.sort.map { |position, klass| "#{position}:#{klass}" }.join(',')
end
Comment on lines +108 to +114
case status
when :scanned then payload
# A non-`:text` plist (xml/binary) has no tokenizer here, but `plutil` collapses any duplicate to
# its last value when parsing it, and toolkit-generated XML is built from a Hash and is dup-free
# anyway — so there's nothing to catch.
when :unsupported_format then {}
# A `:text` file `plutil` parses but our scanner can't tokenize (e.g. a nested dictionary/array
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.

3 participants