appstore-precheck 1.12.0 → 1.12.1

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,61 @@ All notable changes to this project are documented here. Versioning follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.12.1] - 2026-07-08
9
+
10
+ Quality patch from a full fresh-eyes review (three independent review passes over the
11
+ scanner core, the tooling shell, and the docs). No new checks; every change either removes
12
+ a false-RED path, fixes a real bug, or hardens the release surface.
13
+
14
+ ### Fixed (scanner accuracy — false-RED cluster)
15
+ - **3.1.2 Restore Purchases** no longer misses StoreKit 2 paywalls: the match is
16
+ case-insensitive and recognizes `AppStore.sync()` (a capitalized `Button("Restore
17
+ Purchases")` used to force a false RED).
18
+ - **3.1.2 Terms/Privacy links** match human-readable labels ("Terms of Use", "Privacy
19
+ Policy") and nonstandard URL paths (`/legal/tos`, `/eula`, `datenschutz`, `gizlilik`),
20
+ case-insensitively.
21
+ - **Remote-configured paywalls** (RevenueCatUI `PaywallView`, `presentPaywall`,
22
+ `paywallFooter`, AdaptyUI): missing Restore/Terms/Privacy in app source now downgrades
23
+ to a verify-in-dashboard WARN instead of a hard FAIL — the SDK renders those controls
24
+ from the vendor dashboard.
25
+ - **5.1.1 FileTimestamp Required Reason** check is anchored to real filesystem APIs;
26
+ plain `creationDate` / `modificationDate` model properties no longer false-FAIL.
27
+
28
+ ### Fixed (coverage and correctness)
29
+ - **Objective-C blind spot closed**: all code-level greps now share one include-set
30
+ (`*.swift`, `*.m`, `*.mm`, `*.h`). Camera/mic/photo purpose-string FAILs, ATT/tracking
31
+ signals, SIWA, analytics and the other code checks now fire on ObjC sources too.
32
+ - **`--dir` is authoritative**: `scan.sh --dir <path>` (new flag) scans exactly that
33
+ directory instead of snapping to the enclosing git toplevel; the npx CLI passes it
34
+ through and the GitHub Action uses it, fixing monorepo-subdir scans and SARIF paths
35
+ (SARIF upload now also sets `checkout_path`).
36
+ - **Rule 42 suppressible**: `.precheck-ignore` accepted only rules 1–41; the bound is now
37
+ derived from the catalog, so `screenshot-dimensions` (and any future rule) can be
38
+ suppressed.
39
+ - **Verdict thresholds deduplicated** into `scripts/thresholds.sh`, shared by `verdict.sh`
40
+ and the JSON renderer so the two can never diverge.
41
+ - **CLI**: a signal-killed scanner now exits 70 instead of masquerading as success.
42
+
43
+ ### Added
44
+ - Fixtures + assertions for every fix above (`storekit2-paywall-app`,
45
+ `revenuecat-paywall-app`, `objc-camera-app`, monorepo `--dir` case, rule-42 suppression),
46
+ all labelled in the synthetic scorecard corpus.
47
+ - **`tests/test-pack.sh`**: packs the real npm tarball and runs the CLI from the extracted
48
+ layout — a `files`-array regression can no longer ship silently.
49
+
50
+ ### Changed (docs and CI hardening)
51
+ - README: CI example pins `@v1` (was stale `@v1.5.0`), "How it works" gains the opt-in
52
+ Phase 6 row and the screenshot-vision mention, new Troubleshooting section, Windows/exit-code
53
+ notes; coverage table reflects ObjC support.
54
+ - SKILL.md: Phase 6's Maestro MCP tools added to `allowed-tools`; Phase 2 gains an explicit
55
+ skip-without-credentials instruction; scanner paths phrased skill-relative; the Phase 4
56
+ summary separates the "+5 vision checks" from the "of 28" count; upload-guard hook note
57
+ scoped to plugin installs. `simulator-dynamic-review.md` now names the real Maestro MCP
58
+ tools (`list_devices`, `run`, `inspect_screen`, `take_screenshot`).
59
+ - Workflows: least-privilege `permissions:` blocks everywhere; all third-party actions
60
+ pinned to commit SHAs (also in `action.yml`).
61
+ - MAINTENANCE.md: stale "41 vectors" corrected; docs index expanded.
62
+
8
63
  ## [1.12.0] - 2026-07-02
9
64
 
10
65
  Roadmap #5 (final roadmap item): an **optional, opt-in local dynamic simulator tier** —
package/README.md CHANGED
@@ -142,13 +142,14 @@ static scan already flagged a guideline, the deep check adds semantic context th
142
142
  ### Supported app types
143
143
 
144
144
  Every check that reads store metadata, the privacy manifest, screenshots, or the export-compliance
145
- key works on **any iOS app**. The code-level checks read Swift source, so their coverage depends on
145
+ key works on **any iOS app**. The code-level checks read Swift and Objective-C(++) source
146
+ (`.swift`, `.m`, `.mm`, `.h`), so their coverage depends on
146
147
  how the app is built:
147
148
 
148
149
  | App type | Coverage |
149
150
  |----------|----------|
150
151
  | 🟢 **Native Swift / SwiftUI** | **Full.** All 42 vectors apply. |
151
- | 🟡 **React Native / Flutter** | Metadata, privacy manifest, screenshots, and export compliance apply in full. The Swift-source checks (ATT, paywall links, private API, SDK detection, navigation) **under-detect rather than misfire**: that logic lives in JS/Dart, so they stay quiet instead of blocking. |
152
+ | 🟡 **React Native / Flutter** | Metadata, privacy manifest, screenshots, and export compliance apply in full. The native-source checks (ATT, paywall links, private API, SDK detection, navigation) **under-detect rather than misfire**: that logic lives in JS/Dart, so they stay quiet instead of blocking. |
152
153
 
153
154
  ## Quick start
154
155
 
@@ -237,6 +238,26 @@ npx appstore-precheck # scans the current directory, prints the verdi
237
238
  npx appstore-precheck --fail-on YELLOW
238
239
  ```
239
240
 
241
+ **Homebrew** — same CLI, installed on PATH:
242
+
243
+ ```bash
244
+ brew install berkayturk/tap/appstore-precheck
245
+ appstore-precheck --dir path/to/your-ios-app
246
+ ```
247
+
248
+ **fastlane** — gate a lane on the verdict with the
249
+ [`appstore_precheck` plugin](https://github.com/berkayturk/fastlane-plugin-appstore_precheck)
250
+ ([RubyGems](https://rubygems.org/gems/fastlane-plugin-appstore_precheck)):
251
+
252
+ ```ruby
253
+ # fastlane add_plugin appstore_precheck
254
+ lane :beta do
255
+ appstore_precheck # fails the lane on a RED verdict
256
+ build_app
257
+ upload_to_testflight
258
+ end
259
+ ```
260
+
240
261
  **Standalone Bash** — same static scan, run by hand or in CI:
241
262
 
242
263
  ```bash
@@ -255,7 +276,7 @@ Drop the static scan into a workflow with the bundled composite action. It fails
255
276
  job on a RED verdict; set `fail-on: YELLOW` to be stricter:
256
277
 
257
278
  ```yaml
258
- - uses: berkayturk/appstore-precheck@v1.5.0
279
+ - uses: berkayturk/appstore-precheck@v1
259
280
  with:
260
281
  working-directory: . # optional, default: . (repo root)
261
282
  fail-on: RED # optional, default: RED (RED | YELLOW)
@@ -295,8 +316,9 @@ nothing is auto-fixed.
295
316
  | **1** | **Static scan**: `scan.sh` over the 42 vectors above. |
296
317
  | **2** | **`fastlane precheck`**: Apple's own metadata rule engine. |
297
318
  | **3** | **Pierre commentary**: explains **every** FAIL and WARN from Phases 0–2 in 2–3 sentences each. |
298
- | **4** | **Pierre deep review**: 28 semantic checks (22 Tier A + 6 Tier B v1 heuristic). Advisory only. |
319
+ | **4** | **Pierre deep review**: 28 semantic checks (22 Tier A + 6 Tier B v1 heuristic), plus 5 screenshot-vision checks when screenshots are present. Advisory only. |
299
320
  | **5** | **Verdict**: GREEN / YELLOW / RED from Phases 0–2 counts, plus `.precheck-pass` token the upload guard gates on. |
321
+ | **6** | *(opt-in, agent mode)* **Local dynamic simulator tier**: launch/paywall/permission smoke checks on a local simulator via Maestro + `xcrun simctl`. Advisory; never changes the verdict. |
300
322
 
301
323
  ## Demo
302
324
 
@@ -350,6 +372,19 @@ and [Phase 2 `fastlane precheck`](examples/fastlane-precheck.md) results.
350
372
  | 🟡 **YELLOW** | 0 FAIL, 5+ WARN | not written | blocked; needs confirmation |
351
373
  | 🔴 **RED** | ≥1 FAIL | removed | blocked; shows the FAIL list |
352
374
 
375
+ ### Badge — Prechecked by Pierre
376
+
377
+ Shipped a GREEN? Put Pierre in your README:
378
+
379
+ [![Prechecked by Pierre](https://img.shields.io/badge/prechecked_by-Pierre_🟢-2E7D32?labelColor=24292e)](https://github.com/berkayturk/appstore-precheck)
380
+
381
+ ```markdown
382
+ [![Prechecked by Pierre](https://img.shields.io/badge/prechecked_by-Pierre_🟢-2E7D32?labelColor=24292e)](https://github.com/berkayturk/appstore-precheck)
383
+ ```
384
+
385
+ The badge is self-declared (the scan is local and read-only, so there is nothing to verify
386
+ server-side) — it says "this project runs `appstore-precheck` before submitting", nothing more.
387
+
353
388
  ## Measured accuracy
354
389
 
355
390
  [`docs/scorecard.md`](docs/scorecard.md) is generated by `scripts/scorecard.sh` and tracks two
@@ -431,6 +466,8 @@ App Store apps (DuckDuckGo, Pocket Casts, Wikipedia).
431
466
  `bash`, `git`, `grep`, `find` are all the static scan, `npx appstore-precheck`, and the GitHub
432
467
  Action need, and they run with **zero credentials and no network**. `jq` (config + String Catalog
433
468
  checks) and `python3` (exact Unicode length counts) are optional and sharpen a few checks.
469
+ On Windows, run the CLI under WSL or Git Bash. CLI exit codes: `0` ok, `1` verdict at or past
470
+ `--fail-on`, `64` bad usage, `70` environment error (`bash` or the bundled scanner missing).
434
471
 
435
472
  Only the **optional Phase 2** (`fastlane precheck`) needs `fastlane` and an
436
473
  [App Store Connect API key](https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api).
@@ -439,6 +476,26 @@ Everything else works without one.
439
476
  **Secrets**: the ASC API key is read from your environment at runtime and deleted immediately after
440
477
  `fastlane precheck`. Never commit it; `.gitignore` blocks `*asc-key*.json` and `.env`.
441
478
 
479
+ ## Troubleshooting
480
+
481
+ - **Wrong source directory detected** (`PASS: layout — ios='…'` names the wrong folder): set
482
+ `.iosSourceDir` in `.appstore-precheck.json` (copy
483
+ [`config.example.json`](skills/appstore-precheck/config.example.json)). In a monorepo, pass
484
+ `--dir path/to/app` — an explicit `--dir` is authoritative and never snaps to the git root.
485
+ - **Unexpected YELLOW**: YELLOW means 5+ WARNs, no FAILs. Advisory checks (background modes,
486
+ tracking SDKs, screenshot sizes) can pile up on legitimate apps — review each WARN, then
487
+ suppress the accepted ones via [`.precheck-ignore`](#suppressing-findings-precheck-ignore)
488
+ so real signals stay visible.
489
+ - **Paywall FAILs on a remote-configured paywall** (RevenueCat dashboard paywalls and similar):
490
+ the scanner downgrades these to a verify-in-dashboard WARN when it sees `RevenueCatUI` /
491
+ `presentPaywall`. If your setup isn't detected, check the dashboard paywall manually and
492
+ suppress with `.precheck-ignore`.
493
+ - **`fastlane precheck` auth errors** (Phase 2): confirm the three `ASC_KEY_*` env vars are set
494
+ and the API key has App Manager access. Phase 2 is optional — skipping it only skips Apple's
495
+ metadata linter, the static verdict is unaffected.
496
+ - **`jq` or `python3` missing**: the scan still runs; config-file overrides, String Catalog
497
+ checks, and exact Unicode length counts degrade gracefully (noted in the output).
498
+
442
499
  ## Uninstall
443
500
 
444
501
  ```bash
package/bin/cli.js CHANGED
@@ -91,7 +91,11 @@ function main() {
91
91
  fail(`not a directory: ${opts.dir}`, 64);
92
92
  }
93
93
 
94
- const scanArgs = opts.format === 'text' ? [SCAN] : [SCAN, '--format', opts.format];
94
+ // --dir is passed through explicitly: scan.sh treats it as authoritative,
95
+ // so a monorepo subdirectory is scanned as requested instead of snapping to
96
+ // the enclosing git toplevel.
97
+ const scanArgs = [SCAN, '--dir', opts.dir];
98
+ if (opts.format !== 'text') scanArgs.push('--format', opts.format);
95
99
  const scan = spawnSync('bash', scanArgs, {
96
100
  cwd: opts.dir,
97
101
  encoding: 'utf8',
@@ -101,6 +105,7 @@ function main() {
101
105
  fail('bash is required to run the scanner (install bash, or use WSL / Git Bash on Windows)', 70);
102
106
  }
103
107
  if (scan.error) fail(`failed to run the scanner: ${scan.error.message}`, 70);
108
+ if (scan.signal) fail(`scanner was killed by signal ${scan.signal}`, 70);
104
109
 
105
110
  const scanOut = scan.stdout || '';
106
111
  process.stdout.write(scanOut);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appstore-precheck",
3
- "version": "1.12.0",
3
+ "version": "1.12.1",
4
4
  "description": "Read-only iOS App Store pre-submission check, packaged as a portable Agent Skill with native Claude Code, Cursor, and Codex plugins, plus an npx CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Berkay Turk (https://github.com/berkayturk)",
@@ -25,7 +25,7 @@
25
25
  "scripts": {
26
26
  "test": "bash tests/all.sh",
27
27
  "check-versions": "bash scripts/check-versions.sh",
28
- "lint": "node --check bin/cli.js && bash -n skills/appstore-precheck/scripts/scan.sh && bash -n skills/appstore-precheck/scripts/verdict.sh && bash -n skills/appstore-precheck/scripts/phase2-precheck.sh && bash -n skills/appstore-precheck/scripts/findings.sh && bash -n skills/appstore-precheck/scripts/suppress.sh && bash -n hooks/fastlane-guard.sh && bash -n install.sh && bash -n tests/run.sh && bash -n tests/all.sh && bash -n tests/test-verdict.sh && bash -n tests/test-guard.sh && bash -n tests/test-config.sh && bash -n tests/test-install.sh && bash -n tests/test-phase2.sh && bash -n tests/test-cli.sh && bash -n tests/test-findings.sh && bash -n tests/test-format-json.sh && bash -n tests/test-suppress.sh && bash -n tests/test-scorecard.sh && bash -n scripts/scorecard.sh && bash -n scripts/scorecard-real.sh"
28
+ "lint": "node --check bin/cli.js && bash -n skills/appstore-precheck/scripts/scan.sh && bash -n skills/appstore-precheck/scripts/verdict.sh && bash -n skills/appstore-precheck/scripts/phase2-precheck.sh && bash -n skills/appstore-precheck/scripts/findings.sh && bash -n skills/appstore-precheck/scripts/suppress.sh && bash -n hooks/fastlane-guard.sh && bash -n install.sh && bash -n tests/run.sh && bash -n tests/all.sh && bash -n tests/test-verdict.sh && bash -n tests/test-guard.sh && bash -n tests/test-config.sh && bash -n tests/test-install.sh && bash -n tests/test-phase2.sh && bash -n tests/test-cli.sh && bash -n tests/test-findings.sh && bash -n tests/test-format-json.sh && bash -n tests/test-suppress.sh && bash -n tests/test-scorecard.sh && bash -n scripts/scorecard.sh && bash -n scripts/scorecard-real.sh && bash -n scripts/update-brew-formula.sh && bash -n tests/test-pack.sh"
29
29
  },
30
30
  "keywords": ["ios", "app-store", "fastlane", "agent-skill", "claude-code", "precheck"]
31
31
  }
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: appstore-precheck
3
- description: Read-only pre-submission check for an iOS app before App Store review. Scans Swift code, fastlane metadata, screenshots, PrivacyInfo.xcprivacy, and the paywall for 42 rejection vectors, wraps Apple's official `fastlane precheck`, watches for live App Store Review Guideline drift, has Pierre explain every FAIL and WARN, then runs 22 semantic deep-review checks (Tier A) plus 6 heuristic checks (Tier B v1) — 28 total. Emits a GREEN/YELLOW/RED verdict and a `.precheck-pass` token an upload guard can gate on. Use when preparing an iOS App Store submission (before Archive, before "Submit for Review", before TestFlight, or before any `fastlane deliver/pilot/release`), or when the user mentions App Store rejection, app review, or fastlane upload.
3
+ description: Read-only pre-submission check for an iOS app before App Store review. Scans Swift and Objective-C code, fastlane metadata, screenshots, PrivacyInfo.xcprivacy, and the paywall for 42 rejection vectors, wraps Apple's official `fastlane precheck`, watches for live App Store Review Guideline drift, has Pierre explain every FAIL and WARN, then runs 22 semantic deep-review checks (Tier A) plus 6 heuristic checks (Tier B v1) — 28 total. Emits a GREEN/YELLOW/RED verdict and a `.precheck-pass` token an upload guard can gate on. Use when preparing an iOS App Store submission (before Archive, before "Submit for Review", before TestFlight, or before any `fastlane deliver/pilot/release`), or when the user mentions App Store rejection, app review, or fastlane upload.
4
4
  license: MIT
5
5
  metadata:
6
6
  author: Berkay Turk
7
- version: 1.12.0
8
- allowed-tools: Bash Read Grep Glob WebFetch
7
+ version: 1.12.1
8
+ allowed-tools: Bash Read Grep Glob WebFetch mcp__maestro__list_devices mcp__maestro__run mcp__maestro__inspect_screen mcp__maestro__take_screenshot mcp__maestro__cheat_sheet
9
9
  ---
10
10
 
11
11
  # App Store Precheck
@@ -112,7 +112,13 @@ check(s). It is non-blocking and never runs in the offline user scan; the finger
112
112
 
113
113
  ### Phase 1: Static scan
114
114
 
115
+ Run the bundled scanner from the app repo root. `scripts/` here is relative to THIS skill's own
116
+ directory (wherever the skill is installed — a plugin dir, `.claude/skills/`, `.agents/skills/`,
117
+ or a clone of the source repo):
118
+
115
119
  ```bash
120
+ bash <skill-dir>/scripts/scan.sh
121
+ # from a clone of the source repo, that is:
116
122
  bash skills/appstore-precheck/scripts/scan.sh
117
123
  ```
118
124
 
@@ -151,7 +157,9 @@ output is unchanged.
151
157
  ### Phase 2: Apple's official `fastlane precheck`
152
158
 
153
159
  Requires `bundleId` in config (or pass `app_identifier` directly) and App Store Connect API
154
- credentials. **Never commit the key.** The bundled wrapper builds the ASC API key JSON from your
160
+ credentials. **This phase is optional: if no ASC credentials are available, skip it, note the
161
+ skip in the report, and continue to Phase 3 — do not stall waiting for credentials.**
162
+ **Never commit the key.** The bundled wrapper builds the ASC API key JSON from your
155
163
  environment, runs precheck, and deletes the key on exit (use `--dry-run` to preview the command
156
164
  with no credentials and no network):
157
165
 
@@ -305,6 +313,8 @@ narrative; verdict.sh just pins the threshold arithmetic. `REVIEW-FINDING` lines
305
313
  3. Present **Phase 3 commentary** — Pierre's 2–3 sentence explanation for every FAIL and WARN.
306
314
  4. Present **Phase 4 deep review** — summary count (`REVIEW-FINDING` vs `REVIEW-PASS` of 28), then
307
315
  every `REVIEW-FINDING` with Pierre explanation; list `REVIEW-PASS` lines compactly or omit if all 28 passed.
316
+ The 5 screenshot-vision checks (S1–S5) emit the same `REVIEW-*` prefixes but count as a
317
+ **separate "+5 vision checks" sub-block** in the summary, never inside the "of 28" denominator.
308
318
  5. Present the **machine-faithful** scan output: each `FAIL:`/`WARN:` line verbatim, then for each
309
319
  FAIL a `file:line` reference and a suggested fix (one line each, surgical, not paraphrased).
310
320
  6. State the verdict and token action (example one-liners — each goes in its own language block, not inline):
@@ -360,5 +370,7 @@ Follow [`references/simulator-dynamic-review.md`](references/simulator-dynamic-r
360
370
  ## Optional: upload guard hook
361
371
 
362
372
  `hooks/fastlane-guard.sh` blocks `fastlane deliver/pilot/release` unless a fresh `.precheck-pass`
363
- token exists. In Claude Code it auto-wires via `hooks/hooks.json`. In other environments, wire it
373
+ token exists. In Claude Code it auto-wires via `hooks/hooks.json` **when installed as a plugin**
374
+ (the hook path uses `${CLAUDE_PLUGIN_ROOT}`; an `install.sh`-vendored copy gets no automatic
375
+ hook). In other environments, wire it
364
376
  as a pre-command check yourself, or treat the token as a manual go/no-go signal.
@@ -13,6 +13,7 @@ whole file to run the skill.
13
13
  - [Verdict thresholds](#verdict-thresholds)
14
14
  - [SARIF output](#sarif-output---format-sarif)
15
15
  - [Real App Store outcomes](#real-app-store-outcomes-corpusoutcomes)
16
+ - [Optional local dynamic simulator tier](#optional-local-dynamic-simulator-tier)
16
17
  - [Pre-submit manual checklist](#pre-submit-manual-checklist)
17
18
 
18
19
  ---
@@ -149,7 +149,7 @@ REVIEW-PASS: <guideline> — not applicable (<reason>)
149
149
  3. Flag screenshots showing features absent from the build or metadata.
150
150
  4. For the full structured screenshot vision review (placeholder/empty-state, text overflow,
151
151
  wrong device frame, misleading marketing, metadata mismatch), follow
152
- `references/screenshot-vision-review.md`.
152
+ `screenshot-vision-review.md` (sibling file in this same `references/` directory).
153
153
 
154
154
  ### 9 — 2.3.6 Pricing language
155
155
 
@@ -271,6 +271,6 @@ After Phase 4, include in the final report:
271
271
 
272
272
  1. Trilingual verdict block (from scan counts only).
273
273
  2. Phase 3 commentary (every scan FAIL/WARN).
274
- 3. Phase 4 summary table: 28 checks → count of `REVIEW-FINDING` vs `REVIEW-PASS` (note Tier B items 4, 5, 7, 10, 15, 28 if any fired).
274
+ 3. Phase 4 summary table: 28 checks → count of `REVIEW-FINDING` vs `REVIEW-PASS` (note Tier B items 4, 5, 7, 10, 15, 28 if any fired). The 5 screenshot-vision checks (S1–S5) report as a separate "+5 vision checks" sub-block, outside the "of 28" count.
275
275
  4. Phase 4 detail: every `REVIEW-FINDING` with Pierre explanation; optionally list `REVIEW-PASS` lines compactly.
276
276
  5. Verbatim Phase 1 scan output + verdict/token action.
@@ -49,17 +49,27 @@ reporter, or real-device QA.
49
49
 
50
50
  ## Per-check procedure
51
51
 
52
+ The Maestro MCP tools are: `mcp__maestro__list_devices` (pick a booted simulator's `device_id`),
53
+ `mcp__maestro__run` (execute a declarative YAML flow — `launchApp`, `tapOn`, `assertVisible`, …),
54
+ `mcp__maestro__inspect_screen` (view hierarchy), `mcp__maestro__take_screenshot`, and
55
+ `mcp__maestro__cheat_sheet` (YAML command reference). Every local tool needs a `device_id` from
56
+ `list_devices` first. `xcrun simctl` is the fallback when Maestro is unavailable.
57
+
52
58
  ### D1 — Launch without crash
53
- 1. `mcp__maestro__start_device` (or `xcrun simctl boot`) a disposable simulator; `launch_app` the supplied app/bundle id.
59
+ 1. `mcp__maestro__list_devices` for a booted disposable simulator (boot one with
60
+ `xcrun simctl boot` if needed); then `mcp__maestro__run` a flow that declares the `appId` and
61
+ starts with `launchApp` (fallback: `xcrun simctl launch`).
54
62
  2. Observe for a short window; if the app process disappears or a crash alert shows, `DYNAMIC-FINDING`.
55
- 3. `take_screenshot` at launch as evidence.
63
+ 3. `mcp__maestro__take_screenshot` at launch as evidence.
56
64
 
57
65
  ### D2 — Core screen reachable
58
- 1. After launch, inspect the first real screen (`inspect_view_hierarchy` / `take_screenshot`).
66
+ 1. After launch, inspect the first real screen (`mcp__maestro__inspect_screen` /
67
+ `mcp__maestro__take_screenshot`).
59
68
  2. Flag if stuck on a splash, blank, or an error/"something went wrong" state.
60
69
 
61
70
  ### D3 — Paywall renders
62
- 1. If a paywall exists (per the static scan / app structure), navigate to it (`tap_on` / a flow).
71
+ 1. If a paywall exists (per the static scan / app structure), navigate to it with a
72
+ `mcp__maestro__run` flow (`tapOn` steps; check `mcp__maestro__cheat_sheet` for selector syntax).
63
73
  2. Confirm price + trial/auto-renew/cancel terms are visibly rendered; screenshot. Not applicable if no paywall.
64
74
 
65
75
  ### D4 — Permission prompt vs purpose string
@@ -72,15 +72,19 @@ _record_suppressed() {
72
72
  # (RED >=1 FAIL; YELLOW >=5 WARN; else GREEN), counting non-suppressed findings only.
73
73
  render_json() {
74
74
  local buf="${FINDINGS_TMP:-/dev/null}"
75
+ # Thresholds live in thresholds.sh (shared with verdict.sh).
76
+ # shellcheck source=thresholds.sh
77
+ . "$(dirname "${BASH_SOURCE[0]}")/thresholds.sh"
75
78
  [[ -s "$buf" ]] || { printf '%s\n' '{"findings":[]}' | jq \
76
79
  --arg v "$PRECHECK_VERSION" '{tool:"appstore-precheck",version:$v,verdict:"GREEN",summary:{fail:0,warn:0,pass:0,suppressed:0},findings:[]}'; return 0; }
77
- jq -s --arg v "$PRECHECK_VERSION" '
80
+ jq -s --arg v "$PRECHECK_VERSION" \
81
+ --argjson fmin "$RED_FAIL_MIN" --argjson wmin "$YELLOW_WARN_MIN" '
78
82
  (map(select(.suppressed==false))) as $live
79
83
  | ($live|map(select(.severity=="FAIL"))|length) as $f
80
84
  | ($live|map(select(.severity=="WARN"))|length) as $w
81
85
  | ($live|map(select(.severity=="PASS"))|length) as $p
82
86
  | (map(select(.suppressed==true))|length) as $s
83
- | (if $f>=1 then "RED" elif $w>=5 then "YELLOW" else "GREEN" end) as $verdict
87
+ | (if $f>=$fmin then "RED" elif $w>=$wmin then "YELLOW" else "GREEN" end) as $verdict
84
88
  | {tool:"appstore-precheck", version:$v, verdict:$verdict,
85
89
  summary:{fail:$f, warn:$w, pass:$p, suppressed:$s},
86
90
  findings: .}' "$buf"
@@ -9,34 +9,49 @@
9
9
 
10
10
  set -u
11
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; }
12
+ # Resolve the script dir to an absolute path BEFORE any cd, so the sibling
13
+ # sources below cannot break when an explicit --dir moves us elsewhere.
14
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
15
 
15
- source "$(dirname "${BASH_SOURCE[0]}")/findings.sh"
16
- source "$(dirname "${BASH_SOURCE[0]}")/suppress.sh"
17
- source "$(dirname "${BASH_SOURCE[0]}")/project-model.sh"
18
- source "$(dirname "${BASH_SOURCE[0]}")/image-dims.sh"
19
- source "$(dirname "${BASH_SOURCE[0]}")/sarif.sh"
20
- FINDINGS_TMP="$(mktemp)"; export FINDINGS_TMP
21
- trap 'rm -f "$FINDINGS_TMP"' EXIT
22
16
  FORMAT="text"
23
- # The envelope `version` is the appstore-precheck TOOL's own version (from this
24
- # skill's SKILL.md), never the scanned repo's — $ROOT above is the SCANNED app's
25
- # git root, so reading its package.json here would leak the wrong version.
26
- PRECHECK_VERSION="$(grep -m1 -E '^[[:space:]]*version:' "$(dirname "${BASH_SOURCE[0]}")/../SKILL.md" 2>/dev/null | sed -E 's/.*version:[[:space:]]*//; s/[[:space:]]*$//')"
27
- [[ -z "$PRECHECK_VERSION" ]] && PRECHECK_VERSION="dev"
28
-
17
+ SCAN_DIR=""
29
18
  while [[ $# -gt 0 ]]; do
30
19
  case "$1" in
31
20
  --format)
32
21
  if [[ $# -lt 2 ]]; then echo "scan.sh: --format needs a value (text|json|sarif)" >&2; exit 64; fi
33
22
  FORMAT="$2"; shift 2 ;;
34
23
  --format=*) FORMAT="${1#*=}"; shift ;;
24
+ --dir)
25
+ if [[ $# -lt 2 ]]; then echo "scan.sh: --dir needs a path" >&2; exit 64; fi
26
+ SCAN_DIR="$2"; shift 2 ;;
27
+ --dir=*) SCAN_DIR="${1#*=}"; shift ;;
35
28
  *) shift ;;
36
29
  esac
37
30
  done
38
31
  [[ "$FORMAT" == json || "$FORMAT" == text || "$FORMAT" == sarif ]] || { echo "scan.sh: --format must be text|json|sarif" >&2; exit 64; }
39
32
 
33
+ # An explicit --dir is authoritative: scan exactly that directory. Without it,
34
+ # snap to the enclosing git toplevel (monorepo subdirs need --dir to opt out).
35
+ if [[ -n "$SCAN_DIR" ]]; then
36
+ ROOT="$(cd "$SCAN_DIR" 2>/dev/null && pwd)" || { echo "scan.sh: --dir is not a directory: $SCAN_DIR" >&2; exit 64; }
37
+ else
38
+ ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT="$(pwd)"
39
+ fi
40
+ cd "$ROOT" || { echo "FAIL: repo-root — could not enter repository root"; exit 0; }
41
+
42
+ source "$SCRIPT_DIR/findings.sh"
43
+ source "$SCRIPT_DIR/suppress.sh"
44
+ source "$SCRIPT_DIR/project-model.sh"
45
+ source "$SCRIPT_DIR/image-dims.sh"
46
+ source "$SCRIPT_DIR/sarif.sh"
47
+ FINDINGS_TMP="$(mktemp)"; export FINDINGS_TMP
48
+ trap 'rm -f "$FINDINGS_TMP"' EXIT
49
+ # The envelope `version` is the appstore-precheck TOOL's own version (from this
50
+ # skill's SKILL.md), never the scanned repo's — $ROOT above is the SCANNED app's
51
+ # git root, so reading its package.json here would leak the wrong version.
52
+ PRECHECK_VERSION="$(grep -m1 -E '^[[:space:]]*version:' "$SCRIPT_DIR/../SKILL.md" 2>/dev/null | sed -E 's/.*version:[[:space:]]*//; s/[[:space:]]*$//')"
53
+ [[ -z "$PRECHECK_VERSION" ]] && PRECHECK_VERSION="dev"
54
+
40
55
  CONFIG="${APPSTORE_PRECHECK_CONFIG:-.appstore-precheck.json}"
41
56
  have_jq() { command -v jq >/dev/null 2>&1; }
42
57
 
@@ -84,6 +99,11 @@ GREP_PRUNE=( --exclude-dir=.git --exclude-dir=Pods --exclude-dir=Carthage
84
99
  --exclude-dir=.claude --exclude-dir=worktrees
85
100
  --exclude-dir=node_modules --exclude-dir=vendor )
86
101
 
102
+ # One shared source include-set: Swift plus Objective-C(++). Every code-level
103
+ # grep uses this set — scanning *.swift only silently passes real rejection
104
+ # vectors (missing purpose strings, tracking SDKs) living in .m/.h files.
105
+ SRC_INC=( --include='*.swift' --include='*.m' --include='*.mm' --include='*.h' )
106
+
87
107
  # Load .precheck-ignore path exclusions (Task 2 suppress.sh) and extend both
88
108
  # prune sets so a suppressed path is skipped by detection AND grep passes.
89
109
  load_precheck_ignore "$ROOT"
@@ -217,7 +237,7 @@ echo "PASS: layout — ios='${IOS_DIR:-?}' metadata='${META_DIR:-?}' xcstrings='
217
237
  set_rule "privacy-manifest-parity"
218
238
  check_required_reason_api() {
219
239
  local cat="$1" pattern="$2" hits declared
220
- hits=$(grep -rEl "$pattern" "$IOS_DIR" --include="*.swift" 2>/dev/null | head -3)
240
+ hits=$(grep -rEl "$pattern" "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -3)
221
241
  declared=$(grep -c "NSPrivacyAccessedAPICategory${cat}" "$PRIVACY_FILE" 2>/dev/null)
222
242
  if [[ -n "$hits" && "${declared:-0}" -eq 0 ]]; then
223
243
  fail "5.1.1 Required Reason API — '$cat' used in code (e.g. $(echo "$hits" | head -1)) but not declared in PrivacyInfo.xcprivacy" "$PRIVACY_FILE"
@@ -233,7 +253,9 @@ elif [[ -z "$PRIVACY_FILE" ]]; then
233
253
  fail "5.1.1 Required Reason API — PrivacyInfo.xcprivacy not found (required since May 2024 for apps using Required Reason APIs)" "$INFO_PLIST"
234
254
  else
235
255
  check_required_reason_api "UserDefaults" 'UserDefaults|@AppStorage'
236
- check_required_reason_api "FileTimestamp" 'attributesOfItem|creationDate|modificationDate|\.fileCreationDate|\.fileModificationDate'
256
+ # Anchored to real filesystem APIs: bare `creationDate` / `modificationDate`
257
+ # are ubiquitous model/Core-Data property names and false-FAIL on them.
258
+ check_required_reason_api "FileTimestamp" 'attributesOfItem|fileCreationDate|fileModificationDate|creationDateKey|contentModificationDateKey|contentModificationDate'
237
259
  check_required_reason_api "SystemBootTime" 'systemUptime|mach_absolute_time|CACurrentMediaTime\(\)'
238
260
  check_required_reason_api "DiskSpace" 'volumeAvailableCapacity|volumeTotalCapacity'
239
261
  check_required_reason_api "ActiveKeyboard" 'activeInputModes|UITextInputMode'
@@ -255,7 +277,7 @@ else
255
277
  "Contacts:NSContactsUsageDescription" \
256
278
  "HealthKit:NSHealthShareUsageDescription"; do
257
279
  framework="${fw%%:*}"; needed_key="${fw##*:}"
258
- if grep -rqE "import ($framework)|($framework)\." "$IOS_DIR" --include="*.swift" 2>/dev/null; then
280
+ if grep -rqE "import ($framework)|($framework)\." "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
259
281
  if ! grep -qE "$needed_key" "$INFO_PLIST" 2>/dev/null; then
260
282
  fail "5.1.1 framework '${framework%%|*}' imported but Info.plist is missing '${needed_key%%|*}'" "$INFO_PLIST"
261
283
  fi
@@ -267,7 +289,7 @@ else
267
289
  # AVAudioPlayer/AVPlayer, and Photos/PhotosUI's PhotosPicker/PHPickerViewController
268
290
  # run out-of-process and need no Info.plist key at all).
269
291
  # Camera: purpose string required only when a capture API is actually used.
270
- if grep -rqE 'AVCaptureDevice|AVCaptureSession|UIImagePickerController' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
292
+ if grep -rqE 'AVCaptureDevice|AVCaptureSession|UIImagePickerController' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
271
293
  grep -qE 'NSCameraUsageDescription' "$INFO_PLIST" 2>/dev/null || \
272
294
  fail "5.1.1 camera capture API used but Info.plist is missing 'NSCameraUsageDescription'" "$INFO_PLIST"
273
295
  fi
@@ -275,15 +297,15 @@ else
275
297
  # separately from the camera check above: a bare AVCaptureDevice only
276
298
  # implies microphone use when it is audio-typed (`.audio` device/media
277
299
  # type), not for a video-only capture device.
278
- if grep -rqE 'AVAudioRecorder|installTap\(|AVAudioEngine\(|AVAudioSession[^;]*\.(playAndRecord|record)|AVCaptureDevice[^;)]*\.audio|for:[[:space:]]*\.audio' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
300
+ if grep -rqE 'AVAudioRecorder|installTap\(|AVAudioEngine\(|AVAudioSession[^;]*\.(playAndRecord|record)|AVCaptureDevice[^;)]*\.audio|for:[[:space:]]*\.audio' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
279
301
  grep -qE 'NSMicrophoneUsageDescription' "$INFO_PLIST" 2>/dev/null || \
280
302
  fail "5.1.1 microphone/recording API used but Info.plist is missing 'NSMicrophoneUsageDescription'" "$INFO_PLIST"
281
303
  fi
282
304
  # Photo library READ: PhotosPicker/PHPicker need no key; only true read/fetch APIs do.
283
- if grep -rqE 'PHAsset\b|PHFetchResult|PHImageManager|fetchAssets|PHAssetCollection' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
305
+ if grep -rqE 'PHAsset\b|PHFetchResult|PHImageManager|fetchAssets|PHAssetCollection' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
284
306
  grep -qE 'NSPhotoLibraryUsageDescription' "$INFO_PLIST" 2>/dev/null || \
285
307
  fail "5.1.1 Photos read API used but Info.plist is missing 'NSPhotoLibraryUsageDescription'" "$INFO_PLIST"
286
- elif grep -rqE 'PHAssetCreationRequest|UIImageWriteToSavedPhotosAlbum|performChanges' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
308
+ elif grep -rqE 'PHAssetCreationRequest|UIImageWriteToSavedPhotosAlbum|performChanges' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
287
309
  # Add-only save: covered by the add-only key.
288
310
  grep -qE 'NSPhotoLibraryAddUsageDescription' "$INFO_PLIST" 2>/dev/null || \
289
311
  fail "5.1.1 Photos add-only API used but Info.plist is missing 'NSPhotoLibraryAddUsageDescription'" "$INFO_PLIST"
@@ -297,8 +319,8 @@ fi
297
319
  # when the ATT framework IS imported; §16 fires when a tracking SDK is present but
298
320
  # the ATT prompt is NOT. Computed once here so the two checks never contradict.
299
321
  set_rule "att-usage"
300
- 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)
301
- att_used=$(grep -rlE 'AppTrackingTransparency|ATTrackingManager' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
322
+ tracking_sdk=$(grep -rlE 'advertisingIdentifier|ASIdentifierManager|GADMobileAds|GoogleMobileAds|AppLovinSDK|ALSdk|AppsFlyerLib|import Adjust|Adjust\.|FBAudienceNetwork|BranchSDK|IronSource' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
323
+ att_used=$(grep -rlE 'AppTrackingTransparency|ATTrackingManager' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
302
324
  if [[ -n "$att_used" ]]; then
303
325
  if grep -q "NSUserTrackingUsageDescription" "$INFO_PLIST" 2>/dev/null; then
304
326
  pass "5.1.2 ATT — used + NSUserTrackingUsageDescription present"
@@ -437,7 +459,7 @@ fi
437
459
  # In-app purchase gate — only run 3.1.2 checks if IAP signals exist
438
460
  # ===================================================================
439
461
  iap_detected=""
440
- grep -rqE 'SKPaymentQueue|SKProduct|SKMutablePayment|Product\.products|Product\(for:|Product\(id:|\.purchase\(|Transaction\.currentEntitlements|Transaction\.updates|RevenueCat|Purchases\.(shared|configure|logIn|getProducts)|Adapty|Glassfy|import Qonversion' "$IOS_DIR" --include="*.swift" 2>/dev/null && iap_detected=1
462
+ grep -rqE 'SKPaymentQueue|SKProduct|SKMutablePayment|Product\.products|Product\(for:|Product\(id:|\.purchase\(|Transaction\.currentEntitlements|Transaction\.updates|RevenueCat|Purchases\.(shared|configure|logIn|getProducts)|Adapty|Glassfy|import Qonversion' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null && iap_detected=1
441
463
  [[ -n "$SUB_VIEW" ]] && iap_detected=1
442
464
 
443
465
  if [[ -z "$iap_detected" ]]; then
@@ -483,21 +505,28 @@ else
483
505
  # representative file in messages.
484
506
  set_rule "subscription-links-restore"
485
507
  if (( ${#PAYWALL_FILES[@]} > 0 )); then
486
- if grep -qE 'restore|restorePurchases' "${PAYWALL_FILES[@]}"; then
487
- pass "3.1.2 Restore Purchases present in $(basename "$SUB_VIEW")"
488
- else
489
- fail "3.1.2 Restore Purchases not found in the paywall views (e.g. $SUB_VIEW) (required by Apple)"
490
- fi
491
- if grep -qE 'terms_of_use|termsURL|termsOfUse|subscription_terms|EULA|/terms' "${PAYWALL_FILES[@]}"; then
492
- pass "3.1.2 Terms of Use (EULA) link — present"
493
- else
494
- fail "3.1.2 Terms of Use (EULA) link not found in the paywall views (e.g. $SUB_VIEW)"
495
- fi
496
- if grep -qE 'privacy_policy|privacyURL|privacyPolicy|subscription_privacy|/privacy' "${PAYWALL_FILES[@]}"; then
497
- pass "3.1.2 Privacy Policy link — present"
498
- else
499
- fail "3.1.2 Privacy Policy link — not found in the paywall views (e.g. $SUB_VIEW)"
500
- fi
508
+ # Remote-configured paywalls (RevenueCatUI's PaywallView, presentPaywall,
509
+ # paywallFooter, AdaptyUI) render Restore/Terms/Privacy from the vendor
510
+ # dashboard, so those controls never appear in app source. A hard FAIL here
511
+ # would false-RED a compliant app; downgrade the missing-link findings to
512
+ # verify-in-dashboard WARNs when that signal is present.
513
+ remote_paywall=""
514
+ grep -rqE 'RevenueCatUI|presentPaywall|paywallFooter|AdaptyUI' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null && remote_paywall=1
515
+ # paywall_req <label> <pattern> [pass-suffix]: PASS when the pattern is in
516
+ # any paywall view, WARN when a remote paywall likely renders it, else FAIL.
517
+ paywall_req() {
518
+ local label="$1" pattern="$2" pass_suffix="${3:-— present}"
519
+ if grep -qiE "$pattern" "${PAYWALL_FILES[@]}"; then
520
+ pass "3.1.2 $label $pass_suffix"
521
+ elif [[ -n "$remote_paywall" ]]; then
522
+ warn "3.1.2 $label — not in app source, but a remote-configured paywall (RevenueCatUI / presentPaywall) was detected; verify the dashboard-configured paywall shows it before submitting"
523
+ else
524
+ fail "3.1.2 $label — not found in the paywall views (e.g. $SUB_VIEW)"
525
+ fi
526
+ }
527
+ paywall_req "Restore Purchases" 'restore|AppStore\.sync' "— present in $(basename "$SUB_VIEW")"
528
+ paywall_req "Terms of Use (EULA) link" 'terms[ _]?of[ _]?(use|service)|termsURL|subscription_terms|EULA|/terms|/tos\b|/eula'
529
+ paywall_req "Privacy Policy link" 'privacy[ _]?policy|privacyURL|subscription_privacy|/privacy|datenschutz|gizlilik'
501
530
  else
502
531
  warn "3.1.2 IAP detected but no paywall/subscription view found — set .paywallGlobs so required-link checks can run"
503
532
  fi
@@ -508,7 +537,7 @@ fi
508
537
  # ===================================================================
509
538
  set_rule "private-api"
510
539
  banned_api='UIWebView|setSelectionIndicatorImage|_UIBackdropView|NSURLConnection[^a-zA-Z]|UIAlertView[^Q]|UIActionSheet[^Q]'
511
- banned_hits=$(grep -rEnI "$banned_api" "$IOS_DIR" --include="*.swift" --include="*.m" --include="*.h" 2>/dev/null | head -5)
540
+ banned_hits=$(grep -rEnI "$banned_api" "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -5)
512
541
  if [[ -n "$banned_hits" ]]; then
513
542
  pa_first="$(printf '%s\n' "$banned_hits" | head -1)" # "path:line:match"
514
543
  pa_file="${pa_first%%:*}"
@@ -547,9 +576,9 @@ fi
547
576
  # ===================================================================
548
577
  set_rule "siwa-parity"
549
578
  if [[ -n "$IOS_DIR" ]]; then
550
- social_login=$(grep -rlE 'GIDSignIn|import GoogleSignIn|FBSDKLoginKit|FBSDKLoginManager|FacebookLogin|import Auth0|LineSDK|VKSdkAuthorization' "$IOS_DIR" --include='*.swift' 2>/dev/null | head -1)
579
+ social_login=$(grep -rlE 'GIDSignIn|import GoogleSignIn|FBSDKLoginKit|FBSDKLoginManager|FacebookLogin|import Auth0|LineSDK|VKSdkAuthorization' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
551
580
  if [[ -n "$social_login" ]]; then
552
- if grep -rqE 'ASAuthorizationAppleID|SignInWithAppleButton|AppleIDProvider' "$IOS_DIR" --include='*.swift' 2>/dev/null; then
581
+ if grep -rqE 'ASAuthorizationAppleID|SignInWithAppleButton|AppleIDProvider' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
553
582
  pass "4.8 Sign in with Apple — present alongside third-party login"
554
583
  else
555
584
  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."
@@ -562,7 +591,7 @@ fi
562
591
  # ===================================================================
563
592
  set_rule "external-purchase-link"
564
593
  ext_purchase=""
565
- grep -rqE 'ExternalPurchase|ExternalPurchaseLink|ExternalPurchaseCustomLink' "${IOS_DIR:-.}" --include='*.swift' 2>/dev/null && ext_purchase=1
594
+ grep -rqE 'ExternalPurchase|ExternalPurchaseLink|ExternalPurchaseCustomLink' "${IOS_DIR:-.}" "${SRC_INC[@]}" 2>/dev/null && ext_purchase=1
566
595
  grep -rqE 'external-purchase' "${IOS_DIR:-.}" --include='*.entitlements' 2>/dev/null && ext_purchase=1
567
596
  if [[ -n "$ext_purchase" ]]; then
568
597
  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))."
@@ -637,7 +666,7 @@ fi
637
666
  # incomplete. Soft "verify" wording (a crash-only Sentry may genuinely collect
638
667
  # nothing), so WARN, never FAIL.
639
668
  set_rule "analytics-privacyinfo-mismatch"
640
- analytics_sdk=$(grep -rlE 'FirebaseAnalytics|import Firebase|import Amplitude|Amplitude\(|import Mixpanel|Mixpanel\.|import Sentry|SentrySDK|import Segment|SEGAnalytics|Analytics\.shared\(|import Bugsnag|Bugsnag\.|AppCenterAnalytics|import Datadog|DatadogCore' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
669
+ analytics_sdk=$(grep -rlE 'FirebaseAnalytics|import Firebase|import Amplitude|Amplitude\(|import Mixpanel|Mixpanel\.|import Sentry|SentrySDK|import Segment|SEGAnalytics|Analytics\.shared\(|import Bugsnag|Bugsnag\.|AppCenterAnalytics|import Datadog|DatadogCore' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
641
670
  if [[ -n "$analytics_sdk" ]]; then
642
671
  declared_data=""
643
672
  if [[ -n "$PRIVACY_FILE" && -f "$PRIVACY_FILE" ]]; then
@@ -680,7 +709,7 @@ fi
680
709
  # content. We can't tell digital from physical statically, so this is advisory.
681
710
  set_rule "thirdparty-payment-sdk"
682
711
  if [[ -n "$IOS_DIR" ]]; then
683
- payment_sdk=$(grep -rlE 'import Stripe|StripePaymentSheet|StripeApplePay|import Braintree|BTPaymentFlow|import PayPal|PayPalCheckout|PayPalNativeCheckout|import Square|SquareInAppPayments|import Adyen|AdyenComponents|RazorpaySDK|import Paddle' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
712
+ payment_sdk=$(grep -rlE 'import Stripe|StripePaymentSheet|StripeApplePay|import Braintree|BTPaymentFlow|import PayPal|PayPalCheckout|PayPalNativeCheckout|import Square|SquareInAppPayments|import Adyen|AdyenComponents|RazorpaySDK|import Paddle' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
684
713
  if [[ -n "$payment_sdk" ]]; then
685
714
  warn "3.1.1 Third-party payment SDK — '$(basename "$payment_sdk")' detected; selling digital content/functionality must use in-app purchase, not an external processor (3.1.1). Allowed only for physical goods/services — verify your offering."
686
715
  fi
@@ -694,9 +723,9 @@ fi
694
723
  # warn when no report/block/moderation affordance is found anywhere in the source.
695
724
  set_rule "ugc-no-moderation"
696
725
  if [[ -n "$IOS_DIR" ]]; then
697
- ugc_signal=$(grep -rlE 'userGeneratedContent|\bUGC\b|StreamChat|MessageKit|SendbirdSDK|PubNub|postComment|submitComment|createPost|publishPost|uploadUserPhoto|uploadVideo' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
726
+ ugc_signal=$(grep -rlE 'userGeneratedContent|\bUGC\b|StreamChat|MessageKit|SendbirdSDK|PubNub|postComment|submitComment|createPost|publishPost|uploadUserPhoto|uploadVideo' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
698
727
  if [[ -n "$ugc_signal" ]]; then
699
- if grep -rqiE 'report(Content|User|Abuse|Post|Comment|Message|Reason|ed)|block(ed)?User|unblockUser|moderat(e|ion|or)|flag(Content|Post|User|Comment|Message)|content.?filter' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
728
+ if grep -rqiE 'report(Content|User|Abuse|Post|Comment|Message|Reason|ed)|block(ed)?User|unblockUser|moderat(e|ion|or)|flag(Content|Post|User|Comment|Message)|content.?filter' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
700
729
  pass "1.2 UGC — user-generated content with report/block/moderation affordances present"
701
730
  else
702
731
  warn "1.2 UGC — user-generated content detected (e.g. $(basename "$ugc_signal")) but no report/block/moderation mechanism found; UGC apps must offer content filtering, a report mechanism, user blocking, and published contact info (1.2)"
@@ -728,7 +757,7 @@ fi
728
757
  # auto-renew copy or a one-time PassKit payment with recurring Apple Pay. We don't
729
758
  # try to detect the disclosure text (too noisy) — we flag it for manual verify.
730
759
  set_rule "applepay-recurring-disclosure"
731
- if [[ -n "$IOS_DIR" ]] && grep -rqE 'PKRecurringPaymentRequest' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
760
+ if [[ -n "$IOS_DIR" ]] && grep -rqE 'PKRecurringPaymentRequest' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
732
761
  warn "4.9 Apple Pay — recurring Apple Pay (PKRecurringPaymentRequest) detected; verify you disclose the renewal term, what's provided, the charges, and how to cancel before purchase (4.9)"
733
762
  fi
734
763
 
@@ -739,9 +768,9 @@ fi
739
768
  # use the system SKStoreReviewController / requestReview API.
740
769
  set_rule "custom-review-prompt"
741
770
  if [[ -n "$IOS_DIR" ]]; then
742
- review_link=$(grep -rlE 'write-review|action=write-review|itms-apps[^"]*review' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
771
+ review_link=$(grep -rlE 'write-review|action=write-review|itms-apps[^"]*review' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
743
772
  if [[ -n "$review_link" ]]; then
744
- if grep -rqE 'requestReview|SKStoreReviewController|\.requestReview' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
773
+ if grep -rqE 'requestReview|SKStoreReviewController|\.requestReview' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
745
774
  pass "5.6.1 App reviews — uses the system requestReview API alongside the review link"
746
775
  else
747
776
  warn "5.6.1 App reviews — a direct App Store review link/prompt was found (e.g. $(basename "$review_link")) but no system requestReview (SKStoreReviewController) call; Apple disallows custom review prompts (5.6.1)"
@@ -805,8 +834,8 @@ fi
805
834
  # Personal health information must not be stored in iCloud, and HealthKit data
806
835
  # may not be used for advertising/marketing.
807
836
  set_rule "health-icloud-sync"
808
- if [[ -n "$IOS_DIR" ]] && grep -rqE 'import HealthKit|HKHealthStore' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
809
- if grep -rqE 'import CloudKit|CKRecord|CKContainer|NSUbiquitousKeyValueStore|NSUbiquitousContainer' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
837
+ if [[ -n "$IOS_DIR" ]] && grep -rqE 'import HealthKit|HKHealthStore' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
838
+ if grep -rqE 'import CloudKit|CKRecord|CKContainer|NSUbiquitousKeyValueStore|NSUbiquitousContainer' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
810
839
  warn "5.1.3 Health data — HealthKit and iCloud/CloudKit are both used; personal health information must not be stored in iCloud (5.1.3). Verify HealthKit data is not synced to iCloud."
811
840
  else
812
841
  pass "5.1.3 Health data — HealthKit used without an obvious iCloud sync path"
@@ -820,7 +849,7 @@ fi
820
849
  # on-screen before use, and may not sell/share data.
821
850
  set_rule "vpn-networkextension"
822
851
  if [[ -n "$IOS_DIR" ]]; then
823
- vpn_use=$(grep -rlE 'NEVPNManager|NETunnelProviderManager|NEPacketTunnelProvider|NEVPNProtocol' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
852
+ vpn_use=$(grep -rlE 'NEVPNManager|NETunnelProviderManager|NEPacketTunnelProvider|NEVPNProtocol' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
824
853
  if [[ -n "$vpn_use" ]]; then
825
854
  warn "5.4 VPN — NetworkExtension/NEVPNManager usage detected (e.g. $(basename "$vpn_use")); VPN apps must be offered by an organization account, disclose data collection on-screen before use, and not sell/share data (5.4). Verify compliance."
826
855
  fi
@@ -835,7 +864,7 @@ fi
835
864
  # the reviewer-prep notes. Social-only logins are not gated here (too noisy).
836
865
  set_rule "demo-account"
837
866
  if [[ -n "$IOS_DIR" ]]; then
838
- auth_signal=$(grep -rlE 'SecureField' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
867
+ auth_signal=$(grep -rlE 'SecureField' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
839
868
  [[ -z "$auth_signal" ]] && auth_signal=$(find "$IOS_DIR" "${PRUNE[@]}" \( -name '*Login*View*.swift' -o -name '*SignIn*View*.swift' \) 2>/dev/null | head -1)
840
869
  if [[ -n "$auth_signal" ]]; then
841
870
  demo_present=""
@@ -862,7 +891,7 @@ fi
862
891
  # JS-bundle OTA for React Native (CodePush) is allowed, so we do NOT flag it.
863
892
  set_rule "executable-code-download"
864
893
  if [[ -n "$IOS_DIR" ]]; then
865
- hotcode=$(grep -rlE 'import JSPatch|JSPatch\.|[Jj][Ss][Pp]atch|import Rollout|Rollout\.|rollout\.io|DynamicCocoa|import SwiftPatch' "$IOS_DIR" --include="*.swift" --include="*.m" --include="*.h" 2>/dev/null | head -1)
894
+ hotcode=$(grep -rlE 'import JSPatch|JSPatch\.|[Jj][Ss][Pp]atch|import Rollout|Rollout\.|rollout\.io|DynamicCocoa|import SwiftPatch' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
866
895
  if [[ -n "$hotcode" ]]; then
867
896
  warn "2.5.2 Executable code — a hot-patch / remote-code framework was detected (e.g. $(basename "$hotcode")); apps may not download or run code that changes features (JSPatch/Rollout-style native hot-patching). Allowed JS-bundle OTA (e.g. React Native CodePush) is fine — verify this is not native hot-patching (2.5.2)"
868
897
  fi
@@ -882,13 +911,13 @@ if [[ -f "$INFO_PLIST" && -n "$IOS_DIR" ]]; then
882
911
  while IFS= read -r m; do
883
912
  [[ -z "$m" ]] && continue
884
913
  case "$m" in
885
- location) grep -rqE 'CLLocationManager|CoreLocation' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused location" ;;
886
- audio) grep -rqE 'AVAudioSession|AVPlayer|AVAudioPlayer|AVQueuePlayer|AVAudioEngine|AVKit|VideoPlayer|AVPlayerViewController|MPMusicPlayerController|MPNowPlayingInfoCenter' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused audio" ;;
887
- voip) grep -rqE 'PushKit|PKPushRegistry|CallKit|CXProvider' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused voip" ;;
888
- fetch) grep -rqE 'BGAppRefreshTask|BGTaskScheduler|BackgroundTasks|setMinimumBackgroundFetchInterval|performFetchWithCompletionHandler' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused fetch" ;;
889
- processing) grep -rqE 'BGProcessingTask|BGTaskScheduler|BackgroundTasks' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused processing" ;;
890
- bluetooth-central|bluetooth-peripheral) grep -rqE 'CoreBluetooth|CBCentralManager|CBPeripheralManager' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused $m" ;;
891
- remote-notification) grep -rqE 'didReceiveRemoteNotification|UNUserNotificationCenter|registerForRemoteNotifications' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused remote-notification" ;;
914
+ location) grep -rqE 'CLLocationManager|CoreLocation' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null || unused="$unused location" ;;
915
+ audio) grep -rqE 'AVAudioSession|AVPlayer|AVAudioPlayer|AVQueuePlayer|AVAudioEngine|AVKit|VideoPlayer|AVPlayerViewController|MPMusicPlayerController|MPNowPlayingInfoCenter' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null || unused="$unused audio" ;;
916
+ voip) grep -rqE 'PushKit|PKPushRegistry|CallKit|CXProvider' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null || unused="$unused voip" ;;
917
+ fetch) grep -rqE 'BGAppRefreshTask|BGTaskScheduler|BackgroundTasks|setMinimumBackgroundFetchInterval|performFetchWithCompletionHandler' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null || unused="$unused fetch" ;;
918
+ processing) grep -rqE 'BGProcessingTask|BGTaskScheduler|BackgroundTasks' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null || unused="$unused processing" ;;
919
+ bluetooth-central|bluetooth-peripheral) grep -rqE 'CoreBluetooth|CBCentralManager|CBPeripheralManager' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null || unused="$unused $m" ;;
920
+ remote-notification) grep -rqE 'didReceiveRemoteNotification|UNUserNotificationCenter|registerForRemoteNotifications' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null || unused="$unused remote-notification" ;;
892
921
  esac
893
922
  done <<< "$modes"
894
923
  if [[ -n "$unused" ]]; then
@@ -904,7 +933,7 @@ fi
904
933
  # ===================================================================
905
934
  set_rule "crypto-wallet-mining"
906
935
  if [[ -n "$IOS_DIR" ]]; then
907
- crypto_sdk=$(grep -rlE 'import Web3|web3swift|Web3Swift|WalletConnect|TrustWalletCore|CoinbaseWalletSDK|SolanaSwift|CryptoMining|coinhive|MoneroMiner' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
936
+ crypto_sdk=$(grep -rlE 'import Web3|web3swift|Web3Swift|WalletConnect|TrustWalletCore|CoinbaseWalletSDK|SolanaSwift|CryptoMining|coinhive|MoneroMiner' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
908
937
  if [[ -n "$crypto_sdk" ]]; then
909
938
  warn "3.1.5(a) Cryptocurrency — a crypto wallet/exchange/mining signal was detected (e.g. $(basename "$crypto_sdk")); wallets & exchanges have entity and licensing requirements, and on-device mining is not permitted (3.1.5(a)). Verify eligibility."
910
939
  fi
@@ -918,7 +947,7 @@ fi
918
947
  # files. WARN (verify) — this is the most false-positive-prone of the batch.
919
948
  set_rule "webview-wrapper"
920
949
  if [[ -n "$IOS_DIR" ]]; then
921
- if grep -rqE 'WKWebView' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
950
+ if grep -rqE 'WKWebView' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
922
951
  swift_n=$(find "$IOS_DIR" "${PRUNE[@]}" -name '*.swift' 2>/dev/null | wc -l | tr -d ' ')
923
952
  if (( swift_n > 0 && swift_n <= 4 )); then
924
953
  warn "4.2.3 Minimum functionality — the app appears to be a WKWebView wrapper with only $swift_n Swift file(s); a thin wrapper around a website is rejected under 4.2.3. Add native value, or verify this is a real app rather than a repackaged site."
@@ -931,7 +960,7 @@ fi
931
960
  # ===================================================================
932
961
  set_rule "remote-desktop"
933
962
  if [[ -n "$IOS_DIR" ]]; then
934
- remote_desktop=$(grep -rlE 'import libvncclient|LibVNC|VNCClient|RDPSession|RDPKit|RemoteDesktopClient|import FreeRDP|JumpDesktop' "$IOS_DIR" --include="*.swift" --include="*.m" 2>/dev/null | head -1)
963
+ remote_desktop=$(grep -rlE 'import libvncclient|LibVNC|VNCClient|RDPSession|RDPKit|RemoteDesktopClient|import FreeRDP|JumpDesktop' "$IOS_DIR" "${SRC_INC[@]}" --include="*.m" 2>/dev/null | head -1)
935
964
  if [[ -n "$remote_desktop" ]]; then
936
965
  warn "4.2.7 Remote desktop — a remote-desktop/mirroring signal was detected (e.g. $(basename "$remote_desktop")); host-mirroring apps must only show/control the owner's host, display host content (not App Store content), and be free or use IAP (4.2.7). Verify."
937
966
  fi
@@ -954,9 +983,9 @@ fi
954
983
  # for an in-app deletion path. Deletion via a web page is missed → WARN, not FAIL.
955
984
  set_rule "account-no-delete"
956
985
  if [[ -n "$IOS_DIR" ]]; then
957
- signup=$(grep -rlE 'createUser|signUp|signup|createAccount|registerUser|registerNewUser|Auth\.auth\(\)\.createUser' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
986
+ signup=$(grep -rlE 'createUser|signUp|signup|createAccount|registerUser|registerNewUser|Auth\.auth\(\)\.createUser' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
958
987
  if [[ -n "$signup" ]]; then
959
- if grep -rqiE 'delete.?account|account.?deletion|closeAccount|deleteUser|removeAccount|deleteMyAccount' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
988
+ if grep -rqiE 'delete.?account|account.?deletion|closeAccount|deleteUser|removeAccount|deleteMyAccount' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
960
989
  pass "5.1.1(v) Account deletion — account creation present and an in-app account-deletion path was found"
961
990
  else
962
991
  warn "5.1.1(v) Account deletion — account creation was detected (e.g. $(basename "$signup")) but no in-app account-deletion path found; apps that support account creation must let users delete their account from within the app (5.1.1(v))"
@@ -1001,7 +1030,7 @@ fi
1001
1030
  # ===================================================================
1002
1031
  set_rule "mdm"
1003
1032
  if [[ -n "$IOS_DIR" ]]; then
1004
- mdm_sig=$(grep -rlE 'import DeviceManagement|MDMConfiguration|ManagedAppConfiguration|com\.apple\.mdm' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
1033
+ mdm_sig=$(grep -rlE 'import DeviceManagement|MDMConfiguration|ManagedAppConfiguration|com\.apple\.mdm' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
1005
1034
  [[ -z "$mdm_sig" ]] && mdm_sig=$(grep -rlE 'com\.apple\.configuration\.managed' --include='*.plist' "${GREP_PRUNE[@]}" . 2>/dev/null | pick_shallowest)
1006
1035
  if [[ -n "$mdm_sig" ]]; then
1007
1036
  warn "5.5 MDM — a Mobile Device Management signal was detected (e.g. $(basename "$mdm_sig")); MDM apps require a commercial enterprise/education entity, may request the MDM capability only for that purpose, and must not sell or use the data for other ends (5.5). Verify eligibility."
@@ -8,11 +8,12 @@ _SUPP_PATHS="" # path globs excluded from scanning, one per line (trailin
8
8
  _SUPP_REASON="" # set by is_suppressed on a hit
9
9
 
10
10
  # _is_catalog_rule <token> -> 0 if token is a known rule slug.
11
+ # The bound is derived from the catalog itself (rule_slug returns "" past the
12
+ # last rule), so a newly added rule can never be silently unsuppressable again.
11
13
  _is_catalog_rule() {
12
14
  local n s
13
15
  n=1
14
- while [[ $n -le 41 ]]; do
15
- s="$(rule_slug "$n")"
16
+ while s="$(rule_slug "$n")" && [[ -n "$s" ]]; do
16
17
  [[ "$s" == "$1" ]] && return 0
17
18
  n=$((n + 1))
18
19
  done
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bash
2
+ # thresholds.sh — the single source of truth for the verdict thresholds.
3
+ # Sourced by verdict.sh and findings.sh so the two renderers can never diverge.
4
+ #
5
+ # RED >= RED_FAIL_MIN FAILs
6
+ # YELLOW >= YELLOW_WARN_MIN WARNs (and no FAIL)
7
+ # GREEN otherwise
8
+
9
+ # shellcheck disable=SC2034 # consumed by the sourcing scripts, not here
10
+ RED_FAIL_MIN=1
11
+ # shellcheck disable=SC2034
12
+ YELLOW_WARN_MIN=5
@@ -47,9 +47,13 @@ fails=$(count '^FAIL:')
47
47
  warns=$(count '^WARN:')
48
48
  passes=$(count '^PASS:')
49
49
 
50
- if (( fails >= 1 )); then
50
+ # Thresholds live in thresholds.sh (shared with findings.sh's JSON renderer).
51
+ # shellcheck source=thresholds.sh
52
+ . "$(dirname "${BASH_SOURCE[0]}")/thresholds.sh"
53
+
54
+ if (( fails >= RED_FAIL_MIN )); then
51
55
  verdict="RED"; token="remove"; code=1
52
- elif (( warns >= 5 )); then
56
+ elif (( warns >= YELLOW_WARN_MIN )); then
53
57
  verdict="YELLOW"; token="hold"; code=2
54
58
  else
55
59
  verdict="GREEN"; token="write"; code=0