GetTranslated.AI
AI Translation & Automation

5 Things That Must Happen Between Your LLM Output and Your Release Branch (A Localization Validation Checklist)

5 Things That Must Happen Between Your LLM Output and Your Release Branch (A Localization Validation Checklist)

Everyone's talking about how to prompt ChatGPT or Claude for better translations. Almost nobody talks about what to do with the output once it lands in your repo. That gap is where broken apps are born.

The failure mode is predictable: an engineer runs a batch of strings through an LLM, the files look reasonable, CI goes green, and three weeks later a user in Germany sees Hello, %1$s! rendered literally on screen — or worse, the app crashes because a <plurals> block is malformed and the XML parser gave up quietly. The translation looked fine. It was not fine.

This post is the safety-net chapter of the series. Earlier posts covered the failure modes you'll encounter after shipping and why LLMs ship confidently wrong translations without any warning. This post is about the fix layer you put before merge — the five gates every AI-translated file needs to pass before it touches your release branch.


Why LLM Output Is Never "Merge-Ready"

Let's be direct about something: a language model is optimized to produce plausible text. It is not optimized to preserve your %2$d positional argument, respect CLDR plural category rules for Russian, or keep your product's glossary consistent across 40 string files. Those are structural and semantic constraints that the model simply doesn't treat as hard requirements.

The failure modes are well-documented at this point. Placeholder corruption — where %1$s becomes %s or disappears entirely — is one of the most common ways AI translation breaks mobile resource files. Structural breakage in .stringsdict and Android <plurals> blocks is a category of silent failures that consistently reach production. Terminology drift — where your product name or a key UI term gets translated inconsistently across strings — compounds over releases until your app sounds like it was written by three different companies.

The insidious part is that none of these failures look like failures at the file level. XML parses. JSON is valid. xcodebuild doesn't complain. CI goes green. The translation ships.

The five gates below are designed to catch what your existing toolchain misses.


The 5 Gates Every AI-Translated File Must Pass

Gate 1: Placeholder Integrity Check

This is the highest-priority check and should block merge on any failure. The rule is simple: every placeholder in the source string must appear verbatim in the translated string. No reordering without positional specifiers, no dropping, no format specifier substitution.

What to check by platform:

For Android strings.xml:

<!-- Source (English) -->
<string name="welcome_message">Hello, %1$s! You have %2$d new messages.</string>

<!-- Valid German translation -->
<string name="welcome_message">Hallo, %1$s! Sie haben %2$d neue Nachrichten.</string>

<!-- Invalid — LLM dropped the positional specifier -->
<string name="welcome_message">Hallo, %s! Sie haben %d neue Nachrichten.</string>

For iOS Localizable.strings and .stringsdict, verify %@, %ld, %d, and positional variants like %1$@ are preserved exactly.

For React Native JSON, check both simple tokens and ICU-style constructs:

{
  "greeting": "Welcome back, {name}!",
  "item_count": "{count, plural, one {# item} other {# items}}"
}

A placeholder check script doesn't need to be complex. A regex-based scan that extracts all format specifiers from the source string and diffs them against the translated string is sufficient for most teams. Run it per key, per language, per file.

import re

PLACEHOLDER_RE = re.compile(r'%(?:\d+\$)?[@sdf]|{[^}]+}')

def check_placeholders(source: str, translation: str, key: str, lang: str):
    src_tokens = sorted(PLACEHOLDER_RE.findall(source))
    trn_tokens = sorted(PLACEHOLDER_RE.findall(translation))
    if src_tokens != trn_tokens:
        raise ValueError(
            f"[{lang}] Key '{key}': placeholder mismatch. "
            f"Expected {src_tokens}, got {trn_tokens}"
        )

On failure: block the merge. Log the exact key, the language, the expected placeholders, and the actual output. Do not attempt an automatic fix — the model made a judgment call about the string structure, and the right response is human review.

For a deeper look at why placeholders break in the first place, this post on placeholder safety covers the root causes worth understanding before you write your validation logic.


Gate 2: File Structure Validation

Before you check what's in the files, verify that the files are structurally valid. This sounds obvious. It is not always done.

Android strings.xml:

# Well-formedness check
xmllint --noout res/values-de/strings.xml

# Check for duplicate string keys (xmllint won't catch these)
grep -oP '(?<=name=")[^"]+' res/values-de/strings.xml | sort | uniq -d

Also verify that <plurals> blocks use valid quantity attributes (zero, one, two, few, many, other) and that no quantity value is duplicated within a single block. LLMs frequently produce malformed plurals — sometimes duplicating the other case, sometimes using quantity attribute names that don't exist in the CLDR spec.

