ANTIGRAVITY LABJP
Articles/Integrations
Integrations/2026-06-25Advanced

A Translated Line Had Quietly Reverted to English — Guarding String Resources an Agent's Refactor Touched

Let an agent tidy your values folder and translated strings can silently revert to the source text. Here is a design and implementation that treats the default locale as the source of truth, reads every other locale as a diff, and blocks only dropped keys, reverted translations, and broken format arguments at pre-commit.

i18n3Android21localization5pre-commit2Antigravity269

Premium Article

Last week, during a pre-release check, I stopped short. The Chinese settings screen showed the English word "Notifications" sitting plainly in the list. That was a line I had translated half a year earlier, down to fixing the layout that wrapped awkwardly.

Just before, I had handed the values folder to an agent to tidy up — merge duplicate keys, drop unused ones. Somewhere in that pass, a handful of translated values had reverted to the default-language source text. The diff spanned hundreds of lines, far past what the eye can follow.

When you ship a wallpaper app in many languages as an indie developer, string resources swell to several hundred keys before you notice. I have had a translated line quietly break and stay broken until a store review pointed it out. So this is the kind of accident I would rather stop with a machine's eye than a human's.

Ignore reordering, catch only the change in value

When an agent tidies XML, it reorders keys and re-indents. That on its own does no harm. If you try to guard with a text diff, those harmless reformats fire as violations too, and the gate stops being read within a day.

What I want to stop is the three events where meaning, not appearance, changes: a key disappearing, a translated value reverting to the source, and a format-argument count drifting from the default. The last one is not a cosmetic problem — String.format throws IllegalFormatException and takes the whole screen down with it.

EventWhat happens at runtimeGate verdict
Keys reordered / reformattedNothing changesAllowed (ignored)
Key missing from a localeFalls back to the default; that one line mixes languagesBlocked
Translation reverts to sourceEnglish shows up on a supposedly translated screenBlocked
Format-argument count mismatchThat screen crashesBlocked

Put the comparison on a key-to-value dictionary rather than a text diff, and reordering is treated as identical automatically, leaving only changes in value. That is the heart of the design.

Treat the default locale as the source, each locale as a diff

I treat res/values/strings.xml as the source of truth, and read each locale — res/values-en/, res/values-zh-rCN/ — as a diff against it. plurals expand into name#quantity form, and keys marked translatable="false" are left out.

#!/usr/bin/env python3
"""A pre-commit gate that diffs string resources under res/values against
HEAD to detect translation drift introduced by an agent's edits."""
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
 
# Keys allowed to equal the source value (brand names, symbolic labels)
ALLOW_EQUAL = {"app_name", "ok_label", "brand_tagline"}
 
# Normalize %1$s / %d / %@ / {count} into an order-free multiset
ARG_RE = re.compile(r"%(?:\d+\$)?[-#+ 0,(]*\d*(?:\.\d+)?([@a-zA-Z])|\{(\w+)\}")
 
def format_args(value: str):
    args = []
    for m in ARG_RE.finditer(value):
        if m.group(2) is not None:      # ICU form {name}
            args.append("{}")
        else:                            # printf form %1$s / %d / %@
            args.append("%" + m.group(1))
    return sorted(args)
 
def parse_strings(xml_text: str):
    """Turn strings.xml into a name->value dict; plurals as name#quantity."""
    out = {}
    if not xml_text.strip():
        return out
    root = ET.fromstring(xml_text)
    for s in root.findall("string"):
        name = s.get("name")
        if name and s.get("translatable") != "false":
            out[name] = "".join(s.itertext())
    for p in root.findall("plurals"):
        name = p.get("name")
        for item in p.findall("item"):
            out[f"{name}#{item.get('quantity')}"] = "".join(item.itertext())
    return out
 
def at_head(rel_path: str):
    """Fetch the same file at HEAD (empty dict for a new file)."""
    r = subprocess.run(["git", "show", f"HEAD:{rel_path}"],
                       capture_output=True, text=True)
    return parse_strings(r.stdout) if r.returncode == 0 else {}

Using itertext() matters: when a <string> wraps decoration tags like <b>, it still gathers the display text without dropping anything. If the agent reshapes the tag structure while reformatting, the joined string can still be compared as equal.

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
A design that compares translation resources key by key with the default locale as the source of truth, catching removed keys, values reverted to the source, and broken format arguments mechanically
An implementation that diffs strings.xml and xcstrings against HEAD and stops untranslated fall-through and placeholder-count mismatches at pre-commit
A diff rule that tolerates reordering and flags only value changes, plus the exit-code contract and excluded-key design for putting it in CI
Secure payment via Stripe · Cancel anytime

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

Integrations2026-06-18
Gating Your Agent's Commits With pre-commit — Keeping Broken Changes Out of the Main Repo
How to wire up a pre-commit gate that lints, type-checks, runs fast tests, and scans for secrets the moment Antigravity's agent commits — with measured timings and the ordering that keeps it fast.
Integrations2026-05-28
Five Weeks Letting Antigravity's Background Agent Refresh App Store Screenshots Across Four Apps
An operations log from running Antigravity Background Agent for five weeks to keep the App Store screenshots for four wallpaper apps in sync across eight locales — including the boundaries I kept, the recurring failure modes, and what I learned about where automation should stop.
App Dev2026-03-21
Antigravity × i18n — Build Multi-Language Apps Efficiently with AI Agents
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →