Claude Media
Claude Codeでstrings.xmlを翻訳 — 未訳とplurals崩れをlintで止める

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/res
values-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 lintDebug

abortOnError の既定は 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ファイルの翻訳にまとめています。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. developer.android.com/guide/topics/resources/localization
  2. developer.android.com/guide/topics/resources/string-resource
  3. developer.android.com/studio/write/lint
  4. googlesamples.github.io/android-custom-lint-rules/checks/MissingTranslation.md.html
  5. code.claude.com/docs/en/hooks
この記事を共有:XはてブLinkedIn