手元のシミュレータでは背景画像がちゃんと出ているのに、クラウドのビルドだけが「モジュールが見つからない」で止まりました。
差分を見ても、直前に触ったのは画像を1枚差し替えただけです。ファイルは確かにあります。開いて確認もしました。
原因は、Sunset.png を sunset.png に直したことでした。私にとっては同じファイルの整理でしたが、ビルドサーバーにとっては別のファイルの話だったのです。
個人開発で壁紙アプリのように画像を何百枚も抱えていると、この種の取り違えは一定の割合で必ず起きます。私自身、レビュー担当が他にいるわけでもありませんので、運任せにしない形にしておきたい部分です。
手元のファイルシステムは、大文字と小文字を区別していません
まずここが出発点です。macOS の既定のファイルシステムは、大文字と小文字を「区別せずに扱う」設定になっています。Sunset.png と sunset.png を同じものとして解決します。
一方、Linux はそれらを別のファイルとして扱います。Rork や Expo のクラウドビルド、GitHub Actions、EAS Build — どれも中身は Linux です。Android 端末のファイルシステムも同様です。
自分の環境がどちら側にいるかは、2行で確かめられます。
: > probe_a.txt
[ -e PROBE_A.TXT ] && echo "区別しない" || echo "区別する"同じ条件を Linux 側(Node.js v22.23.2)で作って、実際の挙動を確認しました。assets/Sunset.png を置いた状態で、コードからは小文字で参照します。
// app.js — 実体は assets/Sunset.png、参照は小文字
const p = require.resolve("./assets/sunset.png");
console.log("resolved:", p);実行結果です。
Error: Cannot find module './assets/sunset.png'
手元では通り、ビルドサーバーでは落ちる。この非対称が、差分を何度見返しても原因にたどり着けない理由でした。
Metro のバンドラは、パスの綴りをそのままファイルシステムに問い合わせます。問い合わせ先の性質が違えば、答えも変わるという単純な話です。
もっと厄介なのは、git がリネームを記録しないほうです
ここからが、私が本当につまずいた場所です。
macOS の git は、既定で core.ignorecase が true になります。大文字小文字だけが違うリネームを、git が変更として認識しません。
同じ設定を作って、何が起きるかを確かめました。
git config core.ignorecase true
mv assets/Sunset.png assets/tmp && mv assets/tmp assets/sunset.png
git status --porcelain出力はこの1行だけでした。
D assets/Sunset.png
削除だけが見えていて、新しく置いた sunset.png は未追跡としてすら現れません。この状態で git add -A を実行すると、ステージされるのは削除です。
D assets/Sunset.png
コミットしたあと、リポジトリに何が残っているかを確認しました。
git ls-files assets # → 何も出力されない
ls assets # → sunset.png(作業ツリーには存在する)作業ツリーには画像があるのに、リポジトリからは消えています。クリーンな clone を作ると、assets ディレクトリそのものが存在しませんでした。
つまり、綴りの不一致は入口にすぎず、実際に起きていたのはアセットがリポジトリから抜け落ちていたことでした。手元でだけ動いていたのは当然です。手元にしかファイルがなかったのですから。
正しい直し方は git mv -f を通すことです。
git mv -f assets/Sunset.png assets/sunset.png
git diff --cached --name-statusR100 assets/Sunset.png assets/sunset.png
R100 はリネームとして記録されたという意味です。この状態で clone すると、sunset.png がきちんと入っていました。
Rork からエクスポートしたコードを最初に git 管理へ載せるときの注意は、Rork のコードをエクスポートしたら、git init より先に .gitignore を用意します にまとめています。あわせて core.ignorecase の扱いも決めておくと安全です。
綴りの不一致を、コミット前に見つける
原因が分かっても、人間の注意力で毎回防ぐのは無理があります。私は検出を短いスクリプトに任せることにしました。
やることは単純です。ソースから相対パスのアセット参照を集め、綴りどおりに存在するかを確認し、存在しなければ「小文字にすると一致するファイル」があるかを調べます。あれば大文字小文字の不一致、なければ本当に存在しないファイルです。
// asset-case-check.mjs — 依存なしで動きます
import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
const ROOTS = process.argv.slice(2).length ? process.argv.slice(2) : ["src", "app"];
const CODE = /\.(t|j)sx?$/;
const ASSET = /\.(png|jpg|jpeg|gif|webp|svg|mp3|mp4|ttf|otf|json)$/i;
// require("./a.png") / from "./a.png" / import("./a.png") の3形をまとめて拾います
const REF = /(?:require\(|from\s+|import\()\s*["'](\.\.?\/[^"']+)["']/g;
async function walk(dir, out = []) {
let items;
try { items = await readdir(dir, { withFileTypes: true }); } catch { return out; }
for (const it of items) {
if (it.name === "node_modules" || it.name.startsWith(".")) continue;
const full = path.join(dir, it.name);
if (it.isDirectory()) await walk(full, out);
else out.push(full);
}
return out;
}
// ディレクトリの実エントリ名を一度だけ読み、小文字キーで引けるようにします
const dirCache = new Map();
async function entriesOf(dir) {
if (dirCache.has(dir)) return dirCache.get(dir);
let names = [];
try { names = await readdir(dir); } catch {}
const map = new Map();
for (const n of names) {
const k = n.toLowerCase();
if (!map.has(k)) map.set(k, []);
map.get(k).push(n);
}
dirCache.set(dir, map);
return map;
}
async function exists(p) { try { await stat(p); return true; } catch { return false; } }
const findings = [];
let scanned = 0, refs = 0;
for (const root of ROOTS) {
for (const file of await walk(root)) {
if (!CODE.test(file)) continue;
scanned++;
const src = await readFile(file, "utf8");
for (const m of src.matchAll(REF)) {
const spec = m[1];
if (!ASSET.test(spec)) continue;
refs++;
const abs = path.resolve(path.dirname(file), spec);
if (await exists(abs)) continue; // 綴りどおりに存在する
const map = await entriesOf(path.dirname(abs));
const hit = map.get(path.basename(abs).toLowerCase()); // 小文字で一致するものがあるか
findings.push({ file, spec, actual: hit ? hit[0] : null });
}
}
}
for (const f of findings) {
console.log(`${f.actual ? "CASE" : "MISS"} ${f.file}\n 参照: ${f.spec}` +
(f.actual ? `\n 実体: ${f.actual}` : ""));
}
const caseCount = findings.filter((f) => f.actual).length;
console.log(`\nソース ${scanned} 件 / アセット参照 ${refs} 件 / ` +
`大文字小文字の不一致 ${caseCount} 件 / 実体なし ${findings.length - caseCount} 件`);
process.exit(caseCount ? 1 : 0);exists() の判定を先に置いているのが要点です。綴りどおりに見つかるものは、そこで終わります。残ったものだけディレクトリ一覧を読むので、正常なプロジェクトではほとんど何も読みません。
小さなフィクスチャで動かした結果です。Sunset.png hero-Banner.webp icons/Play.svg を置き、コードからは全て小文字で参照しました。
CASE src/screens/Home.tsx
参照: ../assets/sunset.png
実体: Sunset.png
CASE src/screens/Home.tsx
参照: ../assets/hero-banner.webp
実体: hero-Banner.webp
CASE src/screens/Home.tsx
参照: ../assets/icons/play.svg
実体: Play.svg
MISS src/screens/Home.tsx
参照: ../assets/nothing-here.png
ソース 1 件 / アセット参照 5 件 / 大文字小文字の不一致 3 件 / 実体なし 1 件
CASE と MISS を分けているのは、直し方が違うからです。CASE は参照側の綴りを実体に合わせるか、git mv -f で実体側を揃えます。MISS はファイルの置き忘れなので、探す場所がそもそも別です。
実プロジェクトの規模で、どのくらい待たされるか
検出は速くないと習慣になりません。壁紙アプリに近い規模を作って計測しました。アセット420枚、ソース120ファイル、アセット参照480件。そのうち9件だけ、意図的に小文字へ崩してあります。
ソース 120 件 / アセット参照 480 件 / 大文字小文字の不一致 9 件 / 実体なし 0 件
real 0m0.144s
仕込んだ9件をそのまま9件として検出しました。所要は0.144秒です。
| 項目 | 値 |
|---|---|
| アセット枚数 | 420 |
| ソースファイル | 120 |
| アセット参照 | 480 件 |
| 仕込んだ不一致 | 9 件 |
| 検出 | 9 件(見逃し 0) |
| 実行時間 | 0.144 秒(Node.js v22.23.2) |
0.1秒台なら、コミットのたびに走らせても邪魔になりません。不一致があるときだけ終了コード1を返すので、package.json に置いて CI からも同じコマンドを呼べます。
{
"scripts": {
"check:assets": "node asset-case-check.mjs src app"
}
}私はこれをビルド前の一行として入れています。クラウドのビルドが始まってから10分待って失敗を知るより、手元で0.1秒のほうが精神衛生上ずっと良いというだけの理由です。個人開発だと、待ち時間はそのまま作業の中断になりますので。
起きる前に決めておくと楽になること
3つだけ、先に決めておくと後が楽です。
1. アセットのファイル名は小文字とハイフンだけにする。 Hero Banner@2x.PNG のような名前を許すと、大文字小文字に加えて空白とアットマークの扱いも増えます。最初に決めてしまえば、迷う場面自体がなくなります。
2. 名前を直すときは必ず git mv -f を通す。 Finder やエディタのリネームは、core.ignorecase の下では git に届きません。ここだけはコマンドで通す、と決めておくのが確実です。
3. 検出を人ではなく仕組みに持たせる。 一括置換のように、成功したように見えて別の場所が壊れる作業は他にもあります。一括置換は全ファイル成功しました。壊れたのは、消さなかった行のほうです でも同じ構図を扱いました。検証は毎回同じ手順で機械に任せるほうが、結果として速く終わります。
最初の公開前に決めておくべき名前は、アセット以外にもあります。変えられる名前と、変えられない名前 — Rork で最初の1本を公開する前の30分 も、着手前に一度目を通しておくと手戻りが減ります。
次にビルドが「見つからない」で止まったら、まず git ls-files でそのファイルがリポジトリに入っているかを確かめてみてください。作業ツリーにあることと、リポジトリにあることは別だ — 今回の私は、そこを一日かけて学びました。
同じ場所で一日を落とす方が一人でも減れば、この記録を残した意味があります。