深夜のランのログに、こう残っていました。
FileNotFoundError: [Errno 2] No such file or directory:
'content/reference/壁紙_配色ガイド.md'
同じ端末で ls content/reference/ と叩くと、壁紙_配色ガイド.md が涼しい顔で並んでいます。目で見て、一字ずつ追って、同じ名前です。それでもエージェントは開けないと言い張る。
コピーして貼り付ければ開ける。エージェントに任せると開けない。この非対称が、いちばん気持ちの悪いところでした。
三日間、私は同期を疑っていました
最初の見立ては外れています。正直に書いておきます。
私の作業フォルダはクラウド同期の下にあり、以前にも実体のないプレースホルダを掴んで空ファイルを読んだことがありました。だから今回もそれだろうと決めてかかり、同期の除外設定を触り、ローカルへ全ダウンロードをかけ、ログを眺めて、また同じエラーを見ました。
二日目には権限を疑いました。所有者も、モードも、何も問題はありません。
三日目の夜、諦めてバイト列を見ました。
$ ls content/reference/ | grep 壁紙 | xxd | head -3
00000000: e5a3 81e7 b499 5fe9 858d e889 b2e3 8299 ....._........
00000010: e382 ace3 82a4 e383 895f 2e6d 6400 ........._.md.
e3 82 99 が見えます。これは結合文字の濁点、単独で存在する「゛」です。つまりファイル名の中の「配」の次に来る文字は、ガ という一文字ではなく、カ と ゛ の二文字として並んでいました。
エージェントが探していたのは、ガ(e3 82 ac)を含む名前です。ファイルシステムに刻まれていたのは、カ + ゛ を含む名前です。人間の目にはどちらも同じ形に描画されます。バイト列としては、まったく別の文字列です。
存在しないと言われて当然でした。エージェントは正しかったのです。
なぜエージェントだけが踏み抜くのか
ここが本題です。同じリポジトリを人間が手で触っている限り、この問題はほとんど表に出てきません。
理由は単純で、人間はファイル名をコピー&ペーストするか、タブ補完で入力するからです。どちらもファイルシステムから読み出したバイト列をそのまま使いますから、書かれている通りの並びで問い合わせることになります。
エージェントは違います。エージェントはファイル名を文脈の中の日本語から組み立ててきます。設計メモに「壁紙_配色ガイド.md を参照してください」と書いてあれば、その文字列を読んで、パスとして再構成する。そして言語モデルが出力する日本語は、ほぼ例外なく合成済み(NFC)の形です。
一方 macOS のファイルシステムは、歴史的な経緯から分解済み(NFD)に寄せた名前を返してきます。Finder でフォルダを作り、日本語で名前を付けた瞬間に、濁点は独立した文字として書き込まれます。
つまりこういう構図になります。
| 経路 | ファイル名の形 | 「ガ」のバイト列 |
| macOS の Finder で命名 | NFD(分解) | e3 82 ab + e3 82 99 |
| 言語モデルが文脈から生成 | NFC(合成) | e3 82 ac |
Linux の VM で mkdir | NFC(渡された通り) | e3 82 ac |
| Git のインデックス | 書かれた通りを保持 | どちらもあり得る |
Git はここで積極的な仲裁をしません。core.precomposeunicode は macOS 側の入口を合成済みに寄せてくれますが、既に分解済みで入ってしまった履歴までは戻してくれない。私のリポジトリには、この設定を知る前に入れた NFD のファイルが、そのまま何年も残っていました。
そして最悪の展開が起きます。エージェントは「無い」と判断すると、親切心から作りにいくのです。
content/reference/壁紙_配色ガイド.md ← 2023年から在る(NFD)
content/reference/壁紙_配色ガイド.md ← 昨夜エージェントが作った(NFC)
ls の出力に同じ名前が二行並びます。中身は違います。片方は数年分の知見で、片方は生まれたての空に近いファイルです。どちらを読むかは、その時のパスの組み立て方次第で変わる。私はこれを「幽霊二重ファイル」と呼んで警戒しています。
混ぜてはいけない三つの軸
ここで一度整理しておきます。「名前が一致しない」という症状はひとつでも、原因は独立した三本の軸に分かれます。切り分けずにまとめて直そうとすると、必ずどこかで取りこぼします。
軸1: Unicode 正規化(NFC / NFD)
いま見てきたものです。濁点・半濁点を持つ仮名と、アクセント付きラテン文字が影響を受けます。カ + ゛ と ガ。e + ´ と é。見た目は同一、バイト列は別。
日本語のファイル名を扱う限り、これが最頻出です。
軸2: 大文字小文字の畳み込み
macOS の APFS は既定で大文字小文字を区別しません。Linux の ext4 は区別します。
エージェントが utils.ts を探して見つからず Utils.ts を作る。macOS では同じファイルへの上書きになります。Linux の CI では別ファイルとして二つ並びます。手元では通り、CI で落ちる典型がこれです。日本語より気づきにくく、直したつもりが直っていない事故になりやすい軸でもあります。
軸3: 不可視文字と紛らわしい文字
全角スペース(U+3000)、ノーブレークスペース、末尾の半角スペース、ゼロ幅スペース。それから全角の英数字。
これは正規化では消えません。NFC にしても全角スペースは全角スペースのまま残ります。別の検出が要ります。
| 軸 | 正規化で直るか | 主な発生源 | 気づくタイミング |
| NFC / NFD | 直る | macOS で日本語名を命名 | エージェントが「存在しません」 |
| 大文字小文字 | 直らない | 手元とCIのFS差 | CI だけが落ちる |
| 不可視・全角 | 直らない | コピペ・手入力 | ずっと気づかない |
三本目がいちばん厄介です。落ちてくれないからです。誰も困っていないように見えて、ある日エージェントがそのパスを引用した瞬間に牙を剥きます。
目で見ずに、バイト列で走査する
方針は決まりました。目視をやめて、機械に数えさせます。
リポジトリ全体のパスを走査し、三軸それぞれで異常を報告する小さなスクリプトを書きました。実運用しているものをそのまま載せます。
#!/usr/bin/env python3
"""filename_gate.py — リポジトリ内のパスを3軸で検査する。
python3 filename_gate.py <repo_root>
終了コード 0=クリーン / 1=要対処
"""
import subprocess
import sys
import unicodedata
from collections import defaultdict
from pathlib import Path
# 軸3で弾く文字(正規化では消えないもの)
SUSPICIOUS_CHARS = {
" ": "全角スペース",
" ": "ノーブレークスペース",
"": "ゼロ幅スペース",
"": "左横書き記号",
"": "BOM",
}
def tracked_paths(repo: Path):
"""Git が追跡しているパスを、エスケープさせずに生バイトで受け取る。"""
out = subprocess.run(
["git", "-C", str(repo), "ls-files", "-z"],
capture_output=True, check=True,
).stdout
for chunk in out.split(b"\x00"):
if chunk:
yield chunk.decode("utf-8", errors="surrogateescape")
def check(repo: Path):
findings = []
casefold_groups = defaultdict(list)
for path in tracked_paths(repo):
name = Path(path).name
# --- 軸1: 正規化 ---
nfc = unicodedata.normalize("NFC", path)
if path != nfc:
findings.append(("NFD", path, f"NFC 形にすると {nfc!r}"))
# --- 軸2: 大文字小文字の衝突候補 ---
casefold_groups[nfc.casefold()].append(path)
# --- 軸3: 不可視・紛らわしい文字 ---
for ch, label in SUSPICIOUS_CHARS.items():
if ch in name:
findings.append(("CHAR", path, f"{label} (U+{ord(ch):04X}) を含む"))
if name != name.strip():
findings.append(("CHAR", path, "先頭または末尾に空白"))
if any(unicodedata.east_asian_width(c) == "F" for c in name):
findings.append(("CHAR", path, "全角の英数字または記号を含む"))
for _, group in casefold_groups.items():
if len(group) > 1:
findings.append(("CASE", " / ".join(group), "大小のみが異なる複数のパス"))
return findings
def main():
repo = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
findings = check(repo)
if not findings:
print("✅ filename_gate: クリーン")
return 0
by_kind = defaultdict(list)
for kind, path, note in findings:
by_kind[kind].append((path, note))
for kind in ("NFD", "CASE", "CHAR"):
for path, note in by_kind.get(kind, []):
print(f"[{kind}] {path}\n └ {note}")
print(f"\n❌ filename_gate: {len(findings)} 件")
return 1
if __name__ == "__main__":
sys.exit(main())
一点だけ、実装で外せない勘所があります。git ls-files に -z を付けている理由です。
付けないと、Git は非 ASCII のパスを "\346\226\207..." のような八進エスケープ付きの引用符で返してきます。それを素直に読むと、検査したいバイト列そのものを見損なう。最初に書いたときはここで一度、静かに全件クリーンという嘘の結果を出しました。検査スクリプトが検査対象を取り違えていたわけです。
errors="surrogateescape" も同じ理由です。壊れたバイト列に出会っても例外で止まらず、そのまま持って回って報告できます。検査する側が落ちてしまっては意味がありません。
初回の実行結果はこうでした。
[NFD] content/reference/壁紙_配色ガイド.md
└ NFC 形にすると 'content/reference/壁紙_配色ガイド.md'
[NFD] content/reference/申請_チェックリスト.md
└ NFC 形にすると 'content/reference/申請_チェックリスト.md'
[CASE] src/lib/Utils.ts / src/lib/utils.ts
└ 大小のみが異なる複数のパス
[CHAR] docs/リリース メモ.md
└ 全角スペース (U+3000) を含む
❌ filename_gate: 14 件
出力の1行目と2行目が「同じ文字列」に見えるのが、この問題の本質を端的に表しています。表示は同じ、中身は別。だからこそ、人間のレビューでは絶対に捕まりません。
三層で塞ぐ
検出できたら、次は再発を止めます。ひとつの層で完璧を狙うより、性質の違う三層に分けたほうが、結果として穴が小さくなりました。
第1層: 入口を狭める(いちばん効いた)
リポジトリの AGENTS.md に、次の一文を足しました。
## ファイル名の規約
- リポジトリに追加するパスは、ディレクトリ名・ファイル名ともに
ASCII の小文字・数字・ハイフンのみで構成する。
- 日本語はファイル名に置かない。フロントマターの title など、
ファイルの「中身」に置く。
- 既存の日本語ファイル名を参照するときは、名前を文脈から
組み立てず、必ず `git ls-files` の出力から取得する。
拍子抜けするほど地味です。しかし6週間の実測では、新規に生まれる名前ドリフトの約 90% をこの一文が止めました。残り 10% を第2層と第3層が拾う配分です。正規化の面倒を巧妙に処理するより、面倒が発生する場所を先に消すほうが強い。私はこの順番を推奨します。
三つ目の項目は、エージェント固有の対策として効きました。「文脈から組み立てるな、ファイルシステムから読め」という指示です。原因が「モデルの出力は NFC」である以上、モデルに出力させないのがいちばん確実な回避になります。
第2層: CI で機械的に落とす
入口の規約は破られます。悪意ではなく、単に忘れられます。だから機械に見張らせます。
#!/usr/bin/env bash
# ci-filename-gate.sh — 追加・変更されたパスだけを検査する
set -euo pipefail
BASE="${1:-origin/main}"
# 差分のあるパスだけを -z で取得(全件走査は大きなリポジトリで無駄)
CHANGED=$(git diff --name-only -z "${BASE}"...HEAD | tr '\0' '\n' | grep -c . || true)
if [ "${CHANGED}" -eq 0 ]; then
echo "変更されたパスがありません"
exit 0
fi
python3 scripts/filename_gate.py . || {
cat <<'MSG'
────────────────────────────────────────────
ファイル名ゲートで停止しました。
NFD → パスを ASCII 名へ改名してください
CASE → 大小のみ異なる重複を解消してください
CHAR → 全角・不可視文字を取り除いてください
改名は `git mv` を2段階で行ってください:
git mv 旧名 一時名 && git mv 一時名 新名
(大小のみの変更は1段階だと macOS で無視されます)
────────────────────────────────────────────
MSG
exit 1
}
最後の注記は、実際に踏んだので書いてあります。git mv Utils.ts utils.ts を macOS で叩くと、ファイルシステムが両者を同一視するため、何事もなかったかのように成功して何も変わりません。一時名を経由する二段階が要ります。この落とし穴に30分ほど溶かしました。
第3層: エージェント側のリゾルバ
それでも、既存の NFD ファイルはリポジトリに残ります。数年分の資産を一斉に改名するのは、リンク切れの危険と釣り合いません。
そこで、エージェントが使うツール側に、寛容な解決器を挟みました。
import unicodedata
from pathlib import Path
def resolve_tolerant(root: Path, wanted: str) -> Path | None:
"""要求されたパスを、正規化と大小を吸収して実体へ解決する。
厳密一致を最優先し、緩い一致は候補が1つのときだけ採用する。
複数候補があるなら、それは幽霊二重ファイルの徴候なので解決しない。
"""
exact = root / wanted
if exact.exists():
return exact
def key(s: str) -> str:
return unicodedata.normalize("NFC", s).casefold()
target = key(wanted)
matches = [p for p in root.rglob("*") if key(str(p.relative_to(root))) == target]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
raise FileExistsError(
f"同名に解決される実体が {len(matches)} 件あります: {matches}"
)
return None
この設計で意識したのは、曖昧なときに黙って選ばないという一点です。
候補が複数見つかったら例外を投げます。片方を適当に返せば処理は流れますが、流れた先で古い資産を空ファイルで踏み潰すかもしれない。止まって人間を呼ぶほうが、桁違いに安い。無人で走らせるものほど、迷ったら止める側に倒すべきだと私は考えています。
6週間、回してみた数字
導入から6週間、4つのリポジトリで走らせた結果です。
| 項目 | 実測 |
| 初回走査で検出(NFD / CASE / CHAR) | 14件(9 / 2 / 3) |
| 幽霊二重ファイル | 3組 |
| CI ゲートでの停止 | 5回 |
| うち規約違反の新規日本語パス | 4回 |
| 誤検知 | 1回(停止5回中の20%・意図的な全角を含む固定資産) |
| ゲートの実行時間(約2,400パス) | 約 0.4 秒 |
| 「存在しません」系の失敗ラン | 導入前 6週で 11回 → 導入後 0回(100% 減) |
いちばん驚いたのは、幽霊二重ファイルの3組です。うち1組は4か月前から並存していました。片方はアプリの配色に関する数年分の記録、もう片方はエージェントが夜中に作った 12 行の断片です。
その断片を、私は何度か読んでいます。「あれ、思ったより薄いな」と感じながら、それが本体だと思い込んで読んでいました。本物は隣にあり、同じ名前で、静かに無視されていました。
背筋が寒くなったのは、この4か月間、誰も何も気づかなかったという事実のほうです。エラーは出ません。CI も緑です。ただ、参照されない資産が積もっていくだけ。
実行時間 0.4 秒という数字も、判断を軽くしてくれました。差分のみに絞れば、さらに 3 倍ほど速くなります。全 CI に無条件で挟んでも誰も文句を言いません。10 秒かかるゲートは「今日は急ぐから外そう」と言われますが、0.4 秒のゲートは外す理由が誰にも作れない。ゲートを定着させたいなら、速さは正義です。
それでも、正規化に寄りかかりすぎない
最後に、線引きの話をしておきます。
第3層のリゾルバを書いたあと、私は一度これを万能にしたい誘惑に駆られました。カタカナと平仮名も吸収しようか、全角半角も畳もうか、と。
やめました。理由は二つです。
ひとつは、寛容な解決が問題を見えなくするからです。リゾルバが賢くなるほど、リポジトリの名前は汚れても誰も困らなくなります。困らないまま汚れが溜まり、ある日リゾルバの外側(他人のツール、Git 本体、CI のシェルスクリプト)で牙を剥く。エージェントは救えても、grep は救えません。
もうひとつは、畳み込みの規則が言語と文脈に依存するからです。トルコ語の I の畳み込みは英語と違います。個人開発で日本語だけを相手にしているうちは気づきませんが、規則を自作した瞬間に、自分だけのルールを抱え込むことになります。
だから私の結論はこうです。第1層に体重を乗せ、第3層は既存資産のための橋として最小限にとどめる。橋は広げない。渡り終えたら畳む。
改名を進める際は、次の順で手を付けています。
filename_gate.py を走らせ、検出を全件リスト化する
- CASE と CHAR を先に潰す(正規化では直らず、影響が読みにくいため)
- NFD は参照元を
grep で洗い出してから改名する(git mv は二段階で)
- 改名したパスは、リダイレクトの要否を必ず確認する(公開 URL に露出していれば必須)
- 全件クリーンになったら、CI ゲートを必須化する
順番を逆にすると、改名の途中で CI が赤くなり、赤いまま慣れてしまいます。ゲートは、クリーンになってから required にするほうがうまくいきました。
次に手を動かすなら
まず filename_gate.py を、いま手元にあるリポジトリで一度だけ走らせてみてください。所要は数秒です。
✅ クリーン が出れば、それは幸運ではなく、これまで日本語ファイル名を避けてきた誰かの判断の成果です。何か出たなら、その中に幽霊が混じっていないか、ls | xxd で確かめる価値があります。
見えているものが、そこに在るとは限りません。エージェントに任せる範囲が広がるほど、その前提は静かに揺らいでいきます。
長い記事にお付き合いいただき、ありがとうございました。私自身、三日を溶かして初めてバイト列を見た側の人間ですので、同じ夜を短くできれば嬉しく思います。
関連して、Antigravity のターミナルで日本語が文字化けするときの対処は表示側の話、クラウド同期のワークスペースで空ファイルを掴む問題は実体側の話として、それぞれ別の軸を扱っています。並列エージェントが同じファイルへ書き込むときの調停は、共有ファイル書き込みの調停設計にまとめてあります。