Claude CodeでUnicode正規化の不具合を直す — 濁点と半角カナの再現テストから
検索や比較で「同じに見える日本語」が一致しない原因は、NFCとNFDの違いや半角カナです。再現テストを先に書かせ、入口でnormalizeして直す手順を実測つきでまとめます。
Claude CodeでUnicode正規化の不具合を直す — 濁点と半角カナの再現テストから
画面では同じ「ガイド」なのに、検索しても見つからない。=== で比べるとfalseになる。こうした不具合の多くは、文字の見た目ではなくコードポイントの並びが違うことが原因です。Claude Codeにこの種の不具合を直させるなら、症状を説明して修正を頼む前に、違いを再現する失敗テストを先に書かせるのが近道です。
この記事では、濁点が分離したNFD、半角カナ、全角英数字の3つを手元のNode.jsで再現し、テストを赤にしてから、入口で normalize() をかけて緑にするまでを順に追います。
同じに見える文字が一致しない理由
Unicodeでは、同じ文字を複数のコードポイント列で表せることがあります。MDNの String.prototype.normalize() の説明では、「ñ」が単一のコードポイントU+00F1でも、「n」と結合文字の組み合わせでも書けると説明され、この2つは見た目が同じでも === で比べるとfalseで、length も1と2で食い違います。
日本語で同じことが起きるのが濁点です。「ガ」は1文字のU+30ACでも、「カ」(U+30AB)に結合用濁点(U+3099)を続けた2コードポイントでも書けます。手元のNode.js 20.18.0で確かめた結果が次のとおりです。
const nfc = "ガ";
const nfd = nfc.normalize("NFD");
console.log(nfc.length, nfd.length, nfc === nfd); // 1 2 false
console.log(nfd.normalize("NFC") === nfc); // true
console.log("ガ".includes("カ")); // false
console.log(nfd.includes("カ")); // true最後の2行が厄介です。NFDの「ガ」は「カ」の直後に濁点が続く並びなので、「カ」で検索すると一致してしまいます。一致しない不具合だけでなく、一致してはいけないものが一致する不具合にもなります。
4つの正規化形式
normalize() が受け取る形式は4つです。MDNとUnicode標準のTR15では、次のように定義されています。
| 形式 | 処理 | 日本語での効き方 |
|---|---|---|
| NFC | 処理正準分解のあと正準合成 | 日本語での効き方濁点を1文字にまとめる |
| NFD | 処理正準分解 | 日本語での効き方濁点を分離する |
| NFKC | 処理互換分解のあと正準合成 | 日本語での効き方NFCに加え、半角カナや全角英数字も寄せる |
| NFKD | 処理互換分解 | 日本語での効き方NFDに加え、互換文字も分解する |
引数を省略したときの形式はNFCです。"K" は互換(Compatibility)の頭文字で、NFCやNFDと混同しないようにKを付けた、とTR15に書かれています。
日本語で起きる3つの症状と必要な形式
症状によって、使う形式が変わります。ここを取り違えると、直したつもりで別の不一致が残ります。
| 症状 | 例 | NFC | NFKC |
|---|---|---|---|
| 濁点・半濁点の分離 | 例NFDの「がぎ」 | NFC直る | NFKC直る |
| 半角カナ | 例「ガイド」(5コードポイント) | NFC直らない | NFKC「ガイド」(3文字)に直る |
| 全角英数字 | 例「ABC123」 | NFC直らない | NFKC「ABC123」に直る |
手元の実測では、「ガ」(U+FF76とU+FF9E)をNFCにかけても全角の「ガ」とは一致せず、NFKCをかけると一致しました。半角カナの濁点は、元から2コードポイントで書かれているだけで、分離したNFDとは別の問題です。NFCでは直らず、互換文字を寄せるNFKCが必要になります。
切り分けの目安
ファイル名や貼り付けで濁点だけがずれる
NFCで足ります。見た目を変えずに合成済みの形へ揃えられます。
半角カナや全角英数字も同一視したい
NFKCを使います。検索キーや重複判定のような比較用の値向けです。
表示や保存する値を変えたくない
比較の瞬間だけ正規化したキーを作り、元の文字列は残します。
先に再現テストを書かせる
Claude Codeの公式ドキュメントには、テストを扱う手順があります。未テストのコードを洗い出し、テストの雛形を作らせ、境界条件を足し、実行して失敗を直す、という流れです。さらに、境界値や想定外の入力は見落とされやすいので、Claudeに洗い出しを頼むよう勧めています。ベストプラクティスのページにも、バグ修正では「失敗するテストを書いて再現し、それから直す」という頼み方が例として載っています。
正規化の不具合に当てはめると、頼み方は次のようになります。
ファイル名の検索で、濁点が分離した名前と半角カナの名前が一致しない。
src/search.mjs の searchFiles を直す前に、次の3つを再現する
失敗テストを search.test.mjs に書いて。
1. NFDの「がぎぐ.txt」が検索語「がぎ」に一致する
2. 半角カナの「ガイド.md」が検索語「ガイド」に一致する
3. NFDの「ガイド.md」が検索語「カ」に一致しない
NFDの文字列はリテラルで直書きせず normalize("NFD") で作って。
まず node --test を実行して3件とも落ちることを見せて。指示のポイントは3つあります。
- 落ちる様子を実行出力で見せさせる。通ってしまうなら再現できていません
- NFDの文字列をソースに直書きさせない。NFCとNFDは画面上で見分けがつかず、レビューでも気づけません。MDNの例もコードポイントのエスケープで書いています
- 3件目の「一致してはいけない」も含める。正規化で全部寄せた結果、別の文字が一致する副作用を検知できます
修正前のテストが落ちる
同じ3件を書いた最小のテストを手元で動かした結果です。修正前の searchFiles は includes で素直に比べるだけの実装です。
node --test
# tests 3
# pass 0
# fail 33件とも落ちました。1件目と2件目は一致するはずのものが一致せず、3件目は一致してはいけないものが一致して落ちています。この赤い状態が、修正の完了条件になります。
入口で正規化して緑にする
修正は、比較する両側を同じ形式に寄せるだけです。
const fold = (s) => s.normalize("NFKC");
export function searchFiles(names, query) {
const q = fold(query);
return names.filter((n) => fold(n).includes(q));
}修正後に node --test を実行すると、3件とも通りました。ここで大事なのは、正規化の置き場所です。比較する関数の中で毎回ばらばらに normalize() を呼ぶと、呼び忘れた経路が必ず残ります。ユーザー入力、ファイル一覧、外部APIの応答といった入口に1つの関数を置き、比較や検索キーの生成は必ずそこを通す。この形にしておくと、Claudeに新しい比較処理を足させるときも、通すべき関数が1つに決まります。
Pythonなら unicodedata.normalize("NFKC", s) が同じ役割です。Pythonの標準ライブラリの説明も、形式にNFC、NFKC、NFD、NFKDを取ります。
表示する値には使わない
NFKCは互換文字を別の文字に置き換えるので、見た目が変わります。手元では次のようになりました。
| 入力 | NFKCの結果 |
|---|---|
| 「㈱」 | NFKCの結果「(株)」 |
| 全角のチルダ「~」(U+FF5E) | NFKCの結果半角の「~」 |
| 波ダッシュ「〜」(U+301C) | NFKCの結果変化なし |
| 全角スペース(U+3000) | NFKCの結果半角スペース(削除はされない) |
MDNも、互換正規化は検索には向いても、見た目が変わるので表示には向かないことがあると説明しています。保存する値は元のまま残し、比較用のキーだけをNFKCにする、という分け方が安全です。
落とし穴
波ダッシュとチルダは別の文字として残る
U+301Cの「〜」と、U+FF5Eの「~」は、NFKCをかけても同じにはなりません。後者だけが半角の ~ に変わります。範囲指定の記号が混在するデータでは、正規化のあとも一致しない組み合わせが残るので、必要なら別途置換のルールを足します。
macOSのファイル名で実際に起きること
濁点が分離するNFDは、ファイル名に由来して持ち込まれる例が多くあります。手元のmacOSで、PythonでNFDの「がぎ.txt」を作って一覧を読み戻したところ、返ってきた名前はNFDのままで、NFCの文字列との == はFalseでした。一方、NFCの名前を指定した os.path.exists はTrueを返しました。
つまり、ファイルシステムは両方の綴りを同じファイルとして扱うのに、プログラム内の文字列比較は区別します。ファイルは開けるのに、一覧と照合すると見つからない、という食い違いになります。
Gitにも関連する設定があります。core.precomposeUnicode はmacOS版のGitだけに効く設定で、trueにするとmacOSが行った濁点の分解を元に戻します。macOSとLinuxやWindowsの間でリポジトリを共有するときに役立つ、と説明されています。差分や追跡でファイル名が二重に見える場合の確認先です。
git config --get core.precomposeUnicode文字化けとは原因が別です。バイト列の解釈がずれる文字化けはClaude Codeの/copyで日本語が文字化けする原因と対処法のようにエンコーディング側の問題で、正規化は文字としては正しい列の「並び方」の違いを扱います。
修正の切り分けフロー
不一致が起きたとき、先に確かめる順番は次のとおりです。
日本語の不一致を切り分ける
- Qコードポイントを16進で並べると、2つの文字列に違いがあるか?答えがない答えがはい
違いがなければ、正規化は原因ではありません。エンコーディングや不可視文字を疑います。
- Q違いのある側に U+3099 や U+309A が含まれるか?答えが含まれない答えがはい
含まれなければ、半角カナや全角英数字のような互換文字の違いです。
形式を決める
U+3099やU+309Aがあれば濁点の分離なのでNFCで揃えます。互換文字の違いなら比較用のキーをNFKCで作り、表示用の値は元のまま残します。
16進に並べる確認は、Claudeにそのまま頼めます。
この2つの文字列のコードポイントを16進で並べて、
違う箇所を教えて。修正はまだしないで。すでに保存されたデータは別に直す
入口を直しても、過去に保存したNFDの名前やキーは残ります。比較用のキーを毎回その場で作る設計なら放置できますが、DBの一意制約や重複判定に保存済みの値を使っているなら、移行が要ります。
保存済みの name 列のうち、NFKCにかけると値が変わる行を
一覧にして。更新はまだしないで。件数と先頭10件を見せて。先に一覧だけを出させて、変わる行の数と中身を人間が見てから、更新を頼みます。「㈱」のように見た目が変わる行が混じっていれば、NFKCではなくNFCで移行する判断もできます。
同じ不具合を繰り返さないためにCLAUDE.mdへ書く
入口で正規化する、という約束は、毎回の依頼文に書くより CLAUDE.md に置いたほうが安定します。次のような短い断片で足ります。ベストプラクティスのページは、コードを読めば分かることは書かず、リポジトリの外にある情報を書くよう勧めています。
## 文字列の比較と検索
- ファイル名・ユーザー入力・外部データは、比較や検索キーにする前に
src/text/fold.ts の fold() を通す(NFKC)
- テストでNFDを使うときは、リテラルを直書きせず
"...".normalize("NFD") か \u3099 のエスケープで書く
- 表示や保存する値にはfold()をかけない(㈱が(株)に変わるため)この3行は、そのまま検査にもなります。新しい比較処理が入ったあとに、再現テストと同じ3件を流して通るかを確かめれば、約束が守られているかを確認できます。再現テストを回帰テストとして残す考え方は、Claude Codeに再現テストを先に書かせてバグを直す手順で詳しく扱っています。
まとめ
不一致の原因がコードポイント列の違いなら、直す前に失敗テストで再現し、比較の入口に正規化関数を1つ置くのが最小の修正です。濁点だけならNFC、半角カナや全角英数字まで揃えるならNFKCを選び、NFKCは比較用のキーに限って使います。表示する値まで変えると、「㈱」が「(株)」になるように別の不具合が生まれます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。