Claude Media
Claude CodeでICUのplural・selectを訳文で壊さない検査を書く

Claude CodeでICUのplural・selectを訳文で壊さない検査を書く

ICU MessageFormatの翻訳で起きる構文崩れ・複数形カテゴリの欠落・selectキーワードの翻訳を、パーサーとIntl.PluralRulesで検出する検査をhookに組み込む手順です。

ICU MessageFormatの文字列をClaude Codeに翻訳させるとき、最も見つけにくい不具合は「JSONとして正しく、画面にも出るのに、特定の数のときだけ表示が壊れる」ものです。たとえばアラビア語に必要な6つの複数形のうち2つしか訳されていなければ、残りの数では実行時にフォールバックするか、エラーになります。

対策は、訳文を書き換えるたびにICUのパーサーと言語ごとの複数形カテゴリで機械的に点検し、違反をClaude Codeへ返すことです。本記事では、約80行のNode.jsスクリプトと、それを呼び出すPostToolUse hookおよびStop hookの設定、CLAUDE.md の規約を組みます。実行結果は、わざと壊した英語・アラビア語・日本語・ロシア語のファイルで確かめたものです。

訳文でICUが壊れる4つの形

ICUの plural は数値で、select は決まったキーワードで、入れ子になった文を選ぶ構文です。翻訳者は文全体を1つの単位として訳し、語順も組み替えます。その過程で、構文が次の4通りに壊れます。

壊れ方

訳文で起きるICU固有の不整合

  • 構文エラー

    閉じ括弧を落とす、other を訳す前に消す、といった崩れです。other 節の無い plural や select は構文として成立しません。

  • 複数形カテゴリの欠落

    英語の one / other の形のまま訳すと、few や many が必要な言語では該当する数の文が存在しません。

  • selectキーワードの翻訳

    male female other は選択キーワードで、表示文ではありません。男性 に訳すと、渡される値と一致せず other 節に落ちます。

  • アポストロフィによる波括弧の無効化

    フランス語の l'{count} のように、ASCIIのアポストロフィが直前にあると、引数が文字として扱われます。構文エラーにならないのが厄介です。

この4つのうち、1つ目はパーサーを通すだけで拾えます。残りの3つは構文として正しいので、言語の知識と基準言語との比較が必要です。以降のスクリプトは、この3つを狙って書いています。

言語ごとに複数形カテゴリが違う

ICUの複数形は、zero one two few many other の6カテゴリから、言語が必要なものだけを使います。ICUのユーザーガイドも、英語のメッセージに必要なのは other で、one や few は対象言語に応じて翻訳システムと翻訳者が足す、と述べています。つまり「どのカテゴリが必要か」は基準言語の側からは分からず、訳側の言語で決まります。

その正解は、JavaScriptの Intl.PluralRules から取り出せます。Node.js v20.18.0で new Intl.PluralRules(言語).resolvedOptions().pluralCategories を評価した結果が次の表です。

言語必要なカテゴリ(cardinal)
英語 en必要なカテゴリ(cardinal)one other
日本語 ja必要なカテゴリ(cardinal)other
ロシア語 ru必要なカテゴリ(cardinal)one few many other
フランス語 fr必要なカテゴリ(cardinal)one many other
アラビア語 ar必要なカテゴリ(cardinal)zero one two few many other

フランス語に many が入る点は見落としやすいところです。このバージョンのNode.jsでは、fr が3カテゴリを返しました。英語から訳した2カテゴリの plural を、フランス語にそのまま使うと欠落になります。検査にカテゴリの一覧を手書きせず、実行環境が返す値を使えば、辞書の古さを気にせずに済みます。

パーサーとカテゴリで検査するスクリプト

構文エラーの検出には、ICU MessageFormatのパーサーを使います。ここでは @formatjs/icu-messageformat-parser(確認したバージョンは3.5.22)を選びました。AST(構文木)を返すので、plural と select の入れ子を機械的にたどれます。

npm i -D @formatjs/icu-messageformat-parser
mkdir -p scripts

