prebuild とテストを通った生成 Swift が、Xcode archive で初めて落ちた
Expo config plugin が生成する Swift を文字列でテストし、同じ plugin を2回適用しても結果が変わらないことを確認した。prebuild も2回連続で成功した。それでも iOS の archive は、optional binding と helper の access control で初めて落ちた。足りなかったのはテストケースの数ではなく、Swift コンパイラを通る検証段階だった。
prebuild を2回通したあと、archive が落ちた
勤怠アプリの Live Activity を拡張するため、config plugin から3本の Swift ファイルへコードを差し込んでいた。表示側には状態の投影ロジック、action store 側には永続 queue と helper、module 側には JavaScript へ公開する bridge が入る。
plugin 単体では、既知の上流ソースが期待どおり置換されること、必要な断片が生成物に含まれること、2回目の適用結果が1回目と一致することを検証した。さらにprebuild を2回続けて実行し、生成済みソースに最新 marker が残ることも確認した。ここまではすべて成功していた。
最初の iOS native build で、生成 Swift に2件の問題が見つかった。修正後は archive、Widget extension の署名、IPA export まで成功した。つまり設定の生成には成功していたが、生成されたプログラムはまだコンパイル可能とは限らなかった。
文字列として正しくても、Swift としては正しくない
1件目は optional binding だった。property の名前は timeProjectionなのに、生成コードは guard let projection else としていた。shorthand binding は同名の optional を束縛する構文なので、その scope に projectionがなければ成立しない。必要だったのは property を右辺に明示する形だった。
2件目は helper の可視性だった。数値を検証する helper を privateにしていたが、呼び出し元は別の生成 Swift ファイルにあった。同じ extension target に入っていても、file scope の private を越えて参照することはできない。
// 変更前: shorthand binding の対象になる projection が存在しない
guard let projection else {
return fallback
}
// 変更後: optional property を明示して、ローカル変数へ束縛する
guard let projection = timeProjection else {
return fallback
}
// 変更前: 同じファイルの外からは参照できない
private func actionMilliseconds(_ value: Double) -> Int64? { ... }
// 変更後: 別の生成 Swift ファイルから参照できる
func actionMilliseconds(_ value: Double) -> Int64? { ... }config plugin の成功が証明するのは「変換できたこと」までだ。生成先の言語として有効かどうかは、その言語の compiler にしか証明できない。
2つの誤りは、どちらもテストの外側にあった
表示側のテストは、時間投影の関数、日境界の判定、後続の projection.confirmedWorkedMsなどが文字列に含まれることを確認していた。ロジックの材料は揃っていたが、冒頭の binding が Swift の名前解決を通るかは見ていなかった。文字列検索には scope がないからだ。
queue 側も同じだった。helper の定義と bridge からの呼び出しはそれぞれ存在した。しかしテストは2本の Swift ファイルを1つの module としてコンパイルしないため、access control の不整合は見えない。冪等性テストも、壊れた文字列を2回目に変えないことは確認できるが、その文字列がコンパイル可能かは確認できない。
修正後には、正しい binding が含まれ shorthand 版が含まれないこと、helper が module から参照できる形であり private版が残らないことを回帰テストへ足した。これは速い検知には効く。ただし、ここで文字列 assert を追加して完了とする案は捨てた。次の Swift 構文や可視性の誤りまで、検索文字列として先回りして列挙することはできないからだ。
assert.ok(nativeViewSource.includes(
"guard let projection = timeProjection else"
));
assert.ok(!nativeViewSource.includes("guard let projection else"));
assert.match(actionStoreSource, /\nfunc .*ActionMilliseconds/);
assert.ok(!actionStoreSource.includes(
"private func actionMilliseconds"
));native compile を最終 gate にした
検証を二層に分けた。文字列テストと2回の prebuildは、上流ソースの変化、置換漏れ、二重挿入を早く見つける。そのうえで iOS native build を最終 gate に置き、生成された全 Swift ファイルを実際の target と scope でコンパイルする。前者を減らすのではなく、前者では扱えない性質を後者に担当させた。
もう1つ、すでに生成済みのソースをどう直すかが残った。新しい template だけを修正すると、過去の marker が付いたファイルは「適用済み」と判断され、誤った helper が残り続ける。そこで現在形を v6 とし、既知の v1〜v5 は、それぞれ必要な fingerprint がすべて揃う場合だけ v6 へ置き換えるようにした。
一部だけ適用されたソースや、想定外に変わった上流ソースは upgrade しない。plugin を失敗させて止める。曖昧な状態を推測で書き換えると、prebuild は通っても別の欠損を埋め込む経路になるためだ。v6 では helper の可視性を marker の完成条件に含め、表示側は正しい binding を直接テストする。どちらも2回目の適用は同じ結果を返す。これで新規生成、旧版からの更新、部分適用の3経路を分けて閉じた。
まとめ
文字列変換テスト、冪等性テスト、複数回の prebuildは、config plugin の検証として必要だった。ただし、どれも Swift の名前解決やファイルをまたぐ access control を実行してはいない。生成コードを持つなら、生成処理の成功と生成物のコンパイル成功を別の gate として扱う必要がある。
再発防止も同じ二層になった。既知の誤りは狙った文字列テストで早く落とし、未知の Swift の誤りは native compile で止める。そして marker の versioned upgrade と fail-closed な判定で、古い生成物や部分適用から同じ誤りが戻る経路を閉じる。テストを増やすだけではなく、各テストが証明していないものを最後の gate に渡すことが要点だった。