iOS Localizable.strings and .stringsdict:

# Check .strings encoding and syntax
plutil -lint en.lproj/Localizable.strings

# Validate .stringsdict as a plist
plutil -lint en.lproj/Localizable.stringsdict

Encoding matters here: .strings files should be UTF-8. An LLM output pipeline that goes through a copy-paste step or an intermediate text file can silently introduce a BOM or shift to UTF-16, which Xcode will either reject or misparse.

React Native JSON:

# Dry-run parse — catches malformed JSON before it hits the bundler
node -e "JSON.parse(require('fs').readFileSync('locales/de.json', 'utf8'))"

Also validate that the key structure matches the source. If your English file has nested keys like errors.network.timeout, the German file should too. A flat structure in the translation file will silently miss strings at runtime.

One concrete failure worth highlighting: ChatGPT has been observed closing a <string> tag as </strings> — matching the parent element name rather than the tag it actually opened. XML parsers will throw on this, but only at runtime on device, not during a standard CI lint step that doesn't include XML validation.


Gate 3: Plural Coverage Check

This is where most teams have a genuine blind spot, and where LLMs fail with almost comic consistency.

The pattern: you ask an LLM to translate a plural string, and it outputs one and other for every language. Arabic. Russian. Polish. Doesn't matter. You get one and other. This is wrong for a significant portion of the languages your app probably targets.

CLDR defines up to six plural categories: zero, one, two, few, many, other. The required set varies by language:

  • English: one, other
  • Russian: one, few, many, other
  • Arabic: zero, one, two, few, many, other
  • Polish: one, few, many, other
  • French: one, many, other

A missing plural category means your app will silently fall back — sometimes to the wrong string, sometimes to nothing, depending on the platform.

Android validation:

# Minimum required CLDR categories by language code
REQUIRED_PLURALS = {
    "ar": {"zero", "one", "two", "few", "many", "other"},
    "ru": {"one", "few", "many", "other"},
    "pl": {"one", "few", "many", "other"},
    "de": {"one", "other"},
    "fr": {"one", "many", "other"},
    # ... extend as needed
}

def check_plural_coverage(plural_element, lang_code: str, key: str):
    found = {item.get("quantity") for item in plural_element.findall("item")}
    required = REQUIRED_PLURALS.get(lang_code, {"one", "other"})
    missing = required - found
    if missing:
        raise ValueError(
            f"[{lang_code}] Plurals key '{key}' missing categories: {missing}"
        )

iOS .stringsdict: Verify that each plural rule entry contains the three required keys: NSStringLocalizedFormatKey, NSStringFormatSpecTypeKey, and NSStringFormatValueTypeKey. An LLM that reconstructs a .stringsdict from scratch frequently omits one of these, producing a plist that parses successfully but renders nothing at runtime.

React Native ICU: If you're using react-intl or i18next with ICU message format, validate that the plural categories in your ICU string match the CLDR-required set for the target locale. A string like {count, plural, one {# item} other {# items}} will fail silently for Russian users who trigger the few or many branch.


Gate 4: Glossary / Terminology Consistency Scan

Terminology drift is subtle, cumulative, and expensive to fix after it ships. The LLM translates "Workspace" as Arbeitsbereich in one string and Arbeitsplatz in another. Neither is wrong in isolation. Together, they make your UI feel incoherent.

The fix is a machine-checkable glossary and a CI step that enforces it.

A minimal glossary JSON structure that a lint script can consume:

{
  "de": {
    "Workspace": {
      "approved": "Arbeitsbereich",
      "rejected": ["Arbeitsplatz", "Arbeitsraum"]
    },
    "Subscription": {
      "approved": "Abonnement",
      "rejected": ["Abo", "Mitgliedschaft"]
    }
  },
  "fr": {
    "Workspace": {
      "approved": "Espace de travail",
      "rejected": ["Espace travail"]
    }
  }
}

The CI check loads this glossary and scans every string file for the target language:

def check_glossary(strings: dict, glossary: dict, lang: str):
    lang_rules = glossary.get(lang, {})
    for term, rules in lang_rules.items():
        approved = rules["approved"]
        rejected = rules.get("rejected", [])
        for key, value in strings.items():
            for bad_term in rejected:
                if bad_term.lower() in value.lower():
                    raise ValueError(
                        f"[{lang}] Key '{key}' uses rejected term "
                        f"'{bad_term}' (approved: '{approved}')"
                    )