次のスクリプトを scripts/check-icu.mjs に置きます。引数にファイルを渡せばそのファイルを、引数が無ければhookのstdinにあるJSONの tool_input.file_path を検査します。ロケールは locales/<言語>/ というパスから取り、基準言語は en とします。

// 使い方: node scripts/check-icu.mjs <locales/xx/file.json>...
// 引数が無ければ、hook の stdin(JSON)から tool_input.file_path を読む。終了コード 2 = 違反あり
import fs from 'node:fs';
import path from 'node:path';
import { parse, TYPE } from '@formatjs/icu-messageformat-parser';
 
const BASE = 'en';
let files = process.argv.slice(2);
if (files.length === 0) {
  const input = JSON.parse(fs.readFileSync(0, 'utf8'));
  files = [input.tool_input?.file_path].filter(Boolean);
}
 
const flatten = (o, p = '') => Object.entries(o).flatMap(([k, v]) =>
  v && typeof v === 'object' ? flatten(v, `${p}${k}.`) : [[`${p}${k}`, String(v)]]);
 
// plural / select を再帰的に集める(入れ子の選択肢も辿る)
function collect(ast, out = []) {
  for (const el of ast) {
    if (el.type === TYPE.plural || el.type === TYPE.select) {
      out.push(el);
      for (const opt of Object.values(el.options)) collect(opt.value, out);
    } else if (el.type === TYPE.tag) collect(el.children, out);
  }
  return out;
}
 
// 引用符で波括弧が文字として読まれた箇所(l'{count} のような ASCII アポストロフィ)を探す
function literals(ast, out = []) {
  for (const el of ast) {
    if (el.type === TYPE.literal) out.push(el.value);
    else if (el.type === TYPE.plural || el.type === TYPE.select) Object.values(el.options).forEach((o) => literals(o.value, out));
    else if (el.type === TYPE.tag) literals(el.children, out);
  }
  return out;
}
 
let problems = 0;
const report = (msg) => { console.error(msg); problems++; };
 
