9月の頭、壁紙アプリの更新を提出しようとして、App Store Connect の途中で手が止まりました。これまでになかった質問票が挟まっていたのです。2026年9月以降、新規アプリとアップデートの提出時、それから代替配布のための公証申請時に、この回答が必須になりました。iOS 27 / iPadOS 27 / macOS 27 の年齢レーティングと Time Allowances の挙動を決めるものです。
私は記憶で答えようとしました。二問目で止まりました。「アプリ内でウェブを開くか」という問いに、はい と いいえ のどちらとも言い切れなかったのです。設定画面のどこかに、ヘルプを開く導線を置いた覚えがありました。三年前の自分が置いたもので、いま画面を見ても目に入りません。
そのとき決めたことを、そのまま原則にしました。
提出画面で思い出そうとする質問は、リポジトリに書いてあるべき答えです。
個人開発でアプリを複数本出していると、同じ質問票を年に何度も、アプリの数だけ埋めることになります。そのたびに記憶を掘り返していては、いつか答えがぶれます。ぶれた側が正しいのか、実装の側が正しいのかも分からなくなります。ここでは、回答を一枚の宣言ファイルに置き、実装と食い違ったら止まる仕組みを作るところまでをお伝えします。
質問票が訊いているのは「するか」ではなく「しうるか」
最初に引っかかったのはここでした。この種の質問票は、アプリが日常的に何をするかではなく、利用者がその機能に到達しうるかを訊いています。
たとえば広告です。広告そのものは年齢レーティングの一項目ですが、それとは別に、広告のランディング先はアプリ内ブラウザで開かれます。つまり広告 SDK を同梱している時点で、自分のソースに WKWebView が一行もなくても、利用者はアプリの中から任意のウェブページに辿り着けるのです。
もうひとつは、機能を外したあとの残骸です。トラッキングの許諾を出すのをやめて ATTrackingManager の呼び出しを消しても、Info.plist の NSUserTrackingUsageDescription は消し忘れがちです。審査側から見れば、その鍵が残っているアプリは許諾を求めうるアプリです。
| 入口の種類 | 証拠が残る場所 | 自分のソースを読んで気づけるか |
| 自分で書いた機能 | Swift / Kotlin のソース | 気づけます |
| 過去に消し残した宣言 | Info.plist | 気づきにくいです |
| 依存 SDK が持ち込む導線 | Podfile.lock / Package.resolved | 気づけません |
「該当なし」と答えた項目ほど、あとで状況が変わっても誰も気づきません。守る値打ちがあるのは、まさにその「いいえ」のほうです。
回答を一枚の宣言ファイルにする
まず、質問票の答えをアプリごとに一枚のファイルへ書き出します。標準ライブラリだけで読めるように JSON にしました。
{
"app": "WallpaperApp",
"questionnaire_version": "2026-09",
"answers": {
"in_app_browser": "none",
"advertising": "contextual",
"user_generated_content": "none",
"in_app_purchase": true,
"location_sharing": false,
"tracking": false
}
}
questionnaire_version を入れているのには理由があります。質問票そのものが改訂されるからです。改訂が来たときに「この回答はどの版の設問に対するものか」が残っていないと、翌年また記憶を掘り返すことになります。
この段階では、まだ答えは間違っていて構いません。上の内容は、実際に私が記憶で埋めたときの回答をそのまま写したものです。ここから機械に反証させます。
宣言と実装を突き合わせる
照合スクリプトは Python の標準ライブラリだけで書けます。plistlib が入っているので、Info.plist はバイナリ形式のままでも読めます。証拠を集める先は三つに分けました。
一つめ: 自分が書いたソース
.swift / .kt などを行単位で走査し、SFSafariViewController や StoreKit のような印が出た行を、ファイル名と行番号ごと控えます。行番号まで残すのは、あとで「これは本当に生きている画面か」を自分の目で確かめるためです。
二つめ: Info.plist の鍵
plistlib.loads() にバイト列を渡すだけで、XML でもバイナリでも読めます。NSUserTrackingUsageDescription や位置情報の usage description は、実装より長生きします。コードから機能を外しても鍵が残っていれば、そのアプリは求めうるアプリです。
三つめ: 依存のロックファイル
Podfile.lock と Package.resolved を文字列で見ます。AdMob(Google-Mobile-Ads-SDK)や AppLovin のような広告 SDK が並んでいたら、広告そのものに加えて「アプリ内ブラウザ」の側にも証拠を立てます。ここが、自分のソースをいくら読んでも出てこない部分です。
#!/usr/bin/env python3
"""宣言した年齢レーティング回答と、実装が示す事実を突き合わせる。
標準ライブラリのみ。exit 1 = 宣言と実装が食い違っている。"""
import json, plistlib, re, sys
from pathlib import Path
SOURCE_SUFFIXES = {".swift", ".m", ".mm", ".h", ".kt", ".java"}
# 自分のソースに現れたら「その機能が有る」と見なす印
SOURCE_SIGNS = {
"in_app_browser": [r"\bSFSafariViewController\b", r"\bWKWebView\b",
r"\bASWebAuthenticationSession\b", r"\bUIWebView\b"],
"advertising": [r"\bGoogleMobileAds\b", r"\bBannerView\b", r"\bAppLovin", r"\bGADMobileAds\b"],
"user_generated_content": [r"\bUITextView\b", r"\bTextEditor\b", r"uploadComment", r"submitPost"],
"in_app_purchase": [r"\bStoreKit\b", r"\bTransaction\.currentEntitlements\b", r"\bSKPayment"],
"location_sharing": [r"\bCLLocationManager\b", r"\brequestWhenInUseAuthorization\b"],
"tracking": [r"\bATTrackingManager\b", r"\brequestTrackingAuthorization\b"],
}
# Info.plist にこの鍵があれば、その機能に到達する道がある
PLIST_SIGNS = {
"location_sharing": ["NSLocationWhenInUseUsageDescription",
"NSLocationAlwaysAndWhenInUseUsageDescription"],
"tracking": ["NSUserTrackingUsageDescription"],
}
# 依存 SDK が内側に持ち込むもの(自分のソースには一行も現れない)
DEPENDENCY_IMPLIES = {
r"Google-Mobile-Ads-SDK|GoogleMobileAds": [
("advertising", "広告 SDK が同梱されています"),
("in_app_browser", "広告のランディング先をアプリ内ブラウザで開きます"),
],
r"AppLovin|InMobi|UnityAds": [
("advertising", "広告メディエーション SDK が同梱されています"),
("in_app_browser", "広告のランディング先をアプリ内ブラウザで開きます"),
],
}
# 「無い」と宣言してよいのは、証拠が一つも出なかったときだけ
DECLARED_ABSENT = {
"in_app_browser": lambda v: v == "none",
"advertising": lambda v: v == "none",
"user_generated_content": lambda v: v == "none",
"in_app_purchase": lambda v: v is False,
"location_sharing": lambda v: v is False,
"tracking": lambda v: v is False,
}
def collect_evidence(root: Path) -> dict[str, list[str]]:
found: dict[str, list[str]] = {}
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
if path.suffix in SOURCE_SUFFIXES:
text = path.read_text(encoding="utf-8", errors="ignore")
for line_no, line in enumerate(text.splitlines(), 1):
for key, patterns in SOURCE_SIGNS.items():
for pattern in patterns:
if re.search(pattern, line):
found.setdefault(key, []).append(
f"{path.relative_to(root)}:{line_no} {line.strip()[:60]}")
elif path.name == "Info.plist":
try:
plist = plistlib.loads(path.read_bytes())
except Exception:
continue
for key, plist_keys in PLIST_SIGNS.items():
for plist_key in plist_keys:
if plist_key in plist:
found.setdefault(key, []).append(
f"{path.relative_to(root)} {plist_key}")
elif path.name in {"Podfile.lock", "Package.resolved"}:
text = path.read_text(encoding="utf-8", errors="ignore")
for pattern, implications in DEPENDENCY_IMPLIES.items():
if re.search(pattern, text):
for key, why in implications:
found.setdefault(key, []).append(
f"{path.relative_to(root)} 依存越し: {why}")
return found
def main() -> int:
manifest_path, app_root = Path(sys.argv[1]), Path(sys.argv[2])
answers = json.loads(manifest_path.read_text(encoding="utf-8"))["answers"]
evidence = collect_evidence(app_root)
conflicts = 0
print(f"{'項目':<24}{'宣言':<14}{'証拠':>4} 判定")
print("-" * 72)
for key, declared in answers.items():
hits = evidence.get(key, [])
says_absent = DECLARED_ABSENT[key](declared)
if says_absent and hits:
verdict, conflicts = "✗ 実装は有ると言っています", conflicts + 1
elif not says_absent and not hits:
verdict = "△ 証拠が見つかりません"
else:
verdict = "✓"
print(f"{key:<24}{str(declared):<14}{len(hits):>4} {verdict}")
if says_absent and hits:
for hit in hits:
print(f"{'':<24}└ {hit}")
print("-" * 72)
print(f"食い違い: {conflicts} 件")
return 1 if conflicts else 0
if __name__ == "__main__":
raise SystemExit(main())
設計で迷ったのは、判定を三値にするかどうかでした。結局、止めるのは**「無い」と宣言したのに証拠が出た場合だけ**にしています。逆側、つまり「有る」と宣言したのに証拠が出ない場合は △ を出すだけで通します。検出の印が足りていないだけのことが多く、ここで止めると誰も走らせなくなるからです。
走らせる前に踏んだ落とし穴
書きながら三つの注意点に当たりました。いずれも黙って結果を狂わせる種類のもので、先に回避しておくことをお勧めします。
Info.plist はアプリ本体だけではありません。ウィジェットや共有拡張を持っているアプリでは複数あります。rglob で全部拾うのが正解ですが、拡張側の鍵まで本体の回答に効く点は意識しておいてください。
Package.resolved はフォーマットに版があります。文字列一致で見ている限り影響はありませんが、構造化して読む方向へ育てるなら、version を先に確かめる対処が要ります。
errors="ignore" は付けたままにしてください。外していると、リポジトリの片隅にある UTF-8 でないファイル一つでスクリプト全体が落ちます。この場合は静かに読み飛ばすほうが実用的です。
走らせたら、二か所で食い違いました
再現できるよう、最小のツリーで動かします。Sources/ に画面と広告バナー、Settings/ にヘルプを開く画面、それに Info.plist と Podfile.lock を置いた構成です。
$ python3 check_rating_answers.py rating-answers.json WallpaperApp
項目 宣言 証拠 判定
------------------------------------------------------------------------
in_app_browser none 2 ✗ 実装は有ると言っています
└ Podfile.lock 依存越し: 広告のランディング先をアプリ内ブラウザで開きます
└ Settings/SupportLinkView.swift:7 vc.present(SFSafariViewController(url: url), a
advertising contextual 3 ✓
user_generated_content none 0 ✓
in_app_purchase True 2 ✓
location_sharing False 0 ✓
tracking False 1 ✗ 実装は有ると言っています
└ Info.plist NSUserTrackingUsageDescription
------------------------------------------------------------------------
食い違い: 2 件
$ echo $?
1
in_app_browser は、私が提出画面で言い淀んだ項目そのものです。三年前に置いたヘルプの導線が、行番号つきで出てきました。もう一件の tracking は完全に忘れていました。許諾を求める実装は残っていないのに、Info.plist の鍵だけが残っていたのです。
食い違いの三分の一は、自分のソースの外にありました
ここが、書いてみるまで予想と逆だった部分です。私は「消し忘れたコードが出てくるだろう」と思っていました。実際に集まった証拠を数えると、そうではありませんでした。
| 証拠の出どころ | 件数 |
自分で書いたソース(.swift) | 5 |
Info.plist と Podfile.lock | 3 |
| 合計 | 8 |
証拠の 37.5% が、自分では一行も書いていない場所から出てきたことになります。さらに大事なのは、二件の食い違いのうち一件は、自分のソースには根拠が一行も無かったことです。tracking の証拠は Info.plist の一行だけでした。in_app_browser のほうも、Podfile.lock 由来の一件が加わっています。
つまり Sources/ に対して grep を走らせるだけの検査では、止めるべき二件のうち一件を素通りさせていました。アプリが何をしうるかは、自分が書いた範囲の外側に置かれているのです。この形は、私自身がふだん触っているアプリでも同じでした。AdMob のメディエーションに他社アダプタを足したとき、追加したのは依存の宣言だけで、ソースコードは一行も増えていません。
宣言を直して、緑で止める
食い違いが出たら、直すのは宣言か実装のどちらかです。今回はヘルプの導線も広告も残す判断でしたので、宣言のほうを実態に合わせました。in_app_browser を restricted、tracking を true にしています。
$ python3 check_rating_answers.py rating-answers.json WallpaperApp
項目 宣言 証拠 判定
------------------------------------------------------------------------
in_app_browser restricted 2 ✓
advertising contextual 3 ✓
user_generated_content none 0 ✓
in_app_purchase True 2 ✓
location_sharing False 0 ✓
tracking True 1 ✓
------------------------------------------------------------------------
食い違い: 0 件
$ echo $?
0
tracking を true に戻したことで、Info.plist の鍵を残すか消すかという判断が、提出のときではなく、いま手元に降りてきました。これが宣言を先に書いておくことの効き目だと感じています。
走る速さも測っておきました。ソースを2,000ファイルまで増やしたツリー(合計2,002ファイル)で 0.21秒 でした。読んでいるのは拡張子で絞ったテキストだけですので、リポジトリが育ってもこの桁で収まります。CI にも、コミット前のフックにも置ける重さです。
提出直前ではなく、日常の側に置く
置き場所は二つ考えられます。提出直前のチェックリストに入れるか、CI に入れるかです。私は CI を選びました。
提出直前に走らせると、食い違いが見つかった瞬間がいちばん時間の無い瞬間になります。そこで「どちらが正しいか」を落ち着いて決められる人は多くありません。CI に置けば、依存を足した日や Info.plist を触った日に赤が出ます。判断のための時間が、提出の何週間も前に手に入るのです。
# .github/workflows/rating-answers.yml
name: rating-answers
on:
pull_request:
paths:
- "**/Info.plist"
- "**/Podfile.lock"
- "**/Package.resolved"
- "rating-answers.json"
- "**/*.swift"
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python3 check_rating_answers.py rating-answers.json WallpaperApp
paths の書き方には気をつけてください。Info.plist と各種ロックファイルを入れ忘れると、いちばん検出したい変更のときにワークフローが起動しません。設定が黙って無視されて通り抜ける経路については、無視された設定キーが CI を通り抜ける経路と、その塞ぎ方にも書き残しています。
機械が言えるところと、私が決めるところ
このスクリプトが言えるのは「宣言と実装が食い違っています」までです。どちらを直すべきかは言いません。そこは意図の話で、証拠から導けないからです。
エージェントに直させるときも、同じ線で切っています。証拠を集めるところ、行番号を添えるところ、差分を提示するところまでは任せます。rating-answers.json を書き換える手はこちらで動かします。証拠集めは機械に、申告の言葉は自分の指で。 提出物として私の名前で出ていくものは、最後の一手を自分に残しておきたいのです。
秋のリリース前は、この種の「提出のための作業」がまとめて降ってきます。初期化の順番や権利の復元まわりの確かめ方は、iOS 27 の RC から正式リリースまでの5日間のほうにまとめてあります。
明日いちばん先に置く一行
まずは、いま手元にいちばん近いアプリのリポジトリのルートに rating-answers.json を一枚置いて、記憶のままの回答を書き込んでみてください。照合はあとからで構いません。答えが文字になった時点で、次の提出のときに掘り返す記憶がひとつ減ります。
私自身、まだ全部のアプリを移し替えられてはいません。同じ時期に同じ質問票と向き合っている方の役に立てば嬉しく思います。お読みいただきありがとうございました。