This check should run against all string files for the language, not just the files that changed in the current PR. Terminology drift can hide in strings that haven't been touched in months — you need a full scan to catch it.


Gate 5: Human Spot-Check Routing

Automated gates catch structural and mechanical errors. They cannot catch fluency problems, cultural misfires, awkward register, or a translation that's technically accurate but reads like it was written by someone who learned the language from a dictionary. That requires a human.

The key is routing, not exhaustive review. You can't have every AI-translated string reviewed by a native speaker before every release — that eliminates the efficiency gain. But you can triage intelligently.

Prioritize for human review:
- High-visibility strings: onboarding flows, paywall copy, empty states, error messages
- Any new language being shipped for the first time
- Strings that failed an automated gate and were manually corrected
- Strings containing legal or compliance language
- Any string that contains a product name, a pricing reference, or a call to action

How to route: Wire Gate 5 as a required PR review from a language-specific reviewer — more on the mechanics in the CI sketch below. If you don't have native speakers on staff, a lightweight async review queue through a translation platform review step is a reasonable alternative. GetTranslated.AI's safe AI translation workflow is designed specifically for this pattern: automated gates first, human review for the strings that need it.

The goal is not zero-risk — it's right-sized risk. Structural failures should never ship. Fluency issues in low-visibility strings are an acceptable tradeoff if the alternative is a 3-week review cycle.


Putting It Together: A CI Pipeline Sketch

Here's a minimal GitHub Actions job that runs Gates 1–4 and blocks merge on failure:

name: Localization Validation

on:
  pull_request:
    paths:
      - '**/values-**/strings.xml'
      - '**/*.lproj/Localizable.strings'
      - '**/*.lproj/Localizable.stringsdict'
      - '**/locales/*.json'

jobs:
  validate-localization:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: pip install lxml

      - name: Gate 1 — Placeholder integrity
        run: python scripts/check_placeholders.py

      - name: Gate 2 — File structure validation
        run: |
          find . -name "strings.xml" | xargs -I{} xmllint --noout {}
          find . -name "*.stringsdict" | xargs -I{} plutil -lint {}
          node scripts/validate_json_structure.js

      - name: Gate 3 — Plural coverage
        run: python scripts/check_plural_coverage.py

      - gate: Gate 4 — Glossary consistency
        run: python scripts/check_glossary.py --glossary glossary.json --full-scan

For Gate 5, configure a CODEOWNERS file that maps language-specific paths to designated reviewers:

# .github/CODEOWNERS
**/values-de/**    @de-reviewer
**/values-fr/**    @fr-reviewer
**/values-ar/**    @ar-reviewer
*.lproj/           @ios-l10n-team

Set these as required reviewers in your branch protection rules. A PR that modifies German strings cannot merge without approval from @de-reviewer. This is Gate 5 at zero infrastructure cost.

The localization CI pipeline patterns post goes deeper on CI integration if you're building this out from scratch, and this guide on AI translation problems covers the broader failure surface that these gates are designed to address.


What Teams Get Wrong About Validation

Running checks locally but not in CI. This is the most common mistake. A script that runs on a developer's machine and fails silently in CI — or doesn't run at all — catches nothing in practice. If the gate doesn't run on every PR, it doesn't exist.

Checking only the files that changed. A PR that modifies five German strings might introduce a glossary violation in a string that was translated six months ago and hasn't been touched since. If you only scan the diff, you miss it. Gate 4 in particular requires a full scan of all string files for the target language, not just the changed ones. This is also why running localization checks in CI vs. locally produces different results — scope of the check is often the culprit.

Treating a passing lint as a passing translation. XML valid. JSON parses. Plurals present. CI green. These checks confirm that the file is mechanically correct. They say nothing about whether the translation is accurate, appropriate for the audience, or consistent with how the product has been localized in the past. A file can pass all four automated gates and still ship a translation that embarrasses you. Gate 5 exists for this reason.


Conclusion

AI translation without validation gates is just hope with extra steps. The output will look plausible, the files will parse, and something structurally wrong will eventually reach a user. The five gates here — placeholder integrity, file structure, plural coverage, glossary consistency, and human routing — turn AI output from "probably fine" into "actually shippable."

None of these gates are complicated to implement. Most can be wired into an existing CI pipeline in an afternoon. The cost of skipping them is paid in production incidents, user complaints, and the kind of debugging session that makes you question your career choices.

The next post in this series covers how to build a translation memory and glossary that make Gates 4 and 5 progressively cheaper over time — because the right validation infrastructure pays compound interest as your localization coverage grows.

Ready to localize your app?

Get started free — no credit card required.

Start Translating →