Antigravity CLI 1.1.21(8月26日)のリリースノートに、非 ASCII を含むファイルで編集が壊れる問題の修正が入っていました。
修正されたのは良いことです。ただ、読んだ瞬間に引っかかったのは別のところでした。もし同じことが手元で起きていたとして、私はそれに気づけただろうか、という点です。
確かめるために、浮世絵の作品名を並べたリソースファイルを模したものを用意して、「神奈川沖浪裏」から1バイトだけを故意に削ってみました。壊れたファイルの差分は、拍子抜けするほど普通に見えました。
個人開発で日本語のリソースを抱えていると、この話は他人事になりません。私自身、日本語の作品名と解説を持つ JSON や、日本語コメントの入ったヘッダファイルを日常的にエージェントへ渡しています。壊れたことに気づく仕組みを、こちら側に一つも置いていませんでした。
git の差分は、壊れたバイトを普通の1行変更として見せます
まず、壊れたときに何が見えるのかを手元で確かめました。正常な UTF-8 のファイルから、3バイト文字の途中の1バイトだけを削ります。
# 「浪」の3バイト目を1バイトだけ削って、不正な UTF-8 を作る
python3 -c "
b = bytearray(open('tracked.txt','rb').read())
i = b.find('浪'.encode())
del b[i+2]
open('tracked.txt','wb').write(bytes(b))"
git diff --numstat
git diff | head -8
出力はこうなりました。
1 1 tracked.txt
diff --git a/tracked.txt b/tracked.txt
index 4d2ec8b..50590fb 100644
--- a/tracked.txt
+++ b/tracked.txt
@@ -1,2 +1,2 @@
// 浮世絵の作品名を保持します
-const title = "神奈川沖浪裏";
+const title = "神奈川沖��裏";
git はバイナリ扱いにしません。警告も出しません。1 insertion(+), 1 deletion(-) という、ごく普通の1行変更です。
ここが厄介な点です。エージェントが 30 ファイルに触った差分を上から順に読んでいるとき、日本語の中の1文字が置換記号になっている行は、視覚的にほとんど引っかかりません。行の長さも構造も変わらないためです。
無人でエージェントを走らせる構成なら、書きかけのファイルが残る問題のほうがまだ気づけます。ファイルが途中で切れていれば、少なくともビルドが落ちます。1文字の置換は落ちません。
壊れ方は3種類あり、検査の当たり方が違います
手元で意図的に壊したファイルを5つ用意して、よく使われる検査コマンドの反応を並べました。
| ファイル | 状態 | file の判定 | iconv -f UTF-8 -t UTF-8 | Python の strict デコード |
| ok.txt | 正常な UTF-8 | UTF-8 text | exit 0 | OK |
| broken.txt | 1バイト欠損 | Non-ISO extended-ASCII text | exit 1 | FAIL(invalid continuation byte) |
| sjis.txt | CP932 で保存 | OpenPGP Secret Key | exit 1 | FAIL(invalid start byte) |
| bom.txt | BOM 付き UTF-8 | UTF-8 (with BOM) text | exit 0 | OK |
| nfd.txt | 濁点が分解された NFD | UTF-8 text | exit 0 | OK |
3点、読み取れることがあります。
file は当てになりません。CP932 で保存された日本語のテキストを、私の環境では「OpenPGP Secret Key」と判定しました。中身は「表示言語を切り替えます」の一行だけです。文字コードの判定にヒューリスティックを使う以上、こういう外し方は起こります。判定結果を条件分岐に使うのは避けることを推奨します。
iconv と Python の strict デコードは一致します。どちらもバイト列としての妥当性を見ているだけなので、当然といえば当然です。手軽さで選んでよい部分です。
- BOM と NFD は、どの検査も通ります。 バイト列としては完全に妥当な UTF-8 だからです。壊れているのはバイトではなく、その先の扱いのほうです。
本番のリソースを扱う場面で怖いのは、3つ目です。検査が緑になったという事実だけが残り、壊れていることのほうは誰にも見えません。
検査①: 不正な UTF-8 シーケンス
一番わかりやすい壊れ方から押さえます。バイト列として成立していないファイルを拾います。
# リポジトリ全体を並列で検査し、不正なファイルだけを表示する
find content -type f \( -name '*.mdx' -o -name '*.json' -o -name '*.strings' \) -print0 \
| xargs -0 -P4 -n50 sh -c '
for f do
iconv -f UTF-8 -t UTF-8 "$f" >/dev/null 2>&1 || echo "INVALID: $f"
done' _
iconv の出力は捨てて、終了コードだけを見ます。変換結果が要るわけではないためです。
手元のリポジトリ(MDX 2,064 本)に対して走らせたところ、667 ミリ秒で完走し、検出は0件でした。この規模なら、コミットのたびに走らせても待ち時間として認識されません。
ただし、この検査には決定的な穴があります。
置換記号で「直った」ファイルは、この検査を通ります
エージェントやツールチェーンの側が、壊れたバイトに出会ったときに落ちない実装になっていることがあります。よくあるのは、読み込み時に置換文字へ差し替える書き方です。
# 落ちないための、よくある読み方
raw = open("broken.txt", "rb").read()
s = raw.decode("utf-8", errors="replace") # 不正バイト -> U+FFFD
open("healed.txt", "w", encoding="utf-8").write(s)
この後で healed.txt を検査すると、こうなります。
healed.txt decode=OK (検査は通る)
本文 : const title = "�奈川沖浪裏";
U+FFFD 個数 : 1
iconv exit=0
iconv の終了コードは0です。Python の strict デコードも通ります。バイト列としては、完全に妥当な UTF-8 に戻っています。 戻っていないのは中身のほうだけです。
バイト列を並べると、置き換わった様子がはっきりします。
破損直後: 2074 6974 6c65 203d 2022 e7a5 e5a5 88e5 title = "......
置換後 : 2074 6974 6c65 203d 2022 efbf bde5 a588 title = "......
欠けた e7a5 の残骸が、efbfbd(U+FFFD)という3バイトに置き換わっています。ここから元の「神」を復元する手段はありません。
私がこの挙動を知って考えを変えたのは、検査の置き場所についてでした。妥当性の検査をツールチェーンの後ろに置くと、壊れた事実そのものが検査の前に消えます。 後ろに置くなら、別の指標が要ります。
このすれ違いを回避する方法は2つしかありません。検査をツールチェーンの前へ移すか、置換文字そのものを数えるかです。個人開発の環境では前者だけで足りることが多いのですが、外部のツールを挟む工程があるなら、後者も併せて持っておくほうが確実です。
検査②: U+FFFD の混入を数える
置換で失われた痕跡を拾える手段は、実質これだけです。バイト列 EF BF BD を探します。
# 置換文字が本文に紛れ込んでいるファイルを検出する
grep -rlP '\xef\xbf\xbd' content/ 2>/dev/null
注意点が一つあります。U+FFFD は、意図して本文に書くことがまずない文字です。ただし、文字化けそのものを扱った記事や、テストの期待値として置いている場合には正当に出現します。私はこの除外を、テストデータの入ったディレクトリ1つだけに保っています。
除外リストは短く保つのがコツです。長くなってきたら、それは検査の設計がずれているサインだと考えています。
検査③: 正規化ゆれと BOM
3つ目は、バイト列としては妥当なのに、扱いが壊れる組み合わせです。
macOS のファイルシステム由来で、濁点や半濁点が分解された NFD の文字列が混ざることがあります。見た目は同じです。バイト列は違います。
NFC: e381 b7e3 8289 e381 990a (ぷ = 1文字)
NFD: e381 b5e3 829a e382 89e3 8199 0a (ふ + 濁点記号)
この差は、検索に直接効きます。
$ grep -c "ぷ" nfc.txt
1
$ grep -c "ぷ" nfd.txt
0
同じ文字を探しているつもりで、ヒットしません。エージェントに「この語を含む箇所を直して」と頼んだときに、対象が静かに漏れる原因になります。ファイル名側で同じことが起きる話は、日本語ファイル名が macOS と Linux で別物になるときにまとめています。今回はファイルの中身の側です。
BOM のほうは、先頭3バイトを見るだけで済みます。JSON のパーサや、シェルスクリプトの shebang 判定が BOM で崩れることがあるため、リソースファイルを扱うなら見ておく価値があります。
3つを1パスにまとめる
コマンドを3回走らせると、ファイルを3回読むことになります。数千ファイルの規模だと、この差は無視できません。1回の読み込みで3つとも判定する形にまとめました。
#!/usr/bin/env python3
"""日本語を含むリソースの、編集前後チェック。
戻り値: 問題があれば 1、なければ 0。
"""
import glob
import sys
import unicodedata
PATTERNS = ["content/**/*.mdx", "resources/**/*.json", "ios/**/*.strings"]
SKIP = ("/fixtures/", "/testdata/") # 文字化けを意図的に置く場所だけ除外
def scan():
invalid, fffd, non_nfc, bom, total = [], [], [], [], 0
for pattern in PATTERNS:
for path in glob.glob(pattern, recursive=True):
if any(s in path for s in SKIP):
continue
total += 1
raw = open(path, "rb").read()
# 検査③-b: BOM(バイト列としては妥当なので、先に見る)
if raw[:3] == b"\xef\xbb\xbf":
bom.append(path)
# 検査①: バイト列としての妥当性
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as e:
invalid.append(f"{path} (pos={e.start}, {e.reason})")
continue # デコードできない以上、以降の検査は成立しない
# 検査②: 置換で失われた痕跡
if "�" in text:
fffd.append(f"{path} (x{text.count(chr(0xfffd))})")
# 検査③-a: 正規化ゆれ
if text != unicodedata.normalize("NFC", text):
non_nfc.append(path)
return total, invalid, fffd, non_nfc, bom
total, invalid, fffd, non_nfc, bom = scan()
print(f"scanned={total} invalid_utf8={len(invalid)} "
f"replacement_char={len(fffd)} non_nfc={len(non_nfc)} bom={len(bom)}")
for label, items in (("INVALID UTF-8", invalid), ("U+FFFD", fffd),
("NON-NFC", non_nfc), ("BOM", bom)):
for item in items:
print(f" [{label}] {item}")
# U+FFFD と不正バイトは停止条件、正規化ゆれと BOM は警告として扱う
sys.exit(1 if (invalid or fffd) else 0)
MDX 2,064 本に対する実行結果です。
scanned=2064 invalid_utf8=0 replacement_char=0 non_nfc=0 bom=0
149 ミリ秒でした。iconv を並列で回した 667 ミリ秒より速く終わっています。プロセス起動のコストが、ファイルを1回多く読むコストを上回っていたということです。
停止条件を2つに絞った理由も書いておきます。正規化ゆれと BOM は、正当な理由で混ざることがあります。外部から受け取ったデータをそのまま置いている場合などです。ここで止めると、除外リストが際限なく伸びていきます。伸びた除外リストは、いずれ誰も読まなくなります。
どこへ置くかは、壊れたときに戻せる距離で決めます
置き場所の候補は3つあります。実際に運用してみた感触を並べます。
| 置き場所 | 壊れてから気づくまで | 向いている場面 |
| エージェントへ渡す直前 | 0(渡す前に止まる) | 大量のファイルを一括で編集させるとき |
| コミット前フック | 1回のコミット分 | 常用。150ms なら体感に出ません |
| CI | 1回の push 分 | 最低限の砦。ここだけだと差し戻しが重い |
私はコミット前フックを主にしています。理由は復旧の距離です。CI だけを採用する場合でも検査そのものは機能しますが、気づくまでの距離が変わります。コミット前なら git checkout -- <file> で戻せます。CI で気づいた場合、その間に別の編集が重なっていることがあり、壊れた1文字だけを剥がす作業が発生します。
# .git/hooks/pre-commit
#!/bin/sh
python3 tools/check_encoding.py || {
echo "エンコーディング検査で停止しました。上記のファイルを確認してください。" >&2
exit 1
}
一括編集の前にも走らせておくと、「壊したのはエージェントか、それとも元から壊れていたのか」の切り分けが一瞬で済みます。編集の前後で同じスクリプトを走らせるだけです。この切り分けができないと、patch does not apply で失敗するときのように、原因の候補が広がったまま時間が溶けます。
次にやること
手元のリポジトリで、まず U+FFFD を1回だけ検索してみてください。
grep -rlP '\xef\xbf\xbd' . --exclude-dir=.git 2>/dev/null
0件なら、今日の時点では抜けていません。1件でも出たら、それは過去のどこかで1文字が失われた記録です。git の履歴を git log -S で辿れば、混入した地点まで戻れます。
私自身、この検査を入れるまでは「日本語が壊れたら見ればわかる」と思っていました。実際には、差分の中の1文字は見えません。見えないものは、機械に数えてもらうのが確実だと考えています。