Claude Codeでstrings.xmlを翻訳 — 未訳とplurals崩れをlintで止める
Androidのstrings.xmlをClaude Codeでvalues-jaへ翻訳するとき、未訳・余剰・書式指定子・pluralsの崩れを検査スクリプトとAndroid lintで止める手順です。
Androidアプリの strings.xml をClaude Codeで日本語に訳すときの落とし穴は、訳文の質より先に「訳し漏らしても画面が壊れない」ことにあります。未翻訳の文字列は英語のまま表示されるだけで、アプリは普通に動くため、リリース後まで気づけません。
対策は、基準言語と訳側を突き合わせる検査を2段で置くことです。編集のたびに回す軽いスクリプトと、ビルド前に回すAndroid lintを分けます。本記事では、values-ja の追加、translatable="false" の扱い、%1$s の順序、plurals の数、' のエスケープを検査し、Claude Codeのhookで「通るまで終わらない」状態にする設定までを組みます。
訳し漏らしても落ちない — Androidの翻訳が黙って崩れる所
Androidは、端末の言語に対応する res/values-ja/strings.xml に文字列が無ければ、デフォルトの res/values/strings.xml から読みます。公式の例では、日本語のファイルに title だけ無い場合、日本語端末でも title は英語で表示されます。デフォルト側は全文字列を持つ必要があり、翻訳側は一部だけでも許されます。
つまり「訳が足りない」状態は仕様上の正常動作です。Claude Codeに「values-ja を作って」と頼んだあと、数十件のうち数件が抜けていても、ビルドもテストも通ります。崩れ方は次の4種類に分けて見ます。
翻訳で壊れる4か所
未訳
基準にあるキーが訳側にありません。英語のまま表示されるだけなので、目視でも見落とします。
余剰
基準側で消したキーが訳側に残っています。lintのドキュメントには、訳側にしか無い文字列は、翻訳を持たないロケールで参照されるとクラッシュし得るとあります。
書式指定子
%1$sと%2$dの型や個数が訳側で変わります。日本語は語順が英語と違うため、順序を入れ替えること自体は正当で、検査は「位置番号と型の組」で比べます。plurals
日本語には単数・複数の区別がないので、使われない
oneを書いても意味がありません。逆に、言語が要求する数の区分が欠けると、そのロケールで例外になります。
どれも「JSONやXMLとして正しい」ことしか保証されない層の問題です。Claude Codeの出力を目で確認する運用は、文字列が数百件になった時点で続きません。
lintに任せる範囲と、スクリプトで補う範囲
Android lintには、この4種類に対応するチェックが最初からあります。重大度はドキュメントの記載どおりで、既定のままでは見逃される項目も混ざります。
| チェックID | 見るもの | 既定の重大度 |
|---|---|---|
MissingTranslation | 見るもの基準にあって訳側に無い文字列 | 既定の重大度Error |
ExtraTranslation | 見るもの訳側にあって基準に無い文字列 | 既定の重大度Fatal |
StringFormatCount | 見るもの翻訳間で書式引数の個数が違う | 既定の重大度Warning |
StringFormatMatches | 見るもの翻訳間で同じ位置の引数の型が違う | 既定の重大度Error |
MissingQuantity | 見るもの言語が要求するquantityが欠けている | 既定の重大度Error |
UnusedQuantity | 見るものその言語で使われないquantityがある | 既定の重大度Warning |
ImpliedQuantity | 見るものquantityが複数の数に当たるのに数値の引数が無い | 既定の重大度Error |
StringFormatCount と UnusedQuantity は警告止まりです。引数が1つ減った翻訳や、日本語に残った one が、ビルドを止めないまま残ります。
lintは ./gradlew lint で動かし、バリアントを指定するなら ./gradlew lintDebug のように書きます。公式は、lintはビルドの一部として自動実行されないので、CIで明示的に回すよう勧めています。結果はHTMLレポートで出ます。ただしGradleを起動する検査なので、Claude Codeが1ファイル編集するたびに回すのには向きません。
そこで役割を分けます。
- 編集のたびに回す: 自前のPythonスクリプト。基準と訳側のXMLを突き合わせるだけで、短時間で終わる
- ビルド前・CIで回す:
./gradlew lintDebug。上の表のチェックを網羅的に実行し、重大度をlint.xmlで引き上げる
lintが出す指摘と同じ観点をスクリプトに持たせておくと、Claude Codeが直した結果がCIで別の理由で落ちる、という行き違いが減ります。
CLAUDE.mdに翻訳の規約を置く
検査を書く前に、守るべき規則をプロジェクトの CLAUDE.md に置きます。hookで止めるだけでは「なぜ直すのか」が伝わらず、やり直しが増えるためです。
## Android 文字列の翻訳規約
- 基準言語は `app/src/main/res/values/strings.xml`(英語)。翻訳先は `values-ja`
- `translatable="false"` の文字列は訳さない。`values-ja` にも書かない
- `%1$s` `%2$d` などの書式指定子は、位置番号と型を変えない。語順に合わせた並べ替えは可
- `plurals` は日本語では `other` だけを書く(`one` は書かない)
- 文中の `'` は `\'` と書くか、文字列全体を二重引用符で囲む
- 基準側に無いキーを `values-ja` に足さない。基準側で消えたキーは訳側からも消す
- 編集後は `python3 scripts/check_strings.py app/src/main/res` を実行し、0件になるまで直すアポストロフィの規則は、公式のエスケープ表にある内容です。\' と書くか、全体を二重引用符で囲むか、どちらかが必要です。
検査スクリプトを置く
基準の values/strings.xml と、values-ja のような翻訳先を読み、上の規約を1つずつ確かめます。標準ライブラリだけで動くので、依存の追加はありません。
#!/usr/bin/env python3
"""strings.xml の基準言語と翻訳先を突き合わせる。使い方: check_strings.py <res ディレクトリ> [基準 values] """
import re, sys
import xml.etree.ElementTree as ET
from pathlib import Path
# 数による語形変化を持たない言語は other だけを使う(Android の plurals)
PLURAL_QUANTITIES = {"ja": {"other"}, "zh": {"other"}, "ko": {"other"}, "en": {"one", "other"}}
SPEC = re.compile(r"%(?:(\d+)\$)?[-#+ 0,(]*\d*(?:\.\d+)?([a-zA-Z%])")
def specs(text):
"""書式指定子を [(位置, 変換)] で返す。%% は除く。位置指定なしは 0。"""
return sorted((int(p or 0), c) for p, c in SPEC.findall(text or "") if c != "%")
def load(path):
root = ET.parse(path).getroot()
strings, plurals = {}, {}
for el in root:
name = el.get("name")
text = "".join(el.itertext())
if el.tag == "string":
strings[name] = (text, el.get("translatable") != "false")
elif el.tag == "plurals":
plurals[name] = {i.get("quantity"): "".join(i.itertext()) for i in el}
return strings, plurals
def main(res, base_dir="values"):
res = Path(res)
base_s, base_p = load(res / base_dir / "strings.xml")
errors = []
for loc_dir in sorted(res.glob("values-*")):
f = loc_dir / "strings.xml"
if not f.exists() or not re.fullmatch(r"values-[a-z]{2,3}(-r[A-Z]{2})?", loc_dir.name):
continue
lang = loc_dir.name.split("-")[1]
s, p = load(f)
err = lambda key, msg: errors.append(f"{loc_dir.name}: {key}: {msg}")
for k, (txt, tr) in base_s.items():
if tr and k not in s:
err(k, "MissingTranslation 基準にあるが未翻訳")
if not tr and k in s:
err(k, 'translatable="false" の文字列が訳側にある')
if tr and k in s and specs(txt) != specs(s[k][0]):
err(k, f"書式指定子が一致しない 基準={specs(txt)} 訳={specs(s[k][0])}")
for k, (txt, _) in s.items():
if k not in base_s:
err(k, "ExtraTranslation 基準に無い")
if re.search(r"(?<!\\)'", txt) and not txt.strip().startswith('"'):
err(k, "アポストロフィがエスケープされていない")
for k, items in base_p.items():
if k not in p:
err(k, "MissingTranslation plurals が未翻訳")
continue
want = PLURAL_QUANTITIES.get(lang)
if want and set(p[k]) != want:
err(k, f"quantity が {sorted(want)} と一致しない 実際={sorted(p[k])}")
base_spec = specs(items.get("other", ""))
for q, t in p[k].items():
if specs(t) != base_spec:
err(k, f'quantity="{q}" の書式指定子が基準の other と違う')
for k in p:
if k not in base_p:
err(k, "ExtraTranslation 基準に無い plurals")
for e in errors:
print(e)
print(f"{len(errors)} 件")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main(*sys.argv[1:3]))書式指定子は、位置番号つきなら番号と型の組、位置なしなら番号0の型で比べます。並べ替えた %2$d 件の新着があります、%1$s さん は基準と同じ組になるので通り、型だけ変わった %2$s と %2$d は落ちます。PLURAL_QUANTITIES に載せた言語だけ数の区分を検査するため、pl のような複雑な言語を足すときは、MissingQuantity の期待に合わせて集合を追加します。
実行するとどう出るか
次の構成の小さなプロジェクトで実行しました。基準側は app_name に translatable="false" を付け、welcome と sync_done が2つの引数を取り、note_count が one と other を持ちます。values-ja には、app_name の訳、save の欠落、sync_done の型違い、基準に無い old_banner、one の残る note_count を仕込んであります。
python3 scripts/check_strings.py app/src/main/resvalues-ja: app_name: translatable="false" の文字列が訳側にある
values-ja: save: MissingTranslation 基準にあるが未翻訳
values-ja: sync_done: 書式指定子が一致しない 基準=[(1, 's'), (2, 's')] 訳=[(1, 's'), (2, 'd')]
values-ja: old_banner: ExtraTranslation 基準に無い
values-ja: note_count: quantity が ['other'] と一致しない 実際=['one', 'other']
5 件終了コードは1です。仕込んだ5件がそのまま5件の指摘になり、並べ替えただけの welcome は出ていません。指摘は「どのロケールの、どのキーが、何の理由で」の順に並ぶので、そのままClaude Codeへのフィードバックとして読めます。
hookで「通るまで終わらない」にする
規約を書いても、検査を実行し忘れる余地は残ります。そこで2つのhookを .claude/settings.json に置きます。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-strings.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "sh -c 'python3 \"$CLAUDE_PROJECT_DIR/scripts/check_strings.py\" \"$CLAUDE_PROJECT_DIR/app/src/main/res\" >&2 || exit 2'"
}
]
}
]
}
}PostToolUse 側が呼ぶシェルは、標準入力のJSONに strings.xml が含まれるときだけ検査します。
#!/bin/sh
# PostToolUse で標準入力に来る JSON に strings.xml が含まれるときだけ検査する
grep -q 'strings\.xml' || exit 0
python3 "$CLAUDE_PROJECT_DIR/scripts/check_strings.py" \
"$CLAUDE_PROJECT_DIR/app/src/main/res" >&2 || exit 2実行権限(chmod +x)を付けておきます。仕様上の根拠は次のとおりです。
PostToolUseはツールの実行後に走り、tool_inputに編集対象のパスが入ります。終了コード2は、標準エラー出力をClaudeに見せます。ツールはすでに実行済みなので、編集は取り消されませんStopはClaude Codeが応答を終えるたびに走り、終了コード2で停止を止めて会話を続けさせます。標準エラーの内容が、続ける理由としてClaudeに渡ります- 検査スクリプトの失敗は終了コード1なので、
|| exit 2で2に読み替えています。出力を>&2に回すのは、指摘の一覧をそのままClaudeに渡すためです Stopの入力にはstop_hook_activeが含まれます。連続して継続できる回数にも上限(8回)があり、直せない指摘で無限ループにはなりません
編集の直後に指摘が返り、最後にもう一度確認が入る二重の構成です。PostToolUse の Edit|Write は、Bashコマンドが同じファイルを書き換えた場合には発火しません。Stop だけだと、指摘が返るのが作業の終わりで、修正の往復が長くなります。
lint.xmlでCIの関門を締める
スクリプトが通ったら、残りはAndroid lintで確かめます。lint.xml に重大度を書くと、警告止まりの項目をエラーに引き上げられます。ファイルはプロジェクトのルートに置き、親タグは <lint> です。
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<issue id="MissingTranslation" severity="fatal" />
<issue id="StringFormatCount" severity="error" />
<issue id="UnusedQuantity" severity="error" />
</lint>重大度の段階は、無視・情報・警告・エラー・致命的です。ExtraTranslation の説明には、致命的(Fatal)の指摘はリリースビルドでも実行されるとあります。MissingTranslation を fatal にしているのは、未訳をリリースビルドの段階で止める意図です。
CIでは、次の1行を回します。
./gradlew lintDebugabortOnError の既定は true なので、エラー以上の指摘があればビルドは失敗します。既存プロジェクトにいきなり導入して大量の指摘が出るときは、公式のベースライン機能(lint-baseline.xml)で既存分を固定し、新しく出た指摘だけを止められます。
基準言語をlintに伝える
res/values/ の言語が英語であることを、lintに明示しておくこともできます。ドキュメントによれば、ルートの resources 要素に tools:locale を付けます。
<resources xmlns:tools="http://schemas.android.com/tools" tools:locale="en">翻訳しない文字列の置き場にも選択肢があります。translatable="false" を付ける方法のほか、donottranslate.xml というファイルにまとめる方法が MissingTranslation のドキュメントに載っています。tools:ignore="MissingTranslation" で個別に黙らせる方法もありますが、あとから理由を追えなくなるため、置き場所を決めて運用するほうが安全です。
つまずきやすい点
落とし穴
日本語の plurals に one を残すと警告止まりで残る
英語のpluralsをそのまま写して one と other の両方を日本語側に書くと、UnusedQuantity は既定が警告なので、ビルドは通ります。スクリプトは日本語を other だけと決め打ちして落とすため、この見落としを防げます。中国語では zero や one を書いても返されない、とlintのドキュメントにあり、日本語も同じ型の言語です。
translatable="false"の文字列を訳してしまう: アプリ名やブランド名が典型です。lintのドキュメントは、訳さない文字列にtranslatable="false"を付けるよう案内しています。付けた文字列が訳側に残っていれば、本記事のスクリプトは独自の規約として落としますxliff:gタグの中身を訳す: 公式は、訳してはいけない部分をxliff:gタグで囲むよう勧めています。スクリプトはitertext()でタグの中身を連結して読むので、書式指定子の比較には影響しません。タグ自体の有無までは見ません- 数値の引数が無いplurals: 数を表示しない文は、ロケールによっては
ImpliedQuantityのエラーになります。公式も、数を含まない文はpluralsに向かないとしています string-arrayは検査しない: スクリプトはstringとpluralsだけを見ます。配列を使うプロジェクトでは、string-arrayの要素数の比較を足す必要があります- 複数のresディレクトリ・プロダクトフレーバーは対象外: 引数に渡した
resの直下のvalues*だけを読みます。フレーバーごとのsrc/<flavor>/resを持つ構成は、ディレクトリごとに実行します
状況別の進め方
| 状況 | 先に見るもの |
|---|---|
values-ja を初めて作る | 先に見るものCLAUDE.md の規約とスクリプト。translatable="false" の洗い出しが最初 |
| 基準側に文字列が増えた | 先に見るものスクリプトの MissingTranslation 指摘。増えたキーだけを訳させる |
言語を追加する(例: values-ko) | 先に見るものPLURAL_QUANTITIES に言語を追加してから実行 |
| CIでlintが大量に指摘する | 先に見るものlint-baseline.xml で既存分を固定し、新規分から止める |
訳文の質は、これらの検査では測れません。検査が保証するのは「構造が壊れていない」ことまでで、語感・敬語・UIに収まる長さは、翻訳者かネイティブの確認に残ります。
まとめ
Androidの翻訳は、欠けても画面が壊れないぶん、構造の崩れが長く残ります。基準言語と訳側を突き合わせるスクリプトを編集のたびに回し、./gradlew lintDebug をCIの関門にする二段構えなら、Claude Codeに任せた翻訳でも未訳・余剰・書式指定子・pluralsの4種類を機械的に止められます。まず values-ja の現状をスクリプトに通し、指摘が0件になるまでをClaude Codeに直させるところから始めると、規約と検査の穴が見えます。
ログ解析の側から不具合を切り分ける手順はClaude CodeにlogcatとANRトレースを読ませるにあります。JSON形式の翻訳ファイルで同じ発想を使う例はi18nextのJSON翻訳、gettextの .po で書式指定子を守る例はpoファイルの翻訳にまとめています。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。