The night before an update to my indie wallpaper app, I opened the German build and triggered the "saved" message. Where a number should have been, there was nothing.
The cause was a single line in strings.xml. The base language had both %1$s and %2$d; the German version only had %1$s. It didn't crash. The screen just read slightly wrong — which is exactly why it had slipped past review.
So I asked the agent to "check every locale's strings.xml." What came back was a confident-sounding summary. When I counted again mechanically later, several gaps it hadn't mentioned were still there.
Let a machine count; let the agent think about the fix. Here is how I arrived at that line.
"Look at everything" gets skimmed
Locale files are long runs of near-identical lines. Just as a human reviewer would, a model tends to fold similar rows into "looks fine."
There is a second reason. A missing key leaves nothing on the page to notice. It isn't a problem you spot by reading a line; it only shows up when you compare against the base. And comparing is just a set difference — work I'm happy to give to a script.
Four things to check mechanically
I narrowed the checks to four.
| Check | What it finds | Symptom if ignored |
|---|---|---|
| missing | Keys in the base file but not in a locale | Base-language text leaks into the UI |
| orphan | Keys that exist only in a locale | Invisible in the UI, but clutters later cleanups |
| empty | Blank translations | Buttons or labels render empty |
| placeholder_mismatch | Different count or type of %1$s-style tokens | Numbers vanish, or a type mismatch fails at runtime |
Keys marked translatable="false" are dropped first, since they are not meant to be translated — an app name used verbatim in every language, for instance.
The script
It runs on the standard library alone. Pass the res directory; it prints per-locale problems as JSON and exits with 1 if anything is found.
#!/usr/bin/env python3
"""Detect per-locale drift across res/values*/strings.xml.
Usage: python3 check_strings.py app/src/main/res
Exit code: 0 = clean / 1 = problems found
"""
import json
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
PLACEHOLDER = re.compile(r"%(?:\d+\$)?[-#+ 0,(]*\d*(?:\.\d+)?[sdfxXeEgGc]|%%|\{\w+\}")
def load(path):
"""name -> text. Skips translatable="false"."""
result = {}
for node in ET.parse(path).getroot().findall("string"):
if node.get("translatable") == "false":
continue
result[node.get("name")] = "".join(node.itertext())
return result
def placeholders(text):
return sorted(PLACEHOLDER.findall(text))
def main(res_dir):
res = Path(res_dir)
base = load(res / "values" / "strings.xml")
report = {}
for path in sorted(res.glob("values-*/strings.xml")):
locale = path.parent.name.removeprefix("values-")
target = load(path)
issues = {
"missing": sorted(set(base) - set(target)),
"orphan": sorted(set(target) - set(base)),
"empty": sorted(k for k, v in target.items() if k in base and not v.strip()),
"placeholder_mismatch": sorted(
k for k, v in target.items()
if k in base and placeholders(v) != placeholders(base[k])
),
}
issues = {k: v for k, v in issues.items() if v}
if issues:
report[locale] = issues
print(json.dumps(report, ensure_ascii=False, indent=2))
return 1 if report else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit("usage: check_strings.py <res_dir>")
sys.exit(main(sys.argv[1]))I built a tiny sample and planted four different problems in the German file. The output was:
{
"de": {
"missing": ["favorite_added"],
"orphan": ["old_key"],
"empty": ["retry"],
"placeholder_mismatch": ["download_done"]
}
}All four planted problems came back, exactly as planted. That finishes the counting half of the review.
Hand Antigravity the findings, not the files
Give the agent the script's output rather than the full strings.xml files. A prompt along these lines works:
Here is the output of check_strings.py.
Propose fixes per locale, only for the keys listed in this JSON.
- The base is values/strings.xml
- For placeholder_mismatch, keep the same placeholders as the base
- Do not touch keys that are not listedThe last line matters most. Restricting the scope to listed keys stops the agent from rephrasing neighboring strings "while it's there," and the diff stays easy to review.
After the fixes land, run the script again. One round is done when the exit code is 0.
What still needs a human
An "empty" report isn't always wrong. In some language you may deliberately show nothing.
Reordered placeholders can also be legitimate. If you write %1$s and %2$d with positions, a different word order still works. The regex here compares sorted tokens, so order alone is not flagged. Two bare %s tokens are a different story: swap the word order and the meaning swaps too, so I check those by eye.
One more gap: plurals aren't covered. The number of required forms differs per language, and a plain comparison can't judge that. It needs its own check.
Making it a habit
A wallpaper app picks up a few new strings with every update, so the gaps between languages only widen if I leave them alone. I put the script right before cutting a release branch. Because it uses an exit code, it also fits a pre-commit hook.
Give the agent only the facts a machine has found. With facts up front, the prompt gets shorter and the returned fixes stay small.
Run it once on your own project today. If it prints an empty {}, that becomes your baseline. If it prints something, that line is the quickest thing you can fix today.