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.
|
|
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)",
|
|
@@ -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" ]] && {
|
|
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" ]]
|
|
133
|
-
echo "$alt"
|
|
145
|
+
if [[ -n "$best" ]]; then IOS_DIR="$best"; else IOS_DIR="$alt"; fi
|
|
134
146
|
}
|
|
135
147
|
|
|
136
|
-
|
|
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
|