appstore-precheck 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +219 -0
  4. package/bin/cli.js +119 -0
  5. package/package.json +31 -0
  6. package/skills/appstore-precheck/SKILL.md +188 -0
  7. package/skills/appstore-precheck/config.example.json +25 -0
  8. package/skills/appstore-precheck/evals/README.md +40 -0
  9. package/skills/appstore-precheck/evals/evals.json +73 -0
  10. package/skills/appstore-precheck/evals/files/green-app/App/ContentView.swift +2 -0
  11. package/skills/appstore-precheck/evals/files/green-app/App/Info.plist +3 -0
  12. package/skills/appstore-precheck/evals/files/green-app/App/PrivacyInfo.xcprivacy +3 -0
  13. package/skills/appstore-precheck/evals/files/green-app/App/SubscriptionView.swift +13 -0
  14. package/skills/appstore-precheck/evals/files/green-app/fastlane/metadata/en-US/description.txt +1 -0
  15. package/skills/appstore-precheck/evals/files/green-app/fastlane/metadata/en-US/keywords.txt +1 -0
  16. package/skills/appstore-precheck/evals/files/green-app/fastlane/metadata/en-US/name.txt +1 -0
  17. package/skills/appstore-precheck/evals/files/green-app/fastlane/metadata/en-US/privacy_url.txt +1 -0
  18. package/skills/appstore-precheck/evals/files/green-app/fastlane/metadata/en-US/subtitle.txt +1 -0
  19. package/skills/appstore-precheck/evals/files/green-app/fastlane/metadata/en-US/support_url.txt +1 -0
  20. package/skills/appstore-precheck/evals/files/no-iap-app/App/ContentView.swift +2 -0
  21. package/skills/appstore-precheck/evals/files/no-iap-app/App/Info.plist +3 -0
  22. package/skills/appstore-precheck/evals/files/no-iap-app/App/PrivacyInfo.xcprivacy +3 -0
  23. package/skills/appstore-precheck/evals/files/no-iap-app/fastlane/metadata/en-US/description.txt +1 -0
  24. package/skills/appstore-precheck/evals/files/no-iap-app/fastlane/metadata/en-US/keywords.txt +1 -0
  25. package/skills/appstore-precheck/evals/files/no-iap-app/fastlane/metadata/en-US/name.txt +1 -0
  26. package/skills/appstore-precheck/evals/files/no-iap-app/fastlane/metadata/en-US/subtitle.txt +1 -0
  27. package/skills/appstore-precheck/evals/files/red-app/App/ContentView.swift +2 -0
  28. package/skills/appstore-precheck/evals/files/red-app/App/Info.plist +3 -0
  29. package/skills/appstore-precheck/evals/files/red-app/App/PrivacyInfo.xcprivacy +3 -0
  30. package/skills/appstore-precheck/evals/files/red-app/App/SubscriptionView.swift +3 -0
  31. package/skills/appstore-precheck/evals/files/red-app/fastlane/metadata/en-US/description.txt +1 -0
  32. package/skills/appstore-precheck/evals/files/red-app/fastlane/metadata/en-US/keywords.txt +1 -0
  33. package/skills/appstore-precheck/evals/files/red-app/fastlane/metadata/en-US/name.txt +1 -0
  34. package/skills/appstore-precheck/evals/files/red-app/fastlane/metadata/en-US/subtitle.txt +1 -0
  35. package/skills/appstore-precheck/guidelines-baseline.json +35 -0
  36. package/skills/appstore-precheck/references/methodology.md +131 -0
  37. package/skills/appstore-precheck/scripts/phase2-precheck.sh +68 -0
  38. package/skills/appstore-precheck/scripts/scan.sh +513 -0
  39. package/skills/appstore-precheck/scripts/verdict.sh +75 -0
