appstore-api-mcp 1.16.4 → 1.18.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,463 @@
1
+ // Preflight rules engine.
2
+ //
3
+ // Turns the *facts* gathered by the local scanner (src/preflight-scan.js) and
4
+ // by the App Store Connect API (assembled in index.js) into guideline-cited
5
+ // findings. This is where the "learn from app-store-preflight" lives: the
6
+ // upstream project delegates pass/fail to LLM audit agents reading Apple's
7
+ // guideline texts; here we encode the highest-signal checks deterministically
8
+ // so the MCP can gate a submission without a model in the loop.
9
+ //
10
+ // Philosophy, borrowed from the upstream project: every finding names the
11
+ // Apple rule it is about and shows its evidence, and only high-confidence,
12
+ // low-false-positive checks are allowed to BLOCK a submission (verdict FAIL).
13
+ // Heuristic / regex-driven concerns surface as WARN or MANUAL so a real
14
+ // submission is never blocked by a guess.
15
+
16
+ // ---- Verdicts ----
17
+ export const VERDICT = {
18
+ PASS: "PASS", // requirement met
19
+ FAIL: "FAIL", // violated or a required item missing — blocks submission
20
+ WARN: "WARN", // probable problem or "should" advice not followed
21
+ MANUAL: "MANUAL", // only the developer can confirm (legal/business/content)
22
+ ASC: "ASC", // only App Store Connect can settle it
23
+ NA: "NA", // does not apply to this app, or not checked
24
+ };
25
+
26
+ const G = (section, title) => ({
27
+ ref: `App Review ${section}`,
28
+ url: `https://developer.apple.com/app-store/review/guidelines/#${title}`,
29
+ });
30
+ // App Store Connect help topics aren't numbered guidelines; cite the area.
31
+ const ASC_REF = (area) => ({ ref: `App Store Connect — ${area}`, url: "https://developer.apple.com/help/app-store-connect/" });
32
+
33
+ // Permission-triggering feature signals → the Info.plist purpose-string key(s)
34
+ // Apple requires. `anyOf` keys satisfy the rule if at least one is declared.
35
+ // severity FAIL = a missing string crashes or is rejected deterministically;
36
+ // WARN = real but with enough edge cases that we don't hard-block.
37
+ const FEATURE_PURPOSE_KEYS = [
38
+ { signal: "camera", keys: ["NSCameraUsageDescription"], severity: VERDICT.FAIL, label: "camera" },
39
+ { signal: "photos", keys: ["NSPhotoLibraryUsageDescription", "NSPhotoLibraryAddUsageDescription"], severity: VERDICT.FAIL, label: "photo library" },
40
+ { signal: "microphone", keys: ["NSMicrophoneUsageDescription"], severity: VERDICT.FAIL, label: "microphone" },
41
+ { signal: "location", keys: ["NSLocationWhenInUseUsageDescription", "NSLocationAlwaysAndWhenInUseUsageDescription", "NSLocationUsageDescription"], severity: VERDICT.FAIL, label: "location" },
42
+ { signal: "contacts", keys: ["NSContactsUsageDescription"], severity: VERDICT.FAIL, label: "contacts" },
43
+ { signal: "calendar", keys: ["NSCalendarsUsageDescription", "NSCalendarsFullAccessUsageDescription"], severity: VERDICT.FAIL, label: "calendar" },
44
+ { signal: "healthkit", keys: ["NSHealthShareUsageDescription", "NSHealthUpdateUsageDescription"], severity: VERDICT.FAIL, label: "HealthKit" },
45
+ { signal: "speech", keys: ["NSSpeechRecognitionUsageDescription"], severity: VERDICT.FAIL, label: "speech recognition" },
46
+ { signal: "nfc", keys: ["NFCReaderUsageDescription"], severity: VERDICT.FAIL, label: "NFC" },
47
+ { signal: "app_tracking_transparency", keys: ["NSUserTrackingUsageDescription"], severity: VERDICT.FAIL, label: "App Tracking Transparency" },
48
+ { signal: "bluetooth", keys: ["NSBluetoothAlwaysUsageDescription", "NSBluetoothPeripheralUsageDescription"], severity: VERDICT.WARN, label: "Bluetooth" },
49
+ { signal: "face_id", keys: ["NSFaceIDUsageDescription"], severity: VERDICT.WARN, label: "Face ID" },
50
+ ];
51
+
52
+ const has = (scan, signal) => Boolean(scan?.featureSignals?.[signal]);
53
+ const firstHit = (scan, signal) => {
54
+ const h = scan?.featureSignals?.[signal]?.hits?.[0];
55
+ return h ? { file: h.file, line: h.line, text: h.text } : undefined;
56
+ };
57
+
58
+ // ---- Source-derived findings ----
59
+ export function evaluateSourceFindings(scan) {
60
+ const out = [];
61
+ if (!scan || scan.error) return out;
62
+ const declared = new Set(scan.purposeStrings?.declared || []);
63
+
64
+ // 1. Purpose strings for permission-triggering APIs.
65
+ for (const m of FEATURE_PURPOSE_KEYS) {
66
+ if (!has(scan, m.signal)) continue;
67
+ const present = m.keys.some((k) => declared.has(k));
68
+ if (present) {
69
+ out.push({
70
+ id: `purpose-string-${m.signal}`,
71
+ verdict: VERDICT.PASS,
72
+ ...G("5.1.1", "section-5-1-1"),
73
+ title: `Purpose string for ${m.label}`,
74
+ detail: `Declares ${m.keys.filter((k) => declared.has(k)).join(", ")}.`,
75
+ });
76
+ } else {
77
+ out.push({
78
+ id: `purpose-string-${m.signal}`,
79
+ verdict: m.severity,
80
+ ...G("5.1.1", "section-5-1-1"),
81
+ title: `Missing purpose string for ${m.label}`,
82
+ detail: `Code uses ${m.label} but Info.plist declares none of: ${m.keys.join(", ")}. iOS requires a purpose string before granting access${m.severity === VERDICT.FAIL ? "; its absence crashes on request and is rejected" : ""}.`,
83
+ evidence: firstHit(scan, m.signal),
84
+ fix: `Add ${m.keys[0]} to Info.plist with a specific, user-facing reason.`,
85
+ });
86
+ }
87
+ }
88
+
89
+ // 2. Declared purpose strings that are too short to explain the use.
90
+ const short = (scan.purposeStrings?.shortOrEmpty || []).filter((k) => declared.has(k));
91
+ if (short.length) {
92
+ out.push({
93
+ id: "purpose-string-quality",
94
+ verdict: VERDICT.WARN,
95
+ ...G("5.1.1", "section-5-1-1"),
96
+ title: "Purpose string too short / generic",
97
+ detail: `These purpose strings are under 20 characters and likely don't explain the use: ${short.join(", ")}. Apple rejects vague strings (e.g. "Mic").`,
98
+ fix: "Rewrite each to say what the app does with the data and why.",
99
+ });
100
+ }
101
+
102
+ // 3. Required-reason APIs must be declared in a privacy manifest.
103
+ const usedCats = (scan.requiredReasonApiUsage || []).map((c) => c.category);
104
+ if (usedCats.length) {
105
+ const manifests = (scan.privacyManifests || []).filter((p) => p.values);
106
+ const declaredCats = new Set();
107
+ for (const man of manifests) {
108
+ for (const t of man.values?.NSPrivacyAccessedAPITypes || []) {
109
+ const type = t?.NSPrivacyAccessedAPIType;
110
+ const reasons = t?.NSPrivacyAccessedAPITypeReasons || [];
111
+ if (type && reasons.length) declaredCats.add(type);
112
+ }
113
+ }
114
+ if (!manifests.length) {
115
+ out.push({
116
+ id: "required-reason-no-manifest",
117
+ verdict: VERDICT.FAIL,
118
+ ...ASC_REF("Privacy manifests & required-reason APIs"),
119
+ title: "Required-reason APIs used with no privacy manifest",
120
+ detail: `The app calls required-reason APIs (${usedCats.join(", ")}) but no PrivacyInfo.xcprivacy was found. Apple rejects apps that use these APIs without declaring an approved reason.`,
121
+ evidence: { file: scan.requiredReasonApiUsage[0]?.hits?.[0]?.file, line: scan.requiredReasonApiUsage[0]?.hits?.[0]?.line, text: scan.requiredReasonApiUsage[0]?.hits?.[0]?.text },
122
+ fix: "Add a PrivacyInfo.xcprivacy with NSPrivacyAccessedAPITypes entries and an approved reason code for each category.",
123
+ });
124
+ } else {
125
+ for (const cat of usedCats) {
126
+ const ok = declaredCats.has(cat);
127
+ out.push({
128
+ id: `required-reason-${cat}`,
129
+ verdict: ok ? VERDICT.PASS : VERDICT.FAIL,
130
+ ...ASC_REF("Privacy manifests & required-reason APIs"),
131
+ title: ok ? `Required-reason declared: ${cat}` : `Required-reason API not declared: ${cat}`,
132
+ detail: ok
133
+ ? `Privacy manifest declares an approved reason for ${cat}.`
134
+ : `Code uses ${cat} but no privacy manifest declares an approved reason for it.`,
135
+ fix: ok ? undefined : `Add ${cat} to NSPrivacyAccessedAPITypes with an approved NSPrivacyAccessedAPITypeReasons code.`,
136
+ });
137
+ }
138
+ }
139
+ }
140
+
141
+ // 4. SDKs Apple lists as requiring a privacy manifest + signature.
142
+ if ((scan.sdksRequiringManifest || []).length) {
143
+ out.push({
144
+ id: "sdk-privacy-manifest",
145
+ verdict: VERDICT.WARN,
146
+ ...ASC_REF("Privacy manifests & required-reason APIs"),
147
+ title: "SDKs that require a privacy manifest are present",
148
+ detail: `These dependencies are on Apple's list of SDKs that must ship a privacy manifest and signature: ${scan.sdksRequiringManifest.join(", ")}. Make sure you're on a version that includes them.`,
149
+ fix: "Update each to an SDK version that bundles PrivacyInfo.xcprivacy and is signed.",
150
+ });
151
+ }
152
+
153
+ // 5. Tracking without an ATT prompt.
154
+ if ((has(scan, "advertising_id") || has(scan, "ads_sdk")) && !has(scan, "app_tracking_transparency")) {
155
+ out.push({
156
+ id: "att-missing",
157
+ verdict: VERDICT.WARN,
158
+ ...G("5.1.2", "section-5-1-2"),
159
+ title: "Advertising/tracking code without an ATT request",
160
+ detail: "The app references the advertising identifier or an ads SDK but doesn't call ATTrackingManager.requestTrackingAuthorization. Tracking users without the App Tracking Transparency prompt is a common rejection.",
161
+ evidence: firstHit(scan, "advertising_id") || firstHit(scan, "ads_sdk"),
162
+ fix: "Request tracking authorization with ATT before tracking, or confirm the app does no tracking.",
163
+ });
164
+ }
165
+
166
+ // 6. Account creation without account deletion (5.1.1(v)).
167
+ if (has(scan, "account_creation")) {
168
+ const del = has(scan, "account_deletion");
169
+ out.push({
170
+ id: "account-deletion",
171
+ verdict: del ? VERDICT.MANUAL : VERDICT.WARN,
172
+ ...G("5.1.1(v)", "section-5-1-1"),
173
+ title: del ? "Account deletion present (confirm it's in-app)" : "Account creation without an account-deletion path",
174
+ detail: del
175
+ ? "Found both account-creation and account-deletion signals. Confirm deletion is initiated from inside the app, not only via a website or email."
176
+ : "The app appears to support account creation but no in-app account-deletion path was found. Apps that let people create an account must let them delete it from within the app.",
177
+ evidence: firstHit(scan, "account_creation"),
178
+ fix: del ? undefined : "Add an in-app 'Delete account' flow (not just a web link).",
179
+ });
180
+ }
181
+
182
+ // 7. Sign in with Apple when third-party social login is offered (4.8).
183
+ if (has(scan, "third_party_login") && !has(scan, "sign_in_with_apple")) {
184
+ out.push({
185
+ id: "sign-in-with-apple",
186
+ verdict: VERDICT.MANUAL,
187
+ ...G("4.8", "section-4-8"),
188
+ title: "Third-party login without Sign in with Apple",
189
+ detail: "The app offers a third-party/social login but no Sign in with Apple. Guideline 4.8 requires an equivalent privacy-focused login option (Sign in with Apple qualifies) unless your login already meets its criteria.",
190
+ evidence: firstHit(scan, "third_party_login"),
191
+ fix: "Offer Sign in with Apple, or confirm your login already meets the 4.8 criteria.",
192
+ });
193
+ }
194
+
195
+ // 8. Deprecated UIWebView.
196
+ if (has(scan, "uiwebview_deprecated")) {
197
+ out.push({
198
+ id: "uiwebview",
199
+ verdict: VERDICT.FAIL,
200
+ ...G("2.5.1", "section-2-5-1"),
201
+ title: "Deprecated UIWebView in use",
202
+ detail: "The app references UIWebView, which Apple no longer accepts. Builds using it are rejected.",
203
+ evidence: firstHit(scan, "uiwebview_deprecated"),
204
+ fix: "Replace UIWebView with WKWebView (including in any third-party SDKs).",
205
+ });
206
+ }
207
+
208
+ // 9-11. Lower-confidence heuristics → WARN / MANUAL.
209
+ if (has(scan, "private_api_hints")) {
210
+ out.push({
211
+ id: "private-api",
212
+ verdict: VERDICT.WARN,
213
+ ...G("2.5.1", "section-2-5-1"),
214
+ title: "Possible private/undocumented API use",
215
+ detail: "Patterns that often indicate private API access (KVC on underscore-prefixed keys, dynamic selectors, LSApplicationWorkspace) were found. Using non-public APIs is rejected.",
216
+ evidence: firstHit(scan, "private_api_hints"),
217
+ });
218
+ }
219
+ if (has(scan, "placeholder_text")) {
220
+ out.push({
221
+ id: "placeholder-text",
222
+ verdict: VERDICT.WARN,
223
+ ...G("2.1", "section-2-1"),
224
+ title: "Placeholder / unfinished content markers",
225
+ detail: "Found placeholder markers (lorem ipsum, TODO, 'coming soon', etc.). Apple rejects apps that look incomplete. These may be harmless code comments — verify none are user-visible.",
226
+ evidence: firstHit(scan, "placeholder_text"),
227
+ });
228
+ }
229
+ if (has(scan, "insecure_http")) {
230
+ out.push({
231
+ id: "insecure-http",
232
+ verdict: VERDICT.WARN,
233
+ ...ASC_REF("App Transport Security"),
234
+ title: "Cleartext HTTP references",
235
+ detail: "Non-HTTPS (http://) URLs were found. App Transport Security requires secure connections unless a justified exception is declared.",
236
+ evidence: firstHit(scan, "insecure_http"),
237
+ });
238
+ }
239
+
240
+ // 12. External payments alongside digital purchases (3.1.1).
241
+ if (has(scan, "external_payments")) {
242
+ out.push({
243
+ id: "external-payments",
244
+ verdict: VERDICT.MANUAL,
245
+ ...G("3.1.1", "section-3-1-1"),
246
+ title: "External payment SDK present",
247
+ detail: "A third-party payment SDK (e.g. Stripe/PayPal) was found. Digital content and services consumed in the app must use In-App Purchase; external payment is only for physical goods/services. Confirm what is being sold.",
248
+ evidence: firstHit(scan, "external_payments"),
249
+ });
250
+ }
251
+
252
+ // 13. Restore purchases for StoreKit apps (3.1.1).
253
+ if (has(scan, "storekit_iap") && !has(scan, "restore_purchases")) {
254
+ out.push({
255
+ id: "restore-purchases",
256
+ verdict: VERDICT.WARN,
257
+ ...G("3.1.1", "section-3-1-1"),
258
+ title: "In-app purchases without a Restore control",
259
+ detail: "StoreKit usage was found but no 'Restore Purchases' action. Apps with non-consumable or subscription purchases must offer a restore mechanism.",
260
+ evidence: firstHit(scan, "storekit_iap"),
261
+ });
262
+ }
263
+
264
+ // 14. Export compliance key in Info.plist.
265
+ const anyPlistKey = (key) =>
266
+ (scan.infoPlists || []).some((p) => key in (p.keys || {})) ||
267
+ key in (scan.generatedInfoPlistKeys || {});
268
+ if (!anyPlistKey("ITSAppUsesNonExemptEncryption")) {
269
+ out.push({
270
+ id: "export-compliance-plist",
271
+ verdict: VERDICT.WARN,
272
+ ...ASC_REF("Export compliance"),
273
+ title: "ITSAppUsesNonExemptEncryption not set in Info.plist",
274
+ detail: "No ITSAppUsesNonExemptEncryption key was found. Without it, every submission prompts for export-compliance and a build can stall as 'Missing Compliance'.",
275
+ fix: "Add ITSAppUsesNonExemptEncryption (true/false) to Info.plist to answer it once.",
276
+ });
277
+ }
278
+
279
+ return out;
280
+ }
281
+
282
+ // ---- App Store Connect-derived findings ----
283
+ // `asc` is assembled in index.js. Fields that weren't fetched are `undefined`
284
+ // and yield an NA ("not checked") finding rather than a false FAIL.
285
+ export function evaluateAscFindings(asc) {
286
+ const out = [];
287
+ if (!asc) return out;
288
+ const push = (f) => out.push(f);
289
+
290
+ // Privacy policy URL (required for all apps).
291
+ if (asc.appInfoLocalizations !== undefined) {
292
+ const withUrl = (asc.appInfoLocalizations || []).filter((l) => (l.privacyPolicyUrl || "").trim());
293
+ const anyUrl = withUrl.length > 0;
294
+ push({
295
+ id: "asc-privacy-policy",
296
+ verdict: anyUrl ? VERDICT.PASS : VERDICT.FAIL,
297
+ ...G("5.1.1", "section-5-1-1"),
298
+ title: anyUrl ? "Privacy policy URL set" : "Privacy policy URL missing",
299
+ detail: anyUrl ? `Set in ${withUrl.length} locale(s).` : "No privacy policy URL on the app's App Information. A privacy policy URL is required for every app.",
300
+ fix: anyUrl ? undefined : "Set a privacy policy URL (update_app_info_localization).",
301
+ });
302
+ }
303
+
304
+ // Description + support URL per version localization.
305
+ if (asc.versionLocalizations !== undefined) {
306
+ const locs = asc.versionLocalizations || [];
307
+ const noDesc = locs.filter((l) => !(l.description || "").trim()).map((l) => l.locale);
308
+ push({
309
+ id: "asc-description",
310
+ verdict: noDesc.length ? VERDICT.FAIL : VERDICT.PASS,
311
+ ...G("2.3.1", "section-2-3"),
312
+ title: noDesc.length ? "Description missing in some locales" : "Description present",
313
+ detail: noDesc.length ? `Missing in: ${noDesc.join(", ")}` : `${locs.length} locale(s) have a description.`,
314
+ });
315
+ const noSupport = locs.filter((l) => !(l.supportUrl || "").trim()).map((l) => l.locale);
316
+ push({
317
+ id: "asc-support-url",
318
+ verdict: noSupport.length ? VERDICT.FAIL : VERDICT.PASS,
319
+ ...ASC_REF("App information"),
320
+ title: noSupport.length ? "Support URL missing" : "Support URL set",
321
+ detail: noSupport.length ? `Missing in: ${noSupport.join(", ")}. A support URL is required to submit.` : "Support URL present.",
322
+ fix: noSupport.length ? "Set a support URL (update_app_store_version_localization)." : undefined,
323
+ });
324
+ // What's New only matters for an update that has a prior released version.
325
+ if (asc.isUpdate) {
326
+ const noWhats = locs.filter((l) => !(l.whatsNew || "").trim()).map((l) => l.locale);
327
+ if (noWhats.length) {
328
+ push({
329
+ id: "asc-whats-new",
330
+ verdict: VERDICT.WARN,
331
+ ...ASC_REF("Version information"),
332
+ title: "What's New empty for an update",
333
+ detail: `Missing in: ${noWhats.join(", ")}.`,
334
+ });
335
+ }
336
+ }
337
+ }
338
+
339
+ // Screenshots per locale.
340
+ if (asc.localesMissingScreenshots !== undefined) {
341
+ const missing = asc.localesMissingScreenshots || [];
342
+ push({
343
+ id: "asc-screenshots",
344
+ verdict: missing.length ? VERDICT.FAIL : VERDICT.PASS,
345
+ ...G("2.3.3", "section-2-3"),
346
+ title: missing.length ? "Screenshots missing in some locales" : "Screenshots present",
347
+ detail: missing.length ? `No screenshots in: ${missing.join(", ")}` : "Every locale has at least one screenshot.",
348
+ });
349
+ }
350
+
351
+ // Assets still processing block submission.
352
+ if (asc.incompleteAssets !== undefined) {
353
+ const n = (asc.incompleteAssets.screenshots?.length || 0) + (asc.incompleteAssets.previews?.length || 0);
354
+ push({
355
+ id: "asc-assets-processing",
356
+ verdict: n ? VERDICT.FAIL : VERDICT.PASS,
357
+ ...ASC_REF("Media"),
358
+ title: n ? "Media still processing" : "Media processing complete",
359
+ detail: n ? `${n} screenshot(s)/preview(s) are not COMPLETE; submission will fail until they finish.` : "All screenshots/previews are COMPLETE.",
360
+ });
361
+ }
362
+
363
+ // Age rating declaration.
364
+ if (asc.ageRatingDeclared !== undefined) {
365
+ push({
366
+ id: "asc-age-rating",
367
+ verdict: asc.ageRatingDeclared ? VERDICT.PASS : VERDICT.FAIL,
368
+ ...ASC_REF("Age rating"),
369
+ title: asc.ageRatingDeclared ? "Age rating set" : "Age rating not set",
370
+ detail: asc.ageRatingDeclared ? "An age-rating declaration exists." : "No age-rating declaration found. It's required to submit.",
371
+ });
372
+ }
373
+
374
+ // Build attached & VALID.
375
+ if (asc.build !== undefined) {
376
+ if (!asc.build || !asc.build.attached) {
377
+ push({
378
+ id: "asc-build-attached",
379
+ verdict: VERDICT.FAIL,
380
+ ...ASC_REF("Builds"),
381
+ title: "No build attached",
382
+ detail: "This version has no build attached.",
383
+ fix: "attach_build_to_version(versionId, buildId|buildNumber).",
384
+ });
385
+ } else {
386
+ const valid = asc.build.processingState === "VALID";
387
+ push({
388
+ id: "asc-build-attached",
389
+ verdict: valid ? VERDICT.PASS : VERDICT.FAIL,
390
+ ...ASC_REF("Builds"),
391
+ title: valid ? "Build attached and VALID" : `Build is ${asc.build.processingState}`,
392
+ detail: valid ? "A processed build is attached." : `Attached build is ${asc.build.processingState}, not VALID.`,
393
+ });
394
+ // Export compliance per build.
395
+ push({
396
+ id: "asc-export-compliance",
397
+ verdict: asc.build.usesNonExemptEncryption == null ? VERDICT.FAIL : VERDICT.PASS,
398
+ ...ASC_REF("Export compliance"),
399
+ title: asc.build.usesNonExemptEncryption == null ? "Export compliance unset on build" : "Export compliance set",
400
+ detail: asc.build.usesNonExemptEncryption == null
401
+ ? "The build's usesNonExemptEncryption is unset (per-build; does not carry over). A common cause of the opaque 409 at submit."
402
+ : `usesNonExemptEncryption = ${asc.build.usesNonExemptEncryption}.`,
403
+ fix: asc.build.usesNonExemptEncryption == null ? "update_build(buildId, usesNonExemptEncryption:false|true)." : undefined,
404
+ });
405
+ }
406
+ }
407
+
408
+ // IDFA declaration on the version.
409
+ if (asc.version !== undefined && asc.version) {
410
+ push({
411
+ id: "asc-idfa",
412
+ verdict: asc.version.usesIdfa == null ? VERDICT.FAIL : VERDICT.PASS,
413
+ ...ASC_REF("Advertising identifier (IDFA)"),
414
+ title: asc.version.usesIdfa == null ? "IDFA declaration unset" : "IDFA declaration set",
415
+ detail: asc.version.usesIdfa == null ? "The version's usesIdfa is unset; submission fails with an opaque 409." : `usesIdfa = ${asc.version.usesIdfa}.`,
416
+ fix: asc.version.usesIdfa == null ? "update_app_store_version(versionId, usesIdfa:false|true)." : undefined,
417
+ });
418
+ }
419
+
420
+ // Demo account / review notes when the app has a login.
421
+ if (asc.reviewDetail !== undefined && asc.hasLogin) {
422
+ const rd = asc.reviewDetail || {};
423
+ const hasDemo = Boolean((rd.demoAccountName || "").trim() && (rd.demoAccountPassword || "").trim());
424
+ const hasNotes = Boolean((rd.notes || "").trim());
425
+ const ok = hasDemo || hasNotes;
426
+ push({
427
+ id: "asc-demo-account",
428
+ verdict: ok ? VERDICT.PASS : VERDICT.WARN,
429
+ ...G("2.1", "section-2-1"),
430
+ title: ok ? "Review access provided" : "No demo account or review notes, but the app has a login",
431
+ detail: ok
432
+ ? (hasDemo ? "A demo account is set in App Review Information." : "Review notes explain how to access the app.")
433
+ : "The app appears to require sign-in but App Review Information has no demo account and no notes. Reviewers who can't get in reject under 2.1.",
434
+ fix: ok ? undefined : "Add a working demo account (or notes explaining access) in App Review Information.",
435
+ });
436
+ }
437
+
438
+ return out;
439
+ }
440
+
441
+ /** Combine source + ASC findings and roll up a status. */
442
+ export function evaluatePreflight({ scan, asc } = {}) {
443
+ const findings = [...evaluateSourceFindings(scan), ...evaluateAscFindings(asc)];
444
+ return { ...summarize(findings), findings, meta: {
445
+ scannedProject: Boolean(scan && !scan.error),
446
+ projectRoot: scan?.projectRoot,
447
+ ascChecked: Boolean(asc),
448
+ scanNote: scan?.error || (scan && !scan.projectTypes?.length ? scan.notes?.[0] : undefined),
449
+ } };
450
+ }
451
+
452
+ export function summarize(findings) {
453
+ const summary = { PASS: 0, FAIL: 0, WARN: 0, MANUAL: 0, ASC: 0, NA: 0 };
454
+ for (const f of findings) summary[f.verdict] = (summary[f.verdict] || 0) + 1;
455
+ const blocking = findings.filter((f) => f.verdict === VERDICT.FAIL);
456
+ const ready = blocking.length === 0;
457
+ const caution = summary.WARN + summary.MANUAL + summary.ASC;
458
+ let status;
459
+ if (!ready) status = `NOT READY — ${blocking.length} blocking issue(s)`;
460
+ else if (caution) status = `READY WITH CAUTION — ${caution} item(s) to review`;
461
+ else status = "READY";
462
+ return { status, ready, summary, blocking };
463
+ }