appstore-api-mcp 1.16.4 → 1.17.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.
@@ -0,0 +1,615 @@
1
+ // Local-project scanner for the App Store preflight.
2
+ //
3
+ // A zero-dependency JavaScript port of app-store-preflight's scan_project.py
4
+ // (MIT, Ilan Uzan — https://github.com/ilan-uzan/app-store-preflight). It
5
+ // collects *facts only*, no verdicts: which features the code uses, what the
6
+ // Info.plist / entitlements / privacy manifests declare, which required-reason
7
+ // APIs and manifest-requiring SDKs appear. src/preflight-rules.js turns these
8
+ // facts into guideline-cited findings.
9
+ //
10
+ // Everything here is best-effort and read-only: a missing or unreadable file
11
+ // is skipped, never fatal, so the scan degrades gracefully in CI or when a
12
+ // project is partially present.
13
+
14
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
15
+ import { join, relative, basename, extname, dirname } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { execFileSync } from "node:child_process";
18
+
19
+ const DATA_DIR = fileURLToPath(new URL("./preflight-data/", import.meta.url));
20
+
21
+ const SKIP_DIRS = new Set([
22
+ ".git", "node_modules", "Pods", "Carthage", "DerivedData", ".build", "build",
23
+ ".swiftpm", "SourcePackages", ".gradle", ".dart_tool", "vendor", ".expo",
24
+ "xcuserdata", ".idea", ".vscode",
25
+ ]);
26
+ const SOURCE_EXT = new Set([
27
+ ".swift", ".m", ".mm", ".h", ".c", ".cpp", ".js", ".jsx", ".ts", ".tsx", ".dart", ".kt",
28
+ ]);
29
+ const MAX_FILE_BYTES = 2_000_000;
30
+ const MAX_HITS_PER_PATTERN = 25;
31
+
32
+ // Feature signals the rules use to decide which guidelines apply. Each key maps
33
+ // to a list of regex sources searched line-by-line across source files. A `(?i)`
34
+ // prefix (Python inline flag) is translated to a case-insensitive JS regex.
35
+ const FEATURE_SIGNALS = {
36
+ storekit_iap: [String.raw`\bSKPaymentQueue\b`, String.raw`\bProduct\.products\(`, String.raw`\bTransaction\.(updates|currentEntitlements)\b`, String.raw`\bStoreView\b`, String.raw`\bSubscriptionStoreView\b`, String.raw`\bProductView\b`, String.raw`RevenueCat|Purchases\.shared|Adapty|Qonversion|Superwall`],
37
+ restore_purchases: [String.raw`restoreCompletedTransactions`, String.raw`AppStore\.sync\(`, String.raw`(?i)restore\s*purchases?`, String.raw`restorePurchases`],
38
+ external_payments: [String.raw`(?i)stripe`, String.raw`(?i)paypal`, String.raw`(?i)braintree`, String.raw`(?i)checkout\.com`, String.raw`ExternalPurchase`, String.raw`ExternalPurchaseLink`],
39
+ sign_in_with_apple: [String.raw`ASAuthorizationAppleIDProvider`, String.raw`SignInWithAppleButton`, String.raw`ASAuthorizationAppleIDButton`],
40
+ third_party_login: [String.raw`GIDSignIn|GoogleSignIn`, String.raw`FBSDKLoginKit|FacebookLogin|LoginManager\(\)`, String.raw`(?i)\bauth0\b`, String.raw`(?i)firebase.?auth|Auth\.auth\(\)`, String.raw`(?i)supabase.*auth|signInWithOAuth|signInWithIdToken`, String.raw`(?i)\bclerk\b`, String.raw`(?i)login\s*with\s*(google|facebook|twitter|x|github|linkedin|microsoft)`],
41
+ account_creation: [String.raw`(?i)sign\s*up|create\s*(an\s*)?account|register\b|createUser`],
42
+ account_deletion: [String.raw`(?i)delete\s*(my\s*)?account|deleteAccount|account\s*deletion|remove\s*account`],
43
+ app_tracking_transparency: [String.raw`ATTrackingManager`, String.raw`requestTrackingAuthorization`],
44
+ advertising_id: [String.raw`ASIdentifierManager`, String.raw`advertisingIdentifier`],
45
+ ads_sdk: [String.raw`GoogleMobileAds|GADMobileAds|GADBannerView`, String.raw`(?i)applovin|ironsource|unityads|UnityAds|chartboost|vungle|inmobi|mintegral|pangle|AppLovinSDK`, String.raw`FBAudienceNetwork`],
46
+ analytics_sdk: [String.raw`(?i)firebase.?analytics|FirebaseAnalytics`, String.raw`(?i)\bmixpanel\b`, String.raw`(?i)\bamplitude\b`, String.raw`(?i)\bsegment\b.*analytics`, String.raw`(?i)posthog`, String.raw`(?i)appsflyer`, String.raw`(?i)adjust\b`, String.raw`(?i)branch\.io|import Branch`, String.raw`(?i)sentry`, String.raw`(?i)crashlytics`, String.raw`(?i)datadog`, String.raw`(?i)telemetrydeck`],
47
+ location: [String.raw`CLLocationManager`, String.raw`requestWhenInUseAuthorization|requestAlwaysAuthorization`, String.raw`CLLocationUpdate`],
48
+ background_location: [String.raw`allowsBackgroundLocationUpdates`, String.raw`requestAlwaysAuthorization`],
49
+ healthkit: [String.raw`\bHKHealthStore\b`, String.raw`import HealthKit`],
50
+ health_or_medical_terms: [String.raw`(?i)\b(diagnos\w*|dosage|symptom|blood\s*pressure|glucose|heart\s*rate|medical|clinical)\b`],
51
+ camera: [String.raw`AVCaptureSession|AVCaptureDevice`, String.raw`UIImagePickerController`, String.raw`\bCameraView\b`],
52
+ photos: [String.raw`PHPhotoLibrary|PHPickerViewController|PhotosPicker`],
53
+ microphone: [String.raw`AVAudioRecorder|AVAudioEngine|requestRecordPermission|AVAudioApplication`],
54
+ speech: [String.raw`SFSpeechRecognizer`],
55
+ contacts: [String.raw`CNContactStore`],
56
+ calendar: [String.raw`EKEventStore`],
57
+ bluetooth: [String.raw`CBCentralManager|CBPeripheralManager`],
58
+ nfc: [String.raw`NFCNDEFReaderSession|NFCTagReaderSession`],
59
+ face_id: [String.raw`LAContext`, String.raw`evaluatePolicy`],
60
+ push_notifications: [String.raw`registerForRemoteNotifications`, String.raw`UNUserNotificationCenter`],
61
+ notification_permission: [String.raw`requestAuthorization\(options`],
62
+ web_view: [String.raw`\bWKWebView\b`, String.raw`\bWebView\b`, String.raw`react-native-webview`],
63
+ uiwebview_deprecated: [String.raw`\bUIWebView\b`],
64
+ safari_view: [String.raw`SFSafariViewController`, String.raw`ASWebAuthenticationSession`],
65
+ user_generated_content: [String.raw`(?i)report\s*(post|user|content|abuse)`, String.raw`(?i)block\s*user`, String.raw`(?i)\b(comment|post|feed|chat|message)s?\b.*(upload|send|publish)`, String.raw`(?i)moderat`],
66
+ chat_or_social: [String.raw`(?i)\bchat\b`, String.raw`(?i)direct\s*message`, String.raw`(?i)friend\s*request`, String.raw`(?i)\bfollowers?\b`],
67
+ generative_ai: [String.raw`(?i)api\.openai\.com|OpenAI\(`, String.raw`(?i)api\.anthropic\.com|Anthropic\(`, String.raw`(?i)generativelanguage\.googleapis|GenerativeModel`, String.raw`FoundationModels|LanguageModelSession`, String.raw`(?i)replicate\.com|stability\.ai|elevenlabs`],
68
+ crypto: [String.raw`(?i)\bwallet\b.*(seed|mnemonic|private\s*key)`, String.raw`(?i)web3|ethers|walletconnect`, String.raw`(?i)\bmining\b|\bminer\b`],
69
+ vpn: [String.raw`NEVPNManager|NETunnelProviderManager|NEPacketTunnelProvider`],
70
+ mdm: [String.raw`(?i)mobile\s*device\s*management|\bMDM\b`],
71
+ callkit: [String.raw`CXProvider|CXCallController`],
72
+ pushkit_voip: [String.raw`PKPushRegistry`],
73
+ gambling_or_contests: [String.raw`(?i)\b(sweepstakes|lottery|casino|betting|wager|raffle)\b`],
74
+ kids_terms: [String.raw`(?i)\b(kids?|children|toddler|preschool)\b`],
75
+ screen_time_family_controls: [String.raw`FamilyControls|ManagedSettings|DeviceActivity`],
76
+ matter: [String.raw`MatterSupport|import Matter`],
77
+ widgets_extensions: [String.raw`WidgetKit|\bWidget\b.*:\s*Widget`, String.raw`NSExtensionPointIdentifier`],
78
+ keyboard_extension: [String.raw`UIInputViewController`],
79
+ siri_shortcuts: [String.raw`AppIntents|INIntent|AppShortcutsProvider`],
80
+ apple_pay: [String.raw`PKPaymentRequest|PKPaymentAuthorization|PayWithApplePayButton`],
81
+ game_center: [String.raw`GKLocalPlayer|GameKit`],
82
+ arkit: [String.raw`ARKit|ARSession|RealityKit`],
83
+ remote_desktop_or_streaming: [String.raw`(?i)remote\s*desktop|cloud\s*gaming|game\s*streaming`],
84
+ dynamic_code_loading: [String.raw`\bdlopen\b`, String.raw`NSClassFromString\(`, String.raw`JSContext|JavaScriptCore`, String.raw`(?i)codepush|expo-updates|hot\s*update`, String.raw`(?i)\beval\(`],
85
+ private_api_hints: [String.raw`performSelector\(.*"_`, String.raw`valueForKey:@?"_`, String.raw`value\(forKey:\s*"_`, String.raw`(?i)LSApplicationWorkspace`, String.raw`objc_msgSend`],
86
+ placeholder_text: [String.raw`(?i)lorem\s+ipsum`, String.raw`(?i)\bplaceholder\b`, String.raw`(?i)coming\s+soon`, String.raw`(?i)\bTODO\b`, String.raw`(?i)\bFIXME\b`, String.raw`(?i)under\s+construction`, String.raw`(?i)test\s*user|dummy\s*data`],
87
+ beta_or_test_wording: [String.raw`(?i)\b(beta|alpha|trial version|demo version|preview build)\b`],
88
+ other_platform_mentions: [String.raw`(?i)\bandroid\b`, String.raw`(?i)google\s*play`, String.raw`(?i)\bwindows\s*phone\b`],
89
+ insecure_http: [String.raw`http://(?!localhost|127\.0\.0\.1|www\.apple\.com/DTDs|schemas\.|www\.w3\.org)`],
90
+ privacy_policy_link: [String.raw`(?i)privacy\s*policy`, String.raw`(?i)/privacy`],
91
+ terms_link: [String.raw`(?i)terms\s*(of\s*(use|service))?`, String.raw`(?i)\bEULA\b`, String.raw`(?i)/terms`],
92
+ support_contact: [String.raw`(?i)mailto:`, String.raw`(?i)contact\s*(us|support)`, String.raw`(?i)/support`],
93
+ rate_review_prompt: [String.raw`SKStoreReviewController|requestReview`, String.raw`(?i)rate\s*(us|this\s*app)`],
94
+ custom_review_prompt: [String.raw`(?i)itms-apps://.*action=write-review`, String.raw`(?i)leave\s*a\s*review`],
95
+ alternate_icons: [String.raw`setAlternateIconName`],
96
+ hidden_features_flags: [String.raw`(?i)remote\s*config|feature\s*flag|RemoteConfig|LaunchDarkly|Statsig|ConfigCat`],
97
+ };
98
+
99
+ const INFO_PLIST_KEYS_OF_INTEREST = new Set([
100
+ "CFBundleDisplayName", "CFBundleName", "CFBundleIdentifier", "CFBundleShortVersionString",
101
+ "CFBundleVersion", "UIBackgroundModes", "NSAppTransportSecurity", "ITSAppUsesNonExemptEncryption",
102
+ "ITSEncryptionExportComplianceCode", "LSApplicationQueriesSchemes", "UIRequiredDeviceCapabilities",
103
+ "CFBundleURLTypes", "UISupportedInterfaceOrientations", "UISupportedInterfaceOrientations~ipad",
104
+ "UIRequiresFullScreen", "UILaunchStoryboardName", "UILaunchScreen", "UIApplicationSceneManifest",
105
+ "NSUserActivityTypes", "LSSupportsOpeningDocumentsInPlace", "UIFileSharingEnabled",
106
+ "GADApplicationIdentifier", "SKAdNetworkItems", "NSUserTrackingUsageDescription",
107
+ "UIApplicationExitsOnSuspend", "MinimumOSVersion", "LSMinimumSystemVersion", "NSPrincipalClass",
108
+ "NSExtension", "WKApplication", "WKCompanionAppBundleIdentifier", "WKRunsIndependentlyOfCompanionApp",
109
+ ]);
110
+
111
+ const PBXPROJ_KEYS = new Set([
112
+ "PRODUCT_BUNDLE_IDENTIFIER", "MARKETING_VERSION", "CURRENT_PROJECT_VERSION",
113
+ "IPHONEOS_DEPLOYMENT_TARGET", "MACOSX_DEPLOYMENT_TARGET", "WATCHOS_DEPLOYMENT_TARGET",
114
+ "TVOS_DEPLOYMENT_TARGET", "XROS_DEPLOYMENT_TARGET", "TARGETED_DEVICE_FAMILY", "SDKROOT",
115
+ "SUPPORTED_PLATFORMS", "CODE_SIGN_ENTITLEMENTS", "INFOPLIST_FILE", "GENERATE_INFOPLIST_FILE",
116
+ "SWIFT_VERSION", "ASSETCATALOG_COMPILER_APPICON_NAME", "ENABLE_APP_SANDBOX", "ENABLE_HARDENED_RUNTIME",
117
+ "SUPPORTS_MACCATALYST", "SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD", "PRODUCT_NAME",
118
+ ]);
119
+
120
+ function loadData(name, dflt) {
121
+ const path = join(DATA_DIR, name);
122
+ try {
123
+ return JSON.parse(readFileSync(path, "utf8"));
124
+ } catch {
125
+ return dflt;
126
+ }
127
+ }
128
+
129
+ /** Compile a Python-style pattern (optional `(?i)` prefix) to a JS RegExp. */
130
+ function compilePattern(src) {
131
+ let flags = "";
132
+ let body = src;
133
+ if (body.includes("(?i)")) {
134
+ flags = "i";
135
+ body = body.replace(/\(\?i\)/g, "");
136
+ }
137
+ try {
138
+ return new RegExp(body, flags);
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ const COMPILED_SIGNALS = Object.fromEntries(
145
+ Object.entries(FEATURE_SIGNALS).map(([k, pats]) => [
146
+ k,
147
+ pats.map((p) => ({ src: p, re: compilePattern(p) })).filter((x) => x.re),
148
+ ]),
149
+ );
150
+
151
+ function decodeEntities(s) {
152
+ return s
153
+ .replace(/&lt;/g, "<")
154
+ .replace(/&gt;/g, ">")
155
+ .replace(/&quot;/g, '"')
156
+ .replace(/&apos;/g, "'")
157
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
158
+ .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
159
+ .replace(/&amp;/g, "&");
160
+ }
161
+
162
+ /**
163
+ * Minimal XML-plist parser — enough for Info.plist, .entitlements and
164
+ * PrivacyInfo.xcprivacy. Returns a plain object/array/scalar, or null if the
165
+ * file is not an XML plist we can parse (e.g. a binary plist).
166
+ */
167
+ export function parseXmlPlist(xml) {
168
+ const tokens = [];
169
+ const re = /<(\/)?([A-Za-z0-9_]+)([^>]*?)(\/)?>|([^<]+)/g;
170
+ let m;
171
+ while ((m = re.exec(xml))) {
172
+ if (m[2]) {
173
+ const selfClose = m[4] === "/" || /\/\s*$/.test(m[3] || "");
174
+ if (m[1]) tokens.push({ type: "close", name: m[2] });
175
+ else if (selfClose) tokens.push({ type: "selfclose", name: m[2] });
176
+ else tokens.push({ type: "open", name: m[2] });
177
+ } else if (m[5] != null) {
178
+ tokens.push({ type: "text", text: decodeEntities(m[5]) });
179
+ }
180
+ }
181
+ // Start right after the <plist> open tag.
182
+ let pos = tokens.findIndex((t) => t.type === "open" && t.name === "plist");
183
+ if (pos < 0) return null;
184
+ pos += 1;
185
+
186
+ const skipWs = () => {
187
+ while (tokens[pos] && tokens[pos].type === "text" && !tokens[pos].text.trim()) pos++;
188
+ };
189
+ const readText = () => {
190
+ let s = "";
191
+ for (;;) {
192
+ const n = tokens[pos];
193
+ if (!n) break;
194
+ if (n.type === "close") { pos++; break; }
195
+ if (n.type === "text") s += n.text;
196
+ pos++;
197
+ }
198
+ return s;
199
+ };
200
+ const parseValue = () => {
201
+ skipWs();
202
+ const t = tokens[pos++];
203
+ if (!t) return undefined;
204
+ if (t.type === "selfclose") {
205
+ switch (t.name) {
206
+ case "true": return true;
207
+ case "false": return false;
208
+ case "array": return [];
209
+ case "dict": return {};
210
+ case "string": return "";
211
+ default: return null;
212
+ }
213
+ }
214
+ if (t.type !== "open") return undefined;
215
+ switch (t.name) {
216
+ case "dict": {
217
+ const o = {};
218
+ for (;;) {
219
+ skipWs();
220
+ const n = tokens[pos];
221
+ if (!n || (n.type === "close" && n.name === "dict")) { if (n) pos++; break; }
222
+ if (n.type === "open" && n.name === "key") {
223
+ pos++;
224
+ const key = readText();
225
+ o[key] = parseValue();
226
+ } else {
227
+ pos++; // tolerate stray tokens
228
+ }
229
+ }
230
+ return o;
231
+ }
232
+ case "array": {
233
+ const a = [];
234
+ for (;;) {
235
+ skipWs();
236
+ const n = tokens[pos];
237
+ if (!n || (n.type === "close" && n.name === "array")) { if (n) pos++; break; }
238
+ a.push(parseValue());
239
+ }
240
+ return a;
241
+ }
242
+ case "integer": return parseInt(readText().trim(), 10);
243
+ case "real": return parseFloat(readText().trim());
244
+ case "string": return readText();
245
+ case "date": return readText().trim();
246
+ case "data": return readText().trim();
247
+ default: return readText();
248
+ }
249
+ };
250
+ try {
251
+ return parseValue();
252
+ } catch {
253
+ return null;
254
+ }
255
+ }
256
+
257
+ /** Load a plist file (XML directly; binary via `plutil` when available). */
258
+ export function loadPlist(path) {
259
+ let raw;
260
+ try {
261
+ if (statSync(path).size > MAX_FILE_BYTES) return null;
262
+ raw = readFileSync(path);
263
+ } catch {
264
+ return null;
265
+ }
266
+ if (raw.slice(0, 6).toString("latin1") === "bplist") {
267
+ // Binary plist: best-effort convert with plutil (macOS/Xcode only).
268
+ try {
269
+ const xml = execFileSync("plutil", ["-convert", "xml1", "-o", "-", path], {
270
+ encoding: "utf8",
271
+ maxBuffer: MAX_FILE_BYTES,
272
+ });
273
+ return parseXmlPlist(xml);
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+ return parseXmlPlist(raw.toString("utf8"));
279
+ }
280
+
281
+ function readText(path) {
282
+ try {
283
+ if (statSync(path).size > MAX_FILE_BYTES) return null;
284
+ return readFileSync(path, "utf8");
285
+ } catch {
286
+ return null;
287
+ }
288
+ }
289
+
290
+ function grep(text, re) {
291
+ const hits = [];
292
+ const lines = text.split(/\r?\n/);
293
+ for (let i = 0; i < lines.length; i++) {
294
+ if (re.test(lines[i])) {
295
+ hits.push([i + 1, lines[i].trim().slice(0, 200)]);
296
+ re.lastIndex = 0;
297
+ }
298
+ }
299
+ return hits;
300
+ }
301
+
302
+ /** width/height/alpha of a PNG without third-party libs. */
303
+ function pngInfo(path) {
304
+ try {
305
+ const fd = readFileSync(path);
306
+ if (fd.length < 26) return null;
307
+ if (fd.slice(0, 8).toString("latin1") !== "\x89PNG\r\n\x1a\n") return null;
308
+ const width = fd.readUInt32BE(16);
309
+ const height = fd.readUInt32BE(20);
310
+ const colorType = fd.readUInt8(25);
311
+ return { width, height, has_alpha_channel: colorType === 4 || colorType === 6 };
312
+ } catch {
313
+ return null;
314
+ }
315
+ }
316
+
317
+ function pbxprojSettings(text) {
318
+ const out = {};
319
+ for (const line of text.split(/\r?\n/)) {
320
+ const m = line.match(/^\s*"?([A-Za-z0-9_]+(?:\[[^\]]*\])?)"?\s*=\s*(.+?);\s*$/);
321
+ if (!m) continue;
322
+ const key = m[1];
323
+ const value = m[2].trim().replace(/^"|"$/g, "");
324
+ const base = key.split("[")[0];
325
+ if (PBXPROJ_KEYS.has(base) || base.startsWith("INFOPLIST_KEY_")) {
326
+ (out[key] ||= new Set()).add(value);
327
+ }
328
+ }
329
+ const res = {};
330
+ for (const [k, v] of Object.entries(out)) res[k] = [...v].sort();
331
+ return res;
332
+ }
333
+
334
+ function* walk(root) {
335
+ let entries;
336
+ try {
337
+ entries = readdirSync(root, { withFileTypes: true });
338
+ } catch {
339
+ return;
340
+ }
341
+ for (const e of entries) {
342
+ const full = join(root, e.name);
343
+ if (e.isDirectory()) {
344
+ if (SKIP_DIRS.has(e.name) || e.name.endsWith(".xcarchive")) continue;
345
+ yield* walk(full);
346
+ } else if (e.isFile()) {
347
+ yield full;
348
+ }
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Scan an app project and return a facts object. `root` is an absolute path.
354
+ * Never throws for ordinary filesystem problems; returns `{ error }` only when
355
+ * the root itself is unreadable.
356
+ */
357
+ export function scanProject(root) {
358
+ if (!existsSync(root)) return { error: `Path not found: ${root}`, projectRoot: root };
359
+
360
+ const requiredReason = loadData("required_reason_apis.json", { categories: [] });
361
+ const sdkList = loadData("sdks_requiring_manifest.json", { sdks: [] });
362
+ const purposeKeys = loadData("purpose_string_keys.json", { keys: [] });
363
+
364
+ const facts = {
365
+ projectRoot: root,
366
+ projectTypes: [],
367
+ xcodeProjects: [],
368
+ infoPlists: [],
369
+ generatedInfoPlistKeys: {},
370
+ buildSettings: {},
371
+ entitlements: [],
372
+ privacyManifests: [],
373
+ requiredReasonApiUsage: [],
374
+ dependencies: { spm: [], cocoapods: [], carthage: [], npm: [], pub: [] },
375
+ sdksRequiringManifest: [],
376
+ featureSignals: {},
377
+ appIcons: [],
378
+ localizations: [],
379
+ storeKitConfigs: [],
380
+ fastlaneMetadata: [],
381
+ counts: { sourceFiles: 0 },
382
+ notes: [],
383
+ };
384
+
385
+ const rel = (p) => relative(root, p);
386
+ const sources = [];
387
+
388
+ for (const path of walk(root)) {
389
+ const name = basename(path);
390
+ const ext = extname(name);
391
+ const r = rel(path);
392
+
393
+ if (name === "project.pbxproj") {
394
+ const text = readText(path) || "";
395
+ facts.xcodeProjects.push(r);
396
+ const settings = pbxprojSettings(text);
397
+ for (const [k, v] of Object.entries(settings)) {
398
+ if (k.startsWith("INFOPLIST_KEY_")) {
399
+ const kk = k.slice("INFOPLIST_KEY_".length);
400
+ (facts.generatedInfoPlistKeys[kk] ||= []).push(...v);
401
+ } else {
402
+ (facts.buildSettings[k] ||= []).push(...v);
403
+ }
404
+ }
405
+ } else if (name === "Info.plist" || (ext === ".plist" && name.endsWith("-Info.plist"))) {
406
+ const pl = loadPlist(path);
407
+ if (pl && typeof pl === "object" && !Array.isArray(pl)) {
408
+ const usage = {};
409
+ const keys = {};
410
+ for (const [k, v] of Object.entries(pl)) {
411
+ if (k.endsWith("UsageDescription")) usage[k] = v;
412
+ if (INFO_PLIST_KEYS_OF_INTEREST.has(k)) keys[k] = v;
413
+ }
414
+ facts.infoPlists.push({ path: r, usageDescriptions: usage, keys });
415
+ }
416
+ } else if (ext === ".entitlements") {
417
+ const pl = loadPlist(path);
418
+ if (pl && typeof pl === "object" && !Array.isArray(pl)) {
419
+ facts.entitlements.push({ path: r, values: pl });
420
+ }
421
+ } else if (name === "PrivacyInfo.xcprivacy") {
422
+ const pl = loadPlist(path);
423
+ facts.privacyManifests.push({
424
+ path: r,
425
+ parsed: pl != null,
426
+ values: pl && typeof pl === "object" ? pl : null,
427
+ });
428
+ } else if (name === "Package.resolved") {
429
+ try {
430
+ const data = JSON.parse(readText(path) || "{}");
431
+ const pins = data.pins || data.object?.pins || [];
432
+ for (const p of pins) {
433
+ facts.dependencies.spm.push({
434
+ identity: p.identity || p.package,
435
+ location: p.location || p.repositoryURL,
436
+ version: (p.state || {}).version,
437
+ });
438
+ }
439
+ } catch {
440
+ facts.notes.push(`Could not parse ${r}`);
441
+ }
442
+ } else if (name === "Podfile.lock") {
443
+ const text = readText(path) || "";
444
+ for (const m of text.matchAll(/^ - ([A-Za-z0-9_\-+/.]+) \(([^)]+)\)/gm)) {
445
+ facts.dependencies.cocoapods.push({ name: m[1], version: m[2] });
446
+ }
447
+ } else if (name === "Cartfile.resolved") {
448
+ for (const line of (readText(path) || "").split(/\r?\n/)) {
449
+ const parts = line.split(/\s+/);
450
+ if (parts.length >= 3) {
451
+ facts.dependencies.carthage.push({
452
+ name: parts[1].replace(/"/g, ""),
453
+ version: parts[2].replace(/"/g, ""),
454
+ });
455
+ }
456
+ }
457
+ } else if (name === "package.json" && !r.includes("node_modules")) {
458
+ try {
459
+ const data = JSON.parse(readText(path) || "{}");
460
+ const deps = { ...data.dependencies, ...data.devDependencies };
461
+ for (const [k, v] of Object.entries(deps)) facts.dependencies.npm.push({ name: k, version: v });
462
+ if ("react-native" in deps) facts.projectTypes.push("react-native");
463
+ if ("expo" in deps) facts.projectTypes.push("expo");
464
+ if ("@capacitor/core" in deps || "cordova" in deps) facts.projectTypes.push("hybrid-webview");
465
+ } catch {
466
+ /* ignore */
467
+ }
468
+ } else if (name === "pubspec.lock") {
469
+ facts.projectTypes.push("flutter");
470
+ for (const m of (readText(path) || "").matchAll(
471
+ /^ {2}([a-z0-9_]+):\n(?: {4}.*\n)*? {4}version: "([^"]+)"/gm,
472
+ )) {
473
+ facts.dependencies.pub.push({ name: m[1], version: m[2] });
474
+ }
475
+ } else if (ext === ".storekit") {
476
+ facts.storeKitConfigs.push(r);
477
+ } else if (name === "Contents.json" && r.includes(".appiconset")) {
478
+ const folder = dirname(path);
479
+ let data = {};
480
+ try {
481
+ data = JSON.parse(readText(path) || "{}");
482
+ } catch {
483
+ /* ignore */
484
+ }
485
+ const images = [];
486
+ for (const img of data.images || []) {
487
+ const fn = img.filename;
488
+ const info = fn ? pngInfo(join(folder, fn)) : null;
489
+ images.push({
490
+ filename: fn, size: img.size, idiom: img.idiom,
491
+ platform: img.platform, appearances: img.appearances, png: info,
492
+ });
493
+ }
494
+ facts.appIcons.push({ path: rel(folder), images });
495
+ } else if (name.endsWith(".xcstrings")) {
496
+ facts.localizations.push(r);
497
+ }
498
+
499
+ if (r.includes("/fastlane/metadata/") || r.startsWith("fastlane/metadata/")) {
500
+ facts.fastlaneMetadata.push(r);
501
+ }
502
+ if (SOURCE_EXT.has(ext)) sources.push(path);
503
+ }
504
+
505
+ // .lproj directories (localizations).
506
+ for (const path of walkDirs(root)) {
507
+ if (path.endsWith(".lproj")) facts.localizations.push(rel(path));
508
+ }
509
+ facts.localizations = [...new Set(facts.localizations)].sort();
510
+
511
+ if (facts.xcodeProjects.length) facts.projectTypes.push("xcode");
512
+ if (existsSync(join(root, "Package.swift"))) facts.projectTypes.push("swiftpm");
513
+ facts.projectTypes = [...new Set(facts.projectTypes)].sort();
514
+ facts.counts.sourceFiles = sources.length;
515
+
516
+ // Read all source files once.
517
+ const texts = {};
518
+ for (const path of sources) {
519
+ const t = readText(path);
520
+ if (t != null) texts[rel(path)] = t;
521
+ }
522
+
523
+ // Feature signals.
524
+ for (const [key, pats] of Object.entries(COMPILED_SIGNALS)) {
525
+ const hits = [];
526
+ for (const [r, t] of Object.entries(texts)) {
527
+ for (const { src, re } of pats) {
528
+ for (const [lineNo, line] of grep(t, re)) {
529
+ hits.push({ file: r, line: lineNo, text: line, pattern: src });
530
+ }
531
+ }
532
+ }
533
+ if (hits.length) facts.featureSignals[key] = { count: hits.length, hits: hits.slice(0, MAX_HITS_PER_PATTERN) };
534
+ }
535
+
536
+ // Required-reason API usage.
537
+ for (const cat of requiredReason.categories || []) {
538
+ const hits = [];
539
+ for (const [r, t] of Object.entries(texts)) {
540
+ for (const sym of cat.symbols || []) {
541
+ const re = new RegExp(`\\b${sym.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
542
+ for (const [lineNo, line] of grep(t, re)) {
543
+ hits.push({ file: r, line: lineNo, symbol: sym, text: line });
544
+ }
545
+ }
546
+ }
547
+ if (hits.length) {
548
+ facts.requiredReasonApiUsage.push({ category: cat.category, count: hits.length, hits: hits.slice(0, MAX_HITS_PER_PATTERN) });
549
+ }
550
+ }
551
+
552
+ // SDKs that require a privacy manifest.
553
+ const depNames = new Set();
554
+ for (const group of Object.values(facts.dependencies)) {
555
+ for (const d of group) {
556
+ for (const field of ["identity", "name", "location"]) {
557
+ const v = d[field];
558
+ if (v) {
559
+ depNames.add(v.toLowerCase().replace(/\/$/, "").split("/").pop().replace(/\.git$/, ""));
560
+ depNames.add(v.toLowerCase());
561
+ }
562
+ }
563
+ }
564
+ }
565
+ for (const sdk of sdkList.sdks || []) {
566
+ const aliases = (sdk.aliases || [sdk.name || ""]).map((a) => a.toLowerCase());
567
+ if (aliases.some((a) => depNames.has(a) || [...depNames].some((n) => a === n.split("/")[0]))) {
568
+ facts.sdksRequiringManifest.push(sdk.name);
569
+ }
570
+ }
571
+
572
+ // Purpose strings: declared keys, values, and heuristics for the rules.
573
+ const declared = new Set();
574
+ for (const p of facts.infoPlists) for (const k of Object.keys(p.usageDescriptions)) declared.add(k);
575
+ for (const k of Object.keys(facts.generatedInfoPlistKeys)) if (k.endsWith("UsageDescription")) declared.add(k);
576
+ const values = {};
577
+ for (const p of facts.infoPlists) {
578
+ for (const [k, v] of Object.entries(p.usageDescriptions)) (values[k] ||= []).push({ source: p.path, value: v });
579
+ }
580
+ for (const [k, vs] of Object.entries(facts.generatedInfoPlistKeys)) {
581
+ if (k.endsWith("UsageDescription")) {
582
+ (values[k] ||= []).push(...vs.map((v) => ({ source: "build setting INFOPLIST_KEY_" + k, value: v })));
583
+ }
584
+ }
585
+ const known = new Set((purposeKeys.keys || []).map((k) => k.key));
586
+ facts.purposeStrings = {
587
+ declared: [...declared].sort(),
588
+ values,
589
+ shortOrEmpty: Object.keys(values)
590
+ .filter((k) => values[k].some((v) => String(v.value).trim().length < 20))
591
+ .sort(),
592
+ unknownKeys: [...declared].filter((k) => known.size && !known.has(k)).sort(),
593
+ };
594
+
595
+ if (!facts.xcodeProjects.length && !facts.projectTypes.length) {
596
+ facts.notes.push("No Xcode project, Swift package, React Native, Expo or Flutter project was found under the root.");
597
+ }
598
+ return facts;
599
+ }
600
+
601
+ function* walkDirs(root) {
602
+ let entries;
603
+ try {
604
+ entries = readdirSync(root, { withFileTypes: true });
605
+ } catch {
606
+ return;
607
+ }
608
+ for (const e of entries) {
609
+ if (!e.isDirectory()) continue;
610
+ if (SKIP_DIRS.has(e.name)) continue;
611
+ const full = join(root, e.name);
612
+ yield full;
613
+ yield* walkDirs(full);
614
+ }
615
+ }