asc-doctor 1.0.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/dist/index.js ADDED
@@ -0,0 +1,2014 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+ import chalk2 from "chalk";
6
+ import ora from "ora";
7
+ import fs3 from "fs";
8
+
9
+ // src/config.ts
10
+ import fs from "fs";
11
+ import path from "path";
12
+ var CONFIG_FILE_NAMES = [".ascdocrc.json", ".ascdocrc", "ascdoc.config.json"];
13
+ function findConfigFile(cwd) {
14
+ for (const name of CONFIG_FILE_NAMES) {
15
+ const filePath = path.join(cwd, name);
16
+ if (fs.existsSync(filePath)) {
17
+ return filePath;
18
+ }
19
+ }
20
+ return null;
21
+ }
22
+ function loadConfigFile(filePath) {
23
+ try {
24
+ const content = fs.readFileSync(filePath, "utf-8");
25
+ return JSON.parse(content);
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+ function loadEnvConfig() {
31
+ const config = {};
32
+ if (process.env.ASC_KEY_ID) config.keyId = process.env.ASC_KEY_ID;
33
+ if (process.env.ASC_ISSUER_ID) config.issuerId = process.env.ASC_ISSUER_ID;
34
+ if (process.env.ASC_KEY_PATH) config.keyPath = process.env.ASC_KEY_PATH;
35
+ if (process.env.ASC_APP_ID) config.appId = process.env.ASC_APP_ID;
36
+ if (process.env.ASC_MIN_SCORE) config.minScore = parseInt(process.env.ASC_MIN_SCORE, 10);
37
+ return config;
38
+ }
39
+ function resolveConfig(cliOptions) {
40
+ const defaults = {
41
+ keyId: "",
42
+ issuerId: "",
43
+ keyPath: "",
44
+ appId: void 0,
45
+ minScore: 0,
46
+ skip: [],
47
+ only: [],
48
+ format: "terminal",
49
+ output: void 0,
50
+ ci: false,
51
+ demo: false
52
+ };
53
+ const configFilePath = findConfigFile(process.cwd());
54
+ const fileConfig = configFilePath ? loadConfigFile(configFilePath) : {};
55
+ const envConfig = loadEnvConfig();
56
+ const merged = {
57
+ ...defaults,
58
+ ...fileConfig,
59
+ ...envConfig,
60
+ ...stripUndefined(cliOptions)
61
+ };
62
+ return merged;
63
+ }
64
+ function stripUndefined(obj) {
65
+ const result = {};
66
+ for (const key in obj) {
67
+ if (obj[key] !== void 0) {
68
+ result[key] = obj[key];
69
+ }
70
+ }
71
+ return result;
72
+ }
73
+ function validateConfig(config) {
74
+ if (config.demo) return [];
75
+ const errors = [];
76
+ if (!config.keyId) {
77
+ errors.push("Missing --key-id (or ASC_KEY_ID env variable)");
78
+ }
79
+ if (!config.issuerId) {
80
+ errors.push("Missing --issuer-id (or ASC_ISSUER_ID env variable)");
81
+ }
82
+ if (!config.keyPath) {
83
+ errors.push("Missing --key (or ASC_KEY_PATH env variable)");
84
+ } else if (!fs.existsSync(config.keyPath)) {
85
+ errors.push(`Key file not found: ${config.keyPath}`);
86
+ }
87
+ return errors;
88
+ }
89
+
90
+ // src/auth.ts
91
+ import fs2 from "fs";
92
+ import * as jose from "jose";
93
+ var cachedToken = null;
94
+ var tokenExpiry = 0;
95
+ async function generateToken(credentials) {
96
+ const now = Math.floor(Date.now() / 1e3);
97
+ if (cachedToken && tokenExpiry > now + 60) {
98
+ return cachedToken;
99
+ }
100
+ const privateKeyPem = fs2.readFileSync(credentials.keyPath, "utf-8");
101
+ const privateKey = await jose.importPKCS8(privateKeyPem, "ES256");
102
+ const jwt = await new jose.SignJWT({}).setProtectedHeader({
103
+ alg: "ES256",
104
+ kid: credentials.keyId,
105
+ typ: "JWT"
106
+ }).setIssuer(credentials.issuerId).setIssuedAt(now).setExpirationTime(now + 1200).setAudience("appstoreconnect-v1").sign(privateKey);
107
+ cachedToken = jwt;
108
+ tokenExpiry = now + 1200;
109
+ return jwt;
110
+ }
111
+
112
+ // src/api/client.ts
113
+ var BASE_URL = "https://api.appstoreconnect.apple.com";
114
+ var MAX_RETRIES = 3;
115
+ var RATE_LIMIT_DELAY = 1100;
116
+ var ASCAPIError = class extends Error {
117
+ constructor(status, errors) {
118
+ const message = errors.map((e) => `[${e.status}] ${e.title}: ${e.detail}`).join("\n");
119
+ super(message);
120
+ this.status = status;
121
+ this.errors = errors;
122
+ this.name = "ASCAPIError";
123
+ }
124
+ status;
125
+ errors;
126
+ };
127
+ var lastRequestTime = 0;
128
+ async function throttle() {
129
+ const now = Date.now();
130
+ const elapsed = now - lastRequestTime;
131
+ if (elapsed < RATE_LIMIT_DELAY && lastRequestTime > 0) {
132
+ await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAY - elapsed));
133
+ }
134
+ lastRequestTime = Date.now();
135
+ }
136
+ var APIClient = class {
137
+ credentials;
138
+ constructor(credentials) {
139
+ this.credentials = credentials;
140
+ }
141
+ async request(path2, params) {
142
+ await throttle();
143
+ const token = await generateToken(this.credentials);
144
+ const url = new URL(path2, BASE_URL);
145
+ if (params) {
146
+ for (const [key, value] of Object.entries(params)) {
147
+ url.searchParams.set(key, value);
148
+ }
149
+ }
150
+ let lastError = null;
151
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
152
+ try {
153
+ const response = await fetch(url.toString(), {
154
+ headers: {
155
+ Authorization: `Bearer ${token}`,
156
+ "Content-Type": "application/json"
157
+ }
158
+ });
159
+ if (response.status === 429) {
160
+ const retryAfter = parseInt(response.headers.get("Retry-After") || "5", 10);
161
+ await new Promise((resolve) => setTimeout(resolve, retryAfter * 1e3));
162
+ continue;
163
+ }
164
+ if (!response.ok) {
165
+ const body = await response.json();
166
+ throw new ASCAPIError(response.status, body.errors || []);
167
+ }
168
+ return await response.json();
169
+ } catch (error) {
170
+ lastError = error instanceof Error ? error : new Error(String(error));
171
+ if (error instanceof ASCAPIError && error.status !== 429 && error.status !== 500) {
172
+ throw error;
173
+ }
174
+ if (attempt < MAX_RETRIES - 1) {
175
+ await new Promise((resolve) => setTimeout(resolve, 1e3 * (attempt + 1)));
176
+ }
177
+ }
178
+ }
179
+ throw lastError || new Error("Max retries exceeded");
180
+ }
181
+ async paginate(path2, params) {
182
+ const allData = [];
183
+ let nextUrl = void 0;
184
+ const limit = "200";
185
+ const initialParams = { ...params, "limit": limit };
186
+ const firstResponse = await this.request(path2, initialParams);
187
+ allData.push(...Array.isArray(firstResponse.data) ? firstResponse.data : [firstResponse.data]);
188
+ nextUrl = firstResponse.links?.next;
189
+ while (nextUrl) {
190
+ await throttle();
191
+ const token = await generateToken(this.credentials);
192
+ const response = await fetch(nextUrl, {
193
+ headers: {
194
+ Authorization: `Bearer ${token}`,
195
+ "Content-Type": "application/json"
196
+ }
197
+ });
198
+ if (!response.ok) {
199
+ break;
200
+ }
201
+ const body = await response.json();
202
+ if (Array.isArray(body.data)) {
203
+ allData.push(...body.data);
204
+ }
205
+ nextUrl = body.links?.next;
206
+ }
207
+ return allData;
208
+ }
209
+ };
210
+
211
+ // src/api/fetcher.ts
212
+ async function fetchAppData(client, appId) {
213
+ const app = await resolveApp(client, appId);
214
+ const version = await getLatestVersion(client, app.id);
215
+ const [
216
+ versionLocalizations,
217
+ appInfoResult,
218
+ screenshotSets,
219
+ reviewDetail
220
+ ] = await Promise.all([
221
+ getVersionLocalizations(client, version.id),
222
+ getAppInfo(client, app.id),
223
+ getScreenshotSets(client, version.id),
224
+ getReviewDetail(client, version.id)
225
+ ]);
226
+ const { appInfo, appInfoLocalizations, ageRatingDeclaration } = appInfoResult;
227
+ const [subscriptionData, iapData, territories] = await Promise.all([
228
+ getSubscriptionData(client, app.id),
229
+ getIAPData(client, app.id),
230
+ getTerritories(client, app.id)
231
+ ]);
232
+ return {
233
+ app,
234
+ version,
235
+ versionLocalizations,
236
+ appInfo,
237
+ appInfoLocalizations,
238
+ ageRatingDeclaration,
239
+ screenshotSets,
240
+ ...subscriptionData,
241
+ inAppPurchases: iapData,
242
+ reviewDetail,
243
+ availableTerritories: territories
244
+ };
245
+ }
246
+ async function resolveApp(client, appId) {
247
+ if (appId) {
248
+ const response = await client.request(`/v1/apps/${appId}`);
249
+ return response.data;
250
+ }
251
+ const apps = await client.paginate("/v1/apps");
252
+ if (apps.length === 0) {
253
+ throw new Error("No apps found in your App Store Connect account.");
254
+ }
255
+ if (apps.length === 1) {
256
+ return apps[0];
257
+ }
258
+ const appList = apps.map((a) => ` \u2022 ${a.attributes.name} (${a.attributes.bundleId}) \u2192 --app-id ${a.id}`).join("\n");
259
+ throw new Error(
260
+ `Multiple apps found. Please specify --app-id:
261
+
262
+ ${appList}`
263
+ );
264
+ }
265
+ async function getLatestVersion(client, appId) {
266
+ const versions = await client.paginate(
267
+ `/v1/apps/${appId}/appStoreVersions`,
268
+ {
269
+ "filter[platform]": "IOS",
270
+ "sort": "-createdDate",
271
+ "limit": "5"
272
+ }
273
+ );
274
+ if (versions.length === 0) {
275
+ throw new Error("No App Store versions found for this app.");
276
+ }
277
+ const editableStates = [
278
+ "PREPARE_FOR_SUBMISSION",
279
+ "DEVELOPER_REJECTED",
280
+ "REJECTED",
281
+ "METADATA_REJECTED",
282
+ "INVALID_BINARY"
283
+ ];
284
+ const editable = versions.find((v) => editableStates.includes(v.attributes.appStoreState));
285
+ return editable || versions[0];
286
+ }
287
+ async function getVersionLocalizations(client, versionId) {
288
+ return client.paginate(
289
+ `/v1/appStoreVersions/${versionId}/appStoreVersionLocalizations`
290
+ );
291
+ }
292
+ async function getAppInfo(client, appId) {
293
+ const appInfos = await client.paginate(`/v1/apps/${appId}/appInfos`);
294
+ if (appInfos.length === 0) {
295
+ throw new Error("No app info found.");
296
+ }
297
+ const appInfo = appInfos[0];
298
+ const [localizations, ageRating] = await Promise.all([
299
+ client.paginate(
300
+ `/v1/appInfos/${appInfo.id}/appInfoLocalizations`
301
+ ),
302
+ getAgeRatingDeclaration(client, appInfo.id)
303
+ ]);
304
+ return {
305
+ appInfo,
306
+ appInfoLocalizations: localizations,
307
+ ageRatingDeclaration: ageRating
308
+ };
309
+ }
310
+ async function getAgeRatingDeclaration(client, appInfoId) {
311
+ try {
312
+ const response = await client.request(
313
+ `/v1/appInfos/${appInfoId}/ageRatingDeclaration`
314
+ );
315
+ return response.data;
316
+ } catch {
317
+ return null;
318
+ }
319
+ }
320
+ async function getScreenshotSets(client, versionId) {
321
+ const localizations = await client.paginate(
322
+ `/v1/appStoreVersions/${versionId}/appStoreVersionLocalizations`
323
+ );
324
+ const allSets = [];
325
+ for (const loc of localizations) {
326
+ const sets = await client.paginate(
327
+ `/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`
328
+ );
329
+ for (const set of sets) {
330
+ const screenshots = await client.paginate(
331
+ `/v1/appScreenshotSets/${set.id}/appScreenshots`
332
+ );
333
+ allSets.push({
334
+ ...set,
335
+ screenshotCount: screenshots.length,
336
+ locale: loc.attributes.locale
337
+ });
338
+ }
339
+ }
340
+ return allSets;
341
+ }
342
+ async function getReviewDetail(client, versionId) {
343
+ try {
344
+ const response = await client.request(
345
+ `/v1/appStoreVersions/${versionId}/appStoreReviewDetail`
346
+ );
347
+ return response.data;
348
+ } catch {
349
+ return null;
350
+ }
351
+ }
352
+ async function getSubscriptionData(client, appId) {
353
+ try {
354
+ const groups = await client.paginate(
355
+ `/v1/apps/${appId}/subscriptionGroups`
356
+ );
357
+ const subscriptions = [];
358
+ const groupLocalizations = [];
359
+ for (const group of groups) {
360
+ const subs = await client.paginate(
361
+ `/v1/subscriptionGroups/${group.id}/subscriptions`
362
+ );
363
+ for (const sub of subs) {
364
+ const locs = await client.paginate(
365
+ `/v1/subscriptions/${sub.id}/subscriptionLocalizations`
366
+ );
367
+ subscriptions.push({ ...sub, localizations: locs });
368
+ }
369
+ const gLocs = await client.paginate(
370
+ `/v1/subscriptionGroups/${group.id}/subscriptionGroupLocalizations`
371
+ );
372
+ groupLocalizations.push({ groupId: group.id, localizations: gLocs });
373
+ }
374
+ return {
375
+ subscriptionGroups: groups,
376
+ subscriptions,
377
+ subscriptionGroupLocalizations: groupLocalizations
378
+ };
379
+ } catch {
380
+ return {
381
+ subscriptionGroups: [],
382
+ subscriptions: [],
383
+ subscriptionGroupLocalizations: []
384
+ };
385
+ }
386
+ }
387
+ async function getIAPData(client, appId) {
388
+ try {
389
+ const iaps = await client.paginate(
390
+ `/v2/apps/${appId}/inAppPurchasesV2`
391
+ );
392
+ const enriched = [];
393
+ for (const iap of iaps) {
394
+ const locs = await client.paginate(
395
+ `/v2/inAppPurchases/${iap.id}/inAppPurchaseLocalizations`
396
+ );
397
+ enriched.push({ ...iap, localizations: locs });
398
+ }
399
+ return enriched;
400
+ } catch {
401
+ return [];
402
+ }
403
+ }
404
+ async function getTerritories(client, appId) {
405
+ try {
406
+ return await client.paginate(
407
+ `/v1/apps/${appId}/availableTerritories`
408
+ );
409
+ } catch {
410
+ return [];
411
+ }
412
+ }
413
+
414
+ // src/auditors/localization.ts
415
+ var PLACEHOLDER_PATTERNS = [
416
+ /lorem\s+ipsum/i,
417
+ /\bTODO\b/,
418
+ /\bTBD\b/,
419
+ /\bFIXME\b/,
420
+ /\[PLACEHOLDER\]/i,
421
+ /\bXXX\b/,
422
+ /\bsample\s+text\b/i,
423
+ /\bdummy\b/i,
424
+ /\btest\s+description\b/i,
425
+ /\bcoming\s+soon\b/i
426
+ ];
427
+ function auditLocalization(data) {
428
+ const start = Date.now();
429
+ const findings = [];
430
+ const primaryLocale = data.app.attributes.primaryLocale;
431
+ for (const loc of data.versionLocalizations) {
432
+ const locale = loc.attributes.locale;
433
+ const attrs = loc.attributes;
434
+ if (!attrs.description || attrs.description.trim().length === 0) {
435
+ findings.push({
436
+ id: "LOC-001",
437
+ module: "localization",
438
+ severity: "critical",
439
+ title: `Missing description for locale \`${locale}\``,
440
+ message: `The App Store description is empty for the ${locale} localization. This is a required field for submission.`,
441
+ locale,
442
+ remedy: `Add a compelling description for ${locale} in App Store Connect > App Store > Version Information.`
443
+ });
444
+ }
445
+ if (!attrs.keywords || attrs.keywords.trim().length === 0) {
446
+ findings.push({
447
+ id: "LOC-002",
448
+ module: "localization",
449
+ severity: "warning",
450
+ title: `Missing keywords for locale \`${locale}\``,
451
+ message: `Keywords field is empty for ${locale}. This is a missed ASO (App Store Optimization) opportunity.`,
452
+ locale,
453
+ remedy: `Add comma-separated keywords (max 100 chars) for ${locale} to improve search visibility.`
454
+ });
455
+ }
456
+ if (!attrs.promotionalText || attrs.promotionalText.trim().length === 0) {
457
+ findings.push({
458
+ id: "LOC-003",
459
+ module: "localization",
460
+ severity: "info",
461
+ title: `Missing promotional text for locale \`${locale}\``,
462
+ message: `Promotional text is empty for ${locale}. This field can be updated without app review.`,
463
+ locale,
464
+ remedy: `Add promotional text for ${locale}. This is shown at the top of the description and can be updated anytime.`
465
+ });
466
+ }
467
+ if (attrs.description) {
468
+ for (const pattern of PLACEHOLDER_PATTERNS) {
469
+ if (pattern.test(attrs.description)) {
470
+ findings.push({
471
+ id: "LOC-004",
472
+ module: "localization",
473
+ severity: "critical",
474
+ title: `Placeholder text detected in \`${locale}\` description`,
475
+ message: `The description for ${locale} contains placeholder text matching "${pattern.source}". This will likely cause rejection.`,
476
+ locale,
477
+ remedy: `Replace placeholder text in the ${locale} description with real content before submission.`
478
+ });
479
+ break;
480
+ }
481
+ }
482
+ }
483
+ if (attrs.description && attrs.description.trim().length > 0 && attrs.description.trim().length < 100) {
484
+ findings.push({
485
+ id: "LOC-005",
486
+ module: "localization",
487
+ severity: "warning",
488
+ title: `Description too short for locale \`${locale}\``,
489
+ message: `The description for ${locale} is only ${attrs.description.trim().length} characters. Consider expanding it for better ASO.`,
490
+ locale,
491
+ remedy: `Expand the ${locale} description to at least 100 characters with feature highlights, benefits, and relevant keywords.`
492
+ });
493
+ }
494
+ if (attrs.keywords) {
495
+ for (const pattern of PLACEHOLDER_PATTERNS) {
496
+ if (pattern.test(attrs.keywords)) {
497
+ findings.push({
498
+ id: "LOC-006",
499
+ module: "localization",
500
+ severity: "critical",
501
+ title: `Placeholder text in \`${locale}\` keywords`,
502
+ message: `Keywords for ${locale} contain placeholder text matching "${pattern.source}".`,
503
+ locale,
504
+ remedy: `Replace placeholder keywords with real, relevant search terms for ${locale}.`
505
+ });
506
+ break;
507
+ }
508
+ }
509
+ }
510
+ }
511
+ const hasPrimaryLocale = data.versionLocalizations.some(
512
+ (l) => l.attributes.locale === primaryLocale
513
+ );
514
+ if (!hasPrimaryLocale) {
515
+ findings.push({
516
+ id: "LOC-007",
517
+ module: "localization",
518
+ severity: "critical",
519
+ title: `Primary locale \`${primaryLocale}\` missing version localization`,
520
+ message: `Your app's primary locale (${primaryLocale}) has no version localization. This is required for submission.`,
521
+ locale: primaryLocale,
522
+ remedy: `Add a version localization for your primary locale (${primaryLocale}) in App Store Connect.`
523
+ });
524
+ }
525
+ const versionLocales = new Set(data.versionLocalizations.map((l) => l.attributes.locale));
526
+ const infoLocales = new Set(data.appInfoLocalizations.map((l) => l.attributes.locale));
527
+ for (const infoLocale of infoLocales) {
528
+ if (!versionLocales.has(infoLocale)) {
529
+ findings.push({
530
+ id: "LOC-008",
531
+ module: "localization",
532
+ severity: "high",
533
+ title: `App info locale \`${infoLocale}\` missing version metadata`,
534
+ message: `App info localization exists for ${infoLocale} but there's no corresponding version localization (description, keywords, etc.).`,
535
+ locale: infoLocale,
536
+ remedy: `Add version metadata (description, keywords) for ${infoLocale} or remove the app info localization if not needed.`
537
+ });
538
+ }
539
+ }
540
+ return {
541
+ module: "localization",
542
+ label: "Localization",
543
+ icon: "\u{1F30D}",
544
+ findings,
545
+ duration: Date.now() - start
546
+ };
547
+ }
548
+
549
+ // src/auditors/screenshots.ts
550
+ var REQUIRED_IPHONE_TYPES = [
551
+ "APP_IPHONE_67",
552
+ // 6.7" (iPhone 15 Pro Max, 16 Plus)
553
+ "APP_IPHONE_69"
554
+ // 6.9" (iPhone 16 Pro Max) — newest
555
+ ];
556
+ var DEVICE_TYPE_LABELS = {
557
+ "APP_IPHONE_35": 'iPhone 3.5"',
558
+ "APP_IPHONE_40": 'iPhone 4.0"',
559
+ "APP_IPHONE_47": 'iPhone 4.7"',
560
+ "APP_IPHONE_55": 'iPhone 5.5"',
561
+ "APP_IPHONE_58": 'iPhone 5.8"',
562
+ "APP_IPHONE_61": 'iPhone 6.1"',
563
+ "APP_IPHONE_65": 'iPhone 6.5"',
564
+ "APP_IPHONE_67": 'iPhone 6.7"',
565
+ "APP_IPHONE_69": 'iPhone 6.9"',
566
+ "APP_IPAD_97": 'iPad 9.7"',
567
+ "APP_IPAD_105": 'iPad 10.5"',
568
+ "APP_IPAD_PRO_3GEN_11": 'iPad Pro 11"',
569
+ "APP_IPAD_PRO_3GEN_129": 'iPad Pro 12.9"',
570
+ "APP_IPAD_PRO_129": 'iPad Pro 12.9"',
571
+ "APP_IPAD_13": 'iPad 13"',
572
+ "APP_APPLE_TV": "Apple TV",
573
+ "APP_WATCH_ULTRA": "Apple Watch Ultra",
574
+ "APP_WATCH_SERIES_7": "Apple Watch Series 7+",
575
+ "APP_WATCH_SERIES_4": "Apple Watch Series 4-6",
576
+ "APP_DESKTOP": "Mac Desktop"
577
+ };
578
+ function auditScreenshots(data) {
579
+ const start = Date.now();
580
+ const findings = [];
581
+ const platform = data.version.attributes.platform;
582
+ const setsByLocale = /* @__PURE__ */ new Map();
583
+ for (const set of data.screenshotSets) {
584
+ const locale = set.locale;
585
+ if (!setsByLocale.has(locale)) {
586
+ setsByLocale.set(locale, []);
587
+ }
588
+ setsByLocale.get(locale).push(set);
589
+ }
590
+ for (const loc of data.versionLocalizations) {
591
+ const locale = loc.attributes.locale;
592
+ const sets = setsByLocale.get(locale);
593
+ if (!sets || sets.length === 0) {
594
+ findings.push({
595
+ id: "SCR-001",
596
+ module: "screenshots",
597
+ severity: "critical",
598
+ title: `No screenshots for locale \`${locale}\``,
599
+ message: `The ${locale} localization has no screenshot sets. At least one set with screenshots is required for submission.`,
600
+ locale,
601
+ remedy: `Upload screenshots for ${locale} in App Store Connect > App Store > Version > Media.`
602
+ });
603
+ continue;
604
+ }
605
+ const deviceTypes = sets.map((s) => s.attributes.screenshotDisplayType);
606
+ if (platform === "IOS") {
607
+ const hasRequiredIPhone = REQUIRED_IPHONE_TYPES.some((t) => deviceTypes.includes(t));
608
+ if (!hasRequiredIPhone) {
609
+ findings.push({
610
+ id: "SCR-002",
611
+ module: "screenshots",
612
+ severity: "critical",
613
+ title: `Missing required iPhone screenshots for \`${locale}\``,
614
+ message: `No 6.7" or 6.9" iPhone screenshots found for ${locale}. Apple requires screenshots for the largest iPhone display.`,
615
+ locale,
616
+ remedy: `Upload 6.9" (1320\xD72868) or 6.7" (1290\xD72796) iPhone screenshots for ${locale}.`
617
+ });
618
+ }
619
+ }
620
+ for (const set of sets) {
621
+ if (set.screenshotCount === 0) {
622
+ const deviceLabel = DEVICE_TYPE_LABELS[set.attributes.screenshotDisplayType] || set.attributes.screenshotDisplayType;
623
+ findings.push({
624
+ id: "SCR-003",
625
+ module: "screenshots",
626
+ severity: "high",
627
+ title: `Empty screenshot set for ${deviceLabel} in \`${locale}\``,
628
+ message: `A screenshot set exists for ${deviceLabel} (${locale}) but contains 0 screenshots. Either upload screenshots or remove the empty set.`,
629
+ locale,
630
+ remedy: `Upload screenshots to the ${deviceLabel} set for ${locale}, or delete the empty set.`
631
+ });
632
+ }
633
+ }
634
+ for (const set of sets) {
635
+ if (set.screenshotCount > 0 && set.screenshotCount < 3) {
636
+ const deviceLabel = DEVICE_TYPE_LABELS[set.attributes.screenshotDisplayType] || set.attributes.screenshotDisplayType;
637
+ findings.push({
638
+ id: "SCR-004",
639
+ module: "screenshots",
640
+ severity: "warning",
641
+ title: `Few screenshots for ${deviceLabel} in \`${locale}\``,
642
+ message: `Only ${set.screenshotCount} screenshot(s) for ${deviceLabel} (${locale}). Apple allows up to 10. More screenshots improve conversion.`,
643
+ locale,
644
+ remedy: `Consider adding more screenshots (recommended: 5-8) for ${deviceLabel} in ${locale}.`
645
+ });
646
+ }
647
+ }
648
+ }
649
+ const primaryLocale = data.app.attributes.primaryLocale;
650
+ if (!setsByLocale.has(primaryLocale) || setsByLocale.get(primaryLocale).length === 0) {
651
+ const alreadyCaught = findings.some(
652
+ (f) => f.id === "SCR-001" && f.locale === primaryLocale
653
+ );
654
+ if (!alreadyCaught) {
655
+ findings.push({
656
+ id: "SCR-005",
657
+ module: "screenshots",
658
+ severity: "critical",
659
+ title: `Primary locale \`${primaryLocale}\` has no screenshots`,
660
+ message: `Your primary locale (${primaryLocale}) is missing screenshots. This is required for submission.`,
661
+ locale: primaryLocale,
662
+ remedy: `Upload screenshots for your primary locale (${primaryLocale}) before submitting.`
663
+ });
664
+ }
665
+ }
666
+ return {
667
+ module: "screenshots",
668
+ label: "Screenshots",
669
+ icon: "\u{1F4F1}",
670
+ findings,
671
+ duration: Date.now() - start
672
+ };
673
+ }
674
+
675
+ // src/auditors/age-rating.ts
676
+ var NONE_VALUE = "NONE";
677
+ function auditAgeRating(data) {
678
+ const start = Date.now();
679
+ const findings = [];
680
+ if (!data.ageRatingDeclaration) {
681
+ findings.push({
682
+ id: "AGE-001",
683
+ module: "age-rating",
684
+ severity: "high",
685
+ title: "Age rating declaration not found",
686
+ message: "Could not retrieve the age rating declaration. This may indicate it has not been configured.",
687
+ remedy: "Go to App Store Connect > App Information > Age Rating and complete the questionnaire."
688
+ });
689
+ return {
690
+ module: "age-rating",
691
+ label: "Age Rating",
692
+ icon: "\u{1F51E}",
693
+ findings,
694
+ duration: Date.now() - start
695
+ };
696
+ }
697
+ const attrs = data.ageRatingDeclaration.attributes;
698
+ const contentFields = [
699
+ "alcoholTobaccoOrDrugUseOrReferences",
700
+ "horrorOrFearThemes",
701
+ "matureOrSuggestiveThemes",
702
+ "profanityOrCrudeHumor",
703
+ "sexualContentGraphicAndNudity",
704
+ "sexualContentOrNudity",
705
+ "violenceCartoonOrFantasy",
706
+ "violenceRealistic",
707
+ "violenceRealisticProlongedGraphicOrSadistic",
708
+ "gamblingSimulated"
709
+ ];
710
+ const allNone = contentFields.every((field) => {
711
+ const value = attrs[field];
712
+ return value === null || value === NONE_VALUE || value === "INFREQUENT_OR_MILD";
713
+ });
714
+ const allNull = contentFields.every((field) => attrs[field] === null);
715
+ if (allNull) {
716
+ findings.push({
717
+ id: "AGE-002",
718
+ module: "age-rating",
719
+ severity: "high",
720
+ title: "Age rating fields appear unset",
721
+ message: "All age rating content descriptors are null, suggesting the questionnaire may not have been completed.",
722
+ remedy: "Complete the age rating questionnaire in App Store Connect > App Information > Age Rating."
723
+ });
724
+ } else if (allNone) {
725
+ findings.push({
726
+ id: "AGE-003",
727
+ module: "age-rating",
728
+ severity: "info",
729
+ title: "All age rating fields set to minimal",
730
+ message: "All content descriptors are set to NONE or INFREQUENT_OR_MILD. Verify this accurately reflects your app's content.",
731
+ remedy: "Review the age rating questionnaire to ensure it accurately represents your app's content."
732
+ });
733
+ }
734
+ if (attrs.gambling === true && (!attrs.gamblingSimulated || attrs.gamblingSimulated === NONE_VALUE)) {
735
+ findings.push({
736
+ id: "AGE-004",
737
+ module: "age-rating",
738
+ severity: "critical",
739
+ title: "Gambling flag set without proper classification",
740
+ message: "The gambling flag is enabled but simulated gambling content descriptor is not configured. This inconsistency may cause review issues.",
741
+ remedy: "Review the gambling-related fields in the age rating to ensure consistency."
742
+ });
743
+ }
744
+ if (attrs.unrestrictedWebAccess === true) {
745
+ findings.push({
746
+ id: "AGE-005",
747
+ module: "age-rating",
748
+ severity: "info",
749
+ title: "Unrestricted web access enabled",
750
+ message: "Your app is flagged as providing unrestricted web access. This increases the age rating. Ensure this is intentional.",
751
+ remedy: "If your app doesn't provide a general web browser, consider disabling this flag to lower the age rating."
752
+ });
753
+ }
754
+ if (attrs.seventeenPlus === true) {
755
+ findings.push({
756
+ id: "AGE-006",
757
+ module: "age-rating",
758
+ severity: "info",
759
+ title: "App rated 17+",
760
+ message: "Your app is flagged as 17+. This significantly limits your audience. Ensure this is necessary.",
761
+ remedy: "If possible, review whether 17+ content can be gated or made optional to lower the base age rating."
762
+ });
763
+ }
764
+ if (attrs.kidsAgeBand !== null && attrs.kidsAgeBand !== void 0) {
765
+ const hasMatureContent = attrs.violenceRealistic && attrs.violenceRealistic !== NONE_VALUE || attrs.sexualContentOrNudity && attrs.sexualContentOrNudity !== NONE_VALUE || attrs.profanityOrCrudeHumor && attrs.profanityOrCrudeHumor !== NONE_VALUE && attrs.profanityOrCrudeHumor !== "INFREQUENT_OR_MILD";
766
+ if (hasMatureContent) {
767
+ findings.push({
768
+ id: "AGE-007",
769
+ module: "age-rating",
770
+ severity: "critical",
771
+ title: "Kids age band with mature content flags",
772
+ message: "Your app targets kids (Kids Age Band is set) but also has mature content descriptors. This is an inconsistency that will likely cause rejection.",
773
+ remedy: "Either remove the Kids Age Band setting or adjust the content descriptors to be appropriate for children."
774
+ });
775
+ }
776
+ }
777
+ return {
778
+ module: "age-rating",
779
+ label: "Age Rating",
780
+ icon: "\u{1F51E}",
781
+ findings,
782
+ duration: Date.now() - start
783
+ };
784
+ }
785
+
786
+ // src/auditors/subtitle.ts
787
+ var GENERIC_WORDS = [
788
+ "best",
789
+ "great",
790
+ "amazing",
791
+ "awesome",
792
+ "top",
793
+ "number one",
794
+ "#1",
795
+ "the best",
796
+ "most popular",
797
+ "world's",
798
+ "leading",
799
+ "ultimate",
800
+ "premium",
801
+ "pro",
802
+ "simple",
803
+ "easy"
804
+ ];
805
+ function auditSubtitle(data) {
806
+ const start = Date.now();
807
+ const findings = [];
808
+ for (const loc of data.appInfoLocalizations) {
809
+ const locale = loc.attributes.locale;
810
+ const subtitle = loc.attributes.subtitle;
811
+ const appName = loc.attributes.name;
812
+ if (!subtitle || subtitle.trim().length === 0) {
813
+ findings.push({
814
+ id: "SUB-001",
815
+ module: "subtitle",
816
+ severity: "warning",
817
+ title: `Missing subtitle for locale \`${locale}\``,
818
+ message: `No subtitle is set for ${locale}. Subtitles appear below the app name in search results and improve discoverability.`,
819
+ locale,
820
+ remedy: `Add a compelling subtitle (max 30 chars) for ${locale} that highlights your app's key value proposition.`
821
+ });
822
+ continue;
823
+ }
824
+ if (subtitle.trim().length < 10) {
825
+ findings.push({
826
+ id: "SUB-002",
827
+ module: "subtitle",
828
+ severity: "info",
829
+ title: `Subtitle too short for locale \`${locale}\``,
830
+ message: `The subtitle for ${locale} is only ${subtitle.trim().length} characters ("${subtitle.trim()}"). Consider using more of the 30-character limit.`,
831
+ locale,
832
+ remedy: `Expand the subtitle for ${locale} to better utilize the 30-character limit.`
833
+ });
834
+ }
835
+ if (appName && subtitle.toLowerCase().includes(appName.toLowerCase())) {
836
+ findings.push({
837
+ id: "SUB-003",
838
+ module: "subtitle",
839
+ severity: "warning",
840
+ title: `Subtitle repeats app name for locale \`${locale}\``,
841
+ message: `The subtitle for ${locale} ("${subtitle}") contains the app name ("${appName}"). This wastes valuable subtitle space and may trigger review flags.`,
842
+ locale,
843
+ remedy: `Use the subtitle to add new information, not repeat the app name. Focus on features or benefits.`
844
+ });
845
+ }
846
+ const lowerSubtitle = subtitle.toLowerCase();
847
+ const matchedGeneric = GENERIC_WORDS.find((word) => lowerSubtitle.includes(word));
848
+ if (matchedGeneric) {
849
+ findings.push({
850
+ id: "SUB-004",
851
+ module: "subtitle",
852
+ severity: "warning",
853
+ title: `Generic subtitle for locale \`${locale}\``,
854
+ message: `The subtitle for ${locale} ("${subtitle}") contains the generic term "${matchedGeneric}". Apple may flag superlative or subjective claims.`,
855
+ locale,
856
+ remedy: `Replace generic terms with specific, factual descriptions of what your app does.`
857
+ });
858
+ }
859
+ if (subtitle.length > 30) {
860
+ findings.push({
861
+ id: "SUB-005",
862
+ module: "subtitle",
863
+ severity: "high",
864
+ title: `Subtitle too long for locale \`${locale}\``,
865
+ message: `The subtitle for ${locale} is ${subtitle.length} characters, exceeding the 30-character limit. It will be truncated.`,
866
+ locale,
867
+ remedy: `Shorten the subtitle for ${locale} to 30 characters or fewer.`
868
+ });
869
+ }
870
+ }
871
+ return {
872
+ module: "subtitle",
873
+ label: "Subtitle",
874
+ icon: "\u{1F4AC}",
875
+ findings,
876
+ duration: Date.now() - start
877
+ };
878
+ }
879
+
880
+ // src/auditors/privacy.ts
881
+ function auditPrivacy(data) {
882
+ const start = Date.now();
883
+ const findings = [];
884
+ for (const loc of data.appInfoLocalizations) {
885
+ const locale = loc.attributes.locale;
886
+ if (!loc.attributes.privacyPolicyUrl || loc.attributes.privacyPolicyUrl.trim().length === 0) {
887
+ findings.push({
888
+ id: "PRV-001",
889
+ module: "privacy",
890
+ severity: "critical",
891
+ title: `Missing privacy policy URL for locale \`${locale}\``,
892
+ message: `No privacy policy URL is set for ${locale}. A valid privacy policy URL is required for all apps since October 2018.`,
893
+ locale,
894
+ remedy: `Add a publicly accessible privacy policy URL for ${locale} in App Store Connect > App Information.`
895
+ });
896
+ continue;
897
+ }
898
+ const url = loc.attributes.privacyPolicyUrl.trim();
899
+ try {
900
+ new URL(url);
901
+ } catch {
902
+ findings.push({
903
+ id: "PRV-002",
904
+ module: "privacy",
905
+ severity: "high",
906
+ title: `Invalid privacy policy URL for locale \`${locale}\``,
907
+ message: `The privacy policy URL for ${locale} ("${url}") is not a valid URL format.`,
908
+ locale,
909
+ remedy: `Fix the privacy policy URL format for ${locale}. It should be a full URL starting with https://`
910
+ });
911
+ continue;
912
+ }
913
+ if (!url.startsWith("https://")) {
914
+ findings.push({
915
+ id: "PRV-003",
916
+ module: "privacy",
917
+ severity: "warning",
918
+ title: `Privacy policy URL not HTTPS for locale \`${locale}\``,
919
+ message: `The privacy policy URL for ${locale} uses HTTP instead of HTTPS. Apple recommends HTTPS for all URLs.`,
920
+ locale,
921
+ remedy: `Update the privacy policy URL for ${locale} to use HTTPS.`
922
+ });
923
+ }
924
+ }
925
+ const urls = data.appInfoLocalizations.map((l) => l.attributes.privacyPolicyUrl?.trim()).filter(Boolean);
926
+ if (urls.length > 1) {
927
+ const uniqueUrls = new Set(urls);
928
+ if (uniqueUrls.size > 3 && uniqueUrls.size === urls.length) {
929
+ findings.push({
930
+ id: "PRV-004",
931
+ module: "privacy",
932
+ severity: "info",
933
+ title: "Many unique privacy policy URLs",
934
+ message: `Found ${uniqueUrls.size} unique privacy policy URLs across ${urls.length} localizations. Verify each is correct and accessible.`,
935
+ remedy: "Review all privacy policy URLs to ensure they point to valid, locale-appropriate privacy policies."
936
+ });
937
+ }
938
+ }
939
+ const hasPrivacyChoicesUrl = data.appInfoLocalizations.some(
940
+ (l) => l.attributes.privacyChoicesUrl && l.attributes.privacyChoicesUrl.trim().length > 0
941
+ );
942
+ if (!hasPrivacyChoicesUrl) {
943
+ findings.push({
944
+ id: "PRV-005",
945
+ module: "privacy",
946
+ severity: "info",
947
+ title: "No privacy choices URL configured",
948
+ message: "No privacy choices URL is set. This is optional but recommended if your app collects personal data and you offer data management options.",
949
+ remedy: "Consider adding a privacy choices URL where users can manage their data preferences."
950
+ });
951
+ }
952
+ return {
953
+ module: "privacy",
954
+ label: "Privacy",
955
+ icon: "\u{1F512}",
956
+ findings,
957
+ duration: Date.now() - start
958
+ };
959
+ }
960
+
961
+ // src/auditors/subscription.ts
962
+ function auditSubscriptions(data) {
963
+ const start = Date.now();
964
+ const findings = [];
965
+ const appLocales = new Set(data.versionLocalizations.map((l) => l.attributes.locale));
966
+ for (const groupLocData of data.subscriptionGroupLocalizations) {
967
+ const { groupId, localizations } = groupLocData;
968
+ const group = data.subscriptionGroups.find((g) => g.id === groupId);
969
+ const groupName = group?.attributes.referenceName || groupId;
970
+ const groupLocales = new Set(localizations.map((l) => l.attributes.locale));
971
+ for (const locale of appLocales) {
972
+ if (!groupLocales.has(locale)) {
973
+ findings.push({
974
+ id: "SUBS-001",
975
+ module: "subscription",
976
+ severity: "high",
977
+ title: `Subscription group "${groupName}" missing \`${locale}\` localization`,
978
+ message: `The subscription group "${groupName}" has no display name for ${locale}. This can cause "Missing Metadata" states for subscriptions in this group.`,
979
+ locale,
980
+ remedy: `Add a display name for ${locale} under the subscription group "${groupName}" in App Store Connect.`
981
+ });
982
+ }
983
+ }
984
+ for (const loc of localizations) {
985
+ if (!loc.attributes.name || loc.attributes.name.trim().length === 0) {
986
+ findings.push({
987
+ id: "SUBS-002",
988
+ module: "subscription",
989
+ severity: "high",
990
+ title: `Subscription group "${groupName}" has empty name for \`${loc.attributes.locale}\``,
991
+ message: `The display name for subscription group "${groupName}" in ${loc.attributes.locale} is empty.`,
992
+ locale: loc.attributes.locale,
993
+ remedy: `Set a display name for the subscription group "${groupName}" in ${loc.attributes.locale}.`
994
+ });
995
+ }
996
+ }
997
+ }
998
+ for (const sub of data.subscriptions) {
999
+ const subName = sub.attributes.name || sub.attributes.productId;
1000
+ const subLocales = new Set(sub.localizations.map((l) => l.attributes.locale));
1001
+ for (const loc of sub.localizations) {
1002
+ if (!loc.attributes.name || loc.attributes.name.trim().length === 0) {
1003
+ findings.push({
1004
+ id: "SUBS-003",
1005
+ module: "subscription",
1006
+ severity: "critical",
1007
+ title: `Subscription "${subName}" missing display name for \`${loc.attributes.locale}\``,
1008
+ message: `The subscription "${subName}" has no localized display name for ${loc.attributes.locale}. This is required for the subscription sheet.`,
1009
+ locale: loc.attributes.locale,
1010
+ remedy: `Add a display name for "${subName}" in ${loc.attributes.locale}.`
1011
+ });
1012
+ }
1013
+ }
1014
+ for (const loc of sub.localizations) {
1015
+ if (!loc.attributes.description || loc.attributes.description.trim().length === 0) {
1016
+ findings.push({
1017
+ id: "SUBS-004",
1018
+ module: "subscription",
1019
+ severity: "critical",
1020
+ title: `Subscription "${subName}" missing description for \`${loc.attributes.locale}\``,
1021
+ message: `The subscription "${subName}" has no localized description for ${loc.attributes.locale}. This is required for App Store display.`,
1022
+ locale: loc.attributes.locale,
1023
+ remedy: `Add a description for "${subName}" in ${loc.attributes.locale}.`
1024
+ });
1025
+ }
1026
+ }
1027
+ for (const locale of appLocales) {
1028
+ if (!subLocales.has(locale)) {
1029
+ findings.push({
1030
+ id: "SUBS-005",
1031
+ module: "subscription",
1032
+ severity: "high",
1033
+ title: `Subscription "${subName}" missing \`${locale}\` localization`,
1034
+ message: `Your app supports ${locale} but the subscription "${subName}" has no localization for it.`,
1035
+ locale,
1036
+ remedy: `Add localized metadata for "${subName}" in ${locale}.`
1037
+ });
1038
+ }
1039
+ }
1040
+ }
1041
+ for (const iap of data.inAppPurchases) {
1042
+ const iapName = iap.attributes.name || iap.attributes.productId;
1043
+ const iapLocales = new Set(iap.localizations.map((l) => l.attributes.locale));
1044
+ for (const loc of iap.localizations) {
1045
+ if (!loc.attributes.name || loc.attributes.name.trim().length === 0) {
1046
+ findings.push({
1047
+ id: "SUBS-006",
1048
+ module: "subscription",
1049
+ severity: "critical",
1050
+ title: `IAP "${iapName}" missing display name for \`${loc.attributes.locale}\``,
1051
+ message: `The in-app purchase "${iapName}" has no localized display name for ${loc.attributes.locale}.`,
1052
+ locale: loc.attributes.locale,
1053
+ remedy: `Add a display name for "${iapName}" in ${loc.attributes.locale}.`
1054
+ });
1055
+ }
1056
+ }
1057
+ for (const loc of iap.localizations) {
1058
+ if (!loc.attributes.description || loc.attributes.description.trim().length === 0) {
1059
+ findings.push({
1060
+ id: "SUBS-007",
1061
+ module: "subscription",
1062
+ severity: "warning",
1063
+ title: `IAP "${iapName}" missing description for \`${loc.attributes.locale}\``,
1064
+ message: `The in-app purchase "${iapName}" has no localized description for ${loc.attributes.locale}.`,
1065
+ locale: loc.attributes.locale,
1066
+ remedy: `Add a description for "${iapName}" in ${loc.attributes.locale}.`
1067
+ });
1068
+ }
1069
+ }
1070
+ for (const locale of appLocales) {
1071
+ if (!iapLocales.has(locale)) {
1072
+ findings.push({
1073
+ id: "SUBS-008",
1074
+ module: "subscription",
1075
+ severity: "high",
1076
+ title: `IAP "${iapName}" missing \`${locale}\` localization`,
1077
+ message: `Your app supports ${locale} but the IAP "${iapName}" has no localization for it.`,
1078
+ locale,
1079
+ remedy: `Add localized metadata for "${iapName}" in ${locale}.`
1080
+ });
1081
+ }
1082
+ }
1083
+ }
1084
+ return {
1085
+ module: "subscription",
1086
+ label: "Subscriptions & IAP",
1087
+ icon: "\u{1F4B3}",
1088
+ findings,
1089
+ duration: Date.now() - start
1090
+ };
1091
+ }
1092
+
1093
+ // src/auditors/storefront.ts
1094
+ var MAJOR_MARKETS = [
1095
+ { id: "USA", name: "United States" },
1096
+ { id: "GBR", name: "United Kingdom" },
1097
+ { id: "DEU", name: "Germany" },
1098
+ { id: "FRA", name: "France" },
1099
+ { id: "JPN", name: "Japan" },
1100
+ { id: "CHN", name: "China" },
1101
+ { id: "KOR", name: "South Korea" },
1102
+ { id: "CAN", name: "Canada" },
1103
+ { id: "AUS", name: "Australia" },
1104
+ { id: "BRA", name: "Brazil" },
1105
+ { id: "IND", name: "India" },
1106
+ { id: "ITA", name: "Italy" },
1107
+ { id: "ESP", name: "Spain" },
1108
+ { id: "NLD", name: "Netherlands" },
1109
+ { id: "TUR", name: "Turkey" }
1110
+ ];
1111
+ function auditStorefront(data) {
1112
+ const start = Date.now();
1113
+ const findings = [];
1114
+ const territoryIds = new Set(data.availableTerritories.map((t) => t.id));
1115
+ const totalTerritories = data.availableTerritories.length;
1116
+ if (totalTerritories === 0) {
1117
+ findings.push({
1118
+ id: "STR-001",
1119
+ module: "storefront",
1120
+ severity: "high",
1121
+ title: "No territory availability data found",
1122
+ message: "Could not determine which territories your app is available in.",
1123
+ remedy: "Check App Store Connect > Pricing and Availability to configure territories."
1124
+ });
1125
+ } else if (totalTerritories < 10) {
1126
+ findings.push({
1127
+ id: "STR-002",
1128
+ module: "storefront",
1129
+ severity: "warning",
1130
+ title: `Limited territory coverage (${totalTerritories} territories)`,
1131
+ message: `Your app is available in only ${totalTerritories} territories. Apple supports 175+ storefronts.`,
1132
+ remedy: "Consider expanding availability to more territories to increase your potential audience."
1133
+ });
1134
+ } else if (totalTerritories < 50) {
1135
+ findings.push({
1136
+ id: "STR-003",
1137
+ module: "storefront",
1138
+ severity: "info",
1139
+ title: `Moderate territory coverage (${totalTerritories} territories)`,
1140
+ message: `Your app is available in ${totalTerritories} territories. You could reach more users by expanding.`,
1141
+ remedy: "Review if there are additional markets worth targeting."
1142
+ });
1143
+ }
1144
+ if (totalTerritories > 0) {
1145
+ const missingMajor = MAJOR_MARKETS.filter((m) => !territoryIds.has(m.id));
1146
+ if (missingMajor.length > 0) {
1147
+ const marketList = missingMajor.map((m) => m.name).join(", ");
1148
+ findings.push({
1149
+ id: "STR-004",
1150
+ module: "storefront",
1151
+ severity: missingMajor.length > 5 ? "warning" : "info",
1152
+ title: `Missing ${missingMajor.length} major market(s)`,
1153
+ message: `Your app is not available in: ${marketList}. These are among the highest-revenue App Store markets.`,
1154
+ remedy: `Consider making your app available in these markets: ${marketList}.`
1155
+ });
1156
+ }
1157
+ }
1158
+ const localeToTerritory = {
1159
+ "en-US": "USA",
1160
+ "en-GB": "GBR",
1161
+ "de-DE": "DEU",
1162
+ "fr-FR": "FRA",
1163
+ "ja": "JPN",
1164
+ "zh-Hans": "CHN",
1165
+ "ko": "KOR",
1166
+ "pt-BR": "BRA",
1167
+ "it": "ITA",
1168
+ "es-ES": "ESP",
1169
+ "es-MX": "MEX",
1170
+ "nl-NL": "NLD",
1171
+ "tr": "TUR",
1172
+ "ru": "RUS",
1173
+ "ar-SA": "SAU",
1174
+ "th": "THA",
1175
+ "vi": "VNM",
1176
+ "id": "IDN",
1177
+ "ms": "MYS"
1178
+ };
1179
+ const appLocales = data.versionLocalizations.map((l) => l.attributes.locale);
1180
+ for (const locale of appLocales) {
1181
+ const territory = localeToTerritory[locale];
1182
+ if (territory && totalTerritories > 0 && !territoryIds.has(territory)) {
1183
+ findings.push({
1184
+ id: "STR-005",
1185
+ module: "storefront",
1186
+ severity: "warning",
1187
+ title: `Localized for \`${locale}\` but not available in corresponding territory`,
1188
+ message: `Your app has ${locale} localization but is not available in the corresponding territory. The localization effort may be wasted.`,
1189
+ locale,
1190
+ remedy: `Consider making your app available in the territory that corresponds to the ${locale} localization, or remove the unused localization.`
1191
+ });
1192
+ }
1193
+ }
1194
+ return {
1195
+ module: "storefront",
1196
+ label: "Storefront Coverage",
1197
+ icon: "\u{1F310}",
1198
+ findings,
1199
+ duration: Date.now() - start
1200
+ };
1201
+ }
1202
+
1203
+ // src/auditors/review-info.ts
1204
+ function auditReviewInfo(data) {
1205
+ const start = Date.now();
1206
+ const findings = [];
1207
+ if (!data.reviewDetail) {
1208
+ findings.push({
1209
+ id: "REV-001",
1210
+ module: "review-info",
1211
+ severity: "warning",
1212
+ title: "No review detail found",
1213
+ message: "Could not find App Store review detail information for this version. This section helps the review team understand your app.",
1214
+ remedy: "Add review information in App Store Connect > App Store > Version > App Review Information."
1215
+ });
1216
+ return {
1217
+ module: "review-info",
1218
+ label: "Review Info",
1219
+ icon: "\u{1F4CB}",
1220
+ findings,
1221
+ duration: Date.now() - start
1222
+ };
1223
+ }
1224
+ const attrs = data.reviewDetail.attributes;
1225
+ const hasContact = attrs.contactFirstName && attrs.contactFirstName.trim().length > 0 || attrs.contactEmail && attrs.contactEmail.trim().length > 0 || attrs.contactPhone && attrs.contactPhone.trim().length > 0;
1226
+ if (!hasContact) {
1227
+ findings.push({
1228
+ id: "REV-002",
1229
+ module: "review-info",
1230
+ severity: "high",
1231
+ title: "Missing reviewer contact information",
1232
+ message: "No contact information (name, email, or phone) is provided for the App Review team. If they have questions, they won't be able to reach you quickly.",
1233
+ remedy: "Add contact information (at minimum an email) in the App Review Information section."
1234
+ });
1235
+ } else {
1236
+ if (!attrs.contactEmail || attrs.contactEmail.trim().length === 0) {
1237
+ findings.push({
1238
+ id: "REV-003",
1239
+ module: "review-info",
1240
+ severity: "warning",
1241
+ title: "Missing reviewer contact email",
1242
+ message: "No email address provided for the review team. Email is the most reliable way for reviewers to contact you.",
1243
+ remedy: "Add a monitored email address to the App Review Information section."
1244
+ });
1245
+ }
1246
+ if (!attrs.contactPhone || attrs.contactPhone.trim().length === 0) {
1247
+ findings.push({
1248
+ id: "REV-004",
1249
+ module: "review-info",
1250
+ severity: "info",
1251
+ title: "Missing reviewer contact phone",
1252
+ message: "No phone number provided for the review team.",
1253
+ remedy: "Consider adding a phone number for faster communication during review."
1254
+ });
1255
+ }
1256
+ }
1257
+ if (attrs.demoAccountRequired === true) {
1258
+ if (!attrs.demoAccountName || attrs.demoAccountName.trim().length === 0) {
1259
+ findings.push({
1260
+ id: "REV-005",
1261
+ module: "review-info",
1262
+ severity: "critical",
1263
+ title: "Demo account required but username is missing",
1264
+ message: "The app requires a demo account for review but no username is provided. This will cause rejection.",
1265
+ remedy: "Provide a working demo account username in App Review Information."
1266
+ });
1267
+ }
1268
+ if (!attrs.demoAccountPassword || attrs.demoAccountPassword.trim().length === 0) {
1269
+ findings.push({
1270
+ id: "REV-006",
1271
+ module: "review-info",
1272
+ severity: "critical",
1273
+ title: "Demo account required but password is missing",
1274
+ message: "The app requires a demo account for review but no password is provided. This will cause rejection.",
1275
+ remedy: "Provide a working demo account password in App Review Information."
1276
+ });
1277
+ }
1278
+ }
1279
+ if (!attrs.notes || attrs.notes.trim().length === 0) {
1280
+ findings.push({
1281
+ id: "REV-007",
1282
+ module: "review-info",
1283
+ severity: "info",
1284
+ title: "No review notes provided",
1285
+ message: "The review notes field is empty. Notes can help the reviewer understand special features, required hardware, or complex flows.",
1286
+ remedy: "Consider adding notes to guide the reviewer, especially for apps with sign-in, subscriptions, or specialized hardware."
1287
+ });
1288
+ }
1289
+ return {
1290
+ module: "review-info",
1291
+ label: "Review Info",
1292
+ icon: "\u{1F4CB}",
1293
+ findings,
1294
+ duration: Date.now() - start
1295
+ };
1296
+ }
1297
+
1298
+ // src/scoring/risk.ts
1299
+ var SEVERITY_WEIGHTS = {
1300
+ critical: 15,
1301
+ high: 8,
1302
+ warning: 3,
1303
+ info: 1
1304
+ };
1305
+ function calculateRiskScore(findings) {
1306
+ const totalDeduction = findings.reduce((sum, finding) => {
1307
+ return sum + SEVERITY_WEIGHTS[finding.severity];
1308
+ }, 0);
1309
+ return Math.max(0, 100 - totalDeduction);
1310
+ }
1311
+ function getGrade(score) {
1312
+ if (score >= 90) return "A";
1313
+ if (score >= 75) return "B";
1314
+ if (score >= 50) return "C";
1315
+ if (score >= 25) return "D";
1316
+ return "F";
1317
+ }
1318
+ function getGradeLabel(score) {
1319
+ if (score >= 90) return "Ship it!";
1320
+ if (score >= 75) return "Almost ready";
1321
+ if (score >= 50) return "Needs attention";
1322
+ if (score >= 25) return "High risk";
1323
+ return "Do not submit";
1324
+ }
1325
+ function getGradeEmoji(score) {
1326
+ if (score >= 90) return "\u{1F7E2}";
1327
+ if (score >= 75) return "\u{1F7E1}";
1328
+ if (score >= 50) return "\u{1F7E0}";
1329
+ if (score >= 25) return "\u{1F534}";
1330
+ return "\u26D4";
1331
+ }
1332
+ function getSeverityEmoji(severity) {
1333
+ switch (severity) {
1334
+ case "critical":
1335
+ return "\u{1F534}";
1336
+ case "high":
1337
+ return "\u{1F7E0}";
1338
+ case "warning":
1339
+ return "\u{1F7E1}";
1340
+ case "info":
1341
+ return "\u2139\uFE0F";
1342
+ }
1343
+ }
1344
+
1345
+ // src/auditors/index.ts
1346
+ var ALL_AUDITORS = [
1347
+ { name: "localization", fn: auditLocalization },
1348
+ { name: "screenshots", fn: auditScreenshots },
1349
+ { name: "age-rating", fn: auditAgeRating },
1350
+ { name: "subtitle", fn: auditSubtitle },
1351
+ { name: "privacy", fn: auditPrivacy },
1352
+ { name: "subscription", fn: auditSubscriptions },
1353
+ { name: "storefront", fn: auditStorefront },
1354
+ { name: "review-info", fn: auditReviewInfo }
1355
+ ];
1356
+ function getAuditorNames() {
1357
+ return ALL_AUDITORS.map((a) => a.name);
1358
+ }
1359
+ async function runAudit(data, options) {
1360
+ let auditors = [...ALL_AUDITORS];
1361
+ if (options.only.length > 0) {
1362
+ auditors = auditors.filter((a) => options.only.includes(a.name));
1363
+ }
1364
+ if (options.skip.length > 0) {
1365
+ auditors = auditors.filter((a) => !options.skip.includes(a.name));
1366
+ }
1367
+ const results = auditors.map((auditor) => auditor.fn(data));
1368
+ const allFindings = results.flatMap((r) => r.findings);
1369
+ const score = calculateRiskScore(allFindings);
1370
+ const grade = getGrade(score);
1371
+ const gradeLabel = getGradeLabel(score);
1372
+ const critical = allFindings.filter((f) => f.severity === "critical").length;
1373
+ const high = allFindings.filter((f) => f.severity === "high").length;
1374
+ const warning = allFindings.filter((f) => f.severity === "warning").length;
1375
+ const info = allFindings.filter((f) => f.severity === "info").length;
1376
+ const passed = results.filter(
1377
+ (r) => r.findings.filter((f) => f.severity !== "info").length === 0
1378
+ ).length;
1379
+ return {
1380
+ appName: data.app.attributes.name,
1381
+ bundleId: data.app.attributes.bundleId,
1382
+ version: data.version.attributes.versionString,
1383
+ platform: data.version.attributes.platform,
1384
+ date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
1385
+ score,
1386
+ grade,
1387
+ gradeLabel,
1388
+ results,
1389
+ findings: allFindings,
1390
+ summary: {
1391
+ critical,
1392
+ high,
1393
+ warning,
1394
+ info,
1395
+ total: allFindings.length,
1396
+ passed
1397
+ }
1398
+ };
1399
+ }
1400
+
1401
+ // src/reporter/terminal.ts
1402
+ import chalk from "chalk";
1403
+ function renderTerminal(report) {
1404
+ const lines = [];
1405
+ lines.push("");
1406
+ lines.push(chalk.bold(" \u{1FA7A} ASC Doctor Report"));
1407
+ lines.push(chalk.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1408
+ lines.push(` ${chalk.dim("App:")} ${chalk.white.bold(report.appName)} ${chalk.dim(`(${report.bundleId})`)}`);
1409
+ lines.push(` ${chalk.dim("Version:")} ${chalk.white(report.version)} ${chalk.dim(`(${report.platform})`)}`);
1410
+ lines.push(` ${chalk.dim("Date:")} ${chalk.white(report.date)}`);
1411
+ lines.push("");
1412
+ const emoji = getGradeEmoji(report.score);
1413
+ const scoreColor = report.score >= 90 ? chalk.green : report.score >= 75 ? chalk.yellow : report.score >= 50 ? chalk.hex("#FF8C00") : chalk.red;
1414
+ lines.push(` ${chalk.dim("Score:")} ${scoreColor.bold(`${report.score}/100`)} ${emoji} ${chalk.bold(report.grade)} \u2014 ${chalk.italic(report.gradeLabel)}`);
1415
+ lines.push("");
1416
+ lines.push(chalk.dim(" \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2510"));
1417
+ lines.push(chalk.dim(" \u2502") + chalk.bold(" Module ") + chalk.dim("\u2502") + chalk.red.bold(" \u{1F534} ") + chalk.dim("\u2502") + chalk.hex("#FF8C00").bold(" \u{1F7E0} ") + chalk.dim("\u2502") + chalk.yellow.bold(" \u{1F7E1} ") + chalk.dim("\u2502") + chalk.blue.bold(" \u2139\uFE0F ") + chalk.dim("\u2502"));
1418
+ lines.push(chalk.dim(" \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2524"));
1419
+ for (const result of report.results) {
1420
+ const c = result.findings.filter((f) => f.severity === "critical").length;
1421
+ const h = result.findings.filter((f) => f.severity === "high").length;
1422
+ const w = result.findings.filter((f) => f.severity === "warning").length;
1423
+ const i = result.findings.filter((f) => f.severity === "info").length;
1424
+ const hasIssues = c + h + w > 0;
1425
+ const nameStr = `${result.icon} ${result.label}`.padEnd(24);
1426
+ const colorize = hasIssues ? (s) => s : chalk.green;
1427
+ lines.push(
1428
+ chalk.dim(" \u2502") + colorize(` ${nameStr}`) + chalk.dim("\u2502") + ` ${c > 0 ? chalk.red.bold(String(c).padStart(2)) : chalk.dim(" \xB7")} ` + chalk.dim("\u2502") + ` ${h > 0 ? chalk.hex("#FF8C00").bold(String(h).padStart(2)) : chalk.dim(" \xB7")} ` + chalk.dim("\u2502") + ` ${w > 0 ? chalk.yellow.bold(String(w).padStart(2)) : chalk.dim(" \xB7")} ` + chalk.dim("\u2502") + ` ${i > 0 ? chalk.blue(String(i).padStart(2)) : chalk.dim(" \xB7")} ` + chalk.dim("\u2502")
1429
+ );
1430
+ }
1431
+ lines.push(chalk.dim(" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2518"));
1432
+ lines.push("");
1433
+ lines.push(` ${chalk.dim("Total:")} ${chalk.red.bold(`${report.summary.critical} critical`)}${chalk.dim(",")} ${chalk.hex("#FF8C00").bold(`${report.summary.high} high`)}${chalk.dim(",")} ${chalk.yellow.bold(`${report.summary.warning} warning`)}${chalk.dim(",")} ${chalk.blue(`${report.summary.info} info`)}`);
1434
+ lines.push("");
1435
+ const severityOrder = ["critical", "high", "warning", "info"];
1436
+ const severityLabels = {
1437
+ critical: "\u{1F534} Critical Findings",
1438
+ high: "\u{1F7E0} High Findings",
1439
+ warning: "\u{1F7E1} Warnings",
1440
+ info: "\u2139\uFE0F Informational"
1441
+ };
1442
+ for (const severity of severityOrder) {
1443
+ const findings = report.findings.filter((f) => f.severity === severity);
1444
+ if (findings.length === 0) continue;
1445
+ lines.push(chalk.bold(` ${severityLabels[severity]}`));
1446
+ lines.push(chalk.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1447
+ for (const finding of findings) {
1448
+ const emoji2 = getSeverityEmoji(finding.severity);
1449
+ lines.push(` ${emoji2} ${chalk.bold(`[${finding.id}]`)} ${finding.title}`);
1450
+ lines.push(` ${chalk.dim(finding.message)}`);
1451
+ if (finding.remedy) {
1452
+ lines.push(` ${chalk.green("\u2192")} ${chalk.green(finding.remedy)}`);
1453
+ }
1454
+ lines.push("");
1455
+ }
1456
+ }
1457
+ const passedModules = report.results.filter(
1458
+ (r) => r.findings.filter((f) => f.severity !== "info").length === 0
1459
+ );
1460
+ if (passedModules.length > 0) {
1461
+ lines.push(chalk.green.bold(" \u2705 Passed Checks"));
1462
+ lines.push(chalk.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1463
+ for (const mod of passedModules) {
1464
+ lines.push(` ${chalk.green("\u2713")} ${mod.icon} ${mod.label}`);
1465
+ }
1466
+ lines.push("");
1467
+ }
1468
+ lines.push(chalk.dim(" Generated by ASC Doctor \u2022 https://github.com/aykutbey3543/ascdoc"));
1469
+ lines.push("");
1470
+ return lines.join("\n");
1471
+ }
1472
+
1473
+ // src/reporter/markdown.ts
1474
+ function renderMarkdown(report) {
1475
+ const lines = [];
1476
+ lines.push("# \u{1FA7A} ASC Doctor Report");
1477
+ lines.push("");
1478
+ lines.push(`| | |`);
1479
+ lines.push(`|---|---|`);
1480
+ lines.push(`| **App** | ${report.appName} (\`${report.bundleId}\`) |`);
1481
+ lines.push(`| **Version** | ${report.version} (${report.platform}) |`);
1482
+ lines.push(`| **Date** | ${report.date} |`);
1483
+ lines.push(`| **Risk Score** | **${report.score}/100** ${getGradeEmoji(report.score)} **${report.grade}** \u2014 ${report.gradeLabel} |`);
1484
+ lines.push("");
1485
+ lines.push("## Summary");
1486
+ lines.push("");
1487
+ lines.push("| Module | \u{1F534} Critical | \u{1F7E0} High | \u{1F7E1} Warning | \u2139\uFE0F Info |");
1488
+ lines.push("|--------|:-----------:|:-------:|:----------:|:------:|");
1489
+ for (const result of report.results) {
1490
+ const c = result.findings.filter((f) => f.severity === "critical").length;
1491
+ const h = result.findings.filter((f) => f.severity === "high").length;
1492
+ const w = result.findings.filter((f) => f.severity === "warning").length;
1493
+ const i = result.findings.filter((f) => f.severity === "info").length;
1494
+ const status = c + h + w === 0 ? " \u2705" : "";
1495
+ lines.push(`| ${result.icon} ${result.label}${status} | ${c || "\u2014"} | ${h || "\u2014"} | ${w || "\u2014"} | ${i || "\u2014"} |`);
1496
+ }
1497
+ lines.push("");
1498
+ const severityOrder = ["critical", "high", "warning", "info"];
1499
+ const severityLabels = {
1500
+ critical: "\u{1F534} Critical Findings",
1501
+ high: "\u{1F7E0} High Findings",
1502
+ warning: "\u{1F7E1} Warnings",
1503
+ info: "\u2139\uFE0F Informational"
1504
+ };
1505
+ for (const severity of severityOrder) {
1506
+ const findings = report.findings.filter((f) => f.severity === severity);
1507
+ if (findings.length === 0) continue;
1508
+ lines.push(`## ${severityLabels[severity]}`);
1509
+ lines.push("");
1510
+ for (const finding of findings) {
1511
+ const emoji = getSeverityEmoji(finding.severity);
1512
+ lines.push(`### ${emoji} [${finding.id}] ${finding.title}`);
1513
+ lines.push("");
1514
+ lines.push(finding.message);
1515
+ lines.push("");
1516
+ if (finding.remedy) {
1517
+ lines.push(`> **Fix:** ${finding.remedy}`);
1518
+ lines.push("");
1519
+ }
1520
+ }
1521
+ }
1522
+ const passedModules = report.results.filter(
1523
+ (r) => r.findings.filter((f) => f.severity !== "info").length === 0
1524
+ );
1525
+ if (passedModules.length > 0) {
1526
+ lines.push("## \u2705 Passed Checks");
1527
+ lines.push("");
1528
+ for (const mod of passedModules) {
1529
+ lines.push(`- ${mod.icon} ${mod.label}`);
1530
+ }
1531
+ lines.push("");
1532
+ }
1533
+ lines.push("---");
1534
+ lines.push("");
1535
+ lines.push("*Generated by [ASC Doctor](https://github.com/aykutbey3543/ascdoc)*");
1536
+ lines.push("");
1537
+ return lines.join("\n");
1538
+ }
1539
+
1540
+ // src/reporter/json.ts
1541
+ function renderJSON(report) {
1542
+ return JSON.stringify(
1543
+ {
1544
+ tool: "ascdoc",
1545
+ version: "1.0.0",
1546
+ app: {
1547
+ name: report.appName,
1548
+ bundleId: report.bundleId,
1549
+ version: report.version,
1550
+ platform: report.platform
1551
+ },
1552
+ date: report.date,
1553
+ score: report.score,
1554
+ grade: report.grade,
1555
+ gradeLabel: report.gradeLabel,
1556
+ summary: report.summary,
1557
+ results: report.results.map((r) => ({
1558
+ module: r.module,
1559
+ label: r.label,
1560
+ findingCount: r.findings.length,
1561
+ duration: r.duration,
1562
+ findings: r.findings.map((f) => ({
1563
+ id: f.id,
1564
+ severity: f.severity,
1565
+ title: f.title,
1566
+ message: f.message,
1567
+ locale: f.locale || null,
1568
+ remedy: f.remedy || null
1569
+ }))
1570
+ }))
1571
+ },
1572
+ null,
1573
+ 2
1574
+ );
1575
+ }
1576
+
1577
+ // src/demo/data.ts
1578
+ function generateDemoData() {
1579
+ return {
1580
+ app: {
1581
+ type: "apps",
1582
+ id: "1234567890",
1583
+ attributes: {
1584
+ name: "WeatherPulse",
1585
+ bundleId: "com.example.weatherpulse",
1586
+ sku: "WEATHERPULSE_2024",
1587
+ primaryLocale: "en-US"
1588
+ }
1589
+ },
1590
+ version: {
1591
+ type: "appStoreVersions",
1592
+ id: "ver-001",
1593
+ attributes: {
1594
+ platform: "IOS",
1595
+ versionString: "2.4.0",
1596
+ appStoreState: "PREPARE_FOR_SUBMISSION",
1597
+ releaseType: "MANUAL",
1598
+ createdDate: "2026-03-28T10:00:00Z"
1599
+ }
1600
+ },
1601
+ versionLocalizations: [
1602
+ {
1603
+ type: "appStoreVersionLocalizations",
1604
+ id: "vloc-en",
1605
+ attributes: {
1606
+ locale: "en-US",
1607
+ description: "WeatherPulse gives you hyper-local weather forecasts with stunning visualizations. Track storms, check hourly conditions, and get severe weather alerts \u2014 all in a beautifully designed interface.",
1608
+ keywords: "weather,forecast,rain,storm,temperature,wind,humidity,radar",
1609
+ promotionalText: "Now with real-time lightning tracking! \u26A1",
1610
+ whatsNew: "Bug fixes and performance improvements.",
1611
+ marketingUrl: "https://weatherpulse.app",
1612
+ supportUrl: "https://weatherpulse.app/support"
1613
+ }
1614
+ },
1615
+ {
1616
+ type: "appStoreVersionLocalizations",
1617
+ id: "vloc-de",
1618
+ attributes: {
1619
+ locale: "de-DE",
1620
+ description: "",
1621
+ // ← Empty description!
1622
+ keywords: "",
1623
+ // ← Empty keywords!
1624
+ promotionalText: null,
1625
+ whatsNew: null,
1626
+ marketingUrl: null,
1627
+ supportUrl: null
1628
+ }
1629
+ },
1630
+ {
1631
+ type: "appStoreVersionLocalizations",
1632
+ id: "vloc-ja",
1633
+ attributes: {
1634
+ locale: "ja",
1635
+ description: "TODO: Add Japanese description here",
1636
+ // ← Placeholder!
1637
+ keywords: "weather,forecast",
1638
+ promotionalText: null,
1639
+ whatsNew: null,
1640
+ marketingUrl: null,
1641
+ supportUrl: null
1642
+ }
1643
+ },
1644
+ {
1645
+ type: "appStoreVersionLocalizations",
1646
+ id: "vloc-fr",
1647
+ attributes: {
1648
+ locale: "fr-FR",
1649
+ description: "WeatherPulse vous offre des pr\xE9visions m\xE9t\xE9o hyper-locales avec des visualisations \xE9poustouflantes.",
1650
+ keywords: "m\xE9t\xE9o,pr\xE9visions,pluie,temp\xEAte,temp\xE9rature",
1651
+ promotionalText: null,
1652
+ whatsNew: null,
1653
+ marketingUrl: null,
1654
+ supportUrl: null
1655
+ }
1656
+ }
1657
+ ],
1658
+ appInfo: {
1659
+ type: "appInfos",
1660
+ id: "info-001",
1661
+ attributes: {
1662
+ appStoreState: "READY_FOR_DISTRIBUTION"
1663
+ }
1664
+ },
1665
+ appInfoLocalizations: [
1666
+ {
1667
+ type: "appInfoLocalizations",
1668
+ id: "iloc-en",
1669
+ attributes: {
1670
+ locale: "en-US",
1671
+ name: "WeatherPulse",
1672
+ subtitle: "Best Weather App",
1673
+ // ← Generic subtitle!
1674
+ privacyPolicyUrl: "https://weatherpulse.app/privacy",
1675
+ privacyChoicesUrl: null,
1676
+ privacyPolicyText: null
1677
+ }
1678
+ },
1679
+ {
1680
+ type: "appInfoLocalizations",
1681
+ id: "iloc-de",
1682
+ attributes: {
1683
+ locale: "de-DE",
1684
+ name: "WeatherPulse",
1685
+ subtitle: null,
1686
+ // ← Missing subtitle!
1687
+ privacyPolicyUrl: "https://weatherpulse.app/privacy",
1688
+ privacyChoicesUrl: null,
1689
+ privacyPolicyText: null
1690
+ }
1691
+ },
1692
+ {
1693
+ type: "appInfoLocalizations",
1694
+ id: "iloc-ja",
1695
+ attributes: {
1696
+ locale: "ja",
1697
+ name: "WeatherPulse",
1698
+ subtitle: "WP",
1699
+ // ← Too short!
1700
+ privacyPolicyUrl: null,
1701
+ // ← Missing privacy policy!
1702
+ privacyChoicesUrl: null,
1703
+ privacyPolicyText: null
1704
+ }
1705
+ },
1706
+ {
1707
+ type: "appInfoLocalizations",
1708
+ id: "iloc-fr",
1709
+ attributes: {
1710
+ locale: "fr-FR",
1711
+ name: "WeatherPulse",
1712
+ subtitle: "M\xE9t\xE9o en temps r\xE9el",
1713
+ privacyPolicyUrl: "http://weatherpulse.app/privacy-fr",
1714
+ // ← HTTP, not HTTPS!
1715
+ privacyChoicesUrl: null,
1716
+ privacyPolicyText: null
1717
+ }
1718
+ },
1719
+ {
1720
+ type: "appInfoLocalizations",
1721
+ id: "iloc-ko",
1722
+ attributes: {
1723
+ locale: "ko",
1724
+ name: "WeatherPulse",
1725
+ subtitle: null,
1726
+ privacyPolicyUrl: "https://weatherpulse.app/privacy",
1727
+ privacyChoicesUrl: null,
1728
+ privacyPolicyText: null
1729
+ }
1730
+ }
1731
+ ],
1732
+ ageRatingDeclaration: {
1733
+ type: "ageRatingDeclarations",
1734
+ id: "age-001",
1735
+ attributes: {
1736
+ alcoholTobaccoOrDrugUseOrReferences: "NONE",
1737
+ contests: "NONE",
1738
+ gamblingAndContests: false,
1739
+ gambling: false,
1740
+ gamblingSimulated: "NONE",
1741
+ horrorOrFearThemes: "NONE",
1742
+ matureOrSuggestiveThemes: "NONE",
1743
+ medicalOrTreatmentInformation: "NONE",
1744
+ profanityOrCrudeHumor: "NONE",
1745
+ sexualContentGraphicAndNudity: "NONE",
1746
+ sexualContentOrNudity: "NONE",
1747
+ violenceCartoonOrFantasy: "NONE",
1748
+ violenceRealistic: "NONE",
1749
+ violenceRealisticProlongedGraphicOrSadistic: "NONE",
1750
+ kidsAgeBand: null,
1751
+ unrestrictedWebAccess: false,
1752
+ seventeenPlus: false
1753
+ }
1754
+ },
1755
+ screenshotSets: [
1756
+ // en-US: Has iPhone 6.7" with 5 screenshots ✓
1757
+ {
1758
+ type: "appScreenshotSets",
1759
+ id: "ss-en-67",
1760
+ attributes: { screenshotDisplayType: "APP_IPHONE_67" },
1761
+ screenshotCount: 5,
1762
+ locale: "en-US"
1763
+ },
1764
+ // en-US: Has iPad 12.9" with 3 screenshots ✓
1765
+ {
1766
+ type: "appScreenshotSets",
1767
+ id: "ss-en-ipad",
1768
+ attributes: { screenshotDisplayType: "APP_IPAD_PRO_3GEN_129" },
1769
+ screenshotCount: 3,
1770
+ locale: "en-US"
1771
+ },
1772
+ // de-DE: iPhone set exists but EMPTY!
1773
+ {
1774
+ type: "appScreenshotSets",
1775
+ id: "ss-de-67",
1776
+ attributes: { screenshotDisplayType: "APP_IPHONE_67" },
1777
+ screenshotCount: 0,
1778
+ // ← Empty!
1779
+ locale: "de-DE"
1780
+ },
1781
+ // ja: Only 1 screenshot (too few)
1782
+ {
1783
+ type: "appScreenshotSets",
1784
+ id: "ss-ja-67",
1785
+ attributes: { screenshotDisplayType: "APP_IPHONE_67" },
1786
+ screenshotCount: 1,
1787
+ // ← Too few
1788
+ locale: "ja"
1789
+ }
1790
+ // fr-FR: No screenshots at all — not in the array!
1791
+ ],
1792
+ subscriptionGroups: [
1793
+ {
1794
+ type: "subscriptionGroups",
1795
+ id: "sg-001",
1796
+ attributes: { referenceName: "WeatherPulse Pro" }
1797
+ }
1798
+ ],
1799
+ subscriptions: [
1800
+ {
1801
+ type: "subscriptions",
1802
+ id: "sub-001",
1803
+ attributes: {
1804
+ name: "Monthly Pro",
1805
+ productId: "com.example.weatherpulse.pro.monthly",
1806
+ state: "APPROVED",
1807
+ subscriptionPeriod: "ONE_MONTH",
1808
+ reviewNote: null,
1809
+ groupLevel: 1
1810
+ },
1811
+ localizations: [
1812
+ {
1813
+ type: "subscriptionLocalizations",
1814
+ id: "subloc-en",
1815
+ attributes: {
1816
+ locale: "en-US",
1817
+ name: "WeatherPulse Pro Monthly",
1818
+ description: "Unlock all pro features with monthly billing.",
1819
+ state: "APPROVED"
1820
+ }
1821
+ },
1822
+ {
1823
+ type: "subscriptionLocalizations",
1824
+ id: "subloc-de",
1825
+ attributes: {
1826
+ locale: "de-DE",
1827
+ name: "",
1828
+ // ← Empty name!
1829
+ description: "",
1830
+ // ← Empty description!
1831
+ state: "APPROVED"
1832
+ }
1833
+ }
1834
+ // ja is missing entirely!
1835
+ // fr-FR is missing entirely!
1836
+ ]
1837
+ }
1838
+ ],
1839
+ subscriptionGroupLocalizations: [
1840
+ {
1841
+ groupId: "sg-001",
1842
+ localizations: [
1843
+ {
1844
+ type: "subscriptionGroupLocalizations",
1845
+ id: "sgloc-en",
1846
+ attributes: {
1847
+ locale: "en-US",
1848
+ name: "WeatherPulse Pro",
1849
+ customAppName: null,
1850
+ state: "APPROVED"
1851
+ }
1852
+ }
1853
+ // de-DE, ja, fr-FR group localizations missing!
1854
+ ]
1855
+ }
1856
+ ],
1857
+ inAppPurchases: [
1858
+ {
1859
+ type: "inAppPurchases",
1860
+ id: "iap-001",
1861
+ attributes: {
1862
+ name: "Radar Themes Pack",
1863
+ productId: "com.example.weatherpulse.themes",
1864
+ inAppPurchaseType: "NON_CONSUMABLE",
1865
+ state: "APPROVED",
1866
+ reviewNote: null
1867
+ },
1868
+ localizations: [
1869
+ {
1870
+ type: "inAppPurchaseLocalizations",
1871
+ id: "iaploc-en",
1872
+ attributes: {
1873
+ locale: "en-US",
1874
+ name: "Radar Themes Pack",
1875
+ description: "Beautiful custom themes for the radar view.",
1876
+ state: "APPROVED"
1877
+ }
1878
+ }
1879
+ ]
1880
+ }
1881
+ ],
1882
+ reviewDetail: {
1883
+ type: "appStoreReviewDetails",
1884
+ id: "rev-001",
1885
+ attributes: {
1886
+ contactFirstName: "John",
1887
+ contactLastName: "Doe",
1888
+ contactPhone: null,
1889
+ // ← No phone
1890
+ contactEmail: "john@weatherpulse.app",
1891
+ demoAccountName: null,
1892
+ demoAccountPassword: null,
1893
+ demoAccountRequired: false,
1894
+ notes: ""
1895
+ // ← Empty notes
1896
+ }
1897
+ },
1898
+ availableTerritories: [
1899
+ { type: "territories", id: "USA", attributes: { currency: "USD" } },
1900
+ { type: "territories", id: "GBR", attributes: { currency: "GBP" } },
1901
+ { type: "territories", id: "DEU", attributes: { currency: "EUR" } },
1902
+ { type: "territories", id: "FRA", attributes: { currency: "EUR" } },
1903
+ { type: "territories", id: "CAN", attributes: { currency: "CAD" } },
1904
+ { type: "territories", id: "AUS", attributes: { currency: "AUD" } }
1905
+ // Missing: JPN, CHN, KOR, BRA, IND, ITA, ESP, NLD, TUR
1906
+ ]
1907
+ };
1908
+ }
1909
+
1910
+ // src/index.ts
1911
+ var VERSION = "1.0.0";
1912
+ var program = new Command();
1913
+ program.name("ascdoc").description("\u{1FA7A} Release readiness auditor for App Store Connect").version(VERSION).option("--key-id <id>", "App Store Connect API Key ID").option("--issuer-id <id>", "App Store Connect API Issuer ID").option("--key <path>", "Path to .p8 private key file").option("--app-id <id>", "App ID (auto-detected if only one app)").option("--format <type>", "Output format: terminal, markdown, json", "terminal").option("--output <path>", "Save report to file").option("--only <modules>", "Run only these audit modules (comma-separated)").option("--skip <modules>", "Skip these audit modules (comma-separated)").option("--ci", "CI mode: exit with non-zero if score below --min-score", false).option("--min-score <score>", "Minimum score for CI mode (default: 75)", "75").option("--demo", "Run with demo data (no API key required)", false).option("--list-modules", "List available audit modules").action(async (options) => {
1914
+ if (options.listModules) {
1915
+ console.log("\n Available audit modules:\n");
1916
+ for (const name of getAuditorNames()) {
1917
+ console.log(` \u2022 ${name}`);
1918
+ }
1919
+ console.log("");
1920
+ process.exit(0);
1921
+ }
1922
+ const config = resolveConfig({
1923
+ keyId: options.keyId,
1924
+ issuerId: options.issuerId,
1925
+ keyPath: options.key,
1926
+ appId: options.appId,
1927
+ format: options.format,
1928
+ output: options.output,
1929
+ skip: options.skip ? options.skip.split(",").map((s) => s.trim()) : [],
1930
+ only: options.only ? options.only.split(",").map((s) => s.trim()) : [],
1931
+ ci: options.ci,
1932
+ minScore: parseInt(options.minScore, 10),
1933
+ demo: options.demo
1934
+ });
1935
+ if (config.format === "terminal") {
1936
+ console.log("");
1937
+ console.log(chalk2.bold(" \u{1FA7A} ASC Doctor") + chalk2.dim(` v${VERSION}`));
1938
+ console.log(chalk2.dim(" Release readiness auditor for App Store Connect"));
1939
+ console.log("");
1940
+ }
1941
+ const errors = validateConfig(config);
1942
+ if (errors.length > 0) {
1943
+ console.error(chalk2.red("\n \u2716 Configuration errors:\n"));
1944
+ for (const error of errors) {
1945
+ console.error(chalk2.red(` \u2022 ${error}`));
1946
+ }
1947
+ console.error("");
1948
+ console.error(chalk2.dim(" Use --demo to try with sample data, or see --help for options."));
1949
+ console.error("");
1950
+ process.exit(1);
1951
+ }
1952
+ let appData;
1953
+ if (config.demo) {
1954
+ if (config.format === "terminal") {
1955
+ console.log(chalk2.yellow(" \u26A0 Running in demo mode with sample data"));
1956
+ console.log("");
1957
+ }
1958
+ appData = generateDemoData();
1959
+ } else {
1960
+ const spinner = config.format === "terminal" ? ora({ text: "Connecting to App Store Connect...", indent: 2 }).start() : null;
1961
+ try {
1962
+ const client = new APIClient({
1963
+ keyId: config.keyId,
1964
+ issuerId: config.issuerId,
1965
+ keyPath: config.keyPath
1966
+ });
1967
+ if (spinner) spinner.text = "Fetching app data...";
1968
+ appData = await fetchAppData(client, config.appId);
1969
+ spinner?.succeed(`Fetched data for ${appData.app.attributes.name}`);
1970
+ } catch (error) {
1971
+ spinner?.fail("Failed to fetch data from App Store Connect");
1972
+ console.error("");
1973
+ console.error(chalk2.red(` ${error instanceof Error ? error.message : String(error)}`));
1974
+ console.error("");
1975
+ process.exit(1);
1976
+ }
1977
+ }
1978
+ const spinnerAudit = config.format === "terminal" ? ora({ text: "Running audits...", indent: 2 }).start() : null;
1979
+ const report = await runAudit(appData, {
1980
+ skip: config.skip,
1981
+ only: config.only
1982
+ });
1983
+ spinnerAudit?.succeed(`Audit complete \u2014 ${report.summary.total} findings`);
1984
+ if (config.format === "terminal") {
1985
+ console.log("");
1986
+ }
1987
+ let output;
1988
+ switch (config.format) {
1989
+ case "markdown":
1990
+ output = renderMarkdown(report);
1991
+ break;
1992
+ case "json":
1993
+ output = renderJSON(report);
1994
+ break;
1995
+ default:
1996
+ output = renderTerminal(report);
1997
+ }
1998
+ if (config.output) {
1999
+ fs3.writeFileSync(config.output, output, "utf-8");
2000
+ if (config.format === "terminal") {
2001
+ console.log(chalk2.green(` \u{1F4C4} Report saved to ${config.output}`));
2002
+ console.log("");
2003
+ }
2004
+ } else {
2005
+ console.log(output);
2006
+ }
2007
+ if (config.ci) {
2008
+ if (report.score < config.minScore) {
2009
+ process.exit(1);
2010
+ }
2011
+ }
2012
+ });
2013
+ program.parse();
2014
+ //# sourceMappingURL=index.js.map