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