appstore-precheck 1.6.0 → 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
@@ -5,6 +5,34 @@ All notable changes to this project are documented here. Versioning follows
5
5
 
6
6
  ## [Unreleased]
7
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
+
8
36
  ## [1.6.0] - 2026-07-01
9
37
 
10
38
  Measurement release: structured findings, suppression, and a published
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appstore-precheck",
3
- "version": "1.6.0",
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)",
@@ -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.6.0
7
+ version: 1.7.0
8
8
  allowed-tools: Bash Read Grep Glob WebFetch
9
9
  ---
10
10
 
@@ -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
+ }
@@ -14,6 +14,7 @@ cd "$ROOT" || { echo "FAIL: repo-root — could not enter repository root"; exit
14
14
 
15
15
  source "$(dirname "${BASH_SOURCE[0]}")/findings.sh"
16
16
  source "$(dirname "${BASH_SOURCE[0]}")/suppress.sh"
17
+ source "$(dirname "${BASH_SOURCE[0]}")/project-model.sh"
17
18
  FINDINGS_TMP="$(mktemp)"; export FINDINGS_TMP
18
19
  trap 'rm -f "$FINDINGS_TMP"' EXIT
19
20
  FORMAT="text"
@@ -109,9 +110,21 @@ NONAPP_TARGET='(Watch|Extension|Widget|Intents|Clip|Notification|Share|Sticker|T
109
110
  # can land on a Watch app, an app extension, or a framework instead of the real app.
110
111
  # We score candidates by Swift-file count and deprioritize obvious non-app targets, so
111
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.
112
117
  detect_ios_dir() {
113
118
  local d; d=$(cfg '.iosSourceDir')
114
- [[ -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).
115
128
  local candidates plist entry
116
129
  candidates=$(
117
130
  find . "${PRUNE[@]}" -name Info.plist 2>/dev/null | while IFS= read -r plist; do dirname "$plist"; done
@@ -129,17 +142,18 @@ detect_ios_dir() {
129
142
  (( n > best_n )) && { best_n=$n; best="$dir"; }
130
143
  fi
131
144
  done <<< "$candidates"
132
- [[ -n "$best" ]] && { echo "$best"; return; }
133
- echo "$alt"
145
+ if [[ -n "$best" ]]; then IOS_DIR="$best"; else IOS_DIR="$alt"; fi
134
146
  }
135
147
 
136
- IOS_DIR="$(detect_ios_dir)"
148
+ PM_INFO_PLIST=""
149
+ IOS_DIR=""
150
+ detect_ios_dir
137
151
  META_DIR="$(cfg '.metadataDir')"; [[ -z "$META_DIR" ]] && META_DIR="$(detect_first -type d -name metadata -path '*fastlane*')"
138
152
  SCREEN_DIR="$(cfg '.screenshotsDir')"; [[ -z "$SCREEN_DIR" ]] && SCREEN_DIR="$(detect_first -type d -name screenshots -path '*fastlane*')"
139
153
  XCSTRINGS="$(cfg '.xcstringsPath')"; [[ -z "$XCSTRINGS" ]] && XCSTRINGS="$(detect_first -name 'Localizable.xcstrings')"
140
154
  [[ -z "$XCSTRINGS" ]] && XCSTRINGS="$(detect_first -name '*.xcstrings')"
141
155
  PRIVACY_FILE="$(detect_first -name 'PrivacyInfo.xcprivacy')"
142
- INFO_PLIST="${IOS_DIR%/}/Info.plist"
156
+ INFO_PLIST="${PM_INFO_PLIST:-${IOS_DIR%/}/Info.plist}"
143
157
  REVIEW_PREP="$(cfg '.reviewPrepNotes')"
144
158
 
145
159
  # Paywall / subscription views. A real app spreads its purchase UI across several files