appstore-precheck 1.5.2 → 1.7.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,57 @@
3
3
  All notable changes to this project are documented here. Versioning follows
4
4
  [SemVer](https://semver.org/). Released as git tags.
5
5
 
6
+ ## [Unreleased]
7
+
8
+ ## [1.7.0] - 2026-07-02
9
+
10
+ Smarter analysis (roadmap #2b): the scanner now resolves the iOS source directory
11
+ and `Info.plist` from the Xcode **project model** (`.pbxproj`) instead of a pure
12
+ grep heuristic, eliminating the dominant remaining false-positive source in
13
+ monorepo / SPM / multi-target layouts. Zero new runtime dependencies (pure
14
+ bash + awk), READ-ONLY preserved, and default text output stays byte-identical for
15
+ any repo without a `.pbxproj`.
16
+
17
+ ### Added
18
+ - **Project-model detection** (`skills/appstore-precheck/scripts/project-model.sh`):
19
+ parses `.pbxproj` to find the primary `application`-type target and resolve its
20
+ source dir + `INFOPLIST_FILE` authoritatively, across ALL `.xcodeproj` in a
21
+ monorepo. `detect_ios_dir` now chains: config `.iosSourceDir` > project-model
22
+ parse > the original grep heuristic (unchanged, kept as fallback).
23
+ - Per-target `INFOPLIST_FILE` attribution via the build-config graph
24
+ (target → `buildConfigurationList` → `XCBuildConfiguration`), used as a last
25
+ resort so already-correct apps are untouched; unexpanded build-variable paths
26
+ (`$(SRCROOT)` etc.) are guarded, and app targets under vendored paths
27
+ (`ThirdParty`/`Vendor`) are deprioritized so a vendored sample app never wins.
28
+
29
+ ### Measured impact (18-app open-source panel, candidate/directional labels)
30
+ - Corrects detection on `wikipedia-ios`, `pocket-casts-ios`, `cwa-app-ios`
31
+ (now read their real custom-named plists) and `brave-ios` (real app over a
32
+ vendored sample). `usage-description-crosscheck` false positives 9 → 6, with
33
+ content-grounded findings surfaced by finally reading the correct plist; zero
34
+ true-positive loss. See `docs/fp-reduction-report.md`.
35
+
36
+ ## [1.6.0] - 2026-07-01
37
+
38
+ Measurement release: structured findings, suppression, and a published
39
+ precision/recall scorecard, plus a measurement-driven false-positive reduction
40
+ round. Default text output stays byte-identical (verified across 11 fixtures);
41
+ every behavior change is gated by TDD and the synthetic-corpus `--check`.
42
+
43
+ ### Added
44
+ - **`scan.sh --format json`**: structured findings envelope (`{tool, version, verdict, summary{fail,warn,pass,suppressed}, findings[{rule_id, severity, guideline, message, file, line, suppressed}]}`) for tooling and measurement. Stable `rule_id` per vector via a 41-slug catalog. Default text output is unchanged.
45
+ - **`.precheck-ignore` suppression**: repo-root rules (`<rule-id>`, `<rule-id> <path-glob>`, `<path-glob>`-exclude) and inline `# precheck:ignore [rule-id]` directives (`//`, `#`, `<!-- -->`). Suppression is emit-time — a suppressed FAIL no longer forces RED — and always transparent: suppressed findings stay in `findings[]` with `suppressed:true`, count in `summary.suppressed`, and a text footer reports the count only when non-zero (byte-identity preserved otherwise).
46
+ - **Precision/recall scorecard**: `scripts/scorecard.sh` (`--check` / `--selftest` / default) over a synthetic labelled corpus (`corpus/synthetic/labels.json`), plus `scripts/scorecard-real.sh` over an 18-app commit-pinned open-source real panel (`corpus/real/`). Published to `docs/scorecard.md` with a mandatory honesty section (neither corpus claims agreement with Apple's actual decisions). Synthetic `--check` is a blocking CI gate; the real panel is non-blocking.
47
+ - File/line threading into locatable checks (surfaced in JSON; text output unchanged).
48
+
49
+ ### Fixed
50
+ - **False-positive reduction round** (measurement-driven; ~33 FP eliminated across the 18-app panel with zero true-positive loss, char-limit-excluded precision 0.37 → ~0.54):
51
+ - **Analytics detection** now requires an import/API-qualified form, so a bare `Segment` substring (e.g. `UISegmentedControl`) no longer trips 5.1.1.
52
+ - **IAP gate** requires a real purchase API, ignoring `SKStoreReviewController` / `SKAdNetwork` / the `AppStore.` namespace.
53
+ - **Usage-description checks** are capture-gated: playback-only AVFoundation / PhotosPicker no longer false-fire; mic matches `.playAndRecord`; video-only `AVCaptureDevice` no longer triggers the microphone check.
54
+ - **Minimum-functionality navigation** detects UIKit / `NavigationView` / React-Navigation repo-wide.
55
+ - **Screenshots-per-locale**: no in-repo screenshots directory is an advisory PASS (managed in App Store Connect), not a WARN.
56
+
6
57
  ## [1.5.2] - 2026-06-30
7
58
 
8
59
  ### Fixed
package/README.md CHANGED
@@ -240,8 +240,14 @@ npx appstore-precheck --fail-on YELLOW
240
240
 
241
241
  ```bash
242
242
  bash skills/appstore-precheck/scripts/scan.sh # from a clone of this repo
243
+ bash skills/appstore-precheck/scripts/scan.sh --format json # structured findings, for tooling
243
244
  ```
244
245
 
246
+ `--format json` emits a structured findings envelope (`rule_id`, `severity`, `guideline`,
247
+ `message`, and optional `file`/`line` per finding, plus the verdict summary) instead of the
248
+ default text lines, for scripts and measurement tooling to consume. It's read-only and additive;
249
+ the default text output is unchanged.
250
+
245
251
  ### CI
246
252
 
247
253
  Drop the static scan into a workflow with the bundled composite action. It fails the
@@ -320,6 +326,26 @@ and [Phase 2 `fastlane precheck`](examples/fastlane-precheck.md) results.
320
326
  | 🟡 **YELLOW** | 0 FAIL, 5+ WARN | not written | blocked; needs confirmation |
321
327
  | 🔴 **RED** | ≥1 FAIL | removed | blocked; shows the FAIL list |
322
328
 
329
+ ## Measured accuracy
330
+
331
+ [`docs/scorecard.md`](docs/scorecard.md) is generated by `scripts/scorecard.sh` and tracks two
332
+ independent measurements:
333
+
334
+ - **Synthetic precision** — the project's own labelled fixtures (`corpus/synthetic/`), each tagging
335
+ the rule-ids it must fire and the rule-ids that must stay silent. This is a **CI-gated floor**:
336
+ `scripts/scorecard.sh --check` fails the build if precision drops below 0.80 or the doc goes stale.
337
+ - **Real-panel FP rate** — a pinned, license-verified panel of permissively-licensed open-source
338
+ iOS/Swift/React-Native apps (`corpus/real/manifest.json`), scanned and joined against a one-time
339
+ human TP/FP labelling pass (`corpus/real/labels.json`). Every finding is reported `UNLABELED` until
340
+ a human reviews it, so **no unreviewed number is ever published**. Run it yourself with
341
+ `bash scripts/scorecard.sh --real`; CI runs it as a separate **non-blocking, informational** job
342
+ (`continue-on-error: true`) that never gates a PR.
343
+
344
+ **Neither measurement claims agreement with Apple's actual review decisions.** Synthetic precision
345
+ measures intended-behaviour fidelity against fixtures this project wrote; real-panel precision
346
+ measures the false-positive rate on real, unrelated open-source code. See
347
+ [`docs/scorecard.md`](docs/scorecard.md) for the current numbers and full methodology.
348
+
323
349
  ## Configuration
324
350
 
325
351
  Zero config for a standard fastlane + Xcode layout. The scanner auto-detects your source directory,
@@ -327,6 +353,37 @@ fastlane metadata, screenshots, String Catalog, paywall view, and locales. Overr
327
353
  `.appstore-precheck.json` at your repo root (copy
328
354
  [`config.example.json`](skills/appstore-precheck/config.example.json)).
329
355
 
356
+ ### Suppressing findings (`.precheck-ignore`)
357
+
358
+ Drop a `.precheck-ignore` file at your repo root to suppress findings you've reviewed and accepted
359
+ on purpose. One entry per line; `#` starts a trailing comment. Three grammar forms:
360
+
361
+ | Form | Example | Effect |
362
+ |------|---------|--------|
363
+ | `<rule-id>` | `account-no-delete` | suppress that rule everywhere in the repo |
364
+ | `<rule-id> <path-glob>` | `ats-arbitrary-loads ios/Legacy/` | suppress that rule only under the matching path |
365
+ | `<path-glob>` | `vendor/` | exclude the matching path from scanning entirely |
366
+
367
+ You can also suppress a single finding inline, with a `//`, `#`, or `<!-- -->` comment marker on the
368
+ flagged line or the line directly above it:
369
+
370
+ ```swift
371
+ let x = UIWebView() // precheck:ignore private-api
372
+ ```
373
+
374
+ ```html
375
+ <!-- precheck:ignore -->
376
+ <key>NSAllowsArbitraryLoads</key>
377
+ ```
378
+
379
+ A bare `precheck:ignore` (no rule-id) suppresses **any** rule on that line; a scoped
380
+ `precheck:ignore <rule-id>` only suppresses that one rule, leaving every other rule on the same line
381
+ free to fire.
382
+
383
+ **Suppressed findings are always counted, never silently hidden.** `scan.sh --format json` still
384
+ emits them with `"suppressed": true`, and the text/summary output always states how many findings
385
+ were suppressed.
386
+
330
387
  ## Cross-tool support
331
388
 
332
389
  `SKILL.md` follows the [Agent Skills open standard](https://agentskills.io), with no per-tool conversion.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appstore-precheck",
3
- "version": "1.5.2",
3
+ "version": "1.7.0",
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 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"
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"
29
29
  },
30
30
  "keywords": ["ios", "app-store", "fastlane", "agent-skill", "claude-code", "precheck"]
31
31
  }
@@ -4,7 +4,7 @@ description: Read-only pre-submission check for an iOS app before App Store revi
4
4
  license: MIT
5
5
  metadata:
6
6
  author: Berkay Turk
7
- version: 1.5.2
7
+ version: 1.7.0
8
8
  allowed-tools: Bash Read Grep Glob WebFetch
9
9
  ---
10
10
 
@@ -136,6 +136,11 @@ full check table is in
136
136
  The scanner is portable Bash, so you can also run it directly, outside any agent, for a quick CI
137
137
  or pre-commit check.
138
138
 
139
+ `scan.sh --format json` emits a structured findings envelope (`rule_id`, `severity`, `guideline`,
140
+ `message`, optional `file`/`line` per finding, plus the verdict summary) instead of the default
141
+ text lines, for tooling and measurement to consume. It's read-only and additive; the default text
142
+ output is unchanged.
143
+
139
144
  ### Phase 2: Apple's official `fastlane precheck`
140
145
 
141
146
  Requires `bundleId` in config (or pass `app_identifier` directly) and App Store Connect API
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bash
2
+ # findings.sh — structured-findings layer for scan.sh.
3
+ # Sourced by scan.sh. Adds a parallel machine-readable channel; text output is untouched.
4
+ # Bash 3.2 compatible: NO associative arrays (catalog is a case lookup).
5
+
6
+ # rule_slug <section-number> -> stable kebab-case slug, or "" if unknown.
7
+ rule_slug() {
8
+ case "$1" in
9
+ 1) echo privacy-manifest-parity ;; 2) echo usage-description-crosscheck ;;
10
+ 3) echo att-usage ;; 4) echo competitor-mentions ;;
11
+ 5) echo metadata-char-limits ;; 6) echo locale-metadata-parity ;;
12
+ 7) echo screenshots-per-locale ;; 8) echo trial-disclosure ;;
13
+ 9) echo autorenew-disclosure ;; 10) echo subscription-links-restore ;;
14
+ 11) echo private-api ;; 12) echo min-functionality-nav ;;
15
+ 13) echo screentime-justification ;; 14) echo siwa-parity ;;
16
+ 15) echo external-purchase-link ;; 16) echo tracking-sdk-no-att ;;
17
+ 17) echo export-compliance ;; 18) echo support-privacy-url ;;
18
+ 19) echo analytics-privacyinfo-mismatch ;; 20) echo placeholder-metadata ;;
19
+ 21) echo thirdparty-payment-sdk ;; 22) echo ugc-no-moderation ;;
20
+ 23) echo ats-arbitrary-loads ;; 24) echo applepay-recurring-disclosure ;;
21
+ 25) echo custom-review-prompt ;; 26) echo misleading-marketing ;;
22
+ 27) echo kids-wording ;; 28) echo keyboard-full-access ;;
23
+ 29) echo health-icloud-sync ;; 30) echo vpn-networkextension ;;
24
+ 31) echo demo-account ;; 32) echo executable-code-download ;;
25
+ 33) echo background-modes-unused ;; 34) echo crypto-wallet-mining ;;
26
+ 35) echo webview-wrapper ;; 36) echo remote-desktop ;;
27
+ 37) echo safari-extension ;; 38) echo account-no-delete ;;
28
+ 39) echo kids-ads-analytics ;; 40) echo realmoney-gambling ;;
29
+ 41) echo mdm ;; *) echo "" ;;
30
+ esac
31
+ }
32
+
33
+ _CURRENT_RULE=""
34
+ set_rule() { _CURRENT_RULE="$1"; }
35
+
36
+ : "${FINDINGS_TMP:=}"
37
+
38
+ # _record <severity> <message> [<file>] [<line>]
39
+ _record() {
40
+ [[ -z "$FINDINGS_TMP" ]] && return 0
41
+ local sev="$1" msg="$2" file="${3:-}" line="${4:-}" guideline
42
+ guideline="$(printf '%s' "$msg" | awk '{print $1}')"
43
+ jq -nc --arg r "$_CURRENT_RULE" --arg s "$sev" --arg g "$guideline" \
44
+ --arg m "$msg" --arg f "$file" --arg l "$line" \
45
+ '{rule_id:$r, severity:$s, guideline:$g, message:$m,
46
+ file:(if $f=="" then null else $f end),
47
+ line:(if $l=="" then null else ($l|tonumber) end),
48
+ suppressed:false}' >> "$FINDINGS_TMP"
49
+ }
50
+
51
+ : "${_SUPPRESSED_COUNT:=0}"
52
+
53
+ # _record_suppressed <severity> <message> [<file>] [<line>]
54
+ # Same JSONL record as _record but suppressed:true, and bumps the counter.
55
+ _record_suppressed() {
56
+ [[ -z "$FINDINGS_TMP" ]] && { _SUPPRESSED_COUNT=$((_SUPPRESSED_COUNT + 1)); return 0; }
57
+ local sev="$1" msg="$2" file="${3:-}" line="${4:-}" guideline
58
+ guideline="$(printf '%s' "$msg" | awk '{print $1}')"
59
+ jq -nc --arg r "$_CURRENT_RULE" --arg s "$sev" --arg g "$guideline" \
60
+ --arg m "$msg" --arg f "$file" --arg l "$line" \
61
+ '{rule_id:$r, severity:$s, guideline:$g, message:$m,
62
+ file:(if $f=="" then null else $f end),
63
+ line:(if $l=="" then null else ($l|tonumber) end),
64
+ suppressed:true}' >> "$FINDINGS_TMP"
65
+ _SUPPRESSED_COUNT=$((_SUPPRESSED_COUNT + 1))
66
+ }
67
+
68
+ : "${PRECHECK_VERSION:=dev}"
69
+
70
+ # render_json -> prints the structured envelope. Verdict reuses verdict.sh thresholds
71
+ # (RED >=1 FAIL; YELLOW >=5 WARN; else GREEN), counting non-suppressed findings only.
72
+ render_json() {
73
+ local buf="${FINDINGS_TMP:-/dev/null}"
74
+ [[ -s "$buf" ]] || { printf '%s\n' '{"findings":[]}' | jq \
75
+ --arg v "$PRECHECK_VERSION" '{tool:"appstore-precheck",version:$v,verdict:"GREEN",summary:{fail:0,warn:0,pass:0,suppressed:0},findings:[]}'; return 0; }
76
+ jq -s --arg v "$PRECHECK_VERSION" '
77
+ (map(select(.suppressed==false))) as $live
78
+ | ($live|map(select(.severity=="FAIL"))|length) as $f
79
+ | ($live|map(select(.severity=="WARN"))|length) as $w
80
+ | ($live|map(select(.severity=="PASS"))|length) as $p
81
+ | (map(select(.suppressed==true))|length) as $s
82
+ | (if $f>=1 then "RED" elif $w>=5 then "YELLOW" else "GREEN" end) as $verdict
83
+ | {tool:"appstore-precheck", version:$v, verdict:$verdict,
84
+ summary:{fail:$f, warn:$w, pass:$p, suppressed:$s},
85
+ findings: .}' "$buf"
86
+ }
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env bash
2
+ # project-model.sh — resolve the primary app target's source dir + Info.plist from
3
+ # an Xcode project model (.pbxproj), authoritatively and dependency-free.
4
+ # Sourced by scan.sh. Pure bash + awk. READ-ONLY. Bash 3.2 compatible.
5
+
6
+ # pm_app_targets <pbxproj> -> names of targets whose productType is the application type.
7
+ # .pbxproj PBXNativeTarget blocks list `name` before `productType`; the block closes
8
+ # with a bare `};`. Nested lists close with `)`, never `};`, so `};` reliably ends a block.
9
+ pm_app_targets() {
10
+ awk '
11
+ /isa = PBXNativeTarget;/ { in_t=1; name=""; pt=""; next }
12
+ in_t && /^[[:space:]]*name = / {
13
+ l=$0; sub(/^[[:space:]]*name = /,"",l); sub(/;[[:space:]]*$/,"",l); gsub(/^"|"$/,"",l); name=l
14
+ }
15
+ in_t && /^[[:space:]]*productType = / {
16
+ l=$0; sub(/^[[:space:]]*productType = /,"",l); sub(/;[[:space:]]*$/,"",l); gsub(/^"|"$/,"",l); pt=l
17
+ }
18
+ in_t && /^[[:space:]]*};[[:space:]]*$/ {
19
+ if (pt == "com.apple.product-type.application" && name != "") print name
20
+ in_t=0
21
+ }
22
+ ' "$1"
23
+ }
24
+
25
+ # pm_infoplist_files <pbxproj> -> every INFOPLIST_FILE value, unquoted, sorted, deduped.
26
+ pm_infoplist_files() {
27
+ awk '/^[[:space:]]*INFOPLIST_FILE = /{
28
+ l=$0; sub(/^[[:space:]]*INFOPLIST_FILE = /,"",l); sub(/;[[:space:]]*$/,"",l); gsub(/^"|"$/,"",l); print l
29
+ }' "$1" | sort -u
30
+ }
31
+
32
+ # pm_target_infoplist <pbxproj> <target> -> the target's own INFOPLIST_FILE via its
33
+ # build configurations (first non-empty), or non-zero. Handles quoted paths with spaces.
34
+ #
35
+ # A repo may declare INFOPLIST_FILE for several targets whose leading path
36
+ # component does not match the target name (e.g. Client's plist lives under
37
+ # "iOS/Supporting Files"). pm_infoplist_files() alone cannot attribute a given
38
+ # plist to a specific target, so this walks the actual pbxproj graph:
39
+ # PBXNativeTarget "name = X" -> buildConfigurationList = <UUID> -> the
40
+ # XCConfigurationList block for that UUID -> its buildConfigurations list ->
41
+ # each XCBuildConfiguration block, looking for INFOPLIST_FILE.
42
+ #
43
+ # NOTE: this returns the FIRST build config's plist, which is only reliable
44
+ # when all configs agree. Per-configuration plists (Debug vs Release) can
45
+ # legitimately differ (e.g. cwa-app-ios's ENA target), so pm_resolve() only
46
+ # calls this as a LAST RESORT — after the leading-component match and the
47
+ # GENERATE dir-name branch have both failed. Callers must also guard against
48
+ # unexpanded Xcode build variables (e.g. "$(SRCROOT)/...") in the returned
49
+ # path, since those can never resolve to a real file on disk.
50
+ pm_target_infoplist() {
51
+ local pbx="$1" target="$2" cl u ip
52
+ cl="$(awk -v t="$target" '
53
+ /isa = PBXNativeTarget;/{inb=1;nm="";c="";next}
54
+ inb&&/^[[:space:]]*name = /{l=$0;sub(/^[[:space:]]*name = /,"",l);sub(/;[[:space:]]*$/,"",l);gsub(/^"|"$/,"",l);nm=l}
55
+ inb&&/^[[:space:]]*buildConfigurationList = /{l=$0;sub(/^[[:space:]]*buildConfigurationList = /,"",l);sub(/;[[:space:]]*$/,"",l);sub(/ .*/,"",l);c=l}
56
+ inb&&/^[[:space:]]*};[[:space:]]*$/{if(nm==t&&c!=""){print c;exit}inb=0}
57
+ ' "$pbx")"
58
+ [[ -z "$cl" ]] && return 1
59
+ # Match only the block-OPENING line for a UUID (ends in "{"), never a
60
+ # reference to that UUID inside some other list (e.g. the UUID also
61
+ # appears as a bare list item "CFG1 /* Debug */," inside the
62
+ # buildConfigurations array itself, which would otherwise false-match).
63
+ local bcs; bcs="$(awk -v cl="$cl" '
64
+ $0 ~ ("^[[:space:]]*" cl "[[:space:]]") && /\{[[:space:]]*$/ {inb=1}
65
+ inb&&/buildConfigurations = \(/{inl=1;next}
66
+ inb&&inl&&/\)/{exit}
67
+ inb&&inl{l=$0;sub(/\/\*.*/,"",l);gsub(/[[:space:]]/,"",l);sub(/,$/,"",l);if(l!="")print l}
68
+ ' "$pbx")"
69
+ [[ -z "$bcs" ]] && return 1
70
+ for u in $bcs; do
71
+ ip="$(awk -v u="$u" '
72
+ $0 ~ ("^[[:space:]]*" u "[[:space:]]") && /\{[[:space:]]*$/ {inb=1}
73
+ inb&&/^[[:space:]]*INFOPLIST_FILE = /{l=$0;sub(/^[[:space:]]*INFOPLIST_FILE = /,"",l);sub(/;[[:space:]]*$/,"",l);gsub(/^"|"$/,"",l);print l;exit}
74
+ inb&&/^[[:space:]]*};[[:space:]]*$/{exit}
75
+ ' "$pbx")"
76
+ [[ -n "$ip" ]] && { printf '%s\n' "$ip"; return 0; }
77
+ done
78
+ return 1
79
+ }
80
+
81
+ # Vendored dirs whose .xcodeproj must never win detection.
82
+ PM_PRUNE_DIRS='node_modules|Pods|Carthage|\.build|DerivedData|\.git'
83
+
84
+ # App targets whose project lives under a vendored/sample path are deprioritized:
85
+ # they only win when no primary (non-vendored) app target exists. This is a
86
+ # deprioritization heuristic (mirrors scan.sh's NONAPP_TARGET), bounded on
87
+ # purpose: a sample app under a clearly-vendored path (ThirdParty/Vendor)
88
+ # loses to a primary app, but the heuristic deliberately does NOT match
89
+ # Demo/Sample/Example-named dirs, because those often hold a library repo's
90
+ # real deliverable app rather than a throwaway sample (Pods/Carthage/
91
+ # node_modules are already fully pruned by PM_PRUNE_DIRS in
92
+ # pm_find_pbxprojs, so they need not be listed here either).
93
+ PM_SAMPLE_PATH='(^|/)(ThirdParty|Vendor(ed)?)(/|$)'
94
+
95
+ # pm_find_pbxprojs <root> -> all project.pbxproj under *.xcodeproj (pruned), deterministic order.
96
+ pm_find_pbxprojs() {
97
+ local root="${1:-.}"
98
+ find "$root" -name 'project.pbxproj' -path '*.xcodeproj/*' 2>/dev/null \
99
+ | grep -Ev "/($PM_PRUNE_DIRS)/" \
100
+ | LC_ALL=C sort
101
+ }
102
+
103
+ # pm_resolve <root> -> "DIR<TAB>PLIST" (ROOT-relative; PLIST may be empty) for the
104
+ # primary app target, or non-zero with no output.
105
+ #
106
+ # A monorepo may contain several .xcodeproj (samples, sub-projects, the real
107
+ # app). The shallowest one is not necessarily the real app, so every pbxproj
108
+ # is resolved on its own terms (against its own SRCROOT-relative projdir) and
109
+ # the single global best — most *.swift sources — wins across all of them.
110
+ pm_resolve() {
111
+ local root="${1:-.}" pbx rel projdir apps plists app plist dir n cand_dir cand_plist
112
+ # "best" is the primary (non-vendored/sample) bucket; "alt" collects app
113
+ # targets whose project lives under a vendored/sample path (PM_SAMPLE_PATH).
114
+ # alt only wins when no primary candidate exists at all — see the tail.
115
+ local best="" best_plist="" best_n=-1
116
+ local alt="" alt_plist="" alt_n=-1
117
+ local found=0
118
+ # Normalize a trailing slash so the ROOT-relative strip below reliably matches
119
+ # (root="/" strips to "" and must stay "/"; root="." is untouched).
120
+ root="${root%/}"; [[ -z "$root" ]] && root="/"
121
+ while IFS= read -r pbx; do
122
+ [[ -z "$pbx" ]] && continue
123
+ apps="$(pm_app_targets "$pbx")"; [[ -z "$apps" ]] && continue
124
+ found=1
125
+ # ROOT-relative dir that contains this .xcodeproj (SRCROOT). Its
126
+ # INFOPLIST_FILE paths and app source dir are relative to this.
127
+ rel="${pbx#"$root"/}" # e.g. ios/App.xcodeproj/project.pbxproj
128
+ projdir="$(dirname "$(dirname "$rel")")"; projdir="${projdir#./}"
129
+ [[ "$projdir" == "." ]] && projdir=""
130
+ plists="$(pm_infoplist_files "$pbx")"
131
+ while IFS= read -r app; do
132
+ [[ -z "$app" ]] && continue
133
+ # Priority 1: a declared plist whose leading path component equals the
134
+ # app target name.
135
+ plist="$(printf '%s\n' "$plists" | awk -v a="$app" -F/ '$1==a{print; exit}')"
136
+ if [[ -n "$plist" ]]; then
137
+ dir="$(dirname "$plist")"
138
+ else
139
+ # Priority 2 (GENERATE_INFOPLIST_FILE): no leading-component plist.
140
+ # Use the dir named after the target.
141
+ dir="$(cd "$root${projdir:+/$projdir}" 2>/dev/null && \
142
+ find . -type d -name "$app" 2>/dev/null | sed 's#^\./##' \
143
+ | awk '{print length, $0}' | sort -n | head -1 | cut -d' ' -f2-)"
144
+ if [[ -z "$dir" ]]; then
145
+ # Priority 3 (LAST RESORT — only reached when both of the above
146
+ # fail, i.e. the target would otherwise be skipped entirely):
147
+ # attribute the target's OWN INFOPLIST_FILE via the pbxproj's
148
+ # build-config graph. Works even when the plist's leading path
149
+ # component doesn't match the target name (e.g. brave-ios's
150
+ # Client -> "iOS/Supporting Files/Info.plist"), but must not
151
+ # override a working leading-component or GENERATE resolution
152
+ # (e.g. eigen's Artsy, cwa-app-ios's ENA), so it stays last.
153
+ plist="$(pm_target_infoplist "$pbx" "$app" 2>/dev/null)"
154
+ # A literal, unexpanded Xcode build variable (e.g. $(SRCROOT),
155
+ # $(PROJECT_DIR)) can never be found on disk — treat it as
156
+ # unusable rather than resolving to a broken path.
157
+ [[ "$plist" == *'$('* ]] && plist=""
158
+ if [[ -n "$plist" ]]; then
159
+ dir="$(dirname "$plist")"
160
+ else
161
+ continue
162
+ fi
163
+ fi
164
+ fi
165
+ n="$(cd "$root${projdir:+/$projdir}" 2>/dev/null && \
166
+ find "$dir" -name '*.swift' 2>/dev/null | wc -l | tr -d ' ')"
167
+ # Prefix the projdir so paths are ROOT-relative before comparing/storing.
168
+ cand_dir="${projdir:+$projdir/}$dir"
169
+ cand_plist=""
170
+ [[ -n "$plist" ]] && cand_plist="${projdir:+$projdir/}$plist"
171
+ if [[ "$projdir" =~ $PM_SAMPLE_PATH ]]; then
172
+ if (( n > alt_n )); then
173
+ alt_n=$n; alt="$cand_dir"; alt_plist="$cand_plist"
174
+ fi
175
+ else
176
+ if (( n > best_n )); then
177
+ best_n=$n; best="$cand_dir"; best_plist="$cand_plist"
178
+ fi
179
+ fi
180
+ done <<< "$apps"
181
+ done <<< "$(pm_find_pbxprojs "$root")"
182
+ [[ $found -eq 0 ]] && return 1
183
+ if [[ -n "$best" ]]; then
184
+ printf '%s\t%s\n' "$best" "$best_plist"
185
+ elif [[ -n "$alt" ]]; then
186
+ printf '%s\t%s\n' "$alt" "$alt_plist"
187
+ else
188
+ return 1
189
+ fi
190
+ }
@@ -12,6 +12,29 @@ set -u
12
12
  ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT="$(pwd)"
