◉RORK LABEN
●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SONNET 4.5 — Claude API での提供終了は 11/30、残り31日。API 連携を足したアプリは移行先の確認を●SDK 58 ISSUE — Android ウィジェットが定期更新されない、Stripe が静的リンクになるなど初期報告が出ています●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SONNET 4.5 — Claude API での提供終了は 11/30、残り31日。API 連携を足したアプリは移行先の確認を●SDK 58 ISSUE — Android ウィジェットが定期更新されない、Stripe が静的リンクになるなど初期報告が出ています●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…
記事一覧/アプリ開発
◇ アプリ開発/2026-07-31上級

生成した壁紙カタログのハッシュが毎回変わる — 非決定の出どころを5つ切り分ける

同じ画像フォルダから同じ catalog.json が出てこない。opendir の列挙順、JSON.stringify のキー並べ替え、照合順序、Unicode 正規化、非同期の完了順。1,225ファイルで一つずつ切り分けて、出力を1種類に固定するまでの実測記録です。

ビルド再現性Node.jsカタログ生成CI7Unicode

✦ プレミアム記事

git diff が 1,984 行。

画像は1枚も追加していません。壁紙アプリのカタログを作り直す npm run catalog を、直前のコミットと同じ状態でもう一度走らせただけです。11,041 行の catalog.json のうち、およそ 18% の行が動いていました。

このスクリプトは、アセットフォルダを舐めて catalog.json を吐くだけの短いものです。壁紙系を個人開発で複数本まわしていると、画像の追加は日常作業になります。だからこそ、生成物が正しいことは人の目ではなく機械で担保したいところでした。CI では「生成物が最新か」を git diff --exit-code で見張るつもりでした。ところが毎回差分が出るので、ゲートは常に赤。結局チェックを外し、生成物のレビューは目視に戻っていました。

差分が出る理由をタイムスタンプだと決めつけて、その行を消しました。直りませんでした。そこから、非決定の出どころを一つずつ切り離して測っていくことになります。

計測環境は Node v22.22.3 / Ubuntu 22.04(Linux 6.8)。検証用に 1,225 ファイル(連番 ID 360件・カテゴリ接頭辞つき 865件・うち日本語名 25件)のフォルダを ext4 と tmpfs の3か所に用意しました。以下の数値はすべてこの環境で実際に走らせて取ったものです。

差分の中身を先に見る

やり直しの前に、何が動いているのかを見ます。

$ node scripts/gen-catalog.mjs assets/ public/catalog.json
$ cp public/catalog.json /tmp/a1.json
$ node scripts/gen-catalog.mjs assets/ public/catalog.json
$ diff /tmp/a1.json public/catalog.json | wc -l
1984
$ wc -l /tmp/a1.json
11041 /tmp/a1.json

差分は 2 種類に分かれていました。generatedAt の 1 行と、items の中身がブロックごと入れ替わっている大量の行です。

後者が厄介でした。値は変わっていません。同じオブジェクトが別の位置に出ているだけです。JSON の意味としては等価で、アプリの動作にも影響しません。だからこそ放置されていたわけですが、差分ゲートを効かせたい以上、バイト列まで一致させる必要があります。

生成スクリプトはこういう形をしていました。

// scripts/gen-catalog.mjs(当時)
import fsp from "node:fs/promises";
import path from "node:path";
 
const SRC = process.argv[2];
const OUT = process.argv[3];
 
const catalog = {};
const tagSet = new Set();
const names = [];
 
// メモリを気にして opendir でストリーミング
const dir = await fsp.opendir(SRC);
for await (const ent of dir) {
  if (ent.isFile() && ent.name.endsWith(".jpg")) names.push(ent.name);
}
 
await Promise.all(names.map(async (name) => {
  const st = await fsp.stat(path.join(SRC, name));
  const id = name.replace(/\.jpg$/, "");
  const cat = id.includes("_") ? id.split("_")[0] : "legacy";
  tagSet.add(cat);
  catalog[id] = {
    id, file: name, category: cat,
    bytes: st.size, width: 1290, height: 2796,
    aspect: 1290 / 2796,
  };
}));
 