for (const file of files) {
  const m = file.match(/locales\/([^/]+)\//);
  if (!m || !file.endsWith('.json') || !fs.existsSync(file)) continue;
  const lng = m[1];
  const rel = path.relative(process.cwd(), file);
  const read = (f) => new Map(flatten(JSON.parse(fs.readFileSync(f, 'utf8'))));
  const dst = read(file);
  const baseFile = file.replace(`locales/${lng}/`, `locales/${BASE}/`);
  const src = lng !== BASE && fs.existsSync(baseFile) ? read(baseFile) : new Map();
 
  for (const [key, msg] of dst) {
    let ast;
    try {
      ast = parse(msg, { requiresOtherClause: true });
    } catch (e) {
      report(`[${rel}] ${key}: ICU構文エラー: ${e.message.split('\n')[0]}`);
      continue;
    }
    for (const lit of literals(ast)) {
      if (/[{}]/.test(lit)) report(`[${rel}] ${key}: 引用符のせいで波括弧が文字として読まれています: ${lit.trim()}`);
    }
    for (const el of collect(ast)) {
      if (el.type !== TYPE.plural) continue;
      const type = el.pluralType === 'ordinal' ? 'ordinal' : 'cardinal';
      const need = new Set(new Intl.PluralRules(lng, { type }).resolvedOptions().pluralCategories);
      const have = Object.keys(el.options).filter((k) => !k.startsWith('='));
      const miss = [...need].filter((c) => !have.includes(c));
      const extra = have.filter((c) => !need.has(c));
      if (miss.length) report(`[${rel}] ${key}: {${el.value}, plural} に ${lng} の ${type} カテゴリ ${miss.join('/')} がありません`);
      if (extra.length) report(`[${rel}] ${key}: {${el.value}, plural} に ${lng} で使われない ${extra.join('/')} があります`);
    }
    // 基準言語と、plural / select の引数名・select の選択肢キーワードを比べる
    const base = src.get(key);
    if (base !== undefined) {
      try {
        const sig = (a) => collect(a)
          .map((e) => (e.type === TYPE.select ? `select:${e.value}:${Object.keys(e.options).sort()}` : `plural:${e.value}`)).sort().join(' | ');
        const a = sig(parse(base)), b = sig(ast);
        if (a !== b) report(`[${rel}] ${key}: 選択構造が基準言語と違います\n    基準: ${a}\n    訳文: ${b}`);
      } catch { /* 基準側の不備はここでは扱わない */ }
    }
  }
}
process.exit(problems ? 2 : 0);

見ている点は5つです。

  1. parse(msg, { requiresOtherClause: true }) で構文を通し、other の無い plural / select もここで落とす
  2. 訳文の plural ごとに、訳側の言語の Intl.PluralRules が返すカテゴリと、実際に書かれたカテゴリを比べる
  3. ordinal の plural(順序数)は、type: 'ordinal' で別のカテゴリ表を引く
  4. 同じキーの基準言語の文から、plural の引数名と select のキーワード集合を取り出し、訳文と一致するか比べる
  5. 解析後の文字列部分に波括弧が残っていたら、引用符で無効化されたとみなす

2の「余剰」も報告します。日本語に one を書いても選ばれることはなく、英語の形を写し込んだ痕跡だからです。

わざと壊した訳文で動かす

基準の locales/en/cart.json には、items(one / other)、invite(female / male / other の select)、msg(one / other)の3キーを置きました。以下は、この基準に対する各言語ファイルの検査結果です。

アラビア語は、items を英語と同じ2カテゴリで訳した状態です。

node scripts/check-icu.mjs locales/ar/cart.json; echo "exit=$?"
[locales/ar/cart.json] items: {count, plural} に ar の cardinal カテゴリ few/many/two/zero がありません
exit=2

日本語は、items に one を残し、invite の male を 男性 に訳し、msg の閉じ括弧を落とした状態です。

[locales/ja/cart.json] items: {count, plural} に ja で使われない one があります
[locales/ja/cart.json] invite: 選択構造が基準言語と違います
    基準: select:gender:female,male,other
    訳文: select:gender:female,other,男性
[locales/ja/cart.json] msg: ICU構文エラー: EXPECT_ARGUMENT_CLOSING_BRACE

ロシア語は、items から other を落とした状態で、パーサーが拒否します。

[locales/ru/cart.json] items: ICU構文エラー: MISSING_OTHER_CLAUSE

日本語の invite に注目してください。男性 と訳した select は、構文としては完全に正しく、パーサーを通ります。基準言語との比較を入れない限り、この誤訳は検査をすり抜けます。

もう1つ、フランス語のファイルで次の文字列を試しました。

{ "a": "l'{count} élément" }

ICUのユーザーガイドによれば、ICU 4.8以降のASCIIアポストロフィは、引用が必要な文字の直前にあるときだけ引用を開始します。{ は引用が必要な文字なので、l'{count} élément の {count} は引数ではなく文字として扱われます。このパーサーでも同じで、解析後の文字列は l{count} élément になり、構文エラーも出ませんでした。スクリプトは、解析後の文字列に波括弧が残っているかどうかでこれを報告します。

[locales/fr/f.json] a: 引用符のせいで波括弧が文字として読まれています: l{count} élément

ガイドは、人が読む文にはU+2019の「’」を使い、ASCIIのアポストロフィはICUの構文でのみ使うよう勧めています。CLAUDE.md に同じ規約を書いておくと、そもそもの発生を減らせます。

PostToolUse hookで保存のたびに検査する

検査を呼び出す入口は2つ置きます。1つ目は、訳文ファイルを書き換えた直後に走るPostToolUse hookです。.claude/settings.json に次のように書きます。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "node \"$CLAUDE_PROJECT_DIR/scripts/check-icu.mjs\""
          }
        ]
      }
    ]
  }
}

hookの挙動は、次の3点を押さえておけば足ります。

  • matcher はツール名で絞る。Edit|Write はファイルの編集と新規作成の両方に当たる
  • 入力のJSONは標準入力に渡され、tool_input.file_path に絶対パスが入る。スクリプトが引数なしで標準入力を読むのはこのため
  • PostToolUseは、終了コード2で終わると標準エラー出力をClaudeに見せる。ツールはすでに実行済みなので、取り消しではなく「直してほしい」という通知になる