13
13
  cd "$ROOT" || { echo "FAIL: repo-root — could not enter repository root"; exit 0; }
14
14
 
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
+ FINDINGS_TMP="$(mktemp)"; export FINDINGS_TMP
19
+ trap 'rm -f "$FINDINGS_TMP"' EXIT
20
+ FORMAT="text"
21
+ # The envelope `version` is the appstore-precheck TOOL's own version (from this
22
+ # skill's SKILL.md), never the scanned repo's — $ROOT above is the SCANNED app's
23
+ # git root, so reading its package.json here would leak the wrong version.
24
+ PRECHECK_VERSION="$(grep -m1 -E '^[[:space:]]*version:' "$(dirname "${BASH_SOURCE[0]}")/../SKILL.md" 2>/dev/null | sed -E 's/.*version:[[:space:]]*//; s/[[:space:]]*$//')"
25
+ [[ -z "$PRECHECK_VERSION" ]] && PRECHECK_VERSION="dev"
26
+
27
+ while [[ $# -gt 0 ]]; do
28
+ case "$1" in
29
+ --format)
30
+ if [[ $# -lt 2 ]]; then echo "scan.sh: --format needs a value (text|json)" >&2; exit 64; fi
31
+ FORMAT="$2"; shift 2 ;;
32
+ --format=*) FORMAT="${1#*=}"; shift ;;
33
+ *) shift ;;
34
+ esac
35
+ done
36
+ [[ "$FORMAT" == json || "$FORMAT" == text ]] || { echo "scan.sh: --format must be text|json" >&2; exit 64; }
37
+
15
38
  CONFIG="${APPSTORE_PRECHECK_CONFIG:-.appstore-precheck.json}"
16
39
  have_jq() { command -v jq >/dev/null 2>&1; }
17
40
 
@@ -32,9 +55,13 @@ cfg_bool() { # cfg_bool <json-path> — echoes "true"/"false"
32
55
  echo "false"
33
56
  }
34
57
 
35
- fail() { echo "FAIL: $1"; }
36
- warn() { echo "WARN: $1"; }
37
- pass() { echo "PASS: $1"; }
58
+ _LAST_SUPPRESSED=0
59
+ fail() { if is_suppressed "$_CURRENT_RULE" "${2:-}" "${3:-}"; then _record_suppressed FAIL "$1" "${2:-}" "${3:-}"; _LAST_SUPPRESSED=1; else echo "FAIL: $1"; _record FAIL "$1" "${2:-}" "${3:-}"; _LAST_SUPPRESSED=0; fi; }
60
+ warn() { if is_suppressed "$_CURRENT_RULE" "${2:-}" "${3:-}"; then _record_suppressed WARN "$1" "${2:-}" "${3:-}"; _LAST_SUPPRESSED=1; else echo "WARN: $1"; _record WARN "$1" "${2:-}" "${3:-}"; _LAST_SUPPRESSED=0; fi; }
61
+ pass() { if is_suppressed "$_CURRENT_RULE" "${2:-}" "${3:-}"; then _record_suppressed PASS "$1" "${2:-}" "${3:-}"; _LAST_SUPPRESSED=1; else echo "PASS: $1"; _record PASS "$1" "${2:-}" "${3:-}"; _LAST_SUPPRESSED=0; fi; }
62
+
63
+ # detail <text> — indented evidence under the previous finding; skipped when it was suppressed.
64
+ detail() { [[ "${_LAST_SUPPRESSED:-0}" == 1 ]] || printf '%s\n' "$1" | sed 's/^/ /'; }
38
65
 
39
66
  # ===================================================================
40
67
  # Auto-detection of the project layout
@@ -55,6 +82,17 @@ GREP_PRUNE=( --exclude-dir=.git --exclude-dir=Pods --exclude-dir=Carthage
55
82
  --exclude-dir=.claude --exclude-dir=worktrees
56
83
  --exclude-dir=node_modules --exclude-dir=vendor )
57
84
 
85
+ # Load .precheck-ignore path exclusions (Task 2 suppress.sh) and extend both
86
+ # prune sets so a suppressed path is skipped by detection AND grep passes.
87
+ load_precheck_ignore "$ROOT"
88
+ while IFS= read -r _g; do
89
+ [[ -z "$_g" ]] && continue
90
+ PRUNE+=( -not -path "*/$_g/*" -not -path "$_g/*" )
91
+ GREP_PRUNE+=( --exclude-dir="${_g##*/}" )
92
+ done <<PRUNE_GLOBS
93
+ $(precheck_prune_globs)
94
+ PRUNE_GLOBS
95
+
58
96
  # Reads newline-separated paths on stdin, prints the one with the fewest path
59
97
  # segments (the shallowest, i.e. the real project copy rather than a nested one).
60
98
  pick_shallowest() { awk '{ n=gsub(/\//,"/"); print n"\t"$0 }' | sort -n | head -1 | cut -f2-; }
@@ -72,9 +110,21 @@ NONAPP_TARGET='(Watch|Extension|Widget|Intents|Clip|Notification|Share|Sticker|T
72
110
  # can land on a Watch app, an app extension, or a framework instead of the real app.
73
111
  # We score candidates by Swift-file count and deprioritize obvious non-app targets, so
74
112
  # they only win when nothing app-like exists.
113
+ # Sets the globals IOS_DIR (and, when resolved via the project model, PM_INFO_PLIST)
114
+ # directly rather than echoing a result — this function MUST be called without
115
+ # command substitution (no `IOS_DIR="$(detect_ios_dir)"`) so its assignments run
116
+ # in the caller's shell instead of a subshell, where they would be discarded.
75
117
  detect_ios_dir() {
76
118
  local d; d=$(cfg '.iosSourceDir')
77
- [[ -n "$d" ]] && { echo "$d"; return; }
119
+ [[ -n "$d" ]] && { IOS_DIR="$d"; return; }
120
+ # Authoritative: parse the Xcode project model when a .pbxproj exists.
121
+ local pm; pm="$(pm_resolve . 2>/dev/null)"
122
+ if [[ -n "$pm" ]]; then
123
+ PM_INFO_PLIST="$(printf '%s' "$pm" | cut -f2)"
124
+ IOS_DIR="$(printf '%s' "$pm" | cut -f1)"
125
+ return
126
+ fi
127
+ # Fallback: the original grep heuristic (unchanged).
78
128
  local candidates plist entry
79
129
  candidates=$(
80
130
  find . "${PRUNE[@]}" -name Info.plist 2>/dev/null | while IFS= read -r plist; do dirname "$plist"; done
@@ -92,17 +142,18 @@ detect_ios_dir() {
92
142
  (( n > best_n )) && { best_n=$n; best="$dir"; }
93
143
  fi
94
144
  done <<< "$candidates"
95
- [[ -n "$best" ]] && { echo "$best"; return; }
96
- echo "$alt"
145
+ if [[ -n "$best" ]]; then IOS_DIR="$best"; else IOS_DIR="$alt"; fi
97
146
  }
98
147
 
99
- IOS_DIR="$(detect_ios_dir)"
148
+ PM_INFO_PLIST=""
149
+ IOS_DIR=""
150
+ detect_ios_dir
100
151
  META_DIR="$(cfg '.metadataDir')"; [[ -z "$META_DIR" ]] && META_DIR="$(detect_first -type d -name metadata -path '*fastlane*')"
101
152
  SCREEN_DIR="$(cfg '.screenshotsDir')"; [[ -z "$SCREEN_DIR" ]] && SCREEN_DIR="$(detect_first -type d -name screenshots -path '*fastlane*')"
102
153
  XCSTRINGS="$(cfg '.xcstringsPath')"; [[ -z "$XCSTRINGS" ]] && XCSTRINGS="$(detect_first -name 'Localizable.xcstrings')"
103
154
  [[ -z "$XCSTRINGS" ]] && XCSTRINGS="$(detect_first -name '*.xcstrings')"
104
155
  PRIVACY_FILE="$(detect_first -name 'PrivacyInfo.xcprivacy')"
105
- INFO_PLIST="${IOS_DIR%/}/Info.plist"
156
+ INFO_PLIST="${PM_INFO_PLIST:-${IOS_DIR%/}/Info.plist}"
106
157
  REVIEW_PREP="$(cfg '.reviewPrepNotes')"
107
158
 
108
159
  # Paywall / subscription views. A real app spreads its purchase UI across several files
@@ -150,6 +201,8 @@ SUB_KEY="$(cfg '.disclosureKeys.subscription' 'subscription_disclosure')"
150
201
  TRIAL_KEY="$(cfg '.disclosureKeys.trial' 'subscription_trial_disclosure')"
151
202
  CHECK_FAMILY="$(cfg_bool '.optionalChecks.familyControls')"
152
203
 
204
+ if [[ "$FORMAT" == json ]]; then exec 4>&1 1>/dev/null; fi
205
+
153
206
  echo "PASS: layout — ios='${IOS_DIR:-?}' metadata='${META_DIR:-?}' xcstrings='${XCSTRINGS:-?}' locales=${#LOCALES[@]}"
154
207
 
155
208
  # ===================================================================
@@ -159,14 +212,15 @@ echo "PASS: layout — ios='${IOS_DIR:-?}' metadata='${META_DIR:-?}' xcstrings='
159
212
  # (v) "Account Sign-In" is a different rule — its account-deletion requirement is
160
213
  # checked separately in §38.
161
214
  # ===================================================================
215
+ set_rule "privacy-manifest-parity"
162
216
  check_required_reason_api() {
163
217
  local cat="$1" pattern="$2" hits declared
164
218
  hits=$(grep -rEl "$pattern" "$IOS_DIR" --include="*.swift" 2>/dev/null | head -3)
165
219
  declared=$(grep -c "NSPrivacyAccessedAPICategory${cat}" "$PRIVACY_FILE" 2>/dev/null)
166
220
  if [[ -n "$hits" && "${declared:-0}" -eq 0 ]]; then
167
- fail "5.1.1 Required Reason API — '$cat' used in code (e.g. $(echo "$hits" | head -1)) but not declared in PrivacyInfo.xcprivacy"
221
+ 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"
168
222
  elif [[ -z "$hits" && "${declared:-0}" -gt 0 ]]; then
169
- warn "5.1.1 PrivacyInfo — '$cat' declared but no code usage grepped (may be a false positive, verify manually)"
223
+ warn "5.1.1 PrivacyInfo — '$cat' declared but no code usage grepped (may be a false positive, verify manually)" "$PRIVACY_FILE"
170
224
  elif [[ -n "$hits" && "${declared:-0}" -gt 0 ]]; then
171
225
  pass "5.1.1 Required Reason API — '$cat' parity OK"
172
226
  fi
@@ -174,7 +228,7 @@ check_required_reason_api() {
174
228
  if [[ -z "$IOS_DIR" ]]; then
175
229
  warn "layout — could not auto-detect iOS source dir; set .iosSourceDir in $CONFIG"
176
230
  elif [[ -z "$PRIVACY_FILE" ]]; then
177
- fail "5.1.1 Required Reason API — PrivacyInfo.xcprivacy not found (required since May 2024 for apps using Required Reason APIs)"
231
+ fail "5.1.1 Required Reason API — PrivacyInfo.xcprivacy not found (required since May 2024 for apps using Required Reason APIs)" "$INFO_PLIST"
178
232
  else
179
233
  check_required_reason_api "UserDefaults" 'UserDefaults|@AppStorage'
180
234
  check_required_reason_api "FileTimestamp" 'attributesOfItem|creationDate|modificationDate|\.fileCreationDate|\.fileModificationDate'
@@ -186,26 +240,52 @@ fi
186
240
  # ===================================================================
187
241
  # §2 — 5.1.1 NSUsageDescription cross-check (Info.plist)
188
242
  # ===================================================================
243
+ set_rule "usage-description-crosscheck"
189
244
  if [[ ! -f "$INFO_PLIST" ]]; then
190
- [[ -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)"
245
+ [[ -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)" "$INFO_PLIST"
191
246
  else
192
247
  awk '/NS[A-Za-z]+UsageDescription/{key=$0; getline; if($0 ~ /<string>[[:space:]]*<\/string>/) print "EMPTY:"key}' "$INFO_PLIST" | while read -r line; do
193
- [[ -n "$line" ]] && fail "5.1.1 Purpose String — $line (empty usage description is rejected by App Review)"
248
+ [[ -n "$line" ]] && fail "5.1.1 Purpose String — $line (empty usage description is rejected by App Review)" "$INFO_PLIST"
194
249
  done
195
250
  for fw in \
196
251
  "FamilyControls|ManagedSettings|DeviceActivity:NSFamilyControlsUsageDescription" \
197
252
  "CoreLocation:NSLocationWhenInUseUsageDescription" \
198
- "AVFoundation:NSCameraUsageDescription|NSMicrophoneUsageDescription" \
199
- "Photos:NSPhotoLibraryUsageDescription" \
200
253
  "Contacts:NSContactsUsageDescription" \
201
254
  "HealthKit:NSHealthShareUsageDescription"; do
202
255
  framework="${fw%%:*}"; needed_key="${fw##*:}"
203
256
  if grep -rqE "import ($framework)|($framework)\." "$IOS_DIR" --include="*.swift" 2>/dev/null; then
204
257
  if ! grep -qE "$needed_key" "$INFO_PLIST" 2>/dev/null; then
205
- fail "5.1.1 framework '${framework%%|*}' imported but Info.plist is missing '${needed_key%%|*}'"
258
+ fail "5.1.1 framework '${framework%%|*}' imported but Info.plist is missing '${needed_key%%|*}'" "$INFO_PLIST"
206
259
  fi
207
260
  fi
208
261
  done
262
+ # AVFoundation and Photos are gated on real capture/read APIs, not bare
263
+ # imports: an import only signals framework linkage, not camera/mic/library
264
+ # access (e.g. AVFoundation is commonly imported for playback-only use via
265
+ # AVAudioPlayer/AVPlayer, and Photos/PhotosUI's PhotosPicker/PHPickerViewController
266
+ # run out-of-process and need no Info.plist key at all).
267
+ # Camera: purpose string required only when a capture API is actually used.
268
+ if grep -rqE 'AVCaptureDevice|AVCaptureSession|UIImagePickerController' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
269
+ grep -qE 'NSCameraUsageDescription' "$INFO_PLIST" 2>/dev/null || \
270
+ fail "5.1.1 camera capture API used but Info.plist is missing 'NSCameraUsageDescription'" "$INFO_PLIST"
271
+ fi
272
+ # Microphone: required only for recording/capture, not playback. Matched
273
+ # separately from the camera check above: a bare AVCaptureDevice only
274
+ # implies microphone use when it is audio-typed (`.audio` device/media
275
+ # type), not for a video-only capture device.
276
+ if grep -rqE 'AVAudioRecorder|installTap\(|AVAudioEngine\(|AVAudioSession[^;]*\.(playAndRecord|record)|AVCaptureDevice[^;)]*\.audio|for:[[:space:]]*\.audio' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
277
+ grep -qE 'NSMicrophoneUsageDescription' "$INFO_PLIST" 2>/dev/null || \
278
+ fail "5.1.1 microphone/recording API used but Info.plist is missing 'NSMicrophoneUsageDescription'" "$INFO_PLIST"
279
+ fi
280
+ # Photo library READ: PhotosPicker/PHPicker need no key; only true read/fetch APIs do.
281
+ if grep -rqE 'PHAsset\b|PHFetchResult|PHImageManager|fetchAssets|PHAssetCollection' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
282
+ grep -qE 'NSPhotoLibraryUsageDescription' "$INFO_PLIST" 2>/dev/null || \
283
+ fail "5.1.1 Photos read API used but Info.plist is missing 'NSPhotoLibraryUsageDescription'" "$INFO_PLIST"
284
+ elif grep -rqE 'PHAssetCreationRequest|UIImageWriteToSavedPhotosAlbum|performChanges' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
285
+ # Add-only save: covered by the add-only key.
286
+ grep -qE 'NSPhotoLibraryAddUsageDescription' "$INFO_PLIST" 2>/dev/null || \
287
+ fail "5.1.1 Photos add-only API used but Info.plist is missing 'NSPhotoLibraryAddUsageDescription'" "$INFO_PLIST"
288
+ fi
209
289
  fi
210
290
 
211
291
  # ===================================================================
@@ -214,13 +294,14 @@ fi
214
294
  # Ad / attribution / IDFA SDK signal, shared with §16. The reverse of §3: §3 fires
215
295
  # when the ATT framework IS imported; §16 fires when a tracking SDK is present but
216
296
  # the ATT prompt is NOT. Computed once here so the two checks never contradict.
297
+ set_rule "att-usage"
217
298
  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)
218
299
  att_used=$(grep -rlE 'AppTrackingTransparency|ATTrackingManager' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
219
300
  if [[ -n "$att_used" ]]; then
220
301
  if grep -q "NSUserTrackingUsageDescription" "$INFO_PLIST" 2>/dev/null; then
221
302
  pass "5.1.2 ATT — used + NSUserTrackingUsageDescription present"
222
303
  else
223
- fail "5.1.2 ATT framework imported but NSUserTrackingUsageDescription missing in Info.plist ($att_used)"
304
+ fail "5.1.2 ATT framework imported but NSUserTrackingUsageDescription missing in Info.plist ($att_used)" "$INFO_PLIST"
224
305
  fi
225
306
  elif [[ -z "$tracking_sdk" ]]; then
226
307
  pass "5.1.2 ATT — not used (no tracking)"
@@ -231,12 +312,13 @@ fi
231
312
  # ===================================================================
232
313
  # §4 — 2.3.10 Other-platform / competitor mentions in metadata
233
314
  # ===================================================================
315
+ set_rule "competitor-mentions"
234
316
  if [[ -d "$META_DIR" ]]; then
235
317
  banned_re='Android|Google[[:space:]]?Play|Play[[:space:]]?Store|Windows[[:space:]]?Phone|Samsung Galaxy|Huawei AppGallery|F-Droid'
236
318
  hits=$(grep -rEnI "$banned_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -30)
237
319
  if [[ -n "$hits" ]]; then
238
320
  fail "2.3.10 Other-platform mention — banned reference in metadata:"
239
- echo "$hits" | sed 's/^/ /'
321
+ detail "$hits"
240
322
  else
241
323
  pass "2.3.10 Other-platform mentions — metadata clean"
242
324
  fi
@@ -245,6 +327,7 @@ fi
245
327
  # ===================================================================
246
328
  # §5 — 2.3.1 Metadata character limits (Unicode codepoints, like ASC)
247
329
  # ===================================================================
330
+ set_rule "metadata-char-limits"
248
331
  check_len() {
249
332
  local file="$1" limit="$2" label="$3" len
250
333
  [[ -f "$file" ]] || return
@@ -254,7 +337,7 @@ check_len() {
254
337
  len=$(wc -m < "$file" | tr -d ' ')
255
338
  fi
256
339
  [[ -z "$len" ]] && return
257
- (( len > limit )) && fail "2.3.1 $label — $file ${len} chars (limit ${limit})"
340
+ (( len > limit )) && fail "2.3.1 $label — $file ${len} chars (limit ${limit})" "$file"
258
341
  }
259
342
  for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
260
343
  d="$META_DIR/$loc"; [[ -d "$d" ]] || continue
@@ -268,6 +351,7 @@ done
268
351
  # ===================================================================
269
352
  # §6 — 2.3.7 Localized metadata parity across all detected locales
270
353
  # ===================================================================
354
+ set_rule "locale-metadata-parity"
271
355
  if (( ${#LOCALES[@]} > 0 )); then
272
356
  expected_files=(name.txt subtitle.txt description.txt keywords.txt)
273
357
  for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
@@ -294,6 +378,7 @@ fi
294
378
  # ===================================================================
295
379
  # §7 — 2.3.3 Screenshots per locale
296
380
  # ===================================================================
381
+ set_rule "screenshots-per-locale"
297
382
  if [[ -n "$SCREEN_DIR" && -d "$SCREEN_DIR" ]]; then
298
383
  for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
299
384
  d="$SCREEN_DIR/$loc"
@@ -307,20 +392,24 @@ if [[ -n "$SCREEN_DIR" && -d "$SCREEN_DIR" ]]; then
307
392
  done
308
393
  pass "2.3.3 Screenshots — checked ${#LOCALES[@]} locales under $SCREEN_DIR"
309
394
  else
310
- warn "2.3.3 Screenshots — screenshots dir not found (set .screenshotsDir if you manage them in-repo)"
395
+ pass "2.3.3 Screenshots — no in-repo screenshots dir; assumed managed in App Store Connect (set .screenshotsDir to check in-repo)"
311
396
  fi
312
397
 
313
398
  # ===================================================================
314
399
  # In-app purchase gate — only run 3.1.2 checks if IAP signals exist
315
400
  # ===================================================================
316
401
  iap_detected=""
317
- grep -rqE 'import StoreKit|RevenueCat|Purchases\.|Product\(for:|AppStore\.|SKProduct|StoreKit2' "$IOS_DIR" --include="*.swift" 2>/dev/null && iap_detected=1
402
+ 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
318
403
  [[ -n "$SUB_VIEW" ]] && iap_detected=1
319
404
 
320
405
  if [[ -z "$iap_detected" ]]; then
406
+ # Not one of the 41 catalog sections — clear the rule_id so this PASS doesn't
407
+ # inherit §7's "screenshots-per-locale" slug in the JSON output.
408
+ set_rule ""
321
409
  pass "3.1.2 IAP — no in-app purchase / subscription signals detected, skipping paywall checks"
322
410
  else
323
411
  # ---- §8 3.1.2 Trial disclosure -------------------------------------------------
412
+ set_rule "trial-disclosure"
324
413
  trial_re='free[[:space:]]?trial|trial[[:space:]]?period|ücretsiz[[:space:]]?dene|kostenlos[[:space:]]?test|essai[[:space:]]?gratuit|prueba[[:space:]]?grat|無料.*トライアル|무료[[:space:]]?체험'
325
414
  if [[ -n "$XCSTRINGS" ]] && grep -EqI "$trial_re" "$XCSTRINGS" 2>/dev/null; then
326
415
  if grep -q "$TRIAL_KEY" "$XCSTRINGS" 2>/dev/null; then
@@ -333,6 +422,7 @@ else
333
422
  fi
334
423
 
335
424
  # ---- §9 3.1.2 Auto-renew subscription disclosure -------------------------------
425
+ set_rule "autorenew-disclosure"
336
426
  if [[ -n "$XCSTRINGS" ]]; then
337
427
  if have_jq && jq -e ".strings | has(\"$SUB_KEY\")" "$XCSTRINGS" >/dev/null 2>&1; then
338
428
  pass "3.1.2 subscription disclosure key '$SUB_KEY' present"
@@ -353,6 +443,7 @@ else
353
443
  # Grep across the whole paywall cluster: a link present in ANY paywall view satisfies
354
444
  # the requirement; only its absence from ALL of them is a FAIL. SUB_VIEW names the
355
445
  # representative file in messages.
446
+ set_rule "subscription-links-restore"
356
447
  if (( ${#PAYWALL_FILES[@]} > 0 )); then
357
448
  if grep -qE 'restore|restorePurchases' "${PAYWALL_FILES[@]}"; then
358
449
  pass "3.1.2 Restore Purchases — present in $(basename "$SUB_VIEW")"
@@ -377,11 +468,15 @@ fi
377
468
  # ===================================================================
378
469
  # §11 — 2.5.1 Private / banned API
379
470
  # ===================================================================
471
+ set_rule "private-api"
380
472
  banned_api='UIWebView|setSelectionIndicatorImage|_UIBackdropView|NSURLConnection[^a-zA-Z]|UIAlertView[^Q]|UIActionSheet[^Q]'
381
473
  banned_hits=$(grep -rEnI "$banned_api" "$IOS_DIR" --include="*.swift" --include="*.m" --include="*.h" 2>/dev/null | head -5)
382
474
  if [[ -n "$banned_hits" ]]; then
383
- fail "2.5.1 Private/Deprecated API:"
384
- echo "$banned_hits" | sed 's/^/ /'
475
+ pa_first="$(printf '%s\n' "$banned_hits" | head -1)" # "path:line:match"
476
+ pa_file="${pa_first%%:*}"
477
+ pa_rest="${pa_first#*:}"; pa_line="${pa_rest%%:*}"
478
+ fail "2.5.1 Private/Deprecated API:" "$pa_file" "$pa_line"
479
+ detail "$banned_hits"
385
480
  else
386
481
  pass "2.5.1 Private API — clean"
387
482
  fi
@@ -389,7 +484,8 @@ fi
389
484
  # ===================================================================
390
485
  # §12 — 4.2 Minimum functionality — navigation hubs
391
486
  # ===================================================================
392
- tab_count=$(grep -rcE 'TabView|NavigationStack|NavigationSplitView' "$IOS_DIR" --include="*.swift" 2>/dev/null | awk -F: '{sum+=$2} END {print sum+0}')
487
+ set_rule "min-functionality-nav"
488
+ tab_count=$(grep -rcE 'TabView|NavigationStack|NavigationSplitView|NavigationView|UITabBarController|UINavigationController|createBottomTabNavigator|createStackNavigator|createNativeStackNavigator' . "${GREP_PRUNE[@]}" --include='*.swift' --include='*.m' --include='*.js' --include='*.jsx' --include='*.ts' --include='*.tsx' 2>/dev/null | awk -F: '{sum+=$2} END {print sum+0}')
393
489
  if (( tab_count < 1 )); then
394
490
  warn "4.2 Minimum functionality — no TabView/NavigationStack found (heuristic, may be a false positive)"
395
491
  else
@@ -399,6 +495,7 @@ fi
399
495
  # ===================================================================
400
496
  # §13 — 5.1.5 Screen Time / sensitive-API justification (optional, opt-in)
401
497
  # ===================================================================
498
+ set_rule "screentime-justification"
402
499
  if [[ "$CHECK_FAMILY" == "true" ]] && grep -q "NSFamilyControlsUsageDescription" "$INFO_PLIST" 2>/dev/null; then
403
500
  if [[ -n "$REVIEW_PREP" && -f "$REVIEW_PREP" ]] && grep -qiE 'family|screen[[:space:]]?time' "$REVIEW_PREP" 2>/dev/null; then
404
501
  pass "5.1.5 Screen Time API — reviewer-prep justification note present"
@@ -410,6 +507,7 @@ fi
410
507
  # ===================================================================
411
508
  # §14 — 4.8 Sign in with Apple parity (only when a third-party social login is used)
412
509
  # ===================================================================
510
+ set_rule "siwa-parity"
413
511
  if [[ -n "$IOS_DIR" ]]; then
414
512
  social_login=$(grep -rlE 'GIDSignIn|import GoogleSignIn|FBSDKLoginKit|FBSDKLoginManager|FacebookLogin|import Auth0|LineSDK|VKSdkAuthorization' "$IOS_DIR" --include='*.swift' 2>/dev/null | head -1)
415
513
  if [[ -n "$social_login" ]]; then
@@ -424,6 +522,7 @@ fi
424
522
  # ===================================================================
425
523
  # §15 — 3.1.1(a) External purchase link (StoreKit External Purchase)
426
524
  # ===================================================================
525
+ set_rule "external-purchase-link"
427
526
  ext_purchase=""
428
527
  grep -rqE 'ExternalPurchase|ExternalPurchaseLink|ExternalPurchaseCustomLink' "${IOS_DIR:-.}" --include='*.swift' 2>/dev/null && ext_purchase=1
429
528
  grep -rqE 'external-purchase' "${IOS_DIR:-.}" --include='*.entitlements' 2>/dev/null && ext_purchase=1
@@ -437,6 +536,7 @@ fi
437
536
  # §3 catches "ATT framework imported but no usage string". This catches the more
438
537
  # common rejection: shipping an ad / attribution SDK (or touching the IDFA) without
439
538
  # ever presenting the ATT prompt. `tracking_sdk` and `att_used` are computed in §3.
539
+ set_rule "tracking-sdk-no-att"
440
540
  if [[ -n "$tracking_sdk" ]]; then
441
541
  att_present=""
442
542
  [[ -n "$att_used" ]] && att_present=1
@@ -455,11 +555,12 @@ fi
455
555
  # submission. Setting it (true/false) removes that friction. Only checked when an
456
556
  # Info.plist exists; a modern app may auto-generate it, in which case the key lives
457
557
  # in build settings, so we stay silent rather than nag.
558
+ set_rule "export-compliance"
458
559
  if [[ -f "$INFO_PLIST" ]]; then
459
560
  if grep -q "ITSAppUsesNonExemptEncryption" "$INFO_PLIST" 2>/dev/null; then
460
561
  pass "export-compliance — ITSAppUsesNonExemptEncryption set in Info.plist"
461
562
  else
462
- warn "export-compliance — Info.plist has no ITSAppUsesNonExemptEncryption; set it (true/false) to skip the App Store Connect encryption-export prompt every submission"
563
+ warn "export-compliance — Info.plist has no ITSAppUsesNonExemptEncryption; set it (true/false) to skip the App Store Connect encryption-export prompt every submission" "$INFO_PLIST"
463
564
  fi
464
565
  fi
465
566
 
@@ -470,6 +571,7 @@ fi
470
571
  # support_url.txt / privacy_url.txt / marketing_url.txt. Apple requires a working
471
572
  # support URL, and a privacy policy URL for apps with accounts or IAP. Flag when
472
573
  # they are absent across every locale, or contain an obvious placeholder.
574
+ set_rule "support-privacy-url"
473
575
  if [[ -d "$META_DIR" ]]; then
474
576
  support_found="" privacy_found=""
475
577
  for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
@@ -482,8 +584,9 @@ if [[ -d "$META_DIR" ]]; then
482
584
  fi
483
585
  url_ph=$(grep -rEnI 'example\.com|localhost|\bTODO\b|\bchangeme\b' "$META_DIR" --include='*_url.txt' 2>/dev/null | grep -v "^Binary file" | head -10)
484
586
  if [[ -n "$url_ph" ]]; then
485
- warn "2.3 Metadata URL placeholder URL in fastlane metadata (replace before submitting):"
486
- echo "$url_ph" | sed 's/^/ /'
587
+ url_ph_file="$(printf '%s\n' "$url_ph" | head -1 | cut -d: -f1)"
588
+ warn "2.3 Metadata URL — placeholder URL in fastlane metadata (replace before submitting):" "$url_ph_file"
589
+ detail "$url_ph"
487
590
  fi
488
591
  fi
489
592
 
@@ -495,7 +598,8 @@ fi
495
598
  # domains, the privacy manifest and the App Privacy nutrition labels are probably
496
599
  # incomplete. Soft "verify" wording (a crash-only Sentry may genuinely collect
497
600
  # nothing), so WARN, never FAIL.
498
- analytics_sdk=$(grep -rlE 'FirebaseAnalytics|import Firebase|Amplitude|Mixpanel|import Sentry|Segment|Bugsnag|AppCenterAnalytics|Datadog' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
601
+ set_rule "analytics-privacyinfo-mismatch"
602
+ 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)
499
603
  if [[ -n "$analytics_sdk" ]]; then
500
604
  declared_data=""
501
605
  if [[ -n "$PRIVACY_FILE" && -f "$PRIVACY_FILE" ]]; then
@@ -507,7 +611,7 @@ if [[ -n "$analytics_sdk" ]]; then
507
611
  if [[ -n "$declared_data" ]]; then
508
612
  pass "5.1.1 Privacy manifest — analytics SDK present and PrivacyInfo declares collected data"
509
613
  else
510
- 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"
614
+ 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" "$PRIVACY_FILE"
511
615
  fi
512
616
  fi
513
617
 
@@ -518,12 +622,14 @@ fi
518
622
  # get rejected under 2.1 and look unfinished. Conservative, specific patterns to
519
623
  # avoid flagging legitimate words. Overlaps the Phase 2 precheck "No placeholder
520
624
  # text" rule, but this one is local and runs before any network call.
625
+ set_rule "placeholder-metadata"
521
626
  if [[ -d "$META_DIR" ]]; then
522
627
  ph_re='lorem ipsum|Lorem ipsum|\bTODO\b|\bFIXME\b|example\.com|placeholder|insert .* here|\bchangeme\b'
523
628
  ph_hits=$(grep -rEnI "$ph_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -20)
524
629
  if [[ -n "$ph_hits" ]]; then
525
- warn "2.1 Metadata content placeholder/dummy text in store metadata (looks unfinished; rejected under 2.1):"
526
- echo "$ph_hits" | sed 's/^/ /'
630
+ ph_file="$(printf '%s\n' "$ph_hits" | head -1 | cut -d: -f1)"
631
+ warn "2.1 Metadata content — placeholder/dummy text in store metadata (looks unfinished; rejected under 2.1):" "$ph_file"
632
+ detail "$ph_hits"
527
633
  fi
528
634
  fi
529
635
 
@@ -534,6 +640,7 @@ fi
534
640
  # third-party payment SDK (Stripe, Braintree, PayPal, …) is legitimate for
535
641
  # PHYSICAL goods/services but a frequent rejection when used to sell digital
536
642
  # content. We can't tell digital from physical statically, so this is advisory.
643
+ set_rule "thirdparty-payment-sdk"
537
644
  if [[ -n "$IOS_DIR" ]]; then
538
645
  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)
539
646
  if [[ -n "$payment_sdk" ]]; then
@@ -547,6 +654,7 @@ fi
547
654
  # Apps with UGC must provide: a content filter, a report mechanism, the ability
548
655
  # to block abusive users, and published contact info. We detect a UGC signal and
549
656
  # warn when no report/block/moderation affordance is found anywhere in the source.
657
+ set_rule "ugc-no-moderation"
550
658
  if [[ -n "$IOS_DIR" ]]; then
551
659
  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)
552
660
  if [[ -n "$ugc_signal" ]]; then
@@ -563,12 +671,13 @@ fi
563
671
  # ===================================================================
564
672
  # NSAllowsArbitraryLoads=true turns off ATS for the whole app, weakening
565
673
  # data-in-transit security and inviting a 1.6 / data-security question at review.
674
+ set_rule "ats-arbitrary-loads"
566
675
  if [[ -f "$INFO_PLIST" ]]; then
567
676
  # Anchor to the exact key: `NSAllowsArbitraryLoads</key>` (followed by `<`), so the
568
677
  # narrower scoped exceptions NSAllowsArbitraryLoadsInWebContent / ...ForMedia — which
569
678
  # do NOT disable ATS app-wide and are the recommended alternative — don't false-fire.
570
679
  if awk '/NSAllowsArbitraryLoads</{getline; if ($0 ~ /<true/) print "ON"}' "$INFO_PLIST" 2>/dev/null | grep -q ON; then
571
- warn "1.6 App Transport Security — NSAllowsArbitraryLoads=true in Info.plist disables ATS app-wide; prefer per-domain exceptions, and expect a data-security justification request at review (1.6)"
680
+ warn "1.6 App Transport Security — NSAllowsArbitraryLoads=true in Info.plist disables ATS app-wide; prefer per-domain exceptions, and expect a data-security justification request at review (1.6)" "$INFO_PLIST"
572
681
  fi
573
682
  fi
574
683
 
@@ -580,6 +689,7 @@ fi
580
689
  # Pay recurring API (PKRecurringPaymentRequest) so it does NOT conflate StoreKit
581
690
  # auto-renew copy or a one-time PassKit payment with recurring Apple Pay. We don't
582
691
  # try to detect the disclosure text (too noisy) — we flag it for manual verify.
692
+ set_rule "applepay-recurring-disclosure"
583
693
  if [[ -n "$IOS_DIR" ]] && grep -rqE 'PKRecurringPaymentRequest' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
584
694
  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)"
585
695
  fi
@@ -589,6 +699,7 @@ fi
589
699
  # ===================================================================
590
700
  # Apple disallows custom review prompts and direct write-review links — apps must
591
701
  # use the system SKStoreReviewController / requestReview API.
702
+ set_rule "custom-review-prompt"
592
703
  if [[ -n "$IOS_DIR" ]]; then
593
704
  review_link=$(grep -rlE 'write-review|action=write-review|itms-apps[^"]*review' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
594
705
  if [[ -n "$review_link" ]]; then
@@ -605,12 +716,14 @@ fi
605
716
  # ===================================================================
606
717
  # Marketing the app for things iOS apps can't actually do (virus/malware
607
718
  # scanners, fake speed boosters) is a 2.3.1 removal vector.
719
+ set_rule "misleading-marketing"
608
720
  if [[ -d "$META_DIR" ]]; then
609
721
  mislead_re='virus scan|virus scanner|antivirus|anti-virus|malware (scan|remov|clean)|spyware remov|clean your (iphone|device)|speed booster|boost.*(speed|ram)|free money|guaranteed.*(win|prize)'
610
722
  mislead_hits=$(grep -rEniI "$mislead_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -10)
611
723
  if [[ -n "$mislead_hits" ]]; then
612
- warn "2.3.1 Misleading marketing claims that often violate 2.3.1 (e.g. iOS virus/malware scanners, fake speed boosters) in metadata; verify the app truly delivers them or remove the claim:"
613
- echo "$mislead_hits" | sed 's/^/ /'
724
+ mislead_file="$(printf '%s\n' "$mislead_hits" | head -1 | cut -d: -f1)"
725
+ warn "2.3.1 Misleading marketing — claims that often violate 2.3.1 (e.g. iOS virus/malware scanners, fake speed boosters) in metadata; verify the app truly delivers them or remove the claim:" "$mislead_file"
726
+ detail "$mislead_hits"
614
727
  fi
615
728
  fi
616
729
 
@@ -619,6 +732,7 @@ fi
619
732
  # ===================================================================
620
733
  # Terms implying a child audience in name/subtitle/keywords/description are
621
734
  # reserved for the Kids Category (2.3.8 / 5.1.4).
735
+ set_rule "kids-wording"
622
736
  if [[ -d "$META_DIR" ]]; then
623
737
  kids_re='for kids|for children|for your (kid|child)|kids[[:space:]]?app|für kinder|para niños|pour enfants'
624
738
  kids_hits=$(grep -rEniI "$kids_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -10)
@@ -626,8 +740,9 @@ if [[ -d "$META_DIR" ]]; then
626
740
  # more specific Kids finding for the same wording signal — don't double-count it here
627
741
  # (one root signal must not cost two WARNs against the 5-WARN YELLOW threshold).
628
742
  if [[ -n "$kids_hits" && -z "${tracking_sdk:-}" && -z "${analytics_sdk:-}" ]]; then
629
- warn "2.3.8 'For Kids/Children' wording terms implying a child audience are reserved for the Kids Category (2.3.8); if not enrolled, remove them from name/subtitle/keywords/description:"
630
- echo "$kids_hits" | sed 's/^/ /'
743
+ kids_file="$(printf '%s\n' "$kids_hits" | head -1 | cut -d: -f1)"
744
+ warn "2.3.8 'For Kids/Children' wording — terms implying a child audience are reserved for the Kids Category (2.3.8); if not enrolled, remove them from name/subtitle/keywords/description:" "$kids_file"
745
+ detail "$kids_hits"
631
746
  fi
632
747
  fi
633
748
 
@@ -636,6 +751,7 @@ fi
636
751
  # ===================================================================
637
752
  # Keyboards must stay functional without full network access / "full access",
638
753
  # and may only collect data to enhance the keyboard.
754
+ set_rule "keyboard-full-access"
639
755
  kb_plist=$(grep -rlE 'com\.apple\.keyboard-service' --include='Info.plist' "${GREP_PRUNE[@]}" . 2>/dev/null | pick_shallowest)
640
756
  if [[ -n "$kb_plist" ]]; then
641
757
  if grep -A1 'RequestsOpenAccess' "$kb_plist" 2>/dev/null | grep -q '<true'; then
@@ -650,6 +766,7 @@ fi
650
766
  # ===================================================================
651
767
  # Personal health information must not be stored in iCloud, and HealthKit data
652
768
  # may not be used for advertising/marketing.
769
+ set_rule "health-icloud-sync"
653
770
  if [[ -n "$IOS_DIR" ]] && grep -rqE 'import HealthKit|HKHealthStore' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
654
771
  if grep -rqE 'import CloudKit|CKRecord|CKContainer|NSUbiquitousKeyValueStore|NSUbiquitousContainer' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
655
772
  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."
@@ -663,6 +780,7 @@ fi
663
780
  # ===================================================================
664
781
  # VPN apps must be offered by an organization account, declare data collection
665
782
  # on-screen before use, and may not sell/share data.
783
+ set_rule "vpn-networkextension"
666
784
  if [[ -n "$IOS_DIR" ]]; then
667
785
  vpn_use=$(grep -rlE 'NEVPNManager|NETunnelProviderManager|NEPacketTunnelProvider|NEVPNProtocol' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
668
786
  if [[ -n "$vpn_use" ]]; then
@@ -677,6 +795,7 @@ fi
677
795
  # or notes). We fire only on a credential-login signal (a password field or a
678
796
  # Login/SignIn view), then look for demo creds in fastlane review_information or
679
797
  # the reviewer-prep notes. Social-only logins are not gated here (too noisy).
798
+ set_rule "demo-account"
680
799
  if [[ -n "$IOS_DIR" ]]; then
681
800
  auth_signal=$(grep -rlE 'SecureField' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
682
801
  [[ -z "$auth_signal" ]] && auth_signal=$(find "$IOS_DIR" "${PRUNE[@]}" \( -name '*Login*View*.swift' -o -name '*SignIn*View*.swift' \) 2>/dev/null | head -1)
@@ -703,6 +822,7 @@ fi
703
822
  # Apps must be self-contained; native hot-patching frameworks (JSPatch, Rollout,
704
823
  # DynamicCocoa) download code that changes features and are a removal vector.
705
824
  # JS-bundle OTA for React Native (CodePush) is allowed, so we do NOT flag it.
825
+ set_rule "executable-code-download"
706
826
  if [[ -n "$IOS_DIR" ]]; then
707
827
  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)
708
828
  if [[ -n "$hotcode" ]]; then
@@ -716,6 +836,7 @@ fi
716
836
  # Declare only the UIBackgroundModes the app actually uses; a mode declared with
717
837
  # no matching API is a frequent rejection. We parse the array and check each
718
838
  # declared mode against its framework/API in Swift.
839
+ set_rule "background-modes-unused"
719
840
  if [[ -f "$INFO_PLIST" && -n "$IOS_DIR" ]]; then
720
841
  modes=$(awk '/<key>UIBackgroundModes<\/key>/{f=1;next} f&&/<\/array>/{f=0} f&&/<string>/{gsub(/.*<string>|<\/string>.*/,""); print}' "$INFO_PLIST" 2>/dev/null)
721
842
  if [[ -n "$modes" ]]; then
@@ -733,7 +854,7 @@ if [[ -f "$INFO_PLIST" && -n "$IOS_DIR" ]]; then
733
854
  esac
734
855
  done <<< "$modes"
735
856
  if [[ -n "$unused" ]]; then
736
- warn "2.5.4 Background modes —$unused declared in UIBackgroundModes but no matching API usage found in Swift; declare only the background modes the app actually uses (2.5.4)"
857
+ warn "2.5.4 Background modes —$unused declared in UIBackgroundModes but no matching API usage found in Swift; declare only the background modes the app actually uses (2.5.4)" "$INFO_PLIST"
737
858
  else
738
859
  pass "2.5.4 Background modes — declared modes have matching API usage"
739
860
  fi
@@ -743,6 +864,7 @@ fi
743
864
  # ===================================================================
744
865
  # §34 — 3.1.5(a) Cryptocurrency wallet / exchange / mining
745
866
  # ===================================================================
867
+ set_rule "crypto-wallet-mining"
746
868
  if [[ -n "$IOS_DIR" ]]; then
747
869
  crypto_sdk=$(grep -rlE 'import Web3|web3swift|Web3Swift|WalletConnect|TrustWalletCore|CoinbaseWalletSDK|SolanaSwift|CryptoMining|coinhive|MoneroMiner' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
748
870
  if [[ -n "$crypto_sdk" ]]; then
@@ -756,6 +878,7 @@ fi
756
878
  # A thin WKWebView wrapper around a website is rejected under minimum
757
879
  # functionality. Heuristic: WKWebView present in a project with very few Swift
758
880
  # files. WARN (verify) — this is the most false-positive-prone of the batch.
881
+ set_rule "webview-wrapper"
759
882
  if [[ -n "$IOS_DIR" ]]; then
760
883
  if grep -rqE 'WKWebView' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
761
884
  swift_n=$(find "$IOS_DIR" "${PRUNE[@]}" -name '*.swift' 2>/dev/null | wc -l | tr -d ' ')
@@ -768,6 +891,7 @@ fi
768
891
  # ===================================================================
769
892
  # §36 — 4.2.7 Remote desktop / host-mirroring
770
893
  # ===================================================================
894
+ set_rule "remote-desktop"
771
895
  if [[ -n "$IOS_DIR" ]]; then
772
896
  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)
773
897
  if [[ -n "$remote_desktop" ]]; then
@@ -778,6 +902,7 @@ fi
778
902
  # ===================================================================
779
903
  # §37 — 4.4.2 Safari extension / content blocker
780
904
  # ===================================================================
905
+ set_rule "safari-extension"
781
906
  safari_ext=$(grep -rlE 'com\.apple\.Safari\.(content-blocker|web-extension|extension)' --include='Info.plist' "${GREP_PRUNE[@]}" . 2>/dev/null | pick_shallowest)
782
907
  if [[ -n "$safari_ext" ]]; then
783
908
  warn "4.4.2 Safari extension — a Safari content-blocker / web extension was detected ($(basename "$safari_ext")); it must use the extension APIs as intended, do only what it declares, and not include hidden analytics/ads or track without consent (4.4.2). Verify."
@@ -789,6 +914,7 @@ fi
789
914
  # Apple 5.1.1(v): apps that support account creation must also let users delete
790
915
  # their account from within the app. Detect account-creation signals, then look
791
916
  # for an in-app deletion path. Deletion via a web page is missed → WARN, not FAIL.
917
+ set_rule "account-no-delete"
792
918
  if [[ -n "$IOS_DIR" ]]; then
793
919
  signup=$(grep -rlE 'createUser|signUp|signup|createAccount|registerUser|registerNewUser|Auth\.auth\(\)\.createUser' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
794
920
  if [[ -n "$signup" ]]; then
@@ -807,6 +933,7 @@ fi
807
933
  # must include a parental gate. We fire when the metadata targets a child
808
934
  # audience AND an ad/analytics SDK is linked. tracking_sdk/analytics_sdk come
809
935
  # from §3/§19.
936
+ set_rule "kids-ads-analytics"
810
937
  if [[ -d "$META_DIR" && -n "$IOS_DIR" ]]; then
811
938
  if grep -rqEiI 'for kids|for children|kids[[:space:]]?app|für kinder|para niños|pour enfants' "$META_DIR" 2>/dev/null; then
812
939
  if [[ -n "$tracking_sdk" || -n "$analytics_sdk" ]]; then
@@ -818,18 +945,23 @@ fi
818
945
  # ===================================================================
819
946
  # §40 — 5.3.4 Real-money gambling
820
947
  # ===================================================================
948
+ set_rule "realmoney-gambling"
821
949
  if [[ -d "$META_DIR" ]]; then
822
950
  gamble_re='real[ -]?money|gambling|casino|sportsbook|sports[ -]?betting|place[ -].*bets|wager(ing)?|roulette.*real'
823
951
  gamble_hits=$(grep -rEniI "$gamble_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -10)
824
952
  if [[ -n "$gamble_hits" ]]; then
825
- warn "5.3.4 Gambling real-money gaming language in metadata; real-money gambling/lotteries need the proper licenses, must be geo-restricted to permitted regions, and must be free on the App Store (5.3.4):"
826
- echo "$gamble_hits" | sed 's/^/ /'
953
+ gamble_first="$(printf '%s\n' "$gamble_hits" | head -1)" # "path:line:match"
954
+ gamble_file="${gamble_first%%:*}"
955
+ gamble_rest="${gamble_first#*:}"; gamble_line="${gamble_rest%%:*}"
956
+ warn "5.3.4 Gambling — real-money gaming language in metadata; real-money gambling/lotteries need the proper licenses, must be geo-restricted to permitted regions, and must be free on the App Store (5.3.4):" "$gamble_file" "$gamble_line"
957
+ detail "$gamble_hits"
827
958
  fi
828
959
  fi
829
960
 
830
961
  # ===================================================================
831
962
  # §41 — 5.5 Mobile Device Management
832
963
  # ===================================================================
964
+ set_rule "mdm"
833
965
  if [[ -n "$IOS_DIR" ]]; then
834
966
  mdm_sig=$(grep -rlE 'import DeviceManagement|MDMConfiguration|ManagedAppConfiguration|com\.apple\.mdm' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
835
967
  [[ -z "$mdm_sig" ]] && mdm_sig=$(grep -rlE 'com\.apple\.configuration\.managed' --include='*.plist' "${GREP_PRUNE[@]}" . 2>/dev/null | pick_shallowest)
@@ -839,3 +971,7 @@ if [[ -n "$IOS_DIR" ]]; then
839
971
  fi
840
972
 
841
973
  echo "---END-OF-SCAN---"
974
+ if [[ "$FORMAT" == text && "${_SUPPRESSED_COUNT:-0}" -gt 0 ]]; then
975
+ printf '(%s finding(s) suppressed via .precheck-ignore)\n' "$_SUPPRESSED_COUNT"
976
+ fi
977
+ if [[ "$FORMAT" == json ]]; then exec 1>&4 4>&-; render_json; fi
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env bash
2
+ # suppress.sh — .precheck-ignore + inline suppression for scan.sh.
3
+ # Sourced by scan.sh AFTER findings.sh (needs rule_slug). Bash 3.2: no associative arrays.
4
+
5
+ _SUPP_RULES="" # rule-ids suppressed everywhere, one per line
6
+ _SUPP_RULE_PATH="" # "rule<TAB>glob" per line
7
+ _SUPP_PATHS="" # path globs excluded from scanning, one per line (trailing / stripped)
8
+ _SUPP_REASON="" # set by is_suppressed on a hit
9
+
10
+ # _is_catalog_rule <token> -> 0 if token is a known rule slug.
11
+ _is_catalog_rule() {
12
+ local n s
13
+ n=1
14
+ while [[ $n -le 41 ]]; do
15
+ s="$(rule_slug "$n")"
16
+ [[ "$s" == "$1" ]] && return 0
17
+ n=$((n + 1))
18
+ done
19
+ return 1
20
+ }
21
+
22
+ # load_precheck_ignore [root]
23
+ load_precheck_ignore() {
24
+ local root="${1:-.}" file line t1 t2
25
+ file="$root/.precheck-ignore"
26
+ _SUPP_RULES=""; _SUPP_RULE_PATH=""; _SUPP_PATHS=""
27
+ [[ -f "$file" ]] || return 0
28
+ while IFS= read -r line || [[ -n "$line" ]]; do
29
+ line="${line%%#*}" # strip trailing comment
30
+ line="$(printf '%s' "$line" | awk '{$1=$1;print}')" # trim ends, collapse ws
31
+ [[ -z "$line" ]] && continue
32
+ t1="$(printf '%s' "$line" | awk '{print $1}')"
33
+ t2="$(printf '%s' "$line" | awk '{print $2}')"
34
+ if _is_catalog_rule "$t1"; then
35
+ if [[ -n "$t2" ]]; then
36
+ _SUPP_RULE_PATH="${_SUPP_RULE_PATH}${t1} ${t2%/}
37
+ "
38
+ else
39
+ _SUPP_RULES="${_SUPP_RULES}${t1}
40
+ "
41
+ fi
42
+ elif [[ "$t1" == */* || "$t1" == *.* || "$t1" == *"*"* ]]; then
43
+ _SUPP_PATHS="${_SUPP_PATHS}${t1%/}
44
+ "
45
+ else
46
+ printf 'suppress: unknown rule-id %s in .precheck-ignore (ignored)\n' "$t1" >&2
47
+ fi
48
+ done < "$file"
49
+ }
50
+
51
+ # precheck_prune_globs -> one path glob per line (for scan.sh PRUNE/GREP_PRUNE).
52
+ precheck_prune_globs() { printf '%s' "$_SUPP_PATHS"; }
53
+
54
+ # _inline_marker <line-text> <rule> -> 0 if a real comment marker suppresses <rule>.
55
+ _inline_marker() {
56
+ local text="$1" rule="$2" spec
57
+ printf '%s' "$text" | grep -qE '(//|#|<!--)[[:space:]]*precheck:ignore' || return 1
58
+ spec="$(printf '%s' "$text" | sed -nE 's/.*precheck:ignore[[:space:]]*([a-z][a-z0-9-]*).*/\1/p')"
59
+ [[ -z "$spec" ]] && return 0 # bare marker suppresses any rule
60
+ [[ "$spec" == "$rule" ]] # scoped marker must match
61
+ }
62
+
63
+ # is_suppressed <rule> <file> <line> -> 0 if suppressed (+ _SUPP_REASON), else 1.
64
+ is_suppressed() {
65
+ local rule="$1" file="${2:-}" line="${3:-}" r g target
66
+ _SUPP_REASON=""
67
+ if [[ -n "$rule" ]] && printf '%s\n' "$_SUPP_RULES" | grep -qxF "$rule"; then
68
+ _SUPP_REASON="rule:$rule"; return 0
69
+ fi
70
+ if [[ -n "$rule" && -n "$file" && -n "$_SUPP_RULE_PATH" ]]; then
71
+ while IFS=' ' read -r r g; do
72
+ [[ -z "$r" ]] && continue
73
+ if [[ "$r" == "$rule" ]]; then
74
+ # shellcheck disable=SC2254
75
+ case "$file" in
76
+ $g|*/$g|$g/*|*/$g/*) _SUPP_REASON="rule-path:$rule:$g"; return 0 ;;
77
+ esac
78
+ fi
79
+ done <<INNER
80
+ $_SUPP_RULE_PATH
81
+ INNER
82
+ fi
83
+ if [[ -n "$file" && -n "$line" && -f "$file" ]]; then
84
+ target="$(sed -n "${line}p" "$file" 2>/dev/null)"
85
+ if _inline_marker "$target" "$rule"; then _SUPP_REASON="inline"; return 0; fi
86
+ if [[ "$line" -gt 1 ]]; then
87
+ target="$(sed -n "$((line - 1))p" "$file" 2>/dev/null)"
88
+ if _inline_marker "$target" "$rule"; then _SUPP_REASON="inline-above"; return 0; fi
89
+ fi
90
+ fi
91
+ return 1
92
+ }