6月のはじめ、ストア公開直前の差分確認で手が止まりました。前の週に手で入れたはずの修正——ATT の同意ダイアログが閉じる前に広告 SDK の初期化を始めないようにするガード——が、跡形もなく消えていたのです。
直接のきっかけは私自身の操作でした。設定画面の文言を直したくて Rork に小さな修正を頼み、出力されたプロジェクトをそのままエクスポートした。それだけです。Rork は依頼どおり文言を直し、同時に、依頼していない場所を生成時点の「正しい姿」に揃え直していました。手動修正は、AI から見れば揃え直すべき逸脱のひとつだったわけです。
再生成は Rork の中核的な強みで、これを手放す選択はありません。一方で、エクスポート後のコードに手を入れる場面は、運用が長くなるほど確実に増えます。広告(AdMob)の初期化順序、課金イベントの取りこぼし対策、クラッシュへの応急対処。つまり「再生成を続けながら、手動修正も守る」を構造として成立させる必要があります。
私はこの出来事を境に、プロジェクトの中に「AI が書く土地」と「人が守る土地」を明示的に分ける設計へ切り替えました。以下、その境界の引き方を、実測と実装、日々の取り込み手順まで含めて書いていきます。
再生成が手動修正を消す仕組みを、数字で確かめる
Rork のチャットへの依頼は、ファイル単位の編集指示ではありません。「アプリがこうあるべき」という状態への指示で、その実現に関わるファイルがまとめて再構成されます。どのファイルが書き換わるかは、依頼文の小ささからは予測できません。
これを体感ではなく数字にしておきたくて、運用中のプロジェクトの複製に対して影響範囲が小さいはずの依頼を3種類投げ、書き換わったファイル数を git diff --stat で数えました。
依頼内容 書き換わったファイル数
設定画面のボタン文言を1箇所変更 7
ホーム画面のカードの角丸を 8 → 12 に変更 4
設定画面にトグルを1つ追加 11
文言1箇所の変更で 7 ファイルです。依頼に直接関係していたのは 1 ファイルだけで、残る 6 ファイル、率にしておよそ 85% は依頼と無関係の書き換えでした。内訳を見ると、目的のファイルに加えて、共有フックやユーティリティが体裁の揃え直しで書き換わっていました。diff の大半は無害な整形です。問題は、その無害な差分の山の中に、手動修正の打ち消しが 1〜2 行だけ混ざることです。数百行の diff から毎回それを人の目で拾い続けるのは、現実的ではありません。
ここから引ける結論は「再生成を避ける」ではありません。再生成されても壊れない場所に手動修正を置く。発想をそちらへ切り替えます。
境界の原則 — 上書きされてよい土地と、守る土地を分ける
私が運用しているルールは3つだけです。
第一に、Rork が生成するツリーは全て「いつ上書きされてもよい」とみなします。app/ も components/ も hooks/ も、次の再生成で消える前提で扱い、ここには手動修正を直接書きません。
第二に、手で書くコードは guarded/ という独立ディレクトリに隔離します。名前は何でも構いません。重要なのは「Rork が生成しないパスである」ことと、「取り込み時に機械的に保護できる」ことの2点です。
第三に、生成コードから guarded/ への接続は、1行の import と1回の関数呼び出しまでに絞ります。接続点が細いほど、再生成で消えたときの復旧が「1行戻す」だけで済みます。
ディレクトリはこうなります。
project/
├── app/ # Rork の土地(いつ上書きされてもよい)
├── components/ # 同上
├── hooks/ # 同上
├── guarded/ # 人の土地(再生成の影響を受けない)
│ ├── ads/
│ │ └── initializeAds.ts
│ ├── analytics/
│ │ └── track.ts
│ └── billing/
│ └── listeners.ts
└── patches/ # guarded に移せない修正のパッチ置き場(後述)
アダプタ層の実装 — 画面が呼ぶのは薄い入口だけにする
冒頭で消えた ATT ガードを例に、手動ロジックを guarded/ へ引き剥がします。消える前の状態では、app/_layout.tsx の中に初期化処理が直接書かれていました。再生成のたびにこのファイルは書き直されるので、ここに置いた修正は必ずいつか消えます。
本体を guarded/ads/initializeAds.ts に移します。
// guarded/ads/initializeAds.ts
// ATT → UMP 同意 → SDK 初期化の順序を保証する。二重呼び出しは無視。
import { Platform } from "react-native";
import mobileAds from "react-native-google-mobile-ads";
import {
getTrackingPermissionsAsync,
requestTrackingPermissionsAsync,
} from "expo-tracking-transparency";
let started = false;
export async function initializeAdsOnce(): Promise<void> {
if (started) return;
started = true;
try {
if (Platform.OS === "ios") {
const current = await getTrackingPermissionsAsync();
if (current.status === "undetermined") {
// ダイアログが閉じるまで SDK 初期化を始めない(消えていたのはこの待機)
await requestTrackingPermissionsAsync();
}
}
await mobileAds().initialize();
} catch (e) {
// 広告の初期化失敗でアプリを止めない。ログだけ残して次回の再試行を許す
console.warn("[ads] initialize failed", e);
started = false;
}
}
画面側に残すのは入口の1行だけです。
// app/_layout.tsx(Rork の土地に置く接続点は1行だけ)
import { initializeAdsOnce } from "../guarded/ads/initializeAds";
useEffect(() => {
void initializeAdsOnce();
}, []);
再生成で _layout.tsx が書き直されても、失われるのはこの1行だけです。復旧は import と呼び出しを戻すだけで、ATT の順序やエラー処理という本体は無傷のまま残ります。
同じ型がそのまま使えるのは、アナリティクスの送信ファサード、課金イベントのリスナー登録、Remote Config の既定値定義あたりです。共通するのは「壊れても画面では気づきにくく、気づいたときには被害が積み上がっている」処理だという点です。
取り込み手順 — 再生成はブランチで受け、guarded を機械的に守る
境界を引いたら、エクスポートの取り込みを毎回同じ手順に固定します。やることは次の5つです。
- エクスポートを受け皿ブランチへ丸ごと反映する
- 被害半径を diff の統計で先に確認する
- guarded/ に差分がないことを機械的に検証する
- main へ合流させる
- 接続点の import が生きていることを確認する
私は main をストアに出す正本、rork-sync を Rork エクスポートの受け皿にしています。
# 1. エクスポートを受け皿ブランチへ丸ごと反映
git checkout rork-sync
rsync -a --delete --exclude .git --exclude guarded --exclude patches \
~/Downloads/rork-export/ ./
git add -A && git commit -m "sync: rork export $(date +%Y%m%d)"
# 2. 被害半径を先に数字で見る
git diff main...rork-sync --stat | tail -5
# 3. guarded/ に差分が出ていないことを機械的に確認(出たら設計違反)
git diff main...rork-sync --stat -- guarded/ | wc -l # 0 であること
# 4. main へ合流
git checkout main
git merge rork-sync
# 5. 接続点の生存確認
grep -rn "initializeAdsOnce" app/ | wc -l # 1 以上であること
ポイントは 2 と 3 を merge より前にやることです。統計を先に見ておくと、「文言修正のはずなのに 30 ファイル動いている」といった異常に、コードを読み始める前に気づけます。
一度だけ、手順 3 がゼロになりませんでした。原因を辿ると、guarded の中身を Rork のチャットに貼り付けて相談したことがあり、その内容が生成対象に取り込まれていました。境界は AI 側が覚えてくれるものではなく、運用する人間の手順で守るものだと実感した一件です。
guarded に移せない修正は、パッチとして資産化する
すべての手動修正が guarded/ に移せるわけではありません。生成されたファイルの内部に 1〜2 行だけ手を入れたい場合があります。一覧画面の FlatList に removeClippedSubviews を足す、生成されたフェッチ処理にタイムアウトを足す、といった類です。
この種の修正は、1修正 = 1パッチの粒度で patches/ に保存しています。
# 修正を1コミットにまとめてから、説明的な名前でパッチ化
git format-patch -1 HEAD -o patches/
mv patches/0001-*.patch patches/flatlist-remove-clipped-subviews.patch
# 再生成の取り込み後に、全パッチを機械的に再適用
git am --3way patches/*.patch
# 当たらないパッチだけが止まるので、そこだけ人が判断する
実測では、patches/ にある 9 個のパッチのうち、再生成 5 回をまたいで自動適用が通り続けたのは 4 個でした。残りは生成側の構造変化で当たらなくなりました。ただ、これは失敗ではないと考えています。「当たらなくなったパッチ」は、その修正を guarded/ へ昇格させるか、Rork への恒久指示に変換するかを検討する明確なシグナルとして機能するからです。
プロンプトで被害半径を狭める — ただし境界の代わりにはしない
依頼文の書き方でも、書き換え範囲はある程度狭まります。先ほどの「ボタン文言1箇所」の依頼に制約を書き添えて比較しました。
制約なしの依頼では 7 ファイルが書き換わりました。「変更は設定画面の表示文言だけにしてください。ナビゲーション、フック、ユーティリティ、他の画面には触れないでください」と添えた依頼では、2 ファイルに収まりました。プロジェクトの説明欄に「guarded/ 以下は変更しない」と恒久指示を書いておくのも効きます。
効果は明確にあります。それでも、プロンプトを境界の代わりにはしません。指示の遵守は確率的で、生成のたびに結果が揺れるからです。構造(guarded とパッチ)が防波堤、プロンプトは波を小さくする工夫。この主従を逆にしないことが、長期運用では効いてきます。
運用して固まった、任せ方の基準
半年ほどこの体制で本番運用を回して、Rork に任せる領域と人が守る領域の線引きは次の形に落ち着きました。
画面のレイアウト、遷移、文言、色やアイコンといった「壊れたら起動した瞬間に気づくもの」は、すべて Rork に任せます。再生成の恩恵が最も大きい領域でもあります。
広告と課金の初期化順序、データの永続化、アナリティクスの送信は guarded/ に置きます。ものさしはシンプルで、「壊れたことに気づくまでの時間が長いものほど、人の土地に置く」です。課金イベントの取りこぼしや初期化順序の崩れは、画面上では何も起きず、数字に表れるまで数日かかります。個人開発では、その数日が売上と信頼の毀損として直接返ってきます。
時間の面では、取り込みのたびに 30 分ほどかけていた diff の目視レビューが、境界導入後は 5〜10 分に収まるようになりました。レビューの中身が「guarded に差分ゼロの確認」と「止まったパッチの判断」に置き換わったためです。
最初の一歩としては、guarded/ を作り、広告か課金の初期化をひとつ移すところから始めることをお勧めします。接続点が1行になったときの安心感は、次に再生成ボタンを押す指の軽さに、そのまま表れます。再生成と長く付き合っていく方の足場になれば幸いです。