Claude Codeにプロダクションコードを全チェックさせたら、金銭バグ4件を含む15件の不具合を自動修正してくれた話
はじめに
「skill使ってバグとか不具合がないか全チェックして」
この一言からすべてが始まった。対象はサロン向け前金予約管理アプリ。iOS(Swift)側が68ファイル、バックエンドがNode.js+Hono+Stripeという構成だ。
結果として、Stripeの二重入金・500件超の同期ロスト・XSSリスク・通知再登録漏れなど、放置すれば本番で深刻な問題になっていたバグが複数発見され、そのまま自動修正まで完了した。レビューから修正・ビルド確認・テスト実行まで、人間がコードを1行も書かずに終わった。
本記事はその全過程の実録だ。何が見つかり、なぜ見つかり、どう直されたかを詳しく記録する。
やったこと
レビュー戦略: 並列エージェントで68ファイルを分割調査
Claude Codeはまず「このディレクトリはGitリポジトリではないため、差分ベースのレビューは使えない」と判断し、コードベース全体スキャンに切り替えた。そのうえで次の戦略を取った。
- バックエンド(Node.js/Hono/Stripe): メインエージェントが自分で精読
-
iOS(Swift 68ファイル): 3つの専門エージェントを並列起動
- エージェントA: Engine / Models / Repositories
- エージェントB: Services層(Stripe/Sync/通知)
- エージェントC: ViewModels / Views
この分割が後で効いてくる。同一バグをエージェントBとエージェントCが独立に検出したことで、そのバグの信頼度が自動的に上がった。
発見されたバグ一覧
🔴 最重要(金銭・データ整合性に直結)
① Stripe決済の二重反映で入金額が水増しされる
// StripePollingService.swift — 修正前
func apply(paid: StripePaid, to reservation: Reservation) {
// depositStatusの再チェックなし!
let payment = Payment(...)
paymentRepository.insert(payment) // 無条件にinsert
}
pollAll()はアプリ起動・フォアグラウンド復帰・ダッシュボード更新ボタン・支払い一覧の「状態を一括更新」から、互いに排他制御なしで呼ばれていた。起動時のポーリングが終わる前にユーザーが手動更新を押すと、同一決済のPayment行が二重挿入される。
// 修正後
private var isPolling = false
func pollAll() async {
guard !isPolling else { return } // 排他制御
isPolling = true
defer { isPolling = false }
// ...
}
func apply(paid: StripePaid, to reservation: Reservation) {
guard reservation.depositStatus == .linkIssued else { return } // ガード追加
let payment = Payment(...)
paymentRepository.insert(payment)
}
これはエージェントBとエージェントCが独立に同一バグを検出した。2系統から報告されたので信頼度は高い。
② 500件超の差分同期でレコードが恒久ロスト
バックエンドがLIMIT 500で最古500件しか返さないのに、iOS側が次回同期カーソルを「サーバーの現在時刻」で保存していた。
// backend/src/index.js — 修正前
const rows = await db.all(
`SELECT * FROM reservations WHERE updated_at > ? ORDER BY updated_at LIMIT 500`,
[since]
);
// hasMoreを返していない
res.json({ reservations: rows });
501件目以降はupdated_atがカーソルより過去のため二度と取得されない。データが静かに消える系のバグで、気づきにくい。
// 修正後
const hasMore = rows.length === 500;
res.json({ reservations: rows, hasMore });
iOS側もカーソルを「今回受信した最後のレコードのupdated_at」に変更し、hasMoreでページングするよう修正。
③ held/pending状態がenumに無く、Web予約が「確定」として誤取込
バックエンドのheld(仮押さえ)とpending(決済待ち)に対応するcaseがSwiftのenumに存在せず、?? .confirmedにフォールバックしていた。
// Enums.swift — 修正前
enum ReservationStatus: String, Codable {
case confirmed
case cancelled
case completed
// held, pending, expiredがない!
}
未決済の仮予約が「確定」としてカレンダーを占有し、期限切れでexpired化するとサーバー側フィルタから消える。誤った予約が永久にカレンダーに残り続ける。
// 修正後
enum ReservationStatus: String, Codable {
case confirmed
case held // Web予約の仮押さえ
case pending // 決済待ち
case expired // 期限切れ
case cancelled
case completed
}
enumに新しいcaseを追加したことで、連動するswitch文の網羅性エラーが1件発生。ReservationDetailView.swiftの前金操作ボタン部分に.pendingケースを追加することで解消した。Xcodeがコンパイルエラーとして検出してくれるのでここは助かった。
④ 予約日時変更後に通知が再登録されない
// ReservationDetailViewModel.swift — 修正前
func reschedule() async {
await repository.update(reservation)
notificationScheduler.cancel(for: reservation.id)
// 再登録コードが存在しない!
}
旧通知をキャンセルするだけで再登録が実装されていなかった。「起動・フォアグラウンド復帰時のみ再構築」という設計を信頼しきっていたが、変更後にアプリを閉じずに使い続けるとリマインドが永久に飛ばない。
// 修正後
func reschedule() async {
await repository.update(reservation)
notificationScheduler.cancel(for: reservation.id)
notificationScheduler.schedule(for: reservation) // 明示的に再登録
}
🟠 中程度
バックエンドのXSSリスク
// 修正前: slugを検証せずHTMLに埋め込み
app.get('/s/:slug', (c) => {
const slug = c.req.param('slug')
return bookingPageHTML(slug) // validateSlug()を通していない
})
// page.js内
const SLUG = ${JSON.stringify(slug)}; // <, / をエスケープしない
URLエンコードした</script><script>alert(1)をslugに含めると任意JS実行の余地があった。修正ではルートでvalidateSlug()を先に通すよう変更。
その他
-
reconcileStorePaymentsのFOR UPDATEロック漏れ(二重payments対策) -
/availabilityの不正date値によるクラッシュ(500エラー) -
Calendar.currentを使った空き枠計算のタイムゾーン不整合(JST固定に修正) - 過去日時での予約作成・変更を防ぐバリデーション未実装
-
StripeAPIClientの400番台エラーメッセージ握りつぶし
修正後の検証
xcodebuild -scheme MaekinYoyaku -destination 'platform=iOS Simulator,...' test
Test Suite 'All tests' passed at 2025-XX-XX
Executed 35 tests, with 0 failures
全35件のテストが通過。バックエンドもnode --checkで構文確認済み。
ハマったポイント
1. Gitリポジトリでないとdiff系skillが使えない
セッション冒頭で/review skill(PRの差分レビューワークフロー)を呼んだが、「Gitリポジトリではないため差分が取れない」と判断されてそのまま停止した。差分ベースではなくコードベース全体スキャンに切り替えるのは正しい判断だったが、skillが想定外の環境で静かに止まる挙動は最初少し戸惑った。
2. enum追加は連鎖的なコンパイルエラーを引き起こす
ReservationStatusにheld/pending/expiredを追加した瞬間、switch文を持つファイルが複数箇所でコンパイルエラーになった。Claude Codeはビルドを走らせてエラーを確認し、自分で連鎖修正した。ただし最初の修正でReservationDetailView.swiftの一箇所を見落とし、2回目のビルドで発見して修正した。enumの追加はビルドを通すまでが1セットという教訓。
3. 「二重起動の排他制御」は非同期コードに潜みやすい
pollAll()の二重起動問題は、コードを静的に読んでいるだけでは見落としやすい。「どこから呼ばれているか」を全ファイルで追う必要がある。今回は並列エージェントが独立に同一バグを検出したことで確度が上がったが、単一レビューだと見落とすリスクがある。
4. LIMITと同期カーソルの組み合わせは罠
サーバー側のページング設計とクライアント側のカーソル更新ロジックは、片方だけ見ていると問題に気づかない。LIMIT 500はバックエンドにあり、カーソル更新はiOS側にある。責任の境界をまたいで追わないと見えないバグだった。今回はバックエンドとiOSを同じセッションでレビューしたため検出できた。
学び
並列エージェントの「独立した確認」は信頼度スコアとして機能する。 今回のStripe二重入金バグはServicesエージェントとViewModelsエージェントが別々に報告した。これは単純に「2人のレビュアーが同じバグを独立に見つけた」状態で、優先度を上げる強いシグナルになった。
「全部修正して」は意外と通じる。 通常のコードレビューでは「指摘して終わり」のことが多い。今回はレビュー結果を受けて「全部修正します!」と一言伝えたら、バックエンド→iOS→ビルド→テストの順で粛々と進めてくれた。人間がやると半日かかる作業量だったと思う(個人の感想)。
コアロジックに問題がないという確認も価値がある。 レビュー結果の最後に「BookingEngine・DepositCalculator・StatusTransition・リポジトリ層・Keychain/StoreKit/FreeQuota周りは明確なバグなし」と明記されていた。「ここは触らなくていい」という確証はリファクタリング計画に直結する。
タイムゾーンのズレは「海外滞在中の利用まで気にするか」という設計判断を含む。 今回はBookingEngineの空き枠計算のみJST固定に修正し、ダッシュボードの「今日」判定や通知発火時刻は現状維持とした。日本国内利用前提のアプリなら問題ないが、この判断は自動化できない。AIが「ここは設計判断が必要です」と明示して止まってくれたのは正しい。
まとめ
| カテゴリ | 件数 | 自動修正 |
|---|---|---|
| 最重要(金銭・データ整合性) | 4件 | ✅ |
| 中程度 | 6件 | ✅ |
| 軽微 | 3件 | ✅ |
| 合計 | 13件 | 全件 |
レビューから修正・ビルド・テストまで、コードを1行も書かずに終わった。特に「複数ファイルにまたがる排他制御の欠陥」や「サーバー/クライアント責任境界をまたいだ同期バグ」は、通常のレビューで見落としがちな類のものだ。
「全体的にチェックして」という曖昧な指示から始まっても、スキャン戦略を自分で決めて、エージェントを並列起動して、信頼度の高い順に報告してくれる。プロダクション前の最終確認として使う価値は十分ある。