locales/ 以外のファイルでも Edit|Write は発火しますが、スクリプトがパスを見て黙って終了するので、無関係な編集は素通りします。終了コード1などそれ以外の非0は、ブロックしないエラーとして扱われ、メッセージの本体がClaudeに渡る経路ではありません。違反時に2を返しているのはこのためです。

Stop hookを最後の網にする

PostToolUse hookには穴があります。Bashコマンドや外部プロセスがファイルを書き換えた場合は、Edit|Write に一致するhookは走りません。たとえば翻訳を sed や自作スクリプトで一括置換すると、検査をすり抜けます。

そこで、Claudeが応答を終えようとする時点で、全ロケールを検査するStop hookを足します。Stopは終了コード2で停止を防ぎ、会話を続けさせる点が、PostToolUseとの違いです。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "sh -c 'node scripts/check-icu.mjs locales/*/*.json >&2 || exit 2'"
          }
        ]
      }
    ]
  }
}

Stopの入力には stop_hook_active が含まれ、停止防止のhookによってすでに続行中のときに true になります。違反が直らないまま2を返し続けるとループになりうるため、この値を見て続行回数を抑える設計にもできます。Stop hookの全体像と、キーや変数の過不足検査との組み合わせ方は、i18nextのJSON翻訳の記事にもまとめています。

CLAUDE.mdに書く規約

検査は違反を見つけるだけで、直し方は教えません。何を守るのかは CLAUDE.md に書きます。ICUのガイドの推奨を、訳す側の言葉に直した例です。

## 翻訳(ICU MessageFormat)
 
- 基準言語は `locales/en/`。訳は `locales/<言語>/` に同じキー構造で書く
- `{count, plural, ...}` の `plural` / `select` と引数名、`select` の
  キーワード(`male` `female` `other`)は訳さない
- `plural` には、その言語が必要とするカテゴリをすべて書く。
  必要なカテゴリは `new Intl.PluralRules("<言語>").resolvedOptions().pluralCategories`
  で調べる。英語の `one` / `other` の形を写さない
- 選択肢の中は、断片ではなく完全な文で訳す
- 人が読む文のアポストロフィは `’`(U+2019)を使う。ASCIIの `'` はICU構文の引用専用
- 書き終えたら `node scripts/check-icu.mjs locales/<言語>/*.json` を実行し、
  出力が空になるまで直す

「選択肢の中は完全な文で訳す」は、ICUのガイドの推奨に沿った項目です。ガイドは、複雑な引数を文の一部だけに使うと、翻訳者が前後との関係や範囲の伸縮を判断できず訳しにくいと述べています。select と plural を入れ子にするときは、select を外側にして plural を内側に置くのが推奨です。

この検査が見ないこと

最後に、検査の守備範囲外を挙げます。

  • 完全一致の =0 =1: ICUのガイドによれば、英語のメッセージに =0 と =1 の両方(オフセット指定時は =offset+1 まで)があると、one は選ばれないため書かなくてよいとされています。このスクリプトは one が無ければ欠落として報告するため、意図して省略した箇所は例外扱いが必要です
  • 実行時ライブラリとの差: ここでの結果は @formatjs/icu-messageformat-parser の解析に基づきます。アプリがICU4JやICU4Cなど別の実装を使っている場合、細かな構文の許容範囲が違うことがあります。最終確認はアプリ側のライブラリで行います
  • 変数の過不足: {name} が訳文から消える、名前が書き換わるといった単純な引数の過不足は、この検査では見ていません。poファイルのプレースホルダ検査や、i18nextの変数検査のように、別の検査で受けます
  • 訳文の質: 抜けや重複の確認は、Claude for Excelによる訳抜けチェックのような別の手段が担います
  • MessageFormat 2.0: ICUのガイドは、現行のMessageFormatの後継として2.0のドラフト仕様が策定中だと記しています。本記事のパーサーは現行の構文用です

検査が見るのは「構文として成り立つか」と「言語に必要な形が揃っているか」の2点です。訳として自然かどうかは別の工程で扱います。

参照した一次ソース

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

  1. code.claude.com/docs/en/hooks
  2. unicode-org.github.io/icu/userguide/format_parse/messages
この記事を共有:XはてブLinkedIn