const doc = {
  generatedAt: new Date().toISOString(),
  count: Object.keys(catalog).length,
  tags: [...tagSet].sort((a, b) => a.localeCompare(b)),
  items: catalog,
};
await fsp.writeFile(OUT, JSON.stringify(doc, null, 2));

30行ほどです。この中に、非決定の原因が5つ入っていました。

タイムスタンプを消しても直らない

最初に generatedAt を落としました。これで終わりだと思っていました。

同じディレクトリに対して 20 回連続で走らせ、出力の SHA-256 を集合に入れて数えます。

スクリプトの状態20回の出力ハッシュ
そのまま20 種類
generatedAt を削除20 種類

1 種類も減りませんでした。タイムスタンプは、目に見えていただけの原因です。

残っていたのは Promise.all の書き方でした。names.map(async ...) の中で配列に push していると、追加される順番は「その非同期処理が終わった順」になります。stat の完了順は毎回わずかにばらつくので、そこから先の並びがすべて変わります。

200件の ID で 20 回ずつ試した結果です。

集め方20回の出力
完了時に配列へ push20 種類
Promise.all の戻り値を使う1 種類

Promise.all は戻り値の配列を入力順で並べ直してくれます。値を返す形に書き換えるだけで、この原因は消えます。

// 完了順に依存する書き方
const items = [];
await Promise.all(names.map(async (n) => { const st = await fsp.stat(n); items.push(mk(n, st)); }));
 
// 入力順が保たれる書き方
const items = await Promise.all(names.map(async (n) => { const st = await fsp.stat(n); return mk(n, st); }));

ここまでで、同じディレクトリを繰り返し処理する限りは出力が 1 種類に落ち着きました。ところが CI では、まだ差分が出ていました。

✦

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
✦readdir は整列済み・opendir は未整列という差の実測。同じ1,225ファイルでもファイルシステムが変われば opendir の順序は同位置一致 2/1225 まで崩れます
✦非決定を1つずつ外していく切り分け表。タイムスタンプを消しても20回中20種類のままだった段階と、その先に何が残っていたか
✦決定的な生成スクリプトの全文と、CI で git diff --exit-code を差分ゲートとして機能させるための正規化ガード
Stripe による安全な決済 · いつでもキャンセル可能
✦

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

◇ アプリ開発2026-07-29
壁紙カタログに同じ絵が二度並んでいた — 知覚ハッシュの閾値を324枚で実測して決める
画像カタログの重複検出を、SHA-256・dHash・色相署名・ブロック署名の4通りで実測しました。dHash 単独では適合率0.9%。平坦な画像でハッシュが潰れる現象と、閾値ではなく正解ラベルの方が間違っていた顛末を、再現できるコードと数値で共有します。
◇ アプリ開発2026-10-29
アップロード前に一度だけ取りにいかれた画像が、エッジで 404 のまま残りました。保持の規則をステータス別に分け、アップロードと消去を一組にしました
新しい壁紙が、一部の地域でだけ 404 のまま表示されない。原因は、アップロード前に一度だけ取りにいかれた 404 を、CDN が画像と同じ長さで保持していたことでした。ステータス別の保持、診断の二本立て、アップロードと消去の一組を、手元で動かして確かめた記録です。
◇ アプリ開発2026-10-28
「Project is incompatible with this version of Expo Go」と出たとき、プロジェクトを疑う前に見る三か所
Expo Go で「このプロジェクトには新しい Expo Go が必要です」と表示されたとき、node_modules を消す前に確かめたい三つの照合先。プロジェクトの SDK、アプリの対応 SDK、そして Android で実際に起きた Expo Go 56.0.1 の取り違えまで、順に絞り込む手順を書き残します。