@@ -0,0 +1,513 @@
1
+ #!/usr/bin/env bash
2
+ # appstore-precheck/scripts/scan.sh
3
+ # Static, read-only pre-submission scanner for iOS App Store rejection vectors.
4
+ # Convention-over-configuration: auto-detects a standard fastlane + Xcode layout,
5
+ # and honors an optional `.appstore-precheck.json` at the repo root for overrides.
6
+ #
7
+ # Output: three line prefixes on stdout — FAIL: / WARN: / PASS: <topic> — <detail> [location]
8
+ # Exit code: always 0. The skill counts FAIL/WARN lines to reach a GREEN/YELLOW/RED verdict.
9
+
10
+ set -u
11
+
12
+ ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT="$(pwd)"
13
+ cd "$ROOT" || { echo "FAIL: repo-root — could not enter repository root"; exit 0; }
14
+
15
+ CONFIG="${APPSTORE_PRECHECK_CONFIG:-.appstore-precheck.json}"
16
+ have_jq() { command -v jq >/dev/null 2>&1; }
17
+
18
+ # cfg <json-path> <fallback> — read a value from the config file, else fallback.
19
+ cfg() {
20
+ local path="$1" fallback="${2:-}"
21
+ if [[ -f "$CONFIG" ]] && have_jq; then
22
+ local v; v=$(jq -r "$path // empty" "$CONFIG" 2>/dev/null)
23
+ [[ -n "$v" && "$v" != "auto" ]] && { echo "$v"; return; }
24
+ fi
25
+ echo "$fallback"
26
+ }
27
+ cfg_bool() { # cfg_bool <json-path> — echoes "true"/"false"
28
+ if [[ -f "$CONFIG" ]] && have_jq; then
29
+ local v; v=$(jq -r "$1 // false" "$CONFIG" 2>/dev/null)
30
+ [[ "$v" == "true" ]] && { echo "true"; return; }
31
+ fi
32
+ echo "false"
33
+ }
34
+
35
+ fail() { echo "FAIL: $1"; }
36
+ warn() { echo "WARN: $1"; }
37
+ pass() { echo "PASS: $1"; }
38
+
39
+ # ===================================================================
40
+ # Auto-detection of the project layout
41
+ # ===================================================================
42
+
43
+ # Paths to ignore everywhere: VCS, dependency checkouts, build output, worktrees.
44
+ PRUNE=( -not -path '*/.git/*' -not -path '*/Pods/*' -not -path '*/Carthage/*'
45
+ -not -path '*/.build/*' -not -path '*/build/*' -not -path '*/DerivedData/*'
46
+ -not -path '*/SourcePackages/*' -not -path '*/checkouts/*' -not -path '*/.swiftpm/*'
47
+ -not -path '*/.claude/*' -not -path '*/worktrees/*'
48
+ -not -path '*/node_modules/*' -not -path '*/vendor/*' )
49
+
50
+ # Same prune set expressed as grep --exclude-dir globs, for repo-wide grep -r passes
51
+ # (the find-style PRUNE above is not valid grep syntax).
52
+ GREP_PRUNE=( --exclude-dir=.git --exclude-dir=Pods --exclude-dir=Carthage
53
+ --exclude-dir=.build --exclude-dir=build --exclude-dir=DerivedData
54
+ --exclude-dir=SourcePackages --exclude-dir=checkouts --exclude-dir=.swiftpm
55
+ --exclude-dir=.claude --exclude-dir=worktrees
56
+ --exclude-dir=node_modules --exclude-dir=vendor )
57
+
58
+ # Reads newline-separated paths on stdin, prints the one with the fewest path
59
+ # segments (the shallowest, i.e. the real project copy rather than a nested one).
60
+ pick_shallowest() { awk '{ n=gsub(/\//,"/"); print n"\t"$0 }' | sort -n | head -1 | cut -f2-; }
61
+
62
+ detect_first() { find . "${PRUNE[@]}" "$@" 2>/dev/null | pick_shallowest; }
63
+
64
+ # Non-app Xcode target basenames, matched CamelCase + case-sensitively so glued
65
+ # suffixes ("WikipediaUnitTests", "WidgetExtension") are caught while lowercase
66
+ # substrings ("Latest" → "test") are not. These are deprioritized in detection.
67
+ NONAPP_TARGET='(Watch|Extension|Widget|Intents|Clip|Notification|Share|Sticker|Tests|UITests|Framework)'
68
+
69
+ # iOS source dir: the app target's source root. Candidates are Info.plist directories
70
+ # AND app-entry-point directories — because modern Xcode apps often have NO checked-in
71
+ # Info.plist for the main target (it is auto-generated), so an Info.plist-only search
72
+ # can land on a Watch app, an app extension, or a framework instead of the real app.
73
+ # We score candidates by Swift-file count and deprioritize obvious non-app targets, so
74
+ # they only win when nothing app-like exists.
75
+ detect_ios_dir() {
76
+ local d; d=$(cfg '.iosSourceDir')
77
+ [[ -n "$d" ]] && { echo "$d"; return; }
78
+ local candidates plist entry
79
+ candidates=$(
80
+ find . "${PRUNE[@]}" -name Info.plist 2>/dev/null | while IFS= read -r plist; do dirname "$plist"; done
81
+ grep -rlE '@main|@UIApplicationMain|class AppDelegate' --include='*.swift' "${GREP_PRUNE[@]}" . 2>/dev/null \
82
+ | while IFS= read -r entry; do dirname "$entry"; done
83
+ )
84
+ candidates=$(printf '%s\n' "$candidates" | sort -u)
85
+ local best="" best_n=-1 alt="" alt_n=-1 dir n
86
+ while IFS= read -r dir; do
87
+ [[ -z "$dir" ]] && continue
88
+ n=$(find "$dir" -maxdepth 4 -name '*.swift' "${PRUNE[@]}" 2>/dev/null | wc -l | tr -d ' ')
89
+ if printf '%s' "$dir" | grep -qE "$NONAPP_TARGET"; then
90
+ (( n > alt_n )) && { alt_n=$n; alt="$dir"; }
91
+ else
92
+ (( n > best_n )) && { best_n=$n; best="$dir"; }
93
+ fi
94
+ done <<< "$candidates"
95
+ [[ -n "$best" ]] && { echo "$best"; return; }
96
+ echo "$alt"
97
+ }
98
+
99
+ IOS_DIR="$(detect_ios_dir)"
100
+ META_DIR="$(cfg '.metadataDir')"; [[ -z "$META_DIR" ]] && META_DIR="$(detect_first -type d -name metadata -path '*fastlane*')"
101
+ SCREEN_DIR="$(cfg '.screenshotsDir')"; [[ -z "$SCREEN_DIR" ]] && SCREEN_DIR="$(detect_first -type d -name screenshots -path '*fastlane*')"
102
+ XCSTRINGS="$(cfg '.xcstringsPath')"; [[ -z "$XCSTRINGS" ]] && XCSTRINGS="$(detect_first -name 'Localizable.xcstrings')"
103
+ [[ -z "$XCSTRINGS" ]] && XCSTRINGS="$(detect_first -name '*.xcstrings')"
104
+ PRIVACY_FILE="$(detect_first -name 'PrivacyInfo.xcprivacy')"
105
+ INFO_PLIST="${IOS_DIR%/}/Info.plist"
106
+ REVIEW_PREP="$(cfg '.reviewPrepNotes')"
107
+
108
+ # Paywall / subscription views. A real app spreads its purchase UI across several files
109
+ # (the paywall, a price row, a footer with the links), so we collect the whole cluster —
110
+ # every glob match, minus *ViewModel* files, which are logic, never the screen — and run
111
+ # the required-link checks across all of them. Checking a single auto-picked file gives
112
+ # false FAILs when detection lands on a manage/cancel screen instead of the paywall.
113
+ # Config globs win if provided. SUB_VIEW is the shallowest match, used only for messages.
114
+ SUB_VIEW=""
115
+ # Initialize with =() so the arrays are definitively assigned: under `set -u`, a
116
+ # `declare -a` that is never populated makes ${#arr[@]} an unbound-variable error on
117
+ # some bash builds (seen on the CI runner). The ${arr[@]+...} idiom below additionally
118
+ # guards empty-array expansion on bash 3.2 (macOS).
119
+ declare -a PAYWALL_GLOBS=() PAYWALL_FILES=()
120
+ if [[ -f "$CONFIG" ]] && have_jq && [[ "$(jq -r '.paywallGlobs | type' "$CONFIG" 2>/dev/null)" == "array" ]]; then
121
+ while IFS= read -r g; do PAYWALL_GLOBS+=("$g"); done < <(jq -r '.paywallGlobs[]' "$CONFIG" 2>/dev/null)
122
+ else
123
+ PAYWALL_GLOBS=( '*SubscriptionView*.swift' '*PaywallView*.swift' '*Paywall*.swift' '*[Ss]ubscription*View.swift' '*[Pp]urchase*View.swift' )
124
+ fi
125
+ for g in "${PAYWALL_GLOBS[@]+"${PAYWALL_GLOBS[@]}"}"; do
126
+ while IFS= read -r f; do
127
+ [[ -z "$f" ]] && continue
128
+ PAYWALL_FILES+=("$f")
129
+ done < <(find "${IOS_DIR:-.}" "${PRUNE[@]}" -name "$g" 2>/dev/null | grep -v 'ViewModel')
130
+ done
131
+ if (( ${#PAYWALL_FILES[@]} > 0 )); then
132
+ SUB_VIEW=$(printf '%s\n' "${PAYWALL_FILES[@]}" | pick_shallowest)
133
+ fi
134
+
135
+ # Locales: explicit config array, else the directory names under metadata/.
136
+ declare -a LOCALES=()
137
+ if [[ -f "$CONFIG" ]] && have_jq && [[ "$(jq -r '.locales | type' "$CONFIG" 2>/dev/null)" == "array" ]]; then
138
+ while IFS= read -r l; do LOCALES+=("$l"); done < <(jq -r '.locales[]' "$CONFIG" 2>/dev/null)
139
+ elif [[ -d "$META_DIR" ]]; then
140
+ while IFS= read -r d; do
141
+ b=$(basename "$d")
142
+ # ASC locale dirs look like xx or xx-YY; skip non-locale folders.
143
+ [[ "$b" =~ ^[a-z]{2}(-[A-Za-z]{2,4})?$ ]] && LOCALES+=("$b")
144
+ done < <(find "$META_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort)
145
+ fi
146
+
147
+ SUB_KEY="$(cfg '.disclosureKeys.subscription' 'subscription_disclosure')"
148
+ TRIAL_KEY="$(cfg '.disclosureKeys.trial' 'subscription_trial_disclosure')"
149
+ CHECK_FAMILY="$(cfg_bool '.optionalChecks.familyControls')"
150
+
151
+ echo "PASS: layout — ios='${IOS_DIR:-?}' metadata='${META_DIR:-?}' xcstrings='${XCSTRINGS:-?}' locales=${#LOCALES[@]}"
152
+
153
+ # ===================================================================
154
+ # §1 — 5.1.1(v) Privacy Manifest Required Reason API parity
155
+ # ===================================================================
156
+ check_required_reason_api() {
157
+ local cat="$1" pattern="$2" hits declared
158
+ hits=$(grep -rEl "$pattern" "$IOS_DIR" --include="*.swift" 2>/dev/null | head -3)
159
+ declared=$(grep -c "NSPrivacyAccessedAPICategory${cat}" "$PRIVACY_FILE" 2>/dev/null)
160
+ if [[ -n "$hits" && "${declared:-0}" -eq 0 ]]; then
161
+ fail "5.1.1(v) Required Reason API — '$cat' used in code (e.g. $(echo "$hits" | head -1)) but not declared in PrivacyInfo.xcprivacy"
162
+ elif [[ -z "$hits" && "${declared:-0}" -gt 0 ]]; then
163
+ warn "5.1.1(v) PrivacyInfo — '$cat' declared but no code usage grepped (may be a false positive, verify manually)"
164
+ elif [[ -n "$hits" && "${declared:-0}" -gt 0 ]]; then
165
+ pass "5.1.1(v) Required Reason API — '$cat' parity OK"
166
+ fi
167
+ }
168
+ if [[ -z "$IOS_DIR" ]]; then
169
+ warn "layout — could not auto-detect iOS source dir; set .iosSourceDir in $CONFIG"
170
+ elif [[ -z "$PRIVACY_FILE" ]]; then
171
+ fail "5.1.1(v) PrivacyInfo.xcprivacy not found (required since May 2024 for apps using Required Reason APIs)"
172
+ else
173
+ check_required_reason_api "UserDefaults" 'UserDefaults|@AppStorage'
174
+ check_required_reason_api "FileTimestamp" 'attributesOfItem|creationDate|modificationDate|\.fileCreationDate|\.fileModificationDate'
175
+ check_required_reason_api "SystemBootTime" 'systemUptime|mach_absolute_time|CACurrentMediaTime\(\)'
176
+ check_required_reason_api "DiskSpace" 'volumeAvailableCapacity|volumeTotalCapacity'
177
+ check_required_reason_api "ActiveKeyboard" 'activeInputModes|UITextInputMode'
178
+ fi
179
+
180
+ # ===================================================================
181
+ # §2 — 5.1.1 NSUsageDescription cross-check (Info.plist)
182
+ # ===================================================================
183
+ if [[ ! -f "$INFO_PLIST" ]]; then
184
+ [[ -n "$IOS_DIR" ]] && warn "5.1.1 Info.plist not found at $INFO_PLIST (modern Xcode may auto-generate it; verify purpose strings in build settings)"
185
+ else
186
+ awk '/NS[A-Za-z]+UsageDescription/{key=$0; getline; if($0 ~ /<string>[[:space:]]*<\/string>/) print "EMPTY:"key}' "$INFO_PLIST" | while read -r line; do
187
+ [[ -n "$line" ]] && fail "5.1.1 Purpose String — $line (empty usage description is rejected by App Review)"
188
+ done
189
+ for fw in \
190
+ "FamilyControls|ManagedSettings|DeviceActivity:NSFamilyControlsUsageDescription" \
191
+ "CoreLocation:NSLocationWhenInUseUsageDescription" \
192
+ "AVFoundation:NSCameraUsageDescription|NSMicrophoneUsageDescription" \
193
+ "Photos:NSPhotoLibraryUsageDescription" \
194
+ "Contacts:NSContactsUsageDescription" \
195
+ "HealthKit:NSHealthShareUsageDescription"; do
196
+ framework="${fw%%:*}"; needed_key="${fw##*:}"
197
+ if grep -rqE "import ($framework)|($framework)\." "$IOS_DIR" --include="*.swift" 2>/dev/null; then
198
+ if ! grep -qE "$needed_key" "$INFO_PLIST" 2>/dev/null; then
199
+ fail "5.1.1 framework '${framework%%|*}' imported but Info.plist is missing '${needed_key%%|*}'"
200
+ fi
201
+ fi
202
+ done
203
+ fi
204
+
205
+ # ===================================================================
206
+ # §3 — 5.1.2 ATT (App Tracking Transparency)
207
+ # ===================================================================
208
+ # Ad / attribution / IDFA SDK signal, shared with §16. The reverse of §3: §3 fires
209
+ # when the ATT framework IS imported; §16 fires when a tracking SDK is present but
210
+ # the ATT prompt is NOT. Computed once here so the two checks never contradict.
211
+ tracking_sdk=$(grep -rlE 'advertisingIdentifier|ASIdentifierManager|GADMobileAds|GoogleMobileAds|AppLovinSDK|ALSdk|AppsFlyerLib|import Adjust|Adjust\.|FBAudienceNetwork|BranchSDK|IronSource' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
212
+ att_used=$(grep -rlE 'AppTrackingTransparency|ATTrackingManager' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
213
+ if [[ -n "$att_used" ]]; then
214
+ if grep -q "NSUserTrackingUsageDescription" "$INFO_PLIST" 2>/dev/null; then
215
+ pass "5.1.2 ATT — used + NSUserTrackingUsageDescription present"
216
+ else
217
+ fail "5.1.2 ATT framework imported but NSUserTrackingUsageDescription missing in Info.plist ($att_used)"
218
+ fi
219
+ elif [[ -z "$tracking_sdk" ]]; then
220
+ pass "5.1.2 ATT — not used (no tracking)"
221
+ fi
222
+ # If a tracking SDK is present without the ATT framework, §3 stays silent and §16
223
+ # raises the WARN, so we never print a misleading "no tracking" PASS.
224
+
225
+ # ===================================================================
226
+ # §4 — 2.3.10 Other-platform / competitor mentions in metadata
227
+ # ===================================================================
228
+ if [[ -d "$META_DIR" ]]; then
229
+ banned_re='Android|Google[[:space:]]?Play|Play[[:space:]]?Store|Windows[[:space:]]?Phone|Samsung Galaxy|Huawei AppGallery|F-Droid'
230
+ hits=$(grep -rEnI "$banned_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -30)
231
+ if [[ -n "$hits" ]]; then
232
+ fail "2.3.10 Other-platform mention — banned reference in metadata:"
233
+ echo "$hits" | sed 's/^/ /'
234
+ else
235
+ pass "2.3.10 Other-platform mentions — metadata clean"
236
+ fi
237
+ fi
238
+
239
+ # ===================================================================
240
+ # §5 — 2.3.1 Metadata character limits (Unicode codepoints, like ASC)
241
+ # ===================================================================
242
+ check_len() {
243
+ local file="$1" limit="$2" label="$3" len
244
+ [[ -f "$file" ]] || return
245
+ if command -v python3 >/dev/null 2>&1; then
246
+ len=$(python3 -c "import sys; print(len(open(sys.argv[1], encoding='utf-8').read().rstrip('\n')))" "$file" 2>/dev/null)
247
+ else
248
+ len=$(wc -m < "$file" | tr -d ' ')
249
+ fi
250
+ [[ -z "$len" ]] && return
251
+ (( len > limit )) && fail "2.3.1 $label — $file ${len} chars (limit ${limit})"
252
+ }
253
+ for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
254
+ d="$META_DIR/$loc"; [[ -d "$d" ]] || continue
255
+ check_len "$d/name.txt" 30 "name[$loc]"
256
+ check_len "$d/subtitle.txt" 30 "subtitle[$loc]"
257
+ check_len "$d/keywords.txt" 100 "keywords[$loc]"
258
+ check_len "$d/promotional_text.txt" 170 "promo[$loc]"
259
+ check_len "$d/description.txt" 4000 "description[$loc]"
260
+ done
261
+
262
+ # ===================================================================
263
+ # §6 — 2.3.7 Localized metadata parity across all detected locales
264
+ # ===================================================================
265
+ if (( ${#LOCALES[@]} > 0 )); then
266
+ expected_files=(name.txt subtitle.txt description.txt keywords.txt)
267
+ for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
268
+ d="$META_DIR/$loc"
269
+ if [[ ! -d "$d" ]]; then fail "2.3.7 Locale missing — $d does not exist"; continue; fi
270
+ for f in "${expected_files[@]}"; do
271
+ [[ -s "$d/$f" ]] || fail "2.3.7 Metadata missing — $d/$f is empty or absent"
272
+ done
273
+ done
274
+ pass "2.3.7 Localized metadata — checked ${#LOCALES[@]} locales"
275
+ fi
276
+
277
+ # ===================================================================
278
+ # §7 — 2.3.3 Screenshots per locale
279
+ # ===================================================================
280
+ if [[ -n "$SCREEN_DIR" && -d "$SCREEN_DIR" ]]; then
281
+ for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
282
+ d="$SCREEN_DIR/$loc"
283
+ if [[ ! -d "$d" ]]; then warn "2.3.3 Screenshots — no folder for $loc"; continue; fi
284
+ cnt=$(find "$d" -maxdepth 2 -type f \( -name "*.png" -o -name "*.jpg" -o -name "*.jpeg" \) 2>/dev/null | wc -l | tr -d ' ')
285
+ if (( cnt == 0 )); then
286
+ fail "2.3.3 Screenshots — $loc folder is empty (at least one iPhone screenshot required)"
287
+ elif (( cnt < 3 )); then
288
+ warn "2.3.3 Screenshots — $loc has only $cnt image(s) (3-10 recommended)"
289
+ fi
290
+ done
291
+ pass "2.3.3 Screenshots — checked ${#LOCALES[@]} locales under $SCREEN_DIR"
292
+ else
293
+ warn "2.3.3 Screenshots — screenshots dir not found (set .screenshotsDir if you manage them in-repo)"
294
+ fi
295
+
296
+ # ===================================================================
297
+ # In-app purchase gate — only run 3.1.2 checks if IAP signals exist
298
+ # ===================================================================
299
+ iap_detected=""
300
+ grep -rqE 'import StoreKit|RevenueCat|Purchases\.|Product\(for:|AppStore\.|SKProduct|StoreKit2' "$IOS_DIR" --include="*.swift" 2>/dev/null && iap_detected=1
301
+ [[ -n "$SUB_VIEW" ]] && iap_detected=1
302
+
303
+ if [[ -z "$iap_detected" ]]; then
304
+ pass "3.1.2 IAP — no in-app purchase / subscription signals detected, skipping paywall checks"
305
+ else
306
+ # ---- §8 3.1.2 Trial disclosure -------------------------------------------------
307
+ trial_re='free[[:space:]]?trial|trial[[:space:]]?period|ücretsiz[[:space:]]?dene|kostenlos[[:space:]]?test|essai[[:space:]]?gratuit|prueba[[:space:]]?grat|無料.*トライアル|무료[[:space:]]?체험'
308
+ if [[ -n "$XCSTRINGS" ]] && grep -EqI "$trial_re" "$XCSTRINGS" 2>/dev/null; then
309
+ if grep -q "$TRIAL_KEY" "$XCSTRINGS" 2>/dev/null; then
310
+ pass "3.1.2 Free trial offered + trial disclosure key '$TRIAL_KEY' present"
311
+ else
312
+ warn "3.1.2 Trial wording detected but disclosure key '$TRIAL_KEY' not found — ensure trial→paid auto-renew terms (length, price, cancel) are shown near the CTA"
313
+ fi
314
+ else
315
+ pass "3.1.2 Trial — no trial wording detected, disclosure check skipped"
316
+ fi
317
+
318
+ # ---- §9 3.1.2 Auto-renew subscription disclosure -------------------------------
319
+ if [[ -n "$XCSTRINGS" ]]; then
320
+ if have_jq && jq -e ".strings | has(\"$SUB_KEY\")" "$XCSTRINGS" >/dev/null 2>&1; then
321
+ pass "3.1.2 subscription disclosure key '$SUB_KEY' present"
322
+ for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
323
+ short="${loc%%-*}"
324
+ if ! jq -e ".strings.\"$SUB_KEY\".localizations | (has(\"$loc\") or has(\"$short\"))" "$XCSTRINGS" >/dev/null 2>&1; then
325
+ warn "3.1.2 subscription disclosure — translation missing for '$loc' (key '$SUB_KEY')"
326
+ fi
327
+ done
328
+ elif grep -qEiI 'auto.?renew|renews automatically|otomatik.*yenilen|automatisch.*verläng|se renueva' "$XCSTRINGS" 2>/dev/null; then
329
+ warn "3.1.2 auto-renew language found but not under key '$SUB_KEY' — verify disclosure key naming or set .disclosureKeys.subscription"
330
+ else
331
+ warn "3.1.2 No auto-renewal disclosure string detected — Apple requires it for auto-renewable subscriptions; verify manually"
332
+ fi
333
+ fi
334
+
335
+ # ---- §10 3.1.2 Required links + Restore Purchases ------------------------------
336
+ # Grep across the whole paywall cluster: a link present in ANY paywall view satisfies
337
+ # the requirement; only its absence from ALL of them is a FAIL. SUB_VIEW names the
338
+ # representative file in messages.
339
+ if (( ${#PAYWALL_FILES[@]} > 0 )); then
340
+ if grep -qE 'restore|restorePurchases' "${PAYWALL_FILES[@]}"; then
341
+ pass "3.1.2 Restore Purchases — present in $(basename "$SUB_VIEW")"
342
+ else
343
+ fail "3.1.2 Restore Purchases — not found in the paywall views (e.g. $SUB_VIEW) (required by Apple)"
344
+ fi
345
+ if grep -qE 'terms_of_use|termsURL|termsOfUse|subscription_terms|EULA|/terms' "${PAYWALL_FILES[@]}"; then
346
+ pass "3.1.2 Terms of Use (EULA) link — present"
347
+ else
348
+ fail "3.1.2 Terms of Use (EULA) link — not found in the paywall views (e.g. $SUB_VIEW)"
349
+ fi
350
+ if grep -qE 'privacy_policy|privacyURL|privacyPolicy|subscription_privacy|/privacy' "${PAYWALL_FILES[@]}"; then
351
+ pass "3.1.2 Privacy Policy link — present"
352
+ else
353
+ fail "3.1.2 Privacy Policy link — not found in the paywall views (e.g. $SUB_VIEW)"
354
+ fi
355
+ else
356
+ warn "3.1.2 IAP detected but no paywall/subscription view found — set .paywallGlobs so required-link checks can run"
357
+ fi
358
+ fi
359
+
360
+ # ===================================================================
361
+ # §11 — 2.5.1 Private / banned API
362
+ # ===================================================================
363
+ banned_api='UIWebView|setSelectionIndicatorImage|_UIBackdropView|NSURLConnection[^a-zA-Z]|UIAlertView[^Q]|UIActionSheet[^Q]'
364
+ banned_hits=$(grep -rEnI "$banned_api" "$IOS_DIR" --include="*.swift" --include="*.m" --include="*.h" 2>/dev/null | head -5)
365
+ if [[ -n "$banned_hits" ]]; then
366
+ fail "2.5.1 Private/Deprecated API:"
367
+ echo "$banned_hits" | sed 's/^/ /'
368
+ else
369
+ pass "2.5.1 Private API — clean"
370
+ fi
371
+
372
+ # ===================================================================
373
+ # §12 — 4.0 Minimum functionality — navigation hubs
374
+ # ===================================================================
375
+ tab_count=$(grep -rcE 'TabView|NavigationStack|NavigationSplitView' "$IOS_DIR" --include="*.swift" 2>/dev/null | awk -F: '{sum+=$2} END {print sum+0}')
376
+ if (( tab_count < 1 )); then
377
+ warn "4.0 Minimum functionality — no TabView/NavigationStack found (heuristic, may be a false positive)"
378
+ else
379
+ pass "4.0 Minimum functionality — $tab_count navigation hub(s) found"
380
+ fi
381
+
382
+ # ===================================================================
383
+ # §13 — 5.1.5 Screen Time / sensitive-API justification (optional, opt-in)
384
+ # ===================================================================
385
+ if [[ "$CHECK_FAMILY" == "true" ]] && grep -q "NSFamilyControlsUsageDescription" "$INFO_PLIST" 2>/dev/null; then
386
+ if [[ -n "$REVIEW_PREP" && -f "$REVIEW_PREP" ]] && grep -qiE 'family|screen[[:space:]]?time' "$REVIEW_PREP" 2>/dev/null; then
387
+ pass "5.1.5 Screen Time API — reviewer-prep justification note present"
388
+ else
389
+ warn "5.1.5 Screen Time API in use — add a justification in your ASC App Review notes (point .reviewPrepNotes at the file). Otherwise 5.1.1 rejection risk is high."
390
+ fi
391
+ fi
392
+
393
+ # ===================================================================
394
+ # §14 — 4.8 Sign in with Apple parity (only when a third-party social login is used)
395
+ # ===================================================================
396
+ if [[ -n "$IOS_DIR" ]]; then
397
+ social_login=$(grep -rlE 'GIDSignIn|import GoogleSignIn|FBSDKLoginKit|FBSDKLoginManager|FacebookLogin|import Auth0|LineSDK|VKSdkAuthorization' "$IOS_DIR" --include='*.swift' 2>/dev/null | head -1)
398
+ if [[ -n "$social_login" ]]; then
399
+ if grep -rqE 'ASAuthorizationAppleID|SignInWithAppleButton|AppleIDProvider' "$IOS_DIR" --include='*.swift' 2>/dev/null; then
400
+ pass "4.8 Sign in with Apple — present alongside third-party login"
401
+ else
402
+ warn "4.8 Sign in with Apple — third-party social login detected (e.g. $(basename "$social_login")) but no Sign in with Apple found; Apple requires an equivalent option (4.8). Some account systems are exempt — verify."
403
+ fi
404
+ fi
405
+ fi
406
+
407
+ # ===================================================================
408
+ # §15 — 3.1.1(a) External purchase link (StoreKit External Purchase)
409
+ # ===================================================================
410
+ ext_purchase=""
411
+ grep -rqE 'ExternalPurchase|ExternalPurchaseLink|ExternalPurchaseCustomLink' "${IOS_DIR:-.}" --include='*.swift' 2>/dev/null && ext_purchase=1
412
+ grep -rqE 'external-purchase' "${IOS_DIR:-.}" --include='*.entitlements' 2>/dev/null && ext_purchase=1
413
+ if [[ -n "$ext_purchase" ]]; then
414
+ warn "3.1.1(a) External purchase link detected — ensure the External Purchase entitlement, eligible storefronts, the required disclosure sheet, and App Store Connect reporting are in place (3.1.1(a))."
415
+ fi
416
+
417
+ # ===================================================================
418
+ # §16 — 5.1.2 Tracking SDK / IDFA without ATT (the reverse of §3)
419
+ # ===================================================================
420
+ # §3 catches "ATT framework imported but no usage string". This catches the more
421
+ # common rejection: shipping an ad / attribution SDK (or touching the IDFA) without
422
+ # ever presenting the ATT prompt. `tracking_sdk` and `att_used` are computed in §3.
423
+ if [[ -n "$tracking_sdk" ]]; then
424
+ att_present=""
425
+ [[ -n "$att_used" ]] && att_present=1
426
+ grep -q "NSUserTrackingUsageDescription" "$INFO_PLIST" 2>/dev/null && att_present=1
427
+ if [[ -n "$att_present" ]]; then
428
+ pass "5.1.2 Tracking SDK — present alongside an ATT prompt"
429
+ else
430
+ warn "5.1.2 Tracking SDK — IDFA/tracking signals detected (e.g. $(basename "$tracking_sdk")) but no ATT prompt found; apps that track must request permission via AppTrackingTransparency (5.1.2)"
431
+ fi
432
+ fi
433
+
434
+ # ===================================================================
435
+ # §17 — Export-compliance key (ITSAppUsesNonExemptEncryption)
436
+ # ===================================================================
437
+ # Without this key, App Store Connect asks the encryption/export question on every
438
+ # submission. Setting it (true/false) removes that friction. Only checked when an
439
+ # Info.plist exists; a modern app may auto-generate it, in which case the key lives
440
+ # in build settings, so we stay silent rather than nag.
441
+ if [[ -f "$INFO_PLIST" ]]; then
442
+ if grep -q "ITSAppUsesNonExemptEncryption" "$INFO_PLIST" 2>/dev/null; then
443
+ pass "export-compliance — ITSAppUsesNonExemptEncryption set in Info.plist"
444
+ else
445
+ warn "export-compliance — Info.plist has no ITSAppUsesNonExemptEncryption; set it (true/false) to skip the App Store Connect encryption-export prompt every submission"
446
+ fi
447
+ fi
448
+
449
+ # ===================================================================
450
+ # §18 — Support / Privacy URL in fastlane metadata
451
+ # ===================================================================
452
+ # fastlane deliver stores the localized review URLs per locale as
453
+ # support_url.txt / privacy_url.txt / marketing_url.txt. Apple requires a working
454
+ # support URL, and a privacy policy URL for apps with accounts or IAP. Flag when
455
+ # they are absent across every locale, or contain an obvious placeholder.
456
+ if [[ -d "$META_DIR" ]]; then
457
+ support_found="" privacy_found=""
458
+ for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
459
+ [[ -s "$META_DIR/$loc/support_url.txt" ]] && support_found=1
460
+ [[ -s "$META_DIR/$loc/privacy_url.txt" ]] && privacy_found=1
461
+ done
462
+ if (( ${#LOCALES[@]} > 0 )); then
463
+ [[ -z "$support_found" ]] && warn "2.3 Support URL — no non-empty support_url.txt in any locale under fastlane metadata; Apple requires a working support URL"
464
+ [[ -z "$privacy_found" ]] && warn "2.3 Privacy URL — no non-empty privacy_url.txt in any locale under fastlane metadata; required for apps with accounts or in-app purchases"
465
+ fi
466
+ url_ph=$(grep -rEnI 'example\.com|localhost|\bTODO\b|changeme' "$META_DIR" --include='*_url.txt' 2>/dev/null | grep -v "^Binary file" | head -10)
467
+ if [[ -n "$url_ph" ]]; then
468
+ warn "2.3 Metadata URL — placeholder URL in fastlane metadata (replace before submitting):"
469
+ echo "$url_ph" | sed 's/^/ /'
470
+ fi
471
+ fi
472
+
473
+ # ===================================================================
474
+ # §19 — Analytics SDK present but PrivacyInfo declares no collected data
475
+ # ===================================================================
476
+ # Privacy-manifest completeness is a growing rejection area. If an analytics SDK is
477
+ # linked but PrivacyInfo.xcprivacy declares neither collected data types nor tracking
478
+ # domains, the privacy manifest and the App Privacy nutrition labels are probably
479
+ # incomplete. Soft "verify" wording (a crash-only Sentry may genuinely collect
480
+ # nothing), so WARN, never FAIL.
481
+ analytics_sdk=$(grep -rlE 'FirebaseAnalytics|import Firebase|Amplitude|Mixpanel|import Sentry|Segment|Bugsnag|AppCenterAnalytics|Datadog' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
482
+ if [[ -n "$analytics_sdk" ]]; then
483
+ declared_data=""
484
+ if [[ -n "$PRIVACY_FILE" && -f "$PRIVACY_FILE" ]]; then
485
+ if grep -qE 'NSPrivacyCollectedDataType</key>' "$PRIVACY_FILE" 2>/dev/null \
486
+ || grep -qE 'NSPrivacyTrackingDomains</key>[[:space:]]*<array>[[:space:]]*<string>' "$PRIVACY_FILE" 2>/dev/null; then
487
+ declared_data=1
488
+ fi
489
+ fi
490
+ if [[ -n "$declared_data" ]]; then
491
+ pass "5.1.1 Privacy manifest — analytics SDK present and PrivacyInfo declares collected data"
492
+ else
493
+ warn "5.1.1 Privacy manifest — analytics SDK detected (e.g. $(basename "$analytics_sdk")) but PrivacyInfo declares no collected data types or tracking domains; verify your privacy manifest and App Privacy nutrition labels"
494
+ fi
495
+ fi
496
+
497
+ # ===================================================================
498
+ # §20 — Placeholder / dummy content in store metadata
499
+ # ===================================================================
500
+ # Lorem ipsum, TODO/FIXME, example URLs, or "insert X here" left in the store copy
501
+ # get rejected under 2.1 and look unfinished. Conservative, specific patterns to
502
+ # avoid flagging legitimate words. Overlaps the Phase 2 precheck "No placeholder
503
+ # text" rule, but this one is local and runs before any network call.
504
+ if [[ -d "$META_DIR" ]]; then
505
+ ph_re='lorem ipsum|Lorem ipsum|\bTODO\b|\bFIXME\b|example\.com|placeholder|insert .* here|changeme'
506
+ ph_hits=$(grep -rEnI "$ph_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -20)
507
+ if [[ -n "$ph_hits" ]]; then
508
+ warn "2.1 Metadata content — placeholder/dummy text in store metadata (looks unfinished; rejected under 2.1):"
509
+ echo "$ph_hits" | sed 's/^/ /'
510
+ fi
511
+ fi
512
+
513
+ echo "---END-OF-SCAN---"
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ # appstore-precheck/scripts/verdict.sh
3
+ # Deterministic verdict from scan.sh output. Reads scan output on stdin (or a file
4
+ # arg), counts top-level FAIL:/WARN:/PASS: lines, and decides GREEN/YELLOW/RED plus
5
+ # the .precheck-pass token action — so the verdict is machine-testable, not just an
6
+ # agent judgement.
7
+ #
8
+ # GREEN 0 FAIL and <=2 WARN -> token: write
9
+ # YELLOW 0 FAIL and >=3 WARN -> token: hold (needs explicit human confirmation)
10
+ # RED >=1 FAIL -> token: remove
11
+ #
12
+ # Usage:
13
+ # bash scan.sh | bash verdict.sh # compute only, print summary
14
+ # bash verdict.sh scan-output.txt # read from a file instead of stdin
15
+ # bash scan.sh | bash verdict.sh --apply # also write/remove the token
16
+ #
17
+ # Output (stdout), stable and grep-friendly:
18
+ # VERDICT: GREEN|YELLOW|RED
19
+ # COUNTS: fail=<n> warn=<n> pass=<n>
20
+ # TOKEN: write|hold|remove
21
+ # Exit code: 0 GREEN, 1 RED, 2 YELLOW (so callers can branch without parsing).
22
+
23
+ set -u
24
+
25
+ APPLY="false"
26
+ SRC=""
27
+ for arg in "$@"; do
28
+ case "$arg" in
29
+ --apply) APPLY="true" ;;
30
+ -*) echo "verdict.sh: unknown option '$arg'" >&2; exit 64 ;;
31
+ *) SRC="$arg" ;;
32
+ esac
33
+ done
34
+
35
+ # Read the scan output from the file arg if given, else from stdin.
36
+ if [[ -n "$SRC" ]]; then
37
+ [[ -f "$SRC" ]] || { echo "verdict.sh: no such file '$SRC'" >&2; exit 66; }
38
+ input="$(cat "$SRC")"
39
+ else
40
+ input="$(cat)"
41
+ fi
42
+
43
+ # Count only TOP-LEVEL verdict lines (anchored at column 0). scan.sh indents the
44
+ # evidence under a FAIL: header, so anchoring avoids double-counting those.
45
+ count() { printf '%s\n' "$input" | grep -cE "$1"; }
46
+ fails=$(count '^FAIL:')
47
+ warns=$(count '^WARN:')
48
+ passes=$(count '^PASS:')
49
+
50
+ if (( fails >= 1 )); then
51
+ verdict="RED"; token="remove"; code=1
52
+ elif (( warns >= 3 )); then
53
+ verdict="YELLOW"; token="hold"; code=2
54
+ else
55
+ verdict="GREEN"; token="write"; code=0
56
+ fi
57
+
58
+ echo "VERDICT: $verdict"
59
+ echo "COUNTS: fail=$fails warn=$warns pass=$passes"
60
+ echo "TOKEN: $token"
61
+
62
+ # Side effects only with --apply. The token lives at the repo root; the guard hook
63
+ # tests it with an mmin -60 filter. YELLOW deliberately leaves the token untouched —
64
+ # writing it is a human-confirmation step the agent performs, not this script.
65
+ if [[ "$APPLY" == "true" ]]; then
66
+ ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT="$(pwd)"
67
+ TOKEN_FILE="$ROOT/.precheck-pass"
68
+ case "$token" in
69
+ write) date +%s > "$TOKEN_FILE" && echo "APPLIED: token written ($TOKEN_FILE)" ;;
70
+ remove) rm -f "$TOKEN_FILE" && echo "APPLIED: token removed" ;;
71
+ hold) echo "APPLIED: token held (YELLOW needs explicit confirmation)" ;;
72
+ esac
73
+ fi
74
+
75
+ exit "$code"