6月の終わりに、Rork Max で作った壁紙ビューアの一覧画面を1行だけ直しました。サムネイルの角丸を 12 から 16 に変えただけの、記録するほどでもない修正です。
翌週、その画面にフィルタ機能を足そうとして、同じプロンプトに一文を追記して投げ直しました。返ってきた画面は問題なく動きました。角丸は 12 に戻っていました。
失ったのは 4pt です。ただ、そこで手が止まりました。この画面は先週まで「捨てて作り直せる画面」でした。私が1行触った瞬間に、それは「作り直すと何かが消える画面」に変わっていました。しかもその変化は、どこにも記録されていませんでした。
個人開発でアプリを配布まで一人で回していると、こういう静かな劣化がいちばん怖いところです。ここでは、生成コードに乗っている「作り直せる」という資産を、意図して残し、意図して手放す設計を書きます。
生成コードには、寿命付きの選択肢が乗っています
手で書いたコードは、書いた瞬間から保守対象です。そこに選択肢はありません。直すか、消すかだけです。
Rork Max のような生成物は違います。生成直後のファイルには「捨てて作り直す」という選択肢が付いてきます。仕様が変わったとき、差分を追いかけて直す代わりに、プロンプトを書き換えて丸ごと出し直せます。これは単なる便利さではなく、自分の時間を買い戻せる権利です。
この権利には寿命があります。人が手を入れるたびに、静かに失効していきます。角丸を 12 から 16 に変えた瞬間、その画面の再生成は「4pt を失う操作」になりました。次に誰かが VoiceOver のラベルを足せば、再生成は「アクセシビリティ対応を失う操作」になります。3ヶ月も経てば、その画面をまだ作り直してよいのか、誰にも分からなくなります。3ヶ月前に手を入れた本人にも分かりません。
私は壁紙系のアプリを6本並行で運用しています。生成物を使い始めて最初に痛かったのは、クレジットの消費でも生成品質でもなく、この「どのファイルがまだ生きた選択肢を持っているか分からない」状態でした。台帳がないので、迷ったら手で直します。手で直すのでさらに失効します。誰も止めないまま、$200/月 のオプションが少しずつ空手形になっていきます。
再生成可能性は、三つの状態で見ます
ファイルを「生成物かどうか」の二値で見ている限り、この劣化は見えません。三つに割ると見えるようになります。
いちばん危険なのは drifted です。regenerable のつもりで再生成すれば黙って何かが消えますし、sealed のつもりで手を入れ続ければ、いつまでも生成の恩恵を受けられません。どちらの前提にも乗れない、判断できない状態です。
運用の目標は単純です。drifted をゼロに近づけます。手を入れたくなったら、二つに一つを選びます。
直した内容をプロンプトか契約へ戻し、regenerable のまま保つ
その旨を台帳に書いて sealed へ落とし、以後は手で守る
角丸の 4pt は、前者で戻すべきものでした。プロンプトに「角丸は 16」と一行足せば済んだ話です。私はそれをせず、手元だけを直しました。判断を先送りにしたのではなく、判断していることに気づいていませんでした。
どこを再生成ゾーンとして残すか
全部を regenerable に保とうとすると、契約が肥大して逆に再生成が不安定になります。全部を sealed にすれば、Rork Max は初回のスキャフォールド生成機になります。線を引く必要があります。
迷ったときは、三つの問いで決めています。
壊れたとき、ユーザーのお金かデータが消えるか。 消えるなら sealed です。再生成の成功率が9割でも、残る1割が課金レシートなら賭けになりません。
仕様が四半期に一度以上変わるか。 変わるなら regenerable の価値が高いところです。年に一度しか触らない画面を無理に契約化しても、契約の保守費のほうが高くつきます。
正しさを自動テストで判定できるか。 できないなら sealed へ寄せます。再生成の安全網はテストしかありません。目視で確かめる約束は、6本のアプリを回していると3週間で守れなくなります。
境界には、生成側が知ってよい型だけを置きます
線を引いただけでは守れません。regenerable ゾーンが sealed 側の型を直接触った瞬間、境界は消えます。生成側が知ってよいものを、こちらから絞り込みます。
契約は手で保守します。ここは sealed です。
// rork-zone: sealed
// 生成側に見せる唯一の型。ここは手で保守します。
import Foundation
public struct WallpaperCardState : Equatable , Sendable {
public let id: String
public let title: String
public let subtitle: String
public let isDownloaded: Bool
/// 表示用に整形済み。Decimal や Locale を生成側へ渡しません。
public let priceLabel: String ?
public init ( id : String , title : String , subtitle : String ,
isDownloaded : Bool , priceLabel : String ? ) {
self .id = id
self .title = title
self .subtitle = subtitle
self .isDownloaded = isDownloaded
self .priceLabel = priceLabel
}
}
public enum WallpaperCardIntent : Sendable {
case tapped (id: String )
case saveRequested (id: String )
}
生成ゾーンの View は、この2型しか知りません。丸ごと捨てて出し直せます。
// rork-zone: regenerable
// rork-prompt: prompts/wallpaper_card_view.md
// rork-contract: WallpaperCardState v3
import SwiftUI
struct WallpaperCardView : View {
let state: WallpaperCardState
let send: (WallpaperCardIntent) -> Void
var body: some View {
VStack ( alignment : .leading, spacing : 8 ) {
Text (state.title)
. font (.headline)
Text (state.subtitle)
. font (.subheadline)
. foregroundStyle (.secondary)
if let priceLabel = state.priceLabel {
Text (priceLabel)
. font (.caption)
. monospacedDigit ()
}
}
. padding ( 12 )
. background (.regularMaterial, in : RoundedRectangle ( cornerRadius : 16 ))
. contentShape ( Rectangle ())
. onTapGesture { send (. tapped ( id : state.id)) }
. accessibilityElement ( children : .combine)
. accessibilityLabel (state.title)
. accessibilityAddTraits (.isButton)
}
}
priceLabel を Decimal ではなく整形済みの String にしている点が、この契約の要です。Decimal を渡すと、通貨記号の位置とロケールの判断が生成側へ漏れます。判断が漏れた場所は、再生成のたびに揺れます。
手元で確かめた範囲では、差がはっきり出ました。同じ画面を5回ずつ再生成して比べたところ、Decimal を渡す契約では5回のうち3回が日本語ロケールでの通貨記号の位置を誤り、整形済み String を渡す契約では0回でした。契約を薄くするほど、生成側に間違える余地が残りません。
なお send をクロージャで受けている理由も同じところにあります。ここに ViewModel の型を書くと、生成側がその型の内部を推測し始めます。推測が入るところが、次のドリフトの起点です。
ドリフトは、目でなく仕掛けで見つけます
台帳を人間の記憶で運用すると、2週間で壊れます。ファイル先頭のマーカーと、生成時のハッシュで機械化します。
#!/usr/bin/env python3
"""zone_inventory.py — 生成ゾーンの申告と実体を突き合わせます。"""
import hashlib
import json
import pathlib
import re
import sys
LEDGER = pathlib.Path( "tools/zone_ledger.json" )
ZONE_RE = re.compile( r " ^ // \s * rork-zone: \s * ( regenerable | sealed )\s * $ " , re. MULTILINE )
def digest (path: pathlib.Path) -> str :
return hashlib.sha256(path.read_bytes()).hexdigest()[: 16 ]
def main (root: str = "Sources" ) -> int :
ledger = json.loads( LEDGER .read_text()) if LEDGER .exists() else {}
drifted, untagged = [], []
for path in sorted (pathlib.Path(root).rglob( "*.swift" )):
text = path.read_text( encoding = "utf-8" )
m = ZONE_RE .search(text)
if not m:
untagged.append( str (path))
continue
if m.group( 1 ) != "regenerable" :
continue
key = str (path)
if key in ledger and ledger[key] != digest(path):
drifted.append(key)
for p in untagged:
print ( f "untagged : { p } " )
for p in drifted:
print ( f "DRIFTED : { p } " )
print ( f " \n untagged= { len (untagged) } drifted= { len (drifted) } " )
return 1 if drifted else 0
if __name__ == "__main__" :
sys.exit(main( * sys.argv[ 1 :]))
生成直後に台帳へハッシュを記録し、以後の差分を drifted として拾います。regenerable と申告しているのにハッシュが動いていれば、宣言と実体がずれた合図です。
境界そのものも検査します。40行ほどで足ります。
#!/usr/bin/env python3
"""zone_boundary.py — regenerable ゾーンの禁止依存をCIで弾きます。"""
import pathlib
import re
import sys
ZONE_RE = re.compile( r " ^ // \s * rork-zone: \s * regenerable \s * $ " , re. MULTILINE )
FORBIDDEN_IMPORTS = ( "Persistence" , "Billing" , "DomainModel" , "AdsCore" )
FORBIDDEN_SYMBOLS = ( "ModelContext" , "Transaction.currentEntitlements" , "URLSession(" )
def violations (path: pathlib.Path) -> list[ str ]:
text = path.read_text( encoding = "utf-8" )
if not ZONE_RE .search(text):
return []
found = []
for module in FORBIDDEN_IMPORTS :
if re.search( rf "^import\s+ { module } \s*$" , text, re. MULTILINE ):
found.append( f "import { module } " )
for symbol in FORBIDDEN_SYMBOLS :
if symbol in text:
found.append(symbol)
return found
def main (root: str = "Sources" ) -> int :
failed = 0
for path in sorted (pathlib.Path(root).rglob( "*.swift" )):
for v in violations(path):
print ( f " { path } : regenerable zone must not reference ' { v } '" )
failed = 1
return failed
if __name__ == "__main__" :
sys.exit(main( * sys.argv[ 1 :]))
ここに落とし穴が一つあります。Rork Max で再生成すると、ファイル先頭のマーカー行はほぼ確実に消えます。 生成側はそのコメントを知りませんから、当然の挙動です。マーカーが消えれば、そのファイルは検査対象から外れ、台帳は静かに空になります。
私は2回これをやりました。1回目は気づくまで3週間かかりました。対処は単純で、生成直後にマーカーを再付与する後処理を1本挟むだけです。
#!/usr/bin/env bash
# restamp_zone.sh <file> <prompt-path> <contract>
set -euo pipefail
file = " $1 " ; prompt = " $2 " ; contract = " $3 "
grep -q "rork-zone:" " $file " && exit 0
tmp = "$( mktemp )"
{
printf '// rork-zone: regenerable\n'
printf '// rork-prompt: %s\n' " $prompt "
printf '// rork-contract: %s\n' " $contract "
cat " $file "
} > " $tmp "
mv " $tmp " " $file "
echo "restamped: $file "
生成の直後、コミットの直前。挟む場所はこの二択で、後者だと忘れます。
四半期に一度、実際に捨てて確かめます
台帳の申告は、放っておくと願望に寄ります。年4回、実際に捨てて確かめる時間を取っています。
regenerable と申告している画面を1つ選びます
作業ブランチを切り、そのファイルを削除します
台帳に記録したプロンプトと契約だけで再生成します
差分・消費クレジット・通るまでのやり直し回数を記録します
3回以内に通らなければ、その画面は事実上 sealed です。台帳の状態を書き換えます
測る指標は2つだけにしています。再生成成功率と、通るまでの平均やり直し回数です。増やすと続きません。
6本のアプリで最初にこのドリルをやったとき、regenerable と台帳に書いていた11画面のうち、3回以内の再生成で通ったのは6画面でした。55%です。残り5画面は、本人が気づかないまま drifted になっていました。数字としては芳しくありませんが、11画面の状態が分かっている今のほうが、11画面すべてを「たぶん大丈夫」と思っていた頃よりずっと安全です。
ドリルの注意点を2つ。本番ブランチではやりません。 再生成は成功するまで何度でも試す前提の作業ですから、履歴が汚れることを気にする場所でやると手加減が入ります。もう1つ、App Store 提出直前の週は避けます。 審査待ちの緊張の中で「作り直せるはずの画面が作り直せない」と分かると、判断が雑になります。
$200/月 は、保守費ではなく再生成オプション費です
Rork Max は $200/月 です。無料枠は週5プロンプト前後、有料プランは $25/月 から始められます(料金の最新は Rork の公式サイト をご確認ください)。個人開発の月次コストとして、$200 は軽い額ではありません。
この費用を「今月これで何を作ったか」で評価すると、作らなかった月に解約したくなります。実際、私も一度そう考えかけました。
見方を変えると評価軸が変わります。再生成ゾーンを保っている限り、この支払いは制作費ではなく、いつでも作り直せる権利への支払い です。オプションの値段です。オプションの価値は、使った回数ではなく、行使できる確率と行使したときの節約で決まります。
私の見積もりでは、1画面を手で作り直すと 4〜6時間かかります。再生成が通れば、プロンプトの調整とレビューで1時間前後に収まります。成功率が55%でも、四半期に3画面を作り直す場面があるなら、期待値としては十分に見合う計算になります。
逆に言えば、判断の合図もここにあります。成功率が20%を切ったら、そのオプションは空手形です。 台帳の regenerable がほとんど嘘になっている状態ですから、$25/月 のプランに落として初回スキャフォールドだけを使うか、sealed 前提の運用へ切り替えるほうが正直です。私はこの線を20%に置いていますが、手作業の単価が高い人ほど、この閾値は下げてよいと考えています。
大事なのは、成功率という数字を持つことです。数字がないと、$200/月 の判断は毎月の気分になります。
次の一手
台帳を最初から完璧に作ろうとすると、たいてい着手できません。
今日できることを一つだけ挙げます。手元のアプリから、直近で再生成したい画面を1つ選び、そのファイルの先頭に3行のマーカーを書いてください。台帳もCIも後で足せます。「このファイルはまだ作り直せる」と自分に宣言するところから始まります。
私自身、角丸の 4pt を失うまでこの資産に気づいていませんでした。同じ発見をもう少し安い授業料で通り過ぎていただければ嬉しく思います。お読みいただきありがとうございました。